diff --git a/neode-ui/src/composables/__tests__/useIbdFinishWatcher.test.ts b/neode-ui/src/composables/__tests__/useIbdFinishWatcher.test.ts new file mode 100644 index 00000000..7fb12782 --- /dev/null +++ b/neode-ui/src/composables/__tests__/useIbdFinishWatcher.test.ts @@ -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, + goalStatus: 'in-progress', + goalProgress: {} as Record, + 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() + }) +}) diff --git a/neode-ui/src/composables/useIbdFinishWatcher.ts b/neode-ui/src/composables/useIbdFinishWatcher.ts index 4344b1d0..7137d830 100644 --- a/neode-ui/src/composables/useIbdFinishWatcher.ts +++ b/neode-ui/src/composables/useIbdFinishWatcher.ts @@ -2,6 +2,7 @@ import { computed, watch, watchEffect, onUnmounted } from 'vue' import { useRouter } from 'vue-router' import { GOALS } from '@/data/goals' import { useGoalStore } from '@/stores/goals' +import { useAppStore } from '@/stores/app' import { useToast } from '@/composables/useToast' import { acquireBitcoinSync, @@ -20,6 +21,7 @@ let firedThisSession = false */ export function useIbdFinishWatcher() { const goalStore = useGoalStore() + const appStore = useAppStore() const router = useRouter() const toast = useToast() @@ -68,8 +70,16 @@ export function useIbdFinishWatcher() { const goalId = pendingLightningGoalId.value if (!goalId) return 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( - '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', onClick: () => { router.push(`/dashboard/goals/${goalId}`) },