339 lines
11 KiB
TypeScript
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);
|
|
});
|
|
});
|