feat(catalog): prevent and repair Arcturus emulator data errors
CI / check (push) Failing after 26s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

Block invalid catalog_items writes at the API level (points currency
allowlist, non-negative prices, positive amount, limited stack >= sold
count, unique sibling order numbers) and auto-assign unique order numbers
on bulk create. Add a catalog-maintenance scan + transactional repair that
fixes pre-existing rows: resets unsupported points_type, clamps negative
costs, sets amount to 1, raises limited_stack, renumbers duplicate orders
and deletes offers with missing page/item references. Surface the issue
count and a fix button in the admin maintenance panel.

Also: add enabled/retired flag to clone sources, classify poster and
currency furniture in item-kind, and remove the obsolete update-Nitrov3.sh.
This commit is contained in:
openhands committed 2026-09-10 11:29:28 +02:00
1 parent ad65d80d41
commit 3e69b72513
26 files changed
+17651 -3844

No files matched your search

+167
View File
@@ -0,0 +1,167 @@
import type { SQL } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
items: [] as Array<Record<string, unknown>>,
pages: [] as Array<Record<string, unknown>>,
bases: [] as Array<Record<string, unknown>>,
orderRows: [] as Array<Record<string, unknown>>,
queries: [] as { sql: string; params: unknown[] }[],
writes: 0,
}));
vi.mock("@/lib/db", () => ({
db: {
execute: async (query: SQL) => {
const compiled = new MySqlDialect().sqlToQuery(query);
if (compiled.sql.includes("FROM catalog_items ci"))
return [state.items, []];
if (compiled.sql.includes("FROM catalog_pages")) return [state.pages, []];
if (compiled.sql.includes("FROM items_base")) return [state.bases, []];
return [[], []];
},
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
return fn({
execute: async (query: SQL) => {
const compiled = new MySqlDialect().sqlToQuery(query);
state.queries.push(compiled);
if (compiled.sql.trimStart().startsWith("SELECT"))
return [state.orderRows, []];
state.writes++;
return [{ affectedRows: 1 }, []];
},
});
},
},
}));
import { fixEmulatorIssues, listEmulatorIssues } from "./furni-maintenance";
const item = (id: number, overrides: Record<string, unknown> = {}) => ({
id,
pageId: 10,
itemIds: "12",
costCredits: 0,
costPoints: 0,
pointsType: 0,
amount: 1,
limitedStack: 0,
limitedSells: 0,
orderNumber: 1,
...overrides,
});
beforeEach(() => {
state.items = [];
state.pages = [{ id: 10 }];
state.bases = [{ id: 12 }];
state.orderRows = [];
state.queries = [];
state.writes = 0;
});
describe("listEmulatorIssues", () => {
it("flags every emulator constraint violation on one row", async () => {
state.items = [
item(1, {
pageId: 500,
itemIds: "10834",
pointsType: 101,
costCredits: -1,
amount: 0,
}),
];
const issues = await listEmulatorIssues();
const types = issues.map((i) => i.type).sort();
expect(types).toContain("missing_page");
expect(types).toContain("missing_item");
expect(types).toContain("points_currency");
expect(types).toContain("negative_cost");
expect(types).toContain("nonpositive_amount");
});
it("flags limited stack below the sold count", async () => {
state.items = [item(1, { limitedStack: 5, limitedSells: 10 })];
const issues = await listEmulatorIssues();
expect(issues.map((i) => i.type)).toContain("limited_stack_vs_sells");
});
it("flags sibling offers that share an order number on the same page", async () => {
state.items = [
item(1, { orderNumber: 1 }),
item(2, { orderNumber: 1 }),
item(3, { orderNumber: 2 }),
];
const issues = await listEmulatorIssues();
const dup = issues.filter((i) => i.type === "duplicate_order");
expect(dup.map((i) => i.id).sort()).toEqual([1, 2]);
});
it("accepts valid pricing row without flagging", async () => {
state.items = [item(1, { orderNumber: 1 })];
const issues = await listEmulatorIssues();
expect(issues).toEqual([]);
});
});
describe("fixEmulatorIssues", () => {
it("deletes orphans, sanitizes pricing and renumbers duplicate orders", async () => {
state.items = [
item(1, {
pageId: 500,
itemIds: "10834",
pointsType: 101,
costCredits: -5,
costPoints: -1,
amount: 0,
}),
item(2, { limitedStack: 5, limitedSells: 10 }),
item(3, { orderNumber: 1 }),
item(4, { orderNumber: 1 }),
item(5, { orderNumber: 2 }),
];
state.orderRows = [
{ id: 3, pageId: 10, orderNumber: 1 },
{ id: 4, pageId: 10, orderNumber: 1 },
{ id: 5, pageId: 10, orderNumber: 2 },
];
const result = await fixEmulatorIssues();
const bySql = (needle: string) =>
state.queries.filter((q) =>
q.sql.replace(/\s+/g, " ").trimStart().includes(needle),
);
const deleteItems = bySql("DELETE FROM catalog_items WHERE id IN");
expect(deleteItems.length).toBe(1);
expect(deleteItems[0].params).toContain(1);
const pointsFix = bySql(
"UPDATE catalog_items SET points_type = 0 WHERE id IN",
);
expect(pointsFix.length).toBe(1);
expect(pointsFix[0].params).toContain(1);
const costFix = bySql("cost_credits = CASE WHEN cost_credits < 0");
expect(costFix.length).toBe(1);
const amountFix = bySql("UPDATE catalog_items SET amount = 1 WHERE id IN");
expect(amountFix.length).toBe(1);
const limitedFix = bySql(
"UPDATE catalog_items SET limited_stack = limited_sells WHERE id IN",
);
expect(limitedFix.length).toBe(1);
expect(limitedFix[0].params).toContain(2);
expect(state.writes).toBeGreaterThan(0);
expect(result.detail.length).toBeGreaterThan(0);
});
it("does not write when there is nothing to fix", async () => {
state.items = [item(1, { orderNumber: 1 })];
const result = await fixEmulatorIssues();
expect(result.fixed).toBe(0);
expect(state.writes).toBe(0);
});
});