1import { describe, expect, it } from "vitest";23import {4 buildSyncManifest,5 diffSyncManifests,6 hashVaultEntry,7 mergeSyncEntries,8 selectEntriesForSync,9} from "./sync-protocol";10import type { VaultEntry } from "./types";1112function entry(overrides: Partial<VaultEntry> = {}): VaultEntry {13 return {14 id: "entry-1",15 type: "login",16 title: "GitHub",17 username: "june",18 password: "secret",19 url: "https://github.com",20 createdAt: 1_000,21 modifiedAt: 1_000,22 ...overrides,23 };24}2526describe("sync protocol helpers", () => {27 it("hashes entries canonically regardless of object key insertion order", async () => {28 const a = entry({ tags: ["work", "code"] });29 const b = {30 tags: ["work", "code"],31 modifiedAt: 1_000,32 createdAt: 1_000,33 url: "https://github.com",34 password: "secret",35 username: "june",36 title: "GitHub",37 type: "login",38 id: "entry-1",39 } as VaultEntry;4041 await expect(hashVaultEntry(a)).resolves.toBe(await hashVaultEntry(b));42 });4344 it("builds a stable manifest sorted by entry id", async () => {45 const manifest = await buildSyncManifest([46 entry({ id: "b", modifiedAt: 20 }),47 entry({ id: "a", modifiedAt: 10, deletedAt: 30 }),48 ]);4950 expect(manifest).toEqual([51 {52 id: "a",53 modifiedAt: 10,54 deletedAt: 30,55 hash: expect.stringMatching(/^[0-9a-f]{64}$/),56 },57 {58 id: "b",59 modifiedAt: 20,60 hash: expect.stringMatching(/^[0-9a-f]{64}$/),61 },62 ]);63 });6465 it("diffs manifests into request, offer, and conflict buckets", () => {66 const diff = diffSyncManifests(67 [68 { id: "local-only", modifiedAt: 10, hash: "a" },69 { id: "newer-local", modifiedAt: 20, hash: "b" },70 { id: "newer-remote", modifiedAt: 5, hash: "c" },71 { id: "same-time", modifiedAt: 7, hash: "d" },72 ],73 [74 { id: "remote-only", modifiedAt: 10, hash: "e" },75 { id: "newer-local", modifiedAt: 12, hash: "f" },76 { id: "newer-remote", modifiedAt: 30, hash: "g" },77 { id: "same-time", modifiedAt: 7, hash: "h" },78 ]79 );8081 expect(diff).toEqual({82 requestFromRemote: ["newer-remote", "remote-only"],83 offerToRemote: ["local-only", "newer-local"],84 conflicts: ["same-time"],85 });86 });8788 it("selects sync entries by id without sharing mutable references", () => {89 const source = [entry({ id: "a" }), entry({ id: "b", title: "Bank" })];90 const selected = selectEntriesForSync(source, ["b"]);9192 expect(selected).toEqual([expect.objectContaining({ id: "b", title: "Bank" })]);93 selected[0]!.title = "Changed";94 expect(source[1]!.title).toBe("Bank");95 });9697 it("merges incoming entries by newest modifiedAt and preserves tombstones", () => {98 const local = [99 entry({ id: "same", modifiedAt: 10, title: "old" }),100 entry({ id: "local-newer", modifiedAt: 50, title: "keep" }),101 ];102 const incoming = [103 entry({ id: "same", modifiedAt: 20, title: "new" }),104 entry({ id: "local-newer", modifiedAt: 40, title: "skip" }),105 entry({ id: "deleted", modifiedAt: 30, deletedAt: 30, title: "trash" }),106 ];107108 const merged = mergeSyncEntries(local, incoming);109110 expect(merged).toMatchObject({ added: 1, updated: 1, skipped: 1 });111 expect(merged.entries.find((item) => item.id === "same")?.title).toBe("new");112 expect(merged.entries.find((item) => item.id === "local-newer")?.title).toBe("keep");113 expect(merged.entries.find((item) => item.id === "deleted")?.deletedAt).toBe(30);114 });115});