test: achieve 80%+ branch/function coverage on frontend logic (E2E-03)

515 tests across 38 files. Branch coverage 88%, function coverage 83%
on testable logic (stores, composables, api, utils, services, router).

New test files: websocket, useLoginSounds, useMobileBackButton,
useControllerNav, routes. Extended: rpc-client (99.5%), container store
(100%). Fixed: useNavSounds AudioContext mock, type errors across tests.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-03-11 17:18:37 +00:00
parent 0b6068f452
commit 1697af725b
14 changed files with 2161 additions and 2 deletions

View File

@@ -163,3 +163,405 @@ describe('RPCClient', () => {
expect(init.signal).toBeInstanceOf(AbortSignal)
})
})
describe('RPCClient convenience methods', () => {
beforeEach(() => {
mockFetch.mockReset()
vi.useFakeTimers({ shouldAdvanceTime: true })
})
afterEach(() => {
vi.useRealTimers()
})
function mockSuccess(result: unknown) {
mockFetch.mockResolvedValueOnce(jsonResponse({ result }))
}
function getLastMethod(): string {
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
return body.method
}
function getLastParams(): Record<string, unknown> {
const body = JSON.parse(mockFetch.mock.calls[0]![1].body)
return body.params
}
it('login calls auth.login with password', async () => {
mockSuccess(null)
await rpcClient.login('test123')
expect(getLastMethod()).toBe('auth.login')
expect(getLastParams().password).toBe('test123')
})
it('loginTotp calls auth.login.totp', async () => {
mockSuccess({ success: true })
await rpcClient.loginTotp('123456')
expect(getLastMethod()).toBe('auth.login.totp')
expect(getLastParams().code).toBe('123456')
})
it('loginBackup calls auth.login.backup', async () => {
mockSuccess({ success: true })
await rpcClient.loginBackup('ABCD-1234')
expect(getLastMethod()).toBe('auth.login.backup')
expect(getLastParams().code).toBe('ABCD-1234')
})
it('totpSetupBegin calls auth.totp.setup.begin', async () => {
mockSuccess({ qr_svg: '<svg/>', secret_base32: 'ABC', pending_token: 'tok' })
await rpcClient.totpSetupBegin('password')
expect(getLastMethod()).toBe('auth.totp.setup.begin')
})
it('totpSetupConfirm calls auth.totp.setup.confirm', async () => {
mockSuccess({ enabled: true, backup_codes: ['A', 'B'] })
await rpcClient.totpSetupConfirm({ code: '123456', password: 'pw', pendingToken: 'tok' })
expect(getLastMethod()).toBe('auth.totp.setup.confirm')
})
it('totpDisable calls auth.totp.disable', async () => {
mockSuccess({ disabled: true })
await rpcClient.totpDisable('pw', '123456')
expect(getLastMethod()).toBe('auth.totp.disable')
})
it('totpStatus calls auth.totp.status', async () => {
mockSuccess({ enabled: false })
await rpcClient.totpStatus()
expect(getLastMethod()).toBe('auth.totp.status')
})
it('changePassword calls auth.changePassword', async () => {
mockSuccess({ success: true })
await rpcClient.changePassword({ currentPassword: 'old', newPassword: 'new' })
expect(getLastMethod()).toBe('auth.changePassword')
expect(getLastParams().alsoChangeSsh).toBe(true)
})
it('changePassword respects alsoChangeSsh option', async () => {
mockSuccess({ success: true })
await rpcClient.changePassword({ currentPassword: 'old', newPassword: 'new', alsoChangeSsh: false })
expect(getLastParams().alsoChangeSsh).toBe(false)
})
it('logout calls auth.logout', async () => {
mockSuccess(undefined)
await rpcClient.logout()
expect(getLastMethod()).toBe('auth.logout')
})
it('completeOnboarding calls auth.onboardingComplete', async () => {
mockSuccess(true)
await rpcClient.completeOnboarding()
expect(getLastMethod()).toBe('auth.onboardingComplete')
})
it('isOnboardingComplete calls auth.isOnboardingComplete', async () => {
mockSuccess(true)
const result = await rpcClient.isOnboardingComplete()
expect(result).toBe(true)
expect(getLastMethod()).toBe('auth.isOnboardingComplete')
})
it('resetOnboarding calls auth.resetOnboarding', async () => {
mockSuccess(true)
await rpcClient.resetOnboarding()
expect(getLastMethod()).toBe('auth.resetOnboarding')
})
it('getNodeDid calls node.did', async () => {
mockSuccess({ did: 'did:key:z123', pubkey: 'abc' })
const result = await rpcClient.getNodeDid()
expect(result.did).toBe('did:key:z123')
expect(getLastMethod()).toBe('node.did')
})
it('signChallenge calls node.signChallenge', async () => {
mockSuccess({ signature: 'sig123' })
await rpcClient.signChallenge('test-challenge')
expect(getLastMethod()).toBe('node.signChallenge')
expect(getLastParams().challenge).toBe('test-challenge')
})
it('createBackup calls node.createBackup', async () => {
mockSuccess({ version: 1, did: 'did:key:z', pubkey: 'pk', kid: 'k1', encrypted: true, blob: 'data', timestamp: '2026-01-01' })
await rpcClient.createBackup('passphrase')
expect(getLastMethod()).toBe('node.createBackup')
})
it('resolveDid calls identity.resolve-did', async () => {
mockSuccess({})
await rpcClient.resolveDid('did:key:z123')
expect(getLastMethod()).toBe('identity.resolve-did')
expect(getLastParams().did).toBe('did:key:z123')
})
it('resolveDid without did sends empty params', async () => {
mockSuccess({})
await rpcClient.resolveDid()
expect(getLastParams()).toEqual({})
})
it('createPresentation calls identity.create-presentation', async () => {
mockSuccess({})
await rpcClient.createPresentation({ holderId: 'h1', credentialIds: ['c1'] })
expect(getLastMethod()).toBe('identity.create-presentation')
})
it('verifyPresentation calls identity.verify-presentation', async () => {
mockSuccess({ valid: true, holder_valid: true, credentials: [] })
await rpcClient.verifyPresentation({ type: 'test' })
expect(getLastMethod()).toBe('identity.verify-presentation')
})
it('createPsbt calls lnd.create-psbt', async () => {
mockSuccess({ psbt_base64: 'psbt', change_output_index: 0, total_amount_sats: 1000, fee_rate_sat_per_vbyte: 10 })
await rpcClient.createPsbt({ outputs: [{ address: 'bc1q...', amount_sats: 1000 }] })
expect(getLastMethod()).toBe('lnd.create-psbt')
expect(getLastParams().fee_rate_sat_per_vbyte).toBe(10)
})
it('finalizePsbt calls lnd.finalize-psbt', async () => {
mockSuccess({ raw_final_tx: 'rawtx', broadcast: true })
await rpcClient.finalizePsbt('signed-psbt')
expect(getLastMethod()).toBe('lnd.finalize-psbt')
})
it('publishNostrIdentity calls node.nostr-publish', async () => {
mockSuccess({ event_id: 'evt', success: 1, failed: 0 })
await rpcClient.publishNostrIdentity()
expect(getLastMethod()).toBe('node.nostr-publish')
})
it('getNostrPubkey calls node.nostr-pubkey', async () => {
mockSuccess({ nostr_pubkey: 'npub1...' })
await rpcClient.getNostrPubkey()
expect(getLastMethod()).toBe('node.nostr-pubkey')
})
it('listPeers calls node-list-peers', async () => {
mockSuccess({ peers: [] })
await rpcClient.listPeers()
expect(getLastMethod()).toBe('node-list-peers')
})
it('addPeer calls node-add-peer', async () => {
mockSuccess({ peers: [] })
await rpcClient.addPeer({ onion: 'abc.onion', pubkey: 'pk' })
expect(getLastMethod()).toBe('node-add-peer')
})
it('removePeer calls node-remove-peer', async () => {
mockSuccess({ peers: [] })
await rpcClient.removePeer('pk123')
expect(getLastMethod()).toBe('node-remove-peer')
})
it('sendMessageToPeer calls node-send-message', async () => {
mockSuccess({ ok: true, sent_to: 'abc.onion' })
await rpcClient.sendMessageToPeer('abc.onion', 'hello')
expect(getLastMethod()).toBe('node-send-message')
})
it('checkPeerReachable calls node-check-peer', async () => {
mockSuccess({ onion: 'abc.onion', reachable: true })
await rpcClient.checkPeerReachable('abc.onion')
expect(getLastMethod()).toBe('node-check-peer')
})
it('getReceivedMessages calls node-messages-received', async () => {
mockSuccess({ messages: [] })
await rpcClient.getReceivedMessages()
expect(getLastMethod()).toBe('node-messages-received')
})
it('discoverNodes calls node-nostr-discover', async () => {
mockSuccess({ nodes: [] })
await rpcClient.discoverNodes()
expect(getLastMethod()).toBe('node-nostr-discover')
})
it('getTorAddress calls node.tor-address', async () => {
mockSuccess({ tor_address: 'abc123.onion' })
await rpcClient.getTorAddress()
expect(getLastMethod()).toBe('node.tor-address')
})
it('verifyNostrRevoked calls node-nostr-verify-revoked', async () => {
mockSuccess({ revoked: false, nostr_pubkey: 'npub' })
await rpcClient.verifyNostrRevoked()
expect(getLastMethod()).toBe('node-nostr-verify-revoked')
})
it('echo calls server.echo', async () => {
mockSuccess('hello')
const result = await rpcClient.echo('hello')
expect(result).toBe('hello')
expect(getLastMethod()).toBe('server.echo')
})
it('getSystemTime calls server.time', async () => {
mockSuccess({ now: '2026-03-11', uptime: 3600 })
await rpcClient.getSystemTime()
expect(getLastMethod()).toBe('server.time')
})
it('getMetrics calls server.metrics', async () => {
mockSuccess({ cpu: 50 })
await rpcClient.getMetrics()
expect(getLastMethod()).toBe('server.metrics')
})
it('updateServer calls server.update', async () => {
mockSuccess('no-updates')
await rpcClient.updateServer('https://example.com')
expect(getLastMethod()).toBe('server.update')
})
it('detectUsbDevices calls system.detect-usb-devices', async () => {
mockSuccess({ devices: [] })
await rpcClient.detectUsbDevices()
expect(getLastMethod()).toBe('system.detect-usb-devices')
})
it('restartServer calls server.restart', async () => {
mockSuccess(undefined)
await rpcClient.restartServer()
expect(getLastMethod()).toBe('server.restart')
})
it('shutdownServer calls server.shutdown', async () => {
mockSuccess(undefined)
await rpcClient.shutdownServer()
expect(getLastMethod()).toBe('server.shutdown')
})
it('installPackage calls package.install', async () => {
mockSuccess('bitcoin-knots')
await rpcClient.installPackage('btc', 'https://mp.com', '1.0')
expect(getLastMethod()).toBe('package.install')
})
it('uninstallPackage calls package.uninstall', async () => {
mockSuccess(undefined)
await rpcClient.uninstallPackage('btc')
expect(getLastMethod()).toBe('package.uninstall')
})
it('startPackage calls package.start', async () => {
mockSuccess(undefined)
await rpcClient.startPackage('btc')
expect(getLastMethod()).toBe('package.start')
})
it('stopPackage calls package.stop', async () => {
mockSuccess(undefined)
await rpcClient.stopPackage('btc')
expect(getLastMethod()).toBe('package.stop')
})
it('restartPackage calls package.restart', async () => {
mockSuccess(undefined)
await rpcClient.restartPackage('btc')
expect(getLastMethod()).toBe('package.restart')
})
it('getMarketplace calls marketplace.get', async () => {
mockSuccess({})
await rpcClient.getMarketplace('https://mp.com')
expect(getLastMethod()).toBe('marketplace.get')
})
it('federationInvite calls federation.invite', async () => {
mockSuccess({ code: 'ABC', did: 'did:key:z', onion: 'abc.onion' })
await rpcClient.federationInvite()
expect(getLastMethod()).toBe('federation.invite')
})
it('federationJoin calls federation.join', async () => {
mockSuccess({ joined: true, node: {} })
await rpcClient.federationJoin('invite-code')
expect(getLastMethod()).toBe('federation.join')
})
it('federationListNodes calls federation.list-nodes', async () => {
mockSuccess({ nodes: [] })
await rpcClient.federationListNodes()
expect(getLastMethod()).toBe('federation.list-nodes')
})
it('federationRemoveNode calls federation.remove-node', async () => {
mockSuccess({ removed: true, nodes_remaining: 0 })
await rpcClient.federationRemoveNode('did:key:z')
expect(getLastMethod()).toBe('federation.remove-node')
})
it('federationSetTrust calls federation.set-trust', async () => {
mockSuccess({ updated: true, did: 'did:key:z', trust_level: 'trusted' })
await rpcClient.federationSetTrust('did:key:z', 'trusted')
expect(getLastMethod()).toBe('federation.set-trust')
})
it('federationSyncState calls federation.sync-state', async () => {
mockSuccess({ synced: 1, failed: 0, results: [] })
await rpcClient.federationSyncState()
expect(getLastMethod()).toBe('federation.sync-state')
})
it('federationDeployApp calls federation.deploy-app', async () => {
mockSuccess({ deployed: true, app_id: 'btc', peer_did: 'did', peer_onion: 'onion' })
await rpcClient.federationDeployApp({ did: 'did:key:z', appId: 'btc' })
expect(getLastMethod()).toBe('federation.deploy-app')
expect(getLastParams().version).toBe('latest')
})
it('vpnStatus calls vpn.status', async () => {
mockSuccess({ connected: false, peers_connected: 0, bytes_in: 0, bytes_out: 0, configured: false, configured_provider: '' })
await rpcClient.vpnStatus()
expect(getLastMethod()).toBe('vpn.status')
})
it('vpnConfigure calls vpn.configure', async () => {
mockSuccess({ configured: true, provider: 'tailscale' })
await rpcClient.vpnConfigure({ provider: 'tailscale', auth_key: 'key' })
expect(getLastMethod()).toBe('vpn.configure')
})
it('vpnDisconnect calls vpn.disconnect', async () => {
mockSuccess({ disconnected: true })
await rpcClient.vpnDisconnect()
expect(getLastMethod()).toBe('vpn.disconnect')
})
it('marketplaceDiscover calls marketplace.discover', async () => {
mockSuccess({ apps: [], relay_count: 0 })
await rpcClient.marketplaceDiscover()
expect(getLastMethod()).toBe('marketplace.discover')
})
it('dnsStatus calls network.dns-status', async () => {
mockSuccess({ provider: 'system', servers: [], doh_enabled: false, doh_url: null, resolv_conf_servers: [] })
await rpcClient.dnsStatus()
expect(getLastMethod()).toBe('network.dns-status')
})
it('configureDns calls network.configure-dns', async () => {
mockSuccess({ ok: true, provider: 'cloudflare', servers: [], doh_enabled: true, doh_url: null })
await rpcClient.configureDns({ provider: 'cloudflare' })
expect(getLastMethod()).toBe('network.configure-dns')
})
it('diskStatus calls system.disk-status', async () => {
mockSuccess({ used_bytes: 100, total_bytes: 1000, free_bytes: 900, used_percent: 10, level: 'ok' })
await rpcClient.diskStatus()
expect(getLastMethod()).toBe('system.disk-status')
})
it('diskCleanup calls system.disk-cleanup', async () => {
mockSuccess({ freed_bytes: 500, freed_human: '500B', actions: [] })
await rpcClient.diskCleanup()
expect(getLastMethod()).toBe('system.disk-cleanup')
})
})

View File

@@ -0,0 +1,261 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
// Mock fast-json-patch
vi.mock('fast-json-patch', () => ({
applyPatch: vi.fn((doc: unknown, _ops: unknown[]) => ({
newDocument: { ...doc as Record<string, unknown>, patched: true },
})),
}))
// Mock WebSocket
class MockWebSocket {
static CONNECTING = 0
static OPEN = 1
static CLOSING = 2
static CLOSED = 3
readyState = MockWebSocket.CONNECTING
onopen: ((ev: Event) => void) | null = null
onclose: ((ev: CloseEvent) => void) | null = null
onerror: ((ev: Event) => void) | null = null
onmessage: ((ev: MessageEvent) => void) | null = null
url: string
constructor(url: string) {
this.url = url
// Auto-open in next tick
setTimeout(() => {
this.readyState = MockWebSocket.OPEN
this.onopen?.(new Event('open'))
}, 0)
}
send = vi.fn()
close = vi.fn().mockImplementation(function (this: MockWebSocket) {
this.readyState = MockWebSocket.CLOSED
this.onclose?.(new CloseEvent('close', { code: 1000, wasClean: true }))
})
}
vi.stubGlobal('WebSocket', MockWebSocket)
// Must import after mocks
const { WebSocketClient, applyDataPatch } = await import('../websocket')
describe('WebSocketClient', () => {
let client: InstanceType<typeof WebSocketClient>
beforeEach(() => {
vi.useFakeTimers()
vi.clearAllMocks()
client = new WebSocketClient('/ws/test')
})
afterEach(() => {
client.reset()
vi.useRealTimers()
})
it('initializes with disconnected state', () => {
expect(client.state).toBe('disconnected')
expect(client.isConnected()).toBe(false)
})
it('connects and transitions to connected state', async () => {
const states: string[] = []
client.onConnectionStateChange((s) => states.push(s))
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
expect(client.state).toBe('connected')
expect(client.isConnected()).toBe(true)
expect(states).toContain('connecting')
expect(states).toContain('connected')
})
it('resolves immediately if already connected', async () => {
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
// Second connect should resolve immediately
await client.connect()
expect(client.isConnected()).toBe(true)
})
it('subscribe returns unsubscribe function', async () => {
const callback = vi.fn()
const unsub = client.subscribe(callback)
expect(typeof unsub).toBe('function')
unsub()
// Should not throw
})
it('notifies subscribers on message', async () => {
const callback = vi.fn()
client.subscribe(callback)
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
// Simulate receiving a message
const ws = (client as unknown as { ws: MockWebSocket }).ws
const update = { id: 1, type: 'state', data: { running: true } }
ws.onmessage?.(new MessageEvent('message', { data: JSON.stringify(update) }))
expect(callback).toHaveBeenCalledWith(update)
})
it('handles malformed JSON messages gracefully', async () => {
const callback = vi.fn()
client.subscribe(callback)
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
const ws = (client as unknown as { ws: MockWebSocket }).ws
// Should not throw
ws.onmessage?.(new MessageEvent('message', { data: 'not-json{' }))
expect(callback).not.toHaveBeenCalled()
})
it('onConnectionStateChange returns unsubscribe function', () => {
const callback = vi.fn()
const unsub = client.onConnectionStateChange(callback)
expect(typeof unsub).toBe('function')
unsub()
})
it('disconnect sets state to disconnecting then cleans up', async () => {
const states: string[] = []
client.onConnectionStateChange((s) => states.push(s))
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
client.disconnect()
expect(states).toContain('disconnecting')
expect(client.isConnected()).toBe(false)
})
it('reset clears all callbacks and disconnects', async () => {
const callback = vi.fn()
client.subscribe(callback)
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
client.reset()
expect(client.isConnected()).toBe(false)
})
it('sends ping messages via heartbeat', async () => {
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
const ws = (client as unknown as { ws: MockWebSocket }).ws
// Advance past ping interval (30s)
await vi.advanceTimersByTimeAsync(31000)
expect(ws.send).toHaveBeenCalledWith(JSON.stringify({ type: 'ping' }))
})
it('disconnect prevents reconnection after abnormal close', async () => {
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
// Disconnect explicitly — should prevent future reconnections
const states: string[] = []
client.onConnectionStateChange((s) => states.push(s))
client.disconnect()
expect(states).toContain('disconnecting')
})
it('handles close event with normal closure code', async () => {
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
const ws = (client as unknown as { ws: MockWebSocket }).ws
// Simulate normal close — should still try to reconnect (shouldReconnect is true)
ws.readyState = MockWebSocket.CLOSED
ws.onclose?.(new CloseEvent('close', { code: 1000, wasClean: true }))
// After close, state transitions to disconnected
// Then reconnection happens automatically (mock auto-opens)
await vi.advanceTimersByTimeAsync(200)
// Client should have attempted reconnect (state went through disconnected → connecting → connected)
expect(client.state).toBe('connected')
})
it('heartbeat detects stale connection after 5 minutes', async () => {
const connectPromise = client.connect()
await vi.advanceTimersByTimeAsync(10)
await connectPromise
const ws = (client as unknown as { ws: MockWebSocket }).ws
const closeSpy = ws.close
// Advance 5+ minutes without any messages
await vi.advanceTimersByTimeAsync(310000)
// Heartbeat should have closed the stale connection
expect(closeSpy).toHaveBeenCalled()
})
it('state getter returns current connection state', () => {
expect(client.state).toBe('disconnected')
})
})
describe('applyDataPatch', () => {
it('returns original data for empty patch', () => {
const data = { a: 1, b: 2 }
const result = applyDataPatch(data, [])
expect(result).toBe(data)
})
it('returns original data for non-array patch', () => {
const data = { a: 1 }
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = applyDataPatch(data, null as any)
expect(result).toBe(data)
})
it('applies valid patch operations', () => {
const data = { name: 'test', count: 0 }
const patch: import('../../types/api').PatchOperation[] = [{ op: 'replace', path: '/count', value: 5 }]
const result = applyDataPatch(data, patch)
// The mock returns { ...data, patched: true }
expect(result).toHaveProperty('patched', true)
})
it('returns original data when patch application throws', async () => {
// Override mock to throw
const { applyPatch: mockApplyPatch } = await import('fast-json-patch')
vi.mocked(mockApplyPatch).mockImplementationOnce(() => {
throw new Error('Invalid patch')
})
const data = { value: 42 }
const patch: import('../../types/api').PatchOperation[] = [{ op: 'replace', path: '/invalid', value: 0 }]
const result = applyDataPatch(data, patch)
expect(result).toBe(data)
})
})