import { describe, expect, it } from "@suaveplan/testing/runner"; import type { Rng } from "@suaveplan/types"; import { VAULT_SYNC_CODES } from "../errors/codes.js"; import { VaultSyncError } from "../errors/vault-sync-error.js"; import type { EncryptedSyncPayload, SyncTransport, VaultItem, VaultSnapshot, } from "../types/types.js"; import { SyncEngine } from "./engine.js"; class CountingRng implements Rng { private state = 1; public nextUint32(): number { this.state = (this.state * 1664525 + 1013904223) >>> 0; return this.state; } public nextFloat(): number { return this.nextUint32() / 0x1_00_00_00_00; } } class MemoryTransport implements SyncTransport { public stored: VaultSnapshot | undefined; public failOn: "fetch" | "push" | undefined; public async fetchSnapshot(): Promise { if (this.failOn === "fetch") throw new Error("offline"); if (!this.stored) { return { version: "v1" as const, nonce: new Uint8Array(24), ciphertext: new Uint8Array(0), }; } // We pass the snapshot through as the envelope; the engine's // injected decrypt callback turns it back into a snapshot. return this.stored as unknown as EncryptedSyncPayload; } public async pushSnapshot(envelope: EncryptedSyncPayload): Promise { if (this.failOn === "push") throw new Error("offline"); this.stored = envelope as unknown as VaultSnapshot; } } function snapshot(items: VaultItem[]): VaultSnapshot { const clock: Record = {}; for (const item of items) { for (const [k, v] of Object.entries(item.clock)) { clock[k] = Math.max(clock[k] ?? 0, v); } } return { items, clock, snapshotAt: 1 }; } function item(over: Partial = {}): VaultItem { return { id: "i1", title: "Email", password: "pw", folderId: null, history: [], nodeId: "a", updatedAt: 1, clock: { a: 1 }, ...over, }; } function makeEngine(transport: MemoryTransport, init?: VaultSnapshot) { return new SyncEngine({ nodeId: "a", transport, encrypt: async (snap) => snap as unknown as EncryptedSyncPayload, decrypt: async (env) => env as VaultSnapshot, ...(init !== undefined ? { initialSnapshot: init } : {}), }); } describe("SyncEngine — pull", () => { it("pull applies remote changes", { timeout: 5000 }, async () => { const t = new MemoryTransport(); const engine = makeEngine(t); t.stored = snapshot([ item({ id: "i1", title: "x" }), item({ id: "i2", title: "y" }), ]); const result = await engine.pull(); expect([...result.merged].sort()).toEqual(["i1", "i2"]); expect(engine.currentSnapshot.items).toHaveLength(2); }); it("empty pull returns empty result", { timeout: 5000 }, async () => { const t = new MemoryTransport(); t.stored = snapshot([]); const engine = makeEngine(t); const result = await engine.pull(); expect(result.merged).toEqual([]); expect(result.conflicts).toEqual([]); }); it("propagates deletions as tombstones", { timeout: 5000 }, async () => { const t = new MemoryTransport(); const engine = makeEngine(t); t.stored = snapshot([item({ id: "i1", deleted: true })]); const result = await engine.pull(); expect(result.deleted).toEqual(["i1"]); }); it("merged-deleted items land in deleted[]", { timeout: 5000, }, async () => { const t = new MemoryTransport(); const engine = makeEngine( t, snapshot([item({ id: "i1", deleted: true })]) ); t.stored = snapshot([item({ id: "i1", nodeId: "b", clock: { b: 1 } })]); const result = await engine.pull(); // The local copy carries deleted=true; the merge propagates that. expect(result.deleted).toContain("i1"); }); it("retains local-only deleted items in deleted[]", { timeout: 5000, }, async () => { const t = new MemoryTransport(); const engine = makeEngine( t, snapshot([item({ id: "i1", deleted: true })]) ); t.stored = snapshot([item({ id: "i2", title: "remote" })]); const result = await engine.pull(); // i1 is local-only and deleted; i2 is new from remote. expect(result.deleted).toContain("i1"); expect(result.merged).toContain("i2"); }); it("retains local-only non-deleted items in the snapshot", { timeout: 5000, }, async () => { const t = new MemoryTransport(); const engine = makeEngine(t, snapshot([item({ id: "i1" })])); t.stored = snapshot([item({ id: "i2", title: "remote" })]); const result = await engine.pull(); // i1 is local-only and NOT deleted; result should not include it. expect(result.deleted).not.toContain("i1"); const ids = engine.currentSnapshot.items.map((i) => i.id).sort(); expect(ids).toEqual(["i1", "i2"]); }); it("wraps transport errors with SYNC_TRANSPORT_FAILED", { timeout: 5000, }, async () => { const t = new MemoryTransport(); t.failOn = "fetch"; const engine = makeEngine(t); try { await engine.pull(); expect.unreachable(); } catch (err) { if (!(err instanceof VaultSyncError)) throw err; expect(err.code).toBe(VAULT_SYNC_CODES["SYNC.TRANSPORT_FAILED"]); } }); }); describe("SyncEngine — push", () => { it("push returns only local changes since last sync", { timeout: 5000, }, async () => { const t = new MemoryTransport(); const engine = makeEngine(t); engine.applyLocalMutation(item({ id: "i1" })); engine.applyLocalMutation(item({ id: "i2" })); const result = await engine.push(); expect([...result.merged].sort()).toEqual(["i1", "i2"]); }); it("empty push returns empty result", { timeout: 5000 }, async () => { const t = new MemoryTransport(); const engine = makeEngine(t); const result = await engine.push(); expect(result.merged).toEqual([]); expect(result.deleted).toEqual([]); }); it("propagates locally-deleted items in deleted[]", { timeout: 5000, }, async () => { const t = new MemoryTransport(); const engine = makeEngine(t); engine.applyLocalMutation(item({ id: "i1", deleted: true })); const result = await engine.push(); expect(result.deleted).toEqual(["i1"]); expect(result.merged).toEqual([]); }); it("wraps push transport errors with SYNC_TRANSPORT_FAILED", { timeout: 5000, }, async () => { const t = new MemoryTransport(); t.failOn = "push"; const engine = makeEngine(t); engine.applyLocalMutation(item({ id: "i1" })); try { await engine.push(); expect.unreachable(); } catch (err) { if (!(err instanceof VaultSyncError)) throw err; expect(err.code).toBe(VAULT_SYNC_CODES["SYNC.TRANSPORT_FAILED"]); } }); }); describe("SyncEngine — sync (bidirectional)", () => { it("bidirectional sync reconciles divergent states", { timeout: 5000, }, async () => { // A merging transport: pushSnapshot merges into stored state, fetch // returns the merged remote state. const merger = new MemoryTransport(); const original = merger.pushSnapshot.bind(merger); merger.pushSnapshot = async (env): Promise => { const incoming = env as unknown as VaultSnapshot; const existing = (merger.stored as VaultSnapshot | undefined) ?? snapshot([]); const byId = new Map(); for (const i of existing.items) byId.set(i.id, i); for (const i of incoming.items) byId.set(i.id, i); await original({ ...env, } as never); merger.stored = { items: Array.from(byId.values()), clock: { ...existing.clock, ...incoming.clock }, snapshotAt: Math.max(existing.snapshotAt, incoming.snapshotAt), }; }; merger.stored = snapshot([ item({ id: "i2", title: "remote", nodeId: "b", clock: { b: 1 } }), ]); const engine = makeEngine(merger); engine.applyLocalMutation(item({ id: "i1", title: "local" })); const result = await engine.sync(); expect(result.merged.length).toBeGreaterThanOrEqual(1); const ids = engine.currentSnapshot.items.map((i) => i.id).sort(); expect(ids).toEqual(["i1", "i2"]); }); it("detects concurrent edits as conflicts", { timeout: 5000, }, async () => { const t = new MemoryTransport(); const local = item({ id: "i1", folderId: "loc", clock: { a: 1 }, }); // Remote: same item, concurrent edit to folderId. t.stored = snapshot([ item({ id: "i1", folderId: "rem", nodeId: "b", clock: { b: 1 }, }), ]); const engine = new SyncEngine({ nodeId: "a", transport: t, encrypt: async (snap) => snap as unknown as EncryptedSyncPayload, decrypt: async (env) => env as VaultSnapshot, defaultStrategy: "atomic", initialSnapshot: snapshot([local]), }); const result = await engine.pull(); expect(result.conflicts).toContain("i1"); }); it("resolves conflicts with configured strategy", { timeout: 5000, }, async () => { const t = new MemoryTransport(); const local = item({ id: "i1", title: "loc", updatedAt: 1 }); t.stored = snapshot([ item({ id: "i1", title: "rem", nodeId: "b", clock: { b: 1 }, updatedAt: 5, }), ]); const engine = new SyncEngine({ nodeId: "a", transport: t, encrypt: async (snap) => snap as unknown as EncryptedSyncPayload, decrypt: async (env) => env as VaultSnapshot, initialSnapshot: snapshot([local]), }); const result = await engine.pull(); expect(result.merged).toContain("i1"); const merged = engine.currentSnapshot.items.find((i) => i.id === "i1"); expect(merged?.title).toBe("rem"); }); it("uses history strategy when items have history", { timeout: 5000, }, async () => { const t = new MemoryTransport(); const local = item({ id: "i1", history: [{ historyId: "h1", timestamp: 1, note: "a" }], clock: { a: 1 }, }); t.stored = snapshot([ item({ id: "i1", history: [{ historyId: "h2", timestamp: 2, note: "b" }], nodeId: "b", clock: { b: 1 }, }), ]); const engine = new SyncEngine({ nodeId: "a", transport: t, encrypt: async (snap) => snap as unknown as EncryptedSyncPayload, decrypt: async (env) => env as VaultSnapshot, initialSnapshot: snapshot([local]), }); await engine.pull(); const merged = engine.currentSnapshot.items.find((i) => i.id === "i1"); expect(merged?.history).toHaveLength(2); }); }); describe("SyncEngine — hydrate / mutations", () => { it("hydrate validates snapshot shape", { timeout: 5000 }, () => { const engine = makeEngine(new MemoryTransport()); try { engine.hydrate({ items: [{ bogus: 1 } as unknown as VaultItem], clock: {}, snapshotAt: 0, }); expect.unreachable(); } catch (err) { if (!(err instanceof VaultSyncError)) throw err; expect(err.code).toBe( VAULT_SYNC_CODES["MERGE.CONFLICT_UNRESOLVED"] ); } }); it("hydrate accepts a valid snapshot", { timeout: 5000 }, () => { const engine = makeEngine(new MemoryTransport()); engine.hydrate(snapshot([item({ id: "i1" })])); expect(engine.currentSnapshot.items).toHaveLength(1); }); it("applyLocalMutation overwrites existing item by id", { timeout: 5000, }, () => { const engine = makeEngine(new MemoryTransport()); engine.applyLocalMutation(item({ id: "i1", title: "a" })); engine.applyLocalMutation( item({ id: "i1", title: "b", clock: { a: 2 } }) ); expect(engine.currentSnapshot.items).toHaveLength(1); expect(engine.currentSnapshot.items[0]?.title).toBe("b"); }); }); describe("SyncEngine — accepts injectable clock and rng", () => { it("accepts injectable clock and rng", { timeout: 5000 }, () => { const engine = new SyncEngine({ nodeId: "a", transport: new MemoryTransport(), encrypt: async (snap) => snap as unknown as EncryptedSyncPayload, decrypt: async (env) => env as VaultSnapshot, clock: () => 99, rng: new CountingRng(), }); expect(engine.owner).toBe("a"); expect(engine.randomSource).toBeDefined(); }); it("randomSource is undefined when no rng provided", { timeout: 5000, }, () => { const engine = makeEngine(new MemoryTransport()); expect(engine.randomSource).toBeUndefined(); }); }); describe("SyncEngine — preferLocal on conflict", () => { it("retains local copy with merged clock when atomic conflict", { timeout: 5000, }, async () => { const t = new MemoryTransport(); const local = item({ id: "i1", folderId: "loc", clock: { a: 1 } }); t.stored = snapshot([ item({ id: "i1", folderId: "rem", nodeId: "b", clock: { b: 1 }, }), ]); const engine = new SyncEngine({ nodeId: "a", transport: t, encrypt: async (snap) => snap as unknown as EncryptedSyncPayload, decrypt: async (env) => env as VaultSnapshot, defaultStrategy: "atomic", initialSnapshot: snapshot([local]), }); const result = await engine.pull(); expect(result.conflicts).toContain("i1"); const stored = engine.currentSnapshot.items.find((i) => i.id === "i1"); expect(stored?.folderId).toBe("loc"); expect(stored?.clock).toEqual({ a: 1, b: 1 }); }); });