diff --git a/tests/helpers/shard-partition.ts b/tests/helpers/shard-partition.ts index a6061f15..c5520f6f 100644 --- a/tests/helpers/shard-partition.ts +++ b/tests/helpers/shard-partition.ts @@ -27,11 +27,19 @@ * Refresh by parsing per-test durations out of the Integration job logs. */ export const FILE_COST_SECONDS: Record = { - "tests/integration/generated/format-matrix-comprehensive.test.ts": 1365, - "tests/integration/generated/format-matrix.test.ts": 1130, - "tests/integration/generated/format-matrix-generated.test.ts": 779, "tests/integration/generated/format-matrix-exotic.test.ts": 370, + "tests/integration/generated/format-matrix-comprehensive-2.test.ts": 348, + "tests/integration/generated/format-matrix-comprehensive-1.test.ts": 343, + "tests/integration/generated/format-matrix-comprehensive-3.test.ts": 343, + "tests/integration/generated/format-matrix-comprehensive-4.test.ts": 343, "tests/integration/generated/format-matrix-expanded.test.ts": 319, + "tests/integration/generated/format-matrix-1.test.ts": 309, + "tests/integration/generated/format-matrix-2.test.ts": 309, + "tests/integration/generated/format-matrix-generated-1.test.ts": 269, + "tests/integration/generated/format-matrix-generated-2.test.ts": 269, + "tests/integration/generated/format-matrix-generated-3.test.ts": 269, + "tests/integration/generated/format-matrix-3.test.ts": 267, + "tests/integration/generated/format-matrix-4.test.ts": 261, "tests/integration/tools/image/image-enhancement.test.ts": 226, "tests/integration/generated/new-formats.test.ts": 202, "tests/integration/generated/format-matrix-multimodal.test.ts": 126, diff --git a/tests/integration/generated/format-matrix-1.test.ts b/tests/integration/generated/format-matrix-1.test.ts new file mode 100644 index 00000000..3fc5f89b --- /dev/null +++ b/tests/integration/generated/format-matrix-1.test.ts @@ -0,0 +1,154 @@ +/** + * Cross-format matrix, part 1 of 2 (see format-matrix.shared.ts). + * + * Vitest shards by file and runs a file's tests serially in one fork, so the + * cross-format matrix is striped across two part files that together cover + * every entry of FORMAT_SAMPLES exactly once. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { apiToolPath } from "@snapotter/shared"; +import { describe, expect, it } from "vitest"; +import { fixtureDir } from "../../fixtures/index.js"; +import { + ACCEPTABLE_FALLBACK_CODES, + adminToken, + app, + buildPayload, + formatSamplesForPart, + isAsyncFallback, + needsFallback, + setupMatrixApp, + TOOLS, +} from "./format-matrix.shared.js"; + +setupMatrixApp(); + +// --------------------------------------------------------------------------- +// Cross-format matrix: every tool x every format +// --------------------------------------------------------------------------- +describe("Cross-format matrix", () => { + for (const fmt of formatSamplesForPart(1)) { + describe(`${fmt.name} input (${fmt.file})`, () => { + const fixturePath = join(fixtureDir.formats, 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 >= 400) { + const body = JSON.parse(res.body); + expect(body.error).toBeDefined(); + expect(typeof body.error).toBe("string"); + } + }, + perTestTimeout, + ); + } + }); + } +}); diff --git a/tests/integration/generated/format-matrix-2.test.ts b/tests/integration/generated/format-matrix-2.test.ts new file mode 100644 index 00000000..19f4edb9 --- /dev/null +++ b/tests/integration/generated/format-matrix-2.test.ts @@ -0,0 +1,154 @@ +/** + * Cross-format matrix, part 2 of 2 (see format-matrix.shared.ts). + * + * Vitest shards by file and runs a file's tests serially in one fork, so the + * cross-format matrix is striped across two part files that together cover + * every entry of FORMAT_SAMPLES exactly once. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { apiToolPath } from "@snapotter/shared"; +import { describe, expect, it } from "vitest"; +import { fixtureDir } from "../../fixtures/index.js"; +import { + ACCEPTABLE_FALLBACK_CODES, + adminToken, + app, + buildPayload, + formatSamplesForPart, + isAsyncFallback, + needsFallback, + setupMatrixApp, + TOOLS, +} from "./format-matrix.shared.js"; + +setupMatrixApp(); + +// --------------------------------------------------------------------------- +// Cross-format matrix: every tool x every format +// --------------------------------------------------------------------------- +describe("Cross-format matrix", () => { + for (const fmt of formatSamplesForPart(2)) { + describe(`${fmt.name} input (${fmt.file})`, () => { + const fixturePath = join(fixtureDir.formats, 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 >= 400) { + const body = JSON.parse(res.body); + expect(body.error).toBeDefined(); + expect(typeof body.error).toBe("string"); + } + }, + perTestTimeout, + ); + } + }); + } +}); diff --git a/tests/integration/generated/format-matrix-3.test.ts b/tests/integration/generated/format-matrix-3.test.ts new file mode 100644 index 00000000..1582cb45 --- /dev/null +++ b/tests/integration/generated/format-matrix-3.test.ts @@ -0,0 +1,268 @@ +/** + * Cross-format matrix, part 3 of 4 (see format-matrix.shared.ts). + * + * Holds the multipage-TIFF, exotic-format resilience, enhancement-analysis and + * strip-metadata-inspection blocks. Split out of format-matrix.test.ts because + * vitest shards by file and runs a file's tests serially in one fork. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { apiToolPath } from "@snapotter/shared"; +import { describe, expect, it } from "vitest"; +import { fixtureDir, fixtures } from "../../fixtures/index.js"; +import { createMultipartPayload } from "../test-server.js"; +import { + adminToken, + app, + buildPayload, + FORMAT_SAMPLES, + isAsyncFallback, + setupMatrixApp, + TOOLS, +} from "./format-matrix.shared.js"; + +setupMatrixApp(); + +// --------------------------------------------------------------------------- +// Edge-case matrix: multipage TIFF +// --------------------------------------------------------------------------- +describe("Multipage TIFF handling", () => { + const multipagePath = fixtures.image.multipageTiff; + + 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, 202, 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(); + } + }); + } +}); + +// --------------------------------------------------------------------------- +// 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(fixtureDir.formats, 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, 202, 400, 422]).toContain(res.statusCode); + + // Response must always be valid JSON + const body = JSON.parse(res.body); + if (res.statusCode >= 400) { + 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(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/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(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/image-enhancement/analyze", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body: payload, + }); + + expect(res.statusCode).not.toBe(500); + expect([200, 202, 400, 422]).toContain(res.statusCode); + + const body = JSON.parse(res.body); + if (res.statusCode >= 400) { + 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(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/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); + }); + } +}); diff --git a/tests/integration/generated/format-matrix-4.test.ts b/tests/integration/generated/format-matrix-4.test.ts new file mode 100644 index 00000000..06a4aecb --- /dev/null +++ b/tests/integration/generated/format-matrix-4.test.ts @@ -0,0 +1,680 @@ +/** + * Cross-format matrix, part 4 of 4 (see format-matrix.shared.ts). + * + * Holds the conversion, watermark-image and image-to-pdf blocks. Split out of + * format-matrix.test.ts because vitest shards by file and runs a file's tests + * serially in one fork. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { fixtureDir, fixtures } from "../../fixtures/index.js"; +import { createMultipartPayload } from "../test-server.js"; +import { + ACCEPTABLE_FALLBACK_CODES, + adminToken, + app, + FORMAT_SAMPLES, + isAsyncFallback, + needsFallback, + setupMatrixApp, +} from "./format-matrix.shared.js"; + +setupMatrixApp(); + +// --------------------------------------------------------------------------- +// 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 : 120_000; + it(`${fmt.name} -> ${outFmt}`, { timeout: testTimeout }, 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({ 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); + }); + } + } +}); + +// --------------------------------------------------------------------------- +// 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 = fixtures.image.formats("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(fixtureDir.formats, 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(fixtureDir.formats, 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(fixtureDir.formats, 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(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({ + 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(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({ + 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(fixtureDir.formats, fmt.file); + const pngPath = fixtures.image.formats("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 = fixtures.image.multipageTiff; + + 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, 202, 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(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({ 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, 202, 400, 422]).toContain(res.statusCode); + + const body = JSON.parse(res.body); + if (res.statusCode >= 400) { + 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 = fixtures.image.formats("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(fixtureDir.formats, 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, 202, 400, 422]).toContain(res.statusCode); + + const body = JSON.parse(res.body); + if (res.statusCode >= 400) { + 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(fixtureDir.formats, 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, 202, 400, 422]).toContain(res.statusCode); + + const body = JSON.parse(res.body); + if (res.statusCode >= 400) { + expect(body.error).toBeDefined(); + expect(typeof body.error).toBe("string"); + expect(body.error.length).toBeGreaterThan(0); + } + }); + } + }); +}); diff --git a/tests/integration/generated/format-matrix-comprehensive-1.test.ts b/tests/integration/generated/format-matrix-comprehensive-1.test.ts new file mode 100644 index 00000000..0d0689e6 --- /dev/null +++ b/tests/integration/generated/format-matrix-comprehensive-1.test.ts @@ -0,0 +1,214 @@ +/** + * Comprehensive cross-format parameterized integration tests, part 1 of 4. + * + * Split from format-matrix-comprehensive.test.ts so the parts shard and run in + * parallel; shared setup and helpers live in + * ./format-matrix-comprehensive.shared.ts. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { fixtureDir } from "../../fixtures/index.js"; +import { createMultipartPayload } from "../test-server.js"; +import { + ACCEPTABLE_FALLBACK_CODES, + adminToken, + app, + assertDownloadResponse, + callTool, + type FormatDef, + getTimeout, + needsFallback, + PRIMARY_FORMATS, + setupMatrixApp, +} from "./format-matrix-comprehensive.shared.js"; + +vi.setConfig({ testTimeout: 60_000 }); + +setupMatrixApp(); + +describe("Strip-metadata across all 16 primary formats", () => { + for (const fmt of PRIMARY_FORMATS) { + it( + `strips metadata from ${fmt.name}`, + async () => { + const res = await callTool("strip-metadata", fmt, { stripAll: true }); + if (!res) return; + const body = assertDownloadResponse(res, fmt); + + // For core formats, verify that stripping metadata produces output + // (it may or may not reduce size depending on whether the fixture + // contains EXIF data) + if (body) { + expect(body.processedSize).toBeGreaterThan(0); + } + }, + getTimeout(fmt), + ); + } + + // Verify the inspect endpoint also works across formats + describe("strip-metadata inspect endpoint", () => { + for (const fmt of PRIMARY_FORMATS) { + it( + `inspects ${fmt.name}`, + 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/strip-metadata/inspect", + 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.filename).toBeDefined(); + expect(body.fileSize).toBeGreaterThan(0); + } + }, + getTimeout(fmt), + ); + } + }); +}); + +// ========================================================================= +// 9. INFO -- 16 formats with deeper metadata assertions +// ========================================================================= + +describe("Image enhancement across all 16 primary formats", () => { + const ENHANCE_CONFIGS = [ + { label: "auto mode, intensity 50", settings: { mode: "auto", intensity: 50 } }, + { label: "portrait mode, intensity 75", settings: { mode: "portrait", intensity: 75 } }, + { label: "low-light mode, intensity 40", settings: { mode: "low-light", intensity: 40 } }, + ] as const; + + for (const cfg of ENHANCE_CONFIGS) { + describe(`enhancement ${cfg.label}`, () => { + for (const fmt of PRIMARY_FORMATS) { + it( + `${fmt.name}`, + async () => { + const res = await callTool("image-enhancement", fmt, { ...cfg.settings }); + if (!res) return; + assertDownloadResponse(res, fmt); + }, + getTimeout(fmt, "image-enhancement"), + ); + } + }); + } + + // Enhancement analyze endpoint across all 16 formats + describe("analyze endpoint", () => { + for (const fmt of PRIMARY_FORMATS) { + it( + `analyzes ${fmt.name}`, + 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/image-enhancement/analyze", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body: payload, + }); + + if (needsFallback(fmt)) { + expect([200, 202, 400, 422]).toContain(res.statusCode); + } else { + expect(res.statusCode).toBe(200); + } + + if (res.statusCode === 200) { + const body = JSON.parse(res.body); + expect(body.corrections).toBeDefined(); + expect(typeof body.corrections).toBe("object"); + } else { + const body = JSON.parse(res.body); + expect(body.error).toBeDefined(); + expect(typeof body.error).toBe("string"); + } + }, + getTimeout(fmt), + ); + } + }); +}); + +// ========================================================================= +// 12. BORDER -- 16 formats x 2 border styles +// ========================================================================= + +describe("Animated GIF handling", () => { + const gifFmt: FormatDef = { + name: "GIF", + file: "sample.gif", + mime: "image/gif", + needsCliDecoder: false, + needsHeifDecoder: false, + mayFailValidation: false, + }; + + const TOOLS_FOR_GIF = [ + { id: "resize", settings: { width: 32, height: 32 } }, + { id: "crop", settings: { width: 10, height: 10, left: 0, top: 0 } }, + { id: "rotate", settings: { angle: 180 } }, + { id: "convert", settings: { format: "png" } }, + { id: "compress", settings: { mode: "quality", quality: 50 } }, + { id: "info", settings: {} }, + { id: "border", settings: { borderWidth: 3, borderColor: "#00FF00" } }, + { id: "strip-metadata", settings: { stripAll: true } }, + { id: "optimize-for-web", settings: { format: "webp", quality: 60 } }, + { id: "image-enhancement", settings: { mode: "auto", intensity: 30 } }, + { id: "adjust-colors", settings: { brightness: 5 } }, + { id: "sharpening", settings: { method: "adaptive" } }, + ]; + + for (const tool of TOOLS_FOR_GIF) { + it(`${tool.id}: processes without crash`, async () => { + const res = await callTool(tool.id, gifFmt, tool.settings); + if (!res) return; + expect(res.statusCode).toBe(200); + + const body = JSON.parse(res.body); + if (tool.id === "info") { + expect(body.width).toBeGreaterThan(0); + expect(body.height).toBeGreaterThan(0); + } else { + expect(body.downloadUrl || body.processedSize).toBeDefined(); + } + }); + } +}); + +// ========================================================================= +// 21. SVG SPECIAL HANDLING: vector format through raster tools +// ========================================================================= diff --git a/tests/integration/generated/format-matrix-comprehensive-2.test.ts b/tests/integration/generated/format-matrix-comprehensive-2.test.ts new file mode 100644 index 00000000..c6f0f0bb --- /dev/null +++ b/tests/integration/generated/format-matrix-comprehensive-2.test.ts @@ -0,0 +1,269 @@ +/** + * Comprehensive cross-format parameterized integration tests, part 2 of 4. + * + * Split from format-matrix-comprehensive.test.ts so the parts shard and run in + * parallel; shared setup and helpers live in + * ./format-matrix-comprehensive.shared.ts. + */ + +import { describe, expect, it, vi } from "vitest"; +import { createMultipartPayload } from "../test-server.js"; +import { + ACCEPTABLE_FALLBACK_CODES, + adminToken, + app, + assertDownloadResponse, + CORE_FORMATS, + callTool, + getTimeout, + isAsyncFallback, + needsFallback, + PRIMARY_FORMATS, + setupMatrixApp, +} from "./format-matrix-comprehensive.shared.js"; + +vi.setConfig({ testTimeout: 60_000 }); + +setupMatrixApp(); + +describe("Resize across all 16 primary formats", () => { + const RESIZE_CONFIGS = [ + { label: "50x50 contain", settings: { width: 50, height: 50, fit: "contain" } }, + { label: "100 wide (height auto)", settings: { width: 100 } }, + { label: "50% percentage", settings: { percentage: 50 } }, + ] as const; + + for (const cfg of RESIZE_CONFIGS) { + describe(`resize ${cfg.label}`, () => { + for (const fmt of PRIMARY_FORMATS) { + it( + `${fmt.name}`, + async () => { + const res = await callTool("resize", fmt, { ...cfg.settings }); + if (!res) return; + assertDownloadResponse(res, fmt); + }, + getTimeout(fmt), + ); + } + }); + } +}); + +// ========================================================================= +// 2. CROP -- 16 formats x 2 crop modes +// ========================================================================= + +describe("Convert: 16 formats -> 3 output targets", () => { + const OUTPUT_TARGETS = [ + { label: "JPEG", format: "jpg", ext: ".jpg" }, + { label: "PNG", format: "png", ext: ".png" }, + { label: "WebP", format: "webp", ext: ".webp" }, + ] as const; + + for (const target of OUTPUT_TARGETS) { + describe(`convert to ${target.label}`, () => { + for (const fmt of PRIMARY_FORMATS) { + // Skip identity conversions + if (fmt.name === "JPEG" && target.format === "jpg") continue; + if (fmt.name === "PNG" && target.format === "png") continue; + if (fmt.name === "WebP" && target.format === "webp") continue; + + it( + `${fmt.name} -> ${target.label}`, + async () => { + const res = await callTool("convert", fmt, { format: target.format }); + if (!res) return; + 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(body.downloadUrl).toContain(target.ext); + expect(body.processedSize).toBeGreaterThan(0); + } else { + const body = JSON.parse(res.body); + expect(body.error).toBeDefined(); + expect(typeof body.error).toBe("string"); + } + }, + getTimeout(fmt), + ); + } + }); + } +}); + +// ========================================================================= +// 5. COMPRESS -- 16 formats x 2 compression modes +// ========================================================================= + +describe("Compress across all 16 primary formats", () => { + const COMPRESS_CONFIGS = [ + { label: "quality mode (q=60)", settings: { mode: "quality", quality: 60 } }, + { label: "quality mode (q=30)", settings: { mode: "quality", quality: 30 } }, + ] as const; + + for (const cfg of COMPRESS_CONFIGS) { + describe(`compress ${cfg.label}`, () => { + for (const fmt of PRIMARY_FORMATS) { + it( + `${fmt.name}`, + async () => { + const res = await callTool("compress", fmt, { ...cfg.settings }); + if (!res) return; + assertDownloadResponse(res, fmt); + }, + getTimeout(fmt), + ); + } + }); + } +}); + +// ========================================================================= +// 6. ADJUST-COLORS -- 16 formats x 3 adjustment combos +// ========================================================================= + +describe("Convert round-trip: format -> WebP -> PNG (core formats)", () => { + for (const fmt of CORE_FORMATS) { + // Skip formats that are already WebP or PNG (not a meaningful round-trip) + if (fmt.name === "WebP" || fmt.name === "PNG") continue; + + it(`${fmt.name} -> WebP -> PNG`, async () => { + // Step 1: Convert to WebP + const toWebpRes = await callTool("convert", fmt, { format: "webp" }); + if (!toWebpRes) return; + expect(toWebpRes.statusCode).toBe(200); + + const webpBody = JSON.parse(toWebpRes.body); + expect(webpBody.downloadUrl).toContain(".webp"); + + // Step 2: Download the WebP output + const downloadRes = await app.inject({ + method: "GET", + url: webpBody.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(downloadRes.statusCode).toBe(200); + + const webpBuffer = Buffer.from(downloadRes.rawPayload); + expect(webpBuffer.length).toBeGreaterThan(0); + + // Step 3: Convert WebP to PNG + const { body: toPngPayload, contentType: toPngCt } = createMultipartPayload([ + { + name: "file", + filename: "intermediate.webp", + contentType: "image/webp", + content: webpBuffer, + }, + { name: "settings", content: JSON.stringify({ format: "png" }) }, + ]); + + const toPngRes = await app.inject({ + method: "POST", + url: "/api/v1/tools/image/convert", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": toPngCt, + }, + body: toPngPayload, + }); + + expect(toPngRes.statusCode).toBe(200); + const pngBody = JSON.parse(toPngRes.body); + expect(pngBody.downloadUrl).toContain(".png"); + expect(pngBody.processedSize).toBeGreaterThan(0); + }); + } +}); + +// ========================================================================= +// 15. ALL 12 TOOLS x each format -- no 500 crashes +// +// The main safety net: every combination must never crash the server. +// This uses a flat loop to generate 16 x 12 = 192 assertions. +// ========================================================================= + +describe("No-crash matrix: 16 formats x 12 tools", () => { + const TOOLS_WITH_SETTINGS: Array<{ + id: string; + label: string; + settings: Record; + }> = [ + { id: "resize", label: "Resize", settings: { width: 50, height: 50 } }, + { id: "crop", label: "Crop", settings: { width: 10, height: 10, left: 0, top: 0 } }, + { id: "rotate", label: "Rotate", settings: { angle: 90 } }, + { id: "convert", label: "Convert", settings: { format: "png" } }, + { id: "compress", label: "Compress", settings: { mode: "quality", quality: 60 } }, + { id: "adjust-colors", label: "Adjust colors", settings: { brightness: 10, contrast: 5 } }, + { id: "sharpening", label: "Sharpening", settings: { method: "adaptive" } }, + { id: "strip-metadata", label: "Strip metadata", settings: { stripAll: true } }, + { id: "info", label: "Info", settings: {} }, + { + id: "optimize-for-web", + label: "Optimize for web", + settings: { format: "webp", quality: 75 }, + }, + { id: "image-enhancement", label: "Enhancement", settings: { mode: "auto", intensity: 50 } }, + { id: "border", label: "Border", settings: { borderWidth: 5, borderColor: "#FF0000" } }, + ]; + + for (const fmt of PRIMARY_FORMATS) { + describe(`${fmt.name}`, () => { + for (const tool of TOOLS_WITH_SETTINGS) { + // Skip identity conversion + if (tool.id === "convert" && fmt.name === "PNG") continue; + + it( + `${tool.label}: no crash`, + async () => { + const res = await callTool(tool.id, fmt, tool.settings); + if (!res) return; + + // Must never return 500 + expect(res.statusCode, `${tool.label} + ${fmt.name}: got ${res.statusCode}`).not.toBe( + 500, + ); + + // A heavy encode may fall back to async (202) under CI load -- accept it. + if (isAsyncFallback(res)) return; + + // Must be a recognized status code + if (needsFallback(fmt)) { + expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode); + } else { + expect(res.statusCode).toBe(200); + } + + // Response must always be valid JSON + const body = JSON.parse(res.body); + expect(typeof body).toBe("object"); + + // If error, verify clean error shape + if (res.statusCode >= 400) { + expect(body.error).toBeDefined(); + expect(typeof body.error).toBe("string"); + expect(body.error.length).toBeGreaterThan(0); + } + }, + getTimeout(fmt, tool.id), + ); + } + }); + } +}); + +// ========================================================================= +// 16. EXTENDED CONVERT MATRIX: core formats -> 5 output formats +// +// Tests AVIF, TIFF, GIF as additional conversion targets beyond the +// JPEG/PNG/WebP matrix tested above. +// ========================================================================= diff --git a/tests/integration/generated/format-matrix-comprehensive-3.test.ts b/tests/integration/generated/format-matrix-comprehensive-3.test.ts new file mode 100644 index 00000000..ddf8be5c --- /dev/null +++ b/tests/integration/generated/format-matrix-comprehensive-3.test.ts @@ -0,0 +1,347 @@ +/** + * Comprehensive cross-format parameterized integration tests, part 3 of 4. + * + * Split from format-matrix-comprehensive.test.ts so the parts shard and run in + * parallel; shared setup and helpers live in + * ./format-matrix-comprehensive.shared.ts. + */ + +import { describe, expect, it, vi } from "vitest"; +import { createMultipartPayload } from "../test-server.js"; +import { + ACCEPTABLE_FALLBACK_CODES, + adminToken, + app, + assertDownloadResponse, + CORE_FORMATS, + callTool, + type FormatDef, + getTimeout, + needsFallback, + PRIMARY_FORMATS, + setupMatrixApp, +} from "./format-matrix-comprehensive.shared.js"; + +vi.setConfig({ testTimeout: 60_000 }); + +setupMatrixApp(); + +describe("Color adjustments across all 16 primary formats", () => { + const COLOR_CONFIGS = [ + { + label: "brightness +20, contrast +10", + settings: { brightness: 20, contrast: 10 }, + }, + { + label: "saturation +30", + settings: { saturation: 30 }, + }, + { + label: "brightness -10, saturation -15, contrast +5", + settings: { brightness: -10, saturation: -15, contrast: 5 }, + }, + ] as const; + + for (const cfg of COLOR_CONFIGS) { + describe(`adjust-colors ${cfg.label}`, () => { + for (const fmt of PRIMARY_FORMATS) { + it( + `${fmt.name}`, + async () => { + const res = await callTool("adjust-colors", fmt, { ...cfg.settings }); + if (!res) return; + assertDownloadResponse(res, fmt); + }, + getTimeout(fmt), + ); + } + }); + } +}); + +// ========================================================================= +// 7. SHARPENING -- 16 formats x 2 methods +// ========================================================================= + +describe("Info (metadata extraction) across all 16 primary formats", () => { + for (const fmt of PRIMARY_FORMATS) { + it( + `extracts metadata from ${fmt.name}`, + async () => { + const res = await callTool("info", fmt, {}); + if (!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.width).toBeGreaterThan(0); + expect(body.height).toBeGreaterThan(0); + expect(body.fileSize).toBeGreaterThan(0); + expect(body.format).toBeDefined(); + expect(typeof body.format).toBe("string"); + expect(body.channels).toBeGreaterThan(0); + // colorSpace is optional but if present should be a string + if (body.colorSpace !== undefined) { + expect(typeof body.colorSpace).toBe("string"); + } + } else { + const body = JSON.parse(res.body); + expect(body.error).toBeDefined(); + expect(typeof body.error).toBe("string"); + } + }, + getTimeout(fmt), + ); + } +}); + +// ========================================================================= +// 10. OPTIMIZE-FOR-WEB -- 16 formats x 2 target output formats +// ========================================================================= + +describe("Optimize-for-web across all 16 primary formats", () => { + const WEB_CONFIGS = [ + { label: "webp q75", settings: { format: "webp", quality: 75 } }, + { label: "avif q60", settings: { format: "avif", quality: 60 } }, + ] as const; + + for (const cfg of WEB_CONFIGS) { + describe(`optimize-for-web ${cfg.label}`, () => { + for (const fmt of PRIMARY_FORMATS) { + it( + `${fmt.name}`, + async () => { + const res = await callTool("optimize-for-web", fmt, { ...cfg.settings }); + if (!res) return; + assertDownloadResponse(res, fmt); + }, + getTimeout(fmt), + ); + } + }); + } +}); + +// ========================================================================= +// 11. IMAGE-ENHANCEMENT -- 16 formats x 3 enhancement modes +// ========================================================================= + +describe("Chained operations: resize then compress (core formats)", () => { + for (const fmt of CORE_FORMATS) { + it(`${fmt.name}: resize 100x100 -> compress q50`, async () => { + // Step 1: Resize + const resizeRes = await callTool("resize", fmt, { width: 100, height: 100 }); + if (!resizeRes) return; + expect(resizeRes.statusCode).toBe(200); + + const resizeBody = JSON.parse(resizeRes.body); + expect(resizeBody.downloadUrl).toBeDefined(); + + // Step 2: Download the resized file and compress it + const downloadRes = await app.inject({ + method: "GET", + url: resizeBody.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(downloadRes.statusCode).toBe(200); + + const resizedBuffer = Buffer.from(downloadRes.rawPayload); + expect(resizedBuffer.length).toBeGreaterThan(0); + + // Step 3: Compress the resized output + const { body: compressPayload, contentType: compressCt } = createMultipartPayload([ + { + name: "file", + filename: `resized-${fmt.file}`, + contentType: (downloadRes.headers["content-type"] as string) || fmt.mime, + content: resizedBuffer, + }, + { + name: "settings", + content: JSON.stringify({ mode: "quality", quality: 50 }), + }, + ]); + + const compressRes = await app.inject({ + method: "POST", + url: "/api/v1/tools/image/compress", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": compressCt, + }, + body: compressPayload, + }); + + expect(compressRes.statusCode).toBe(200); + const compressBody = JSON.parse(compressRes.body); + expect(compressBody.downloadUrl).toBeDefined(); + expect(compressBody.processedSize).toBeGreaterThan(0); + }); + } +}); + +// ========================================================================= +// 14. CONVERT ROUND-TRIP: core format -> WebP -> PNG +// ========================================================================= + +describe("Exotic format error shape verification", () => { + const EXOTIC_FORMATS = PRIMARY_FORMATS.filter((f) => f.needsCliDecoder); + + const TOOLS_TO_CHECK = [ + { id: "resize", settings: { width: 50, height: 50 } }, + { id: "crop", settings: { width: 10, height: 10, left: 0, top: 0 } }, + { id: "compress", settings: { mode: "quality", quality: 60 } }, + { id: "sharpening", settings: { method: "adaptive" } }, + { id: "image-enhancement", settings: { mode: "auto", intensity: 50 } }, + { id: "border", settings: { borderWidth: 5, borderColor: "#FF0000" } }, + ]; + + for (const fmt of EXOTIC_FORMATS) { + for (const tool of TOOLS_TO_CHECK) { + it( + `${fmt.name} + ${tool.id}: clean JSON error`, + async () => { + const res = await callTool(tool.id, fmt, tool.settings); + if (!res) return; + + expect(res.statusCode).not.toBe(500); + expect([200, 202, 400, 422]).toContain(res.statusCode); + + // Response must be valid JSON (not HTML, not raw text) + let body: Record; + try { + body = JSON.parse(res.body); + } catch { + throw new Error( + `${fmt.name} + ${tool.id}: response is not valid JSON: ${res.body.slice(0, 200)}`, + ); + } + + if (res.statusCode >= 400) { + expect(body.error).toBeDefined(); + expect(typeof body.error).toBe("string"); + // Error message should not contain raw stack trace indicators + const errorStr = body.error as string; + expect(errorStr).not.toContain("at Object."); + expect(errorStr).not.toContain("at Module."); + expect(errorStr).not.toContain("node_modules"); + } + }, + getTimeout(fmt), + ); + } + } +}); + +// ========================================================================= +// 19. HEIC/HEIF SPECIFIC: graceful handling when libheif unavailable +// ========================================================================= + +describe("HEIC/HEIF graceful handling", () => { + const HEIF_FORMATS = PRIMARY_FORMATS.filter((f) => f.needsHeifDecoder); + + const CORE_TOOLS = [ + { id: "resize", settings: { width: 50, height: 50 } }, + { id: "crop", settings: { width: 10, height: 10, left: 0, top: 0 } }, + { id: "rotate", settings: { angle: 90 } }, + { id: "convert", settings: { format: "png" } }, + { id: "compress", settings: { mode: "quality", quality: 60 } }, + { id: "adjust-colors", settings: { brightness: 10 } }, + { id: "sharpening", settings: { method: "adaptive" } }, + { id: "strip-metadata", settings: { stripAll: true } }, + { id: "info", settings: {} }, + { id: "optimize-for-web", settings: { format: "webp", quality: 75 } }, + { id: "image-enhancement", settings: { mode: "auto", intensity: 50 } }, + { id: "border", settings: { borderWidth: 5, borderColor: "#FF0000" } }, + ]; + + for (const fmt of HEIF_FORMATS) { + describe(`${fmt.name}`, () => { + for (const tool of CORE_TOOLS) { + it( + `${tool.id}: no crash`, + async () => { + const res = await callTool(tool.id, fmt, tool.settings); + if (!res) return; + + // Must never crash + expect(res.statusCode).not.toBe(500); + + // Accept success (200) or clean error (400/422) + expect([200, 202, 400, 422]).toContain(res.statusCode); + + const body = JSON.parse(res.body); + if (res.statusCode >= 400) { + expect(body.error).toBeDefined(); + expect(typeof body.error).toBe("string"); + } + }, + tool.id === "image-enhancement" ? 300_000 : 180_000, + ); + } + }); + } +}); + +// ========================================================================= +// 20. ANIMATED GIF: verify tools handle it without crashing +// ========================================================================= + +describe("SVG through raster tools", () => { + const svgFmt: FormatDef = { + name: "SVG", + file: "sample.svg", + mime: "image/svg+xml", + needsCliDecoder: false, + needsHeifDecoder: false, + mayFailValidation: false, + }; + + const SVG_TOOLS = [ + { id: "resize", settings: { width: 100, height: 100 } }, + { id: "crop", settings: { width: 50, height: 50, left: 0, top: 0 } }, + { id: "rotate", settings: { angle: 90 } }, + { id: "convert", settings: { format: "png" } }, + { id: "compress", settings: { mode: "quality", quality: 60 } }, + { id: "info", settings: {} }, + { id: "border", settings: { borderWidth: 5, borderColor: "#333333" } }, + { id: "adjust-colors", settings: { brightness: 10 } }, + { id: "sharpening", settings: { method: "adaptive" } }, + { id: "optimize-for-web", settings: { format: "webp", quality: 75 } }, + { id: "image-enhancement", settings: { mode: "auto", intensity: 50 } }, + { id: "strip-metadata", settings: { stripAll: true } }, + ]; + + for (const tool of SVG_TOOLS) { + it(`${tool.id}: handles SVG input`, async () => { + const res = await callTool(tool.id, svgFmt, tool.settings); + if (!res) return; + + // SVG should either be rasterized and processed (200) or cleanly rejected + expect(res.statusCode).not.toBe(500); + expect([200, 202, 400, 422]).toContain(res.statusCode); + + const body = JSON.parse(res.body); + if (res.statusCode === 200) { + if (tool.id === "info") { + expect(body.width).toBeGreaterThan(0); + expect(body.height).toBeGreaterThan(0); + } else { + expect(body.downloadUrl || body.processedSize).toBeDefined(); + } + } else { + expect(body.error).toBeDefined(); + expect(typeof body.error).toBe("string"); + } + }); + } +}); + +// ========================================================================= +// 22. ICO SPECIAL HANDLING: multi-size format +// ========================================================================= diff --git a/tests/integration/generated/format-matrix-comprehensive-4.test.ts b/tests/integration/generated/format-matrix-comprehensive-4.test.ts new file mode 100644 index 00000000..a02e736a --- /dev/null +++ b/tests/integration/generated/format-matrix-comprehensive-4.test.ts @@ -0,0 +1,240 @@ +/** + * Comprehensive cross-format parameterized integration tests, part 4 of 4. + * + * Split from format-matrix-comprehensive.test.ts so the parts shard and run in + * parallel; shared setup and helpers live in + * ./format-matrix-comprehensive.shared.ts. + */ + +import { describe, expect, it, vi } from "vitest"; +import { + assertDownloadResponse, + CORE_FORMATS, + callTool, + type FormatDef, + getTimeout, + isAsyncFallback, + PRIMARY_FORMATS, + setupMatrixApp, +} from "./format-matrix-comprehensive.shared.js"; + +vi.setConfig({ testTimeout: 60_000 }); + +setupMatrixApp(); + +describe("Crop across all 16 primary formats", () => { + const CROP_CONFIGS = [ + { label: "10x10 px at origin", settings: { width: 10, height: 10, left: 0, top: 0 } }, + { label: "50x50 px at 5,5", settings: { width: 50, height: 50, left: 5, top: 5 } }, + ] as const; + + for (const cfg of CROP_CONFIGS) { + describe(`crop ${cfg.label}`, () => { + for (const fmt of PRIMARY_FORMATS) { + it( + `${fmt.name}`, + async () => { + const res = await callTool("crop", fmt, { ...cfg.settings }); + if (!res) return; + assertDownloadResponse(res, fmt); + }, + getTimeout(fmt), + ); + } + }); + } +}); + +// ========================================================================= +// 3. ROTATE -- 16 formats x 4 angles + flip combos +// ========================================================================= + +describe("Rotate across all 16 primary formats", () => { + const ROTATE_CONFIGS = [ + { label: "90 degrees", settings: { angle: 90 } }, + { label: "180 degrees", settings: { angle: 180 } }, + { label: "270 degrees", settings: { angle: 270 } }, + { label: "horizontal flip", settings: { angle: 0, horizontal: true } }, + ] as const; + + for (const cfg of ROTATE_CONFIGS) { + describe(`rotate ${cfg.label}`, () => { + for (const fmt of PRIMARY_FORMATS) { + it( + `${fmt.name}`, + async () => { + const res = await callTool("rotate", fmt, { ...cfg.settings }); + if (!res) return; + assertDownloadResponse(res, fmt); + }, + getTimeout(fmt), + ); + } + }); + } +}); + +// ========================================================================= +// 4. CONVERT -- each of the 16 formats -> JPEG, PNG, WebP +// ========================================================================= + +describe("Sharpening across all 16 primary formats", () => { + const SHARPEN_CONFIGS = [ + { label: "adaptive method", settings: { method: "adaptive" } }, + { label: "unsharp-mask method", settings: { method: "unsharp-mask" } }, + ] as const; + + for (const cfg of SHARPEN_CONFIGS) { + describe(`sharpening ${cfg.label}`, () => { + for (const fmt of PRIMARY_FORMATS) { + it( + `${fmt.name}`, + async () => { + const res = await callTool("sharpening", fmt, { ...cfg.settings }); + if (!res) return; + assertDownloadResponse(res, fmt); + }, + getTimeout(fmt), + ); + } + }); + } +}); + +// ========================================================================= +// 8. STRIP-METADATA -- 16 formats + verify stripped output has fewer bytes +// ========================================================================= + +describe("Border across all 16 primary formats", () => { + const BORDER_CONFIGS = [ + { label: "5px red", settings: { borderWidth: 5, borderColor: "#FF0000" } }, + { label: "10px blue", settings: { borderWidth: 10, borderColor: "#0000FF" } }, + ] as const; + + for (const cfg of BORDER_CONFIGS) { + describe(`border ${cfg.label}`, () => { + for (const fmt of PRIMARY_FORMATS) { + it( + `${fmt.name}`, + async () => { + const res = await callTool("border", fmt, { ...cfg.settings }); + if (!res) return; + assertDownloadResponse(res, fmt); + }, + getTimeout(fmt), + ); + } + }); + } +}); + +// ========================================================================= +// 13. CHAINED OPERATIONS: core formats through resize then compress +// ========================================================================= + +describe("Extended conversion targets (core formats)", () => { + const EXTENDED_TARGETS = [ + { format: "avif", ext: ".avif" }, + { format: "tiff", ext: ".tiff" }, + { format: "gif", ext: ".gif" }, + ] as const; + + for (const target of EXTENDED_TARGETS) { + describe(`convert to ${target.format}`, () => { + for (const fmt of CORE_FORMATS) { + // Skip identity conversions + if (fmt.name.toLowerCase() === target.format) continue; + if (fmt.name === "AVIF" && target.format === "avif") continue; + + const testTimeout = target.format === "avif" || target.format === "gif" ? 120_000 : 120_000; + it(`${fmt.name} -> ${target.format}`, { timeout: testTimeout }, async () => { + const res = await callTool("convert", fmt, { format: target.format }); + if (!res) return; + if (isAsyncFallback(res)) return; + expect(res.statusCode).toBe(200); + + const body = JSON.parse(res.body); + expect(body.downloadUrl).toBeDefined(); + expect(body.downloadUrl).toContain(target.ext); + expect(body.processedSize).toBeGreaterThan(0); + }); + } + }); + } +}); + +// ========================================================================= +// 17. INFO CONSISTENCY: dimensions are consistent with resize output +// +// For core formats: get info, then resize to specific dimensions and +// verify the resize succeeded (demonstrates info output is meaningful). +// ========================================================================= + +describe("Info consistency check (core formats)", () => { + for (const fmt of CORE_FORMATS) { + it(`${fmt.name}: info dimensions are positive`, async () => { + const res = await callTool("info", fmt, {}); + if (!res) return; + expect(res.statusCode).toBe(200); + + const body = JSON.parse(res.body); + expect(body.width).toBeGreaterThan(0); + expect(body.height).toBeGreaterThan(0); + + // Verify we can resize to half the original dimensions + const halfW = Math.max(1, Math.floor(body.width / 2)); + const halfH = Math.max(1, Math.floor(body.height / 2)); + + const resizeRes = await callTool("resize", fmt, { width: halfW, height: halfH }); + if (!resizeRes) return; + expect(resizeRes.statusCode).toBe(200); + + const resizeBody = JSON.parse(resizeRes.body); + expect(resizeBody.processedSize).toBeGreaterThan(0); + }); + } +}); + +// ========================================================================= +// 18. EXOTIC FORMAT ERROR SHAPE VERIFICATION +// +// Exotic formats that fail should return structured errors, not raw +// stack traces or HTML error pages. This is a deeper check than the +// no-crash matrix. +// ========================================================================= + +describe("ICO (multi-size format) through tools", () => { + const icoFmt: FormatDef = { + name: "ICO", + file: "sample.ico", + mime: "image/x-icon", + needsCliDecoder: true, + needsHeifDecoder: false, + mayFailValidation: false, + }; + + const ICO_TOOLS = [ + { id: "resize", settings: { width: 32, height: 32 } }, + { id: "convert", settings: { format: "png" } }, + { id: "info", settings: {} }, + { id: "border", settings: { borderWidth: 2, borderColor: "#000000" } }, + { id: "image-enhancement", settings: { mode: "auto", intensity: 50 } }, + ]; + + for (const tool of ICO_TOOLS) { + it(`${tool.id}: handles ICO input (may need ImageMagick)`, async () => { + const res = await callTool(tool.id, icoFmt, tool.settings); + if (!res) return; + + // ICO requires CLI decoder; accept success or clean error + expect(res.statusCode).not.toBe(500); + expect([200, 202, 400, 422]).toContain(res.statusCode); + + const body = JSON.parse(res.body); + if (res.statusCode >= 400) { + expect(body.error).toBeDefined(); + expect(typeof body.error).toBe("string"); + } + }, 180_000); + } +}); diff --git a/tests/integration/generated/format-matrix-comprehensive.shared.ts b/tests/integration/generated/format-matrix-comprehensive.shared.ts new file mode 100644 index 00000000..2fde67a1 --- /dev/null +++ b/tests/integration/generated/format-matrix-comprehensive.shared.ts @@ -0,0 +1,306 @@ +/** + * Comprehensive cross-format parameterized integration tests. + * + * Targets the 16 primary formats x 12 core tools with deeper + * parameterized coverage using describe.each / test.each patterns: + * + * Formats (16): JPEG, PNG, WebP, AVIF, HEIC, HEIF, GIF, BMP, TIFF, + * SVG, PSD, DNG, TGA, EXR, HDR, ICO + * + * Tools (12): resize, crop, rotate, convert, compress, + * adjust-colors, sharpening, strip-metadata, info, + * optimize-for-web, image-enhancement, border + * + * Each tool section uses test.each to run the same assertion against every + * format. This complements format-matrix.test.ts by adding: + * - Per-tool settings variations (e.g. multiple resize dimensions, + * multiple rotation angles, multiple compression modes) + * - Convert target matrix (each format -> JPEG, PNG, WebP) + * - Output content-type verification for download responses + * - Deeper assertion on info/metadata responses + * - Chained processing: resize then compress a single format + * + * Exotic formats (PSD, DNG, TGA, EXR, HDR, ICO) and HEIC/HEIF may lack + * CLI decoders or libheif. Tests accept 200, 400, or 422 for those and + * verify the error shape when not 200. Core formats must return 200. * + * SPLIT NOTE: this preamble lives in its own module because the original + * single spec file was split into format-matrix-comprehensive-1..4.test.ts. + * Vitest shards by FILE and runs one file's tests serially inside a single + * fork, so one huge spec set the floor for the whole Integration CI job. + * Four part files run in parallel; this module holds everything they share. + */ + +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { apiToolPath } from "@snapotter/shared"; +import { afterAll, beforeAll, expect } from "vitest"; +import { fixtureDir } from "../../fixtures/index.js"; +import { + buildTestApp, + createMultipartPayload, + loginAsAdmin, + type TestApp, +} from "../test-server.js"; + +// --------------------------------------------------------------------------- +// Format definitions for the 16 primary formats +// --------------------------------------------------------------------------- +export interface FormatDef { + name: string; + file: string; + mime: string; + /** Requires CLI decoder (ImageMagick / dcraw) -- may not be installed */ + needsCliDecoder: boolean; + /** Requires libheif decoder -- may not be installed */ + needsHeifDecoder: boolean; + /** Sharp may fail validation for this format */ + mayFailValidation: boolean; +} + +export const PRIMARY_FORMATS: FormatDef[] = [ + { + 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: "AVIF", + file: "sample.avif", + mime: "image/avif", + needsCliDecoder: false, + needsHeifDecoder: false, + mayFailValidation: false, + }, + { + name: "GIF", + file: "sample.gif", + mime: "image/gif", + 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: "SVG", + file: "sample.svg", + mime: "image/svg+xml", + needsCliDecoder: false, + needsHeifDecoder: false, + mayFailValidation: false, + }, + { + 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: "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: "DNG", + file: "sample.dng", + mime: "image/x-adobe-dng", + needsCliDecoder: true, + needsHeifDecoder: false, + mayFailValidation: false, + }, + { + name: "TGA", + file: "sample.tga", + mime: "image/x-tga", + 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, + }, +]; + +export const CORE_FORMATS = PRIMARY_FORMATS.filter( + (f) => !f.needsCliDecoder && !f.needsHeifDecoder && !f.mayFailValidation, +); + +export const ACCEPTABLE_FALLBACK_CODES = [200, 202, 400, 422]; + +export function needsFallback(fmt: FormatDef): boolean { + return fmt.needsCliDecoder || fmt.needsHeifDecoder || fmt.mayFailValidation; +} + +/** + * A CPU-heavy encode can exceed the sync window (SYNC_WAIT_MS, 30s in tests) + * under parallel CI load and fall back to async: 202 {jobId, async: true}. Per + * the documented 200-or-202 contract that is a legitimate "accepted & processing" + * outcome -- the worker runs the same process fn either way -- not a failure. + * Returns true (validating the async body shape) when the response is that + * fallback, so callers can treat it as a pass. + */ +export 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; +} + +export function getTimeout(fmt: FormatDef, toolId?: string): number | undefined { + if ((fmt.needsHeifDecoder || fmt.needsCliDecoder) && toolId === "image-enhancement") + return 300_000; + if (fmt.needsHeifDecoder || fmt.needsCliDecoder) return 180_000; + if (toolId === "image-enhancement") return 120_000; + return undefined; +} + +// --------------------------------------------------------------------------- +// Shared app state +// --------------------------------------------------------------------------- +export let testApp: TestApp; +export let app: TestApp["app"]; +export let adminToken: string; + +/** Registers the shared beforeAll/afterAll for a part file. */ +export function setupMatrixApp(): void { + beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); + }, 30_000); + + afterAll(async () => { + await testApp.cleanup(); + }, 10_000); +} + +// --------------------------------------------------------------------------- +// Helper: send a tool request and return the response +// --------------------------------------------------------------------------- +export async function callTool(toolId: string, fmt: FormatDef, settings: Record) { + const fixturePath = join(fixtureDir.formats, fmt.file); + if (!existsSync(fixturePath)) { + return null; + } + + const buffer = readFileSync(fixturePath); + const fields: Array<{ + name: string; + filename?: string; + contentType?: string; + content: Buffer | string; + }> = [{ name: "file", filename: fmt.file, contentType: fmt.mime, content: buffer }]; + + if (Object.keys(settings).length > 0) { + fields.push({ name: "settings", content: JSON.stringify(settings) }); + } + + const { body: payload, contentType } = createMultipartPayload(fields); + + return app.inject({ + method: "POST", + url: apiToolPath(toolId), + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body: payload, + }); +} + +/** + * Assert a standard download response shape (used by most tools). + * For fallback formats, accepts 200/400/422. For core formats, expects 200. + */ +export function assertDownloadResponse(res: { statusCode: number; body: string }, fmt: FormatDef) { + if (isAsyncFallback(res)) return undefined; + 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); + return body; + } + + // Error path: verify clean JSON error + const body = JSON.parse(res.body); + expect(body.error).toBeDefined(); + expect(typeof body.error).toBe("string"); + expect(body.error.length).toBeGreaterThan(0); + return null; +} diff --git a/tests/integration/generated/format-matrix-comprehensive.test.ts b/tests/integration/generated/format-matrix-comprehensive.test.ts deleted file mode 100644 index 33e2f222..00000000 --- a/tests/integration/generated/format-matrix-comprehensive.test.ts +++ /dev/null @@ -1,1246 +0,0 @@ -/** - * Comprehensive cross-format parameterized integration tests. - * - * Targets the 16 primary formats x 12 core tools with deeper - * parameterized coverage using describe.each / test.each patterns: - * - * Formats (16): JPEG, PNG, WebP, AVIF, HEIC, HEIF, GIF, BMP, TIFF, - * SVG, PSD, DNG, TGA, EXR, HDR, ICO - * - * Tools (12): resize, crop, rotate, convert, compress, - * adjust-colors, sharpening, strip-metadata, info, - * optimize-for-web, image-enhancement, border - * - * Each tool section uses test.each to run the same assertion against every - * format. This complements format-matrix.test.ts by adding: - * - Per-tool settings variations (e.g. multiple resize dimensions, - * multiple rotation angles, multiple compression modes) - * - Convert target matrix (each format -> JPEG, PNG, WebP) - * - Output content-type verification for download responses - * - Deeper assertion on info/metadata responses - * - Chained processing: resize then compress a single format - * - * Exotic formats (PSD, DNG, TGA, EXR, HDR, ICO) and HEIC/HEIF may lack - * CLI decoders or libheif. Tests accept 200, 400, or 422 for those and - * verify the error shape when not 200. Core formats must return 200. - */ - -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { apiToolPath } from "@snapotter/shared"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; -import { fixtureDir } from "../../fixtures/index.js"; -import { - buildTestApp, - createMultipartPayload, - loginAsAdmin, - type TestApp, -} from "../test-server.js"; - -vi.setConfig({ testTimeout: 60_000 }); - -// --------------------------------------------------------------------------- -// Format definitions for the 16 primary formats -// --------------------------------------------------------------------------- -interface FormatDef { - name: string; - file: string; - mime: string; - /** Requires CLI decoder (ImageMagick / dcraw) -- may not be installed */ - needsCliDecoder: boolean; - /** Requires libheif decoder -- may not be installed */ - needsHeifDecoder: boolean; - /** Sharp may fail validation for this format */ - mayFailValidation: boolean; -} - -const PRIMARY_FORMATS: FormatDef[] = [ - { - 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: "AVIF", - file: "sample.avif", - mime: "image/avif", - needsCliDecoder: false, - needsHeifDecoder: false, - mayFailValidation: false, - }, - { - name: "GIF", - file: "sample.gif", - mime: "image/gif", - 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: "SVG", - file: "sample.svg", - mime: "image/svg+xml", - needsCliDecoder: false, - needsHeifDecoder: false, - mayFailValidation: false, - }, - { - 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: "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: "DNG", - file: "sample.dng", - mime: "image/x-adobe-dng", - needsCliDecoder: true, - needsHeifDecoder: false, - mayFailValidation: false, - }, - { - name: "TGA", - file: "sample.tga", - mime: "image/x-tga", - 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, - }, -]; - -const CORE_FORMATS = PRIMARY_FORMATS.filter( - (f) => !f.needsCliDecoder && !f.needsHeifDecoder && !f.mayFailValidation, -); - -const ACCEPTABLE_FALLBACK_CODES = [200, 202, 400, 422]; - -function needsFallback(fmt: FormatDef): boolean { - return fmt.needsCliDecoder || fmt.needsHeifDecoder || fmt.mayFailValidation; -} - -/** - * A CPU-heavy encode can exceed the sync window (SYNC_WAIT_MS, 30s in tests) - * under parallel CI load and fall back to async: 202 {jobId, async: true}. Per - * the documented 200-or-202 contract that is a legitimate "accepted & processing" - * outcome -- the worker runs the same process fn either way -- not a failure. - * Returns true (validating the async body shape) when the response is that - * fallback, so callers can treat it as a pass. - */ -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; -} - -function getTimeout(fmt: FormatDef, toolId?: string): number | undefined { - if ((fmt.needsHeifDecoder || fmt.needsCliDecoder) && toolId === "image-enhancement") - return 300_000; - if (fmt.needsHeifDecoder || fmt.needsCliDecoder) return 180_000; - if (toolId === "image-enhancement") return 120_000; - return undefined; -} - -// --------------------------------------------------------------------------- -// Shared app 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); - -// --------------------------------------------------------------------------- -// Helper: send a tool request and return the response -// --------------------------------------------------------------------------- -async function callTool(toolId: string, fmt: FormatDef, settings: Record) { - const fixturePath = join(fixtureDir.formats, fmt.file); - if (!existsSync(fixturePath)) { - return null; - } - - const buffer = readFileSync(fixturePath); - const fields: Array<{ - name: string; - filename?: string; - contentType?: string; - content: Buffer | string; - }> = [{ name: "file", filename: fmt.file, contentType: fmt.mime, content: buffer }]; - - if (Object.keys(settings).length > 0) { - fields.push({ name: "settings", content: JSON.stringify(settings) }); - } - - const { body: payload, contentType } = createMultipartPayload(fields); - - return app.inject({ - method: "POST", - url: apiToolPath(toolId), - headers: { - authorization: `Bearer ${adminToken}`, - "content-type": contentType, - }, - body: payload, - }); -} - -/** - * Assert a standard download response shape (used by most tools). - * For fallback formats, accepts 200/400/422. For core formats, expects 200. - */ -function assertDownloadResponse(res: { statusCode: number; body: string }, fmt: FormatDef) { - if (isAsyncFallback(res)) return undefined; - 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); - return body; - } - - // Error path: verify clean JSON error - const body = JSON.parse(res.body); - expect(body.error).toBeDefined(); - expect(typeof body.error).toBe("string"); - expect(body.error.length).toBeGreaterThan(0); - return null; -} - -// ========================================================================= -// 1. RESIZE -- 16 formats x 3 dimension sets -// ========================================================================= -describe("Resize across all 16 primary formats", () => { - const RESIZE_CONFIGS = [ - { label: "50x50 contain", settings: { width: 50, height: 50, fit: "contain" } }, - { label: "100 wide (height auto)", settings: { width: 100 } }, - { label: "50% percentage", settings: { percentage: 50 } }, - ] as const; - - for (const cfg of RESIZE_CONFIGS) { - describe(`resize ${cfg.label}`, () => { - for (const fmt of PRIMARY_FORMATS) { - it( - `${fmt.name}`, - async () => { - const res = await callTool("resize", fmt, { ...cfg.settings }); - if (!res) return; - assertDownloadResponse(res, fmt); - }, - getTimeout(fmt), - ); - } - }); - } -}); - -// ========================================================================= -// 2. CROP -- 16 formats x 2 crop modes -// ========================================================================= -describe("Crop across all 16 primary formats", () => { - const CROP_CONFIGS = [ - { label: "10x10 px at origin", settings: { width: 10, height: 10, left: 0, top: 0 } }, - { label: "50x50 px at 5,5", settings: { width: 50, height: 50, left: 5, top: 5 } }, - ] as const; - - for (const cfg of CROP_CONFIGS) { - describe(`crop ${cfg.label}`, () => { - for (const fmt of PRIMARY_FORMATS) { - it( - `${fmt.name}`, - async () => { - const res = await callTool("crop", fmt, { ...cfg.settings }); - if (!res) return; - assertDownloadResponse(res, fmt); - }, - getTimeout(fmt), - ); - } - }); - } -}); - -// ========================================================================= -// 3. ROTATE -- 16 formats x 4 angles + flip combos -// ========================================================================= -describe("Rotate across all 16 primary formats", () => { - const ROTATE_CONFIGS = [ - { label: "90 degrees", settings: { angle: 90 } }, - { label: "180 degrees", settings: { angle: 180 } }, - { label: "270 degrees", settings: { angle: 270 } }, - { label: "horizontal flip", settings: { angle: 0, horizontal: true } }, - ] as const; - - for (const cfg of ROTATE_CONFIGS) { - describe(`rotate ${cfg.label}`, () => { - for (const fmt of PRIMARY_FORMATS) { - it( - `${fmt.name}`, - async () => { - const res = await callTool("rotate", fmt, { ...cfg.settings }); - if (!res) return; - assertDownloadResponse(res, fmt); - }, - getTimeout(fmt), - ); - } - }); - } -}); - -// ========================================================================= -// 4. CONVERT -- each of the 16 formats -> JPEG, PNG, WebP -// ========================================================================= -describe("Convert: 16 formats -> 3 output targets", () => { - const OUTPUT_TARGETS = [ - { label: "JPEG", format: "jpg", ext: ".jpg" }, - { label: "PNG", format: "png", ext: ".png" }, - { label: "WebP", format: "webp", ext: ".webp" }, - ] as const; - - for (const target of OUTPUT_TARGETS) { - describe(`convert to ${target.label}`, () => { - for (const fmt of PRIMARY_FORMATS) { - // Skip identity conversions - if (fmt.name === "JPEG" && target.format === "jpg") continue; - if (fmt.name === "PNG" && target.format === "png") continue; - if (fmt.name === "WebP" && target.format === "webp") continue; - - it( - `${fmt.name} -> ${target.label}`, - async () => { - const res = await callTool("convert", fmt, { format: target.format }); - if (!res) return; - 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(body.downloadUrl).toContain(target.ext); - expect(body.processedSize).toBeGreaterThan(0); - } else { - const body = JSON.parse(res.body); - expect(body.error).toBeDefined(); - expect(typeof body.error).toBe("string"); - } - }, - getTimeout(fmt), - ); - } - }); - } -}); - -// ========================================================================= -// 5. COMPRESS -- 16 formats x 2 compression modes -// ========================================================================= -describe("Compress across all 16 primary formats", () => { - const COMPRESS_CONFIGS = [ - { label: "quality mode (q=60)", settings: { mode: "quality", quality: 60 } }, - { label: "quality mode (q=30)", settings: { mode: "quality", quality: 30 } }, - ] as const; - - for (const cfg of COMPRESS_CONFIGS) { - describe(`compress ${cfg.label}`, () => { - for (const fmt of PRIMARY_FORMATS) { - it( - `${fmt.name}`, - async () => { - const res = await callTool("compress", fmt, { ...cfg.settings }); - if (!res) return; - assertDownloadResponse(res, fmt); - }, - getTimeout(fmt), - ); - } - }); - } -}); - -// ========================================================================= -// 6. ADJUST-COLORS -- 16 formats x 3 adjustment combos -// ========================================================================= -describe("Color adjustments across all 16 primary formats", () => { - const COLOR_CONFIGS = [ - { - label: "brightness +20, contrast +10", - settings: { brightness: 20, contrast: 10 }, - }, - { - label: "saturation +30", - settings: { saturation: 30 }, - }, - { - label: "brightness -10, saturation -15, contrast +5", - settings: { brightness: -10, saturation: -15, contrast: 5 }, - }, - ] as const; - - for (const cfg of COLOR_CONFIGS) { - describe(`adjust-colors ${cfg.label}`, () => { - for (const fmt of PRIMARY_FORMATS) { - it( - `${fmt.name}`, - async () => { - const res = await callTool("adjust-colors", fmt, { ...cfg.settings }); - if (!res) return; - assertDownloadResponse(res, fmt); - }, - getTimeout(fmt), - ); - } - }); - } -}); - -// ========================================================================= -// 7. SHARPENING -- 16 formats x 2 methods -// ========================================================================= -describe("Sharpening across all 16 primary formats", () => { - const SHARPEN_CONFIGS = [ - { label: "adaptive method", settings: { method: "adaptive" } }, - { label: "unsharp-mask method", settings: { method: "unsharp-mask" } }, - ] as const; - - for (const cfg of SHARPEN_CONFIGS) { - describe(`sharpening ${cfg.label}`, () => { - for (const fmt of PRIMARY_FORMATS) { - it( - `${fmt.name}`, - async () => { - const res = await callTool("sharpening", fmt, { ...cfg.settings }); - if (!res) return; - assertDownloadResponse(res, fmt); - }, - getTimeout(fmt), - ); - } - }); - } -}); - -// ========================================================================= -// 8. STRIP-METADATA -- 16 formats + verify stripped output has fewer bytes -// ========================================================================= -describe("Strip-metadata across all 16 primary formats", () => { - for (const fmt of PRIMARY_FORMATS) { - it( - `strips metadata from ${fmt.name}`, - async () => { - const res = await callTool("strip-metadata", fmt, { stripAll: true }); - if (!res) return; - const body = assertDownloadResponse(res, fmt); - - // For core formats, verify that stripping metadata produces output - // (it may or may not reduce size depending on whether the fixture - // contains EXIF data) - if (body) { - expect(body.processedSize).toBeGreaterThan(0); - } - }, - getTimeout(fmt), - ); - } - - // Verify the inspect endpoint also works across formats - describe("strip-metadata inspect endpoint", () => { - for (const fmt of PRIMARY_FORMATS) { - it( - `inspects ${fmt.name}`, - 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/strip-metadata/inspect", - 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.filename).toBeDefined(); - expect(body.fileSize).toBeGreaterThan(0); - } - }, - getTimeout(fmt), - ); - } - }); -}); - -// ========================================================================= -// 9. INFO -- 16 formats with deeper metadata assertions -// ========================================================================= -describe("Info (metadata extraction) across all 16 primary formats", () => { - for (const fmt of PRIMARY_FORMATS) { - it( - `extracts metadata from ${fmt.name}`, - async () => { - const res = await callTool("info", fmt, {}); - if (!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.width).toBeGreaterThan(0); - expect(body.height).toBeGreaterThan(0); - expect(body.fileSize).toBeGreaterThan(0); - expect(body.format).toBeDefined(); - expect(typeof body.format).toBe("string"); - expect(body.channels).toBeGreaterThan(0); - // colorSpace is optional but if present should be a string - if (body.colorSpace !== undefined) { - expect(typeof body.colorSpace).toBe("string"); - } - } else { - const body = JSON.parse(res.body); - expect(body.error).toBeDefined(); - expect(typeof body.error).toBe("string"); - } - }, - getTimeout(fmt), - ); - } -}); - -// ========================================================================= -// 10. OPTIMIZE-FOR-WEB -- 16 formats x 2 target output formats -// ========================================================================= -describe("Optimize-for-web across all 16 primary formats", () => { - const WEB_CONFIGS = [ - { label: "webp q75", settings: { format: "webp", quality: 75 } }, - { label: "avif q60", settings: { format: "avif", quality: 60 } }, - ] as const; - - for (const cfg of WEB_CONFIGS) { - describe(`optimize-for-web ${cfg.label}`, () => { - for (const fmt of PRIMARY_FORMATS) { - it( - `${fmt.name}`, - async () => { - const res = await callTool("optimize-for-web", fmt, { ...cfg.settings }); - if (!res) return; - assertDownloadResponse(res, fmt); - }, - getTimeout(fmt), - ); - } - }); - } -}); - -// ========================================================================= -// 11. IMAGE-ENHANCEMENT -- 16 formats x 3 enhancement modes -// ========================================================================= -describe("Image enhancement across all 16 primary formats", () => { - const ENHANCE_CONFIGS = [ - { label: "auto mode, intensity 50", settings: { mode: "auto", intensity: 50 } }, - { label: "portrait mode, intensity 75", settings: { mode: "portrait", intensity: 75 } }, - { label: "low-light mode, intensity 40", settings: { mode: "low-light", intensity: 40 } }, - ] as const; - - for (const cfg of ENHANCE_CONFIGS) { - describe(`enhancement ${cfg.label}`, () => { - for (const fmt of PRIMARY_FORMATS) { - it( - `${fmt.name}`, - async () => { - const res = await callTool("image-enhancement", fmt, { ...cfg.settings }); - if (!res) return; - assertDownloadResponse(res, fmt); - }, - getTimeout(fmt, "image-enhancement"), - ); - } - }); - } - - // Enhancement analyze endpoint across all 16 formats - describe("analyze endpoint", () => { - for (const fmt of PRIMARY_FORMATS) { - it( - `analyzes ${fmt.name}`, - 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/image-enhancement/analyze", - headers: { - authorization: `Bearer ${adminToken}`, - "content-type": contentType, - }, - body: payload, - }); - - if (needsFallback(fmt)) { - expect([200, 202, 400, 422]).toContain(res.statusCode); - } else { - expect(res.statusCode).toBe(200); - } - - if (res.statusCode === 200) { - const body = JSON.parse(res.body); - expect(body.corrections).toBeDefined(); - expect(typeof body.corrections).toBe("object"); - } else { - const body = JSON.parse(res.body); - expect(body.error).toBeDefined(); - expect(typeof body.error).toBe("string"); - } - }, - getTimeout(fmt), - ); - } - }); -}); - -// ========================================================================= -// 12. BORDER -- 16 formats x 2 border styles -// ========================================================================= -describe("Border across all 16 primary formats", () => { - const BORDER_CONFIGS = [ - { label: "5px red", settings: { borderWidth: 5, borderColor: "#FF0000" } }, - { label: "10px blue", settings: { borderWidth: 10, borderColor: "#0000FF" } }, - ] as const; - - for (const cfg of BORDER_CONFIGS) { - describe(`border ${cfg.label}`, () => { - for (const fmt of PRIMARY_FORMATS) { - it( - `${fmt.name}`, - async () => { - const res = await callTool("border", fmt, { ...cfg.settings }); - if (!res) return; - assertDownloadResponse(res, fmt); - }, - getTimeout(fmt), - ); - } - }); - } -}); - -// ========================================================================= -// 13. CHAINED OPERATIONS: core formats through resize then compress -// ========================================================================= -describe("Chained operations: resize then compress (core formats)", () => { - for (const fmt of CORE_FORMATS) { - it(`${fmt.name}: resize 100x100 -> compress q50`, async () => { - // Step 1: Resize - const resizeRes = await callTool("resize", fmt, { width: 100, height: 100 }); - if (!resizeRes) return; - expect(resizeRes.statusCode).toBe(200); - - const resizeBody = JSON.parse(resizeRes.body); - expect(resizeBody.downloadUrl).toBeDefined(); - - // Step 2: Download the resized file and compress it - const downloadRes = await app.inject({ - method: "GET", - url: resizeBody.downloadUrl, - headers: { authorization: `Bearer ${adminToken}` }, - }); - expect(downloadRes.statusCode).toBe(200); - - const resizedBuffer = Buffer.from(downloadRes.rawPayload); - expect(resizedBuffer.length).toBeGreaterThan(0); - - // Step 3: Compress the resized output - const { body: compressPayload, contentType: compressCt } = createMultipartPayload([ - { - name: "file", - filename: `resized-${fmt.file}`, - contentType: (downloadRes.headers["content-type"] as string) || fmt.mime, - content: resizedBuffer, - }, - { - name: "settings", - content: JSON.stringify({ mode: "quality", quality: 50 }), - }, - ]); - - const compressRes = await app.inject({ - method: "POST", - url: "/api/v1/tools/image/compress", - headers: { - authorization: `Bearer ${adminToken}`, - "content-type": compressCt, - }, - body: compressPayload, - }); - - expect(compressRes.statusCode).toBe(200); - const compressBody = JSON.parse(compressRes.body); - expect(compressBody.downloadUrl).toBeDefined(); - expect(compressBody.processedSize).toBeGreaterThan(0); - }); - } -}); - -// ========================================================================= -// 14. CONVERT ROUND-TRIP: core format -> WebP -> PNG -// ========================================================================= -describe("Convert round-trip: format -> WebP -> PNG (core formats)", () => { - for (const fmt of CORE_FORMATS) { - // Skip formats that are already WebP or PNG (not a meaningful round-trip) - if (fmt.name === "WebP" || fmt.name === "PNG") continue; - - it(`${fmt.name} -> WebP -> PNG`, async () => { - // Step 1: Convert to WebP - const toWebpRes = await callTool("convert", fmt, { format: "webp" }); - if (!toWebpRes) return; - expect(toWebpRes.statusCode).toBe(200); - - const webpBody = JSON.parse(toWebpRes.body); - expect(webpBody.downloadUrl).toContain(".webp"); - - // Step 2: Download the WebP output - const downloadRes = await app.inject({ - method: "GET", - url: webpBody.downloadUrl, - headers: { authorization: `Bearer ${adminToken}` }, - }); - expect(downloadRes.statusCode).toBe(200); - - const webpBuffer = Buffer.from(downloadRes.rawPayload); - expect(webpBuffer.length).toBeGreaterThan(0); - - // Step 3: Convert WebP to PNG - const { body: toPngPayload, contentType: toPngCt } = createMultipartPayload([ - { - name: "file", - filename: "intermediate.webp", - contentType: "image/webp", - content: webpBuffer, - }, - { name: "settings", content: JSON.stringify({ format: "png" }) }, - ]); - - const toPngRes = await app.inject({ - method: "POST", - url: "/api/v1/tools/image/convert", - headers: { - authorization: `Bearer ${adminToken}`, - "content-type": toPngCt, - }, - body: toPngPayload, - }); - - expect(toPngRes.statusCode).toBe(200); - const pngBody = JSON.parse(toPngRes.body); - expect(pngBody.downloadUrl).toContain(".png"); - expect(pngBody.processedSize).toBeGreaterThan(0); - }); - } -}); - -// ========================================================================= -// 15. ALL 12 TOOLS x each format -- no 500 crashes -// -// The main safety net: every combination must never crash the server. -// This uses a flat loop to generate 16 x 12 = 192 assertions. -// ========================================================================= -describe("No-crash matrix: 16 formats x 12 tools", () => { - const TOOLS_WITH_SETTINGS: Array<{ - id: string; - label: string; - settings: Record; - }> = [ - { id: "resize", label: "Resize", settings: { width: 50, height: 50 } }, - { id: "crop", label: "Crop", settings: { width: 10, height: 10, left: 0, top: 0 } }, - { id: "rotate", label: "Rotate", settings: { angle: 90 } }, - { id: "convert", label: "Convert", settings: { format: "png" } }, - { id: "compress", label: "Compress", settings: { mode: "quality", quality: 60 } }, - { id: "adjust-colors", label: "Adjust colors", settings: { brightness: 10, contrast: 5 } }, - { id: "sharpening", label: "Sharpening", settings: { method: "adaptive" } }, - { id: "strip-metadata", label: "Strip metadata", settings: { stripAll: true } }, - { id: "info", label: "Info", settings: {} }, - { - id: "optimize-for-web", - label: "Optimize for web", - settings: { format: "webp", quality: 75 }, - }, - { id: "image-enhancement", label: "Enhancement", settings: { mode: "auto", intensity: 50 } }, - { id: "border", label: "Border", settings: { borderWidth: 5, borderColor: "#FF0000" } }, - ]; - - for (const fmt of PRIMARY_FORMATS) { - describe(`${fmt.name}`, () => { - for (const tool of TOOLS_WITH_SETTINGS) { - // Skip identity conversion - if (tool.id === "convert" && fmt.name === "PNG") continue; - - it( - `${tool.label}: no crash`, - async () => { - const res = await callTool(tool.id, fmt, tool.settings); - if (!res) return; - - // Must never return 500 - expect(res.statusCode, `${tool.label} + ${fmt.name}: got ${res.statusCode}`).not.toBe( - 500, - ); - - // A heavy encode may fall back to async (202) under CI load -- accept it. - if (isAsyncFallback(res)) return; - - // Must be a recognized status code - if (needsFallback(fmt)) { - expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode); - } else { - expect(res.statusCode).toBe(200); - } - - // Response must always be valid JSON - const body = JSON.parse(res.body); - expect(typeof body).toBe("object"); - - // If error, verify clean error shape - if (res.statusCode >= 400) { - expect(body.error).toBeDefined(); - expect(typeof body.error).toBe("string"); - expect(body.error.length).toBeGreaterThan(0); - } - }, - getTimeout(fmt, tool.id), - ); - } - }); - } -}); - -// ========================================================================= -// 16. EXTENDED CONVERT MATRIX: core formats -> 5 output formats -// -// Tests AVIF, TIFF, GIF as additional conversion targets beyond the -// JPEG/PNG/WebP matrix tested above. -// ========================================================================= -describe("Extended conversion targets (core formats)", () => { - const EXTENDED_TARGETS = [ - { format: "avif", ext: ".avif" }, - { format: "tiff", ext: ".tiff" }, - { format: "gif", ext: ".gif" }, - ] as const; - - for (const target of EXTENDED_TARGETS) { - describe(`convert to ${target.format}`, () => { - for (const fmt of CORE_FORMATS) { - // Skip identity conversions - if (fmt.name.toLowerCase() === target.format) continue; - if (fmt.name === "AVIF" && target.format === "avif") continue; - - const testTimeout = target.format === "avif" || target.format === "gif" ? 120_000 : 120_000; - it(`${fmt.name} -> ${target.format}`, { timeout: testTimeout }, async () => { - const res = await callTool("convert", fmt, { format: target.format }); - if (!res) return; - if (isAsyncFallback(res)) return; - expect(res.statusCode).toBe(200); - - const body = JSON.parse(res.body); - expect(body.downloadUrl).toBeDefined(); - expect(body.downloadUrl).toContain(target.ext); - expect(body.processedSize).toBeGreaterThan(0); - }); - } - }); - } -}); - -// ========================================================================= -// 17. INFO CONSISTENCY: dimensions are consistent with resize output -// -// For core formats: get info, then resize to specific dimensions and -// verify the resize succeeded (demonstrates info output is meaningful). -// ========================================================================= -describe("Info consistency check (core formats)", () => { - for (const fmt of CORE_FORMATS) { - it(`${fmt.name}: info dimensions are positive`, async () => { - const res = await callTool("info", fmt, {}); - if (!res) return; - expect(res.statusCode).toBe(200); - - const body = JSON.parse(res.body); - expect(body.width).toBeGreaterThan(0); - expect(body.height).toBeGreaterThan(0); - - // Verify we can resize to half the original dimensions - const halfW = Math.max(1, Math.floor(body.width / 2)); - const halfH = Math.max(1, Math.floor(body.height / 2)); - - const resizeRes = await callTool("resize", fmt, { width: halfW, height: halfH }); - if (!resizeRes) return; - expect(resizeRes.statusCode).toBe(200); - - const resizeBody = JSON.parse(resizeRes.body); - expect(resizeBody.processedSize).toBeGreaterThan(0); - }); - } -}); - -// ========================================================================= -// 18. EXOTIC FORMAT ERROR SHAPE VERIFICATION -// -// Exotic formats that fail should return structured errors, not raw -// stack traces or HTML error pages. This is a deeper check than the -// no-crash matrix. -// ========================================================================= -describe("Exotic format error shape verification", () => { - const EXOTIC_FORMATS = PRIMARY_FORMATS.filter((f) => f.needsCliDecoder); - - const TOOLS_TO_CHECK = [ - { id: "resize", settings: { width: 50, height: 50 } }, - { id: "crop", settings: { width: 10, height: 10, left: 0, top: 0 } }, - { id: "compress", settings: { mode: "quality", quality: 60 } }, - { id: "sharpening", settings: { method: "adaptive" } }, - { id: "image-enhancement", settings: { mode: "auto", intensity: 50 } }, - { id: "border", settings: { borderWidth: 5, borderColor: "#FF0000" } }, - ]; - - for (const fmt of EXOTIC_FORMATS) { - for (const tool of TOOLS_TO_CHECK) { - it( - `${fmt.name} + ${tool.id}: clean JSON error`, - async () => { - const res = await callTool(tool.id, fmt, tool.settings); - if (!res) return; - - expect(res.statusCode).not.toBe(500); - expect([200, 202, 400, 422]).toContain(res.statusCode); - - // Response must be valid JSON (not HTML, not raw text) - let body: Record; - try { - body = JSON.parse(res.body); - } catch { - throw new Error( - `${fmt.name} + ${tool.id}: response is not valid JSON: ${res.body.slice(0, 200)}`, - ); - } - - if (res.statusCode >= 400) { - expect(body.error).toBeDefined(); - expect(typeof body.error).toBe("string"); - // Error message should not contain raw stack trace indicators - const errorStr = body.error as string; - expect(errorStr).not.toContain("at Object."); - expect(errorStr).not.toContain("at Module."); - expect(errorStr).not.toContain("node_modules"); - } - }, - getTimeout(fmt), - ); - } - } -}); - -// ========================================================================= -// 19. HEIC/HEIF SPECIFIC: graceful handling when libheif unavailable -// ========================================================================= -describe("HEIC/HEIF graceful handling", () => { - const HEIF_FORMATS = PRIMARY_FORMATS.filter((f) => f.needsHeifDecoder); - - const CORE_TOOLS = [ - { id: "resize", settings: { width: 50, height: 50 } }, - { id: "crop", settings: { width: 10, height: 10, left: 0, top: 0 } }, - { id: "rotate", settings: { angle: 90 } }, - { id: "convert", settings: { format: "png" } }, - { id: "compress", settings: { mode: "quality", quality: 60 } }, - { id: "adjust-colors", settings: { brightness: 10 } }, - { id: "sharpening", settings: { method: "adaptive" } }, - { id: "strip-metadata", settings: { stripAll: true } }, - { id: "info", settings: {} }, - { id: "optimize-for-web", settings: { format: "webp", quality: 75 } }, - { id: "image-enhancement", settings: { mode: "auto", intensity: 50 } }, - { id: "border", settings: { borderWidth: 5, borderColor: "#FF0000" } }, - ]; - - for (const fmt of HEIF_FORMATS) { - describe(`${fmt.name}`, () => { - for (const tool of CORE_TOOLS) { - it( - `${tool.id}: no crash`, - async () => { - const res = await callTool(tool.id, fmt, tool.settings); - if (!res) return; - - // Must never crash - expect(res.statusCode).not.toBe(500); - - // Accept success (200) or clean error (400/422) - expect([200, 202, 400, 422]).toContain(res.statusCode); - - const body = JSON.parse(res.body); - if (res.statusCode >= 400) { - expect(body.error).toBeDefined(); - expect(typeof body.error).toBe("string"); - } - }, - tool.id === "image-enhancement" ? 300_000 : 180_000, - ); - } - }); - } -}); - -// ========================================================================= -// 20. ANIMATED GIF: verify tools handle it without crashing -// ========================================================================= -describe("Animated GIF handling", () => { - const gifFmt: FormatDef = { - name: "GIF", - file: "sample.gif", - mime: "image/gif", - needsCliDecoder: false, - needsHeifDecoder: false, - mayFailValidation: false, - }; - - const TOOLS_FOR_GIF = [ - { id: "resize", settings: { width: 32, height: 32 } }, - { id: "crop", settings: { width: 10, height: 10, left: 0, top: 0 } }, - { id: "rotate", settings: { angle: 180 } }, - { id: "convert", settings: { format: "png" } }, - { id: "compress", settings: { mode: "quality", quality: 50 } }, - { id: "info", settings: {} }, - { id: "border", settings: { borderWidth: 3, borderColor: "#00FF00" } }, - { id: "strip-metadata", settings: { stripAll: true } }, - { id: "optimize-for-web", settings: { format: "webp", quality: 60 } }, - { id: "image-enhancement", settings: { mode: "auto", intensity: 30 } }, - { id: "adjust-colors", settings: { brightness: 5 } }, - { id: "sharpening", settings: { method: "adaptive" } }, - ]; - - for (const tool of TOOLS_FOR_GIF) { - it(`${tool.id}: processes without crash`, async () => { - const res = await callTool(tool.id, gifFmt, tool.settings); - if (!res) return; - expect(res.statusCode).toBe(200); - - const body = JSON.parse(res.body); - if (tool.id === "info") { - expect(body.width).toBeGreaterThan(0); - expect(body.height).toBeGreaterThan(0); - } else { - expect(body.downloadUrl || body.processedSize).toBeDefined(); - } - }); - } -}); - -// ========================================================================= -// 21. SVG SPECIAL HANDLING: vector format through raster tools -// ========================================================================= -describe("SVG through raster tools", () => { - const svgFmt: FormatDef = { - name: "SVG", - file: "sample.svg", - mime: "image/svg+xml", - needsCliDecoder: false, - needsHeifDecoder: false, - mayFailValidation: false, - }; - - const SVG_TOOLS = [ - { id: "resize", settings: { width: 100, height: 100 } }, - { id: "crop", settings: { width: 50, height: 50, left: 0, top: 0 } }, - { id: "rotate", settings: { angle: 90 } }, - { id: "convert", settings: { format: "png" } }, - { id: "compress", settings: { mode: "quality", quality: 60 } }, - { id: "info", settings: {} }, - { id: "border", settings: { borderWidth: 5, borderColor: "#333333" } }, - { id: "adjust-colors", settings: { brightness: 10 } }, - { id: "sharpening", settings: { method: "adaptive" } }, - { id: "optimize-for-web", settings: { format: "webp", quality: 75 } }, - { id: "image-enhancement", settings: { mode: "auto", intensity: 50 } }, - { id: "strip-metadata", settings: { stripAll: true } }, - ]; - - for (const tool of SVG_TOOLS) { - it(`${tool.id}: handles SVG input`, async () => { - const res = await callTool(tool.id, svgFmt, tool.settings); - if (!res) return; - - // SVG should either be rasterized and processed (200) or cleanly rejected - expect(res.statusCode).not.toBe(500); - expect([200, 202, 400, 422]).toContain(res.statusCode); - - const body = JSON.parse(res.body); - if (res.statusCode === 200) { - if (tool.id === "info") { - expect(body.width).toBeGreaterThan(0); - expect(body.height).toBeGreaterThan(0); - } else { - expect(body.downloadUrl || body.processedSize).toBeDefined(); - } - } else { - expect(body.error).toBeDefined(); - expect(typeof body.error).toBe("string"); - } - }); - } -}); - -// ========================================================================= -// 22. ICO SPECIAL HANDLING: multi-size format -// ========================================================================= -describe("ICO (multi-size format) through tools", () => { - const icoFmt: FormatDef = { - name: "ICO", - file: "sample.ico", - mime: "image/x-icon", - needsCliDecoder: true, - needsHeifDecoder: false, - mayFailValidation: false, - }; - - const ICO_TOOLS = [ - { id: "resize", settings: { width: 32, height: 32 } }, - { id: "convert", settings: { format: "png" } }, - { id: "info", settings: {} }, - { id: "border", settings: { borderWidth: 2, borderColor: "#000000" } }, - { id: "image-enhancement", settings: { mode: "auto", intensity: 50 } }, - ]; - - for (const tool of ICO_TOOLS) { - it(`${tool.id}: handles ICO input (may need ImageMagick)`, async () => { - const res = await callTool(tool.id, icoFmt, tool.settings); - if (!res) return; - - // ICO requires CLI decoder; accept success or clean error - expect(res.statusCode).not.toBe(500); - expect([200, 202, 400, 422]).toContain(res.statusCode); - - const body = JSON.parse(res.body); - if (res.statusCode >= 400) { - expect(body.error).toBeDefined(); - expect(typeof body.error).toBe("string"); - } - }, 180_000); - } -}); diff --git a/tests/integration/generated/format-matrix-generated-1.test.ts b/tests/integration/generated/format-matrix-generated-1.test.ts new file mode 100644 index 00000000..2e97108c --- /dev/null +++ b/tests/integration/generated/format-matrix-generated-1.test.ts @@ -0,0 +1,3 @@ +import { registerToolFormatMatrix } from "./format-matrix-generated.shared.js"; + +registerToolFormatMatrix(1); diff --git a/tests/integration/generated/format-matrix-generated-2.test.ts b/tests/integration/generated/format-matrix-generated-2.test.ts new file mode 100644 index 00000000..900b4ba8 --- /dev/null +++ b/tests/integration/generated/format-matrix-generated-2.test.ts @@ -0,0 +1,3 @@ +import { registerToolFormatMatrix } from "./format-matrix-generated.shared.js"; + +registerToolFormatMatrix(2); diff --git a/tests/integration/generated/format-matrix-generated-3.test.ts b/tests/integration/generated/format-matrix-generated-3.test.ts new file mode 100644 index 00000000..225f133e --- /dev/null +++ b/tests/integration/generated/format-matrix-generated-3.test.ts @@ -0,0 +1,3 @@ +import { registerToolFormatMatrix } from "./format-matrix-generated.shared.js"; + +registerToolFormatMatrix(3); diff --git a/tests/integration/generated/format-matrix-generated.shared.ts b/tests/integration/generated/format-matrix-generated.shared.ts new file mode 100644 index 00000000..110db6c6 --- /dev/null +++ b/tests/integration/generated/format-matrix-generated.shared.ts @@ -0,0 +1,191 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { apiToolPath, TOOLS } from "@snapotter/shared"; +import sharp from "sharp"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { getRegisteredToolIds, getToolConfig } from "../../../apps/api/src/routes/tool-factory.js"; +import { fixtureDir } from "../../fixtures/index.js"; +import { + defaultSettingsFor, + TOOL_SETTINGS_OVERRIDES, +} from "../../helpers/tool-default-settings.js"; +import { cancelAcceptedJobAndWait } from "../settle-job.js"; +import { + buildTestApp, + createMultipartPayload, + loginAsAdmin, + type TestApp, +} from "../test-server.js"; + +/** + * Registry-generated tool x format matrix. + * + * Every registered tool is exercised against every input format fixture with + * its minimal valid settings. The invariant is the factory's error contract: + * success (200/202), clean rejection (400/413/415/422), or AI-not-installed + * (501). A 500 or an undecodable "successful" output is a bug. + * + * PR runs use the core web formats; FULL_MATRIX=1 (nightly) unlocks all + * fixtures in tests/fixtures/image/formats/. + * + * Split across format-matrix-generated-{1,2,3}.test.ts because vitest shards by + * file and runs a file's tests serially in one fork, so a single 800s spec set + * the floor for the whole Integration job. `toolsForPart` stripes TOOLS by + * modulo, which partitions them by construction: every tool lands in exactly + * one part, and the describe name is identical in each so the full test-name + * set is unchanged. + */ + +const CORE_FORMATS = [ + "sample.png", + "sample.jpg", + "sample.webp", + "sample.gif", + "sample.svg", + "sample.heic", +]; + +const fixtureFiles = process.env.FULL_MATRIX + ? readdirSync(fixtureDir.formats).filter((f) => !f.startsWith(".")) + : CORE_FORMATS; + +const ALLOWED_STATUSES = new Set([200, 202, 400, 413, 415, 422, 501]); + +/** + * Raster content types this libvips/Sharp build is guaranteed to decode. Used + * to decide whether a 200 image response should be pixel-verified. Anything + * else (PDF, JSON, ZIP, SVG, or a niche raster like BMP/PSD streamed back + * untouched) carries an honest content-type we don't attempt to decode. + */ +const SHARP_DECODABLE_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/webp", + "image/gif", + "image/tiff", + "image/avif", +]); + +export const MATRIX_PART_COUNT = 3; + +/** Tools belonging to one part. Striped, so the parts partition TOOLS exactly. */ +export function toolsForPart(part: number): typeof TOOLS { + return TOOLS.filter((_, index) => index % MATRIX_PART_COUNT === part - 1); +} + +/** + * Registers the matrix for one part. The registry-wide schema guards are + * global assertions rather than per-tool ones, so they run in part 1 only. + */ +export function registerToolFormatMatrix(part: number): void { + describe("tool x format matrix (generated)", () => { + let testApp: TestApp; + let adminToken: string; + + beforeAll(async () => { + testApp = await buildTestApp(); + adminToken = await loginAsAdmin(testApp.app); + }, 30_000); + + afterAll(async () => { + await testApp.cleanup(); + }, 10_000); + + if (part === 1) { + it("settings overrides only reference registered tools", () => { + const registered = new Set(getRegisteredToolIds()); + for (const toolId of Object.keys(TOOL_SETTINGS_OVERRIDES)) { + expect(registered.has(toolId), `override for unknown tool "${toolId}"`).toBe(true); + } + }); + + it("default settings are valid for every registered tool", () => { + const invalid: string[] = []; + for (const toolId of getRegisteredToolIds()) { + const config = getToolConfig(toolId); + if (!config) continue; + const result = config.settingsSchema.safeParse(defaultSettingsFor(toolId)); + if (!result.success) { + invalid.push( + `${toolId}: ${result.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`, + ); + } + } + expect( + invalid, + `tools needing TOOL_SETTINGS_OVERRIDES entries:\n${invalid.join("\n")}`, + ).toEqual([]); + }); + } + + for (const tool of toolsForPart(part)) { + const toolId = tool.id; + it(`${toolId} handles every input format cleanly`, async () => { + for (const fixture of fixtureFiles) { + const content = readFileSync(join(fixtureDir.formats, fixture)); + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: fixture, contentType: "application/octet-stream", content }, + { name: "settings", content: JSON.stringify(defaultSettingsFor(toolId)) }, + ]); + const res = await testApp.app.inject({ + method: "POST", + url: apiToolPath(toolId), + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + + // Custom-route tools can 404 on the standard path; covered elsewhere. + if (res.statusCode === 404) return; + + expect( + ALLOWED_STATUSES.has(res.statusCode), + `${toolId} x ${fixture}: status ${res.statusCode}: ${res.body.slice(0, 300)}`, + ).toBe(true); + + if (res.statusCode === 200) { + const resType = (res.headers["content-type"]?.toString() ?? "").split(";")[0]; + if (resType !== "application/json") { + // Tools like bulk-rename/favicon/split stream a ZIP directly. + if (resType === "application/zip") { + expect( + res.rawPayload.subarray(0, 2).toString("latin1"), + `${toolId} x ${fixture}: ZIP response is not a ZIP`, + ).toBe("PK"); + } + continue; + } + const payload = JSON.parse(res.body) as { downloadUrl?: string }; + if (!payload.downloadUrl) continue; + const dl = await testApp.app.inject({ + method: "GET", + url: payload.downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); + expect(dl.statusCode, `${toolId} x ${fixture}: download failed`).toBe(200); + const outType = (dl.headers["content-type"]?.toString() ?? "").split(";")[0]; + // Allowlist of raster types this libvips build is guaranteed to + // decode. An allowlist (vs the old denylist) is robust to tools that + // legitimately stream back niche formats untouched (e.g. edit-metadata + // writing tags in place on a BMP/PSD): those carry an honest + // content-type we simply don't pixel-verify, rather than being + // misread as a corrupt JPEG. + const sharpDecodable = SHARP_DECODABLE_TYPES.has(outType); + if (sharpDecodable) { + // The processed output must actually decode; a corrupt "success" is a bug. + const meta = await sharp(dl.rawPayload).metadata(); + expect(meta.width, `${toolId} x ${fixture}: output not decodable`).toBeGreaterThan(0); + } + } + + if (res.statusCode === 202 && (toolId === "ocr" || toolId === "ocr-pdf")) { + const payload = JSON.parse(res.body) as { jobId?: string }; + expect(payload.jobId).toBeDefined(); + await cancelAcceptedJobAndWait(payload.jobId as string, "ai"); + } + } + // The nightly avif converters run many slow encodes per test; a busy runner + // intermittently overran the old 240s cap, so allow the job's full budget. + }, 600_000); + } + }); +} diff --git a/tests/integration/generated/format-matrix-generated.test.ts b/tests/integration/generated/format-matrix-generated.test.ts deleted file mode 100644 index 2897935d..00000000 --- a/tests/integration/generated/format-matrix-generated.test.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { readdirSync, readFileSync } from "node:fs"; -import { join } from "node:path"; -import { apiToolPath, TOOLS } from "@snapotter/shared"; -import sharp from "sharp"; -import { afterAll, beforeAll, describe, expect, it } from "vitest"; -import { getRegisteredToolIds, getToolConfig } from "../../../apps/api/src/routes/tool-factory.js"; -import { fixtureDir } from "../../fixtures/index.js"; -import { - defaultSettingsFor, - TOOL_SETTINGS_OVERRIDES, -} from "../../helpers/tool-default-settings.js"; -import { cancelAcceptedJobAndWait } from "../settle-job.js"; -import { - buildTestApp, - createMultipartPayload, - loginAsAdmin, - type TestApp, -} from "../test-server.js"; - -/** - * Registry-generated tool x format matrix. - * - * Every registered tool is exercised against every input format fixture with - * its minimal valid settings. The invariant is the factory's error contract: - * success (200/202), clean rejection (400/413/415/422), or AI-not-installed - * (501). A 500 or an undecodable "successful" output is a bug. - * - * PR runs use the core web formats; FULL_MATRIX=1 (nightly) unlocks all - * fixtures in tests/fixtures/image/formats/. - */ - -const CORE_FORMATS = [ - "sample.png", - "sample.jpg", - "sample.webp", - "sample.gif", - "sample.svg", - "sample.heic", -]; - -const fixtureFiles = process.env.FULL_MATRIX - ? readdirSync(fixtureDir.formats).filter((f) => !f.startsWith(".")) - : CORE_FORMATS; - -const ALLOWED_STATUSES = new Set([200, 202, 400, 413, 415, 422, 501]); - -/** - * Raster content types this libvips/Sharp build is guaranteed to decode. Used - * to decide whether a 200 image response should be pixel-verified. Anything - * else (PDF, JSON, ZIP, SVG, or a niche raster like BMP/PSD streamed back - * untouched) carries an honest content-type we don't attempt to decode. - */ -const SHARP_DECODABLE_TYPES = new Set([ - "image/jpeg", - "image/png", - "image/webp", - "image/gif", - "image/tiff", - "image/avif", -]); - -describe("tool x format matrix (generated)", () => { - let testApp: TestApp; - let adminToken: string; - - beforeAll(async () => { - testApp = await buildTestApp(); - adminToken = await loginAsAdmin(testApp.app); - }, 30_000); - - afterAll(async () => { - await testApp.cleanup(); - }, 10_000); - - it("settings overrides only reference registered tools", () => { - const registered = new Set(getRegisteredToolIds()); - for (const toolId of Object.keys(TOOL_SETTINGS_OVERRIDES)) { - expect(registered.has(toolId), `override for unknown tool "${toolId}"`).toBe(true); - } - }); - - it("default settings are valid for every registered tool", () => { - const invalid: string[] = []; - for (const toolId of getRegisteredToolIds()) { - const config = getToolConfig(toolId); - if (!config) continue; - const result = config.settingsSchema.safeParse(defaultSettingsFor(toolId)); - if (!result.success) { - invalid.push( - `${toolId}: ${result.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`, - ); - } - } - expect( - invalid, - `tools needing TOOL_SETTINGS_OVERRIDES entries:\n${invalid.join("\n")}`, - ).toEqual([]); - }); - - for (const tool of TOOLS) { - const toolId = tool.id; - it(`${toolId} handles every input format cleanly`, async () => { - for (const fixture of fixtureFiles) { - const content = readFileSync(join(fixtureDir.formats, fixture)); - const { body, contentType } = createMultipartPayload([ - { name: "file", filename: fixture, contentType: "application/octet-stream", content }, - { name: "settings", content: JSON.stringify(defaultSettingsFor(toolId)) }, - ]); - const res = await testApp.app.inject({ - method: "POST", - url: apiToolPath(toolId), - headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, - body, - }); - - // Custom-route tools can 404 on the standard path; covered elsewhere. - if (res.statusCode === 404) return; - - expect( - ALLOWED_STATUSES.has(res.statusCode), - `${toolId} x ${fixture}: status ${res.statusCode}: ${res.body.slice(0, 300)}`, - ).toBe(true); - - if (res.statusCode === 200) { - const resType = (res.headers["content-type"]?.toString() ?? "").split(";")[0]; - if (resType !== "application/json") { - // Tools like bulk-rename/favicon/split stream a ZIP directly. - if (resType === "application/zip") { - expect( - res.rawPayload.subarray(0, 2).toString("latin1"), - `${toolId} x ${fixture}: ZIP response is not a ZIP`, - ).toBe("PK"); - } - continue; - } - const payload = JSON.parse(res.body) as { downloadUrl?: string }; - if (!payload.downloadUrl) continue; - const dl = await testApp.app.inject({ - method: "GET", - url: payload.downloadUrl, - headers: { authorization: `Bearer ${adminToken}` }, - }); - expect(dl.statusCode, `${toolId} x ${fixture}: download failed`).toBe(200); - const outType = (dl.headers["content-type"]?.toString() ?? "").split(";")[0]; - // Allowlist of raster types this libvips build is guaranteed to - // decode. An allowlist (vs the old denylist) is robust to tools that - // legitimately stream back niche formats untouched (e.g. edit-metadata - // writing tags in place on a BMP/PSD): those carry an honest - // content-type we simply don't pixel-verify, rather than being - // misread as a corrupt JPEG. - const sharpDecodable = SHARP_DECODABLE_TYPES.has(outType); - if (sharpDecodable) { - // The processed output must actually decode; a corrupt "success" is a bug. - const meta = await sharp(dl.rawPayload).metadata(); - expect(meta.width, `${toolId} x ${fixture}: output not decodable`).toBeGreaterThan(0); - } - } - - if (res.statusCode === 202 && (toolId === "ocr" || toolId === "ocr-pdf")) { - const payload = JSON.parse(res.body) as { jobId?: string }; - expect(payload.jobId).toBeDefined(); - await cancelAcceptedJobAndWait(payload.jobId as string, "ai"); - } - } - // The nightly avif converters run many slow encodes per test; a busy runner - // intermittently overran the old 240s cap, so allow the job's full budget. - }, 600_000); - } -}); diff --git a/tests/integration/generated/format-matrix.shared.ts b/tests/integration/generated/format-matrix.shared.ts new file mode 100644 index 00000000..f27dae97 --- /dev/null +++ b/tests/integration/generated/format-matrix.shared.ts @@ -0,0 +1,509 @@ +/** + * 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. + */ + +/* + * Shared preamble for format-matrix-1..4.test.ts. + * + * The suite used to live in a single format-matrix.test.ts. Vitest shards by + * file and runs one file's tests serially inside a single fork, so that one + * file set the wall-clock floor for the whole Integration job. Splitting the + * describe blocks across four part files lets them run in parallel; this module + * holds the fixtures, tool table, and helpers they all share. + */ + +import { afterAll, beforeAll, expect } from "vitest"; +import { + buildTestApp, + createMultipartPayload, + loginAsAdmin, + type TestApp, +} from "../test-server.js"; + +// --------------------------------------------------------------------------- +// Format sample definitions +// --------------------------------------------------------------------------- +export 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; +} + +export 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, + }, +]; + +/** Number of part files the cross-format matrix is spread across. */ +export const CROSS_MATRIX_PART_COUNT = 2; + +/** Striped so the parts partition FORMAT_SAMPLES exactly. */ +export function formatSamplesForPart(part: number): FormatSample[] { + return FORMAT_SAMPLES.filter((_, i) => i % CROSS_MATRIX_PART_COUNT === part - 1); +} + +// --------------------------------------------------------------------------- +// Tool definitions — settings each tool requires +// --------------------------------------------------------------------------- +export 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"; +} + +export 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 */ +export const ACCEPTABLE_FALLBACK_CODES = [200, 202, 400, 422]; + +export 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. + */ +export 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. + */ +export 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; +export let app: TestApp["app"]; +export let adminToken: string; + +/** Registers the per-file app lifecycle each part file shares. */ +export function setupMatrixApp(): void { + beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); + }, 30_000); + + afterAll(async () => { + await testApp.cleanup(); + }, 10_000); +} diff --git a/tests/integration/generated/format-matrix.test.ts b/tests/integration/generated/format-matrix.test.ts deleted file mode 100644 index bf1e9637..00000000 --- a/tests/integration/generated/format-matrix.test.ts +++ /dev/null @@ -1,1519 +0,0 @@ -/** - * 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 { fixtureDir, fixtures } from "../../fixtures/index.js"; -import { - buildTestApp, - createMultipartPayload, - loginAsAdmin, - type TestApp, -} from "../test-server.js"; - -// --------------------------------------------------------------------------- -// 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, 202, 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(fixtureDir.formats, 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 >= 400) { - 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 = fixtures.image.multipageTiff; - - 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, 202, 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 : 120_000; - it(`${fmt.name} -> ${outFmt}`, { timeout: testTimeout }, 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({ 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(fixtureDir.formats, 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, 202, 400, 422]).toContain(res.statusCode); - - // Response must always be valid JSON - const body = JSON.parse(res.body); - if (res.statusCode >= 400) { - 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(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/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(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/image-enhancement/analyze", - headers: { - authorization: `Bearer ${adminToken}`, - "content-type": contentType, - }, - body: payload, - }); - - expect(res.statusCode).not.toBe(500); - expect([200, 202, 400, 422]).toContain(res.statusCode); - - const body = JSON.parse(res.body); - if (res.statusCode >= 400) { - 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(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/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 = fixtures.image.formats("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(fixtureDir.formats, 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(fixtureDir.formats, 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(fixtureDir.formats, 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(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({ - 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(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({ - 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(fixtureDir.formats, fmt.file); - const pngPath = fixtures.image.formats("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 = fixtures.image.multipageTiff; - - 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, 202, 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(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({ 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, 202, 400, 422]).toContain(res.statusCode); - - const body = JSON.parse(res.body); - if (res.statusCode >= 400) { - 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 = fixtures.image.formats("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(fixtureDir.formats, 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, 202, 400, 422]).toContain(res.statusCode); - - const body = JSON.parse(res.body); - if (res.statusCode >= 400) { - 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(fixtureDir.formats, 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, 202, 400, 422]).toContain(res.statusCode); - - const body = JSON.parse(res.body); - if (res.statusCode >= 400) { - expect(body.error).toBeDefined(); - expect(typeof body.error).toBe("string"); - expect(body.error.length).toBeGreaterThan(0); - } - }); - } - }); -}); diff --git a/tests/unit/infra/shard-partition.test.ts b/tests/unit/infra/shard-partition.test.ts index 09cdf92d..e715f0bc 100644 --- a/tests/unit/infra/shard-partition.test.ts +++ b/tests/unit/infra/shard-partition.test.ts @@ -19,12 +19,14 @@ function integrationSpecs(): string[] { return out.sort(); } -const HEAVYWEIGHTS = [ - "tests/integration/generated/format-matrix-comprehensive.test.ts", - "tests/integration/generated/format-matrix.test.ts", - "tests/integration/generated/format-matrix-generated.test.ts", - "tests/integration/generated/format-matrix-exotic.test.ts", -]; +/** + * The four costliest specs, read from the table rather than hardcoded so this + * survives specs being split, renamed, or reweighted. + */ +const HEAVYWEIGHTS = Object.entries(FILE_COST_SECONDS) + .sort(([, a], [, b]) => b - a) + .slice(0, 4) + .map(([file]) => file); describe("partitionByCost", () => { // The whole point of the helper: a shard must never silently drop a spec. @@ -95,9 +97,10 @@ describe("partitionByCost", () => { }); describe("costOf", () => { - it("returns the measured cost for a known-heavy spec", () => { - const known = "tests/integration/generated/format-matrix-comprehensive.test.ts"; - expect(costOf(known)).toBe(FILE_COST_SECONDS[known]); + it("returns the measured cost for every listed spec", () => { + for (const [file, seconds] of Object.entries(FILE_COST_SECONDS)) { + expect(costOf(file)).toBe(seconds); + } }); it("falls back to a default for unmeasured specs", () => { @@ -105,7 +108,7 @@ describe("partitionByCost", () => { }); it("matches regardless of leading slash or absolute prefix", () => { - const known = "tests/integration/generated/format-matrix.test.ts"; + const known = HEAVYWEIGHTS[0]; expect(costOf(`/${known}`)).toBe(costOf(known)); expect(costOf(path.join(repoRoot, known))).toBe(costOf(known)); });