sync-protocol.test.ts

4.9 KB · raw

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    // Both peers run chooseConflictWinner against the same hash pair but in101    // opposite local/remote slots — the resulting winner-side must point at102    // the same physical hash on both peers.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    // Re-running with the same loser hash returns the same id (no duplicate copies on re-sync)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});