refactor(catalog): unify commands and embed guarded catalog workspace
CI / check (push) Successful in 1m16s
CI / deploy (push) Successful in 28s

This commit is contained in:
Simo committed 2026-09-06 19:40:52 +02:00
1 parent 16f35568f5
commit 193b6686a9
57 files changed
+3268 -728

No files matched your search

@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import {
assertParent,
collectSubtree,
validateSiblingOrder,
} from "./hierarchy";
const pages = [
{ id: 1, parentId: -1, orderNum: 0 },
{ id: 2, parentId: 1, orderNum: 1 },
{ id: 3, parentId: 1, orderNum: 2 },
{ id: 4, parentId: 2, orderNum: 1 },
];
describe("catalog hierarchy commands", () => {
it("rejects self, descendant, missing and malformed destinations", () => {
for (const parent of [1, 2, 4, 999, 1.5, -2])
expect(() => assertParent(pages, 1, parent)).toThrow();
});
it("allows detaching a page from an inherited cycle", () =>
expect(() =>
assertParent(
[
{ id: 1, parentId: 2 },
{ id: 2, parentId: 1 },
],
1,
-1,
),
).not.toThrow());
it("rejects a destination with an inherited parent cycle", () =>
expect(() =>
assertParent(
[
{ id: 1, parentId: -1 },
{ id: 2, parentId: 3 },
{ id: 3, parentId: 2 },
],
1,
2,
),
).toThrow());
it("collects cyclic subtrees once without recursion", () =>
expect(
collectSubtree(
[
{ id: 1, parentId: 2 },
{ id: 2, parentId: 1 },
],
1,
),
).toEqual([1, 2]));
it("rejects stale or partial sibling lists before reordering", () => {
expect(() => validateSiblingOrder(pages, 1, [2], [2, 3])).toThrow();
expect(() => validateSiblingOrder(pages, 1, [3, 2], [3, 2])).toThrow();
expect(() => validateSiblingOrder(pages, 1, [2, 2], [2, 3])).toThrow();
});
it("accepts a complete reorder matching the previous sibling order", () =>
expect(() => validateSiblingOrder(pages, 1, [3, 2], [2, 3])).not.toThrow());
});
it("orders mixed root conventions with a stable ID tie breaker", () => {
const roots = [
{ id: 3, parentId: 0, orderNum: 1 },
{ id: 2, parentId: -1, orderNum: 1 },
];
expect(() => validateSiblingOrder(roots, -1, [3, 2], [2, 3])).not.toThrow();
expect(() => validateSiblingOrder(roots, -1, [2, 3], [3, 2])).toThrow();
});
+83
View File
@@ -0,0 +1,83 @@
export interface HierarchyPage {
id: number;
parentId: number;
orderNum?: number;
}
export class CatalogConflict extends Error {}
export class CatalogInputError extends Error {}
export class CatalogNotFound extends Error {}
export function positiveId(id: number) {
if (!Number.isSafeInteger(id) || id <= 0)
throw new CatalogInputError("Invalid catalog ID");
}
export function assertParent(
pages: readonly HierarchyPage[],
pageId: number,
parentId: number,
) {
if (!Number.isSafeInteger(parentId) || parentId < -1)
throw new CatalogInputError("Invalid parent category");
const map = new Map(pages.map((page) => [page.id, page]));
const visited = new Set<number>();
let current = parentId;
while (current > 0) {
if (current === pageId || visited.has(current))
throw new CatalogInputError(
"Cannot move page: would create a circular hierarchy",
);
visited.add(current);
const parent = map.get(current);
if (!parent) throw new CatalogInputError("Parent category does not exist");
current = parent.parentId;
}
}
export function collectSubtree(
pages: readonly HierarchyPage[],
pageId: number,
): number[] {
const children = new Map<number, number[]>();
for (const page of pages) {
const list = children.get(page.parentId) ?? [];
list.push(page.id);
children.set(page.parentId, list);
}
const seen = new Set<number>();
const queue = [pageId];
for (let index = 0; index < queue.length; index++) {
const id = queue[index];
if (seen.has(id)) continue;
seen.add(id);
queue.push(...(children.get(id) ?? []));
}
return [...seen];
}
export function validateSiblingOrder(
pages: readonly HierarchyPage[],
parentId: number,
ids: readonly number[],
expected: readonly number[],
) {
const current = pages
.filter((page) =>
parentId <= 0
? page.parentId === -1 || page.parentId === 0
: page.parentId === parentId,
)
.sort((a, b) => (a.orderNum ?? 0) - (b.orderNum ?? 0) || a.id - b.id)
.map((page) => page.id);
if (
current.length !== expected.length ||
current.some((id, index) => id !== expected[index])
)
throw new CatalogConflict(
"The category order changed. Reload before trying again.",
);
if (
ids.length !== current.length ||
new Set(ids).size !== ids.length ||
ids.some((id) => !current.includes(id))
)
throw new CatalogInputError(
"A reorder must include every sibling exactly once",
);
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import {
numericValue,
offerPatchSchema,
parseFurnitureIds,
} from "./offer-input";
describe("offer compatibility", () => {
it.each([0, "0", -1, "-1"])(
"preserves explicit numeric value %s",
(value) => {
expect(numericValue(value, 99)).toBe(Number(value));
},
);
it.each([null, undefined, "", "invalid", Infinity])(
"defaults missing or invalid value %s",
(value) => {
expect(numericValue(value, 99)).toBe(99);
},
);
it("preserves bundle multiplicity and LTD/special offer values", () => {
expect(parseFurnitureIds("12;23;12")).toEqual([12, 23, 12]);
const values = {
itemIds: "12;23;12",
amount: 0,
offerId: -1,
orderNumber: 0,
limitedSells: 4,
limitedStack: 20,
songId: 0,
extradata: "a;b",
haveOffer: "0",
clubOnly: "1",
};
expect(offerPatchSchema.parse(values)).toEqual(values);
});
it.each(["1;0", "1;2x", "1;;2", "-1", "1.5"])(
"rejects invalid furniture references %s",
(value) => {
expect(() => parseFurnitureIds(value)).toThrow();
},
);
it("rejects invalid page ids and nonfinite prices without changing field meaning", () => {
expect(offerPatchSchema.safeParse({ pageId: 0 }).success).toBe(false);
expect(offerPatchSchema.safeParse({ costCredits: Infinity }).success).toBe(
false,
);
expect(offerPatchSchema.parse({ offerId: 0, amount: 0 })).toEqual({
offerId: 0,
amount: 0,
});
});
});
@@ -0,0 +1,83 @@
import { z } from "zod";
export const offerInteger = z.number().int().min(-2147483648).max(2147483647);
export const offerIdSchema = offerInteger.positive();
/** Missing SQL values use the field default; explicit zero and special negatives survive. */
export function numericValue(value: unknown, fallback: number): number {
if (value == null || (typeof value === "string" && value.trim() === ""))
return fallback;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
export function parseFurnitureIds(value: string): number[] {
const parts = value.split(";");
if (!parts.length || parts.some((part) => !/^\d+$/.test(part.trim())))
throw Error("Invalid furniture references");
return parts.map((part) => offerIdSchema.parse(Number(part)));
}
const itemIds = z
.string()
.max(666)
.refine((value) => {
try {
parseFurnitureIds(value);
return true;
} catch {
return false;
}
}, "Invalid furniture references");
// Keep signed/special values on existing offer fields: bundle/LTD meaning belongs to the emulator.
export const offerPatchSchema = z.object({
pageId: offerIdSchema.optional(),
itemIds: itemIds.optional(),
catalogName: z.string().max(100).optional(),
costCredits: offerInteger.optional(),
costPoints: offerInteger.optional(),
pointsType: offerInteger.optional(),
amount: offerInteger.optional(),
orderNumber: offerInteger.optional(),
offerId: offerInteger.optional(),
songId: z.number().int().min(0).max(4294967295).optional(),
limitedSells: offerInteger.optional(),
limitedStack: offerInteger.optional(),
extradata: z.string().max(500).optional(),
haveOffer: z.enum(["0", "1"]).optional(),
clubOnly: z.enum(["0", "1"]).optional(),
});
const flag = z.union([
z.literal(0),
z.literal(1),
z.literal("0"),
z.literal("1"),
]);
export const furniturePatchSchema = z.object({
publicName: z.string().max(56).optional(),
itemName: z.string().max(70).optional(),
type: z.string().max(3).optional(),
width: offerInteger.optional(),
length: offerInteger.optional(),
stackHeight: z.number().finite().optional(),
allowStack: flag.optional(),
allowSit: flag.optional(),
allowLay: flag.optional(),
allowWalk: flag.optional(),
allowGift: flag.optional(),
allowTrade: flag.optional(),
allowRecycle: flag.optional(),
allowMarketplaceSell: flag.optional(),
allowInventoryStack: flag.optional(),
interactionType: z.string().max(500).optional(),
interactionModesCount: offerInteger.optional(),
vendingIds: z.string().max(255).optional(),
customparams: z.string().max(25600).optional(),
effectIdMale: offerInteger.optional(),
effectIdFemale: offerInteger.optional(),
clothingOnWalk: z.string().max(255).optional(),
});
export function distinctOfferIds(ids: number[]): number[] {
for (const id of ids) offerIdSchema.parse(id);
if (new Set(ids).size !== ids.length)
throw Error("Duplicate catalog item ids");
return ids;
}
@@ -0,0 +1,27 @@
import { expect, it } from "vitest";
import { pagePatchSchema } from "./page-input";
it("preserves nullable page texts when editing unrelated fields", () => {
const fields = {
caption: "Changed",
pageText1: null,
pageText2: null,
pageTextDetails: null,
pageTextTeaser: null,
pageSpecial: null,
};
expect(pagePatchSchema.parse(fields)).toEqual(fields);
});
it("preserves valid zero values and rejects invalid flags", () => {
expect(
pagePatchSchema.parse({ parentId: 0, orderNum: 0, iconImage: 0 }),
).toEqual({ parentId: 0, orderNum: 0, iconImage: 0 });
expect(() => pagePatchSchema.parse({ enabled: "true" })).toThrow();
});
it("preserves caption whitespace for optimistic comparisons but rejects blank captions", () => {
expect(pagePatchSchema.parse({ caption: " Existing " })).toEqual({
caption: " Existing ",
});
expect(() => pagePatchSchema.parse({ caption: " " })).toThrow();
});
+31
View File
@@ -0,0 +1,31 @@
import { z } from "zod";
const flag = z.enum(["0", "1"]);
const integer = z.number().int().min(-2147483648).max(2147483647);
export const pagePatchSchema = z.object({
caption: z
.string()
.min(1)
.max(128)
.refine((value) => value.trim().length > 0, "Category name is required")
.optional(),
captionSave: z.string().max(25).optional(),
parentId: integer.min(-1).optional(),
pageLayout: z.string().min(1).max(26).optional(),
enabled: flag.optional(),
visible: flag.optional(),
clubOnly: flag.optional(),
vipOnly: flag.optional(),
minRank: integer.min(0).optional(),
orderNum: integer.optional(),
iconImage: integer.min(0).optional(),
iconColor: integer.min(0).optional(),
pageHeadline: z.string().max(1024).optional(),
pageTeaser: z.string().max(64).optional(),
pageSpecial: z.string().max(2048).nullable().optional(),
pageText1: z.string().nullable().optional(),
pageText2: z.string().nullable().optional(),
pageTextDetails: z.string().nullable().optional(),
pageTextTeaser: z.string().nullable().optional(),
includes: z.string().max(128).optional(),
});
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { parseCatalogSearch, validSearchQuery } from "./search";
describe("catalog search input", () => {
it("allows a one digit ID but requires at least two text characters", () => {
expect(validSearchQuery("7")).toBe(true);
expect(validSearchQuery("a")).toBe(false);
expect(validSearchQuery(" ab ")).toBe(true);
});
it("does not round oversized numeric identifiers", () => {
expect(parseCatalogSearch("normal", "9007199254740992").numericId).toBe(
null,
);
});
it("escapes LIKE wildcard and escape characters", () => {
expect(parseCatalogSearch("bc", "a_%=").pattern).toBe("a=_=%==%");
});
});
+32
View File
@@ -0,0 +1,32 @@
import { CatalogInputError } from "./hierarchy";
export type SearchCatalogKind = "normal" | "bc";
export interface CatalogSearchResult {
kind: "category" | "offer";
id: number;
pageId: number;
caption: string;
path: string[];
href: string;
furniture?: { id: number; classname: string; spriteId: number };
}
export function validSearchQuery(query: string) {
const value = query.trim();
return query.length <= 100 && (value.length >= 2 || /^\d$/.test(value));
}
export function parseCatalogSearch(kind: string, query: string) {
if (kind !== "normal" && kind !== "bc")
throw new CatalogInputError("Invalid catalog type");
if (!validSearchQuery(query))
throw new CatalogInputError(
"Search requires 2 to 100 characters or a numeric ID",
);
const value = query.trim();
const parsed = /^\d+$/.test(value) ? Number(value) : NaN;
const numericId = Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
return {
kind,
query: value,
numericId,
pattern: `${value.replace(/[=%_]/g, (char) => `=${char}`)}%`,
};
}