test(catalog): add read-only live audit consistency harness
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.
This commit is contained in:
1 parent
2650d325ec
commit
8c47c3c158
1 file changed
+221
@@ -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<Record<string, unknown>> = [];
|
||||
await runCatalogAudit(
|
||||
(evt) => events.push(evt as unknown as Record<string, unknown>),
|
||||
{},
|
||||
);
|
||||
|
||||
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<number>();
|
||||
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<string, number>();
|
||||
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);
|
||||
});
|
||||
Reference in new issue
Block a user