/** * Expanded cross-format matrix integration tests. * * Covers tools NOT tested in format-matrix.test.ts or format-matrix-ai.test.ts: * * Single-file tools: * - color-blindness (createToolRoute factory, standard response) * - beautify (custom route, download response) * - edit-metadata (custom route + /inspect endpoint) * - vectorize (custom route, raster -> SVG) * - split (custom route, streams ZIP) * - favicon (custom route, streams ZIP) * - meme-generator (custom route, download response) * - barcode-read (custom route, JSON result) * * Multi-file tools (tested with pairs of format fixtures): * - compose (base + overlay) * - compare (image A + image B) * - collage (2+ images, template-based) * - stitch (2+ images, horizontal/vertical join) * * SVG-only tool: * - svg-to-raster (only accepts SVG input) * * AI tool with 501 guard: * - transparency-fixer (returns 501 when AI sidecar is not installed) * * For exotic formats (PSD, EXR, HDR, TGA, DNG, ICO, JXL, etc.) the test * accepts both success (200) and graceful errors (400, 422) since CLI * decoders may not be installed. For HEIC/HEIF the test accepts 422 when * libheif is unavailable. No tool should ever return 500 (server crash). */ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { fixtureDir, fixtures } from "../../fixtures/index.js"; import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp, } from "../test-server.js"; // --------------------------------------------------------------------------- // Format sample definitions (subset relevant to the expanded tools) // --------------------------------------------------------------------------- interface FormatSample { name: string; file: string; mime: string; needsCliDecoder: boolean; needsHeifDecoder: boolean; 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, }, ]; /** Core formats: natively readable by Sharp without CLI decoders */ const CORE_FORMATS = FORMAT_SAMPLES.filter( (f) => !f.needsCliDecoder && !f.needsHeifDecoder && !f.mayFailValidation, ); /** Exotic formats: need CLI decoders or HEIF support */ const EXOTIC_FORMATS = FORMAT_SAMPLES.filter( (f) => f.needsCliDecoder || f.needsHeifDecoder || f.mayFailValidation, ); /** Status codes we accept for formats that may lack decoder support */ const ACCEPTABLE_FALLBACK_CODES = [200, 202, 400, 422]; function needsFallback(fmt: FormatSample): boolean { return fmt.needsCliDecoder || fmt.needsHeifDecoder || fmt.mayFailValidation; } // --------------------------------------------------------------------------- // 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); // --------------------------------------------------------------------------- // 1. Color-blindness simulation x all formats // // Uses createToolRoute factory, returns standard download response. // --------------------------------------------------------------------------- describe("Color-blindness simulation cross-format", () => { for (const fmt of FORMAT_SAMPLES) { const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined; it( `processes ${fmt.name} input`, async () => { const fixturePath = join(fixtureDir.formats, 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({ simulationType: "deuteranomaly", }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/color-blindness", headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType, }, body: payload, }); 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 if (res.statusCode >= 400) { // 202 (accepted, async) has no sync body to verify; only error // codes carry a JSON error. const body = JSON.parse(res.body); expect(body.error).toBeDefined(); expect(typeof body.error).toBe("string"); } }, perTestTimeout, ); } }); // --------------------------------------------------------------------------- // 2. Beautify x all formats // // Custom route, accepts main image + optional background image. // Returns standard {downloadUrl, processedSize} response. // --------------------------------------------------------------------------- describe("Beautify cross-format", () => { for (const fmt of FORMAT_SAMPLES) { const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined; it( `processes ${fmt.name} input`, async () => { const fixturePath = join(fixtureDir.formats, 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({ padding: 20, borderRadius: 8, backgroundType: "solid", backgroundColor: "#f0f0f0", }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/beautify", headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType, }, body: payload, }); 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 if (res.statusCode >= 400) { // 202 (accepted, async) has no sync body to verify; only error // codes carry a JSON error. const body = JSON.parse(res.body); expect(body.error).toBeDefined(); expect(typeof body.error).toBe("string"); } }, perTestTimeout, ); } }); // --------------------------------------------------------------------------- // 3. Edit-metadata x all formats // // Custom route. Also tests the /inspect endpoint. // Edit-metadata writes EXIF tags in-place using ExifTool. // --------------------------------------------------------------------------- describe("Edit-metadata cross-format", () => { describe("edit endpoint", () => { for (const fmt of FORMAT_SAMPLES) { const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined; it( `edits metadata in ${fmt.name} input`, async () => { const fixturePath = join(fixtureDir.formats, 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({ title: "Test Title", author: "Test Author", }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/edit-metadata", headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType, }, body: payload, }); if (needsFallback(fmt)) { expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode); } else { // edit-metadata may also fail with 422 if ExifTool is not installed expect([200, 202, 400, 422]).toContain(res.statusCode); } 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("inspect endpoint", () => { for (const fmt of FORMAT_SAMPLES) { const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined; it( `inspects ${fmt.name} metadata`, async () => { const fixturePath = join(fixtureDir.formats, 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/edit-metadata/inspect", headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType, }, body: payload, }); // Inspect uses ExifTool, which may not be installed; accept clean errors expect(res.statusCode).not.toBe(500); expect([200, 202, 400, 422]).toContain(res.statusCode); const body = JSON.parse(res.body); if (res.statusCode === 200) { // inspect returns metadata fields; at minimum it is an object expect(typeof body).toBe("object"); } else { expect(body.error).toBeDefined(); expect(typeof body.error).toBe("string"); } }, perTestTimeout, ); } }); }); // --------------------------------------------------------------------------- // 4. Vectorize x all formats // // Custom route: converts raster image to SVG via potrace/vtracer. // Returns {downloadUrl, processedSize} with an SVG output. // --------------------------------------------------------------------------- describe("Vectorize cross-format", () => { for (const fmt of FORMAT_SAMPLES) { const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined; it( `vectorizes ${fmt.name} input`, async () => { const fixturePath = join(fixtureDir.formats, 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({ colorMode: "bw", threshold: 128 }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/vectorize", headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType, }, body: payload, }); 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.downloadUrl).toContain(".svg"); expect(body.processedSize).toBeGreaterThan(0); } else { const body = JSON.parse(res.body); expect(body.error).toBeDefined(); expect(typeof body.error).toBe("string"); } }, perTestTimeout, ); } }); // --------------------------------------------------------------------------- // 5. Split x core formats // // Custom route that streams a ZIP response (reply.hijack). // We verify the HTTP status is 200 and content-type is application/zip. // Only tested against core formats since the ZIP streaming response // prevents JSON error parsing on failure. // --------------------------------------------------------------------------- describe("Split cross-format", () => { for (const fmt of CORE_FORMATS) { it(`splits ${fmt.name} into grid tiles`, async () => { const fixturePath = join(fixtureDir.formats, 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({ columns: 2, rows: 2 }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/split", headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType, }, body: payload, }); // Split streams a ZIP, so 200 means the archive was successfully created expect(res.statusCode).toBe(200); expect(res.headers["content-type"]).toContain("application/zip"); }); } // Exotic formats: should not crash for (const fmt of EXOTIC_FORMATS) { const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined; it( `${fmt.name}: no server crash`, async () => { const fixturePath = join(fixtureDir.formats, 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({ columns: 2, rows: 2 }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/split", headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType, }, body: payload, }); // Must not crash. Accept 200 (ZIP streamed) or 422 (clean error) expect(res.statusCode).not.toBe(500); expect([200, 202, 400, 422]).toContain(res.statusCode); }, perTestTimeout, ); } }); // --------------------------------------------------------------------------- // 6. Favicon x core formats // // Custom route that streams a ZIP response with favicon variants. // Only tested against core formats since it hijacks the response. // --------------------------------------------------------------------------- describe("Favicon cross-format", () => { for (const fmt of CORE_FORMATS) { it(`generates favicons from ${fmt.name} input`, async () => { const fixturePath = join(fixtureDir.formats, 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/favicon", headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType, }, body: payload, }); // Favicon streams a ZIP expect(res.statusCode).toBe(200); expect(res.headers["content-type"]).toContain("application/zip"); }); } // NOTE: Exotic format error resilience is not tested for favicon because // the route uses reply.hijack() to stream ZIP output. When Sharp fails // mid-stream on an exotic format, the archive never finalizes and // app.inject() hangs indefinitely. The error path is still safe (the // route's try/catch returns 422 before hijack if decoding fails early), // but mid-stream failures are inherently untestable via inject(). }); // --------------------------------------------------------------------------- // 7. Meme-generator x core formats // // Custom route, returns {downloadUrl, processedSize}. // Uses text overlays rendered via SVG compositing. // --------------------------------------------------------------------------- describe("Meme-generator cross-format", () => { for (const fmt of FORMAT_SAMPLES) { const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined; it( `generates meme from ${fmt.name} input`, async () => { const fixturePath = join(fixtureDir.formats, 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({ textLayout: "top-bottom", textBoxes: [ { id: "top", text: "TOP TEXT" }, { id: "bottom", text: "BOTTOM TEXT" }, ], }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/meme-generator", headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType, }, body: payload, }); 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, ); } }); // --------------------------------------------------------------------------- // 8. Barcode-read x core formats // // Custom route. Returns JSON with barcode scan results and optional overlay. // Images without barcodes should still return 200 with empty results. // --------------------------------------------------------------------------- describe("Barcode-read cross-format", () => { for (const fmt of FORMAT_SAMPLES) { const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined; it( `scans ${fmt.name} input for barcodes`, async () => { const fixturePath = join(fixtureDir.formats, 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({ tryHarder: true }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/barcode-read", headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType, }, body: payload, }); 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); // barcode-read returns { barcodes: [...], overlayUrl?, width, height } expect(typeof body).toBe("object"); // The fixture images likely have no barcodes, so barcodes array // should be empty -- but the response must be well-formed if (body.barcodes !== undefined) { expect(Array.isArray(body.barcodes)).toBe(true); } } else { const body = JSON.parse(res.body); expect(body.error).toBeDefined(); expect(typeof body.error).toBe("string"); } }, perTestTimeout, ); } }); // --------------------------------------------------------------------------- // 9. SVG-to-raster (SVG-only tool) // // Only accepts SVG input. Non-SVG formats should be rejected with 400. // --------------------------------------------------------------------------- describe("SVG-to-raster", () => { it("converts SVG to PNG", async () => { const fixturePath = fixtures.image.formats("svg"); if (!existsSync(fixturePath)) return; const buffer = readFileSync(fixturePath); const { body: payload, contentType } = createMultipartPayload([ { name: "file", filename: "sample.svg", contentType: "image/svg+xml", content: buffer, }, { name: "settings", content: JSON.stringify({ outputFormat: "png", width: 200, dpi: 150, }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/svg-to-raster", 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(typeof body.downloadUrl).toBe("string"); expect(body.processedSize).toBeGreaterThan(0); }); // Test multiple output formats from SVG const SVG_OUTPUT_FORMATS = ["png", "jpg", "webp", "avif"] as const; for (const outFmt of SVG_OUTPUT_FORMATS) { const testTimeout = outFmt === "avif" ? 120_000 : 120_000; it(`converts SVG to ${outFmt}`, { timeout: testTimeout }, async () => { const fixturePath = fixtures.image.formats("svg"); if (!existsSync(fixturePath)) return; const buffer = readFileSync(fixturePath); const { body: payload, contentType } = createMultipartPayload([ { name: "file", filename: "sample.svg", contentType: "image/svg+xml", content: buffer, }, { name: "settings", content: JSON.stringify({ outputFormat: outFmt }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/svg-to-raster", 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.processedSize).toBeGreaterThan(0); }); } // Non-SVG formats should be rejected const NON_SVG_FORMATS = FORMAT_SAMPLES.filter( (f) => f.name !== "SVG" && !f.needsCliDecoder && !f.needsHeifDecoder, ); for (const fmt of NON_SVG_FORMATS) { it(`rejects ${fmt.name} input with 400`, async () => { const fixturePath = join(fixtureDir.formats, 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({ outputFormat: "png" }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/svg-to-raster", headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType, }, body: payload, }); expect(res.statusCode).toBe(400); const body = JSON.parse(res.body); expect(body.error).toBeDefined(); expect(typeof body.error).toBe("string"); }); } }); // --------------------------------------------------------------------------- // 10. Compose (multi-file) x core formats // // Requires two files: base image + overlay image. Tests each core format // as the base image with a fixed PNG overlay, and vice versa. // --------------------------------------------------------------------------- describe("Compose cross-format", () => { const PNG_PATH = fixtures.image.formats("png"); describe("format as base image (overlay is PNG)", () => { for (const fmt of CORE_FORMATS) { it(`${fmt.name} base + PNG overlay`, async () => { const fixturePath = join(fixtureDir.formats, fmt.file); if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return; const baseBuffer = readFileSync(fixturePath); const overlayBuffer = readFileSync(PNG_PATH); const { body: payload, contentType } = createMultipartPayload([ { name: "file", filename: fmt.file, contentType: fmt.mime, content: baseBuffer, }, { name: "overlay", filename: "sample.png", contentType: "image/png", content: overlayBuffer, }, { name: "settings", content: JSON.stringify({ x: 0, y: 0, opacity: 50, blendMode: "over", }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/compose", 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.processedSize).toBeGreaterThan(0); }); } }); describe("format as overlay image (base is PNG)", () => { for (const fmt of CORE_FORMATS) { // Skip PNG-on-PNG (redundant with the reverse test) if (fmt.name === "PNG") continue; it(`PNG base + ${fmt.name} overlay`, async () => { const fixturePath = join(fixtureDir.formats, fmt.file); if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return; const baseBuffer = readFileSync(PNG_PATH); const overlayBuffer = readFileSync(fixturePath); const { body: payload, contentType } = createMultipartPayload([ { name: "file", filename: "sample.png", contentType: "image/png", content: baseBuffer, }, { name: "overlay", filename: fmt.file, contentType: fmt.mime, content: overlayBuffer, }, { name: "settings", content: JSON.stringify({ x: 0, y: 0, opacity: 75, blendMode: "multiply", }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/compose", headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType, }, body: payload, }); // Overlay compositing with opacity < 100 can fail for some formats // (e.g., WebP, AVIF) due to alpha channel handling in Sharp. // Accept 200 (success) or 422 (processing error) for core formats. expect([200, 422]).toContain(res.statusCode); if (res.statusCode === 200) { const body = JSON.parse(res.body); expect(body.downloadUrl).toBeDefined(); expect(body.processedSize).toBeGreaterThan(0); } else { const body = JSON.parse(res.body); expect(body.error).toBeDefined(); expect(typeof body.error).toBe("string"); } }); } }); // Exotic format error resilience describe("exotic format as base (no crash)", () => { for (const fmt of EXOTIC_FORMATS) { const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined; it( `${fmt.name} base + PNG overlay: no crash`, async () => { const fixturePath = join(fixtureDir.formats, fmt.file); if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return; const baseBuffer = readFileSync(fixturePath); const overlayBuffer = readFileSync(PNG_PATH); const { body: payload, contentType } = createMultipartPayload([ { name: "file", filename: fmt.file, contentType: fmt.mime, content: baseBuffer, }, { name: "overlay", filename: "sample.png", contentType: "image/png", content: overlayBuffer, }, { name: "settings", content: JSON.stringify({ x: 0, y: 0, opacity: 50 }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/compose", headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType, }, body: payload, }); expect(res.statusCode).not.toBe(500); expect([200, 202, 400, 422]).toContain(res.statusCode); }, perTestTimeout, ); } }); }); // --------------------------------------------------------------------------- // 11. Compare (multi-file) x core formats // // Requires two files: image A + image B. // Tests comparing each core format against PNG. // --------------------------------------------------------------------------- describe("Compare cross-format", () => { const PNG_PATH = fixtures.image.formats("png"); for (const fmt of CORE_FORMATS) { it(`compares ${fmt.name} with PNG`, async () => { const fixturePath = join(fixtureDir.formats, fmt.file); if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return; const bufferA = readFileSync(fixturePath); const bufferB = readFileSync(PNG_PATH); const { body: payload, contentType } = createMultipartPayload([ { name: "file", filename: fmt.file, contentType: fmt.mime, content: bufferA, }, { name: "file", filename: "sample.png", contentType: "image/png", content: bufferB, }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/compare", headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType, }, body: payload, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); // Compare returns similarity metrics and diff image expect(typeof body).toBe("object"); if (body.similarity !== undefined) { expect(typeof body.similarity).toBe("number"); } }); } // Exotic format error resilience for (const fmt of EXOTIC_FORMATS) { const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined; it( `${fmt.name} vs PNG: no crash`, async () => { const fixturePath = join(fixtureDir.formats, fmt.file); if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return; const bufferA = readFileSync(fixturePath); const bufferB = readFileSync(PNG_PATH); const { body: payload, contentType } = createMultipartPayload([ { name: "file", filename: fmt.file, contentType: fmt.mime, content: bufferA, }, { name: "file", filename: "sample.png", contentType: "image/png", content: bufferB, }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/compare", headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType, }, body: payload, }); expect(res.statusCode).not.toBe(500); expect([200, 202, 400, 422]).toContain(res.statusCode); }, perTestTimeout, ); } }); // --------------------------------------------------------------------------- // 12. Collage (multi-file) x core formats // // Requires 2+ images and a template. Tests pairing each core format // with a PNG fixture in a 2-image collage. // --------------------------------------------------------------------------- describe("Collage cross-format", () => { const PNG_PATH = fixtures.image.formats("png"); for (const fmt of CORE_FORMATS) { // Skip PNG + PNG (trivial) if (fmt.name === "PNG") continue; it(`${fmt.name} + PNG in 2-image collage`, async () => { const fixturePath = join(fixtureDir.formats, fmt.file); if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return; const fmtBuffer = readFileSync(fixturePath); const pngBuffer = readFileSync(PNG_PATH); 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({ templateId: "2-h-equal", gap: 4, outputFormat: "png", }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/collage", 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.processedSize).toBeGreaterThan(0); }); } // Exotic format resilience for (const fmt of EXOTIC_FORMATS) { const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined; it( `${fmt.name} + PNG collage: no crash`, async () => { const fixturePath = join(fixtureDir.formats, fmt.file); if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return; const fmtBuffer = readFileSync(fixturePath); const pngBuffer = readFileSync(PNG_PATH); 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({ templateId: "2-h-equal", gap: 4, outputFormat: "png", }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/collage", headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType, }, body: payload, }); expect(res.statusCode).not.toBe(500); expect([200, 202, 400, 422]).toContain(res.statusCode); }, perTestTimeout, ); } }); // --------------------------------------------------------------------------- // 13. Stitch (multi-file) x core formats // // Requires 2+ images. Tests stitching each core format with a PNG fixture. // --------------------------------------------------------------------------- describe("Stitch cross-format", () => { const PNG_PATH = fixtures.image.formats("png"); for (const fmt of CORE_FORMATS) { // Skip PNG + PNG (trivial) if (fmt.name === "PNG") continue; it(`stitches ${fmt.name} + PNG horizontally`, async () => { const fixturePath = join(fixtureDir.formats, fmt.file); if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return; const fmtBuffer = readFileSync(fixturePath); const pngBuffer = readFileSync(PNG_PATH); 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({ direction: "horizontal", resizeMode: "fit", format: "png", }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/stitch", 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.processedSize).toBeGreaterThan(0); }); } // Exotic format resilience for (const fmt of EXOTIC_FORMATS) { const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined; it( `${fmt.name} + PNG stitch: no crash`, async () => { const fixturePath = join(fixtureDir.formats, fmt.file); if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return; const fmtBuffer = readFileSync(fixturePath); const pngBuffer = readFileSync(PNG_PATH); 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({ direction: "horizontal", resizeMode: "fit", format: "png", }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/stitch", headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType, }, body: payload, }); expect(res.statusCode).not.toBe(500); expect([200, 202, 400, 422]).toContain(res.statusCode); }, perTestTimeout, ); } }); // --------------------------------------------------------------------------- // 14. Transparency-fixer (AI tool with 501 guard) x formats // // Returns 501 when AI sidecar is not installed. // Tests that the validation layer works correctly for all formats. // --------------------------------------------------------------------------- describe("Transparency-fixer cross-format", () => { for (const fmt of FORMAT_SAMPLES) { const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined; it( `${fmt.name}: returns 501 or accepts input`, async () => { const fixturePath = join(fixtureDir.formats, 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({ defringe: 30, outputFormat: "png" }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/transparency-fixer", headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType, }, body: payload, }); // Must never crash expect(res.statusCode).not.toBe(500); // Acceptable: 200 (sync success), 202 (async accepted), 400 (bad input), // 422 (processing error), 501 (AI not installed) expect([200, 202, 400, 422, 501]).toContain(res.statusCode); const body = JSON.parse(res.body); if (res.statusCode === 501) { expect(body.code).toBe("FEATURE_NOT_INSTALLED"); expect(body.error).toBeDefined(); } else if (res.statusCode === 202) { expect(body.jobId).toBeDefined(); expect(body.async).toBe(true); } else if (res.statusCode >= 400) { expect(body.error).toBeDefined(); expect(typeof body.error).toBe("string"); } }, perTestTimeout, ); } }); // --------------------------------------------------------------------------- // 15. Color-blindness simulation types x JPEG // // Verifies each simulation type works correctly with a single core format. // --------------------------------------------------------------------------- describe("Color-blindness simulation types", () => { const SIMULATION_TYPES = [ "protanopia", "deuteranopia", "tritanopia", "protanomaly", "deuteranomaly", "tritanomaly", "achromatopsia", "blueConeMonochromacy", ] as const; for (const simType of SIMULATION_TYPES) { it(`simulation: ${simType}`, async () => { const fixturePath = fixtures.image.formats("jpg"); if (!existsSync(fixturePath)) return; const buffer = readFileSync(fixturePath); const { body: payload, contentType } = createMultipartPayload([ { name: "file", filename: "sample.jpg", contentType: "image/jpeg", content: buffer, }, { name: "settings", content: JSON.stringify({ simulationType: simType }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/color-blindness", 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.processedSize).toBeGreaterThan(0); }); } }); // --------------------------------------------------------------------------- // 16. Vectorize color mode variants x JPEG // // Verifies both BW and color vectorization modes. // --------------------------------------------------------------------------- describe("Vectorize color modes", () => { it("BW mode", async () => { const fixturePath = fixtures.image.formats("jpg"); if (!existsSync(fixturePath)) return; const buffer = readFileSync(fixturePath); const { body: payload, contentType } = createMultipartPayload([ { name: "file", filename: "sample.jpg", contentType: "image/jpeg", content: buffer, }, { name: "settings", content: JSON.stringify({ colorMode: "bw", threshold: 128, pathMode: "spline", }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/vectorize", headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType, }, body: payload, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.downloadUrl).toContain(".svg"); expect(body.processedSize).toBeGreaterThan(0); }); it("color mode", async () => { const fixturePath = fixtures.image.formats("jpg"); if (!existsSync(fixturePath)) return; const buffer = readFileSync(fixturePath); const { body: payload, contentType } = createMultipartPayload([ { name: "file", filename: "sample.jpg", contentType: "image/jpeg", content: buffer, }, { name: "settings", content: JSON.stringify({ colorMode: "color", colorPrecision: 4, layerDifference: 8, }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/vectorize", headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType, }, body: payload, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); expect(body.downloadUrl).toContain(".svg"); expect(body.processedSize).toBeGreaterThan(0); }); }); // --------------------------------------------------------------------------- // 17. Compose blend modes x JPEG // // Verifies each blend mode works correctly. // --------------------------------------------------------------------------- describe("Compose blend modes", () => { const BLEND_MODES = ["over", "multiply", "screen", "overlay", "darken", "lighten"] as const; const PNG_PATH = fixtures.image.formats("png"); for (const blendMode of BLEND_MODES) { it(`blend mode: ${blendMode}`, async () => { const jpgPath = fixtures.image.formats("jpg"); if (!existsSync(jpgPath) || !existsSync(PNG_PATH)) return; const baseBuffer = readFileSync(jpgPath); const overlayBuffer = readFileSync(PNG_PATH); const { body: payload, contentType } = createMultipartPayload([ { name: "file", filename: "sample.jpg", contentType: "image/jpeg", content: baseBuffer, }, { name: "overlay", filename: "sample.png", contentType: "image/png", content: overlayBuffer, }, { name: "settings", content: JSON.stringify({ x: 0, y: 0, opacity: 50, blendMode, }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/compose", 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.processedSize).toBeGreaterThan(0); }); } }); // --------------------------------------------------------------------------- // 18. Stitch direction modes x JPEG // // Verifies horizontal, vertical, and grid stitching. // --------------------------------------------------------------------------- describe("Stitch direction modes", () => { const DIRECTIONS = [ { direction: "horizontal", gridColumns: 2 }, { direction: "vertical", gridColumns: 2 }, { direction: "grid", gridColumns: 2 }, ] as const; const JPG_PATH = fixtures.image.formats("jpg"); const PNG_PATH = fixtures.image.formats("png"); for (const { direction, gridColumns } of DIRECTIONS) { it(`direction: ${direction}`, async () => { if (!existsSync(JPG_PATH) || !existsSync(PNG_PATH)) return; const bufA = readFileSync(JPG_PATH); const bufB = readFileSync(PNG_PATH); const { body: payload, contentType } = createMultipartPayload([ { name: "file", filename: "sample.jpg", contentType: "image/jpeg", content: bufA, }, { name: "file", filename: "sample.png", contentType: "image/png", content: bufB, }, { name: "settings", content: JSON.stringify({ direction, gridColumns, resizeMode: "fit", format: "png", }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/stitch", 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.processedSize).toBeGreaterThan(0); }); } }); // --------------------------------------------------------------------------- // 15b. Find-duplicates (multi-file) x core formats // // Requires 2+ images. Tests each core format paired with a PNG fixture. // find-duplicates computes perceptual hashes and returns duplicate groups. // --------------------------------------------------------------------------- describe("Find-duplicates cross-format", () => { const PNG_PATH = fixtures.image.formats("png"); for (const fmt of CORE_FORMATS) { it(`detects ${fmt.name} + PNG pair`, async () => { const fixturePath = join(fixtureDir.formats, fmt.file); if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return; const fmtBuffer = readFileSync(fixturePath); const pngBuffer = readFileSync(PNG_PATH); 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({ threshold: 8 }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/find-duplicates", headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType, }, body: payload, }); expect(res.statusCode).toBe(200); const body = JSON.parse(res.body); // find-duplicates returns { totalImages, duplicateGroups, uniqueCount } expect(typeof body).toBe("object"); expect(body.totalImages).toBe(2); expect(Array.isArray(body.duplicateGroups)).toBe(true); }); } // Exotic format resilience for (const fmt of EXOTIC_FORMATS) { const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined; it( `${fmt.name} + PNG find-duplicates: no crash`, async () => { const fixturePath = join(fixtureDir.formats, fmt.file); if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return; const fmtBuffer = readFileSync(fixturePath); const pngBuffer = readFileSync(PNG_PATH); 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({ threshold: 8 }), }, ]); const res = await app.inject({ method: "POST", url: "/api/v1/tools/image/find-duplicates", headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType, }, body: payload, }); expect(res.statusCode).not.toBe(500); expect([200, 202, 400, 422]).toContain(res.statusCode); }, perTestTimeout, ); } }); // --------------------------------------------------------------------------- // 19. Error resilience: missing file for expanded tools // // Verifies that each expanded tool returns a clean 400 when no file is sent. // --------------------------------------------------------------------------- describe("Missing file returns 400 for expanded tools", () => { const TOOL_ENDPOINTS = [ { url: "/api/v1/tools/image/color-blindness", settings: { simulationType: "deuteranomaly" } }, { url: "/api/v1/tools/image/beautify", settings: { padding: 20 } }, { url: "/api/v1/tools/image/edit-metadata", settings: { title: "test" } }, { url: "/api/v1/tools/image/vectorize", settings: { colorMode: "bw" } }, { url: "/api/v1/tools/image/split", settings: { columns: 2, rows: 2 } }, { url: "/api/v1/tools/image/favicon", settings: {} }, { url: "/api/v1/tools/image/meme-generator", settings: { textLayout: "top-bottom", textBoxes: [{ id: "top", text: "TEST" }], }, }, { url: "/api/v1/tools/image/barcode-read", settings: { tryHarder: true } }, { url: "/api/v1/tools/image/svg-to-raster", settings: { outputFormat: "png" } }, { url: "/api/v1/tools/image/compose", settings: { x: 0, y: 0 } }, { url: "/api/v1/tools/image/compare", settings: {} }, { url: "/api/v1/tools/image/collage", settings: { templateId: "2-h-equal" } }, { url: "/api/v1/tools/image/stitch", settings: { direction: "horizontal" } }, { url: "/api/v1/tools/image/find-duplicates", settings: { threshold: 8 } }, ]; for (const { url, settings } of TOOL_ENDPOINTS) { const toolName = url.split("/").pop(); it(`${toolName}: no file -> 400`, async () => { const { body: payload, contentType } = createMultipartPayload([ { name: "settings", content: JSON.stringify(settings), }, ]); const res = await app.inject({ method: "POST", url, headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType, }, body: payload, }); expect(res.statusCode).toBe(400); const body = JSON.parse(res.body); expect(body.error).toBeDefined(); expect(typeof body.error).toBe("string"); }); } }); // --------------------------------------------------------------------------- // 20. Unauthenticated requests -> 401 for expanded tools // --------------------------------------------------------------------------- describe("Unauthenticated requests return 401 for expanded tools", () => { const TOOL_ENDPOINTS = [ "/api/v1/tools/image/color-blindness", "/api/v1/tools/image/beautify", "/api/v1/tools/image/edit-metadata", "/api/v1/tools/image/vectorize", "/api/v1/tools/image/split", "/api/v1/tools/image/favicon", "/api/v1/tools/image/meme-generator", "/api/v1/tools/image/barcode-read", "/api/v1/tools/image/svg-to-raster", "/api/v1/tools/image/compose", "/api/v1/tools/image/compare", "/api/v1/tools/image/collage", "/api/v1/tools/image/stitch", "/api/v1/tools/image/find-duplicates", "/api/v1/tools/image/transparency-fixer", ]; for (const url of TOOL_ENDPOINTS) { const toolName = url.split("/").pop(); it(`${toolName}: no auth -> 401`, async () => { const fixturePath = fixtures.image.formats("png"); if (!existsSync(fixturePath)) return; const buffer = readFileSync(fixturePath); const { body: payload, contentType } = createMultipartPayload([ { name: "file", filename: "sample.png", contentType: "image/png", content: buffer, }, ]); const res = await app.inject({ method: "POST", url, headers: { // No authorization header "content-type": contentType, }, body: payload, }); expect(res.statusCode).toBe(401); }); } });