Initial Commit
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user