- Replace Prisma client runtime with Drizzle ORM (zero Prisma engine/query engine in production) - Add Prisma-compatible facade (@/lib/prisma-facade.ts) backed by Drizzle for backwards compatibility - Runtime queries route through Drizzle ORM; @prisma/client is now devDependency (types only) - Remove @prisma/adapter-mariadb dependency; delete prisma-pool.ts and types/prisma.ts - New Drizzle schema layer: src/db/schema.ts (176 tables) and src/lib/db.ts (connection) - Update README documenting the dual-layer ORM architecture - Restore src/generated/ gitignore (build artifact for local type generation) - 0 TypeScript errors, 583 tests passing The facade intentionally uses `any` types to match the Prisma Client API surface, allowing existing code to run unmodified while routing queries through Drizzle at runtime.
221 lines
5.5 KiB
TypeScript
221 lines
5.5 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
const insertValues = vi.hoisted(() => vi.fn());
|
|
const selectLimit = vi.hoisted(() => vi.fn());
|
|
const updateSetData = vi.hoisted(() => vi.fn());
|
|
const updateWhere = vi.hoisted(() => vi.fn());
|
|
const deleteWhere = vi.hoisted(() => vi.fn());
|
|
const executeMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("@/lib/db", () => ({
|
|
db: {
|
|
insert: () => ({ values: insertValues }),
|
|
select: () => ({
|
|
from: () => ({
|
|
where: () => ({
|
|
limit: () => Promise.resolve(selectLimit()),
|
|
}),
|
|
orderBy: () => ({
|
|
where: () => ({
|
|
limit: () => Promise.resolve(selectLimit()),
|
|
}),
|
|
}),
|
|
}),
|
|
}),
|
|
update: () => ({
|
|
set: (data: Record<string, unknown>) => {
|
|
updateSetData(data);
|
|
return {
|
|
where: () => updateWhere(data),
|
|
};
|
|
},
|
|
}),
|
|
delete: () => ({
|
|
where: () => Promise.resolve(deleteWhere()),
|
|
}),
|
|
execute: executeMock,
|
|
},
|
|
CatalogPages: { id: "CatalogPages.id", parentId: "CatalogPages.parentId" },
|
|
}));
|
|
|
|
vi.mock("@/env", () => ({ env: {} }));
|
|
|
|
import type { TreeNode } from "@/types/catalog";
|
|
import {
|
|
buildNestedTree,
|
|
createPage,
|
|
deletePage,
|
|
getAncestors,
|
|
movePage,
|
|
toInt,
|
|
} from "./catalog-tree";
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("toInt", () => {
|
|
it("converts valid numbers", () => {
|
|
expect(toInt(42)).toBe(42);
|
|
expect(toInt("42")).toBe(42);
|
|
});
|
|
|
|
it("uses fallback for invalid", () => {
|
|
expect(toInt("abc", -1)).toBe(-1);
|
|
expect(toInt(NaN, 0)).toBe(0);
|
|
});
|
|
|
|
it("defaults fallback to 0", () => {
|
|
expect(toInt("abc")).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("buildNestedTree", () => {
|
|
const flat: TreeNode[] = [
|
|
{
|
|
id: 1,
|
|
caption: "Root",
|
|
parentId: -1,
|
|
depth: 0,
|
|
orderNum: 1,
|
|
enabled: "1",
|
|
visible: "1",
|
|
iconImage: 0,
|
|
iconColor: 0,
|
|
pageLayout: "default_3x3",
|
|
childCount: 2,
|
|
itemCount: 0,
|
|
},
|
|
{
|
|
id: 2,
|
|
caption: "Child",
|
|
parentId: 1,
|
|
depth: 1,
|
|
orderNum: 1,
|
|
enabled: "1",
|
|
visible: "1",
|
|
iconImage: 0,
|
|
iconColor: 0,
|
|
pageLayout: "default_3x3",
|
|
childCount: 1,
|
|
itemCount: 5,
|
|
},
|
|
{
|
|
id: 3,
|
|
caption: "Grandchild",
|
|
parentId: 2,
|
|
depth: 2,
|
|
orderNum: 1,
|
|
enabled: "1",
|
|
visible: "1",
|
|
iconImage: 0,
|
|
iconColor: 0,
|
|
pageLayout: "default_3x3",
|
|
childCount: 0,
|
|
itemCount: 0,
|
|
},
|
|
];
|
|
|
|
it("builds nested tree from flat nodes", () => {
|
|
const tree = buildNestedTree(flat);
|
|
expect(tree).toHaveLength(1);
|
|
expect(tree[0].id).toBe(1);
|
|
expect(tree[0].children).toHaveLength(1);
|
|
expect(tree[0].children[0].children[0].id).toBe(3);
|
|
});
|
|
|
|
it("returns empty array for empty input", () => {
|
|
expect(buildNestedTree([])).toEqual([]);
|
|
});
|
|
|
|
it("uses custom rootParentId", () => {
|
|
expect(buildNestedTree(flat, 999)).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("createPage", () => {
|
|
it("creates page with default values", async () => {
|
|
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
|
await createPage({ parentId: 0, caption: "Test" });
|
|
|
|
const data = insertValues.mock.calls[0][0];
|
|
expect(data.caption).toBe("Test");
|
|
expect(data.pageLayout).toBe("default_3x3");
|
|
expect(data.visible).toBe("1");
|
|
expect(data.enabled).toBe("1");
|
|
expect(data.clubOnly).toBe("0");
|
|
expect(data.minRank).toBe(1);
|
|
});
|
|
|
|
it("truncates captionSave to 25 chars", async () => {
|
|
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
|
await createPage({ parentId: 0, caption: "a".repeat(50) });
|
|
|
|
expect(insertValues.mock.calls[0][0].captionSave).toBe("a".repeat(25));
|
|
});
|
|
|
|
it("converts boolean visible/enabled to string", async () => {
|
|
insertValues.mockResolvedValue([{ insertId: 1 }]);
|
|
await createPage({
|
|
parentId: 0,
|
|
caption: "x",
|
|
visible: false,
|
|
enabled: false,
|
|
});
|
|
expect(insertValues.mock.calls[0][0].visible).toBe("0");
|
|
expect(insertValues.mock.calls[0][0].enabled).toBe("0");
|
|
});
|
|
});
|
|
|
|
describe("movePage", () => {
|
|
it("throws on circular hierarchy", async () => {
|
|
selectLimit.mockResolvedValue([{ parentId: 1 }]);
|
|
await expect(movePage(1, 2)).rejects.toThrow("circular hierarchy");
|
|
});
|
|
|
|
it("allows valid move to root", async () => {
|
|
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
|
await movePage(1, 0);
|
|
expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 0 });
|
|
});
|
|
|
|
it("allows valid move to other parent", async () => {
|
|
selectLimit.mockResolvedValue([{ parentId: 0 }]);
|
|
updateWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
|
await movePage(1, 2);
|
|
expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 2 });
|
|
});
|
|
});
|
|
|
|
describe("getAncestors", () => {
|
|
it("returns breadcrumb from root to page", async () => {
|
|
selectLimit
|
|
.mockResolvedValueOnce([{ id: 3, caption: "Grandchild", parentId: 2 }])
|
|
.mockResolvedValueOnce([{ id: 2, caption: "Child", parentId: 1 }])
|
|
.mockResolvedValueOnce([{ id: 1, caption: "Root", parentId: 0 }]);
|
|
|
|
const ancestors = await getAncestors(3);
|
|
expect(ancestors).toHaveLength(3);
|
|
expect(ancestors[0].caption).toBe("Root");
|
|
expect(ancestors[1].caption).toBe("Child");
|
|
expect(ancestors[2].caption).toBe("Grandchild");
|
|
});
|
|
|
|
it("stops when page not found", async () => {
|
|
selectLimit.mockResolvedValue([]);
|
|
expect(await getAncestors(999)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("deletePage", () => {
|
|
it("reparent mode moves children to parent", async () => {
|
|
selectLimit.mockResolvedValue([{ parentId: 1 }]);
|
|
updateWhere.mockResolvedValue([{ affectedRows: 2 }]);
|
|
deleteWhere.mockResolvedValue([{ affectedRows: 1 }]);
|
|
|
|
const result = await deletePage(5, "reparent");
|
|
expect(result).toEqual({ deletedPages: 1, movedChildren: 2 });
|
|
expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 1 });
|
|
});
|
|
});
|