sync-trigger.test.ts

14.1 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  setSyncDialTargetSelector,7  setSyncRunner,8  _resetSyncTriggerForTest,9  getSyncState,10  hydratePersistedSyncState,11} from "./sync-trigger";1213function makeStorageStub() {14  const sync = new Map<string, unknown>();15  const local = new Map<string, unknown>();16  function area(store: Map<string, unknown>) {17    return {18      async get(key?: string | string[] | null) {19        if (key === null || key === undefined) {20          const all: Record<string, unknown> = {};21          for (const [k, v] of store) all[k] = v;22          return all;23        }24        const keys = Array.isArray(key) ? key : [key];25        const out: Record<string, unknown> = {};26        for (const k of keys) if (store.has(k)) out[k] = store.get(k);27        return out;28      },29      async set(values: Record<string, unknown>) {30        for (const [k, v] of Object.entries(values)) store.set(k, v);31      },32      async remove(key: string | string[]) {33        const keys = Array.isArray(key) ? key : [key];34        for (const k of keys) store.delete(k);35      },36    };37  }38  (globalThis as unknown as { chrome: unknown }).chrome = {39    storage: { sync: area(sync), local: area(local) },40    runtime: { sendMessage: () => Promise.resolve() },41  };42}4344const ANDROID: PairedDevice = {45  deviceId: "AAA-android",46  deviceName: "Pixel",47  deviceType: "android",48  longTermPublicKeyHex: "a".repeat(64),49  trustSource: "direct",50  pairedAt: 1700000000000,51};5253const IOS: PairedDevice = {54  deviceId: "BBB-ios",55  deviceName: "iPhone",56  deviceType: "ios",57  longTermPublicKeyHex: "b".repeat(64),58  trustSource: "direct",59  pairedAt: 1700000000000,60};6162const EXT: PairedDevice = {63  deviceId: "CCC-ext",64  deviceName: "Other Chrome",65  deviceType: "extension",66  longTermPublicKeyHex: "c".repeat(64),67  pairedAt: 1700000000000,68};6970describe("triggerSyncIfReachable", () => {71  beforeEach(() => {72    makeStorageStub();73    _resetSyncTriggerForTest();74  });7576  it("does not call the runner when no mobile hosts are paired", async () => {77    const runner = vi.fn(async () => ({ ok: true as const, entriesReceived: 0, entriesSent: 0 }));78    setSyncRunner(runner);79    await upsertPairedDevice(EXT);80    await triggerSyncIfReachable({ reason: "manual" });81    expect(runner).not.toHaveBeenCalled();82  });8384  it("calls the runner once per mobile host on a single trigger", async () => {85    const seen: string[] = [];86    setSyncRunner(async (device) => {87      seen.push(device.deviceId);88      return { ok: true, entriesReceived: 1, entriesSent: 0 };89    });90    await upsertPairedDevice(ANDROID);91    await upsertPairedDevice(IOS);92    await upsertPairedDevice(EXT);93    await triggerSyncIfReachable({ reason: "manual" });94    expect(seen.sort()).toEqual([ANDROID.deviceId, IOS.deviceId]);95  });9697  it("marks lastSyncedAt on each host that completes successfully", async () => {98    setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));99    await upsertPairedDevice(ANDROID);100    await upsertPairedDevice(IOS);101    await triggerSyncIfReachable({ reason: "manual" });102    const a = await findPairedDeviceById(ANDROID.deviceId);103    const b = await findPairedDeviceById(IOS.deviceId);104    expect(typeof a?.lastSyncedAt).toBe("number");105    expect(typeof b?.lastSyncedAt).toBe("number");106  });107108  it("does not start a second sync if one is already in flight (non-force)", async () => {109    const runner = vi.fn(async () => {110      await new Promise<void>((r) => setTimeout(r, 50));111      return { ok: true as const, entriesReceived: 0, entriesSent: 0 };112    });113    setSyncRunner(runner);114    await upsertPairedDevice(ANDROID);115    const a = triggerSyncIfReachable({ reason: "vault_change" });116    const b = triggerSyncIfReachable({ reason: "vault_change" });117    await Promise.all([a, b]);118    expect(runner).toHaveBeenCalledTimes(1);119  });120121  it("force:true bypasses the syncInFlight guard for manual triggers", async () => {122    const runner = vi.fn(async () => {123      await new Promise<void>((r) => setTimeout(r, 30));124      return { ok: true as const, entriesReceived: 0, entriesSent: 0 };125    });126    setSyncRunner(runner);127    await upsertPairedDevice(ANDROID);128    const a = triggerSyncIfReachable({ reason: "vault_change" });129    const b = triggerSyncIfReachable({ reason: "manual", force: true });130    await Promise.all([a, b]);131    expect(runner).toHaveBeenCalledTimes(2);132  });133134  it("transitions to error state when no host succeeds", async () => {135    setSyncRunner(async () => {136      throw new Error("unreachable");137    });138    await upsertPairedDevice(ANDROID);139    await triggerSyncIfReachable({ reason: "manual" });140    expect(getSyncState().status).toBe("error");141    expect(getSyncState().error).toBe("unreachable");142  });143144  it("returns to idle state on success with aggregated entry counts", async () => {145    setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 2 }));146    await upsertPairedDevice(ANDROID);147    await upsertPairedDevice(IOS);148    await triggerSyncIfReachable({ reason: "manual" });149    const s = getSyncState();150    expect(s.status).toBe("idle");151    expect(s.entriesReceived).toBe(6);152    expect(s.entriesSent).toBe(4);153    expect(typeof s.lastSyncAt).toBe("number");154  });155156  it("syncInFlight is cleared in finally even when runner throws", async () => {157    setSyncRunner(async () => { throw new Error("runner exploded"); });158    await upsertPairedDevice(ANDROID);159    await triggerSyncIfReachable({ reason: "manual", force: true });160    // After the trigger resolves, a second non-force trigger must be able to run.161    let ran = false;162    setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });163    await triggerSyncIfReachable({ reason: "vault_change" });164    expect(ran).toBe(true);165  });166167  it("syncs a vouched device — mesh introduction is the point of vouching", async () => {168    // A device only reaches the registry as "vouched" after the user confirms169    // it out of the MV-7 pending queue (CONFIRM_VOUCHED_DEVICE). Skipping it170    // here made that confirmation a no-op: the device was listed but never171    // synced. An UNconfirmed device is still never a target — it is absent172    // from the registry entirely, which the vault-adapter specs cover.173    const vouched: PairedDevice = {174      ...ANDROID,175      deviceId: "VVV-vouched",176      trustSource: "vouched",177    };178    const seen: string[] = [];179    setSyncRunner(async (d) => { seen.push(d.deviceId); return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });180    await upsertPairedDevice(vouched);181    await triggerSyncIfReachable({ reason: "foreground" });182    expect(seen).toEqual(["VVV-vouched"]);183  });184185  it("keeps the legacy no-selector test fallback limited to native hosts", async () => {186    let ran = false;187    setSyncRunner(async () => { ran = true; return { ok: true as const, entriesReceived: 0, entriesSent: 0 }; });188    await upsertPairedDevice({189      ...ANDROID,190      deviceId: "EXT-other",191      deviceType: "extension",192      trustSource: "direct",193    });194    await triggerSyncIfReachable({ reason: "foreground" });195    expect(ran).toBe(false);196  });197198  it("lets the production role selector choose an extension dial target", async () => {199    const seen: string[] = [];200    setSyncRunner(async (device) => {201      seen.push(device.deviceId);202      return { ok: true as const, entriesReceived: 0, entriesSent: 0 };203    });204    setSyncDialTargetSelector(async (devices) =>205      devices.filter((device) => device.deviceType === "extension")206    );207    await upsertPairedDevice({208      ...EXT,209      trustSource: "vouched",210    });211212    await triggerSyncIfReachable({ reason: "foreground" });213214    expect(seen).toEqual(["CCC-ext"]);215  });216217  it("persists peach_last_sync_result to storage after a successful sync", async () => {218    setSyncRunner(async () => ({ ok: true, entriesReceived: 3, entriesSent: 1 }));219    await upsertPairedDevice(ANDROID);220    await triggerSyncIfReachable({ reason: "manual" });221    // Read the raw storage value to confirm persistence.222    const raw = await (globalThis as unknown as { chrome: { storage: { local: { get(k: string): Promise<Record<string, unknown>> } } } })223      .chrome.storage.local.get("peach_last_sync_result");224    expect(raw.peach_last_sync_result).toMatchObject({225      outcome: "completed_changes",226      entriesReceived: 3,227      entriesSent: 1,228    });229    expect(typeof (raw.peach_last_sync_result as { at: unknown }).at).toBe("number");230  });231232  // Every terminating path must be distinguishable. Before this, "nobody to233  // sync with", "runner not wired" and "synced, nothing to do" were all a bare234  // `return` — the user and the logs saw the same nothing in all three cases.235  describe("reported outcomes", () => {236    it("reports no_paired_devices when the registry is empty", async () => {237      setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));238      await triggerSyncIfReachable({ reason: "manual" });239      const s = getSyncState();240      expect(s.outcome).toBe("no_paired_devices");241      expect(s.status).toBe("idle");242      expect(typeof s.finishedAt).toBe("number");243    });244245    it("reports no_eligible_host naming the deviceType and trustSource found", async () => {246      // This is the diagnostic that matters on real hardware: a device that247      // paired fine but whose type/trust puts it outside the host filter.248      setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));249      await upsertPairedDevice({ ...EXT, deviceName: "Other Chrome" });250      await upsertPairedDevice({251        ...ANDROID,252        deviceId: "untrusted-android",253        deviceName: "Pixel",254        trustSource: undefined,255      });256      await triggerSyncIfReachable({ reason: "manual" });257      const s = getSyncState();258      expect(s.outcome).toBe("no_eligible_host");259      expect(s.status).toBe("error");260      expect(s.detail).toContain("Other Chrome [type=extension, trust=unset]");261      expect(s.detail).toContain("Pixel [type=android, trust=unset]");262    });263264    it("reports runner_unavailable when the runner slot is never wired", async () => {265      setSyncRunner(null);266      await upsertPairedDevice(ANDROID);267      await triggerSyncIfReachable({ reason: "manual" });268      const s = getSyncState();269      expect(s.outcome).toBe("runner_unavailable");270      expect(s.status).toBe("error");271    });272273    it("distinguishes completed_no_changes from a failure", async () => {274      setSyncRunner(async () => ({ ok: true, entriesReceived: 0, entriesSent: 0 }));275      await upsertPairedDevice(ANDROID);276      await triggerSyncIfReachable({ reason: "manual" });277      const s = getSyncState();278      expect(s.outcome).toBe("completed_no_changes");279      expect(s.status).toBe("idle");280      expect(s.error).toBeUndefined();281    });282283    it("surfaces a runner throw as its own message rather than swallowing it", async () => {284      setSyncRunner(async () => {285        throw new Error("relay_sync_failed:WebSocket connect timed out");286      });287      await upsertPairedDevice(ANDROID);288      await triggerSyncIfReachable({ reason: "manual" });289      const s = getSyncState();290      expect(s.outcome).toBe("relay_unreachable");291      expect(s.detail).toBe("relay_sync_failed:WebSocket connect timed out");292      // relay_unreachable is now a network-class warning, not a failure — the293      // detail carries the diagnostic but `error` stays undefined so the294      // indicator does not turn red for what is a transient miss.295      expect(s.error).toBeUndefined();296    });297298    it.each([299      ["sync_disabled", "sync_disabled"],300      ["private_relay_unset", "private_relay_unset"],301      ["vault_id_mismatch", "vault_id_mismatch"],302      ["sync_timeout", "sync_timeout"],303      ["VAULT_LOCKED", "vault_locked"],304      ["relay_sync_failed:sync_handshake_timeout", "handshake_failed"],305      ["something nobody predicted", "run_failed"],306    ])("classifies runner error %s as %s", async (error, outcome) => {307      setSyncRunner(async () => ({ ok: false, error }));308      await upsertPairedDevice(ANDROID);309      await triggerSyncIfReachable({ reason: "manual", force: true });310      expect(getSyncState().outcome).toBe(outcome);311    });312313    it("keeps the in-flight run's state when a concurrent trigger is dropped", async () => {314      setSyncRunner(async () => {315        await new Promise<void>((r) => setTimeout(r, 30));316        return { ok: true as const, entriesReceived: 0, entriesSent: 0 };317      });318      await upsertPairedDevice(ANDROID);319      const first = triggerSyncIfReachable({ reason: "manual" });320      while (getSyncState().status !== "syncing") await Promise.resolve();321      await triggerSyncIfReachable({ reason: "vault_change" });322      // The dropped trigger must not stamp a terminal outcome over a live run.323      expect(getSyncState().status).toBe("syncing");324      await first;325      expect(getSyncState().outcome).toBe("completed_no_changes");326    });327328    it("restores the last outcome across a SW restart so a failure stays inspectable", async () => {329      setSyncRunner(async () => ({ ok: false, error: "vault_id_mismatch" }));330      await upsertPairedDevice(ANDROID);331      await triggerSyncIfReachable({ reason: "manual" });332      _resetSyncTriggerForTest();333      expect(getSyncState().outcome).toBeUndefined();334      await hydratePersistedSyncState();335      const s = getSyncState();336      expect(s.outcome).toBe("vault_id_mismatch");337      expect(s.detail).toBe("vault_id_mismatch");338      expect(typeof s.finishedAt).toBe("number");339    });340  });341342  it("hydratePersistedSyncState restores lastSyncAt across SW restart simulation", async () => {343    // Write a value directly to storage, then hydrate — simulates SW restart.344    await (globalThis as unknown as { chrome: { storage: { local: { set(v: Record<string, unknown>): Promise<void> } } } })345      .chrome.storage.local.set({ peach_last_sync_at: 1_700_005_000_000 });346    _resetSyncTriggerForTest();347    expect(getSyncState().lastSyncAt).toBeUndefined();348    await hydratePersistedSyncState();349    expect(getSyncState().lastSyncAt).toBe(1_700_005_000_000);350  });351});