1import { describe, it, expect, vi } from 'vitest';2import { runBlobSyncSession } from './sync-coordinator';3import type { SyncDeps } from './sync-coordinator';4import type { SyncTransport } from '../shared/websocket-sync-transport';5import type { SyncableDevice } from '../shared/sync-types';6789function makePairedTransports(): [SyncTransport, SyncTransport] {10 const queueAtoB: Uint8Array[] = [];11 const queueBtoA: Uint8Array[] = [];12 const waitersAtoB: ((d: Uint8Array) => void)[] = [];13 const waitersBtoA: ((d: Uint8Array) => void)[] = [];1415 function push(queue: Uint8Array[], waiters: ((d: Uint8Array) => void)[], data: Uint8Array) {16 if (waiters.length > 0) waiters.shift()!(data);17 else queue.push(data);18 }1920 function pop(queue: Uint8Array[], waiters: ((d: Uint8Array) => void)[]): Promise<Uint8Array> {21 return new Promise(res => {22 if (queue.length > 0) res(queue.shift()!);23 else waiters.push(res);24 });25 }2627 const a: SyncTransport = {28 send: async (d) => push(queueAtoB, waitersAtoB, d),29 receive: () => pop(queueBtoA, waitersBtoA),30 close: async () => {},31 };32 const b: SyncTransport = {33 send: async (d) => push(queueBtoA, waitersBtoA, d),34 receive: () => pop(queueAtoB, waitersAtoB),35 close: async () => {},36 };37 return [a, b];38}3940const DEVICE_LIST: SyncableDevice[] = [41 { publicKeyHex: 'aaa', deviceName: 'iPhone', pairedAt: 1000 },42];4344function makeDeps(overrides: Partial<SyncDeps> = {}): SyncDeps {45 return {46 isVaultUnlocked: () => true,47 getVaultBlobForSync: async () => ({48 blob: JSON.stringify({ a: 'slotA', b: 'slotB' }),49 timestamp: 1000,50 devices: DEVICE_LIST,51 }),52 applyIncomingVault: async () => false,53 ...overrides,54 };55}5657describe('runBlobSyncSession', () => {58 it('initiator applies newer remote vault', async () => {59 const apply = vi.fn().mockResolvedValue(true);60 const [tInitiator, tResponder] = makePairedTransports();6162 63 const responderDeps = makeDeps({64 getVaultBlobForSync: async () => ({65 blob: JSON.stringify({ a: 'newer', b: null }),66 timestamp: 2000,67 devices: DEVICE_LIST,68 }),69 applyIncomingVault: async () => false,70 });71 const initiatorDeps = makeDeps({ applyIncomingVault: apply });7273 const [result] = await Promise.all([74 runBlobSyncSession('initiator', tInitiator, initiatorDeps),75 runBlobSyncSession('responder', tResponder, responderDeps),76 ]);7778 expect(result.status).toBe('completed');79 expect(result.applied).toBe(true);80 expect(apply).toHaveBeenCalledWith(81 expect.stringContaining('newer'),82 2000,83 undefined,84 DEVICE_LIST,85 );86 });8788 it('initiator keeps local vault when remote is older', async () => {89 const apply = vi.fn().mockResolvedValue(false);90 const [tInitiator, tResponder] = makePairedTransports();9192 const responderDeps = makeDeps({93 getVaultBlobForSync: async () => ({94 blob: JSON.stringify({ a: 'old' }),95 timestamp: 500, 96 devices: DEVICE_LIST,97 }),98 applyIncomingVault: async () => false,99 });100 const initiatorDeps = makeDeps({ applyIncomingVault: apply });101102 const [result] = await Promise.all([103 runBlobSyncSession('initiator', tInitiator, initiatorDeps),104 runBlobSyncSession('responder', tResponder, responderDeps),105 ]);106107 expect(result.status).toBe('completed');108 expect(result.applied).toBe(false);109 });110111 it('both sides exchange vaults bidirectionally', async () => {112 const applyI = vi.fn().mockResolvedValue(true);113 const applyR = vi.fn().mockResolvedValue(true);114 const [tInitiator, tResponder] = makePairedTransports();115116 const initiatorDeps = makeDeps({117 getVaultBlobForSync: async () => ({118 blob: JSON.stringify({ a: 'initiatorVault' }),119 timestamp: 1000,120 devices: DEVICE_LIST,121 }),122 applyIncomingVault: applyI,123 });124 const responderDeps = makeDeps({125 getVaultBlobForSync: async () => ({126 blob: JSON.stringify({ a: 'responderVault' }),127 timestamp: 2000,128 devices: DEVICE_LIST,129 }),130 applyIncomingVault: applyR,131 });132133 await Promise.all([134 runBlobSyncSession('initiator', tInitiator, initiatorDeps),135 runBlobSyncSession('responder', tResponder, responderDeps),136 ]);137138 139 expect(applyI).toHaveBeenCalledWith(expect.stringContaining('responderVault'), 2000, undefined, DEVICE_LIST);140 expect(applyR).toHaveBeenCalledWith(expect.stringContaining('initiatorVault'), 1000, undefined, DEVICE_LIST);141 });142143 it('returns skipped when vault is locked', async () => {144 const [t] = makePairedTransports();145 const deps = makeDeps({ isVaultUnlocked: () => false });146 const result = await runBlobSyncSession('initiator', t, deps);147 expect(result.status).toBe('skipped');148 expect(result.error).toBe('vault_locked');149 });150151 it('transfers Pro license when receiver has none', async () => {152 const apply = vi.fn().mockResolvedValue(true);153 const [tInitiator, tResponder] = makePairedTransports();154155 const responderDeps = makeDeps({156 getVaultBlobForSync: async () => ({157 blob: JSON.stringify({ a: 'vault' }),158 timestamp: 2000,159 license: 'PRO-LICENSE-KEY',160 devices: DEVICE_LIST,161 }),162 applyIncomingVault: async () => false,163 });164 const initiatorDeps = makeDeps({ applyIncomingVault: apply });165166 await Promise.all([167 runBlobSyncSession('initiator', tInitiator, initiatorDeps),168 runBlobSyncSession('responder', tResponder, responderDeps),169 ]);170171 expect(apply).toHaveBeenCalledWith(expect.any(String), 2000, 'PRO-LICENSE-KEY', DEVICE_LIST);172 });173174 it('does not override existing license when both sides have one', async () => {175 176 177 178 const apply = vi.fn().mockResolvedValue(false);179 const [tInitiator, tResponder] = makePairedTransports();180181 const responderDeps = makeDeps({182 getVaultBlobForSync: async () => ({183 blob: JSON.stringify({ a: 'vault' }),184 timestamp: 500,185 license: 'RESPONDER-LICENSE',186 devices: DEVICE_LIST,187 }),188 applyIncomingVault: async () => false,189 });190 const initiatorDeps = makeDeps({191 getVaultBlobForSync: async () => ({192 blob: JSON.stringify({ a: 'vault' }),193 timestamp: 1000,194 license: 'INITIATOR-LICENSE',195 devices: DEVICE_LIST,196 }),197 applyIncomingVault: apply,198 });199200 await Promise.all([201 runBlobSyncSession('initiator', tInitiator, initiatorDeps),202 runBlobSyncSession('responder', tResponder, responderDeps),203 ]);204205 206 expect(apply).toHaveBeenCalledWith(expect.any(String), 500, 'RESPONDER-LICENSE', DEVICE_LIST);207 });208});209210describe('mesh device registry propagation', () => {211 it('passes remote device list to applyIncomingVault', async () => {212 const apply = vi.fn().mockResolvedValue(true);213 const [tInitiator, tResponder] = makePairedTransports();214215 const remoteDevices: SyncableDevice[] = [216 { publicKeyHex: 'aaa', deviceName: 'iPhone', pairedAt: 1000 },217 { publicKeyHex: 'bbb', deviceName: 'MacBook', pairedAt: 2000 },218 { publicKeyHex: 'ccc', deviceName: 'iPad', pairedAt: 3000 },219 ];220221 const responderDeps = makeDeps({222 getVaultBlobForSync: async () => ({223 blob: JSON.stringify({ a: 'vault' }),224 timestamp: 2000,225 devices: remoteDevices,226 }),227 applyIncomingVault: async () => false,228 });229 const initiatorDeps = makeDeps({ applyIncomingVault: apply });230231 await Promise.all([232 runBlobSyncSession('initiator', tInitiator, initiatorDeps),233 runBlobSyncSession('responder', tResponder, responderDeps),234 ]);235236 expect(apply).toHaveBeenCalledWith(expect.any(String), 2000, undefined, remoteDevices);237 });238239 it('sends local device list in SYNC_VAULT_SEND', async () => {240 const remoteApply = vi.fn().mockResolvedValue(false);241 const [tInitiator, tResponder] = makePairedTransports();242243 const localDevices: SyncableDevice[] = [244 { publicKeyHex: 'ddd', deviceName: 'Work Mac', pairedAt: 4000 },245 ];246247 const initiatorDeps = makeDeps({248 getVaultBlobForSync: async () => ({249 blob: JSON.stringify({ a: 'vault' }),250 timestamp: 500,251 devices: localDevices,252 }),253 });254 const responderDeps = makeDeps({ applyIncomingVault: remoteApply });255256 await Promise.all([257 runBlobSyncSession('initiator', tInitiator, initiatorDeps),258 runBlobSyncSession('responder', tResponder, responderDeps),259 ]);260261 262 expect(remoteApply).toHaveBeenCalledWith(expect.any(String), 500, undefined, localDevices);263 });264});