feat(cms): add recovery history and operational reliability tools

This commit is contained in:
Simo committed 2026-09-09 21:57:56 +02:00
1 parent 89526af344
commit 1ef405be0a
92 files changed
+6384 -267

No files matched your search

+84
View File
@@ -0,0 +1,84 @@
import type { SQL } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", async () => ({ ...(await import("@/db/schema")), db: {} }));
import { historyChanges, sameSnapshot } from "./model";
import {
applyHistory,
type HistoryTransaction,
historySnapshot,
} from "./server";
describe("history restore safety", () => {
const before = { costCredits: 10, costPoints: 0, pointsType: 0 };
const after = { costCredits: 20, costPoints: 0, pointsType: 0 };
function transaction(current = after) {
const writes: string[] = [];
const audit = vi.fn();
let snapshot = current;
const tx = {
execute: vi.fn(async (query: SQL) => {
const text = new MySqlDialect().sqlToQuery(query).sql;
if (text.startsWith("SELECT")) return [[snapshot]];
writes.push(text);
snapshot = before;
return [{}];
}),
insert: () => ({ values: audit }),
} as unknown as HistoryTransaction;
return { tx, writes, audit };
}
it("compares all recorded fields and does not confuse null with empty", () => {
expect(sameSnapshot({ a: null }, { a: "" })).toBe(false);
expect(sameSnapshot(after, { ...after, extra: 0 })).toBe(false);
expect(historyChanges(after, before)).toEqual([
{ key: "costCredits", current: 20, restore: 10 },
]);
});
it("rejects concurrent edits without any writes or audit", async () => {
const { tx, writes, audit } = transaction({ ...after, costPoints: 5 });
await expect(
applyHistory(tx, { kind: "prices", targetId: 1, before, after }, 7),
).rejects.toThrow("conflict");
expect(writes).toEqual([]);
expect(audit).not.toHaveBeenCalled();
});
it("restores only the price fields and audits in the same transaction", async () => {
const { tx, writes, audit } = transaction();
await applyHistory(tx, { kind: "prices", targetId: 1, before, after }, 7);
expect(writes).toHaveLength(1);
expect(writes[0]).not.toContain("limited_sells");
expect(audit).toHaveBeenCalledWith(
expect.objectContaining({
userId: 7,
action: "history_restore",
before: JSON.stringify(after),
after: JSON.stringify(before),
}),
);
});
it("propagates audit failure so the caller transaction rolls back", async () => {
const { tx, audit } = transaction();
audit.mockRejectedValue(Error("audit unavailable"));
await expect(
applyHistory(tx, { kind: "prices", targetId: 1, before, after }, 7),
).rejects.toThrow("audit unavailable");
});
it("rejects a second restore of the same revision", async () => {
const { tx, writes, audit } = transaction();
const entry = { kind: "prices" as const, targetId: 1, before, after };
await applyHistory(tx, entry, 7);
await expect(applyHistory(tx, entry, 7)).rejects.toThrow("conflict");
expect(writes).toHaveLength(1);
expect(audit).toHaveBeenCalledTimes(1);
});
it("reads only columns present in the smaller builder club schema", async () => {
const { tx } = transaction();
const snapshot = await historySnapshot(tx, "category_bc", 1);
expect(snapshot).not.toHaveProperty("captionSave");
expect(snapshot).not.toHaveProperty("includes");
expect(snapshot).toHaveProperty("caption");
});
});