import { describe, expect, it } from "@suaveplan/testing/runner"; import { VAULT_SYNC_CODES } from "../errors/codes.js"; import { VaultSyncError } from "../errors/vault-sync-error.js"; import type { VaultHistoryEntry, VaultItem } from "../types/types.js"; import { assertMergeStrategy, MERGE_STRATEGIES, mergeVaultItems, } from "./merge.js"; function item(over: Partial = {}): VaultItem { return { id: "i1", title: "Email", password: "pw", folderId: null, history: [] as VaultHistoryEntry[], nodeId: "a", updatedAt: 1, clock: { a: 1 }, ...over, }; } describe("mergeVaultItems — lww strategy", () => { it("lww selects later timestamp for password", { timeout: 5000 }, () => { const local = item({ password: "old", updatedAt: 1, clock: { a: 1 } }); const remote = item({ password: "new", updatedAt: 2, nodeId: "b", clock: { b: 1 }, }); const m = mergeVaultItems(local, remote, "lww"); expect(m.password).toBe("new"); }); it("merge is commutative", { timeout: 5000 }, () => { const local = item({ password: "old", updatedAt: 1, clock: { a: 1 } }); const remote = item({ password: "new", updatedAt: 2, nodeId: "b", clock: { b: 1 }, }); const m1 = mergeVaultItems(local, remote, "lww"); const m2 = mergeVaultItems(remote, local, "lww"); expect(m1).toEqual(m2); }); it("breaks pathological clock-tie by canonical content order", { timeout: 5000, }, () => { // Identical clock + updatedAt + nodeId, but different content. // canonicalString() places local > remote → remote wins. const local = item({ title: "zzz", clock: { a: 1 } }); const remote = item({ title: "aaa", clock: { a: 1 } }); const m = mergeVaultItems(local, remote, "lww"); expect(m.title).toBe("aaa"); }); it("merge is idempotent", { timeout: 5000 }, () => { const local = item({ password: "p", updatedAt: 1 }); const m1 = mergeVaultItems(local, local, "lww"); const m2 = mergeVaultItems(m1, m1, "lww"); expect(m1).toEqual(m2); }); it("propagates deleted flag", { timeout: 5000 }, () => { const local = item(); const remote = item({ deleted: true }); expect(mergeVaultItems(local, remote, "lww").deleted).toBe(true); }); }); describe("mergeVaultItems — append strategy", () => { it("append merges history without duplication", { timeout: 5000 }, () => { const local = item({ history: [ { historyId: "h1", timestamp: 1, note: "a" }, { historyId: "h2", timestamp: 2, note: "b" }, ], }); const remote = item({ history: [ { historyId: "h2", timestamp: 2, note: "b" }, { historyId: "h3", timestamp: 3, note: "c" }, ], }); const m = mergeVaultItems(local, remote, "append"); const ids = m.history.map((h) => h.historyId); expect(ids).toEqual(["h1", "h2", "h3"]); }); it("append sorts merged history by timestamp", { timeout: 5000 }, () => { const local = item({ history: [{ historyId: "h2", timestamp: 5, note: "z" }], }); const remote = item({ history: [ { historyId: "h1", timestamp: 1, note: "x" }, { historyId: "h3", timestamp: 3, note: "y" }, ], }); const m = mergeVaultItems(local, remote, "append"); expect(m.history.map((h) => h.timestamp)).toEqual([1, 3, 5]); }); it("dedup picks higher-timestamp entry when historyId conflicts", { timeout: 5000, }, () => { const local = item({ history: [{ historyId: "h1", timestamp: 5, note: "later" }], }); const remote = item({ history: [{ historyId: "h1", timestamp: 1, note: "earlier" }], }); const m = mergeVaultItems(local, remote, "append"); expect(m.history).toHaveLength(1); expect(m.history[0]?.note).toBe("later"); }); it("dedup picks lex-smaller note on equal timestamps + historyId", { timeout: 5000, }, () => { const local = item({ history: [{ historyId: "h1", timestamp: 1, note: "zzz" }], }); const remote = item({ history: [{ historyId: "h1", timestamp: 1, note: "aaa" }], }); const m = mergeVaultItems(local, remote, "append"); expect(m.history).toHaveLength(1); expect(m.history[0]?.note).toBe("aaa"); }); it("dedup picks lex-greater note when first is bigger", { timeout: 5000, }, () => { const local = item({ history: [{ historyId: "h1", timestamp: 1, note: "aaa" }], }); const remote = item({ history: [{ historyId: "h1", timestamp: 1, note: "zzz" }], }); const m = mergeVaultItems(local, remote, "append"); expect(m.history[0]?.note).toBe("aaa"); }); it("dedup keeps entry when historyId+timestamp+note are identical", { timeout: 5000, }, () => { const local = item({ history: [{ historyId: "h1", timestamp: 1, note: "x" }], }); const remote = item({ history: [{ historyId: "h1", timestamp: 1, note: "x" }], }); const m = mergeVaultItems(local, remote, "append"); expect(m.history).toHaveLength(1); }); it("breaks timestamp ties by historyId", { timeout: 5000 }, () => { const local = item({ history: [{ historyId: "z", timestamp: 1, note: "x" }], }); const remote = item({ history: [{ historyId: "a", timestamp: 1, note: "y" }], }); const m = mergeVaultItems(local, remote, "append"); expect(m.history.map((h) => h.historyId)).toEqual(["a", "z"]); }); it("propagates deleted flag in append", { timeout: 5000 }, () => { const local = item(); const remote = item({ deleted: true }); expect(mergeVaultItems(local, remote, "append").deleted).toBe(true); }); }); describe("mergeVaultItems — atomic strategy", () => { it("atomic replaces entire object on linear advance", { timeout: 5000, }, () => { const local = item({ folderId: null, clock: { a: 1 } }); const remote = item({ folderId: "f1", clock: { a: 1, b: 1 }, nodeId: "b", updatedAt: 2, }); const m = mergeVaultItems(local, remote, "atomic"); expect(m.folderId).toBe("f1"); }); it("atomic keeps local on a > b causal advance", { timeout: 5000, }, () => { const local = item({ folderId: "local", clock: { a: 1, b: 1 }, updatedAt: 5, }); const remote = item({ folderId: "remote", clock: { a: 1 }, nodeId: "b", updatedAt: 2, }); // Local dominates remote causally → local wins; folderId differs but // is not concurrent so the merge is allowed. const m = mergeVaultItems(local, remote, "atomic"); expect(m.folderId).toBe("local"); }); it("atomic rejects concurrent folderId changes", { timeout: 5000 }, () => { const local = item({ folderId: "f1", clock: { a: 1 } }); const remote = item({ folderId: "f2", nodeId: "b", clock: { b: 1 }, }); expect(() => mergeVaultItems(local, remote, "atomic")).toThrow( VaultSyncError ); }); it("atomic rejects concurrent title changes", { timeout: 5000 }, () => { const local = item({ title: "loc", clock: { a: 1 } }); const remote = item({ title: "rem", nodeId: "b", clock: { b: 1 }, }); expect(() => mergeVaultItems(local, remote, "atomic")).toThrow( VaultSyncError ); }); it("atomic rejects concurrent password changes", { timeout: 5000 }, () => { const local = item({ password: "loc", clock: { a: 1 } }); const remote = item({ password: "rem", nodeId: "b", clock: { b: 1 }, }); expect(() => mergeVaultItems(local, remote, "atomic")).toThrow( VaultSyncError ); }); it("atomic with identical clocks succeeds (same causal state)", { timeout: 5000, }, () => { const local = item({ folderId: "f1", clock: { a: 1 } }); const remote = item({ folderId: "f1", clock: { a: 1 } }); const m = mergeVaultItems(local, remote, "atomic"); expect(m.folderId).toBe("f1"); }); it("atomic rejects concurrent deleted-flag divergence", { timeout: 5000, }, () => { const local = item({ clock: { a: 1 }, deleted: true }); const remote = item({ nodeId: "b", clock: { b: 1 }, deleted: false, }); try { mergeVaultItems(local, remote, "atomic"); expect.unreachable(); } catch (err) { if (!(err instanceof VaultSyncError)) throw err; expect(err.code).toBe( VAULT_SYNC_CODES["MERGE.CONFLICT_UNRESOLVED"] ); } }); it("atomic concurrent but with no atomic-field diff is allowed", { timeout: 5000, }, () => { const local = item({ folderId: "shared", clock: { a: 1 }, history: [{ historyId: "h1", timestamp: 1, note: "x" }], }); const remote = item({ folderId: "shared", clock: { b: 1 }, nodeId: "b", history: [{ historyId: "h2", timestamp: 2, note: "y" }], }); const m = mergeVaultItems(local, remote, "atomic"); expect(m.history).toHaveLength(2); }); }); describe("mergeVaultItems — error paths", () => { it("rejects unknown strategy", { timeout: 5000 }, () => { const local = item(); const remote = item(); expect(() => mergeVaultItems( local, remote, "smart" as unknown as Parameters[2] ) ).toThrow(VaultSyncError); }); it("rejects mismatched ids", { timeout: 5000 }, () => { const local = item({ id: "i1" }); const remote = item({ id: "i2" }); try { mergeVaultItems(local, remote, "lww"); expect.unreachable(); } catch (err) { if (!(err instanceof VaultSyncError)) throw err; expect(err.code).toBe( VAULT_SYNC_CODES["MERGE.CONFLICT_UNRESOLVED"] ); } }); }); describe("assertMergeStrategy", () => { it("returns the strategy when valid", { timeout: 5000 }, () => { for (const s of MERGE_STRATEGIES) { expect(assertMergeStrategy(s)).toBe(s); } }); it("throws on unknown", { timeout: 5000 }, () => { expect(() => assertMergeStrategy("smart")).toThrow(VaultSyncError); }); });