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.
800 lines
23 KiB
TypeScript
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");
|
|
});
|
|
}
|
|
});
|