mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
186 lines
6.8 KiB
TypeScript
186 lines
6.8 KiB
TypeScript
/**
|
|
* Cross-modality launch smoke test.
|
|
*
|
|
* The ship / no-ship signal: one fast tool per modality (image, video, audio,
|
|
* document, data) run end to end via buildTestApp() -- upload a real fixture,
|
|
* process it, assert valid output -- plus an auth check.
|
|
*
|
|
* All chosen tools have executionHint "fast" and use real (small) fixtures so
|
|
* they stay within the sync window under normal conditions. If a tool falls
|
|
* back to async (202) under CI load, the test validates the async response
|
|
* shape and passes (not a real failure).
|
|
*
|
|
* Tools needing a local binary (ffmpeg, qpdf) are gated with skipIf so the
|
|
* test stays green on machines without those binaries.
|
|
*/
|
|
|
|
import { spawnSync } from "node:child_process";
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
|
|
|
const FIXTURES = join(__dirname, "..", "fixtures");
|
|
|
|
// ── Binary gates ──────────────────────────────────────────────────────
|
|
function hasBinary(name: string): boolean {
|
|
const res = spawnSync("which", [name], { encoding: "utf8" });
|
|
return res.status === 0 && res.stdout.trim().length > 0;
|
|
}
|
|
|
|
const HAS_FFMPEG = hasBinary("ffmpeg");
|
|
const HAS_QPDF = hasBinary("qpdf");
|
|
|
|
// ── Helpers ───────────────────────────────────────────────────────────
|
|
|
|
function isAsyncFallback(res: { statusCode: number; body: string }): boolean {
|
|
if (res.statusCode !== 202) return false;
|
|
const body = JSON.parse(res.body);
|
|
expect(body.async).toBe(true);
|
|
expect(body.jobId).toBeDefined();
|
|
return true;
|
|
}
|
|
|
|
async function postTool(
|
|
app: TestApp["app"],
|
|
token: string,
|
|
toolId: string,
|
|
file: Buffer,
|
|
filename: string,
|
|
contentType: string,
|
|
settings: Record<string, unknown>,
|
|
): Promise<{ statusCode: number; body: string }> {
|
|
const { body, contentType: ct } = createMultipartPayload([
|
|
{ name: "file", filename, contentType, content: file },
|
|
{ name: "settings", content: JSON.stringify(settings) },
|
|
]);
|
|
return app.inject({
|
|
method: "POST",
|
|
url: `/api/v1/tools/${toolId}`,
|
|
headers: { authorization: `Bearer ${token}`, "content-type": ct },
|
|
body,
|
|
});
|
|
}
|
|
|
|
// ── Test suite ────────────────────────────────────────────────────────
|
|
|
|
describe("cross-modality launch smoke", () => {
|
|
let testApp: TestApp;
|
|
let app: TestApp["app"];
|
|
let adminToken: string;
|
|
|
|
beforeAll(async () => {
|
|
testApp = await buildTestApp();
|
|
app = testApp.app;
|
|
adminToken = await loginAsAdmin(app);
|
|
}, 30_000);
|
|
|
|
afterAll(async () => {
|
|
await testApp.cleanup();
|
|
}, 10_000);
|
|
|
|
// ── Auth gate ─────────────────────────────────────────────────────
|
|
it("unauthenticated request is rejected", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: "test.png",
|
|
contentType: "image/png",
|
|
content: readFileSync(join(FIXTURES, "test-200x150.png")),
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ angle: 90, flipH: false, flipV: false }) },
|
|
]);
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/rotate",
|
|
headers: { "content-type": contentType },
|
|
body,
|
|
});
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
// ── Image: rotate (Sharp, no external binary) ─────────────────────
|
|
it("image modality: rotate", async () => {
|
|
const file = readFileSync(join(FIXTURES, "test-200x150.png"));
|
|
const res = await postTool(app, adminToken, "rotate", file, "test.png", "image/png", {
|
|
angle: 90,
|
|
flipH: false,
|
|
flipV: false,
|
|
});
|
|
if (isAsyncFallback(res)) return;
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.jobId).toBeDefined();
|
|
});
|
|
|
|
// ── Video: mute-video (ffmpeg) ────────────────────────────────────
|
|
it.skipIf(!HAS_FFMPEG)(
|
|
"video modality: mute-video",
|
|
async () => {
|
|
const file = readFileSync(join(FIXTURES, "media", "tiny.mp4"));
|
|
const res = await postTool(app, adminToken, "mute-video", file, "tiny.mp4", "video/mp4", {});
|
|
if (isAsyncFallback(res)) return;
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.jobId).toBeDefined();
|
|
},
|
|
30_000,
|
|
);
|
|
|
|
// ── Audio: convert-audio (ffmpeg) ─────────────────────────────────
|
|
it.skipIf(!HAS_FFMPEG)(
|
|
"audio modality: convert-audio",
|
|
async () => {
|
|
const file = readFileSync(join(FIXTURES, "media", "tiny.wav"));
|
|
const res = await postTool(app, adminToken, "convert-audio", file, "tone.wav", "audio/wav", {
|
|
format: "mp3",
|
|
bitrate: 128,
|
|
});
|
|
if (isAsyncFallback(res)) return;
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.jobId).toBeDefined();
|
|
},
|
|
30_000,
|
|
);
|
|
|
|
// ── Document: rotate-pdf (qpdf) ──────────────────────────────────
|
|
it.skipIf(!HAS_QPDF)(
|
|
"document modality: rotate-pdf",
|
|
async () => {
|
|
const file = readFileSync(join(FIXTURES, "documents", "tiny.pdf"));
|
|
const res = await postTool(
|
|
app,
|
|
adminToken,
|
|
"rotate-pdf",
|
|
file,
|
|
"tiny.pdf",
|
|
"application/pdf",
|
|
{ angle: 90, range: "1-z" },
|
|
);
|
|
if (isAsyncFallback(res)) return;
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.jobId).toBeDefined();
|
|
},
|
|
30_000,
|
|
);
|
|
|
|
// ── Data/File: csv-json (pure JS, no binary) ─────────────────────
|
|
it("data modality: csv-json", async () => {
|
|
const file = readFileSync(join(FIXTURES, "data", "tiny.csv"));
|
|
const res = await postTool(app, adminToken, "csv-json", file, "tiny.csv", "text/csv", {
|
|
direction: "csv-to-json",
|
|
});
|
|
if (isAsyncFallback(res)) return;
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.jobId).toBeDefined();
|
|
});
|
|
});
|