1import { describe, it, expect, vi, beforeEach } from 'vitest';2import { runBlobSyncSession } from './sync-coordinator';3import type { SyncDeps } from './sync-coordinator';4import type { SyncTransport } from '../shared/websocket-sync-transport';5import { encodeSyncMessage } from '../shared/sync-wire';6import type { SyncableDevice } from '../shared/sync-types';78910function makePairedTransports(): [SyncTransport, SyncTransport] {11 const queueAtoB: Uint8Array[] = [];12 const queueBtoA: Uint8Array[] = [];13 const waitersAtoB: ((d: Uint8Array) => void)[] = [];14 const waitersBtoA: ((d: Uint8Array) => void)[] = [];1516 function push(queue: Uint8Array[], waiters: ((d: Uint8Array) => void)[], data: Uint8Array) {17 if (waiters.length > 0) waiters.shift()!(data);18 else queue.push(data);19 }2021 function pop(queue: Uint8Array[], waiters: ((d: Uint8Array) => void)[]): Promise<Uint8Array> {22 return new Promise(res => {23 if (queue.length > 0) res(queue.shift()!);24 else waiters.push(res);25 });26 }2728 const a: SyncTransport = {29 send: async (d) => push(queueAtoB, waitersAtoB, d),30 receive: () => pop(queueBtoA, waitersBtoA),31 close: async () => {},32 };33 const b: SyncTransport = {34 send: async (d) => push(queueBtoA, waitersBtoA, d),35 receive: () => pop(queueAtoB, waitersAtoB),36 close: async () => {},37 };38 return [a, b];39}4041const DEVICE_LIST: SyncableDevice[] = [42 { publicKeyHex: 'aaa', deviceName: 'iPhone', pairedAt: 1000 },43];4445function makeDeps(overrides: Partial<SyncDeps> = {}): SyncDeps {46 return {47 isVaultUnlocked: () => true,48 getVaultBlobForSync: async () => ({49 blob: JSON.stringify({ a: 'slotA', b: 'slotB' }),50 timestamp: 1000,51 devices: DEVICE_LIST,52 }),53 applyIncomingVault: async () => false,54 ...overrides,55 };56}5758describe('runBlobSyncSession', () => {59 it('initiator applies newer remote vault', async () => {60 const apply = vi.fn().mockResolvedValue(true);61 const [tInitiator, tResponder] = makePairedTransports();6263 64 const responderDeps = makeDeps({65 getVaultBlobForSync: async () => ({66 blob: JSON.stringify({ a: 'newer', b: null }),67 timestamp: 2000,68 devices: DEVICE_LIST,69 }),70 applyIncomingVault: async () => false,71 });72 const initiatorDeps = makeDeps({ applyIncomingVault: apply });7374 const [result] = await Promise.all([75 runBlobSyncSession('initiator', tInitiator, initiatorDeps),76 runBlobSyncSession('responder', tResponder, responderDeps),77 ]);7879 expect(result.status).toBe('completed');80 expect(result.applied).toBe(true);81 expect(apply).toHaveBeenCalledWith(82 expect.stringContaining('newer'),83 2000,84 undefined,85 DEVICE_LIST,86 );87 });8889 it('initiator keeps local vault when remote is older', async () => {90 const apply = vi.fn().mockResolvedValue(false);91 const [tInitiator, tResponder] = makePairedTransports();9293 const responderDeps = makeDeps({94 getVaultBlobForSync: async () => ({95 blob: JSON.stringify({ a: 'old' }),96 timestamp: 500, 97 devices: DEVICE_LIST,98 }),99 applyIncomingVault: async () => false,100 });101 const initiatorDeps = makeDeps({ applyIncomingVault: apply });102103 const [result] = await Promise.all([104 runBlobSyncSession('initiator', tInitiator, initiatorDeps),105 runBlobSyncSession('responder', tResponder, responderDeps),106 ]);107108 expect(result.status).toBe('completed');109 expect(result.applied).toBe(false);110 });111112 it('both sides exchange vaults bidirectionally', async () => {113 const applyI = vi.fn().mockResolvedValue(true);114 const applyR = vi.fn().mockResolvedValue(true);115 const [tInitiator, tResponder] = makePairedTransports();116117 const initiatorDeps = makeDeps({118 getVaultBlobForSync: async () => ({119 blob: JSON.stringify({ a: 'initiatorVault' }),120 timestamp: 1000,121 devices: DEVICE_LIST,122 }),123 applyIncomingVault: applyI,124 });125 const responderDeps = makeDeps({126 getVaultBlobForSync: async () => ({127 blob: JSON.stringify({ a: 'responderVault' }),128 timestamp: 2000,129 devices: DEVICE_LIST,130 }),131 applyIncomingVault: applyR,132 });133134 await Promise.all([135 runBlobSyncSession('initiator', tInitiator, initiatorDeps),136 runBlobSyncSession('responder', tResponder, responderDeps),137 ]);138139 140 expect(applyI).toHaveBeenCalledWith(expect.stringContaining('responderVault'), 2000, undefined, DEVICE_LIST);141 expect(applyR).toHaveBeenCalledWith(expect.stringContaining('initiatorVault'), 1000, undefined, DEVICE_LIST);142 });143144 it('returns skipped when vault is locked', async () => {145 const [t] = makePairedTransports();146 const deps = makeDeps({ isVaultUnlocked: () => false });147 const result = await runBlobSyncSession('initiator', t, deps);148 expect(result.status).toBe('skipped');149 expect(result.error).toBe('vault_locked');150 });151152 it('transfers Pro license when receiver has none', async () => {153 const apply = vi.fn().mockResolvedValue(true);154 const [tInitiator, tResponder] = makePairedTransports();155156 const responderDeps = makeDeps({157 getVaultBlobForSync: async () => ({158 blob: JSON.stringify({ a: 'vault' }),159 timestamp: 2000,160 license: 'PRO-LICENSE-KEY',161 devices: DEVICE_LIST,162 }),163 applyIncomingVault: async () => false,164 });165 const initiatorDeps = makeDeps({ applyIncomingVault: apply });166167 await Promise.all([168 runBlobSyncSession('initiator', tInitiator, initiatorDeps),169 runBlobSyncSession('responder', tResponder, responderDeps),170 ]);171172 expect(apply).toHaveBeenCalledWith(expect.any(String), 2000, 'PRO-LICENSE-KEY', DEVICE_LIST);173 });174175 it('does not override existing license when both sides have one', async () => {176 177 178 179 const apply = vi.fn().mockResolvedValue(false);180 const [tInitiator, tResponder] = makePairedTransports();181182 const responderDeps = makeDeps({183 getVaultBlobForSync: async () => ({184 blob: JSON.stringify({ a: 'vault' }),185 timestamp: 500,186 license: 'RESPONDER-LICENSE',187 devices: DEVICE_LIST,188 }),189 applyIncomingVault: async () => false,190 });191 const initiatorDeps = makeDeps({192 getVaultBlobForSync: async () => ({193 blob: JSON.stringify({ a: 'vault' }),194 timestamp: 1000,195 license: 'INITIATOR-LICENSE',196 devices: DEVICE_LIST,197 }),198 applyIncomingVault: apply,199 });200201 await Promise.all([202 runBlobSyncSession('initiator', tInitiator, initiatorDeps),203 runBlobSyncSession('responder', tResponder, responderDeps),204 ]);205206 207 expect(apply).toHaveBeenCalledWith(expect.any(String), 500, 'RESPONDER-LICENSE', DEVICE_LIST);208 });209});210211describe('mesh device registry propagation', () => {212 it('passes remote device list to applyIncomingVault', async () => {213 const apply = vi.fn().mockResolvedValue(true);214 const [tInitiator, tResponder] = makePairedTransports();215216 const remoteDevices: SyncableDevice[] = [217 { publicKeyHex: 'aaa', deviceName: 'iPhone', pairedAt: 1000 },218 { publicKeyHex: 'bbb', deviceName: 'MacBook', pairedAt: 2000 },219 { publicKeyHex: 'ccc', deviceName: 'iPad', pairedAt: 3000 },220 ];221222 const responderDeps = makeDeps({223 getVaultBlobForSync: async () => ({224 blob: JSON.stringify({ a: 'vault' }),225 timestamp: 2000,226 devices: remoteDevices,227 }),228 applyIncomingVault: async () => false,229 });230 const initiatorDeps = makeDeps({ applyIncomingVault: apply });231232 await Promise.all([233 runBlobSyncSession('initiator', tInitiator, initiatorDeps),234 runBlobSyncSession('responder', tResponder, responderDeps),235 ]);236237 expect(apply).toHaveBeenCalledWith(expect.any(String), 2000, undefined, remoteDevices);238 });239240 it('sends local device list in SYNC_VAULT_SEND', async () => {241 const remoteApply = vi.fn().mockResolvedValue(false);242 const [tInitiator, tResponder] = makePairedTransports();243244 const localDevices: SyncableDevice[] = [245 { publicKeyHex: 'ddd', deviceName: 'Work Mac', pairedAt: 4000 },246 ];247248 const initiatorDeps = makeDeps({249 getVaultBlobForSync: async () => ({250 blob: JSON.stringify({ a: 'vault' }),251 timestamp: 500,252 devices: localDevices,253 }),254 });255 const responderDeps = makeDeps({ applyIncomingVault: remoteApply });256257 await Promise.all([258 runBlobSyncSession('initiator', tInitiator, initiatorDeps),259 runBlobSyncSession('responder', tResponder, responderDeps),260 ]);261262 263 expect(remoteApply).toHaveBeenCalledWith(expect.any(String), 500, undefined, localDevices);264 });265});