import { describe, expect, it } from "vitest"; import { buildConflictedCopy, buildSyncManifest, chooseConflictWinner, diffSyncManifests, hashVaultEntry, mergeSyncEntries, selectEntriesForSync, } from "./sync-protocol"; import type { VaultEntry } from "./types"; function entry(overrides: Partial = {}): VaultEntry { return { id: "entry-1", type: "login", title: "GitHub", username: "june", password: "secret", url: "https://github.com", createdAt: 1_000, modifiedAt: 1_000, ...overrides, }; } describe("sync protocol helpers", () => { it("hashes entries canonically regardless of object key insertion order", async () => { const a = entry({ tags: ["work", "code"] }); const b = { tags: ["work", "code"], modifiedAt: 1_000, createdAt: 1_000, url: "https://github.com", password: "secret", username: "june", title: "GitHub", type: "login", id: "entry-1", } as VaultEntry; await expect(hashVaultEntry(a)).resolves.toBe(await hashVaultEntry(b)); }); it("builds a stable manifest sorted by entry id", async () => { const manifest = await buildSyncManifest([ entry({ id: "b", modifiedAt: 20 }), entry({ id: "a", modifiedAt: 10, deletedAt: 30 }), ]); expect(manifest).toEqual([ { id: "a", modifiedAt: 10, deletedAt: 30, hash: expect.stringMatching(/^[0-9a-f]{64}$/), }, { id: "b", modifiedAt: 20, hash: expect.stringMatching(/^[0-9a-f]{64}$/), }, ]); }); it("diffs manifests into request, offer, and conflict buckets", () => { const diff = diffSyncManifests( [ { id: "local-only", modifiedAt: 10, hash: "a" }, { id: "newer-local", modifiedAt: 20, hash: "b" }, { id: "newer-remote", modifiedAt: 5, hash: "c" }, { id: "same-time", modifiedAt: 7, hash: "d" }, ], [ { id: "remote-only", modifiedAt: 10, hash: "e" }, { id: "newer-local", modifiedAt: 12, hash: "f" }, { id: "newer-remote", modifiedAt: 30, hash: "g" }, { id: "same-time", modifiedAt: 7, hash: "h" }, ] ); expect(diff).toEqual({ requestFromRemote: ["newer-remote", "remote-only"], offerToRemote: ["local-only", "newer-local"], conflicts: ["same-time"], }); }); it("selects sync entries by id without sharing mutable references", () => { const source = [entry({ id: "a" }), entry({ id: "b", title: "Bank" })]; const selected = selectEntriesForSync(source, ["b"]); expect(selected).toEqual([expect.objectContaining({ id: "b", title: "Bank" })]); selected[0]!.title = "Changed"; expect(source[1]!.title).toBe("Bank"); }); it("chooses the same conflict winner from either peer's perspective", () => { // Both peers run chooseConflictWinner against the same hash pair but in // opposite local/remote slots — the resulting winner-side must point at // the same physical hash on both peers. const peerA = chooseConflictWinner("aaaa", "bbbb"); const peerB = chooseConflictWinner("bbbb", "aaaa"); expect(peerA).toBe("local"); expect(peerB).toBe("remote"); }); it("breaks identical hashes by keeping local (no-op merge)", () => { expect(chooseConflictWinner("xx", "xx")).toBe("local"); }); it("builds an idempotent conflicted copy id from the loser hash", () => { const loser = entry({ id: "shared", title: "Edits" }); const archived = buildConflictedCopy(loser, "abcdef0123456789"); expect(archived.id).toBe("shared-conflict-abcdef01"); expect(archived.title).toBe("Edits (conflicted copy)"); // Re-running with the same loser hash returns the same id (no duplicate copies on re-sync) expect(buildConflictedCopy(loser, "abcdef0123456789").id).toBe(archived.id); }); it("conflicted copy does not mutate the source entry", () => { const loser = entry({ id: "shared", title: "Original" }); buildConflictedCopy(loser, "deadbeef"); expect(loser.id).toBe("shared"); expect(loser.title).toBe("Original"); }); it("merges incoming entries by newest modifiedAt and preserves tombstones", () => { const local = [ entry({ id: "same", modifiedAt: 10, title: "old" }), entry({ id: "local-newer", modifiedAt: 50, title: "keep" }), ]; const incoming = [ entry({ id: "same", modifiedAt: 20, title: "new" }), entry({ id: "local-newer", modifiedAt: 40, title: "skip" }), entry({ id: "deleted", modifiedAt: 30, deletedAt: 30, title: "trash" }), ]; const merged = mergeSyncEntries(local, incoming); expect(merged).toMatchObject({ added: 1, updated: 1, skipped: 1 }); expect(merged.entries.find((item) => item.id === "same")?.title).toBe("new"); expect(merged.entries.find((item) => item.id === "local-newer")?.title).toBe("keep"); expect(merged.entries.find((item) => item.id === "deleted")?.deletedAt).toBe(30); }); });