sync-coordinator.test.ts

9.2 KB · raw

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';78// Builds a paired in-memory transport — one write on side A becomes a read on9// side B and vice versa. Returns [transportA, transportB].10function 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    // Responder side: sends a newer vault (timestamp 2000 > initiator's 1000)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, // older97        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    // Initiator received responder's vault (newer), responder received initiator's (older)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    // Both sides have a license — the responder's apply function receives177    // the initiator's license but this is handled by vault.applyIncomingVault178    // which skips overwriting. The coordinator just passes it through.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    // Initiator's apply receives 'RESPONDER-LICENSE' — vault.ts decides not to overwrite207    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    // Responder received the initiator's vault which included local devices263    expect(remoteApply).toHaveBeenCalledWith(expect.any(String), 500, undefined, localDevices);264  });265});