sync-protocol.test.ts

3.5 KB · raw

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});