sync-trigger.test.ts

13.5 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  hydratePersistedSyncState,10} from "./sync-trigger";1112function makeStorageStub() {13  const sync = new Map<string, unknown>();14  const local = new Map<string, unknown>();15  function area(store: Map<string, unknown>) {16    return {17      async get(key?: string | string[] | null) {18        if (key === null || key === undefined) {19          const all: Record<string, unknown> = {};20          for (const [k, v] of store) all[k] = v;21          return all;22        }23        const keys = Array.isArray(key) ? key : [key];24        const out: Record<string, unknown> = {};25        for (const k of keys) if (store.has(k)) out[k] = store.get(k);26        return out;27      },28      async set(values: Record<string, unknown>) {29        for (const [k, v] of Object.entries(values)) store.set(k, v);30      },31      async remove(key: string | string[]) {32        const keys = Array.isArray(key) ? key : [key];33        for (const k of keys) store.delete(k);34      },35    };36  }37  (globalThis as unknown as { chrome: unknown }).chrome = {38    storage: { sync: area(sync), local: area(local) },39    runtime: { sendMessage: () => Promise.resolve() },40  };41}4243const ANDROID: PairedDevice = {44  deviceId: "AAA-android",45  deviceName: "Pixel",46  deviceType: "android",47  longTermPublicKeyHex: "a".repeat(64),48  trustSource: "direct",49  pairedAt: 1700000000000,50};5152const IOS: PairedDevice = {53  deviceId: "BBB-ios",54  deviceName: "iPhone",55  deviceType: "ios",56  longTermPublicKeyHex: "b".repeat(64),57  trustSource: "direct",58  pairedAt: 1700000000000,59};6061const EXT: PairedDevice = {62  deviceId: "CCC-ext",63  deviceName: "Other Chrome",64  deviceType: "extension",65  longTermPublicKeyHex: "c".repeat(64),66  pairedAt: 1700000000000,67};6869describe("triggerSyncIfReachable", () => {70  beforeEach(() => {71    makeStorageStub();72    _resetSyncTriggerForTest();73  });7475  it("does not call the runner when no mobile hosts are paired", async () => {76    const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 }));77    setSyncRunner(runner);78    await upsertPairedDevice(EXT);79    await triggerSyncIfReachable({ reason: "manual" });80    expect(runner).not.toHaveBeenCalled();81  });8283  it("calls the runner once per mobile host on a single trigger", async () => {84    const seen: string[] = [];85    setSyncRunner(async (device) => {86      seen.push(device.deviceId);87      return { ok: true, entriesReceived: 1, entriesSent: 0 };88    });89    await upsertPairedDevice(ANDROID);90    await upsertPairedDevice(IOS);91    await upsertPairedDevice(EXT);92    await triggerSyncIfReachable({ reason: "manual" });93    expect(seen.sort()).toEqual([ANDROID.deviceId, IOS.deviceId]);94  });9596  it("marks lastSyncedAt on each host that completes successfully", async () => {97    setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));98    await upsertPairedDevice(ANDROID);99    await upsertPairedDevice(IOS);100    await triggerSyncIfReachable({ reason: "manual" });101    const a = await findPairedDeviceById(ANDROID.deviceId);102    const b = await findPairedDeviceById(IOS.deviceId);103    expect(typeof a?.lastSyncedAt).toBe("number");104    expect(typeof b?.lastSyncedAt).toBe("number");105  });106107  it("does not start a second sync if one is already in flight (non-force)", async () => {108    const runner = vi.fn(async () => {109      await new Promise<void>((r) => setTimeout(r, 50));110      return { ok: true as const, entriesReceived: 0, entriesSent: 0 };111    });112    setSyncRunner(runner);113    await upsertPairedDevice(ANDROID);114    const a = triggerSyncIfReachable({ reason: "vault_change" });115    const b = triggerSyncIfReachable({ reason: "vault_change" });116    await Promise.all([a, b]);117    expect(runner).toHaveBeenCalledTimes(1);118  });119120  it("force:true bypasses the syncInFlight guard for manual triggers", async () => {121    const runner = vi.fn(async () => {122      await new Promise<void>((r) => setTimeout(r, 30));123      return { ok: true as const, entriesReceived: 0, entriesSent: 0 };124    });125    setSyncRunner(runner);126    await upsertPairedDevice(ANDROID);127    const a = triggerSyncIfReachable({ reason: "vault_change" });128    const b = triggerSyncIfReachable({ reason: "manual", force: true });129    await Promise.all([a, b]);130    expect(runner).toHaveBeenCalledTimes(2);131  });132133  it("transitions to error state when no host succeeds", async () => {134    setSyncRunner(async () => {135      throw new Error("unreachable");136    });137    await upsertPairedDevice(ANDROID);138    await triggerSyncIfReachable({ reason: "manual" });139    expect(getSyncState().status).toBe("error");140    expect(getSyncState().error).toBe("unreachable");141  });142143  it("returns to idle state on success with aggregated entry counts", async () => {144    setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 2 }));145    await upsertPairedDevice(ANDROID);146    await upsertPairedDevice(IOS);147    await triggerSyncIfReachable({ reason: "manual" });148    const s = getSyncState();149    expect(s.status).toBe("idle");150    expect(s.entriesReceived).toBe(6);151    expect(s.entriesSent).toBe(4);152    expect(typeof s.lastSyncAt).toBe("number");153  });154155  it("syncInFlight is cleared in finally even when runner throws", async () => {156    setSyncRunner(async () => { throw new Error("runner exploded"); });157    await upsertPairedDevice(ANDROID);158    await triggerSyncIfReachable({ reason: "manual", force: true });159    // After the trigger resolves, a second non-force trigger must be able to run.160    let ran = false;161    setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });162    await triggerSyncIfReachable({ reason: "vault_change" });163    expect(ran).toBe(true);164  });165166  it("syncs a vouched device — mesh introduction is the point of vouching", async () => {167    // A device only reaches the registry as "vouched" after the user confirms168    // it out of the MV-7 pending queue (CONFIRM_VOUCHED_DEVICE). Skipping it169    // here made that confirmation a no-op: the device was listed but never170    // synced. An UNconfirmed device is still never a target — it is absent171    // from the registry entirely, which the vault-adapter specs cover.172    const vouched: PairedDevice = {173      ...ANDROID,174      deviceId: "VVV-vouched",175      trustSource: "vouched",176    };177    const seen: string[] = [];178    setSyncRunner(async (d) => { seen.push(d.deviceId); return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });179    await upsertPairedDevice(vouched);180    await triggerSyncIfReachable({ reason: "foreground" });181    expect(seen).toEqual(["VVV-vouched"]);182  });183184  it("never treats another extension as a sync host — it cannot listen", async () => {185    let ran = false;186    setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });187    await upsertPairedDevice({188      ...ANDROID,189      deviceId: "EXT-other",190      deviceType: "extension",191      trustSource: "direct",192    });193    await triggerSyncIfReachable({ reason: "foreground" });194    expect(ran).toBe(false);195  });196197  it("persists peach_last_sync_result to storage after a successful sync", async () => {198    setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 1 }));199    await upsertPairedDevice(ANDROID);200    await triggerSyncIfReachable({ reason: "manual" });201    // Read the raw storage value to confirm persistence.202    const raw = await (globalThis as unknown as { chrome: { storage: { local: { get(k: string): Promise<Record<string, unknown>> } } } })203      .chrome.storage.local.get("peach_last_sync_result");204    expect(raw.peach_last_sync_result).toMatchObject({205      outcome: "completed_changes",206      entriesReceived: 3,207      entriesSent: 1,208    });209    expect(typeof (raw.peach_last_sync_result as { at: unknown }).at).toBe("number");210  });211212  // Every terminating path must be distinguishable. Before this, "nobody to213  // sync with", "runner not wired" and "synced, nothing to do" were all a bare214  // `return` — the user and the logs saw the same nothing in all three cases.215  describe("reported outcomes", () => {216    it("reports no_paired_devices when the registry is empty", async () => {217      setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));218      await triggerSyncIfReachable({ reason: "manual" });219      const s = getSyncState();220      expect(s.outcome).toBe("no_paired_devices");221      expect(s.status).toBe("idle");222      expect(typeof s.finishedAt).toBe("number");223    });224225    it("reports no_eligible_host naming the deviceType and trustSource found", async () => {226      // This is the diagnostic that matters on real hardware: a device that227      // paired fine but whose type/trust puts it outside the host filter.228      setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));229      await upsertPairedDevice({ ...EXT, deviceName: "Other Chrome" });230      await upsertPairedDevice({231        ...ANDROID,232        deviceId: "untrusted-android",233        deviceName: "Pixel",234        trustSource: undefined,235      });236      await triggerSyncIfReachable({ reason: "manual" });237      const s = getSyncState();238      expect(s.outcome).toBe("no_eligible_host");239      expect(s.status).toBe("error");240      expect(s.detail).toContain("Other Chrome [type=extension, trust=unset]");241      expect(s.detail).toContain("Pixel [type=android, trust=unset]");242    });243244    it("reports runner_unavailable when the runner slot is never wired", async () => {245      setSyncRunner(null);246      await upsertPairedDevice(ANDROID);247      await triggerSyncIfReachable({ reason: "manual" });248      const s = getSyncState();249      expect(s.outcome).toBe("runner_unavailable");250      expect(s.status).toBe("error");251    });252253    it("distinguishes completed_no_changes from a failure", async () => {254      setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));255      await upsertPairedDevice(ANDROID);256      await triggerSyncIfReachable({ reason: "manual" });257      const s = getSyncState();258      expect(s.outcome).toBe("completed_no_changes");259      expect(s.status).toBe("idle");260      expect(s.error).toBeUndefined();261    });262263    it("surfaces a runner throw as its own message rather than swallowing it", async () => {264      setSyncRunner(async () => {265        throw new Error("relay_sync_failed:WebSocket connect timed out");266      });267      await upsertPairedDevice(ANDROID);268      await triggerSyncIfReachable({ reason: "manual" });269      const s = getSyncState();270      expect(s.outcome).toBe("relay_unreachable");271      expect(s.detail).toBe("relay_sync_failed:WebSocket connect timed out");272      // relay_unreachable is now a network-class warning, not a failure — the273      // detail carries the diagnostic but `error` stays undefined so the274      // indicator does not turn red for what is a transient miss.275      expect(s.error).toBeUndefined();276    });277278    it.each([279      ["sync_disabled", "sync_disabled"],280      ["private_relay_unset", "private_relay_unset"],281      ["vault_id_mismatch", "vault_id_mismatch"],282      ["sync_timeout", "sync_timeout"],283      ["VAULT_LOCKED", "vault_locked"],284      ["relay_sync_failed:sync_handshake_timeout", "handshake_failed"],285      ["something nobody predicted", "run_failed"],286    ])("classifies runner error %s as %s", async (error, outcome) => {287      setSyncRunner(async () => ({ ok: false, error }));288      await upsertPairedDevice(ANDROID);289      await triggerSyncIfReachable({ reason: "manual", force: true });290      expect(getSyncState().outcome).toBe(outcome);291    });292293    it("keeps the in-flight run's state when a concurrent trigger is dropped", async () => {294      setSyncRunner(async () => {295        await new Promise<void>((r) => setTimeout(r, 30));296        return { ok: true as const, entriesReceived: 0, entriesSent: 0 };297      });298      await upsertPairedDevice(ANDROID);299      const first = triggerSyncIfReachable({ reason: "manual" });300      while (getSyncState().status !== "syncing") await Promise.resolve();301      await triggerSyncIfReachable({ reason: "vault_change" });302      // The dropped trigger must not stamp a terminal outcome over a live run.303      expect(getSyncState().status).toBe("syncing");304      await first;305      expect(getSyncState().outcome).toBe("completed_no_changes");306    });307308    it("restores the last outcome across a SW restart so a failure stays inspectable", async () => {309      setSyncRunner(async () => ({ ok: false, error: "vault_id_mismatch" }));310      await upsertPairedDevice(ANDROID);311      await triggerSyncIfReachable({ reason: "manual" });312      _resetSyncTriggerForTest();313      expect(getSyncState().outcome).toBeUndefined();314      await hydratePersistedSyncState();315      const s = getSyncState();316      expect(s.outcome).toBe("vault_id_mismatch");317      expect(s.detail).toBe("vault_id_mismatch");318      expect(typeof s.finishedAt).toBe("number");319    });320  });321322  it("hydratePersistedSyncState restores lastSyncAt across SW restart simulation", async () => {323    // Write a value directly to storage, then hydrate — simulates SW restart.324    await (globalThis as unknown as { chrome: { storage: { local: { set(v: Record<string, unknown>): Promise<void> } } } })325      .chrome.storage.local.set({ peach_last_sync_at: 1_700_005_000_000 });326    _resetSyncTriggerForTest();327    expect(getSyncState().lastSyncAt).toBeUndefined();328    await hydratePersistedSyncState();329    expect(getSyncState().lastSyncAt).toBe(1_700_005_000_000);330  });331});