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});