mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
This commit is contained in:
@@ -3470,3 +3470,5 @@ export const ptBR: TranslationKeys = {
|
||||
homeLink: "SnapOtter home",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default ptBR;
|
||||
|
||||
@@ -3389,3 +3389,5 @@ export const zhCN: TranslationKeys = {
|
||||
homeLink: "SnapOtter home",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export default zhCN;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* 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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -144,3 +144,463 @@ describe("migrate-from-sqlite", () => {
|
||||
expect(rows[0].n).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Representative 1.x import: a more realistic SQLite database with multiple
|
||||
* rows, diverse type-conversion edge cases (booleans, timestamps, JSON
|
||||
* columns, NULLs), and all 10 tables populated.
|
||||
*/
|
||||
describe("migrate-from-sqlite (representative 1.x database)", () => {
|
||||
const reprDir = mkdtempSync(join(tmpdir(), "snapotter-migrator-repr-"));
|
||||
const reprPath = join(reprDir, "snapotter-1x-representative.db");
|
||||
|
||||
function buildRepresentativeSqlite(path: string): void {
|
||||
const s = new Database(path);
|
||||
s.exec(`
|
||||
CREATE TABLE users (id text PRIMARY KEY, username text NOT NULL, password_hash text,
|
||||
role text NOT NULL DEFAULT 'user', team text NOT NULL DEFAULT 'Default',
|
||||
must_change_password integer NOT NULL DEFAULT 1, auth_provider text NOT NULL DEFAULT 'local',
|
||||
external_id text, email text, created_at integer NOT NULL, updated_at integer NOT NULL,
|
||||
analytics_enabled integer, analytics_consent_shown_at integer, analytics_consent_remind_at integer);
|
||||
CREATE TABLE teams (id text PRIMARY KEY, name text NOT NULL, created_at integer NOT NULL);
|
||||
CREATE TABLE settings ("key" text PRIMARY KEY, value text NOT NULL, updated_at integer NOT NULL);
|
||||
CREATE TABLE roles (id text PRIMARY KEY, name text NOT NULL, description text NOT NULL DEFAULT '',
|
||||
permissions text NOT NULL, is_builtin integer NOT NULL DEFAULT 0, created_by text,
|
||||
created_at integer NOT NULL, updated_at integer NOT NULL);
|
||||
CREATE TABLE sessions (id text PRIMARY KEY, user_id text NOT NULL, expires_at integer NOT NULL,
|
||||
id_token text, created_at integer NOT NULL);
|
||||
CREATE TABLE api_keys (id text PRIMARY KEY, user_id text NOT NULL, key_hash text NOT NULL,
|
||||
key_prefix text, name text NOT NULL DEFAULT 'Default API Key', permissions text,
|
||||
created_at integer NOT NULL, last_used_at integer, expires_at integer);
|
||||
CREATE TABLE pipelines (id text PRIMARY KEY, user_id text, name text NOT NULL, description text,
|
||||
steps text NOT NULL, created_at integer NOT NULL);
|
||||
CREATE TABLE jobs (id text PRIMARY KEY, type text NOT NULL, status text NOT NULL DEFAULT 'queued',
|
||||
progress real NOT NULL DEFAULT 0, input_files text NOT NULL, output_path text, settings text,
|
||||
error text, created_at integer NOT NULL, completed_at integer);
|
||||
CREATE TABLE audit_log (id text PRIMARY KEY, actor_id text, actor_username text NOT NULL,
|
||||
action text NOT NULL, target_type text, target_id text, details text, ip_address text,
|
||||
created_at integer NOT NULL);
|
||||
CREATE TABLE user_files (id text PRIMARY KEY, user_id text, original_name text NOT NULL,
|
||||
stored_name text NOT NULL, mime_type text NOT NULL, size integer NOT NULL, width integer,
|
||||
height integer, version integer NOT NULL DEFAULT 1, parent_id text, tool_chain text,
|
||||
created_at integer NOT NULL);
|
||||
`);
|
||||
|
||||
const t1 = 1748000000; // epoch seconds
|
||||
const t2 = 1748100000;
|
||||
const t3 = 1748200000;
|
||||
|
||||
// ── Users: multiple users with diverse boolean/null combos ──
|
||||
const insU = s.prepare(
|
||||
`INSERT INTO users (id, username, password_hash, role, team, must_change_password,
|
||||
auth_provider, external_id, email, created_at, updated_at,
|
||||
analytics_enabled, analytics_consent_shown_at, analytics_consent_remind_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
);
|
||||
insU.run(
|
||||
"u-admin",
|
||||
"admin",
|
||||
"scrypt-hash-1",
|
||||
"admin",
|
||||
"Default",
|
||||
0,
|
||||
"local",
|
||||
null,
|
||||
"admin@example.com",
|
||||
t1,
|
||||
t1,
|
||||
1,
|
||||
t1,
|
||||
null,
|
||||
);
|
||||
insU.run(
|
||||
"u-editor",
|
||||
"editor",
|
||||
"scrypt-hash-2",
|
||||
"editor",
|
||||
"Design",
|
||||
1,
|
||||
"local",
|
||||
null,
|
||||
null,
|
||||
t2,
|
||||
t2,
|
||||
0,
|
||||
null,
|
||||
null,
|
||||
);
|
||||
insU.run(
|
||||
"u-oidc",
|
||||
"sso-user",
|
||||
null,
|
||||
"user",
|
||||
"Default",
|
||||
0,
|
||||
"oidc",
|
||||
"ext-id-123",
|
||||
"sso@corp.com",
|
||||
t3,
|
||||
t3,
|
||||
null,
|
||||
null,
|
||||
t3,
|
||||
);
|
||||
|
||||
// ── Teams ──
|
||||
const insT = s.prepare("INSERT INTO teams (id, name, created_at) VALUES (?,?,?)");
|
||||
insT.run("tm-1", "Default", t1);
|
||||
insT.run("tm-2", "Design", t1);
|
||||
insT.run("tm-3", "Engineering", t2);
|
||||
|
||||
// ── Settings: plain string, JSON-like string, numeric string ──
|
||||
const insS = s.prepare('INSERT INTO settings ("key", value, updated_at) VALUES (?,?,?)');
|
||||
insS.run("cookieSecret", "super-secret-value", t1);
|
||||
insS.run("siteName", "My SnapOtter", t1);
|
||||
insS.run("maxUploadSize", "50", t2);
|
||||
|
||||
// ── Roles: builtin and custom ──
|
||||
const insR = s.prepare(
|
||||
`INSERT INTO roles (id, name, description, permissions, is_builtin, created_by,
|
||||
created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)`,
|
||||
);
|
||||
insR.run("r-admin", "admin", "Full access", '["*"]', 1, null, t1, t1);
|
||||
insR.run(
|
||||
"r-custom",
|
||||
"reviewer",
|
||||
"Can view audit logs",
|
||||
'["audit:read","files:read"]',
|
||||
0,
|
||||
"u-admin",
|
||||
t2,
|
||||
t2,
|
||||
);
|
||||
|
||||
// ── Sessions ──
|
||||
const insSes = s.prepare(
|
||||
"INSERT INTO sessions (id, user_id, expires_at, id_token, created_at) VALUES (?,?,?,?,?)",
|
||||
);
|
||||
insSes.run("ses-1", "u-admin", t3, null, t1);
|
||||
insSes.run("ses-2", "u-oidc", t3, "eyJhbGciOiJSUzI1NiJ9.fake-jwt-token", t2);
|
||||
|
||||
// ── API Keys: with and without permissions/expiry ──
|
||||
const insK = s.prepare(
|
||||
`INSERT INTO api_keys (id, user_id, key_hash, key_prefix, name, permissions,
|
||||
created_at, last_used_at, expires_at) VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||
);
|
||||
insK.run("ak-1", "u-admin", "hash-abc", "si_abc", "Admin Key", '["*"]', t1, t2, null);
|
||||
insK.run("ak-2", "u-editor", "hash-def", "si_def", "Read-Only", '["files:read"]', t2, null, t3);
|
||||
|
||||
// ── Pipelines: single-step and multi-step ──
|
||||
const insP = s.prepare(
|
||||
`INSERT INTO pipelines (id, user_id, name, description, steps, created_at)
|
||||
VALUES (?,?,?,?,?,?)`,
|
||||
);
|
||||
insP.run(
|
||||
"p-1",
|
||||
"u-admin",
|
||||
"Quick Shrink",
|
||||
"Compress to 70%",
|
||||
'[{"toolId":"compress","settings":{"quality":70}}]',
|
||||
t1,
|
||||
);
|
||||
insP.run(
|
||||
"p-2",
|
||||
"u-editor",
|
||||
"Full Process",
|
||||
null,
|
||||
'[{"toolId":"resize","settings":{"width":800}},{"toolId":"compress","settings":{"quality":80}}]',
|
||||
t2,
|
||||
);
|
||||
|
||||
// ── Jobs: diverse statuses, progress, errors, timestamps ──
|
||||
const insJ = s.prepare(
|
||||
`INSERT INTO jobs (id, type, status, progress, input_files, output_path, settings,
|
||||
error, created_at, completed_at) VALUES (?,?,?,?,?,?,?,?,?,?)`,
|
||||
);
|
||||
insJ.run(
|
||||
"j-done",
|
||||
"single",
|
||||
"completed",
|
||||
1.0,
|
||||
'["uploads/photo.png"]',
|
||||
"/out/photo_compressed.png",
|
||||
'{"quality":70}',
|
||||
null,
|
||||
t1,
|
||||
t2,
|
||||
);
|
||||
insJ.run(
|
||||
"j-fail",
|
||||
"single",
|
||||
"failed",
|
||||
0.33,
|
||||
"[]",
|
||||
null,
|
||||
'{"width":99999}',
|
||||
"Out of memory",
|
||||
t1,
|
||||
t1,
|
||||
);
|
||||
insJ.run(
|
||||
"j-queue",
|
||||
"batch",
|
||||
"queued",
|
||||
0.0,
|
||||
'["a.png","b.png","c.png"]',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
t2,
|
||||
null,
|
||||
);
|
||||
insJ.run("j-null", "single", "completed", 1.0, '["x.jpg"]', "/out/x.jpg", null, null, t3, t3);
|
||||
|
||||
// ── Audit log: various actions with and without details/target ──
|
||||
const insA = s.prepare(
|
||||
`INSERT INTO audit_log (id, actor_id, actor_username, action, target_type,
|
||||
target_id, details, ip_address, created_at) VALUES (?,?,?,?,?,?,?,?,?)`,
|
||||
);
|
||||
insA.run("al-1", "u-admin", "admin", "login", null, null, null, "192.168.1.1", t1);
|
||||
insA.run(
|
||||
"al-2",
|
||||
"u-admin",
|
||||
"admin",
|
||||
"settings.update",
|
||||
"setting",
|
||||
"siteName",
|
||||
'{"key":"siteName","oldValue":"SnapOtter","newValue":"My SnapOtter"}',
|
||||
"192.168.1.1",
|
||||
t1,
|
||||
);
|
||||
insA.run("al-3", "u-editor", "editor", "file.upload", "file", "uf-1", null, "10.0.0.5", t2);
|
||||
insA.run(
|
||||
"al-4",
|
||||
null,
|
||||
"system",
|
||||
"user.create",
|
||||
"user",
|
||||
"u-oidc",
|
||||
'{"provider":"oidc"}',
|
||||
null,
|
||||
t3,
|
||||
);
|
||||
|
||||
// ── User files: various types, dimensions, tool_chain, parent ──
|
||||
const insUF = s.prepare(
|
||||
`INSERT INTO user_files (id, user_id, original_name, stored_name, mime_type,
|
||||
size, width, height, version, parent_id, tool_chain, created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
);
|
||||
insUF.run(
|
||||
"uf-1",
|
||||
"u-editor",
|
||||
"photo.png",
|
||||
"abc123.png",
|
||||
"image/png",
|
||||
204800,
|
||||
1920,
|
||||
1080,
|
||||
1,
|
||||
null,
|
||||
null,
|
||||
t1,
|
||||
);
|
||||
insUF.run(
|
||||
"uf-2",
|
||||
"u-editor",
|
||||
"photo_compressed.png",
|
||||
"def456.png",
|
||||
"image/png",
|
||||
102400,
|
||||
1920,
|
||||
1080,
|
||||
2,
|
||||
"uf-1",
|
||||
'["compress"]',
|
||||
t2,
|
||||
);
|
||||
insUF.run(
|
||||
"uf-3",
|
||||
"u-admin",
|
||||
"report.pdf",
|
||||
"ghi789.pdf",
|
||||
"application/pdf",
|
||||
512000,
|
||||
null,
|
||||
null,
|
||||
1,
|
||||
null,
|
||||
null,
|
||||
t2,
|
||||
);
|
||||
insUF.run(
|
||||
"uf-4",
|
||||
"u-oidc",
|
||||
"meeting.mp4",
|
||||
"jkl012.mp4",
|
||||
"video/mp4",
|
||||
10485760,
|
||||
null,
|
||||
null,
|
||||
1,
|
||||
null,
|
||||
'["trim-video","compress-video"]',
|
||||
t3,
|
||||
);
|
||||
|
||||
s.close();
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
buildRepresentativeSqlite(reprPath);
|
||||
await db.execute(
|
||||
sql`TRUNCATE user_files, audit_log, jobs, pipelines, api_keys, sessions, roles, settings, teams, users CASCADE`,
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db.execute(
|
||||
sql`TRUNCATE user_files, audit_log, jobs, pipelines, api_keys, sessions, roles, settings, teams, users CASCADE`,
|
||||
);
|
||||
});
|
||||
|
||||
it("imports all tables with correct row counts", async () => {
|
||||
const result = await migrateFromSqlite(reprPath, { force: false });
|
||||
expect(result.tables.users).toBe(3);
|
||||
expect(result.tables.teams).toBe(3);
|
||||
expect(result.tables.settings).toBe(3);
|
||||
expect(result.tables.roles).toBe(2);
|
||||
expect(result.tables.sessions).toBe(2);
|
||||
expect(result.tables.api_keys).toBe(2);
|
||||
expect(result.tables.pipelines).toBe(2);
|
||||
expect(result.tables.jobs).toBe(4);
|
||||
expect(result.tables.audit_log).toBe(4);
|
||||
expect(result.tables.user_files).toBe(4);
|
||||
});
|
||||
|
||||
it("boolean conversions: 0 -> false, 1 -> true, NULL -> null", async () => {
|
||||
const users = (await db.execute(sql`SELECT * FROM users ORDER BY id`)).rows;
|
||||
// u-admin: must_change_password=0 -> false, analytics_enabled=1 -> true
|
||||
const admin = users.find((u) => u.id === "u-admin");
|
||||
expect(admin?.must_change_password).toBe(false);
|
||||
expect(admin?.analytics_enabled).toBe(true);
|
||||
// u-editor: must_change_password=1 -> true, analytics_enabled=0 -> false
|
||||
const editor = users.find((u) => u.id === "u-editor");
|
||||
expect(editor?.must_change_password).toBe(true);
|
||||
expect(editor?.analytics_enabled).toBe(false);
|
||||
// u-oidc: analytics_enabled=NULL -> null
|
||||
const oidc = users.find((u) => u.id === "u-oidc");
|
||||
expect(oidc?.analytics_enabled).toBeNull();
|
||||
});
|
||||
|
||||
it("timestamp conversions: epoch seconds -> timestamptz", async () => {
|
||||
const [admin] = (await db.execute(sql`SELECT * FROM users WHERE id = 'u-admin'`)).rows;
|
||||
expect(new Date(admin.created_at as string).getTime()).toBe(1748000000 * 1000);
|
||||
// NULL timestamps stay null
|
||||
expect(admin.analytics_consent_remind_at).toBeNull();
|
||||
// Non-null timestamp
|
||||
const [oidc] = (await db.execute(sql`SELECT * FROM users WHERE id = 'u-oidc'`)).rows;
|
||||
expect(new Date(oidc.analytics_consent_remind_at as string).getTime()).toBe(1748200000 * 1000);
|
||||
});
|
||||
|
||||
it("JSON column conversions: text -> jsonb", async () => {
|
||||
// Pipelines: steps text -> jsonb array
|
||||
const [p1] = (await db.execute(sql`SELECT * FROM pipelines WHERE id = 'p-1'`)).rows;
|
||||
const steps1 = p1.steps as Array<{ toolId: string }>;
|
||||
expect(steps1).toHaveLength(1);
|
||||
expect(steps1[0].toolId).toBe("compress");
|
||||
// Multi-step pipeline
|
||||
const [p2] = (await db.execute(sql`SELECT * FROM pipelines WHERE id = 'p-2'`)).rows;
|
||||
const steps2 = p2.steps as Array<{ toolId: string }>;
|
||||
expect(steps2).toHaveLength(2);
|
||||
expect(steps2[0].toolId).toBe("resize");
|
||||
expect(steps2[1].toolId).toBe("compress");
|
||||
|
||||
// Roles: permissions text -> jsonb array
|
||||
const [rAdmin] = (await db.execute(sql`SELECT * FROM roles WHERE id = 'r-admin'`)).rows;
|
||||
expect(rAdmin.permissions).toEqual(["*"]);
|
||||
const [rCustom] = (await db.execute(sql`SELECT * FROM roles WHERE id = 'r-custom'`)).rows;
|
||||
expect(rCustom.permissions).toEqual(["audit:read", "files:read"]);
|
||||
|
||||
// API keys: permissions
|
||||
const [ak1] = (await db.execute(sql`SELECT * FROM api_keys WHERE id = 'ak-1'`)).rows;
|
||||
expect(ak1.permissions).toEqual(["*"]);
|
||||
});
|
||||
|
||||
it("jobs: progress/error/input/output column remapping", async () => {
|
||||
// Completed: progress 1.0 -> {percent: 100}, error null
|
||||
const [jDone] = (await db.execute(sql`SELECT * FROM jobs WHERE id = 'j-done'`)).rows;
|
||||
expect(jDone.progress).toEqual({ percent: 100 });
|
||||
expect(jDone.error).toBeNull();
|
||||
expect(jDone.input_refs).toEqual([]);
|
||||
expect(jDone.output_refs).toEqual([]);
|
||||
expect(jDone.settings).toEqual({ quality: 70 });
|
||||
expect(jDone.completed_at).not.toBeNull();
|
||||
|
||||
// Failed: progress 0.33 -> {percent: 33}, error text -> {message}
|
||||
const [jFail] = (await db.execute(sql`SELECT * FROM jobs WHERE id = 'j-fail'`)).rows;
|
||||
expect(jFail.progress).toEqual({ percent: 33 });
|
||||
expect(jFail.error).toEqual({ message: "Out of memory" });
|
||||
|
||||
// Queued: progress 0 -> {percent: 0}, null settings, null completed_at
|
||||
const [jQueue] = (await db.execute(sql`SELECT * FROM jobs WHERE id = 'j-queue'`)).rows;
|
||||
expect(jQueue.progress).toEqual({ percent: 0 });
|
||||
expect(jQueue.settings).toBeNull();
|
||||
expect(jQueue.completed_at).toBeNull();
|
||||
|
||||
// Null settings round-trips
|
||||
const [jNull] = (await db.execute(sql`SELECT * FROM jobs WHERE id = 'j-null'`)).rows;
|
||||
expect(jNull.settings).toBeNull();
|
||||
expect(jNull.completed_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it("audit_log: details null and JSON both handled", async () => {
|
||||
const rows = (await db.execute(sql`SELECT * FROM audit_log ORDER BY id`)).rows;
|
||||
expect(rows).toHaveLength(4);
|
||||
const al1 = rows.find((r) => r.id === "al-1");
|
||||
expect(al1?.details).toBeNull();
|
||||
const al2 = rows.find((r) => r.id === "al-2");
|
||||
expect(al2?.details).toEqual({
|
||||
key: "siteName",
|
||||
oldValue: "SnapOtter",
|
||||
newValue: "My SnapOtter",
|
||||
});
|
||||
const al4 = rows.find((r) => r.id === "al-4");
|
||||
expect(al4?.actor_id).toBeNull();
|
||||
expect(al4?.details).toEqual({ provider: "oidc" });
|
||||
});
|
||||
|
||||
it("user_files: tool_chain null and JSON, width/height null", async () => {
|
||||
const rows = (await db.execute(sql`SELECT * FROM user_files ORDER BY id`)).rows;
|
||||
expect(rows).toHaveLength(4);
|
||||
const uf1 = rows.find((r) => r.id === "uf-1");
|
||||
expect(uf1?.tool_chain).toBeNull();
|
||||
expect(uf1?.width).toBe(1920);
|
||||
expect(uf1?.height).toBe(1080);
|
||||
const uf2 = rows.find((r) => r.id === "uf-2");
|
||||
expect(uf2?.tool_chain).toEqual(["compress"]);
|
||||
expect(uf2?.parent_id).toBe("uf-1");
|
||||
expect(uf2?.version).toBe(2);
|
||||
const uf3 = rows.find((r) => r.id === "uf-3");
|
||||
expect(uf3?.width).toBeNull();
|
||||
expect(uf3?.height).toBeNull();
|
||||
const uf4 = rows.find((r) => r.id === "uf-4");
|
||||
expect(uf4?.tool_chain).toEqual(["trim-video", "compress-video"]);
|
||||
});
|
||||
|
||||
it("settings: plain string values preserved as-is", async () => {
|
||||
const rows = (await db.execute(sql`SELECT * FROM settings ORDER BY key`)).rows;
|
||||
expect(rows).toHaveLength(3);
|
||||
const cookie = rows.find((r) => r.key === "cookieSecret");
|
||||
expect(cookie?.value).toBe("super-secret-value");
|
||||
const maxUpload = rows.find((r) => r.key === "maxUploadSize");
|
||||
expect(maxUpload?.value).toBe("50");
|
||||
});
|
||||
|
||||
it("sessions: id_token null and non-null", async () => {
|
||||
const [ses1] = (await db.execute(sql`SELECT * FROM sessions WHERE id = 'ses-1'`)).rows;
|
||||
expect(ses1.id_token).toBeNull();
|
||||
const [ses2] = (await db.execute(sql`SELECT * FROM sessions WHERE id = 'ses-2'`)).rows;
|
||||
expect(ses2.id_token).toBe("eyJhbGciOiJSUzI1NiJ9.fake-jwt-token");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* i18n cross-locale key parity guard.
|
||||
*
|
||||
* Asserts that every locale in SUPPORTED_LOCALES has exactly the same key set
|
||||
* as en.ts (the reference locale). Missing or extra keys in any locale fail
|
||||
* the test.
|
||||
*
|
||||
* Runtime behavior: loadTranslations() falls back to `en` when a locale file
|
||||
* fails to load or the named export is not found (no crash). So missing keys
|
||||
* do not crash the app, but they cause untranslated English text to appear for
|
||||
* users of that locale. This guard catches that drift at PR time.
|
||||
*
|
||||
* Real bug found and fixed: zh-CN and pt-BR exported only a camelCase named
|
||||
* export (e.g. `zhCN`) with no `export default`. loadTranslations looks up
|
||||
* `mod[locale]` (e.g. `mod["zh-CN"]`), which fails for dashed locale codes.
|
||||
* Without a default export fallback, these two locales silently returned
|
||||
* English. Fixed by adding `export default` to both files.
|
||||
*/
|
||||
|
||||
import { en, loadTranslations, SUPPORTED_LOCALES } from "@snapotter/shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/** Recursively collect all dot-separated key paths from an object tree. */
|
||||
function getKeyPaths(obj: Record<string, unknown>, prefix = ""): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const path = prefix ? `${prefix}.${k}` : k;
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) {
|
||||
keys.push(...getKeyPaths(v as Record<string, unknown>, path));
|
||||
} else {
|
||||
keys.push(path);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
/** Recursively collect dot-separated key paths for array-valued leaves too. */
|
||||
function getStructuralKeys(obj: Record<string, unknown>, prefix = ""): string[] {
|
||||
const keys: string[] = [];
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
const path = prefix ? `${prefix}.${k}` : k;
|
||||
if (v && typeof v === "object" && !Array.isArray(v)) {
|
||||
keys.push(...getStructuralKeys(v as Record<string, unknown>, path));
|
||||
} else {
|
||||
// Leaf: string, number, array, etc.
|
||||
keys.push(path);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
describe("i18n cross-locale parity", () => {
|
||||
const enKeys = new Set(getStructuralKeys(en as unknown as Record<string, unknown>));
|
||||
|
||||
it("en reference locale has keys", () => {
|
||||
expect(enKeys.size).toBeGreaterThan(100);
|
||||
});
|
||||
|
||||
it.each(
|
||||
SUPPORTED_LOCALES.filter((l) => l.code !== "en").map((l) => [l.code, l.name]),
|
||||
)("%s (%s) has the same key set as en", async (code) => {
|
||||
const translations = await loadTranslations(code);
|
||||
|
||||
// If loadTranslations fell back to en, we get en back. That is a real
|
||||
// problem (the locale file failed to load). Detect this by checking
|
||||
// whether the returned object is literally the en reference.
|
||||
expect(
|
||||
translations !== en || code === "en",
|
||||
`locale "${code}" fell back to English -- check the file exports a default or named export matching the locale code`,
|
||||
).toBe(true);
|
||||
|
||||
const localeKeys = new Set(
|
||||
getStructuralKeys(translations as unknown as Record<string, unknown>),
|
||||
);
|
||||
|
||||
const missing = [...enKeys].filter((k) => !localeKeys.has(k));
|
||||
const extra = [...localeKeys].filter((k) => !enKeys.has(k));
|
||||
|
||||
expect(
|
||||
missing,
|
||||
`locale "${code}" is missing ${missing.length} keys from en:\n ${missing.slice(0, 20).join("\n ")}${missing.length > 20 ? `\n ... and ${missing.length - 20} more` : ""}`,
|
||||
).toEqual([]);
|
||||
|
||||
expect(
|
||||
extra,
|
||||
`locale "${code}" has ${extra.length} extra keys not in en:\n ${extra.slice(0, 20).join("\n ")}${extra.length > 20 ? `\n ... and ${extra.length - 20} more` : ""}`,
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("all dashed locales load their own translations (not en fallback)", async () => {
|
||||
const dashedLocales = SUPPORTED_LOCALES.filter((l) => l.code.includes("-"));
|
||||
for (const locale of dashedLocales) {
|
||||
const translations = await loadTranslations(locale.code);
|
||||
expect(
|
||||
translations !== en,
|
||||
`locale "${locale.code}" (${locale.name}) fell back to English -- this means ${locale.nativeName} users see untranslated UI`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("loadTranslations returns en for unknown locale (graceful fallback)", async () => {
|
||||
const result = await loadTranslations("xx-FAKE");
|
||||
expect(result).toBe(en);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user