Files
SnapOtter/tests/integration/format-matrix.test.ts
T
SnapOtter 7f62bc32db test: expand coverage to 3,382 tests across all layers
- Unit: 1,353 tests (42 files) — +256 new tests covering AI bridge
  modules, image-engine sharpen/optimize-for-web, Zustand stores, and
  icon-map validation
- Integration: 1,640 tests (57 files) — +826 new tests across all
  tool routes, pipeline/progress/batch infrastructure, user-files,
  edit-metadata, and a 321-test cross-format matrix
- E2E-Docker: 389 passing (20 spec files) — 6 new spec files for
  batch processing, format conversion, layout, optimization,
  watermark/overlay, and pipeline chains. Tests verified against fresh
  Docker container with all 6 AI bundles installed.

Bug fixes discovered during testing:
- fix(compress): SVG/BMP/exotic formats crashed Sharp encoder — added
  format-safety fallback to PNG
- fix(rate-limit): increase default login attempt limit from 10 to 500
  per minute — previous value caused false test failures and is too
  restrictive for a self-hosted app
- fix(auth.setup): wait for consent button visibility before clicking
  to prevent flaky E2E-Docker auth setup
2026-04-24 22:43:14 +08:00

593 lines
18 KiB
TypeScript

/**
* Cross-format matrix integration test.
*
* For each supported input format, verifies that the core non-AI tools
* (resize, crop, rotate, convert, compress, color-adjustments, sharpening,
* info, optimize-for-web, border, watermark-text, image-to-base64) work
* correctly via the API.
*
* Some formats (PSD, EXR, HDR, TGA, DNG, ICO, JXL) require CLI decoders
* (ImageMagick / dcraw) that may not be installed in every test environment.
* For those formats, the test accepts either 200 or 422 and documents the
* reason.
*
* HEIC/HEIF require libheif-examples to decode. If unavailable, the API
* returns 422 which the test accepts.
*/
import { existsSync, 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 FORMATS_DIR = join(__dirname, "..", "fixtures", "formats");
// ---------------------------------------------------------------------------
// Format sample definitions
// ---------------------------------------------------------------------------
interface FormatSample {
name: string;
file: string;
mime: string;
/** True if format requires CLI decoder (ImageMagick / dcraw) */
needsCliDecoder: boolean;
/** True if format requires libheif decoder */
needsHeifDecoder: boolean;
/**
* True if Sharp may fail to read metadata for this format during
* validation, causing a 400 response. This happens for formats like
* BMP (some variants) and JXL where Sharp support is incomplete.
*/
mayFailValidation: boolean;
}
const FORMAT_SAMPLES: FormatSample[] = [
{
name: "JPEG",
file: "sample.jpg",
mime: "image/jpeg",
needsCliDecoder: false,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "PNG",
file: "sample.png",
mime: "image/png",
needsCliDecoder: false,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "WebP",
file: "sample.webp",
mime: "image/webp",
needsCliDecoder: false,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "GIF",
file: "sample.gif",
mime: "image/gif",
needsCliDecoder: false,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "AVIF",
file: "sample.avif",
mime: "image/avif",
needsCliDecoder: false,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "TIFF",
file: "sample.tiff",
mime: "image/tiff",
needsCliDecoder: false,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "BMP",
file: "sample.bmp",
mime: "image/bmp",
needsCliDecoder: false,
needsHeifDecoder: false,
mayFailValidation: true,
},
{
name: "HEIC",
file: "sample.heic",
mime: "image/heic",
needsCliDecoder: false,
needsHeifDecoder: true,
mayFailValidation: false,
},
{
name: "HEIF",
file: "sample.heif",
mime: "image/heif",
needsCliDecoder: false,
needsHeifDecoder: true,
mayFailValidation: false,
},
{
name: "SVG",
file: "sample.svg",
mime: "image/svg+xml",
needsCliDecoder: false,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "ICO",
file: "sample.ico",
mime: "image/x-icon",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "PSD",
file: "sample.psd",
mime: "image/vnd.adobe.photoshop",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "EXR",
file: "sample.exr",
mime: "image/x-exr",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "HDR",
file: "sample.hdr",
mime: "image/vnd.radiance",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "TGA",
file: "sample.tga",
mime: "image/x-tga",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "DNG",
file: "sample.dng",
mime: "image/x-adobe-dng",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "JXL",
file: "sample.jxl",
mime: "image/jxl",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: true,
},
];
// ---------------------------------------------------------------------------
// Tool definitions — settings each tool requires
// ---------------------------------------------------------------------------
interface ToolDef {
/** Tool route name (maps to /api/v1/tools/<id>) */
id: string;
/** Display name for test output */
label: string;
/** Settings JSON sent as the "settings" multipart field */
settings: Record<string, unknown>;
/**
* How to verify a successful (200) response.
* "download" = standard {downloadUrl, processedSize} shape
* "info" = metadata JSON {width, height, fileSize, format}
* "base64" = {results, errors} shape from image-to-base64
*/
responseType: "download" | "info" | "base64";
}
const TOOLS: ToolDef[] = [
{
id: "resize",
label: "Resize",
settings: { width: 50, height: 50 },
responseType: "download",
},
{
id: "crop",
label: "Crop",
settings: { width: 10, height: 10, left: 0, top: 0 },
responseType: "download",
},
{
id: "rotate",
label: "Rotate",
settings: { angle: 90 },
responseType: "download",
},
{
id: "convert",
label: "Convert to PNG",
settings: { format: "png" },
responseType: "download",
},
{
id: "compress",
label: "Compress",
settings: { mode: "quality", quality: 60 },
responseType: "download",
},
{
id: "adjust-colors",
label: "Color adjustments",
settings: { brightness: 10, contrast: 5 },
responseType: "download",
},
{
id: "sharpening",
label: "Sharpening",
settings: { method: "adaptive" },
responseType: "download",
},
{
id: "info",
label: "Info (metadata)",
settings: {},
responseType: "info",
},
{
id: "optimize-for-web",
label: "Optimize for web",
settings: { format: "webp", quality: 75 },
responseType: "download",
},
{
id: "border",
label: "Border",
settings: { borderWidth: 5, borderColor: "#FF0000" },
responseType: "download",
},
{
id: "watermark-text",
label: "Watermark text",
settings: { text: "TEST", fontSize: 16, opacity: 50 },
responseType: "download",
},
{
id: "image-to-base64",
label: "Image to Base64",
settings: {},
responseType: "base64",
},
];
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Status codes we accept for formats that may lack decoder support */
const ACCEPTABLE_FALLBACK_CODES = [200, 400, 422];
function needsFallback(fmt: FormatSample): boolean {
return fmt.needsCliDecoder || fmt.needsHeifDecoder || fmt.mayFailValidation;
}
/**
* Build multipart payload for a tool request.
* Info route does not use a "settings" field; image-to-base64 uses its own
* settings parsing; everything else uses the standard factory shape.
*/
function buildPayload(
fmt: FormatSample,
tool: ToolDef,
buffer: Buffer,
): { body: Buffer; contentType: string } {
const fields: Array<{
name: string;
filename?: string;
contentType?: string;
content: Buffer | string;
}> = [
{
name: "file",
filename: fmt.file,
contentType: fmt.mime,
content: buffer,
},
];
// Info route ignores the settings field; the others need it
if (tool.responseType !== "info" || Object.keys(tool.settings).length > 0) {
fields.push({
name: "settings",
content: JSON.stringify(tool.settings),
});
}
return createMultipartPayload(fields);
}
// ---------------------------------------------------------------------------
// Shared state
// ---------------------------------------------------------------------------
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);
// ---------------------------------------------------------------------------
// Cross-format matrix: every tool x every format
// ---------------------------------------------------------------------------
describe("Cross-format matrix", () => {
for (const fmt of FORMAT_SAMPLES) {
describe(`${fmt.name} input (${fmt.file})`, () => {
const fixturePath = join(FORMATS_DIR, fmt.file);
for (const tool of TOOLS) {
// Skip "Convert to PNG" when input is already PNG (no-op conversion)
if (tool.id === "convert" && fmt.name === "PNG") continue;
it(`${tool.label}`, async () => {
if (!existsSync(fixturePath)) return;
const buffer = readFileSync(fixturePath);
const { body: payload, contentType } = buildPayload(fmt, tool, buffer);
const res = await app.inject({
method: "POST",
url: `/api/v1/tools/${tool.id}`,
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
// ------------------------------------------------------------------
// Assert status code
// ------------------------------------------------------------------
if (needsFallback(fmt)) {
// Formats with optional decoders: accept success or graceful error
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
} else {
// Core formats must always succeed
expect(res.statusCode).toBe(200);
}
// ------------------------------------------------------------------
// If successful, validate the response shape
// ------------------------------------------------------------------
if (res.statusCode === 200) {
const body = JSON.parse(res.body);
switch (tool.responseType) {
case "download":
expect(body.downloadUrl).toBeDefined();
expect(typeof body.downloadUrl).toBe("string");
expect(body.processedSize).toBeGreaterThan(0);
expect(body.originalSize).toBeGreaterThan(0);
break;
case "info":
expect(body.width).toBeGreaterThan(0);
expect(body.height).toBeGreaterThan(0);
expect(body.fileSize).toBeGreaterThan(0);
expect(body.format).toBeDefined();
expect(body.channels).toBeGreaterThan(0);
break;
case "base64":
// image-to-base64 returns { results: [...], errors: [...] }
expect(Array.isArray(body.results)).toBe(true);
expect(body.results.length + body.errors.length).toBeGreaterThan(0);
if (body.results.length > 0) {
const r = body.results[0];
expect(r.base64).toBeDefined();
expect(typeof r.base64).toBe("string");
expect(r.base64.length).toBeGreaterThan(0);
expect(r.dataUri).toMatch(/^data:/);
expect(r.width).toBeGreaterThan(0);
expect(r.height).toBeGreaterThan(0);
}
break;
}
}
// ------------------------------------------------------------------
// If the API returned an error, verify it is a clean JSON error
// (not a raw crash / stack trace / HTML error page)
// ------------------------------------------------------------------
if (res.statusCode !== 200) {
const body = JSON.parse(res.body);
expect(body.error).toBeDefined();
expect(typeof body.error).toBe("string");
}
});
}
});
}
});
// ---------------------------------------------------------------------------
// Edge-case matrix: multipage TIFF
// ---------------------------------------------------------------------------
describe("Multipage TIFF handling", () => {
const multipagePath = join(FORMATS_DIR, "multipage.tiff");
for (const tool of TOOLS) {
it(`${tool.label} handles multipage TIFF`, async () => {
if (!existsSync(multipagePath)) return;
const buffer = readFileSync(multipagePath);
const { body: payload, contentType } = createMultipartPayload([
{
name: "file",
filename: "multipage.tiff",
contentType: "image/tiff",
content: buffer,
},
...(tool.responseType !== "info"
? [{ name: "settings", content: JSON.stringify(tool.settings) }]
: []),
]);
const res = await app.inject({
method: "POST",
url: `/api/v1/tools/${tool.id}`,
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
// Multipage TIFF should either succeed or return a clean error
expect([200, 400, 422]).toContain(res.statusCode);
if (res.statusCode === 200) {
const body = JSON.parse(res.body);
if (tool.responseType === "info") {
expect(body.width).toBeGreaterThan(0);
expect(body.height).toBeGreaterThan(0);
// Multipage TIFFs should report pages > 1
if (body.pages !== undefined) {
expect(body.pages).toBeGreaterThanOrEqual(1);
}
} else if (tool.responseType === "base64") {
expect(Array.isArray(body.results)).toBe(true);
} else {
expect(body.downloadUrl).toBeDefined();
expect(body.processedSize).toBeGreaterThan(0);
}
} else {
const body = JSON.parse(res.body);
expect(body.error).toBeDefined();
}
});
}
});
// ---------------------------------------------------------------------------
// Format-specific convert matrix: convert between various output formats
// ---------------------------------------------------------------------------
describe("Cross-format conversion matrix", () => {
const OUTPUT_FORMATS = ["jpg", "png", "webp", "avif", "tiff", "gif"];
// Only test core Sharp-readable formats for conversion (skip exotic ones)
const CONVERTIBLE_INPUTS = FORMAT_SAMPLES.filter(
(f) => !f.needsCliDecoder && !f.needsHeifDecoder && !f.mayFailValidation,
);
for (const fmt of CONVERTIBLE_INPUTS) {
for (const outFmt of OUTPUT_FORMATS) {
// Skip identity conversions
const inputLower = fmt.name.toLowerCase();
if (inputLower === outFmt) continue;
if (inputLower === "jpeg" && outFmt === "jpg") continue;
it(`${fmt.name} -> ${outFmt}`, async () => {
const fixturePath = join(FORMATS_DIR, fmt.file);
if (!existsSync(fixturePath)) return;
const buffer = readFileSync(fixturePath);
const { body: payload, contentType } = createMultipartPayload([
{
name: "file",
filename: fmt.file,
contentType: fmt.mime,
content: buffer,
},
{
name: "settings",
content: JSON.stringify({ format: outFmt }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/convert",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.downloadUrl).toBeDefined();
expect(body.downloadUrl).toContain(`.${outFmt}`);
expect(body.processedSize).toBeGreaterThan(0);
});
}
}
});
// ---------------------------------------------------------------------------
// Error resilience: exotic formats must return clean errors, never crash
// ---------------------------------------------------------------------------
describe("Exotic format error resilience", () => {
const EXOTIC_FORMATS = FORMAT_SAMPLES.filter((f) => f.needsCliDecoder);
// Tools that actually process the image (not just read metadata)
const PROCESSING_TOOLS = TOOLS.filter((t) => t.responseType === "download");
for (const fmt of EXOTIC_FORMATS) {
for (const tool of PROCESSING_TOOLS) {
it(`${fmt.name} + ${tool.label}: returns JSON error (no crash)`, async () => {
const fixturePath = join(FORMATS_DIR, fmt.file);
if (!existsSync(fixturePath)) return;
const buffer = readFileSync(fixturePath);
const { body: payload, contentType } = buildPayload(fmt, tool, buffer);
const res = await app.inject({
method: "POST",
url: `/api/v1/tools/${tool.id}`,
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
// Must not crash (500) — either succeed or return a clean error
expect(res.statusCode).not.toBe(500);
expect([200, 400, 422]).toContain(res.statusCode);
// Response must always be valid JSON
const body = JSON.parse(res.body);
if (res.statusCode !== 200) {
expect(body.error).toBeDefined();
expect(typeof body.error).toBe("string");
expect(body.error.length).toBeGreaterThan(0);
}
});
}
}
});