feat(catalog): prevent and repair Arcturus emulator data errors
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:
1 parent
ad65d80d41
commit
3e69b72513
26 files changed
+17651
-3844
No files matched your search
@@ -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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user