CI / check (push) Successful in 37s
CI / tests-unit (push) Successful in 2m3s
CI / tests-ui (push) Successful in 2m52s
CI / tests-integration (push) Successful in 2m54s
CI / preflight (push) Skipped
CI / tests-imaging-browsers (push) Failing after 3m23s
CI / deploy (push) Successful in 3m3s
CI / tests-quality (push) Successful in 6m8s
The CLI could audit and repair what already existed, but adding furniture present at a source was still only possible from the admin UI item by item. The repo already ships every piece of that pipeline, so wire them together as further steps of the same run: - `--import-new` imports furniture the compared source has and this hotel lacks. It reuses `getClonableClassnames` for the diff and `cloneSingleFurni` per item, which downloads the icon and the bundle (sources serve `.nitro`, stored as `.hab`), appends the furnidata entry with `deferFurniData` and creates the catalog page plus the offer itself. Deferred entries are written in one `appendFurniEntriesBulk` call, so 26000 items cost one JSON write, not one per item. `--only`, `--limit` and `--concurrency` narrow/tune it, failures are isolated per item and never abort the run. - `--reconcile`, `--fix-sprite-ids` and `--rebuild-offers` run the existing `reconcileFurniDataWithItemsBase`, `verifyAndFixSpriteIds` and `rebuildCatalogOfferIds` passes — the post-import consistency work. - `--align-ids` and `--rename-bundles` delegate to the existing standalone scripts; both only report until `--apply` is given, matching their own contracts. - The menu grows options 6-8 plus a dry run, each with its own confirmation. `--everything` turns on every repair + import step but deliberately leaves the destructive ones off. A live dry run against Wibbo listed 26593 items on the source that this hotel does not have, and Habbo (COM) reported 2871.
229 lines
6.5 KiB
TypeScript
229 lines
6.5 KiB
TypeScript
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
DEFAULT_CATALOG_SYNC_FLAGS,
|
|
describeActions,
|
|
parseCatalogSyncArgs,
|
|
selectImportCandidates,
|
|
writeGeneratedSql,
|
|
} from "./catalog-sync";
|
|
|
|
const entry = (classname: string) =>
|
|
({ classname, name: classname, itemType: "s" }) as never;
|
|
|
|
describe("catalog-sync argument parsing", () => {
|
|
it("defaults to a read-only audit with no filters", () => {
|
|
expect(parseCatalogSyncArgs([])).toEqual({
|
|
...DEFAULT_CATALOG_SYNC_FLAGS,
|
|
sourceIds: [],
|
|
});
|
|
expect(describeActions(parseCatalogSyncArgs([]))).toBe("audit");
|
|
});
|
|
|
|
it("accepts comma-separated source ids", () => {
|
|
const flags = parseCatalogSyncArgs([
|
|
"--sources=default-habbo-com,default-wibbo",
|
|
]);
|
|
expect(flags.sourceIds).toEqual(["default-habbo-com", "default-wibbo"]);
|
|
});
|
|
|
|
it("accepts display names, including ones with spaces", () => {
|
|
const flags = parseCatalogSyncArgs(["--sources=Habbo (COM), Wibbo"]);
|
|
expect(flags.sourceIds).toEqual(["Habbo (COM)", "Wibbo"]);
|
|
});
|
|
|
|
it("trims and drops empty source entries", () => {
|
|
expect(parseCatalogSyncArgs(["--sources= a , ,b,"]).sourceIds).toEqual([
|
|
"a",
|
|
"b",
|
|
]);
|
|
});
|
|
|
|
it("turns --all into every repair step", () => {
|
|
const flags = parseCatalogSyncArgs(["--all"]);
|
|
expect(flags.repairIcons).toBe(true);
|
|
expect(flags.repairNitros).toBe(true);
|
|
expect(flags.repairFurniData).toBe(true);
|
|
expect(flags.repairStructure).toBe(true);
|
|
expect(flags.checkFurniDataIds).toBe(true);
|
|
// Generating/applying SQL is never implied by a repair.
|
|
expect(flags.generateSql).toBe(false);
|
|
expect(flags.applySql).toBe(false);
|
|
});
|
|
|
|
it("treats applying SQL as implying generation", () => {
|
|
expect(parseCatalogSyncArgs(["--apply-sql"]).generateSql).toBe(true);
|
|
expect(parseCatalogSyncArgs(["--organize-sql"]).generateSql).toBe(true);
|
|
expect(describeActions(parseCatalogSyncArgs(["--apply-sql"]))).toBe(
|
|
"audit + apply-sql",
|
|
);
|
|
});
|
|
|
|
it("accepts the alias spellings of the repair flags", () => {
|
|
const flags = parseCatalogSyncArgs([
|
|
"--repair-icons",
|
|
"--repair-nitros",
|
|
"--repair-furnidata",
|
|
"--repair-structure",
|
|
]);
|
|
expect(flags.repairIcons).toBe(true);
|
|
expect(flags.repairNitros).toBe(true);
|
|
expect(flags.repairFurniData).toBe(true);
|
|
expect(flags.repairStructure).toBe(true);
|
|
});
|
|
|
|
it("ignores a bare -- separator", () => {
|
|
expect(parseCatalogSyncArgs(["--", "--icons"]).repairIcons).toBe(true);
|
|
});
|
|
|
|
it("rejects an unknown flag", () => {
|
|
expect(() => parseCatalogSyncArgs(["--nope"])).toThrow(/Unknown flag/);
|
|
});
|
|
|
|
it("rejects --sources without a value", () => {
|
|
expect(() => parseCatalogSyncArgs(["--sources"])).toThrow(/needs a value/);
|
|
});
|
|
});
|
|
|
|
describe("catalog-sync import selection", () => {
|
|
it("skips furniture this hotel already has", () => {
|
|
const candidates = selectImportCandidates(
|
|
[entry("chair"), entry("table"), entry("") as never],
|
|
new Set(["chair"]),
|
|
);
|
|
expect(candidates.map((c) => c.classname)).toEqual(["table"]);
|
|
});
|
|
|
|
it("is case-insensitive against the present set", () => {
|
|
const candidates = selectImportCandidates(
|
|
[entry("Chair"), entry("table")],
|
|
new Set(["chair"]),
|
|
);
|
|
expect(candidates.map((c) => c.classname)).toEqual(["table"]);
|
|
});
|
|
|
|
it("honours the --only filter, also case-insensitively", () => {
|
|
const candidates = selectImportCandidates(
|
|
[entry("Chair"), entry("table"), entry("lamp")],
|
|
new Set(),
|
|
{ only: ["chair"] },
|
|
);
|
|
expect(candidates.map((c) => c.classname)).toEqual(["Chair"]);
|
|
});
|
|
|
|
it("caps the list with --limit", () => {
|
|
const candidates = selectImportCandidates(
|
|
[entry("a"), entry("b"), entry("c")],
|
|
new Set(),
|
|
{ limit: 2 },
|
|
);
|
|
expect(candidates.map((c) => c.classname)).toEqual(["a", "b"]);
|
|
});
|
|
|
|
it("returns everything when no limit is given", () => {
|
|
expect(
|
|
selectImportCandidates([entry("a"), entry("b")], new Set(), {
|
|
limit: 0,
|
|
}).length,
|
|
).toBe(2);
|
|
});
|
|
});
|
|
|
|
describe("catalog-sync step composition", () => {
|
|
it("--everything turns on every step except the writing ones", () => {
|
|
const flags = parseCatalogSyncArgs(["--everything"]);
|
|
expect(describeActions(flags)).toBe(
|
|
[
|
|
"audit",
|
|
"icons",
|
|
"nitros",
|
|
"furnidata",
|
|
"structure",
|
|
"id-check",
|
|
"import",
|
|
"reconcile",
|
|
"sprite-ids",
|
|
"offers",
|
|
].join(" + "),
|
|
);
|
|
// Destructive steps stay opt-in even inside --everything.
|
|
expect(flags.applySql).toBe(false);
|
|
expect(flags.apply).toBe(false);
|
|
expect(flags.alignIds).toBe(false);
|
|
expect(flags.renameBundles).toBe(false);
|
|
});
|
|
|
|
it("marks an import as a dry run", () => {
|
|
expect(
|
|
describeActions(parseCatalogSyncArgs(["--import-new", "--dry-run"])),
|
|
).toContain("import (dry)");
|
|
});
|
|
|
|
it("marks the child steps as reporting unless --apply is given", () => {
|
|
expect(
|
|
describeActions(
|
|
parseCatalogSyncArgs(["--align-ids", "--rename-bundles"]),
|
|
),
|
|
).toContain("align-ids");
|
|
expect(
|
|
describeActions(
|
|
parseCatalogSyncArgs(["--align-ids", "--rename-bundles", "--apply"]),
|
|
),
|
|
).toContain("rename=apply-bundles");
|
|
});
|
|
|
|
it("parses the import tuning flags", () => {
|
|
const flags = parseCatalogSyncArgs([
|
|
"--import-new",
|
|
"--limit=50",
|
|
"--concurrency=8",
|
|
"--only=chair,table",
|
|
]);
|
|
expect(flags.importLimit).toBe(50);
|
|
expect(flags.importConcurrency).toBe(8);
|
|
expect(flags.importOnly).toEqual(["chair", "table"]);
|
|
});
|
|
|
|
it("rejects a non-numeric --limit", () => {
|
|
expect(() => parseCatalogSyncArgs(["--limit=abc"])).toThrow(/integer/);
|
|
});
|
|
|
|
it("rejects --dry-run without an import to preview", () => {
|
|
expect(() => parseCatalogSyncArgs(["--dry-run"])).toThrow(
|
|
/together with --import-new/,
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("generated catalog SQL output", () => {
|
|
let cwd = "";
|
|
|
|
beforeEach(() => {
|
|
cwd = process.cwd();
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
process.chdir(cwd);
|
|
});
|
|
|
|
it("writes the SQL under storage/catalog-sync with a trailing newline", async () => {
|
|
let target = "";
|
|
try {
|
|
const dir = mkdtempSync(join(tmpdir(), "catalog-sync-sql-"));
|
|
target = dir;
|
|
process.chdir(dir);
|
|
const file = await writeGeneratedSql("INSERT INTO `catalog_items` (1);");
|
|
expect(file.startsWith("storage/catalog-sync/")).toBe(true);
|
|
expect(file.endsWith(".sql")).toBe(true);
|
|
expect(readFileSync(join(dir, file), "utf8")).toBe(
|
|
"INSERT INTO `catalog_items` (1);\n",
|
|
);
|
|
} finally {
|
|
if (target) rmSync(target, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|