Files
SnapOtter/tests/integration/generated/format-matrix-ai.test.ts
T
SnapOtterandGitHub d10d0f544f fix: release QA hardening across processing, media, security, and CI gates (#649)
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.
2026-07-27 15:37:30 +08:00

800 lines
23 KiB
TypeScript

/**
* Cross-format matrix integration test for AI tools.
*
* Tests 13 AI tools x 17 input formats to verify:
* - No tool returns 500 (server crash) for any format
* - Validation layer works: missing file -> 400, bad settings -> 400
* - Auth enforcement: unauthenticated -> 401
* - Format acceptance/rejection is graceful (200, 202, 400, 422, or 501)
*
* The Python AI sidecar is NOT running during integration tests.
* AI tools will return 501 (FEATURE_NOT_INSTALLED) or 202 (accepted for
* async processing). This is expected -- we are testing the validation
* layer, NOT that AI processing succeeds.
*
* Some tools (smart-crop, content-aware-resize) use the factory pattern
* or process synchronously and may return 200 or 422 depending on whether
* the Go/Python binary is available.
*/
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 { featureUnavailableDisposition } from "../../helpers/generated-case-accounting.js";
import { cancelAcceptedJobAndWait } from "../settle-job.js";
import {
buildTestApp,
createMultipartPayload,
loginAsAdmin,
type TestApp,
} from "../test-server.js";
// ---------------------------------------------------------------------------
// Format sample definitions (matches format-matrix.test.ts)
// ---------------------------------------------------------------------------
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,
},
];
// ---------------------------------------------------------------------------
// AI tool definitions
// ---------------------------------------------------------------------------
interface AiToolDef {
/** Tool route name (maps to /api/v1/tools/<id>) */
id: string;
/** Display name for test output */
label: string;
/** Settings JSON sent as the "settings" multipart field */
settings: Record<string, unknown>;
/**
* Whether this tool uses the 501 FEATURE_NOT_INSTALLED guard.
* Tools using createToolRoute factory (smart-crop) do not have it.
*/
has501Guard: boolean;
/**
* Whether this tool requires a mask file (e.g. erase-object).
*/
requiresMask: boolean;
/**
* A settings payload that should trigger Zod validation failure.
* Used for the "invalid settings -> 400" tests.
*/
invalidSettings: Record<string, unknown>;
}
const AI_TOOLS: AiToolDef[] = [
{
id: "remove-background",
label: "Remove Background",
settings: {},
has501Guard: true,
requiresMask: false,
invalidSettings: { blurIntensity: 999 },
},
{
id: "upscale",
label: "Upscale",
settings: { scale: 2 },
has501Guard: true,
requiresMask: false,
invalidSettings: { scale: "not-a-number", format: 12345 },
},
{
id: "ocr",
label: "OCR",
// This matrix validates ingress and response shape. Dedicated OCR suites
// exercise the built-in Fast runtime; keeping that work out of this 17x14
// matrix prevents long jobs from starving unrelated format conversions.
settings: { quality: "balanced" },
has501Guard: true,
requiresMask: false,
invalidSettings: { quality: "invalid-enum", language: "xx" },
},
{
id: "blur-faces",
label: "Blur Faces",
settings: {},
has501Guard: true,
requiresMask: false,
invalidSettings: { blurRadius: 999 },
},
{
id: "enhance-faces",
label: "Enhance Faces",
settings: {},
has501Guard: true,
requiresMask: false,
invalidSettings: { model: "nonexistent-model" },
},
{
id: "smart-crop",
label: "Smart Crop",
settings: { mode: "subject", width: 100, height: 100 },
has501Guard: false,
requiresMask: false,
invalidSettings: { mode: "invalid-mode-xyz" },
},
{
id: "colorize",
label: "Colorize",
settings: {},
has501Guard: true,
requiresMask: false,
invalidSettings: { model: "nonexistent-model", intensity: 999 },
},
{
id: "noise-removal",
label: "Noise Removal",
settings: {},
has501Guard: true,
requiresMask: false,
invalidSettings: { tier: "nonexistent-tier" },
},
{
id: "red-eye-removal",
label: "Red Eye Removal",
settings: {},
has501Guard: true,
requiresMask: false,
invalidSettings: { sensitivity: -5 },
},
{
id: "restore-photo",
label: "Restore Photo",
settings: {},
has501Guard: true,
requiresMask: false,
invalidSettings: { mode: "nonexistent-mode" },
},
{
id: "passport-photo",
label: "Passport Photo (Analyze)",
settings: {},
has501Guard: true,
requiresMask: false,
invalidSettings: {},
},
{
id: "erase-object",
label: "Erase Object",
settings: {},
has501Guard: true,
requiresMask: true,
invalidSettings: {},
},
{
id: "content-aware-resize",
label: "Content-Aware Resize",
settings: { width: 50, square: false },
has501Guard: false,
requiresMask: false,
invalidSettings: { blurRadius: 999 },
},
{
id: "ai-canvas-expand",
label: "AI Canvas Expand",
settings: { extendTop: 50, extendRight: 0, extendBottom: 50, extendLeft: 0 },
has501Guard: true,
requiresMask: false,
invalidSettings: { extendTop: -1 },
},
];
// ---------------------------------------------------------------------------
// Valid status codes for AI tool responses during testing
// (no AI sidecar running, so processing won't complete)
// ---------------------------------------------------------------------------
/**
* Acceptable status codes from AI tools in test:
* - 200: synchronous success (e.g. smart-crop with subject mode)
* - 202: accepted for async processing (tools that reply early)
* - 400: format/validation rejected
* - 422: processing error (format decode failure, etc.)
* - 501: AI feature not installed (FEATURE_NOT_INSTALLED)
*/
const ACCEPTABLE_AI_CODES = [200, 202, 400, 422];
const REQUIRE_AI_FEATURES = process.env.REQUIRE_AI_FEATURES === "1";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Get the route URL for a given AI tool.
* passport-photo uses /analyze endpoint.
*/
function getToolUrl(toolId: string): string {
if (toolId === "passport-photo") {
return `${apiToolPath("passport-photo")}/analyze`;
}
return apiToolPath(toolId);
}
function featurePrerequisiteSkipReason(
toolId: string,
statusCode: number,
body: Record<string, unknown>,
): string | undefined {
const disposition = featureUnavailableDisposition({
toolId,
statusCode,
code: body.code,
requireAiFeatures: REQUIRE_AI_FEATURES,
});
return disposition === "skip"
? `${toolId}: optional AI prerequisite absent; set REQUIRE_AI_FEATURES=1 after install`
: undefined;
}
/**
* Build multipart payload for an AI tool request.
* Handles the mask requirement for erase-object.
*/
function buildAiPayload(
fmt: FormatSample,
tool: AiToolDef,
buffer: Buffer,
maskBuffer?: 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,
},
];
// erase-object needs a mask file
if (tool.requiresMask && maskBuffer) {
fields.push({
name: "mask",
filename: "mask.png",
contentType: "image/png",
content: maskBuffer,
});
}
// Add settings if non-empty
if (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;
/** Tiny PNG buffer used as mask for erase-object tests */
let maskBuffer: Buffer;
beforeAll(async () => {
testApp = await buildTestApp();
app = testApp.app;
adminToken = await loginAsAdmin(app);
// Use sample.png as the mask for erase-object
const maskPath = fixtures.image.formats("png");
maskBuffer = readFileSync(maskPath);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
// ---------------------------------------------------------------------------
// Cross-format matrix: every AI tool x every format
// ---------------------------------------------------------------------------
describe("AI tool 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 AI_TOOLS) {
const perTestTimeout = fmt.needsHeifDecoder || fmt.needsCliDecoder ? 180_000 : undefined;
it(
`${tool.label}`,
async (context) => {
if (!existsSync(fixturePath)) {
return context.skip(`${tool.id}: missing fixture ${fmt.file}`);
}
const buffer = readFileSync(fixturePath);
const { body: payload, contentType } = buildAiPayload(
fmt,
tool,
buffer,
tool.requiresMask ? maskBuffer : undefined,
);
const res = await app.inject({
method: "POST",
url: getToolUrl(tool.id),
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
// Must NEVER return 500 (server crash)
expect(res.statusCode, `${tool.label} + ${fmt.name}: got 500 server error`).not.toBe(
500,
);
const responseBody = JSON.parse(res.body) as Record<string, unknown>;
const skipReason = featurePrerequisiteSkipReason(tool.id, res.statusCode, responseBody);
if (skipReason) return context.skip(skipReason);
// Must be one of the acceptable codes
expect(ACCEPTABLE_AI_CODES).toContain(res.statusCode);
// Response must always be valid JSON
const body = responseBody;
if (res.statusCode === 202) {
// Async processing accepted
expect(body.jobId).toBeDefined();
expect(typeof body.jobId).toBe("string");
expect(body.async).toBe(true);
// A developer may have the optional OCR runtime installed even
// though CI does not. This matrix owns only the enqueue contract,
// so never leave that long job consuming CPU after the assertion.
if (tool.id === "ocr") {
await cancelAcceptedJobAndWait(body.jobId as string, "ai");
}
} else if (res.statusCode === 200) {
// Synchronous success -- at minimum, response is valid JSON
expect(typeof body).toBe("object");
} else {
// 400 or 422 error -- verify clean error shape
expect(body.error).toBeDefined();
expect(typeof body.error).toBe("string");
expect(body.error.length).toBeGreaterThan(0);
}
},
perTestTimeout,
);
}
});
}
});
// ---------------------------------------------------------------------------
// Missing file -> 400 for every AI tool
// ---------------------------------------------------------------------------
describe("Missing file returns 400", () => {
for (const tool of AI_TOOLS) {
it(`${tool.label}: no file -> 400`, async (context) => {
// Send empty multipart with only settings
const fields: Array<{
name: string;
filename?: string;
contentType?: string;
content: Buffer | string;
}> = [];
if (Object.keys(tool.settings).length > 0) {
fields.push({
name: "settings",
content: JSON.stringify(tool.settings),
});
}
// Add at least one field so the multipart is valid
if (fields.length === 0) {
fields.push({
name: "settings",
content: JSON.stringify({}),
});
}
const { body: payload, contentType } = createMultipartPayload(fields);
const res = await app.inject({
method: "POST",
url: getToolUrl(tool.id),
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
const body = JSON.parse(res.body) as Record<string, unknown>;
const skipReason = featurePrerequisiteSkipReason(tool.id, res.statusCode, body);
if (skipReason) return context.skip(skipReason);
expect(res.statusCode).toBe(400);
expect(body.error).toBeDefined();
expect(typeof body.error).toBe("string");
});
}
});
// ---------------------------------------------------------------------------
// Unauthenticated -> 401 for every AI tool
// ---------------------------------------------------------------------------
describe("Unauthenticated requests return 401", () => {
for (const tool of AI_TOOLS) {
it(`${tool.label}: no auth -> 401`, async (context) => {
const fixturePath = fixtures.image.formats("png");
if (!existsSync(fixturePath)) return context.skip(`${tool.id}: missing fixture sample.png`);
const buffer = readFileSync(fixturePath);
const fmt = FORMAT_SAMPLES.find((f) => f.file === "sample.png")!;
const { body: payload, contentType } = buildAiPayload(
fmt,
tool,
buffer,
tool.requiresMask ? maskBuffer : undefined,
);
const res = await app.inject({
method: "POST",
url: getToolUrl(tool.id),
headers: {
// No authorization header
"content-type": contentType,
},
body: payload,
});
expect(res.statusCode).toBe(401);
});
}
});
// ---------------------------------------------------------------------------
// Invalid settings -> 400 for tools with settings validation
// ---------------------------------------------------------------------------
describe("Invalid settings return 400", () => {
// Only test tools where we have specifically invalid settings that
// should trigger Zod validation errors
const TOOLS_WITH_INVALID_SETTINGS = AI_TOOLS.filter(
(t) => Object.keys(t.invalidSettings).length > 0,
);
for (const tool of TOOLS_WITH_INVALID_SETTINGS) {
it(`${tool.label}: invalid settings -> 400`, async (context) => {
const fixturePath = fixtures.image.formats("png");
if (!existsSync(fixturePath)) return context.skip(`${tool.id}: missing fixture sample.png`);
const buffer = readFileSync(fixturePath);
const fields: Array<{
name: string;
filename?: string;
contentType?: string;
content: Buffer | string;
}> = [
{
name: "file",
filename: "sample.png",
contentType: "image/png",
content: buffer,
},
{
name: "settings",
content: JSON.stringify(tool.invalidSettings),
},
];
// erase-object needs a mask
if (tool.requiresMask) {
fields.push({
name: "mask",
filename: "mask.png",
contentType: "image/png",
content: maskBuffer,
});
}
const { body: payload, contentType } = createMultipartPayload(fields);
const res = await app.inject({
method: "POST",
url: getToolUrl(tool.id),
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
const body = JSON.parse(res.body) as Record<string, unknown>;
const skipReason = featurePrerequisiteSkipReason(tool.id, res.statusCode, body);
if (skipReason) return context.skip(skipReason);
expect(res.statusCode).toBe(400);
expect(body.error).toBeDefined();
expect(typeof body.error).toBe("string");
});
}
});
// ---------------------------------------------------------------------------
// Erase-object specific: missing mask -> 400
// ---------------------------------------------------------------------------
describe("Erase-object missing mask returns 400", () => {
it("erase-object without mask file -> 400", async (context) => {
const fixturePath = fixtures.image.formats("png");
if (!existsSync(fixturePath)) return context.skip("erase-object: missing fixture sample.png");
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: "/api/v1/tools/image/erase-object",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
const body = JSON.parse(res.body) as Record<string, unknown>;
const skipReason = featurePrerequisiteSkipReason("erase-object", res.statusCode, body);
if (skipReason) return context.skip(skipReason);
expect(res.statusCode).toBe(400);
expect(body.error).toBeDefined();
expect(typeof body.error).toBe("string");
});
});
// ---------------------------------------------------------------------------
// Content-aware-resize specific: missing dimensions -> 400
// ---------------------------------------------------------------------------
describe("Content-aware-resize without dimensions returns 400", () => {
it("content-aware-resize without width/height/square -> 400", async (context) => {
const fixturePath = fixtures.image.formats("png");
if (!existsSync(fixturePath)) {
return context.skip("content-aware-resize: missing fixture sample.png");
}
const buffer = readFileSync(fixturePath);
const { body: payload, contentType } = createMultipartPayload([
{
name: "file",
filename: "sample.png",
contentType: "image/png",
content: buffer,
},
{
name: "settings",
content: JSON.stringify({}),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/content-aware-resize",
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");
});
});
// ---------------------------------------------------------------------------
// Malformed JSON settings -> 400 for AI tools
// ---------------------------------------------------------------------------
describe("Malformed JSON settings return 400", () => {
// Test a representative subset of tools (one per pattern)
const REPRESENTATIVE_TOOLS = AI_TOOLS.filter(
(t) => !t.requiresMask && t.id !== "passport-photo",
).slice(0, 5);
for (const tool of REPRESENTATIVE_TOOLS) {
it(`${tool.label}: malformed JSON settings -> 400`, async (context) => {
const fixturePath = fixtures.image.formats("png");
if (!existsSync(fixturePath)) return context.skip(`${tool.id}: missing fixture sample.png`);
const buffer = readFileSync(fixturePath);
const { body: payload, contentType } = createMultipartPayload([
{
name: "file",
filename: "sample.png",
contentType: "image/png",
content: buffer,
},
{
name: "settings",
content: "{ this is not valid json }}}",
},
]);
const res = await app.inject({
method: "POST",
url: getToolUrl(tool.id),
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body: payload,
});
const body = JSON.parse(res.body) as Record<string, unknown>;
const skipReason = featurePrerequisiteSkipReason(tool.id, res.statusCode, body);
if (skipReason) return context.skip(skipReason);
expect(res.statusCode).toBe(400);
expect(body.error).toBeDefined();
expect(typeof body.error).toBe("string");
});
}
});