/** * 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 { apiToolPath } from "@snapotter/shared"; 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: string; /** Display name for test output */ label: string; /** Settings JSON sent as the "settings" multipart field */ settings: Record; /** * 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: apiToolPath(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: apiToolPath(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/image/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: apiToolPath(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/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/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/image/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/image/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/image/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/image/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/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/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/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/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/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/image/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/image/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); } }); } }); });