Files
suaveplan/packages/security-stack/security/vault-sync/src/merge/merge.test.ts
2026-05-27 23:47:53 +01:00

339 lines
11 KiB
TypeScript

import { describe, expect, it } from "@suaveplan/testing/runner";
import { VAULT_SYNC_CODES } from "../errors/codes.js";
import { VaultSyncError } from "../errors/vault-sync-error.js";
import type { VaultHistoryEntry, VaultItem } from "../types/types.js";
import {
assertMergeStrategy,
MERGE_STRATEGIES,
mergeVaultItems,
} from "./merge.js";
function item(over: Partial<VaultItem> = {}): VaultItem {
return {
id: "i1",
title: "Email",
password: "pw",
folderId: null,
history: [] as VaultHistoryEntry[],
nodeId: "a",
updatedAt: 1,
clock: { a: 1 },
...over,
};
}
describe("mergeVaultItems — lww strategy", () => {
it("lww selects later timestamp for password", { timeout: 5000 }, () => {
const local = item({ password: "old", updatedAt: 1, clock: { a: 1 } });
const remote = item({
password: "new",
updatedAt: 2,
nodeId: "b",
clock: { b: 1 },
});
const m = mergeVaultItems(local, remote, "lww");
expect(m.password).toBe("new");
});
it("merge is commutative", { timeout: 5000 }, () => {
const local = item({ password: "old", updatedAt: 1, clock: { a: 1 } });
const remote = item({
password: "new",
updatedAt: 2,
nodeId: "b",
clock: { b: 1 },
});
const m1 = mergeVaultItems(local, remote, "lww");
const m2 = mergeVaultItems(remote, local, "lww");
expect(m1).toEqual(m2);
});
it("breaks pathological clock-tie by canonical content order", {
timeout: 5000,
}, () => {
// Identical clock + updatedAt + nodeId, but different content.
// canonicalString() places local > remote → remote wins.
const local = item({ title: "zzz", clock: { a: 1 } });
const remote = item({ title: "aaa", clock: { a: 1 } });
const m = mergeVaultItems(local, remote, "lww");
expect(m.title).toBe("aaa");
});
it("merge is idempotent", { timeout: 5000 }, () => {
const local = item({ password: "p", updatedAt: 1 });
const m1 = mergeVaultItems(local, local, "lww");
const m2 = mergeVaultItems(m1, m1, "lww");
expect(m1).toEqual(m2);
});
it("propagates deleted flag", { timeout: 5000 }, () => {
const local = item();
const remote = item({ deleted: true });
expect(mergeVaultItems(local, remote, "lww").deleted).toBe(true);
});
});
describe("mergeVaultItems — append strategy", () => {
it("append merges history without duplication", { timeout: 5000 }, () => {
const local = item({
history: [
{ historyId: "h1", timestamp: 1, note: "a" },
{ historyId: "h2", timestamp: 2, note: "b" },
],
});
const remote = item({
history: [
{ historyId: "h2", timestamp: 2, note: "b" },
{ historyId: "h3", timestamp: 3, note: "c" },
],
});
const m = mergeVaultItems(local, remote, "append");
const ids = m.history.map((h) => h.historyId);
expect(ids).toEqual(["h1", "h2", "h3"]);
});
it("append sorts merged history by timestamp", { timeout: 5000 }, () => {
const local = item({
history: [{ historyId: "h2", timestamp: 5, note: "z" }],
});
const remote = item({
history: [
{ historyId: "h1", timestamp: 1, note: "x" },
{ historyId: "h3", timestamp: 3, note: "y" },
],
});
const m = mergeVaultItems(local, remote, "append");
expect(m.history.map((h) => h.timestamp)).toEqual([1, 3, 5]);
});
it("dedup picks higher-timestamp entry when historyId conflicts", {
timeout: 5000,
}, () => {
const local = item({
history: [{ historyId: "h1", timestamp: 5, note: "later" }],
});
const remote = item({
history: [{ historyId: "h1", timestamp: 1, note: "earlier" }],
});
const m = mergeVaultItems(local, remote, "append");
expect(m.history).toHaveLength(1);
expect(m.history[0]?.note).toBe("later");
});
it("dedup picks lex-smaller note on equal timestamps + historyId", {
timeout: 5000,
}, () => {
const local = item({
history: [{ historyId: "h1", timestamp: 1, note: "zzz" }],
});
const remote = item({
history: [{ historyId: "h1", timestamp: 1, note: "aaa" }],
});
const m = mergeVaultItems(local, remote, "append");
expect(m.history).toHaveLength(1);
expect(m.history[0]?.note).toBe("aaa");
});
it("dedup picks lex-greater note when first is bigger", {
timeout: 5000,
}, () => {
const local = item({
history: [{ historyId: "h1", timestamp: 1, note: "aaa" }],
});
const remote = item({
history: [{ historyId: "h1", timestamp: 1, note: "zzz" }],
});
const m = mergeVaultItems(local, remote, "append");
expect(m.history[0]?.note).toBe("aaa");
});
it("dedup keeps entry when historyId+timestamp+note are identical", {
timeout: 5000,
}, () => {
const local = item({
history: [{ historyId: "h1", timestamp: 1, note: "x" }],
});
const remote = item({
history: [{ historyId: "h1", timestamp: 1, note: "x" }],
});
const m = mergeVaultItems(local, remote, "append");
expect(m.history).toHaveLength(1);
});
it("breaks timestamp ties by historyId", { timeout: 5000 }, () => {
const local = item({
history: [{ historyId: "z", timestamp: 1, note: "x" }],
});
const remote = item({
history: [{ historyId: "a", timestamp: 1, note: "y" }],
});
const m = mergeVaultItems(local, remote, "append");
expect(m.history.map((h) => h.historyId)).toEqual(["a", "z"]);
});
it("propagates deleted flag in append", { timeout: 5000 }, () => {
const local = item();
const remote = item({ deleted: true });
expect(mergeVaultItems(local, remote, "append").deleted).toBe(true);
});
});
describe("mergeVaultItems — atomic strategy", () => {
it("atomic replaces entire object on linear advance", {
timeout: 5000,
}, () => {
const local = item({ folderId: null, clock: { a: 1 } });
const remote = item({
folderId: "f1",
clock: { a: 1, b: 1 },
nodeId: "b",
updatedAt: 2,
});
const m = mergeVaultItems(local, remote, "atomic");
expect(m.folderId).toBe("f1");
});
it("atomic keeps local on a > b causal advance", {
timeout: 5000,
}, () => {
const local = item({
folderId: "local",
clock: { a: 1, b: 1 },
updatedAt: 5,
});
const remote = item({
folderId: "remote",
clock: { a: 1 },
nodeId: "b",
updatedAt: 2,
});
// Local dominates remote causally → local wins; folderId differs but
// is not concurrent so the merge is allowed.
const m = mergeVaultItems(local, remote, "atomic");
expect(m.folderId).toBe("local");
});
it("atomic rejects concurrent folderId changes", { timeout: 5000 }, () => {
const local = item({ folderId: "f1", clock: { a: 1 } });
const remote = item({
folderId: "f2",
nodeId: "b",
clock: { b: 1 },
});
expect(() => mergeVaultItems(local, remote, "atomic")).toThrow(
VaultSyncError
);
});
it("atomic rejects concurrent title changes", { timeout: 5000 }, () => {
const local = item({ title: "loc", clock: { a: 1 } });
const remote = item({
title: "rem",
nodeId: "b",
clock: { b: 1 },
});
expect(() => mergeVaultItems(local, remote, "atomic")).toThrow(
VaultSyncError
);
});
it("atomic rejects concurrent password changes", { timeout: 5000 }, () => {
const local = item({ password: "loc", clock: { a: 1 } });
const remote = item({
password: "rem",
nodeId: "b",
clock: { b: 1 },
});
expect(() => mergeVaultItems(local, remote, "atomic")).toThrow(
VaultSyncError
);
});
it("atomic with identical clocks succeeds (same causal state)", {
timeout: 5000,
}, () => {
const local = item({ folderId: "f1", clock: { a: 1 } });
const remote = item({ folderId: "f1", clock: { a: 1 } });
const m = mergeVaultItems(local, remote, "atomic");
expect(m.folderId).toBe("f1");
});
it("atomic rejects concurrent deleted-flag divergence", {
timeout: 5000,
}, () => {
const local = item({ clock: { a: 1 }, deleted: true });
const remote = item({
nodeId: "b",
clock: { b: 1 },
deleted: false,
});
try {
mergeVaultItems(local, remote, "atomic");
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(
VAULT_SYNC_CODES["MERGE.CONFLICT_UNRESOLVED"]
);
}
});
it("atomic concurrent but with no atomic-field diff is allowed", {
timeout: 5000,
}, () => {
const local = item({
folderId: "shared",
clock: { a: 1 },
history: [{ historyId: "h1", timestamp: 1, note: "x" }],
});
const remote = item({
folderId: "shared",
clock: { b: 1 },
nodeId: "b",
history: [{ historyId: "h2", timestamp: 2, note: "y" }],
});
const m = mergeVaultItems(local, remote, "atomic");
expect(m.history).toHaveLength(2);
});
});
describe("mergeVaultItems — error paths", () => {
it("rejects unknown strategy", { timeout: 5000 }, () => {
const local = item();
const remote = item();
expect(() =>
mergeVaultItems(
local,
remote,
"smart" as unknown as Parameters<typeof mergeVaultItems>[2]
)
).toThrow(VaultSyncError);
});
it("rejects mismatched ids", { timeout: 5000 }, () => {
const local = item({ id: "i1" });
const remote = item({ id: "i2" });
try {
mergeVaultItems(local, remote, "lww");
expect.unreachable();
} catch (err) {
if (!(err instanceof VaultSyncError)) throw err;
expect(err.code).toBe(
VAULT_SYNC_CODES["MERGE.CONFLICT_UNRESOLVED"]
);
}
});
});
describe("assertMergeStrategy", () => {
it("returns the strategy when valid", { timeout: 5000 }, () => {
for (const s of MERGE_STRATEGIES) {
expect(assertMergeStrategy(s)).toBe(s);
}
});
it("throws on unknown", { timeout: 5000 }, () => {
expect(() => assertMergeStrategy("smart")).toThrow(VaultSyncError);
});
});