refactor(catalog): unify commands and embed guarded catalog workspace
This commit is contained in:
1 parent
16f35568f5
commit
193b6686a9
57 files changed
+3268
-728
No files matched your search
@@ -0,0 +1,43 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({ finish: vi.fn(), log: vi.fn() }));
|
||||
vi.mock("./catalog-git-config", () => ({
|
||||
readManagedCatalogConfig: () => ({ enabled: true }),
|
||||
catalogStateRoot: () => "unused",
|
||||
}));
|
||||
vi.mock("./catalog-git-core", () => ({
|
||||
CatalogExportQueue: class {
|
||||
async begin() {
|
||||
return state.finish;
|
||||
}
|
||||
},
|
||||
}));
|
||||
vi.mock("@/lib/logger", () => ({ logger: { error: state.log } }));
|
||||
|
||||
import { isCatalogMutation, withCatalogExport } from "./catalog-git-queue";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
state.finish.mockRejectedValue(Error("disk full"));
|
||||
});
|
||||
it("does not turn a committed create into a failed action when queue completion fails", async () => {
|
||||
expect(await withCatalogExport(async () => ({ ok: true, id: 42 }))).toEqual({
|
||||
ok: true,
|
||||
id: 42,
|
||||
});
|
||||
expect(state.log).toHaveBeenCalled();
|
||||
});
|
||||
it("preserves the original mutation error if cleanup also fails", async () => {
|
||||
await expect(
|
||||
withCatalogExport(async () => {
|
||||
throw Error("database conflict");
|
||||
}),
|
||||
).rejects.toThrow("database conflict");
|
||||
});
|
||||
|
||||
it("keeps hotel retries independent from Git export scheduling", () => {
|
||||
expect(isCatalogMutation("POST", "/api/admin/catalog/status")).toBe(false);
|
||||
expect(isCatalogMutation("POST", "/api/admin/catalog/status/")).toBe(false);
|
||||
expect(isCatalogMutation("PATCH", "/api/admin/catalog/tree")).toBe(true);
|
||||
expect(isCatalogMutation("POST", "/api/admin/import/furni")).toBe(true);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { logger } from "@/lib/logger";
|
||||
import {
|
||||
catalogStateRoot,
|
||||
readManagedCatalogConfig,
|
||||
@@ -18,6 +19,7 @@ export function catalogExportQueue() {
|
||||
export function isCatalogMutation(method: string, pathname: string) {
|
||||
return (
|
||||
["POST", "PUT", "PATCH", "DELETE"].includes(method) &&
|
||||
pathname.replace(/\/$/, "") !== "/api/admin/catalog/status" &&
|
||||
(pathname.startsWith("/api/admin/import/") ||
|
||||
pathname.startsWith("/api/admin/catalog/") ||
|
||||
pathname.startsWith("/api/admin/furni/") ||
|
||||
@@ -53,7 +55,14 @@ export async function withCatalogExport<T>(
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
await finish();
|
||||
try {
|
||||
await finish();
|
||||
} catch (error) {
|
||||
logger.error("Catalog operation finished but export scheduling failed", {
|
||||
module: "catalog",
|
||||
error,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { asc, sql } from "drizzle-orm";
|
||||
import { numericValue } from "@/features/catalog/domain/offer-input";
|
||||
import { CatalogPages, db } from "@/lib/db";
|
||||
import { getFurnitureDataPath } from "@/lib/services/furni-data";
|
||||
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
|
||||
@@ -147,17 +148,17 @@ export async function loadCatalogItemsData(
|
||||
const items: RawItem[] = rawItems.map((r: Record<string, unknown>) => ({
|
||||
id: Number(r.id),
|
||||
itemIds: String(r.item_ids ?? ""),
|
||||
pageId: Number(r.page_id) || 0,
|
||||
offerId: Number(r.offer_id) || -1,
|
||||
songId: Number(r.song_id) || 0,
|
||||
orderNumber: Number(r.order_number) || 99,
|
||||
pageId: numericValue(r.page_id, 0),
|
||||
offerId: numericValue(r.offer_id, -1),
|
||||
songId: numericValue(r.song_id, 0),
|
||||
orderNumber: numericValue(r.order_number, 99),
|
||||
catalogName: String(r.catalog_name ?? ""),
|
||||
costCredits: Number(r.cost_credits) || 0,
|
||||
costPoints: Number(r.cost_points) || 0,
|
||||
pointsType: Number(r.points_type) || 0,
|
||||
amount: Number(r.amount) || 1,
|
||||
limitedSells: Number(r.limited_sells) || 0,
|
||||
limitedStack: Number(r.limited_stack) || 0,
|
||||
costCredits: numericValue(r.cost_credits, 0),
|
||||
costPoints: numericValue(r.cost_points, 0),
|
||||
pointsType: numericValue(r.points_type, 0),
|
||||
amount: numericValue(r.amount, 1),
|
||||
limitedSells: numericValue(r.limited_sells, 0),
|
||||
limitedStack: numericValue(r.limited_stack, 0),
|
||||
extradata: String(r.extradata ?? ""),
|
||||
haveOffer: String(r.have_offer ?? "1"),
|
||||
clubOnly: String(r.club_only ?? "0"),
|
||||
@@ -217,9 +218,9 @@ export async function loadCatalogItemsData(
|
||||
publicName: String(r.public_name ?? ""),
|
||||
itemName: String(r.item_name ?? ""),
|
||||
type: String(r.type ?? "s"),
|
||||
width: Number(r.width) || 1,
|
||||
length: Number(r.length) || 1,
|
||||
stackHeight: Number(r.stack_height) || 0,
|
||||
width: numericValue(r.width, 1),
|
||||
length: numericValue(r.length, 1),
|
||||
stackHeight: numericValue(r.stack_height, 0),
|
||||
allowStack: Number(r.allow_stack ?? 0),
|
||||
allowSit: Number(r.allow_sit ?? 0),
|
||||
allowLay: Number(r.allow_lay ?? 0),
|
||||
@@ -230,11 +231,11 @@ export async function loadCatalogItemsData(
|
||||
allowMarketplaceSell: Number(r.allow_marketplace_sell ?? 0),
|
||||
allowInventoryStack: Number(r.allow_inventory_stack ?? 1),
|
||||
interactionType: String(r.interaction_type ?? "default"),
|
||||
interactionModesCount: Number(r.interaction_modes_count) || 2,
|
||||
interactionModesCount: numericValue(r.interaction_modes_count, 2),
|
||||
vendingIds: String(r.vending_ids ?? "0"),
|
||||
customparams: String(r.customparams ?? ""),
|
||||
effectIdMale: Number(r.effect_id_male) || 0,
|
||||
effectIdFemale: Number(r.effect_id_female) || 0,
|
||||
effectIdMale: numericValue(r.effect_id_male, 0),
|
||||
effectIdFemale: numericValue(r.effect_id_female, 0),
|
||||
clothingOnWalk: String(r.clothing_on_walk ?? ""),
|
||||
}));
|
||||
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const commands = vi.hoisted(() => ({
|
||||
updatePageCommand: vi.fn(),
|
||||
deletePageCommand: vi.fn(),
|
||||
}));
|
||||
vi.mock("@/features/catalog/server/page-commands", () => commands);
|
||||
const insertValues = vi.hoisted(() => vi.fn());
|
||||
const selectLimit = vi.hoisted(() => vi.fn());
|
||||
const updateSetData = vi.hoisted(() => vi.fn());
|
||||
@@ -168,22 +173,11 @@ describe("createPage", () => {
|
||||
});
|
||||
|
||||
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 }]);
|
||||
it("uses the shared structural command", async () => {
|
||||
await movePage(1, 2);
|
||||
expect(updateSetData.mock.calls[0][0]).toEqual({ parentId: 2 });
|
||||
expect(commands.updatePageCommand).toHaveBeenCalledWith("normal", 1, {
|
||||
parentId: 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -208,13 +202,19 @@ describe("getAncestors", () => {
|
||||
});
|
||||
|
||||
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 });
|
||||
it("uses the shared transactional delete", async () => {
|
||||
commands.deletePageCommand.mockResolvedValue({
|
||||
deletedPages: 1,
|
||||
movedChildren: 2,
|
||||
});
|
||||
expect(await deletePage(5, "reparent")).toEqual({
|
||||
deletedPages: 1,
|
||||
movedChildren: 2,
|
||||
});
|
||||
expect(commands.deletePageCommand).toHaveBeenCalledWith(
|
||||
"normal",
|
||||
5,
|
||||
"reparent",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,8 @@
|
||||
import { asc, eq, inArray, sql } from "drizzle-orm";
|
||||
import { asc, eq, sql } from "drizzle-orm";
|
||||
import {
|
||||
deletePageCommand,
|
||||
updatePageCommand,
|
||||
} from "@/features/catalog/server/page-commands";
|
||||
import { CatalogPages, db } from "@/lib/db";
|
||||
|
||||
export type { NestedTreeNode, TreeNode } from "@/types/catalog";
|
||||
@@ -72,7 +76,7 @@ export async function getTreeFlat(): Promise<TreeNode[]> {
|
||||
enabled: CatalogPages.enabled,
|
||||
})
|
||||
.from(CatalogPages)
|
||||
.orderBy(asc(CatalogPages.orderNum));
|
||||
.orderBy(asc(CatalogPages.orderNum), asc(CatalogPages.id));
|
||||
const itemCountMap = await getCatalogItemCounts();
|
||||
|
||||
const childCountMap = new Map<number, number>();
|
||||
@@ -204,94 +208,13 @@ export async function movePage(
|
||||
pageId: number,
|
||||
newParentId: number,
|
||||
): Promise<void> {
|
||||
if (newParentId > 0) {
|
||||
let currentId = newParentId;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
if (currentId === pageId) {
|
||||
throw new Error("Cannot move page: would create a circular hierarchy");
|
||||
}
|
||||
const [parent] = await db
|
||||
.select({ parentId: CatalogPages.parentId })
|
||||
.from(CatalogPages)
|
||||
.where(eq(CatalogPages.id, currentId))
|
||||
.limit(1);
|
||||
if (!parent || parent.parentId <= 0) break;
|
||||
currentId = parent.parentId;
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(CatalogPages)
|
||||
.set({ parentId: newParentId })
|
||||
.where(eq(CatalogPages.id, pageId));
|
||||
await updatePageCommand("normal", pageId, { parentId: newParentId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete catalog_items for the given page ids.
|
||||
* Habbo DBs often store page_id as VARCHAR; text delete on INT or VARCHAR
|
||||
* columns keeps the page_id index usable (CAST would force a full scan).
|
||||
*/
|
||||
async function deleteCatalogItemsByPageIds(pageIds: number[]): Promise<void> {
|
||||
if (pageIds.length === 0) return;
|
||||
const idStrs = pageIds.map(String);
|
||||
await db.execute(sql`
|
||||
DELETE FROM catalog_items
|
||||
WHERE page_id IN (${sql.join(idStrs, sql`, `)})
|
||||
`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a page with cascade or reparent mode.
|
||||
*/
|
||||
export async function deletePage(
|
||||
pageId: number,
|
||||
mode: "cascade" | "reparent" = "reparent",
|
||||
): Promise<{ deletedPages: number; movedChildren: number }> {
|
||||
const [page] = await db
|
||||
.select({ parentId: CatalogPages.parentId })
|
||||
.from(CatalogPages)
|
||||
.where(eq(CatalogPages.id, pageId))
|
||||
.limit(1);
|
||||
if (!page) return { deletedPages: 0, movedChildren: 0 };
|
||||
|
||||
if (mode === "reparent") {
|
||||
const [result] = await db
|
||||
.update(CatalogPages)
|
||||
.set({ parentId: page.parentId })
|
||||
.where(eq(CatalogPages.parentId, pageId));
|
||||
|
||||
await deleteCatalogItemsByPageIds([pageId]);
|
||||
await db.delete(CatalogPages).where(eq(CatalogPages.id, pageId));
|
||||
|
||||
return { deletedPages: 1, movedChildren: result.affectedRows };
|
||||
}
|
||||
|
||||
const deleted = await cascadeDelete(pageId);
|
||||
return { deletedPages: deleted, movedChildren: 0 };
|
||||
}
|
||||
|
||||
async function cascadeDelete(pageId: number): Promise<number> {
|
||||
const toDelete: number[] = [pageId];
|
||||
const queue: number[] = [pageId];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const children = await db
|
||||
.select({ id: CatalogPages.id })
|
||||
.from(CatalogPages)
|
||||
.where(inArray(CatalogPages.parentId, queue));
|
||||
queue.length = 0;
|
||||
for (const child of children) {
|
||||
toDelete.push(child.id);
|
||||
queue.push(child.id);
|
||||
}
|
||||
}
|
||||
|
||||
await deleteCatalogItemsByPageIds(toDelete);
|
||||
for (let i = toDelete.length - 1; i >= 0; i--) {
|
||||
await db.delete(CatalogPages).where(eq(CatalogPages.id, toDelete[i]));
|
||||
}
|
||||
|
||||
return toDelete.length;
|
||||
) {
|
||||
return deletePageCommand("normal", pageId, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in new issue
Block a user