fix(ui): the IBD-finished toast no longer tells a node without LND to fund its wallet
Demo images / Build & push demo images (push) Failing after 52s
Demo images / Build & push demo images (push) Failing after 52s
When Bitcoin's IBD completed mid-Lightning-goal, the watcher toasted "you can now fund your wallet" — but the on-chain wallet lives in LND, not Bitcoin Core. The watcher only checked that the goal had pending manual steps, never that the install-LND step had completed, so a user whose LND wasn't installed yet was pointed at a flow that could not work: the fund modal's address comes from lnd.newaddress and does not exist until LND is installed (issue #143). The toast now checks LND's install state at fire time. With LND installed the message is unchanged; without it, the toast says the actual next step — install Lightning (LND) — and the Finish setup button lands on the goal wizard, whose active step is the pending install-LND one (the wizard itself was already correctly sequenced). The watcher had no tests; added four pinning its contract: the two message branches, silence with no in-progress goal, and silence when the chain was already synced at page load. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a9a30406df
commit
2c984fbd49
@@ -0,0 +1,150 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import { defineComponent, nextTick } from 'vue'
|
||||||
|
|
||||||
|
// Controllable doubles shared between the hoisted block and the mock
|
||||||
|
// factories. Plain holders — each test writes to them before importing the
|
||||||
|
// composable under a fresh module registry (the watcher keeps a module-level
|
||||||
|
// `firedThisSession` session guard, so every case needs its own module).
|
||||||
|
const state = vi.hoisted(() => ({
|
||||||
|
packages: {} as Record<string, unknown>,
|
||||||
|
goalStatus: 'in-progress',
|
||||||
|
goalProgress: {} as Record<string, { completedSteps: string[] }>,
|
||||||
|
toastAction: vi.fn(),
|
||||||
|
routerPush: vi.fn(),
|
||||||
|
// Re-bound every time the useBitcoinSync factory is (re)evaluated; holds the
|
||||||
|
// exact refs the freshly imported composable watches.
|
||||||
|
syncRefs: null as null | { synced: { value: boolean }; loaded: { value: boolean } },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useBitcoinSync', async () => {
|
||||||
|
const { ref } = await import('vue')
|
||||||
|
const synced = ref(false)
|
||||||
|
const loaded = ref(false)
|
||||||
|
state.syncRefs = { synced, loaded }
|
||||||
|
return {
|
||||||
|
bitcoinSynced: synced,
|
||||||
|
bitcoinSyncLoaded: loaded,
|
||||||
|
acquireBitcoinSync: () => () => {},
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
vi.mock('@/stores/goals', () => ({
|
||||||
|
useGoalStore: () => ({
|
||||||
|
getGoalStatus: () => state.goalStatus,
|
||||||
|
progress: state.goalProgress,
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/stores/app', () => ({
|
||||||
|
useAppStore: () => ({
|
||||||
|
get packages() {
|
||||||
|
return state.packages
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useToast', () => ({
|
||||||
|
useToast: () => ({ action: state.toastAction }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('vue-router', () => ({
|
||||||
|
useRouter: () => ({ push: state.routerPush }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fresh module registry → fresh `firedThisSession`, then mount the composable
|
||||||
|
* inside a real component so its watchers live in a proper effect scope.
|
||||||
|
*/
|
||||||
|
async function mountWatcher() {
|
||||||
|
vi.resetModules()
|
||||||
|
const { useIbdFinishWatcher } = await import('../useIbdFinishWatcher')
|
||||||
|
const Host = defineComponent({
|
||||||
|
setup() {
|
||||||
|
useIbdFinishWatcher()
|
||||||
|
return () => null
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return mount(Host)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drive a real unsynced→synced transition through the mocked sync refs. */
|
||||||
|
async function completeSync() {
|
||||||
|
const refs = state.syncRefs!
|
||||||
|
refs.loaded.value = true
|
||||||
|
refs.synced.value = false // the watcher must observe unsynced at least once
|
||||||
|
await nextTick()
|
||||||
|
refs.synced.value = true
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useIbdFinishWatcher', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
state.packages = {}
|
||||||
|
state.goalStatus = 'in-progress'
|
||||||
|
state.goalProgress = {}
|
||||||
|
state.toastAction.mockClear()
|
||||||
|
state.routerPush.mockClear()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says to install LND next when Lightning is not installed yet (#143)', async () => {
|
||||||
|
// Bitcoin synced mid-goal, but the goal's install-LND step is still
|
||||||
|
// pending: the on-chain wallet lives in LND, so "fund your wallet" would
|
||||||
|
// promise a flow that cannot work yet.
|
||||||
|
state.packages = { 'bitcoin-knots': { state: 'running' } }
|
||||||
|
const wrapper = await mountWatcher()
|
||||||
|
await completeSync()
|
||||||
|
|
||||||
|
expect(state.toastAction).toHaveBeenCalledTimes(1)
|
||||||
|
const [message, opts] = state.toastAction.mock.calls[0]
|
||||||
|
expect(message).toBe(
|
||||||
|
"Bitcoin is fully synced — next, install Lightning (LND) to get your node's on-chain wallet.",
|
||||||
|
)
|
||||||
|
expect(opts.label).toBe('Finish setup')
|
||||||
|
opts.onClick()
|
||||||
|
// "Finish setup" lands on the goal wizard, whose active step is the
|
||||||
|
// pending install-LND one — the correct next action.
|
||||||
|
expect(state.routerPush).toHaveBeenCalledWith('/dashboard/goals/open-a-shop')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says to fund the wallet when LND is already installed', async () => {
|
||||||
|
state.packages = { 'bitcoin-knots': { state: 'running' }, lnd: { state: 'running' } }
|
||||||
|
const wrapper = await mountWatcher()
|
||||||
|
await completeSync()
|
||||||
|
|
||||||
|
expect(state.toastAction).toHaveBeenCalledTimes(1)
|
||||||
|
const [message, opts] = state.toastAction.mock.calls[0]
|
||||||
|
expect(message).toBe(
|
||||||
|
'Bitcoin is fully synced — you can now fund your wallet and open your Lightning channel.',
|
||||||
|
)
|
||||||
|
expect(opts.label).toBe('Finish setup')
|
||||||
|
opts.onClick()
|
||||||
|
expect(state.routerPush).toHaveBeenCalledWith('/dashboard/goals/open-a-shop')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stays silent when no Lightning goal is in progress', async () => {
|
||||||
|
state.goalStatus = 'not-started'
|
||||||
|
const wrapper = await mountWatcher()
|
||||||
|
await completeSync()
|
||||||
|
|
||||||
|
expect(state.toastAction).not.toHaveBeenCalled()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stays silent when the chain was already synced at page load', async () => {
|
||||||
|
// A node that's already synced never shows unsynced this session, so the
|
||||||
|
// toast must not fire (it only marks real IBD-completion transitions).
|
||||||
|
const wrapper = await mountWatcher()
|
||||||
|
const refs = state.syncRefs!
|
||||||
|
refs.loaded.value = true
|
||||||
|
refs.synced.value = true
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
expect(state.toastAction).not.toHaveBeenCalled()
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ import { computed, watch, watchEffect, onUnmounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { GOALS } from '@/data/goals'
|
import { GOALS } from '@/data/goals'
|
||||||
import { useGoalStore } from '@/stores/goals'
|
import { useGoalStore } from '@/stores/goals'
|
||||||
|
import { useAppStore } from '@/stores/app'
|
||||||
import { useToast } from '@/composables/useToast'
|
import { useToast } from '@/composables/useToast'
|
||||||
import {
|
import {
|
||||||
acquireBitcoinSync,
|
acquireBitcoinSync,
|
||||||
@@ -20,6 +21,7 @@ let firedThisSession = false
|
|||||||
*/
|
*/
|
||||||
export function useIbdFinishWatcher() {
|
export function useIbdFinishWatcher() {
|
||||||
const goalStore = useGoalStore()
|
const goalStore = useGoalStore()
|
||||||
|
const appStore = useAppStore()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
@@ -68,8 +70,16 @@ export function useIbdFinishWatcher() {
|
|||||||
const goalId = pendingLightningGoalId.value
|
const goalId = pendingLightningGoalId.value
|
||||||
if (!goalId) return
|
if (!goalId) return
|
||||||
firedThisSession = true
|
firedThisSession = true
|
||||||
|
// The on-chain wallet lives in LND, not Bitcoin Core — the address the
|
||||||
|
// fund flow shows comes from `lnd.newaddress`. While the goal's
|
||||||
|
// install-LND step is still pending, "fund your wallet" would point at
|
||||||
|
// something that doesn't exist yet, so the toast names the actual next
|
||||||
|
// step instead (issue #143).
|
||||||
|
const lndInstalled = Object.keys(appStore.packages).includes('lnd')
|
||||||
toast.action(
|
toast.action(
|
||||||
'Bitcoin is fully synced — you can now fund your wallet and open your Lightning channel.',
|
lndInstalled
|
||||||
|
? 'Bitcoin is fully synced — you can now fund your wallet and open your Lightning channel.'
|
||||||
|
: "Bitcoin is fully synced — next, install Lightning (LND) to get your node's on-chain wallet.",
|
||||||
{
|
{
|
||||||
label: 'Finish setup',
|
label: 'Finish setup',
|
||||||
onClick: () => { router.push(`/dashboard/goals/${goalId}`) },
|
onClick: () => { router.push(`/dashboard/goals/${goalId}`) },
|
||||||
|
|||||||
Reference in New Issue
Block a user