1import { describe, expect, it } from "vitest";23import {4 buildConflictedCopy,5 buildSyncManifest,6 chooseConflictWinner,7 diffSyncManifests,8 hashVaultEntry,9 mergeSyncEntries,10 selectEntriesForSync,11} from "./sync-protocol";12import type { VaultEntry } from "./types";1314function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {15 return {16 id: "entry-1",17 type: "login",18 title: "GitHub",19 username: "june",20 password: "secret",21 url: "https://github.com",22 createdAt: 1_000,23 modifiedAt: 1_000,24 ...overrides,25 };26}2728describe("sync protocol helpers", () => {29 it("hashes entries canonically regardless of object key insertion order", async () => {30 const a = entry({ tags: ["work", "code"] });31 const b = {32 tags: ["work", "code"],33 modifiedAt: 1_000,34 createdAt: 1_000,35 url: "https://github.com",36 password: "secret",37 username: "june",38 title: "GitHub",39 type: "login",40 id: "entry-1",41 } as VaultEntry;4243 await expect(hashVaultEntry(a)).resolves.toBe(await hashVaultEntry(b));44 });4546 it("builds a stable manifest sorted by entry id", async () => {47 const manifest = await buildSyncManifest([48 entry({ id: "b", modifiedAt: 20 }),49 entry({ id: "a", modifiedAt: 10, deletedAt: 30 }),50 ]);5152 expect(manifest).toEqual([53 {54 id: "a",55 modifiedAt: 10,56 deletedAt: 30,57 hash: expect.stringMatching(/^[0-9a-f]{64}$/),58 },59 {60 id: "b",61 modifiedAt: 20,62 hash: expect.stringMatching(/^[0-9a-f]{64}$/),63 },64 ]);65 });6667 it("diffs manifests into request, offer, and conflict buckets", () => {68 const diff = diffSyncManifests(69 [70 { id: "local-only", modifiedAt: 10, hash: "a" },71 { id: "newer-local", modifiedAt: 20, hash: "b" },72 { id: "newer-remote", modifiedAt: 5, hash: "c" },73 { id: "same-time", modifiedAt: 7, hash: "d" },74 ],75 [76 { id: "remote-only", modifiedAt: 10, hash: "e" },77 { id: "newer-local", modifiedAt: 12, hash: "f" },78 { id: "newer-remote", modifiedAt: 30, hash: "g" },79 { id: "same-time", modifiedAt: 7, hash: "h" },80 ]81 );8283 expect(diff).toEqual({84 requestFromRemote: ["newer-remote", "remote-only"],85 offerToRemote: ["local-only", "newer-local"],86 conflicts: ["same-time"],87 });88 });8990 it("selects sync entries by id without sharing mutable references", () => {91 const source = [entry({ id: "a" }), entry({ id: "b", title: "Bank" })];92 const selected = selectEntriesForSync(source, ["b"]);9394 expect(selected).toEqual([expect.objectContaining({ id: "b", title: "Bank" })]);95 selected[0]!.title = "Changed";96 expect(source[1]!.title).toBe("Bank");97 });9899 it("chooses the same conflict winner from either peer's perspective", () => {100 101 102 103 const peerA = chooseConflictWinner("aaaa", "bbbb");104 const peerB = chooseConflictWinner("bbbb", "aaaa");105 expect(peerA).toBe("local");106 expect(peerB).toBe("remote");107 });108109 it("breaks identical hashes by keeping local (no-op merge)", () => {110 expect(chooseConflictWinner("xx", "xx")).toBe("local");111 });112113 it("builds an idempotent conflicted copy id from the loser hash", () => {114 const loser = entry({ id: "shared", title: "Edits" });115 const archived = buildConflictedCopy(loser, "abcdef0123456789");116 expect(archived.id).toBe("shared-conflict-abcdef01");117 expect(archived.title).toBe("Edits (conflicted copy)");118 119 expect(buildConflictedCopy(loser, "abcdef0123456789").id).toBe(archived.id);120 });121122 it("conflicted copy does not mutate the source entry", () => {123 const loser = entry({ id: "shared", title: "Original" });124 buildConflictedCopy(loser, "deadbeef");125 expect(loser.id).toBe("shared");126 expect(loser.title).toBe("Original");127 });128129 it("merges incoming entries by newest modifiedAt and preserves tombstones", () => {130 const local = [131 entry({ id: "same", modifiedAt: 10, title: "old" }),132 entry({ id: "local-newer", modifiedAt: 50, title: "keep" }),133 ];134 const incoming = [135 entry({ id: "same", modifiedAt: 20, title: "new" }),136 entry({ id: "local-newer", modifiedAt: 40, title: "skip" }),137 entry({ id: "deleted", modifiedAt: 30, deletedAt: 30, title: "trash" }),138 ];139140 const merged = mergeSyncEntries(local, incoming);141142 expect(merged).toMatchObject({ added: 1, updated: 1, skipped: 1 });143 expect(merged.entries.find((item) => item.id === "same")?.title).toBe("new");144 expect(merged.entries.find((item) => item.id === "local-newer")?.title).toBe("keep");145 expect(merged.entries.find((item) => item.id === "deleted")?.deletedAt).toBe(30);146 });147});