Files
SnapOtter/tests/integration/format-matrix.test.ts
T
SnapOtterandGitHub 7579634633 test: fix 2.0 integration CI -- 202 async fallback + timeout hardening
Fixes all integration CI failures on the 2.0 branch.

## What was broken

Two independent root causes:

1. **202 assertion failures** -- Under 4-fork CI parallel load, the 30s
   `SYNC_WAIT_MS` sync window can expire before a BullMQ worker finishes a
   heavy encode (avif, heic), returning a legitimate `202 {jobId, async: true}`
   instead of `200`. Tests that hard-asserted `200` were spuriously failing.

2. **Vitest timeout race** -- `SYNC_WAIT_MS` (30s) and the default Vitest
   `testTimeout` (also 30s) fired simultaneously. Vitest won the race,
   reporting "Test timed out in 30000ms" instead of the test receiving the
   202 response.

## Fixes

- Added `isAsyncFallback()` helper to four integration test files; validates
  the `{async: true, jobId}` body shape and returns early so the synchronous
  200 path runs full assertions only when warranted.
- Set `vi.setConfig({ testTimeout: 60_000 })` at module level in
  `image-enhancement.test.ts` and `format-matrix-comprehensive.test.ts`,
  giving a 30s buffer between when `waitForJob()` returns 202 and when
  Vitest gives up.
- Bumped explicit matrix timeouts in `format-matrix.test.ts` and
  `new-formats.test.ts` from 30s to 60s for the same reason.
- Installed missing CI doc-engine binaries (qpdf, pandoc, libreoffice,
  pdfcpu) that were causing unrelated integration failures.
- Fixed E2E smoke specs for 2.0 UI changes (modality selector, tool routes,
  validation behavior).
2026-06-19 18:15:20 +08:00

1515 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
* image-enhancement, strip-metadata, replace-color, text-overlay,
* color-palette) 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,
},
{
name: "SVGZ",
file: "sample.svgz",
mime: "image/svg+xml",
needsCliDecoder: false,
needsHeifDecoder: false,
mayFailValidation: true,
},
{
name: "JP2",
file: "sample.jp2",
mime: "image/jp2",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "EPS",
file: "sample.eps",
mime: "application/postscript",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "PPM",
file: "sample.ppm",
mime: "image/x-portable-pixmap",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "PGM",
file: "sample.pgm",
mime: "image/x-portable-graymap",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "PBM",
file: "sample.pbm",
mime: "image/x-portable-bitmap",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "DDS",
file: "sample.dds",
mime: "image/vnd.ms-dds",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "CUR",
file: "sample.cur",
mime: "image/x-icon",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "DPX",
file: "sample.dpx",
mime: "image/x-dpx",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "FITS",
file: "sample.fits",
mime: "image/fits",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "APNG",
file: "sample.apng",
mime: "image/apng",
needsCliDecoder: false,
needsHeifDecoder: false,
mayFailValidation: false,
},
{
name: "QOI",
file: "sample.qoi",
mime: "image/x-qoi",
needsCliDecoder: true,
needsHeifDecoder: false,
mayFailValidation: false,
},
];
// ---------------------------------------------------------------------------
// 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
* "palette" = {colors, count} shape from color-palette
* "pdf" = {downloadUrl, processedSize, pages} shape from image-to-pdf
*/
responseType: "download" | "info" | "base64" | "palette" | "pdf";
}
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",
},
{
id: "image-enhancement",
label: "Image enhancement",
settings: { mode: "auto", intensity: 50 },
responseType: "download",
},
{
id: "strip-metadata",
label: "Strip metadata",
settings: { stripAll: true },
responseType: "download",
},
{
id: "replace-color",
label: "Replace color",
settings: { sourceColor: "#FF0000", targetColor: "#00FF00", tolerance: 30 },
responseType: "download",
},
{
id: "text-overlay",
label: "Text overlay",
settings: { text: "TEST", fontSize: 16, position: "bottom" },
responseType: "download",
},
{
id: "color-palette",
label: "Color palette",
settings: {},
responseType: "palette",
},
{
id: "image-to-pdf",
label: "Image to PDF",
settings: { pageSize: "A4", orientation: "portrait", margin: 20 },
responseType: "pdf",
},
];
// ---------------------------------------------------------------------------
// 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;
}
/**
* A heavy encode can exceed SYNC_WAIT_MS (30s in tests) under parallel CI load
* and fall back to 202 {jobId, async: true}. Per the 200-or-202 API contract
* that is a legitimate "accepted & processing" outcome, not a failure.
* Returns true (validating the async body shape) so callers can early-return.
*/
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;
}
/**
* 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;
const perTestTimeout =
(fmt.needsHeifDecoder || fmt.needsCliDecoder) && tool.id === "image-enhancement"
? 300_000
: fmt.needsHeifDecoder || fmt.needsCliDecoder
? 180_000
: tool.id === "image-enhancement"
? 120_000
: 60_000; // 2× SYNC_WAIT_MS so a 202 fallback never races the Vitest timeout
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
// ------------------------------------------------------------------
// A heavy encode may fall back to async (202) under CI load -- accept it.
if (isAsyncFallback(res)) return;
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;
case "palette":
// color-palette returns { colors: string[], count: number }
expect(Array.isArray(body.colors)).toBe(true);
expect(body.colors.length).toBeGreaterThan(0);
expect(body.count).toBeGreaterThan(0);
// Each color should be a hex string
for (const color of body.colors) {
expect(color).toMatch(/^#[0-9a-f]{6}$/);
}
break;
case "pdf":
// image-to-pdf returns { downloadUrl, processedSize, pages }
expect(body.downloadUrl).toBeDefined();
expect(typeof body.downloadUrl).toBe("string");
expect(body.processedSize).toBeGreaterThan(0);
expect(body.pages).toBeGreaterThanOrEqual(1);
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");
}
},
perTestTimeout,
);
}
});
}
});
// ---------------------------------------------------------------------------
// 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 if (tool.responseType === "palette") {
expect(Array.isArray(body.colors)).toBe(true);
expect(body.count).toBeGreaterThan(0);
} else if (tool.responseType === "pdf") {
expect(body.downloadUrl).toBeDefined();
expect(body.processedSize).toBeGreaterThan(0);
expect(body.pages).toBeGreaterThanOrEqual(1);
} 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;
const testTimeout = outFmt === "avif" ? 120_000 : 60_000;
it(`${fmt.name} -> ${outFmt}`, { timeout: testTimeout }, 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,
});
if (isAsyncFallback(res)) return;
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" || t.responseType === "palette" || t.responseType === "pdf",
);
for (const fmt of EXOTIC_FORMATS) {
for (const tool of PROCESSING_TOOLS) {
it(`${fmt.name} + ${tool.label}: returns JSON error (no crash)`, {
timeout: 120_000,
}, 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
if (isAsyncFallback(res)) return;
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);
}
});
}
}
});
// ---------------------------------------------------------------------------
// Image enhancement analysis: dedicated /analyze endpoint
// ---------------------------------------------------------------------------
describe("Image enhancement analysis across formats", () => {
// Core formats that Sharp can read natively
const ANALYZABLE_FORMATS = FORMAT_SAMPLES.filter(
(f) => !f.needsCliDecoder && !f.needsHeifDecoder && !f.mayFailValidation,
);
for (const fmt of ANALYZABLE_FORMATS) {
it(`analyzes ${fmt.name} and returns correction recommendations`, 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,
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image-enhancement/analyze",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
// Analysis should return corrections object
expect(body.corrections).toBeDefined();
expect(typeof body.corrections).toBe("object");
});
}
// Exotic formats: should not crash, return clean error or succeed
const EXOTIC_FORMATS = FORMAT_SAMPLES.filter((f) => f.needsCliDecoder);
for (const fmt of EXOTIC_FORMATS) {
it(`${fmt.name} analyze: returns clean response (no crash)`, 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,
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image-enhancement/analyze",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
expect(res.statusCode).not.toBe(500);
expect([200, 400, 422]).toContain(res.statusCode);
const body = JSON.parse(res.body);
if (res.statusCode !== 200) {
expect(body.error).toBeDefined();
expect(typeof body.error).toBe("string");
}
});
}
});
// ---------------------------------------------------------------------------
// Strip-metadata inspect: dedicated /inspect endpoint
// ---------------------------------------------------------------------------
describe("Strip-metadata inspection across formats", () => {
// Core formats that Sharp can read natively
const INSPECTABLE_FORMATS = FORMAT_SAMPLES.filter(
(f) => !f.needsCliDecoder && !f.needsHeifDecoder && !f.mayFailValidation,
);
for (const fmt of INSPECTABLE_FORMATS) {
it(`inspects ${fmt.name} metadata`, 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,
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/strip-metadata/inspect",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.filename).toBeDefined();
expect(body.fileSize).toBeGreaterThan(0);
});
}
});
// ---------------------------------------------------------------------------
// Watermark-image cross-format matrix
//
// watermark-image requires TWO file uploads (file + watermark), so it cannot
// use the standard TOOLS/buildPayload path. We test each input format as the
// main image with a fixed PNG watermark, and also each format as the watermark
// image with a fixed PNG main image.
// ---------------------------------------------------------------------------
describe("Watermark-image cross-format matrix", () => {
// Use the PNG fixture as the known-good counterpart
const PNG_PATH = join(FORMATS_DIR, "sample.png");
describe("format as main image (watermark is PNG)", () => {
for (const fmt of FORMAT_SAMPLES) {
const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : 60_000;
it(
`${fmt.name} main image with PNG watermark`,
async () => {
const fixturePath = join(FORMATS_DIR, fmt.file);
if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return;
const mainBuffer = readFileSync(fixturePath);
const wmBuffer = readFileSync(PNG_PATH);
const { body: payload, contentType } = createMultipartPayload([
{
name: "file",
filename: fmt.file,
contentType: fmt.mime,
content: mainBuffer,
},
{
name: "watermark",
filename: "sample.png",
contentType: "image/png",
content: wmBuffer,
},
{
name: "settings",
content: JSON.stringify({
position: "bottom-right",
opacity: 50,
scale: 25,
}),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/watermark-image",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
if (isAsyncFallback(res)) return;
if (needsFallback(fmt)) {
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
} else {
expect(res.statusCode).toBe(200);
}
if (res.statusCode === 200) {
const body = JSON.parse(res.body);
expect(body.downloadUrl).toBeDefined();
expect(typeof body.downloadUrl).toBe("string");
expect(body.processedSize).toBeGreaterThan(0);
expect(body.originalSize).toBeGreaterThan(0);
} else {
const body = JSON.parse(res.body);
expect(body.error).toBeDefined();
expect(typeof body.error).toBe("string");
}
},
perTestTimeout,
);
}
});
describe("format as watermark image (main is PNG)", () => {
for (const fmt of FORMAT_SAMPLES) {
const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : 60_000;
it(
`PNG main image with ${fmt.name} watermark`,
async () => {
const fixturePath = join(FORMATS_DIR, fmt.file);
if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return;
const mainBuffer = readFileSync(PNG_PATH);
const wmBuffer = readFileSync(fixturePath);
const { body: payload, contentType } = createMultipartPayload([
{
name: "file",
filename: "sample.png",
contentType: "image/png",
content: mainBuffer,
},
{
name: "watermark",
filename: fmt.file,
contentType: fmt.mime,
content: wmBuffer,
},
{
name: "settings",
content: JSON.stringify({
position: "center",
opacity: 75,
scale: 30,
}),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/watermark-image",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
if (isAsyncFallback(res)) return;
if (needsFallback(fmt)) {
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
} else {
expect(res.statusCode).toBe(200);
}
if (res.statusCode === 200) {
const body = JSON.parse(res.body);
expect(body.downloadUrl).toBeDefined();
expect(typeof body.downloadUrl).toBe("string");
expect(body.processedSize).toBeGreaterThan(0);
} else {
const body = JSON.parse(res.body);
expect(body.error).toBeDefined();
expect(typeof body.error).toBe("string");
}
},
perTestTimeout,
);
}
});
describe("watermark positions across core formats", () => {
const POSITIONS = ["center", "top-left", "top-right", "bottom-left", "bottom-right"] as const;
// Only test core Sharp-readable formats for position coverage
const CORE_FORMATS = FORMAT_SAMPLES.filter(
(f) => !f.needsCliDecoder && !f.needsHeifDecoder && !f.mayFailValidation,
);
for (const fmt of CORE_FORMATS) {
for (const position of POSITIONS) {
it(`${fmt.name} with position=${position}`, async () => {
const fixturePath = join(FORMATS_DIR, fmt.file);
if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return;
const mainBuffer = readFileSync(fixturePath);
const wmBuffer = readFileSync(PNG_PATH);
const { body: payload, contentType } = createMultipartPayload([
{
name: "file",
filename: fmt.file,
contentType: fmt.mime,
content: mainBuffer,
},
{
name: "watermark",
filename: "sample.png",
contentType: "image/png",
content: wmBuffer,
},
{
name: "settings",
content: JSON.stringify({ position, opacity: 50, scale: 20 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/watermark-image",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
if (isAsyncFallback(res)) return;
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.downloadUrl).toBeDefined();
expect(body.processedSize).toBeGreaterThan(0);
});
}
}
});
});
// ---------------------------------------------------------------------------
// Image-to-PDF cross-format conversion matrix
//
// Verifies that each input format can be converted to PDF, with various
// page size and orientation combinations.
// ---------------------------------------------------------------------------
describe("Image-to-PDF cross-format matrix", () => {
describe("single image conversion across formats", () => {
for (const fmt of FORMAT_SAMPLES) {
const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : 60_000;
it(
`converts ${fmt.name} to PDF`,
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({
pageSize: "A4",
orientation: "portrait",
margin: 20,
}),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image-to-pdf",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
if (isAsyncFallback(res)) return;
if (needsFallback(fmt)) {
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
} else {
expect(res.statusCode).toBe(200);
}
if (res.statusCode === 200) {
const body = JSON.parse(res.body);
expect(body.downloadUrl).toBeDefined();
expect(typeof body.downloadUrl).toBe("string");
expect(body.processedSize).toBeGreaterThan(0);
expect(body.pages).toBe(1);
} else {
const body = JSON.parse(res.body);
expect(body.error).toBeDefined();
expect(typeof body.error).toBe("string");
}
},
perTestTimeout,
);
}
});
describe("page size and orientation variations per core format", () => {
const PAGE_CONFIGS = [
{ pageSize: "A4", orientation: "portrait" },
{ pageSize: "A4", orientation: "landscape" },
{ pageSize: "Letter", orientation: "portrait" },
{ pageSize: "A3", orientation: "landscape" },
{ pageSize: "A5", orientation: "portrait" },
] as const;
// Only test core Sharp-readable formats for page config coverage
const CORE_FORMATS = FORMAT_SAMPLES.filter(
(f) => !f.needsCliDecoder && !f.needsHeifDecoder && !f.mayFailValidation,
);
for (const fmt of CORE_FORMATS) {
for (const cfg of PAGE_CONFIGS) {
it(`${fmt.name} -> ${cfg.pageSize} ${cfg.orientation}`, 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({
pageSize: cfg.pageSize,
orientation: cfg.orientation,
margin: 20,
}),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image-to-pdf",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
if (isAsyncFallback(res)) return;
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.downloadUrl).toBeDefined();
expect(body.processedSize).toBeGreaterThan(0);
expect(body.pages).toBe(1);
});
}
}
});
describe("multi-format PDF (mixed inputs in one document)", () => {
// Only combine core formats that Sharp can read natively
const CORE_FORMATS = FORMAT_SAMPLES.filter(
(f) => !f.needsCliDecoder && !f.needsHeifDecoder && !f.mayFailValidation,
);
// Test pairing each core format with PNG as a 2-page PDF
for (const fmt of CORE_FORMATS) {
// Skip PNG paired with PNG (redundant)
if (fmt.name === "PNG") continue;
it(`${fmt.name} + PNG as 2-page PDF`, async () => {
const fixturePath = join(FORMATS_DIR, fmt.file);
const pngPath = join(FORMATS_DIR, "sample.png");
if (!existsSync(fixturePath) || !existsSync(pngPath)) return;
const fmtBuffer = readFileSync(fixturePath);
const pngBuffer = readFileSync(pngPath);
const { body: payload, contentType } = createMultipartPayload([
{
name: "file",
filename: fmt.file,
contentType: fmt.mime,
content: fmtBuffer,
},
{
name: "file",
filename: "sample.png",
contentType: "image/png",
content: pngBuffer,
},
{
name: "settings",
content: JSON.stringify({ pageSize: "A4" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image-to-pdf",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
if (isAsyncFallback(res)) return;
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.pages).toBe(2);
expect(body.processedSize).toBeGreaterThan(0);
});
}
});
describe("multipage TIFF to PDF", () => {
const multipagePath = join(FORMATS_DIR, "multipage.tiff");
it("converts multipage TIFF to PDF", async () => {
if (!existsSync(multipagePath)) return;
const buffer = readFileSync(multipagePath);
const { body: payload, contentType } = createMultipartPayload([
{
name: "file",
filename: "multipage.tiff",
contentType: "image/tiff",
content: buffer,
},
{
name: "settings",
content: JSON.stringify({ pageSize: "A4" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image-to-pdf",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
expect([200, 400, 422]).toContain(res.statusCode);
if (res.statusCode === 200) {
const body = JSON.parse(res.body);
expect(body.downloadUrl).toBeDefined();
expect(body.processedSize).toBeGreaterThan(0);
expect(body.pages).toBeGreaterThanOrEqual(1);
} else {
const body = JSON.parse(res.body);
expect(body.error).toBeDefined();
}
});
});
describe("exotic format error resilience for image-to-pdf", () => {
const EXOTIC_FORMATS = FORMAT_SAMPLES.filter((f) => f.needsCliDecoder);
for (const fmt of EXOTIC_FORMATS) {
it(`${fmt.name} -> PDF: returns clean response (no crash)`, 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({ pageSize: "A4" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image-to-pdf",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
// Must not crash with 500
if (isAsyncFallback(res)) return;
if (isAsyncFallback(res)) return;
expect(res.statusCode).not.toBe(500);
expect([200, 400, 422]).toContain(res.statusCode);
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);
}
});
}
});
});
// ---------------------------------------------------------------------------
// Watermark-image exotic format error resilience
// ---------------------------------------------------------------------------
describe("Watermark-image exotic format error resilience", () => {
const EXOTIC_FORMATS = FORMAT_SAMPLES.filter((f) => f.needsCliDecoder);
const PNG_PATH = join(FORMATS_DIR, "sample.png");
describe("exotic format as main image", () => {
for (const fmt of EXOTIC_FORMATS) {
it(`${fmt.name} main + PNG watermark: no crash`, async () => {
const fixturePath = join(FORMATS_DIR, fmt.file);
if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return;
const mainBuffer = readFileSync(fixturePath);
const wmBuffer = readFileSync(PNG_PATH);
const { body: payload, contentType } = createMultipartPayload([
{
name: "file",
filename: fmt.file,
contentType: fmt.mime,
content: mainBuffer,
},
{
name: "watermark",
filename: "sample.png",
contentType: "image/png",
content: wmBuffer,
},
{
name: "settings",
content: JSON.stringify({ position: "center", opacity: 50, scale: 25 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/watermark-image",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
expect(res.statusCode).not.toBe(500);
expect([200, 400, 422]).toContain(res.statusCode);
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);
}
});
}
});
describe("exotic format as watermark image", () => {
for (const fmt of EXOTIC_FORMATS) {
it(`PNG main + ${fmt.name} watermark: no crash`, async () => {
const fixturePath = join(FORMATS_DIR, fmt.file);
if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return;
const mainBuffer = readFileSync(PNG_PATH);
const wmBuffer = readFileSync(fixturePath);
const { body: payload, contentType } = createMultipartPayload([
{
name: "file",
filename: "sample.png",
contentType: "image/png",
content: mainBuffer,
},
{
name: "watermark",
filename: fmt.file,
contentType: fmt.mime,
content: wmBuffer,
},
{
name: "settings",
content: JSON.stringify({ position: "bottom-right", opacity: 75, scale: 30 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/watermark-image",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
if (isAsyncFallback(res)) return;
expect(res.statusCode).not.toBe(500);
expect([200, 400, 422]).toContain(res.statusCode);
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);
}
});
}
});
});