Demo images / Build & push demo images (push) Successful in 3m38s
"LND thinks I do not have a channel" while the wallet showed plenty of liquidity (framework-pt, 2026-09-01): the send gate sums outbound over FULLY-OPEN channels only, which is correct — a just-opened channel sits in LND's pending list until it has ~3 confirmations, and an open channel can have all its balance on the far side — but the modal then claimed the node had NO channel at all, in every one of those states, and pointed the user at opening another one. The gate already fetched the full channel list; it now records WHY liquidity is zero and the modal says the truth per state: - pending channels -> "your new channel is waiting for on-chain confirmations, it unlocks automatically, nothing is needed from you" (and no "Open a channel" button — that would send the user to fix a problem they don't have, possibly opening a second channel) - open channels, zero on the needed side -> "balance is on the far side — you can receive but there's nothing to send right now" - payment refused with a routing/liquidity error -> says so, instead of claiming no channels - only a genuinely channel-less node keeps the open-one guidance Eleven unit tests pin the state machine, including the regression case (pending-only -> 'pending', not 'none') and fail-open on RPC errors.
162 lines
6.2 KiB
TypeScript
162 lines
6.2 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import { createPinia, setActivePinia } from 'pinia'
|
|
import { useLightningRequired } from '../useLightningRequired'
|
|
import { rpcClient } from '@/api/rpc-client'
|
|
|
|
// The gate reads install state off the app store's package list. Stub the
|
|
// store rather than the RPC layer so the test pins the decision, not the
|
|
// transport.
|
|
const packages = vi.hoisted(() => ({ value: {} as Record<string, unknown> }))
|
|
vi.mock('@/stores/app', () => ({
|
|
useAppStore: () => ({
|
|
get packages() {
|
|
return packages.value
|
|
},
|
|
}),
|
|
}))
|
|
|
|
vi.mock('@/api/rpc-client', () => ({
|
|
rpcClient: {
|
|
call: vi.fn(),
|
|
},
|
|
}))
|
|
|
|
describe('useLightningRequired', () => {
|
|
beforeEach(() => {
|
|
setActivePinia(createPinia())
|
|
packages.value = {}
|
|
// Module-scope `show` is shared by design (one global modal), so reset it
|
|
// between cases or the first opener leaks into the next test.
|
|
useLightningRequired().close()
|
|
})
|
|
|
|
it('lets the action through when a Lightning node is running', () => {
|
|
packages.value = { lnd: { state: 'running' }, 'bitcoin-knots': { state: 'running' } }
|
|
const lightning = useLightningRequired()
|
|
|
|
expect(lightning.lightningStatus()).toBe('running')
|
|
expect(lightning.requireLightningNode()).toBe(true)
|
|
expect(lightning.show.value).toBe(false)
|
|
})
|
|
|
|
it('blocks when the node is present but NOT running, and says so', () => {
|
|
// The bug this closes: `id in packages` is not "usable". A node with an
|
|
// lnd entry in a non-running state produced a raw connection-refused
|
|
// error ("Operation failed. Check server logs for details.").
|
|
packages.value = { lnd: { state: 'stopped' } }
|
|
const lightning = useLightningRequired()
|
|
|
|
expect(lightning.lightningStatus()).toBe('stopped')
|
|
expect(lightning.requireLightningNode()).toBe(false)
|
|
expect(lightning.show.value).toBe(true)
|
|
expect(lightning.status.value).toBe('stopped')
|
|
})
|
|
|
|
it('blocks and raises the install modal when no Lightning node is installed', () => {
|
|
packages.value = { 'bitcoin-knots': { state: 'running' }, immich: { state: 'running' } }
|
|
const lightning = useLightningRequired()
|
|
|
|
expect(lightning.lightningStatus()).toBe('absent')
|
|
expect(lightning.hasLightningNode()).toBe(false)
|
|
// Returns false so the caller bails WITHOUT surfacing an error string —
|
|
// that was the whole defect: a missing prerequisite rendered as a failure.
|
|
expect(lightning.requireLightningNode()).toBe(false)
|
|
expect(lightning.show.value).toBe(true)
|
|
expect(lightning.status.value).toBe('absent')
|
|
})
|
|
|
|
it('shares one modal state across call sites', () => {
|
|
packages.value = {}
|
|
const a = useLightningRequired()
|
|
const b = useLightningRequired()
|
|
|
|
a.requireLightningNode()
|
|
expect(b.show.value).toBe(true)
|
|
b.close()
|
|
expect(a.show.value).toBe(false)
|
|
})
|
|
|
|
it('treats an empty package list as absent', () => {
|
|
packages.value = {}
|
|
expect(useLightningRequired().lightningStatus()).toBe('absent')
|
|
})
|
|
|
|
describe('requireLightningReady states the node\u2019s real funding state', () => {
|
|
beforeEach(() => {
|
|
packages.value = { lnd: { state: 'running' } }
|
|
vi.mocked(rpcClient.call).mockReset()
|
|
})
|
|
|
|
it('says the channel is confirming, not \u201cno channel\u201d, while pending', async () => {
|
|
// The regression (framework-pt, 2026-09-01): a just-opened channel
|
|
// sits in LND's pending list; the outbound sum is legitimately 0, but
|
|
// the modal claimed the node had no channel at all.
|
|
vi.mocked(rpcClient.call).mockResolvedValue({
|
|
total_inbound: 0,
|
|
total_outbound: 0,
|
|
channels: [{ status: 'pending_open', local_balance: 900000, remote_balance: 0 }],
|
|
})
|
|
const lightning = useLightningRequired()
|
|
|
|
expect(await lightning.requireLightningReady('send')).toBe(false)
|
|
expect(lightning.show.value).toBe(true)
|
|
expect(lightning.status.value).toBe('no-funds')
|
|
expect(lightning.fundingReason.value).toBe('pending')
|
|
})
|
|
|
|
it('says the balance is on the far side when channels exist but outbound is 0', async () => {
|
|
vi.mocked(rpcClient.call).mockResolvedValue({
|
|
total_inbound: 985000,
|
|
total_outbound: 0,
|
|
channels: [{ status: 'active', local_balance: 0, remote_balance: 985000 }],
|
|
})
|
|
const lightning = useLightningRequired()
|
|
|
|
expect(await lightning.requireLightningReady('send')).toBe(false)
|
|
expect(lightning.fundingReason.value).toBe('far-side')
|
|
// The same node CAN receive — the gate must pass for the other way.
|
|
vi.mocked(rpcClient.call).mockResolvedValue({
|
|
total_inbound: 985000,
|
|
total_outbound: 0,
|
|
channels: [{ status: 'active', local_balance: 0, remote_balance: 985000 }],
|
|
})
|
|
expect(await lightning.requireLightningReady('receive')).toBe(true)
|
|
})
|
|
|
|
it('keeps the open-a-channel guidance only when there truly is no channel', async () => {
|
|
vi.mocked(rpcClient.call).mockResolvedValue({
|
|
total_inbound: 0,
|
|
total_outbound: 0,
|
|
channels: [],
|
|
})
|
|
const lightning = useLightningRequired()
|
|
|
|
expect(await lightning.requireLightningReady('send')).toBe(false)
|
|
expect(lightning.fundingReason.value).toBe('none')
|
|
})
|
|
|
|
it('fails OPEN on an RPC error \u2014 a transient blip must not block a working wallet', async () => {
|
|
vi.mocked(rpcClient.call).mockRejectedValue(new Error('Failed to fetch'))
|
|
const lightning = useLightningRequired()
|
|
|
|
expect(await lightning.requireLightningReady('send')).toBe(true)
|
|
expect(lightning.show.value).toBe(false)
|
|
})
|
|
|
|
it('maps a routing/liquidity payment failure onto the modal without claiming \u201cno channel\u201d', () => {
|
|
const lightning = useLightningRequired()
|
|
|
|
expect(lightning.handleLightningFailure(new Error('Payment failed: unable to find a path to destination'))).toBe(true)
|
|
expect(lightning.status.value).toBe('no-funds')
|
|
expect(lightning.fundingReason.value).toBe('failed-payment')
|
|
})
|
|
|
|
it('leaves non-funding payment errors to the caller', () => {
|
|
const lightning = useLightningRequired()
|
|
|
|
expect(lightning.handleLightningFailure(new Error('Payment failed: Not Found'))).toBe(false)
|
|
expect(lightning.show.value).toBe(false)
|
|
})
|
|
})
|
|
})
|