Files
EpicNext-Cms/src/lib/services/catalog-parent-repair.test.ts
T
openhands 8638e81444
CI / check (push) Successful in 4m10s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m4s
refactor(db): typed query helpers, shared test FormData helper
Replace raw db.execute tuple casts with queryRows/rowsFrom/execResult/
affectedRows helpers from lib/db, drop redundant mysql2 casts on typed
query builders, and centralize per-test fakeForm into test/fake-form.
Update db mocks in tests so helpers resolve against mocked execute.
2026-09-17 21:02:57 +02:00

96 lines
3.5 KiB
TypeScript

import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
execute: vi.fn(),
transaction: vi.fn(),
insert: vi.fn(),
values: vi.fn(),
}));
vi.mock("@/lib/db", async () => ({
...(await import("@/test/db-helpers")).createDbHelpers(mocks.execute),
AdminAuditLog: "audit",
db: { execute: mocks.execute, transaction: mocks.transaction },
}));
vi.mock("@/lib/services/furni-data", () => ({}));
vi.mock("@/lib/services/furni-import", () => ({}));
import {
applyCatalogParentRepair,
previewCatalogParentRepair,
} from "./catalog-repair";
const dialect = new MySqlDialect();
let pages: { id: number; parentId: number; caption: string }[];
beforeEach(() => {
vi.clearAllMocks();
pages = [
{ id: 1, parentId: 999, caption: "Orphan" },
{ id: 2, parentId: -1, caption: "Root" },
];
mocks.execute.mockImplementation(async (query) =>
dialect.sqlToQuery(query).sql.startsWith("SELECT")
? [pages, []]
: [{ affectedRows: 1 }, []],
);
mocks.insert.mockReturnValue({ values: mocks.values });
mocks.values.mockResolvedValue(undefined);
mocks.transaction.mockImplementation((callback) =>
callback({ execute: mocks.execute, insert: mocks.insert }),
);
});
describe("catalog parent repair", () => {
it("previews without writes, locks the graph and records each change inside the transaction", async () => {
const preview = await previewCatalogParentRepair("normal");
expect(preview.changes).toEqual([
{ id: 1, caption: "Orphan", from: 999, to: -1 },
]);
expect(mocks.transaction).not.toHaveBeenCalled();
expect(
await applyCatalogParentRepair("normal", preview.fingerprint, 7),
).toEqual({ applied: 1 });
const queries = mocks.execute.mock.calls.map(([query]) =>
dialect.sqlToQuery(query),
);
expect(queries[1].sql).toContain("FOR UPDATE");
expect(queries[2].sql).toContain("UPDATE catalog_pages SET parent_id");
expect(queries[2].params).toEqual([-1, 1, 999]);
expect(queries.some((query) => /DELETE|INSERT/.test(query.sql))).toBe(
false,
);
expect(mocks.values).toHaveBeenCalledWith(
expect.objectContaining({
userId: 7,
targetId: 1,
action: "catalog_parent_repair",
}),
);
});
it("rejects a stale preview before any mutation when the graph changed", async () => {
const preview = await previewCatalogParentRepair("normal");
pages = [...pages, { id: 999, parentId: -1, caption: "Restored" }];
await expect(
applyCatalogParentRepair("normal", preview.fingerprint, 7),
).rejects.toMatchObject({ name: "CatalogPreviewConflict" });
expect(mocks.insert).not.toHaveBeenCalled();
expect(mocks.execute).toHaveBeenCalledTimes(2);
});
it("binds previews to the selected catalog and uses BC tables for BC repairs", async () => {
const preview = await previewCatalogParentRepair("bc");
await expect(
applyCatalogParentRepair("normal", preview.fingerprint, 7),
).rejects.toMatchObject({ name: "CatalogPreviewConflict" });
await applyCatalogParentRepair("bc", preview.fingerprint, 7);
expect(
dialect.sqlToQuery(mocks.execute.mock.calls.at(-1)?.[0]).sql,
).toContain("UPDATE catalog_pages_bc");
});
it("propagates audit failure so the transaction rolls back, instead of claiming success", async () => {
const preview = await previewCatalogParentRepair("normal");
mocks.values.mockRejectedValue(new Error("Audit unavailable"));
await expect(
applyCatalogParentRepair("normal", preview.fingerprint, 7),
).rejects.toThrow("Audit unavailable");
});
});