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