mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
A release-readiness QA pass over the whole product. The commits split into defects a user would hit and gates that were reporting green while measuring nothing. ## Fixes that change behaviour Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so request.ip came from a client-set header and a forged X-Forwarded-For got past the login limiter. The default is now a private-network trust list. A transient Postgres outage stranded in-flight jobs, leaving finished output on disk with no row pointing at it. A reconciler now resolves those rows and adopts the bytes rather than dropping the work. A Redis connection that moved to a new address wedged every read-blocked consumer, so completions stopped signalling while health still answered 200. Socket timeouts plus subscriber pings recover it. Installing more than one AI bundle left the shared venv multi-versioned and silently broke three tools. The installer now reconciles distributions to one version each. Converting an image to JXL at quality 1 through 4 returned a 500, because libjxl 0.7 rejects the distance those values compute. The quality is floored at what the encoder honours. A missing ffmpeg was also reported to the user as a corrupt upload; it now says the engine is unavailable. RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at 0.22.2, and the release scan was split so it can fail on an unfixed critical instead of hiding it behind ignore-unfixed. ## Gates that could not fail Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs build; coverage discarded its whole report on any failing test; the lint gate skipped root tests, scripts, and two workspaces; and several generated matrices counted a host missing ffmpeg as a passing tool. Each now measures what it claims. Full evidence and the outstanding release items are tracked locally and are not part of this branch.
2010 lines
60 KiB
TypeScript
2010 lines
60 KiB
TypeScript
/**
|
|
* Expanded cross-format matrix integration tests.
|
|
*
|
|
* Covers tools NOT tested in format-matrix.test.ts or format-matrix-ai.test.ts:
|
|
*
|
|
* Single-file tools:
|
|
* - color-blindness (createToolRoute factory, standard response)
|
|
* - beautify (custom route, download response)
|
|
* - edit-metadata (custom route + /inspect endpoint)
|
|
* - vectorize (custom route, raster -> SVG)
|
|
* - split (custom route, streams ZIP)
|
|
* - favicon (custom route, streams ZIP)
|
|
* - meme-generator (custom route, download response)
|
|
* - barcode-read (custom route, JSON result)
|
|
*
|
|
* Multi-file tools (tested with pairs of format fixtures):
|
|
* - compose (base + overlay)
|
|
* - compare (image A + image B)
|
|
* - collage (2+ images, template-based)
|
|
* - stitch (2+ images, horizontal/vertical join)
|
|
*
|
|
* SVG-only tool:
|
|
* - svg-to-raster (only accepts SVG input)
|
|
*
|
|
* AI tool with 501 guard:
|
|
* - transparency-fixer (returns 501 when AI sidecar is not installed)
|
|
*
|
|
* For exotic formats (PSD, EXR, HDR, TGA, DNG, ICO, JXL, etc.) the test
|
|
* accepts both success (200) and graceful errors (400, 422) since CLI
|
|
* decoders may not be installed. For HEIC/HEIF the test accepts 422 when
|
|
* libheif is unavailable. No tool should ever return 500 (server crash).
|
|
*/
|
|
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { fixtureDir, fixtures } from "../../fixtures/index.js";
|
|
import { featureUnavailableDisposition } from "../../helpers/generated-case-accounting.js";
|
|
import { settleAsyncFallback } from "../settle-job.js";
|
|
import {
|
|
buildTestApp,
|
|
createMultipartPayload,
|
|
loginAsAdmin,
|
|
type TestApp,
|
|
} from "../test-server.js";
|
|
|
|
const REQUIRE_AI_FEATURES = process.env.REQUIRE_AI_FEATURES === "1";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Format sample definitions (subset relevant to the expanded tools)
|
|
// ---------------------------------------------------------------------------
|
|
interface FormatSample {
|
|
name: string;
|
|
file: string;
|
|
mime: string;
|
|
needsCliDecoder: boolean;
|
|
needsHeifDecoder: boolean;
|
|
mayFailValidation: boolean;
|
|
}
|
|
|
|
const FORMAT_SAMPLES: FormatSample[] = [
|
|
{
|
|
name: "JPEG",
|
|
file: "sample.jpg",
|
|
mime: "image/jpeg",
|
|
needsCliDecoder: false,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "PNG",
|
|
file: "sample.png",
|
|
mime: "image/png",
|
|
needsCliDecoder: false,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "WebP",
|
|
file: "sample.webp",
|
|
mime: "image/webp",
|
|
needsCliDecoder: false,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "GIF",
|
|
file: "sample.gif",
|
|
mime: "image/gif",
|
|
needsCliDecoder: false,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "AVIF",
|
|
file: "sample.avif",
|
|
mime: "image/avif",
|
|
needsCliDecoder: false,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "TIFF",
|
|
file: "sample.tiff",
|
|
mime: "image/tiff",
|
|
needsCliDecoder: false,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "BMP",
|
|
file: "sample.bmp",
|
|
mime: "image/bmp",
|
|
needsCliDecoder: false,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: true,
|
|
},
|
|
{
|
|
name: "HEIC",
|
|
file: "sample.heic",
|
|
mime: "image/heic",
|
|
needsCliDecoder: false,
|
|
needsHeifDecoder: true,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "HEIF",
|
|
file: "sample.heif",
|
|
mime: "image/heif",
|
|
needsCliDecoder: false,
|
|
needsHeifDecoder: true,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "SVG",
|
|
file: "sample.svg",
|
|
mime: "image/svg+xml",
|
|
needsCliDecoder: false,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "ICO",
|
|
file: "sample.ico",
|
|
mime: "image/x-icon",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "PSD",
|
|
file: "sample.psd",
|
|
mime: "image/vnd.adobe.photoshop",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "EXR",
|
|
file: "sample.exr",
|
|
mime: "image/x-exr",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "HDR",
|
|
file: "sample.hdr",
|
|
mime: "image/vnd.radiance",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "TGA",
|
|
file: "sample.tga",
|
|
mime: "image/x-tga",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "DNG",
|
|
file: "sample.dng",
|
|
mime: "image/x-adobe-dng",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: false,
|
|
},
|
|
{
|
|
name: "JXL",
|
|
file: "sample.jxl",
|
|
mime: "image/jxl",
|
|
needsCliDecoder: true,
|
|
needsHeifDecoder: false,
|
|
mayFailValidation: true,
|
|
},
|
|
];
|
|
|
|
/** Core formats: natively readable by Sharp without CLI decoders */
|
|
const CORE_FORMATS = FORMAT_SAMPLES.filter(
|
|
(f) => !f.needsCliDecoder && !f.needsHeifDecoder && !f.mayFailValidation,
|
|
);
|
|
|
|
/** Exotic formats: need CLI decoders or HEIF support */
|
|
const EXOTIC_FORMATS = FORMAT_SAMPLES.filter(
|
|
(f) => f.needsCliDecoder || f.needsHeifDecoder || f.mayFailValidation,
|
|
);
|
|
|
|
/** Status codes we accept for formats that may lack decoder support */
|
|
const ACCEPTABLE_FALLBACK_CODES = [200, 202, 400, 422];
|
|
|
|
function needsFallback(fmt: FormatSample): boolean {
|
|
return fmt.needsCliDecoder || fmt.needsHeifDecoder || fmt.mayFailValidation;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared state
|
|
// ---------------------------------------------------------------------------
|
|
let testApp: TestApp;
|
|
let app: TestApp["app"];
|
|
let adminToken: string;
|
|
|
|
beforeAll(async () => {
|
|
testApp = await buildTestApp();
|
|
app = testApp.app;
|
|
adminToken = await loginAsAdmin(app);
|
|
}, 30_000);
|
|
|
|
afterAll(async () => {
|
|
await testApp.cleanup();
|
|
}, 10_000);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 1. Color-blindness simulation x all formats
|
|
//
|
|
// Uses createToolRoute factory, returns standard download response.
|
|
// ---------------------------------------------------------------------------
|
|
describe("Color-blindness simulation cross-format", () => {
|
|
for (const fmt of FORMAT_SAMPLES) {
|
|
const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined;
|
|
|
|
it(
|
|
`processes ${fmt.name} input`,
|
|
async () => {
|
|
const fixturePath = join(fixtureDir.formats, fmt.file);
|
|
if (!existsSync(fixturePath)) return;
|
|
|
|
const buffer = readFileSync(fixturePath);
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: buffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
simulationType: "deuteranomaly",
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/color-blindness",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
if (needsFallback(fmt)) {
|
|
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
|
|
} else {
|
|
expect(res.statusCode).toBe(200);
|
|
}
|
|
|
|
if (res.statusCode === 200) {
|
|
const body = JSON.parse(res.body);
|
|
expect(body.downloadUrl).toBeDefined();
|
|
expect(typeof body.downloadUrl).toBe("string");
|
|
expect(body.processedSize).toBeGreaterThan(0);
|
|
expect(body.originalSize).toBeGreaterThan(0);
|
|
} else if (res.statusCode >= 400) {
|
|
// 202 (accepted, async) has no sync body to verify; only error
|
|
// codes carry a JSON error.
|
|
const body = JSON.parse(res.body);
|
|
expect(body.error).toBeDefined();
|
|
expect(typeof body.error).toBe("string");
|
|
}
|
|
},
|
|
perTestTimeout,
|
|
);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 2. Beautify x all formats
|
|
//
|
|
// Custom route, accepts main image + optional background image.
|
|
// Returns standard {downloadUrl, processedSize} response.
|
|
// ---------------------------------------------------------------------------
|
|
describe("Beautify cross-format", () => {
|
|
for (const fmt of FORMAT_SAMPLES) {
|
|
const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined;
|
|
|
|
it(
|
|
`processes ${fmt.name} input`,
|
|
async () => {
|
|
const fixturePath = join(fixtureDir.formats, fmt.file);
|
|
if (!existsSync(fixturePath)) return;
|
|
|
|
const buffer = readFileSync(fixturePath);
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: buffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
padding: 20,
|
|
borderRadius: 8,
|
|
backgroundType: "solid",
|
|
backgroundColor: "#f0f0f0",
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/beautify",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
if (needsFallback(fmt)) {
|
|
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
|
|
} else {
|
|
expect(res.statusCode).toBe(200);
|
|
}
|
|
|
|
if (res.statusCode === 200) {
|
|
const body = JSON.parse(res.body);
|
|
expect(body.downloadUrl).toBeDefined();
|
|
expect(typeof body.downloadUrl).toBe("string");
|
|
expect(body.processedSize).toBeGreaterThan(0);
|
|
expect(body.originalSize).toBeGreaterThan(0);
|
|
} else if (res.statusCode >= 400) {
|
|
// 202 (accepted, async) has no sync body to verify; only error
|
|
// codes carry a JSON error.
|
|
const body = JSON.parse(res.body);
|
|
expect(body.error).toBeDefined();
|
|
expect(typeof body.error).toBe("string");
|
|
}
|
|
},
|
|
perTestTimeout,
|
|
);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 3. Edit-metadata x all formats
|
|
//
|
|
// Custom route. Also tests the /inspect endpoint.
|
|
// Edit-metadata writes EXIF tags in-place using ExifTool.
|
|
// ---------------------------------------------------------------------------
|
|
describe("Edit-metadata cross-format", () => {
|
|
describe("edit endpoint", () => {
|
|
for (const fmt of FORMAT_SAMPLES) {
|
|
const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined;
|
|
|
|
it(
|
|
`edits metadata in ${fmt.name} input`,
|
|
async () => {
|
|
const fixturePath = join(fixtureDir.formats, fmt.file);
|
|
if (!existsSync(fixturePath)) return;
|
|
|
|
const buffer = readFileSync(fixturePath);
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: buffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
title: "Test Title",
|
|
author: "Test Author",
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/edit-metadata",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
if (needsFallback(fmt)) {
|
|
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
|
|
} else {
|
|
// edit-metadata may also fail with 422 if ExifTool is not installed
|
|
expect([200, 202, 400, 422]).toContain(res.statusCode);
|
|
}
|
|
|
|
if (await settleAsyncFallback(res)) return;
|
|
if (res.statusCode === 200) {
|
|
const body = JSON.parse(res.body);
|
|
expect(body.downloadUrl).toBeDefined();
|
|
expect(typeof body.downloadUrl).toBe("string");
|
|
expect(body.processedSize).toBeGreaterThan(0);
|
|
} else {
|
|
const body = JSON.parse(res.body);
|
|
expect(body.error).toBeDefined();
|
|
expect(typeof body.error).toBe("string");
|
|
}
|
|
},
|
|
perTestTimeout,
|
|
);
|
|
}
|
|
});
|
|
|
|
describe("inspect endpoint", () => {
|
|
for (const fmt of FORMAT_SAMPLES) {
|
|
const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined;
|
|
|
|
it(
|
|
`inspects ${fmt.name} metadata`,
|
|
async () => {
|
|
const fixturePath = join(fixtureDir.formats, fmt.file);
|
|
if (!existsSync(fixturePath)) return;
|
|
|
|
const buffer = readFileSync(fixturePath);
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: buffer,
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/edit-metadata/inspect",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
// Inspect uses ExifTool, which may not be installed; accept clean errors
|
|
expect(res.statusCode).not.toBe(500);
|
|
expect([200, 202, 400, 422]).toContain(res.statusCode);
|
|
|
|
const body = JSON.parse(res.body);
|
|
if (await settleAsyncFallback(res)) return;
|
|
if (res.statusCode === 200) {
|
|
// inspect returns metadata fields; at minimum it is an object
|
|
expect(typeof body).toBe("object");
|
|
} else {
|
|
expect(body.error).toBeDefined();
|
|
expect(typeof body.error).toBe("string");
|
|
}
|
|
},
|
|
perTestTimeout,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 4. Vectorize x all formats
|
|
//
|
|
// Custom route: converts raster image to SVG via potrace/vtracer.
|
|
// Returns {downloadUrl, processedSize} with an SVG output.
|
|
// ---------------------------------------------------------------------------
|
|
describe("Vectorize cross-format", () => {
|
|
for (const fmt of FORMAT_SAMPLES) {
|
|
const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined;
|
|
|
|
it(
|
|
`vectorizes ${fmt.name} input`,
|
|
async () => {
|
|
const fixturePath = join(fixtureDir.formats, fmt.file);
|
|
if (!existsSync(fixturePath)) return;
|
|
|
|
const buffer = readFileSync(fixturePath);
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: buffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ colorMode: "bw", threshold: 128 }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/vectorize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
if (needsFallback(fmt)) {
|
|
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
|
|
} else {
|
|
expect(res.statusCode).toBe(200);
|
|
}
|
|
|
|
if (res.statusCode === 200) {
|
|
const body = JSON.parse(res.body);
|
|
expect(body.downloadUrl).toBeDefined();
|
|
expect(typeof body.downloadUrl).toBe("string");
|
|
expect(body.downloadUrl).toContain(".svg");
|
|
expect(body.processedSize).toBeGreaterThan(0);
|
|
} else {
|
|
const body = JSON.parse(res.body);
|
|
expect(body.error).toBeDefined();
|
|
expect(typeof body.error).toBe("string");
|
|
}
|
|
},
|
|
perTestTimeout,
|
|
);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 5. Split x core formats
|
|
//
|
|
// Custom route that streams a ZIP response (reply.hijack).
|
|
// We verify the HTTP status is 200 and content-type is application/zip.
|
|
// Only tested against core formats since the ZIP streaming response
|
|
// prevents JSON error parsing on failure.
|
|
// ---------------------------------------------------------------------------
|
|
describe("Split cross-format", () => {
|
|
for (const fmt of CORE_FORMATS) {
|
|
it(`splits ${fmt.name} into grid tiles`, async () => {
|
|
const fixturePath = join(fixtureDir.formats, fmt.file);
|
|
if (!existsSync(fixturePath)) return;
|
|
|
|
const buffer = readFileSync(fixturePath);
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: buffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ columns: 2, rows: 2 }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/split",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
// Split streams a ZIP, so 200 means the archive was successfully created
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers["content-type"]).toContain("application/zip");
|
|
});
|
|
}
|
|
|
|
// Exotic formats: should not crash
|
|
for (const fmt of EXOTIC_FORMATS) {
|
|
const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined;
|
|
|
|
it(
|
|
`${fmt.name}: no server crash`,
|
|
async () => {
|
|
const fixturePath = join(fixtureDir.formats, fmt.file);
|
|
if (!existsSync(fixturePath)) return;
|
|
|
|
const buffer = readFileSync(fixturePath);
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: buffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ columns: 2, rows: 2 }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/split",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
// Must not crash. Accept 200 (ZIP streamed) or 422 (clean error)
|
|
expect(res.statusCode).not.toBe(500);
|
|
expect([200, 202, 400, 422]).toContain(res.statusCode);
|
|
},
|
|
perTestTimeout,
|
|
);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 6. Favicon x core formats
|
|
//
|
|
// Custom route that streams a ZIP response with favicon variants.
|
|
// Only tested against core formats since it hijacks the response.
|
|
// ---------------------------------------------------------------------------
|
|
describe("Favicon cross-format", () => {
|
|
for (const fmt of CORE_FORMATS) {
|
|
it(`generates favicons from ${fmt.name} input`, async () => {
|
|
const fixturePath = join(fixtureDir.formats, fmt.file);
|
|
if (!existsSync(fixturePath)) return;
|
|
|
|
const buffer = readFileSync(fixturePath);
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: buffer,
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/favicon",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
// Favicon streams a ZIP
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.headers["content-type"]).toContain("application/zip");
|
|
});
|
|
}
|
|
|
|
// NOTE: Exotic format error resilience is not tested for favicon because
|
|
// the route uses reply.hijack() to stream ZIP output. When Sharp fails
|
|
// mid-stream on an exotic format, the archive never finalizes and
|
|
// app.inject() hangs indefinitely. The error path is still safe (the
|
|
// route's try/catch returns 422 before hijack if decoding fails early),
|
|
// but mid-stream failures are inherently untestable via inject().
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 7. Meme-generator x core formats
|
|
//
|
|
// Custom route, returns {downloadUrl, processedSize}.
|
|
// Uses text overlays rendered via SVG compositing.
|
|
// ---------------------------------------------------------------------------
|
|
describe("Meme-generator cross-format", () => {
|
|
for (const fmt of FORMAT_SAMPLES) {
|
|
const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined;
|
|
|
|
it(
|
|
`generates meme from ${fmt.name} input`,
|
|
async () => {
|
|
const fixturePath = join(fixtureDir.formats, fmt.file);
|
|
if (!existsSync(fixturePath)) return;
|
|
|
|
const buffer = readFileSync(fixturePath);
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: buffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
textLayout: "top-bottom",
|
|
textBoxes: [
|
|
{ id: "top", text: "TOP TEXT" },
|
|
{ id: "bottom", text: "BOTTOM TEXT" },
|
|
],
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/meme-generator",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
if (needsFallback(fmt)) {
|
|
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
|
|
} else {
|
|
expect(res.statusCode).toBe(200);
|
|
}
|
|
|
|
if (res.statusCode === 200) {
|
|
const body = JSON.parse(res.body);
|
|
expect(body.downloadUrl).toBeDefined();
|
|
expect(typeof body.downloadUrl).toBe("string");
|
|
expect(body.processedSize).toBeGreaterThan(0);
|
|
} else {
|
|
const body = JSON.parse(res.body);
|
|
expect(body.error).toBeDefined();
|
|
expect(typeof body.error).toBe("string");
|
|
}
|
|
},
|
|
perTestTimeout,
|
|
);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 8. Barcode-read x core formats
|
|
//
|
|
// Custom route. Returns JSON with barcode scan results and optional overlay.
|
|
// Images without barcodes should still return 200 with empty results.
|
|
// ---------------------------------------------------------------------------
|
|
describe("Barcode-read cross-format", () => {
|
|
for (const fmt of FORMAT_SAMPLES) {
|
|
const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined;
|
|
|
|
it(
|
|
`scans ${fmt.name} input for barcodes`,
|
|
async () => {
|
|
const fixturePath = join(fixtureDir.formats, fmt.file);
|
|
if (!existsSync(fixturePath)) return;
|
|
|
|
const buffer = readFileSync(fixturePath);
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: buffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ tryHarder: true }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/barcode-read",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
if (needsFallback(fmt)) {
|
|
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
|
|
} else {
|
|
expect(res.statusCode).toBe(200);
|
|
}
|
|
|
|
if (res.statusCode === 200) {
|
|
const body = JSON.parse(res.body);
|
|
// barcode-read returns { barcodes: [...], overlayUrl?, width, height }
|
|
expect(typeof body).toBe("object");
|
|
// The fixture images likely have no barcodes, so barcodes array
|
|
// should be empty -- but the response must be well-formed
|
|
if (body.barcodes !== undefined) {
|
|
expect(Array.isArray(body.barcodes)).toBe(true);
|
|
}
|
|
} else {
|
|
const body = JSON.parse(res.body);
|
|
expect(body.error).toBeDefined();
|
|
expect(typeof body.error).toBe("string");
|
|
}
|
|
},
|
|
perTestTimeout,
|
|
);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 9. SVG-to-raster (SVG-only tool)
|
|
//
|
|
// Only accepts SVG input. Non-SVG formats should be rejected with 400.
|
|
// ---------------------------------------------------------------------------
|
|
describe("SVG-to-raster", () => {
|
|
it("converts SVG to PNG", async () => {
|
|
const fixturePath = fixtures.image.formats("svg");
|
|
if (!existsSync(fixturePath)) return;
|
|
|
|
const buffer = readFileSync(fixturePath);
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: "sample.svg",
|
|
contentType: "image/svg+xml",
|
|
content: buffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
outputFormat: "png",
|
|
width: 200,
|
|
dpi: 150,
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/svg-to-raster",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
const body = JSON.parse(res.body);
|
|
expect(body.downloadUrl).toBeDefined();
|
|
expect(typeof body.downloadUrl).toBe("string");
|
|
expect(body.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
// Test multiple output formats from SVG
|
|
const SVG_OUTPUT_FORMATS = ["png", "jpg", "webp", "avif"] as const;
|
|
|
|
for (const outFmt of SVG_OUTPUT_FORMATS) {
|
|
const testTimeout = outFmt === "avif" ? 120_000 : 120_000;
|
|
it(`converts SVG to ${outFmt}`, { timeout: testTimeout }, async () => {
|
|
const fixturePath = fixtures.image.formats("svg");
|
|
if (!existsSync(fixturePath)) return;
|
|
|
|
const buffer = readFileSync(fixturePath);
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: "sample.svg",
|
|
contentType: "image/svg+xml",
|
|
content: buffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ outputFormat: outFmt }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/svg-to-raster",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
const body = JSON.parse(res.body);
|
|
expect(body.downloadUrl).toBeDefined();
|
|
expect(body.processedSize).toBeGreaterThan(0);
|
|
});
|
|
}
|
|
|
|
// Non-SVG formats should be rejected
|
|
const NON_SVG_FORMATS = FORMAT_SAMPLES.filter(
|
|
(f) => f.name !== "SVG" && !f.needsCliDecoder && !f.needsHeifDecoder,
|
|
);
|
|
|
|
for (const fmt of NON_SVG_FORMATS) {
|
|
it(`rejects ${fmt.name} input with 400`, async () => {
|
|
const fixturePath = join(fixtureDir.formats, fmt.file);
|
|
if (!existsSync(fixturePath)) return;
|
|
|
|
const buffer = readFileSync(fixturePath);
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: buffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ outputFormat: "png" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/svg-to-raster",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
|
|
const body = JSON.parse(res.body);
|
|
expect(body.error).toBeDefined();
|
|
expect(typeof body.error).toBe("string");
|
|
});
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 10. Compose (multi-file) x core formats
|
|
//
|
|
// Requires two files: base image + overlay image. Tests each core format
|
|
// as the base image with a fixed PNG overlay, and vice versa.
|
|
// ---------------------------------------------------------------------------
|
|
describe("Compose cross-format", () => {
|
|
const PNG_PATH = fixtures.image.formats("png");
|
|
|
|
describe("format as base image (overlay is PNG)", () => {
|
|
for (const fmt of CORE_FORMATS) {
|
|
it(`${fmt.name} base + PNG overlay`, async () => {
|
|
const fixturePath = join(fixtureDir.formats, fmt.file);
|
|
if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return;
|
|
|
|
const baseBuffer = readFileSync(fixturePath);
|
|
const overlayBuffer = readFileSync(PNG_PATH);
|
|
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: baseBuffer,
|
|
},
|
|
{
|
|
name: "overlay",
|
|
filename: "sample.png",
|
|
contentType: "image/png",
|
|
content: overlayBuffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
x: 0,
|
|
y: 0,
|
|
opacity: 50,
|
|
blendMode: "over",
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/compose",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
const body = JSON.parse(res.body);
|
|
expect(body.downloadUrl).toBeDefined();
|
|
expect(body.processedSize).toBeGreaterThan(0);
|
|
});
|
|
}
|
|
});
|
|
|
|
describe("format as overlay image (base is PNG)", () => {
|
|
for (const fmt of CORE_FORMATS) {
|
|
// Skip PNG-on-PNG (redundant with the reverse test)
|
|
if (fmt.name === "PNG") continue;
|
|
|
|
it(`PNG base + ${fmt.name} overlay`, async () => {
|
|
const fixturePath = join(fixtureDir.formats, fmt.file);
|
|
if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return;
|
|
|
|
const baseBuffer = readFileSync(PNG_PATH);
|
|
const overlayBuffer = readFileSync(fixturePath);
|
|
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: "sample.png",
|
|
contentType: "image/png",
|
|
content: baseBuffer,
|
|
},
|
|
{
|
|
name: "overlay",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: overlayBuffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
x: 0,
|
|
y: 0,
|
|
opacity: 75,
|
|
blendMode: "multiply",
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/compose",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
// Overlay compositing with opacity < 100 can fail for some formats
|
|
// (e.g., WebP, AVIF) due to alpha channel handling in Sharp.
|
|
// Accept 200 (success) or 422 (processing error) for core formats.
|
|
expect([200, 422]).toContain(res.statusCode);
|
|
|
|
if (res.statusCode === 200) {
|
|
const body = JSON.parse(res.body);
|
|
expect(body.downloadUrl).toBeDefined();
|
|
expect(body.processedSize).toBeGreaterThan(0);
|
|
} else {
|
|
const body = JSON.parse(res.body);
|
|
expect(body.error).toBeDefined();
|
|
expect(typeof body.error).toBe("string");
|
|
}
|
|
});
|
|
}
|
|
});
|
|
|
|
// Exotic format error resilience
|
|
describe("exotic format as base (no crash)", () => {
|
|
for (const fmt of EXOTIC_FORMATS) {
|
|
const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined;
|
|
|
|
it(
|
|
`${fmt.name} base + PNG overlay: no crash`,
|
|
async () => {
|
|
const fixturePath = join(fixtureDir.formats, fmt.file);
|
|
if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return;
|
|
|
|
const baseBuffer = readFileSync(fixturePath);
|
|
const overlayBuffer = readFileSync(PNG_PATH);
|
|
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: baseBuffer,
|
|
},
|
|
{
|
|
name: "overlay",
|
|
filename: "sample.png",
|
|
contentType: "image/png",
|
|
content: overlayBuffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ x: 0, y: 0, opacity: 50 }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/compose",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
expect(res.statusCode).not.toBe(500);
|
|
expect([200, 202, 400, 422]).toContain(res.statusCode);
|
|
},
|
|
perTestTimeout,
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 11. Compare (multi-file) x core formats
|
|
//
|
|
// Requires two files: image A + image B.
|
|
// Tests comparing each core format against PNG.
|
|
// ---------------------------------------------------------------------------
|
|
describe("Compare cross-format", () => {
|
|
const PNG_PATH = fixtures.image.formats("png");
|
|
|
|
for (const fmt of CORE_FORMATS) {
|
|
it(`compares ${fmt.name} with PNG`, async () => {
|
|
const fixturePath = join(fixtureDir.formats, fmt.file);
|
|
if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return;
|
|
|
|
const bufferA = readFileSync(fixturePath);
|
|
const bufferB = readFileSync(PNG_PATH);
|
|
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: bufferA,
|
|
},
|
|
{
|
|
name: "file",
|
|
filename: "sample.png",
|
|
contentType: "image/png",
|
|
content: bufferB,
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/compare",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
const body = JSON.parse(res.body);
|
|
// Compare returns similarity metrics and diff image
|
|
expect(typeof body).toBe("object");
|
|
if (body.similarity !== undefined) {
|
|
expect(typeof body.similarity).toBe("number");
|
|
}
|
|
});
|
|
}
|
|
|
|
// Exotic format error resilience
|
|
for (const fmt of EXOTIC_FORMATS) {
|
|
const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined;
|
|
|
|
it(
|
|
`${fmt.name} vs PNG: no crash`,
|
|
async () => {
|
|
const fixturePath = join(fixtureDir.formats, fmt.file);
|
|
if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return;
|
|
|
|
const bufferA = readFileSync(fixturePath);
|
|
const bufferB = readFileSync(PNG_PATH);
|
|
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: bufferA,
|
|
},
|
|
{
|
|
name: "file",
|
|
filename: "sample.png",
|
|
contentType: "image/png",
|
|
content: bufferB,
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/compare",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
expect(res.statusCode).not.toBe(500);
|
|
expect([200, 202, 400, 422]).toContain(res.statusCode);
|
|
},
|
|
perTestTimeout,
|
|
);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 12. Collage (multi-file) x core formats
|
|
//
|
|
// Requires 2+ images and a template. Tests pairing each core format
|
|
// with a PNG fixture in a 2-image collage.
|
|
// ---------------------------------------------------------------------------
|
|
describe("Collage cross-format", () => {
|
|
const PNG_PATH = fixtures.image.formats("png");
|
|
|
|
for (const fmt of CORE_FORMATS) {
|
|
// Skip PNG + PNG (trivial)
|
|
if (fmt.name === "PNG") continue;
|
|
|
|
it(`${fmt.name} + PNG in 2-image collage`, async () => {
|
|
const fixturePath = join(fixtureDir.formats, fmt.file);
|
|
if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return;
|
|
|
|
const fmtBuffer = readFileSync(fixturePath);
|
|
const pngBuffer = readFileSync(PNG_PATH);
|
|
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: fmtBuffer,
|
|
},
|
|
{
|
|
name: "file",
|
|
filename: "sample.png",
|
|
contentType: "image/png",
|
|
content: pngBuffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
templateId: "2-h-equal",
|
|
gap: 4,
|
|
outputFormat: "png",
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
const body = JSON.parse(res.body);
|
|
expect(body.downloadUrl).toBeDefined();
|
|
expect(body.processedSize).toBeGreaterThan(0);
|
|
});
|
|
}
|
|
|
|
// Exotic format resilience
|
|
for (const fmt of EXOTIC_FORMATS) {
|
|
const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined;
|
|
|
|
it(
|
|
`${fmt.name} + PNG collage: no crash`,
|
|
async () => {
|
|
const fixturePath = join(fixtureDir.formats, fmt.file);
|
|
if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return;
|
|
|
|
const fmtBuffer = readFileSync(fixturePath);
|
|
const pngBuffer = readFileSync(PNG_PATH);
|
|
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: fmtBuffer,
|
|
},
|
|
{
|
|
name: "file",
|
|
filename: "sample.png",
|
|
contentType: "image/png",
|
|
content: pngBuffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
templateId: "2-h-equal",
|
|
gap: 4,
|
|
outputFormat: "png",
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
expect(res.statusCode).not.toBe(500);
|
|
expect([200, 202, 400, 422]).toContain(res.statusCode);
|
|
},
|
|
perTestTimeout,
|
|
);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 13. Stitch (multi-file) x core formats
|
|
//
|
|
// Requires 2+ images. Tests stitching each core format with a PNG fixture.
|
|
// ---------------------------------------------------------------------------
|
|
describe("Stitch cross-format", () => {
|
|
const PNG_PATH = fixtures.image.formats("png");
|
|
|
|
for (const fmt of CORE_FORMATS) {
|
|
// Skip PNG + PNG (trivial)
|
|
if (fmt.name === "PNG") continue;
|
|
|
|
it(`stitches ${fmt.name} + PNG horizontally`, async () => {
|
|
const fixturePath = join(fixtureDir.formats, fmt.file);
|
|
if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return;
|
|
|
|
const fmtBuffer = readFileSync(fixturePath);
|
|
const pngBuffer = readFileSync(PNG_PATH);
|
|
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: fmtBuffer,
|
|
},
|
|
{
|
|
name: "file",
|
|
filename: "sample.png",
|
|
contentType: "image/png",
|
|
content: pngBuffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
direction: "horizontal",
|
|
resizeMode: "fit",
|
|
format: "png",
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/stitch",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
const body = JSON.parse(res.body);
|
|
expect(body.downloadUrl).toBeDefined();
|
|
expect(body.processedSize).toBeGreaterThan(0);
|
|
});
|
|
}
|
|
|
|
// Exotic format resilience
|
|
for (const fmt of EXOTIC_FORMATS) {
|
|
const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined;
|
|
|
|
it(
|
|
`${fmt.name} + PNG stitch: no crash`,
|
|
async () => {
|
|
const fixturePath = join(fixtureDir.formats, fmt.file);
|
|
if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return;
|
|
|
|
const fmtBuffer = readFileSync(fixturePath);
|
|
const pngBuffer = readFileSync(PNG_PATH);
|
|
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: fmtBuffer,
|
|
},
|
|
{
|
|
name: "file",
|
|
filename: "sample.png",
|
|
contentType: "image/png",
|
|
content: pngBuffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
direction: "horizontal",
|
|
resizeMode: "fit",
|
|
format: "png",
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/stitch",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
expect(res.statusCode).not.toBe(500);
|
|
expect([200, 202, 400, 422]).toContain(res.statusCode);
|
|
},
|
|
perTestTimeout,
|
|
);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 14. Transparency-fixer (AI tool with 501 guard) x formats
|
|
//
|
|
// Returns 501 when AI sidecar is not installed.
|
|
// Tests that the validation layer works correctly for all formats.
|
|
// ---------------------------------------------------------------------------
|
|
describe("Transparency-fixer cross-format", () => {
|
|
for (const fmt of FORMAT_SAMPLES) {
|
|
const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined;
|
|
|
|
it(
|
|
`${fmt.name}: accepts input when its AI prerequisite is installed`,
|
|
async (context) => {
|
|
const fixturePath = join(fixtureDir.formats, fmt.file);
|
|
if (!existsSync(fixturePath)) {
|
|
return context.skip(`transparency-fixer: missing fixture ${fmt.file}`);
|
|
}
|
|
|
|
const buffer = readFileSync(fixturePath);
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: buffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ defringe: 30, outputFormat: "png" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/transparency-fixer",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
// Must never crash
|
|
expect(res.statusCode).not.toBe(500);
|
|
|
|
const body = JSON.parse(res.body) as Record<string, unknown>;
|
|
const disposition = featureUnavailableDisposition({
|
|
toolId: "transparency-fixer",
|
|
statusCode: res.statusCode,
|
|
code: body.code,
|
|
requireAiFeatures: REQUIRE_AI_FEATURES,
|
|
});
|
|
if (disposition === "skip") {
|
|
return context.skip(
|
|
"transparency-fixer: optional AI prerequisite absent; set REQUIRE_AI_FEATURES=1 after install",
|
|
);
|
|
}
|
|
|
|
expect([200, 202, 400, 422]).toContain(res.statusCode);
|
|
|
|
if (res.statusCode === 202) {
|
|
expect(body.jobId).toBeDefined();
|
|
expect(body.async).toBe(true);
|
|
} else if (res.statusCode >= 400) {
|
|
expect(body.error).toBeDefined();
|
|
expect(typeof body.error).toBe("string");
|
|
}
|
|
},
|
|
perTestTimeout,
|
|
);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 15. Color-blindness simulation types x JPEG
|
|
//
|
|
// Verifies each simulation type works correctly with a single core format.
|
|
// ---------------------------------------------------------------------------
|
|
describe("Color-blindness simulation types", () => {
|
|
const SIMULATION_TYPES = [
|
|
"protanopia",
|
|
"deuteranopia",
|
|
"tritanopia",
|
|
"protanomaly",
|
|
"deuteranomaly",
|
|
"tritanomaly",
|
|
"achromatopsia",
|
|
"blueConeMonochromacy",
|
|
] as const;
|
|
|
|
for (const simType of SIMULATION_TYPES) {
|
|
it(`simulation: ${simType}`, async () => {
|
|
const fixturePath = fixtures.image.formats("jpg");
|
|
if (!existsSync(fixturePath)) return;
|
|
|
|
const buffer = readFileSync(fixturePath);
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: "sample.jpg",
|
|
contentType: "image/jpeg",
|
|
content: buffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ simulationType: simType }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/color-blindness",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
const body = JSON.parse(res.body);
|
|
expect(body.downloadUrl).toBeDefined();
|
|
expect(body.processedSize).toBeGreaterThan(0);
|
|
});
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 16. Vectorize color mode variants x JPEG
|
|
//
|
|
// Verifies both BW and color vectorization modes.
|
|
// ---------------------------------------------------------------------------
|
|
describe("Vectorize color modes", () => {
|
|
it("BW mode", async () => {
|
|
const fixturePath = fixtures.image.formats("jpg");
|
|
if (!existsSync(fixturePath)) return;
|
|
|
|
const buffer = readFileSync(fixturePath);
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: "sample.jpg",
|
|
contentType: "image/jpeg",
|
|
content: buffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
colorMode: "bw",
|
|
threshold: 128,
|
|
pathMode: "spline",
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/vectorize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
const body = JSON.parse(res.body);
|
|
expect(body.downloadUrl).toContain(".svg");
|
|
expect(body.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("color mode", async () => {
|
|
const fixturePath = fixtures.image.formats("jpg");
|
|
if (!existsSync(fixturePath)) return;
|
|
|
|
const buffer = readFileSync(fixturePath);
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: "sample.jpg",
|
|
contentType: "image/jpeg",
|
|
content: buffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
colorMode: "color",
|
|
colorPrecision: 4,
|
|
layerDifference: 8,
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/vectorize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
const body = JSON.parse(res.body);
|
|
expect(body.downloadUrl).toContain(".svg");
|
|
expect(body.processedSize).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 17. Compose blend modes x JPEG
|
|
//
|
|
// Verifies each blend mode works correctly.
|
|
// ---------------------------------------------------------------------------
|
|
describe("Compose blend modes", () => {
|
|
const BLEND_MODES = ["over", "multiply", "screen", "overlay", "darken", "lighten"] as const;
|
|
|
|
const PNG_PATH = fixtures.image.formats("png");
|
|
|
|
for (const blendMode of BLEND_MODES) {
|
|
it(`blend mode: ${blendMode}`, async () => {
|
|
const jpgPath = fixtures.image.formats("jpg");
|
|
if (!existsSync(jpgPath) || !existsSync(PNG_PATH)) return;
|
|
|
|
const baseBuffer = readFileSync(jpgPath);
|
|
const overlayBuffer = readFileSync(PNG_PATH);
|
|
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: "sample.jpg",
|
|
contentType: "image/jpeg",
|
|
content: baseBuffer,
|
|
},
|
|
{
|
|
name: "overlay",
|
|
filename: "sample.png",
|
|
contentType: "image/png",
|
|
content: overlayBuffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
x: 0,
|
|
y: 0,
|
|
opacity: 50,
|
|
blendMode,
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/compose",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
const body = JSON.parse(res.body);
|
|
expect(body.downloadUrl).toBeDefined();
|
|
expect(body.processedSize).toBeGreaterThan(0);
|
|
});
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 18. Stitch direction modes x JPEG
|
|
//
|
|
// Verifies horizontal, vertical, and grid stitching.
|
|
// ---------------------------------------------------------------------------
|
|
describe("Stitch direction modes", () => {
|
|
const DIRECTIONS = [
|
|
{ direction: "horizontal", gridColumns: 2 },
|
|
{ direction: "vertical", gridColumns: 2 },
|
|
{ direction: "grid", gridColumns: 2 },
|
|
] as const;
|
|
|
|
const JPG_PATH = fixtures.image.formats("jpg");
|
|
const PNG_PATH = fixtures.image.formats("png");
|
|
|
|
for (const { direction, gridColumns } of DIRECTIONS) {
|
|
it(`direction: ${direction}`, async () => {
|
|
if (!existsSync(JPG_PATH) || !existsSync(PNG_PATH)) return;
|
|
|
|
const bufA = readFileSync(JPG_PATH);
|
|
const bufB = readFileSync(PNG_PATH);
|
|
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: "sample.jpg",
|
|
contentType: "image/jpeg",
|
|
content: bufA,
|
|
},
|
|
{
|
|
name: "file",
|
|
filename: "sample.png",
|
|
contentType: "image/png",
|
|
content: bufB,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
direction,
|
|
gridColumns,
|
|
resizeMode: "fit",
|
|
format: "png",
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/stitch",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
const body = JSON.parse(res.body);
|
|
expect(body.downloadUrl).toBeDefined();
|
|
expect(body.processedSize).toBeGreaterThan(0);
|
|
});
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 15b. Find-duplicates (multi-file) x core formats
|
|
//
|
|
// Requires 2+ images. Tests each core format paired with a PNG fixture.
|
|
// find-duplicates computes perceptual hashes and returns duplicate groups.
|
|
// ---------------------------------------------------------------------------
|
|
describe("Find-duplicates cross-format", () => {
|
|
const PNG_PATH = fixtures.image.formats("png");
|
|
|
|
for (const fmt of CORE_FORMATS) {
|
|
it(`detects ${fmt.name} + PNG pair`, async () => {
|
|
const fixturePath = join(fixtureDir.formats, fmt.file);
|
|
if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return;
|
|
|
|
const fmtBuffer = readFileSync(fixturePath);
|
|
const pngBuffer = readFileSync(PNG_PATH);
|
|
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: fmtBuffer,
|
|
},
|
|
{
|
|
name: "file",
|
|
filename: "sample.png",
|
|
contentType: "image/png",
|
|
content: pngBuffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ threshold: 8 }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/find-duplicates",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
const body = JSON.parse(res.body);
|
|
// find-duplicates returns { totalImages, duplicateGroups, uniqueCount }
|
|
expect(typeof body).toBe("object");
|
|
expect(body.totalImages).toBe(2);
|
|
expect(Array.isArray(body.duplicateGroups)).toBe(true);
|
|
});
|
|
}
|
|
|
|
// Exotic format resilience
|
|
for (const fmt of EXOTIC_FORMATS) {
|
|
const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined;
|
|
|
|
it(
|
|
`${fmt.name} + PNG find-duplicates: no crash`,
|
|
async () => {
|
|
const fixturePath = join(fixtureDir.formats, fmt.file);
|
|
if (!existsSync(fixturePath) || !existsSync(PNG_PATH)) return;
|
|
|
|
const fmtBuffer = readFileSync(fixturePath);
|
|
const pngBuffer = readFileSync(PNG_PATH);
|
|
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: fmt.file,
|
|
contentType: fmt.mime,
|
|
content: fmtBuffer,
|
|
},
|
|
{
|
|
name: "file",
|
|
filename: "sample.png",
|
|
contentType: "image/png",
|
|
content: pngBuffer,
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ threshold: 8 }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/find-duplicates",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
expect(res.statusCode).not.toBe(500);
|
|
expect([200, 202, 400, 422]).toContain(res.statusCode);
|
|
},
|
|
perTestTimeout,
|
|
);
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 19. Error resilience: missing file for expanded tools
|
|
//
|
|
// Verifies that each expanded tool returns a clean 400 when no file is sent.
|
|
// ---------------------------------------------------------------------------
|
|
describe("Missing file returns 400 for expanded tools", () => {
|
|
const TOOL_ENDPOINTS = [
|
|
{ url: "/api/v1/tools/image/color-blindness", settings: { simulationType: "deuteranomaly" } },
|
|
{ url: "/api/v1/tools/image/beautify", settings: { padding: 20 } },
|
|
{ url: "/api/v1/tools/image/edit-metadata", settings: { title: "test" } },
|
|
{ url: "/api/v1/tools/image/vectorize", settings: { colorMode: "bw" } },
|
|
{ url: "/api/v1/tools/image/split", settings: { columns: 2, rows: 2 } },
|
|
{ url: "/api/v1/tools/image/favicon", settings: {} },
|
|
{
|
|
url: "/api/v1/tools/image/meme-generator",
|
|
settings: {
|
|
textLayout: "top-bottom",
|
|
textBoxes: [{ id: "top", text: "TEST" }],
|
|
},
|
|
},
|
|
{ url: "/api/v1/tools/image/barcode-read", settings: { tryHarder: true } },
|
|
{ url: "/api/v1/tools/image/svg-to-raster", settings: { outputFormat: "png" } },
|
|
{ url: "/api/v1/tools/image/compose", settings: { x: 0, y: 0 } },
|
|
{ url: "/api/v1/tools/image/compare", settings: {} },
|
|
{ url: "/api/v1/tools/image/collage", settings: { templateId: "2-h-equal" } },
|
|
{ url: "/api/v1/tools/image/stitch", settings: { direction: "horizontal" } },
|
|
{ url: "/api/v1/tools/image/find-duplicates", settings: { threshold: 8 } },
|
|
];
|
|
|
|
for (const { url, settings } of TOOL_ENDPOINTS) {
|
|
const toolName = url.split("/").pop();
|
|
|
|
it(`${toolName}: no file -> 400`, async () => {
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify(settings),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url,
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
|
|
const body = JSON.parse(res.body);
|
|
expect(body.error).toBeDefined();
|
|
expect(typeof body.error).toBe("string");
|
|
});
|
|
}
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 20. Unauthenticated requests -> 401 for expanded tools
|
|
// ---------------------------------------------------------------------------
|
|
describe("Unauthenticated requests return 401 for expanded tools", () => {
|
|
const TOOL_ENDPOINTS = [
|
|
"/api/v1/tools/image/color-blindness",
|
|
"/api/v1/tools/image/beautify",
|
|
"/api/v1/tools/image/edit-metadata",
|
|
"/api/v1/tools/image/vectorize",
|
|
"/api/v1/tools/image/split",
|
|
"/api/v1/tools/image/favicon",
|
|
"/api/v1/tools/image/meme-generator",
|
|
"/api/v1/tools/image/barcode-read",
|
|
"/api/v1/tools/image/svg-to-raster",
|
|
"/api/v1/tools/image/compose",
|
|
"/api/v1/tools/image/compare",
|
|
"/api/v1/tools/image/collage",
|
|
"/api/v1/tools/image/stitch",
|
|
"/api/v1/tools/image/find-duplicates",
|
|
"/api/v1/tools/image/transparency-fixer",
|
|
];
|
|
|
|
for (const url of TOOL_ENDPOINTS) {
|
|
const toolName = url.split("/").pop();
|
|
|
|
it(`${toolName}: no auth -> 401`, async () => {
|
|
const fixturePath = fixtures.image.formats("png");
|
|
if (!existsSync(fixturePath)) return;
|
|
|
|
const buffer = readFileSync(fixturePath);
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: "sample.png",
|
|
contentType: "image/png",
|
|
content: buffer,
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url,
|
|
headers: {
|
|
// No authorization header
|
|
"content-type": contentType,
|
|
},
|
|
body: payload,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
}
|
|
});
|