sync-trigger.test.ts

5.2 KB · raw

1import { describe, expect, it, beforeEach, vi } from "vitest";2import type { PairedDevice } from "../shared/sync-types";3import { upsertPairedDevice, findPairedDeviceById } from "./paired-devices";4import {5  triggerSyncIfReachable,6  setSyncRunner,7  _resetSyncTriggerForTest,8  getSyncState,9} from "./sync-trigger";1011function makeStorageStub() {12  const sync = new Map<string, unknown>();13  const local = new Map<string, unknown>();14  function area(store: Map<string, unknown>) {15    return {16      async get(key?: string | string[] | null) {17        if (key === null || key === undefined) {18          const all: Record<string, unknown> = {};19          for (const [k, v] of store) all[k] = v;20          return all;21        }22        const keys = Array.isArray(key) ? key : [key];23        const out: Record<string, unknown> = {};24        for (const k of keys) if (store.has(k)) out[k] = store.get(k);25        return out;26      },27      async set(values: Record<string, unknown>) {28        for (const [k, v] of Object.entries(values)) store.set(k, v);29      },30      async remove(key: string | string[]) {31        const keys = Array.isArray(key) ? key : [key];32        for (const k of keys) store.delete(k);33      },34    };35  }36  (globalThis as unknown as { chrome: unknown }).chrome = {37    storage: { sync: area(sync), local: area(local) },38    runtime: { sendMessage: () => Promise.resolve() },39  };40}4142const ANDROID: PairedDevice = {43  deviceId: "AAA-android",44  deviceName: "Pixel",45  deviceType: "android",46  longTermPublicKeyHex: "a".repeat(64),47  pairedAt: 1700000000000,48};4950const IOS: PairedDevice = {51  deviceId: "BBB-ios",52  deviceName: "iPhone",53  deviceType: "ios",54  longTermPublicKeyHex: "b".repeat(64),55  pairedAt: 1700000000000,56};5758const EXT: PairedDevice = {59  deviceId: "CCC-ext",60  deviceName: "Other Chrome",61  deviceType: "extension",62  longTermPublicKeyHex: "c".repeat(64),63  pairedAt: 1700000000000,64};6566describe("triggerSyncIfReachable", () => {67  beforeEach(() => {68    makeStorageStub();69    _resetSyncTriggerForTest();70  });7172  it("does not call the runner when no mobile hosts are paired", async () => {73    await upsertPairedDevice(EXT);74    const runner = vi.fn(async () => ({ entriesReceived: 0, entriesSent: 0 }));75    setSyncRunner(runner);76    await triggerSyncIfReachable({ reason: "manual" });77    expect(runner).not.toHaveBeenCalled();78  });7980  it("calls the runner once per mobile host on a single trigger", async () => {81    await upsertPairedDevice(ANDROID);82    await upsertPairedDevice(IOS);83    await upsertPairedDevice(EXT);84    const seen: string[] = [];85    setSyncRunner(async (device) => {86      seen.push(device.deviceId);87      return { entriesReceived: 1, entriesSent: 0 };88    });89    await triggerSyncIfReachable({ reason: "manual" });90    expect(seen.sort()).toEqual([ANDROID.deviceId, IOS.deviceId]);91  });9293  it("marks lastSyncedAt on each host that completes successfully", async () => {94    await upsertPairedDevice(ANDROID);95    await upsertPairedDevice(IOS);96    setSyncRunner(async () => ({ entriesReceived: 0, entriesSent: 0 }));97    await triggerSyncIfReachable({ reason: "manual" });98    const a = await findPairedDeviceById(ANDROID.deviceId);99    const b = await findPairedDeviceById(IOS.deviceId);100    expect(typeof a?.lastSyncedAt).toBe("number");101    expect(typeof b?.lastSyncedAt).toBe("number");102  });103104  it("does not start a second sync if one is already in flight (non-force)", async () => {105    await upsertPairedDevice(ANDROID);106    const runner = vi.fn(async () => {107      await new Promise<void>((r) => setTimeout(r, 50));108      return { entriesReceived: 0, entriesSent: 0 };109    });110    setSyncRunner(runner);111    const a = triggerSyncIfReachable({ reason: "vault_change" });112    const b = triggerSyncIfReachable({ reason: "vault_change" });113    await Promise.all([a, b]);114    expect(runner).toHaveBeenCalledTimes(1);115  });116117  it("force:true bypasses the syncInFlight guard for manual triggers", async () => {118    await upsertPairedDevice(ANDROID);119    const runner = vi.fn(async () => {120      await new Promise<void>((r) => setTimeout(r, 30));121      return { entriesReceived: 0, entriesSent: 0 };122    });123    setSyncRunner(runner);124    const a = triggerSyncIfReachable({ reason: "vault_change" });125    const b = triggerSyncIfReachable({ reason: "manual", force: true });126    await Promise.all([a, b]);127    expect(runner).toHaveBeenCalledTimes(2);128  });129130  it("transitions to error state when no host succeeds", async () => {131    await upsertPairedDevice(ANDROID);132    setSyncRunner(async () => {133      throw new Error("unreachable");134    });135    await triggerSyncIfReachable({ reason: "manual" });136    expect(getSyncState().status).toBe("error");137    expect(getSyncState().error).toBe("unreachable");138  });139140  it("returns to idle state on success with aggregated entry counts", async () => {141    await upsertPairedDevice(ANDROID);142    await upsertPairedDevice(IOS);143    setSyncRunner(async () => ({ entriesReceived: 3, entriesSent: 2 }));144    await triggerSyncIfReachable({ reason: "manual" });145    const s = getSyncState();146    expect(s.status).toBe("idle");147    expect(s.entriesReceived).toBe(6);148    expect(s.entriesSent).toBe(4);149    expect(typeof s.lastSyncAt).toBe("number");150  });151});