From 8c47c3c158d694d9ef88f29e32a8a0a3e272a23d Mon Sep 17 00:00:00 2001 From: openhands Date: Mon, 7 Sep 2026 17:13:44 +0200 Subject: [PATCH] test(catalog): add read-only live audit consistency harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Runs the full catalog audit (runCatalogAudit) against the live hotel DB with no repair/sql options — a pure read pass — and cross-checks the emitted summary against independent DB + asset-dir measurements: row totals, duplicate classname groups, orphaned catalog references and missing catalog / nitro / icon counts must match exactly, with zero error events and a final 'batch_complete'. Guarded by RUN_CATALOG_AUDIT_LIVE=1 so CI never runs it; loads the real .env because vitest fakes DATABASE_URL. --- src/lib/services/catalog-audit-live.test.ts | 221 ++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 src/lib/services/catalog-audit-live.test.ts diff --git a/src/lib/services/catalog-audit-live.test.ts b/src/lib/services/catalog-audit-live.test.ts new file mode 100644 index 00000000..1d6a81a9 --- /dev/null +++ b/src/lib/services/catalog-audit-live.test.ts @@ -0,0 +1,221 @@ +// Read-only live check for the Catalog Studio audit (`runCatalogAudit`). +// No repair/sql options are passed, so nothing is written: the DB is only +// SELECTed, gamedata dirs only listed, and clone-source furnidata only fetched +// for comparison. `ensureDirectories()` may create missing gamedata asset +// dirs, but never touches data. +// +// Deliberately not part of CI: it connects to the live hotel DB and hits the +// network (clone sources). Guarded by RUN_CATALOG_AUDIT_LIVE=1. Because vitest +// fakes DATABASE_URL in its config env, the real .env is loaded first. +// +// Usage: +// RUN_CATALOG_AUDIT_LIVE=1 pnpm exec vitest run --coverage.enabled=false \ +// src/lib/services/catalog-audit-live.test.ts +import { existsSync, readFileSync } from "node:fs"; +import { readdir } from "node:fs/promises"; +import { resolve } from "node:path"; +import { sql } from "drizzle-orm"; +import { beforeAll, describe, expect, it } from "vitest"; + +const runLive = process.env.RUN_CATALOG_AUDIT_LIVE === "1"; +const KNOWN_EVENT_TYPES = new Set([ + "progress", + "items_loaded", + "catalog_loaded", + "checking", + "repair_complete", + "sql_ready", + "sql_applied", + "furnidata_repair_complete", + "structure_repair_complete", + "batch_complete", + "error", +]); + +describe.skipIf(!runLive)("catalog audit live (read-only)", () => { + let db: typeof import("@/lib/db").db; + let runCatalogAudit: typeof import("@/lib/services/catalog-audit").runCatalogAudit; + let types: typeof import("@/lib/services/item-kind"); + + beforeAll(async () => { + // vitest's test.env injects a throwaway DATABASE_URL (root:root@:3306). + // Replace it with the real .env value so the audit targets the hotel DB. + const envFile = resolve(process.cwd(), ".env"); + if (existsSync(envFile)) { + for (const line of readFileSync(envFile, "utf8").split(/\r?\n/)) { + const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/); + if (!m) continue; + let value = m[2].trim(); + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + process.env[m[1]] = value; + } + } + + const [dbMod, auditMod, typesMod] = await Promise.all([ + import("@/lib/db"), + import("@/lib/services/catalog-audit"), + import("@/lib/services/item-kind"), + ]); + db = dbMod.db; + runCatalogAudit = auditMod.runCatalogAudit; + types = typesMod; + }); + + it("completes read-only and its summary matches independent measurements", async () => { + expect(runLive).toBe(true); + expect(process.env.DATABASE_URL).toMatch(/@/); + expect( + process.env.DATABASE_URL?.includes("root:root@localhost:3306/test"), + ).toBe(false); + + const events: Array> = []; + await runCatalogAudit( + (evt) => events.push(evt as unknown as Record), + {}, + ); + + const errors = events.filter((e) => e.type === "error"); + expect(errors).toEqual([]); + for (const evt of events) { + expect(KNOWN_EVENT_TYPES.has(String(evt.type))).toBe(true); + } + + const last = events[events.length - 1]; + expect(last?.type).toBe("batch_complete"); + const summary = last.summary as { + totalItemsBase: number; + totalCatalogItems: number; + missingCatalog: number; + orphanedCatalog: number; + missingNitro: number; + missingIcon: number; + duplicates: number; + }; + expect(summary).toBeDefined(); + + // ── Independent measurements straight from the live DB ────────── + const [itemRows] = (await db.execute(sql` + SELECT id, item_name, public_name, type, interaction_type + FROM items_base ORDER BY id + `)) as unknown as [ + Array<{ + id: number; + item_name: string; + public_name: string; + type: string; + interaction_type: string; + }>, + unknown, + ]; + const [catalogRows] = (await db.execute(sql` + SELECT id, item_ids, catalog_name FROM catalog_items ORDER BY id + `)) as unknown as [ + Array<{ id: number; item_ids: string; catalog_name: string }>, + unknown, + ]; + + const itemIds = new Set(itemRows.map((r) => Number(r.id))); + const catalogedIds = new Set(); + for (const ci of catalogRows) { + for (const id of ci.item_ids.split(";")) { + const n = Number(id.trim()); + if (n) catalogedIds.add(n); + } + } + + // Duplicate classnames: count of names appearing > 1×. + const nameCount = new Map(); + for (const r of itemRows) + nameCount.set(r.item_name, (nameCount.get(r.item_name) ?? 0) + 1); + const duplicateGroups = [...nameCount.values()].filter((c) => c > 1).length; + + // Orphaned references: every (row, id) pointing at a missing sprite. + let orphanedRefs = 0; + for (const ci of catalogRows) { + for (const id of ci.item_ids.split(";")) { + const n = Number(id.trim()); + if (n && !itemIds.has(n)) orphanedRefs++; + } + } + + // Re-implement the audit's kind/asset logic so parity is exact: + // badgeGifSet (album1584 .gif) nudges furni-looking rows to badge. + const { getFurniAssetDirs, getGamedataRoot } = await import( + "@/lib/services/furni-asset-dirs" + ); + const { getRuntimePath } = await import("@/lib/utils/runtime-path"); + const [dirs, gamedataRoot] = await Promise.all([ + getFurniAssetDirs(), + getGamedataRoot(), + ]); + const [iconFiles, nitroFiles, badgeGifs] = await Promise.all([ + readdir(dirs.iconDir).catch(() => [] as string[]), + readdir(dirs.nitroDir).catch(() => [] as string[]), + gamedataRoot + ? readdir(getRuntimePath(gamedataRoot, "album1584")).catch( + () => [] as string[], + ) + : Promise.resolve([] as string[]), + ]); + const iconSet = new Set(iconFiles); + const nitroSet = new Set(nitroFiles); + const badgeGifSet = new Set( + badgeGifs.filter((f) => f.endsWith(".gif")).map((f) => f.slice(0, -4)), + ); + + const kindOf = (r: (typeof itemRows)[0]) => { + let kind = types.classifyCatalogItem(r); + if ( + kind === "furni" && + (types.isBadgeType(r.type) || badgeGifSet.has(r.item_name)) + ) { + kind = "badge"; + } + return kind; + }; + + let missingCatalog = 0; + let missingNitro = 0; + let missingIcon = 0; + for (const r of itemRows) { + const kind = kindOf(r); + if ( + !catalogedIds.has(Number(r.id)) && + !types.NO_CATALOG_KINDS.has(kind) + ) { + missingCatalog++; + } + if (types.NO_ASSET_KINDS.has(kind)) continue; + if ( + !types + .assetNameCandidates(r.item_name) + .nitro.some((n) => nitroSet.has(n)) + ) { + missingNitro++; + } + if ( + !types.assetNameCandidates(r.item_name).icon.some((n) => iconSet.has(n)) + ) { + missingIcon++; + } + } + + expect(summary.totalItemsBase).toBe(itemRows.length); + expect(summary.totalCatalogItems).toBe(catalogRows.length); + expect(summary.duplicates).toBe(duplicateGroups); + expect(summary.orphanedCatalog).toBe(orphanedRefs); + expect(summary.missingCatalog).toBe(missingCatalog); + expect(summary.missingNitro).toBe(missingNitro); + expect(summary.missingIcon).toBe(missingIcon); + + expect(summary.totalItemsBase).toBeGreaterThan(0); + expect(summary.totalCatalogItems).toBeGreaterThan(0); + expect(missingCatalog).toBeGreaterThanOrEqual(0); + expect(orphanedRefs).toBeGreaterThanOrEqual(0); + }, 300_000); +});