Files
SnapOtter/tests/integration/catalog-integrity.test.ts
T
SnapOtter f950dac792 test: add launch-readiness guards and fix zh-CN/pt-BR i18n fallback (phase 4a)
Four launch gates for v2.0.0:

1. Catalog integrity (catalog-integrity.test.ts): asserts every TOOLS entry
   is fully wired end to end (API route + frontend registry + display mode +
   process fn or REGISTRY_EXEMPT). Count checked dynamically against
   TOOLS.length. All 157 tools pass.

2. i18n cross-locale parity (i18n-parity.test.ts): asserts every locale in
   SUPPORTED_LOCALES has the same key set as en.ts. Found and fixed a real
   bug: zh-CN and pt-BR exported only a camelCase named export (zhCN, ptBR)
   with no default export, so loadTranslations silently fell back to English
   for Chinese Simplified and Brazilian Portuguese users. Fixed by adding
   export default to both files. All 20 non-en locales now pass parity.

3. Cross-modality smoke (cross-modality-smoke.test.ts): one fast tool per
   modality (rotate/image, mute-video/video, convert-audio/audio,
   rotate-pdf/document, csv-json/data) plus an auth gate. Tools needing
   ffmpeg or qpdf are gated with skipIf. Ship/no-ship signal.

4. Migration launch gate: extended migrate-from-sqlite.test.ts with a
   representative 1.x SQLite database (3 users, 3 teams, 3 settings,
   2 roles, 2 sessions, 2 API keys, 2 pipelines, 4 jobs, 4 audit entries,
   4 user files) covering boolean/timestamp/JSON/NULL type conversions,
   column remapping (input_files->input_refs, progress real->jsonb), and
   multi-row round-trip verification. 9 new test cases.

Parity: 13260 passed, 0 dropped.
2026-06-20 02:38:30 +08:00

150 lines
4.8 KiB
TypeScript

/**
* Catalog integrity launch gate.
*
* Asserts that EVERY TOOLS catalog entry is fully wired end to end:
* - Has a live API route (POST returns non-404)
* - Has a frontend tool-registry entry with a Settings component
* - Has a display mode in TOOL_DISPLAY_MODES
* - Has an API process-fn registration OR is in REGISTRY_EXEMPT
*
* The existing drift guards (tool-route-drift, tool-registry-drift) check
* individual layers in isolation. This test is the cross-cutting launch gate:
* one assertion that no tool is half-wired across all three layers combined.
*
* The TOOLS.length is checked dynamically -- never hardcoded -- so the gate
* cannot silently drift when tools are added or removed.
*/
import { TOOLS } from "@snapotter/shared";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getRegisteredToolIds } from "../../apps/api/src/routes/tool-factory.js";
import { TOOL_DISPLAY_MODES } from "../../apps/web/src/lib/tool-display-modes.js";
import { toolRegistry } from "../../apps/web/src/lib/tool-registry.js";
import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js";
/**
* Tools whose contract does not fit the single-buffer process fn (multi-file,
* ZIP/JSON output, no-input generators, custom AI routes). They expose an HTTP
* route but are not in the pipeline/batch registry. Imported from the
* tool-route-drift test for consistency.
*/
const REGISTRY_EXEMPT = new Set([
"auto-subtitles",
"background-replace",
"barcode-generate",
"barcode-read",
"blur-background",
"bulk-rename",
"collage",
"color-palette",
"compare",
"compose",
"erase-object",
"favicon",
"find-duplicates",
"html-to-image",
"image-to-base64",
"image-to-pdf",
"info",
"ocr",
"ocr-pdf",
"pdf-to-image",
"qr-generate",
"stitch",
"svg-to-raster",
"transcribe-audio",
"watermark-image",
]);
describe("catalog integrity launch gate", () => {
let testApp: TestApp;
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
adminToken = await loginAsAdmin(testApp.app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
it("TOOLS catalog is non-empty", () => {
expect(TOOLS.length).toBeGreaterThan(0);
});
it("every tool is fully wired: API route + frontend registry + display mode", async () => {
const registeredProcessFns = new Set(getRegisteredToolIds());
const missingFrontend: string[] = [];
const missingDisplayMode: string[] = [];
const missingApiRoute: string[] = [];
const missingProcessFn: string[] = [];
// Check frontend + display mode synchronously
for (const tool of TOOLS) {
if (!toolRegistry.has(tool.id)) {
missingFrontend.push(tool.id);
}
if (!TOOL_DISPLAY_MODES[tool.id]) {
missingDisplayMode.push(tool.id);
}
if (!REGISTRY_EXEMPT.has(tool.id) && !registeredProcessFns.has(tool.id)) {
missingProcessFn.push(tool.id);
}
}
// Check API routes (POST, non-404)
for (const tool of TOOLS) {
const res = await testApp.app.inject({
method: "POST",
url: `/api/v1/tools/${tool.id}`,
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": "application/json",
},
payload: {},
});
if (res.statusCode === 404) {
missingApiRoute.push(tool.id);
}
}
// Report all gaps in one assertion block for clarity
expect(
missingFrontend,
`tools missing frontend registry entry: ${missingFrontend.join(", ")}`,
).toEqual([]);
expect(
missingDisplayMode,
`tools missing display mode: ${missingDisplayMode.join(", ")}`,
).toEqual([]);
expect(
missingApiRoute,
`tools with no live API route (404): ${missingApiRoute.join(", ")}`,
).toEqual([]);
expect(
missingProcessFn,
`non-exempt tools missing process fn: ${missingProcessFn.join(", ")}`,
).toEqual([]);
}, 60_000);
it("total wired count equals TOOLS.length (dynamic, not hardcoded)", () => {
const frontendCount = TOOLS.filter((t) => toolRegistry.has(t.id)).length;
const displayModeCount = TOOLS.filter((t) => !!TOOL_DISPLAY_MODES[t.id]).length;
expect(frontendCount, `frontend registry covers ${frontendCount}/${TOOLS.length} tools`).toBe(
TOOLS.length,
);
expect(
displayModeCount,
`display-mode map covers ${displayModeCount}/${TOOLS.length} tools`,
).toBe(TOOLS.length);
});
it("REGISTRY_EXEMPT set matches actual exempt tools (no stale entries)", () => {
const catalogIds = new Set(TOOLS.map((t) => t.id));
const stale = [...REGISTRY_EXEMPT].filter((id) => !catalogIds.has(id));
expect(stale, `stale REGISTRY_EXEMPT entries: ${stale.join(", ")}`).toEqual([]);
});
});