import { describe, it, expect, vi, beforeEach } from 'vitest'; import { runBlobSyncSession } from './sync-coordinator'; import type { SyncDeps } from './sync-coordinator'; import type { SyncTransport } from '../shared/websocket-sync-transport'; import { encodeSyncMessage } from '../shared/sync-wire'; import type { SyncableDevice } from '../shared/sync-types'; // Builds a paired in-memory transport — one write on side A becomes a read on // side B and vice versa. Returns [transportA, transportB]. function makePairedTransports(): [SyncTransport, SyncTransport] { const queueAtoB: Uint8Array[] = []; const queueBtoA: Uint8Array[] = []; const waitersAtoB: ((d: Uint8Array) => void)[] = []; const waitersBtoA: ((d: Uint8Array) => void)[] = []; function push(queue: Uint8Array[], waiters: ((d: Uint8Array) => void)[], data: Uint8Array) { if (waiters.length > 0) waiters.shift()!(data); else queue.push(data); } function pop(queue: Uint8Array[], waiters: ((d: Uint8Array) => void)[]): Promise { return new Promise(res => { if (queue.length > 0) res(queue.shift()!); else waiters.push(res); }); } const a: SyncTransport = { send: async (d) => push(queueAtoB, waitersAtoB, d), receive: () => pop(queueBtoA, waitersBtoA), close: async () => {}, }; const b: SyncTransport = { send: async (d) => push(queueBtoA, waitersBtoA, d), receive: () => pop(queueAtoB, waitersAtoB), close: async () => {}, }; return [a, b]; } const DEVICE_LIST: SyncableDevice[] = [ { publicKeyHex: 'aaa', deviceName: 'iPhone', pairedAt: 1000 }, ]; function makeDeps(overrides: Partial = {}): SyncDeps { return { isVaultUnlocked: () => true, getVaultBlobForSync: async () => ({ blob: JSON.stringify({ a: 'slotA', b: 'slotB' }), timestamp: 1000, devices: DEVICE_LIST, }), applyIncomingVault: async () => false, ...overrides, }; } describe('runBlobSyncSession', () => { it('initiator applies newer remote vault', async () => { const apply = vi.fn().mockResolvedValue(true); const [tInitiator, tResponder] = makePairedTransports(); // Responder side: sends a newer vault (timestamp 2000 > initiator's 1000) const responderDeps = makeDeps({ getVaultBlobForSync: async () => ({ blob: JSON.stringify({ a: 'newer', b: null }), timestamp: 2000, devices: DEVICE_LIST, }), applyIncomingVault: async () => false, }); const initiatorDeps = makeDeps({ applyIncomingVault: apply }); const [result] = await Promise.all([ runBlobSyncSession('initiator', tInitiator, initiatorDeps), runBlobSyncSession('responder', tResponder, responderDeps), ]); expect(result.status).toBe('completed'); expect(result.applied).toBe(true); expect(apply).toHaveBeenCalledWith( expect.stringContaining('newer'), 2000, undefined, DEVICE_LIST, ); }); it('initiator keeps local vault when remote is older', async () => { const apply = vi.fn().mockResolvedValue(false); const [tInitiator, tResponder] = makePairedTransports(); const responderDeps = makeDeps({ getVaultBlobForSync: async () => ({ blob: JSON.stringify({ a: 'old' }), timestamp: 500, // older devices: DEVICE_LIST, }), applyIncomingVault: async () => false, }); const initiatorDeps = makeDeps({ applyIncomingVault: apply }); const [result] = await Promise.all([ runBlobSyncSession('initiator', tInitiator, initiatorDeps), runBlobSyncSession('responder', tResponder, responderDeps), ]); expect(result.status).toBe('completed'); expect(result.applied).toBe(false); }); it('both sides exchange vaults bidirectionally', async () => { const applyI = vi.fn().mockResolvedValue(true); const applyR = vi.fn().mockResolvedValue(true); const [tInitiator, tResponder] = makePairedTransports(); const initiatorDeps = makeDeps({ getVaultBlobForSync: async () => ({ blob: JSON.stringify({ a: 'initiatorVault' }), timestamp: 1000, devices: DEVICE_LIST, }), applyIncomingVault: applyI, }); const responderDeps = makeDeps({ getVaultBlobForSync: async () => ({ blob: JSON.stringify({ a: 'responderVault' }), timestamp: 2000, devices: DEVICE_LIST, }), applyIncomingVault: applyR, }); await Promise.all([ runBlobSyncSession('initiator', tInitiator, initiatorDeps), runBlobSyncSession('responder', tResponder, responderDeps), ]); // Initiator received responder's vault (newer), responder received initiator's (older) expect(applyI).toHaveBeenCalledWith(expect.stringContaining('responderVault'), 2000, undefined, DEVICE_LIST); expect(applyR).toHaveBeenCalledWith(expect.stringContaining('initiatorVault'), 1000, undefined, DEVICE_LIST); }); it('returns skipped when vault is locked', async () => { const [t] = makePairedTransports(); const deps = makeDeps({ isVaultUnlocked: () => false }); const result = await runBlobSyncSession('initiator', t, deps); expect(result.status).toBe('skipped'); expect(result.error).toBe('vault_locked'); }); it('transfers Pro license when receiver has none', async () => { const apply = vi.fn().mockResolvedValue(true); const [tInitiator, tResponder] = makePairedTransports(); const responderDeps = makeDeps({ getVaultBlobForSync: async () => ({ blob: JSON.stringify({ a: 'vault' }), timestamp: 2000, license: 'PRO-LICENSE-KEY', devices: DEVICE_LIST, }), applyIncomingVault: async () => false, }); const initiatorDeps = makeDeps({ applyIncomingVault: apply }); await Promise.all([ runBlobSyncSession('initiator', tInitiator, initiatorDeps), runBlobSyncSession('responder', tResponder, responderDeps), ]); expect(apply).toHaveBeenCalledWith(expect.any(String), 2000, 'PRO-LICENSE-KEY', DEVICE_LIST); }); it('does not override existing license when both sides have one', async () => { // Both sides have a license — the responder's apply function receives // the initiator's license but this is handled by vault.applyIncomingVault // which skips overwriting. The coordinator just passes it through. const apply = vi.fn().mockResolvedValue(false); const [tInitiator, tResponder] = makePairedTransports(); const responderDeps = makeDeps({ getVaultBlobForSync: async () => ({ blob: JSON.stringify({ a: 'vault' }), timestamp: 500, license: 'RESPONDER-LICENSE', devices: DEVICE_LIST, }), applyIncomingVault: async () => false, }); const initiatorDeps = makeDeps({ getVaultBlobForSync: async () => ({ blob: JSON.stringify({ a: 'vault' }), timestamp: 1000, license: 'INITIATOR-LICENSE', devices: DEVICE_LIST, }), applyIncomingVault: apply, }); await Promise.all([ runBlobSyncSession('initiator', tInitiator, initiatorDeps), runBlobSyncSession('responder', tResponder, responderDeps), ]); // Initiator's apply receives 'RESPONDER-LICENSE' — vault.ts decides not to overwrite expect(apply).toHaveBeenCalledWith(expect.any(String), 500, 'RESPONDER-LICENSE', DEVICE_LIST); }); }); describe('mesh device registry propagation', () => { it('passes remote device list to applyIncomingVault', async () => { const apply = vi.fn().mockResolvedValue(true); const [tInitiator, tResponder] = makePairedTransports(); const remoteDevices: SyncableDevice[] = [ { publicKeyHex: 'aaa', deviceName: 'iPhone', pairedAt: 1000 }, { publicKeyHex: 'bbb', deviceName: 'MacBook', pairedAt: 2000 }, { publicKeyHex: 'ccc', deviceName: 'iPad', pairedAt: 3000 }, ]; const responderDeps = makeDeps({ getVaultBlobForSync: async () => ({ blob: JSON.stringify({ a: 'vault' }), timestamp: 2000, devices: remoteDevices, }), applyIncomingVault: async () => false, }); const initiatorDeps = makeDeps({ applyIncomingVault: apply }); await Promise.all([ runBlobSyncSession('initiator', tInitiator, initiatorDeps), runBlobSyncSession('responder', tResponder, responderDeps), ]); expect(apply).toHaveBeenCalledWith(expect.any(String), 2000, undefined, remoteDevices); }); it('sends local device list in SYNC_VAULT_SEND', async () => { const remoteApply = vi.fn().mockResolvedValue(false); const [tInitiator, tResponder] = makePairedTransports(); const localDevices: SyncableDevice[] = [ { publicKeyHex: 'ddd', deviceName: 'Work Mac', pairedAt: 4000 }, ]; const initiatorDeps = makeDeps({ getVaultBlobForSync: async () => ({ blob: JSON.stringify({ a: 'vault' }), timestamp: 500, devices: localDevices, }), }); const responderDeps = makeDeps({ applyIncomingVault: remoteApply }); await Promise.all([ runBlobSyncSession('initiator', tInitiator, initiatorDeps), runBlobSyncSession('responder', tResponder, responderDeps), ]); // Responder received the initiator's vault which included local devices expect(remoteApply).toHaveBeenCalledWith(expect.any(String), 500, undefined, localDevices); }); });