test: major coverage expansion — 18 new test files, ~750 new tests

Integration tests for all 13 previously untested AI tool routes:
- blur-faces, colorize, enhance-faces, erase-object, noise-removal
- ocr, passport-photo, red-eye-removal, remove-background
- restore-photo, smart-crop, upscale

Dedicated integration tests for core Sharp tools:
- resize (17 tests), crop (17 tests), rotate (17 tests)

Adversarial and edge case expansion (60 tests):
- Zero-byte files, corrupted headers, unicode filenames
- Concurrent stress, injection attempts, batch/pipeline edge cases

Unit tests for web frontend:
- tool-registry coverage, image-preview + download edge cases
This commit is contained in:
SnapOtter
2026-04-25 21:53:13 +08:00
parent 8bc8b18f90
commit 06b12f19d2
18 changed files with 6485 additions and 0 deletions
File diff suppressed because it is too large Load Diff
+273
View File
@@ -0,0 +1,273 @@
/**
* Integration tests for the blur-faces AI tool (/api/v1/tools/blur-faces).
*
* The Python sidecar may not be running, so processing tests accept both
* 200 (sidecar available) and 501 (feature not installed). Validation paths
* are always testable.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const FIXTURES = join(__dirname, "..", "fixtures");
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
const HEIC = readFileSync(join(FIXTURES, "test-200x150.heic"));
const TINY = readFileSync(join(FIXTURES, "test-1x1.png"));
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);
describe("blur-faces", () => {
// ── Processing (sidecar-dependent) ────────────────────────────────
it("responds to the route (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/blur-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("processes with default settings (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/blur-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
if (res.statusCode === 200) {
const json = JSON.parse(res.body);
expect(json.jobId).toBeDefined();
expect(json.downloadUrl).toBeDefined();
}
}, 60_000);
it("accepts explicit blurRadius and sensitivity (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ blurRadius: 80, sensitivity: 0.9 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/blur-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts minimum settings values (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ blurRadius: 1, sensitivity: 0 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/blur-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("handles HEIC input (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.heic", contentType: "image/heic", content: HEIC },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/blur-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("handles 1x1 pixel input (200, 422, or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.png", contentType: "image/png", content: TINY },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/blur-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
// 200 = processed, 422 = processing error, 501 = sidecar not installed
expect([200, 422, 501]).toContain(res.statusCode);
}, 60_000);
// ── Validation (always testable) ──────────────────────────────────
it("rejects requests without a file (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "settings", content: JSON.stringify({ blurRadius: 30 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/blur-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
// 400 when sidecar is available, 501 when not (isToolInstalled check fires first)
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const json = JSON.parse(res.body);
expect(json.error).toMatch(/no image/i);
}
});
it("rejects invalid settings JSON (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: "not-json{{{" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/blur-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
// Either 400 (invalid JSON) or 501 (sidecar not installed, checked first)
expect([400, 501]).toContain(res.statusCode);
});
it("rejects blurRadius above 100 (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ blurRadius: 200, sensitivity: 0.5 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/blur-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([400, 501]).toContain(res.statusCode);
});
it("rejects sensitivity above 1 (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ blurRadius: 30, sensitivity: 5 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/blur-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([400, 501]).toContain(res.statusCode);
});
it("rejects blurRadius below 1 (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ blurRadius: 0, sensitivity: 0.5 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/blur-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([400, 501]).toContain(res.statusCode);
});
it("rejects unauthenticated requests (401)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/blur-faces",
headers: { "content-type": contentType },
body,
});
expect(res.statusCode).toBe(401);
});
it("rejects sensitivity below 0 (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ blurRadius: 30, sensitivity: -0.5 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/blur-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([400, 501]).toContain(res.statusCode);
});
});
+272
View File
@@ -0,0 +1,272 @@
/**
* Integration tests for the colorize AI tool (/api/v1/tools/colorize).
*
* The Python sidecar may not be running, so processing tests accept both
* 200 (sidecar available) and 501 (feature not installed). Validation paths
* are always testable.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const FIXTURES = join(__dirname, "..", "fixtures");
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
const HEIC = readFileSync(join(FIXTURES, "test-200x150.heic"));
const TINY = readFileSync(join(FIXTURES, "test-1x1.png"));
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);
describe("colorize", () => {
// ── Processing (sidecar-dependent) ────────────────────────────────
it("responds to the route (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/colorize",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("processes with default settings (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/colorize",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
if (res.statusCode === 200) {
const json = JSON.parse(res.body);
expect(json.jobId).toBeDefined();
expect(json.downloadUrl).toBeDefined();
expect(json.method).toBeDefined();
}
}, 60_000);
it("accepts explicit intensity and model=auto (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ intensity: 0.8, model: "auto" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/colorize",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts model=ddcolor (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ model: "ddcolor" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/colorize",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts model=opencv (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ model: "opencv" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/colorize",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts minimum intensity of 0 (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ intensity: 0 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/colorize",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("handles HEIC input (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.heic", contentType: "image/heic", content: HEIC },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/colorize",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("handles 1x1 pixel input (200, 422, or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.png", contentType: "image/png", content: TINY },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/colorize",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 422, 501]).toContain(res.statusCode);
}, 60_000);
// ── Validation (always testable) ──────────────────────────────────
it("rejects requests without a file (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/colorize",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
// 400 when sidecar is available, 501 when not (isToolInstalled check fires first)
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const json = JSON.parse(res.body);
expect(json.error).toMatch(/no image/i);
}
});
it("rejects invalid settings JSON (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: "{broken json" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/colorize",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([400, 501]).toContain(res.statusCode);
});
it("rejects intensity above 1 (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ intensity: 5 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/colorize",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([400, 501]).toContain(res.statusCode);
});
it("rejects invalid model value (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ model: "nonexistent" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/colorize",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([400, 501]).toContain(res.statusCode);
});
it("rejects unauthenticated requests (401)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/colorize",
headers: { "content-type": contentType },
body,
});
expect(res.statusCode).toBe(401);
});
});
+335
View File
@@ -0,0 +1,335 @@
/**
* Integration tests for the crop tool (/api/v1/tools/crop).
*
* This is a Sharp-based tool (no AI sidecar). All processing tests should
* return 200. Output dimensions are verified by downloading the result and
* reading metadata with sharp.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import sharp from "sharp";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const FIXTURES = join(__dirname, "..", "fixtures");
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
const HEIC = readFileSync(join(FIXTURES, "test-200x150.heic"));
const TINY = readFileSync(join(FIXTURES, "test-1x1.png"));
let testApp: TestApp;
let app: TestApp["app"];
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
app = testApp.app;
adminToken = await loginAsAdmin(app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
/** Helper: POST to crop, assert 200, download result, return sharp metadata. */
async function cropAndMeta(
settings: Record<string, unknown>,
file = PNG,
filename = "test.png",
fileCt = "image/png",
) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: fileCt, content: file },
{ name: "settings", content: JSON.stringify(settings) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/crop",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined();
const dlRes = await app.inject({
method: "GET",
url: result.downloadUrl,
headers: { authorization: `Bearer ${adminToken}` },
});
expect(dlRes.statusCode).toBe(200);
return sharp(dlRes.rawPayload).metadata();
}
describe("Crop", () => {
// ── Processing with dimension verification ───────────────────────
it("route exists and responds to POST", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ left: 0, top: 0, width: 100, height: 100 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/crop",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(200);
});
it("crops a region from the top-left corner", async () => {
const meta = await cropAndMeta({ left: 0, top: 0, width: 100, height: 75 });
expect(meta.width).toBe(100);
expect(meta.height).toBe(75);
});
it("crops a region from the center", async () => {
const meta = await cropAndMeta({ left: 50, top: 25, width: 100, height: 100 });
expect(meta.width).toBe(100);
expect(meta.height).toBe(100);
});
it("crops a small region", async () => {
const meta = await cropAndMeta({ left: 10, top: 10, width: 20, height: 20 });
expect(meta.width).toBe(20);
expect(meta.height).toBe(20);
});
it("crops the full image dimensions (no-op crop)", async () => {
const meta = await cropAndMeta({ left: 0, top: 0, width: 200, height: 150 });
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
it("crops a 1-pixel-wide strip", async () => {
const meta = await cropAndMeta({ left: 50, top: 0, width: 1, height: 150 });
expect(meta.width).toBe(1);
expect(meta.height).toBe(150);
});
it("crops a 1-pixel-tall strip", async () => {
const meta = await cropAndMeta({ left: 0, top: 50, width: 200, height: 1 });
expect(meta.width).toBe(200);
expect(meta.height).toBe(1);
});
it("crops with percent unit", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ left: 10, top: 10, width: 50, height: 50, unit: "percent" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/crop",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.processedSize).toBeGreaterThan(0);
});
it("works with JPEG input", async () => {
const meta = await cropAndMeta(
{ left: 10, top: 10, width: 50, height: 50 },
JPG,
"test.jpg",
"image/jpeg",
);
expect(meta.width).toBe(50);
expect(meta.height).toBe(50);
});
it("handles HEIC input", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.heic", contentType: "image/heic", content: HEIC },
{
name: "settings",
content: JSON.stringify({ left: 0, top: 0, width: 100, height: 100 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/crop",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.processedSize).toBeGreaterThan(0);
});
it("handles 1x1 pixel input", async () => {
const meta = await cropAndMeta(
{ left: 0, top: 0, width: 1, height: 1 },
TINY,
"tiny.png",
"image/png",
);
expect(meta.width).toBe(1);
expect(meta.height).toBe(1);
});
// ── Validation ───────────────────────────────────────────────────
it("rejects requests without a file", async () => {
const { body, contentType } = createMultipartPayload([
{
name: "settings",
content: JSON.stringify({ left: 0, top: 0, width: 100, height: 100 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/crop",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(400);
const result = JSON.parse(res.body);
expect(result.error).toMatch(/no image/i);
});
it("rejects invalid settings JSON", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: "not json{{{" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/crop",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(400);
const result = JSON.parse(res.body);
expect(result.error).toMatch(/json/i);
});
it("rejects missing required fields", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({ left: 0 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/crop",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(400);
const result = JSON.parse(res.body);
expect(result.error).toMatch(/invalid settings/i);
});
it("rejects negative left value", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ left: -10, top: 0, width: 100, height: 100 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/crop",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(400);
const result = JSON.parse(res.body);
expect(result.error).toMatch(/invalid settings/i);
});
it("rejects invalid unit value", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ left: 0, top: 0, width: 100, height: 100, unit: "em" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/crop",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(400);
const result = JSON.parse(res.body);
expect(result.error).toMatch(/invalid settings/i);
});
it("rejects unauthenticated requests", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ left: 0, top: 0, width: 100, height: 100 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/crop",
headers: { "content-type": contentType },
body,
});
expect(res.statusCode).toBe(401);
});
});
+272
View File
@@ -0,0 +1,272 @@
/**
* Integration tests for the enhance-faces AI tool (/api/v1/tools/enhance-faces).
*
* The Python sidecar may not be running, so processing tests accept both
* 200 (sidecar available) and 501 (feature not installed). Validation paths
* are always testable.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const FIXTURES = join(__dirname, "..", "fixtures");
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
const HEIC = readFileSync(join(FIXTURES, "test-200x150.heic"));
const TINY = readFileSync(join(FIXTURES, "test-1x1.png"));
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);
describe("enhance-faces", () => {
// ── Processing (sidecar-dependent) ────────────────────────────────
it("responds to the route (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/enhance-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("processes with default settings (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/enhance-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
if (res.statusCode === 200) {
const json = JSON.parse(res.body);
expect(json.jobId).toBeDefined();
expect(json.downloadUrl).toBeDefined();
expect(json.model).toBeDefined();
}
}, 60_000);
it("accepts model=gfpgan with explicit strength (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ model: "gfpgan", strength: 0.9 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/enhance-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts model=codeformer (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ model: "codeformer" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/enhance-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts onlyCenterFace=true (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ onlyCenterFace: true, sensitivity: 0.7 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/enhance-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts minimum setting values (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ strength: 0, sensitivity: 0 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/enhance-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("handles HEIC input (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.heic", contentType: "image/heic", content: HEIC },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/enhance-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("handles 1x1 pixel input (200, 422, or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.png", contentType: "image/png", content: TINY },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/enhance-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 422, 501]).toContain(res.statusCode);
}, 60_000);
// ── Validation (always testable) ──────────────────────────────────
it("rejects requests without a file (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/enhance-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
// 400 when sidecar is available, 501 when not (isToolInstalled check fires first)
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const json = JSON.parse(res.body);
expect(json.error).toMatch(/no image/i);
}
});
it("rejects invalid settings JSON (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: "<<<invalid>>>" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/enhance-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([400, 501]).toContain(res.statusCode);
});
it("rejects strength above 1 (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ strength: 2.5 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/enhance-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([400, 501]).toContain(res.statusCode);
});
it("rejects invalid model value (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ model: "invalid-model" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/enhance-faces",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([400, 501]).toContain(res.statusCode);
});
it("rejects unauthenticated requests (401)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/enhance-faces",
headers: { "content-type": contentType },
body,
});
expect(res.statusCode).toBe(401);
});
});
+251
View File
@@ -0,0 +1,251 @@
/**
* Integration tests for the erase-object AI tool (/api/v1/tools/erase-object).
*
* This tool requires BOTH an image and a mask file. The Python sidecar may not
* be running, so processing tests accept both 200 (sidecar available) and
* 501 (feature not installed). Validation paths are always testable.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const FIXTURES = join(__dirname, "..", "fixtures");
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
const HEIC = readFileSync(join(FIXTURES, "test-200x150.heic"));
const TINY = readFileSync(join(FIXTURES, "test-1x1.png"));
// Use the same PNG as a mask (any valid image works for test purposes)
const MASK = readFileSync(join(FIXTURES, "test-200x150.png"));
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);
describe("erase-object", () => {
// ── Processing (sidecar-dependent) ────────────────────────────────
it("responds to the route with image and mask (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "mask", filename: "mask.png", contentType: "image/png", content: MASK },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/erase-object",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("processes with default format and quality (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "mask", filename: "mask.png", contentType: "image/png", content: MASK },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/erase-object",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
if (res.statusCode === 200) {
const json = JSON.parse(res.body);
expect(json.jobId).toBeDefined();
expect(json.downloadUrl).toBeDefined();
expect(json.processedSize).toBeGreaterThan(0);
}
}, 60_000);
it("accepts explicit format=jpg and quality=80 (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "mask", filename: "mask.png", contentType: "image/png", content: MASK },
{ name: "format", content: "jpg" },
{ name: "quality", content: "80" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/erase-object",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts format=webp (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "mask", filename: "mask.png", contentType: "image/png", content: MASK },
{ name: "format", content: "webp" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/erase-object",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("handles HEIC image input (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.heic", contentType: "image/heic", content: HEIC },
{ name: "mask", filename: "mask.png", contentType: "image/png", content: MASK },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/erase-object",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("handles 1x1 pixel image input (200, 422, or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.png", contentType: "image/png", content: TINY },
{ name: "mask", filename: "mask.png", contentType: "image/png", content: TINY },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/erase-object",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 422, 501]).toContain(res.statusCode);
}, 60_000);
// ── Validation (always testable) ──────────────────────────────────
it("rejects requests without an image file (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "mask", filename: "mask.png", contentType: "image/png", content: MASK },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/erase-object",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
// 400 when sidecar is available, 501 when not (isToolInstalled check fires first)
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const json = JSON.parse(res.body);
expect(json.error).toMatch(/no image/i);
}
});
it("rejects requests without a mask file (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/erase-object",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
// 400 for missing mask or 501 for sidecar not installed
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const json = JSON.parse(res.body);
expect(json.error).toMatch(/mask/i);
}
});
it("rejects invalid format value (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "mask", filename: "mask.png", contentType: "image/png", content: MASK },
{ name: "format", content: "bmp" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/erase-object",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([400, 501]).toContain(res.statusCode);
});
it("rejects quality out of range (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "mask", filename: "mask.png", contentType: "image/png", content: MASK },
{ name: "quality", content: "200" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/erase-object",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([400, 501]).toContain(res.statusCode);
});
it("rejects unauthenticated requests (401)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "mask", filename: "mask.png", contentType: "image/png", content: MASK },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/erase-object",
headers: { "content-type": contentType },
body,
});
expect(res.statusCode).toBe(401);
});
it("accepts format=avif (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "mask", filename: "mask.png", contentType: "image/png", content: MASK },
{ name: "format", content: "avif" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/erase-object",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
});
+279
View File
@@ -0,0 +1,279 @@
/**
* Integration tests for the noise-removal AI tool (/api/v1/tools/noise-removal).
*
* The Python sidecar may not be running, so processing tests accept both
* 200 (sidecar available) and 501 (feature not installed). Validation paths
* are always testable.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const FIXTURES = join(__dirname, "..", "fixtures");
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
const HEIC = readFileSync(join(FIXTURES, "test-200x150.heic"));
const TINY = readFileSync(join(FIXTURES, "test-1x1.png"));
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);
describe("noise-removal", () => {
// ── Processing (sidecar-dependent) ────────────────────────────────
it("responds to the route (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/noise-removal",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("processes with default settings (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/noise-removal",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
if (res.statusCode === 200) {
const json = JSON.parse(res.body);
expect(json.jobId).toBeDefined();
expect(json.downloadUrl).toBeDefined();
expect(json.processedSize).toBeGreaterThan(0);
}
}, 60_000);
it("accepts tier=quick (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ tier: "quick" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/noise-removal",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts tier=quality with explicit strength (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ tier: "quality", strength: 80 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/noise-removal",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts tier=maximum (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ tier: "maximum" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/noise-removal",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts all explicit settings (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({
tier: "balanced",
strength: 60,
detailPreservation: 70,
colorNoise: 40,
format: "jpeg",
quality: 85,
}),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/noise-removal",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("handles HEIC input (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.heic", contentType: "image/heic", content: HEIC },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/noise-removal",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("handles 1x1 pixel input (200, 422, or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.png", contentType: "image/png", content: TINY },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/noise-removal",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 422, 501]).toContain(res.statusCode);
}, 60_000);
// ── Validation (always testable) ──────────────────────────────────
it("rejects requests without a file (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "settings", content: JSON.stringify({ tier: "quick" }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/noise-removal",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
// 400 when sidecar is available, 501 when not (isToolInstalled check fires first)
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const json = JSON.parse(res.body);
expect(json.error).toMatch(/no image/i);
}
});
it("rejects invalid settings JSON (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: "not-valid-json!!!" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/noise-removal",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([400, 501]).toContain(res.statusCode);
});
it("rejects invalid tier value (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ tier: "ultra" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/noise-removal",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([400, 501]).toContain(res.statusCode);
});
it("rejects invalid format value (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ format: "bmp" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/noise-removal",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([400, 501]).toContain(res.statusCode);
});
it("rejects unauthenticated requests (401)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/noise-removal",
headers: { "content-type": contentType },
body,
});
expect(res.statusCode).toBe(401);
});
});
+272
View File
@@ -0,0 +1,272 @@
/**
* Integration tests for the OCR AI tool (/api/v1/tools/ocr).
*
* The Python sidecar may not be running, so processing tests accept both
* 200 (sidecar available) and 501 (feature not installed). Validation paths
* are always testable.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const FIXTURES = join(__dirname, "..", "fixtures");
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
const HEIC = readFileSync(join(FIXTURES, "test-200x150.heic"));
const TINY = readFileSync(join(FIXTURES, "test-1x1.png"));
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);
describe("ocr", () => {
// ── Processing (sidecar-dependent) ────────────────────────────────
it("responds to the route (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("processes with default settings (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
if (res.statusCode === 200) {
const json = JSON.parse(res.body);
expect(json.jobId).toBeDefined();
expect(json.text).toBeDefined();
expect(json.engine).toBeDefined();
}
}, 60_000);
it("accepts quality=fast (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ quality: "fast" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts quality=best (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ quality: "best" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts explicit language and enhance=false (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ language: "en", enhance: false }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts backward-compatible engine param (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ engine: "tesseract" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("handles HEIC input (200 or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.heic", contentType: "image/heic", content: HEIC },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("handles 1x1 pixel input (200, 422, or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.png", contentType: "image/png", content: TINY },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 422, 501]).toContain(res.statusCode);
}, 60_000);
// ── Validation (always testable) ──────────────────────────────────
it("rejects requests without a file (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "settings", content: JSON.stringify({ quality: "fast" }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
// 400 when sidecar is available, 501 when not (isToolInstalled check fires first)
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const json = JSON.parse(res.body);
expect(json.error).toMatch(/no image/i);
}
});
it("rejects invalid settings JSON (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: "{{bad json}}" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([400, 501]).toContain(res.statusCode);
});
it("rejects invalid quality value (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ quality: "ultra" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([400, 501]).toContain(res.statusCode);
});
it("rejects invalid language value (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ language: "klingon" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/ocr",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([400, 501]).toContain(res.statusCode);
});
it("rejects unauthenticated requests (401)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/ocr",
headers: { "content-type": contentType },
body,
});
expect(res.statusCode).toBe(401);
});
});
+340
View File
@@ -0,0 +1,340 @@
/**
* Integration tests for the passport-photo AI tool.
*
* Two-phase flow:
* Phase 1: POST /api/v1/tools/passport-photo/analyze (face detection + bg removal)
* Phase 2: POST /api/v1/tools/passport-photo/generate (crop/resize, JSON body)
*
* The Python sidecar may not be running, so processing tests accept both
* 200 (sidecar available) and 501 (feature not installed). Validation paths
* are always testable.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const FIXTURES = join(__dirname, "..", "fixtures");
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
const HEIC = readFileSync(join(FIXTURES, "test-200x150.heic"));
const TINY = readFileSync(join(FIXTURES, "test-1x1.png"));
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);
describe("passport-photo/analyze", () => {
// ── Processing (sidecar-dependent) ────────────────────────────────
it("responds to the analyze route (200, 422, or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/passport-photo/analyze",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
// 200 = success with face, 422 = no face detected or processing error, 501 = sidecar not installed
expect([200, 422, 501]).toContain(res.statusCode);
}, 60_000);
it("returns landmarks and preview on success", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/passport-photo/analyze",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 422, 501]).toContain(res.statusCode);
if (res.statusCode === 200) {
const json = JSON.parse(res.body);
expect(json.jobId).toBeDefined();
expect(json.landmarks).toBeDefined();
expect(json.preview).toBeDefined();
expect(json.imageWidth).toBeDefined();
expect(json.imageHeight).toBeDefined();
}
}, 60_000);
it("handles HEIC input (200, 422, or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.heic", contentType: "image/heic", content: HEIC },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/passport-photo/analyze",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 422, 501]).toContain(res.statusCode);
}, 60_000);
it("handles 1x1 pixel input (200, 422, or 501)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.png", contentType: "image/png", content: TINY },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/passport-photo/analyze",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect([200, 422, 501]).toContain(res.statusCode);
}, 60_000);
// ── Validation (always testable) ──────────────────────────────────
it("rejects requests without a file (400)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "clientJobId", content: "test-123" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/passport-photo/analyze",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
// 400 when sidecar is available, 501 when not (isToolInstalled check fires first)
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const json = JSON.parse(res.body);
expect(json.error).toMatch(/no image/i);
}
});
it("rejects unauthenticated requests to analyze (401)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/passport-photo/analyze",
headers: { "content-type": contentType },
body,
});
expect(res.statusCode).toBe(401);
});
});
describe("passport-photo/generate", () => {
// ── Validation (always testable, JSON body endpoint) ──────────────
it("rejects missing required fields (400)", async () => {
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/passport-photo/generate",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": "application/json",
},
payload: {},
});
expect(res.statusCode).toBe(400);
const json = JSON.parse(res.body);
expect(json.error).toMatch(/invalid settings/i);
});
it("rejects unknown country code without custom dimensions (400)", async () => {
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/passport-photo/generate",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": "application/json",
},
payload: {
jobId: "nonexistent-job-id",
filename: "test.png",
countryCode: "XX",
landmarks: {
leftEye: { x: 0.3, y: 0.4 },
rightEye: { x: 0.7, y: 0.4 },
eyeCenter: { x: 0.5, y: 0.4 },
chin: { x: 0.5, y: 0.8 },
forehead: { x: 0.5, y: 0.2 },
crown: { x: 0.5, y: 0.15 },
nose: { x: 0.5, y: 0.6 },
faceCenterX: 0.5,
},
imageWidth: 200,
imageHeight: 150,
},
});
// 400 for unknown country code or 422 for missing workspace
expect([400, 422]).toContain(res.statusCode);
});
it("rejects invalid dpi value (400)", async () => {
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/passport-photo/generate",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": "application/json",
},
payload: {
jobId: "test-job",
filename: "test.png",
countryCode: "US",
dpi: 50,
landmarks: {
leftEye: { x: 0.3, y: 0.4 },
rightEye: { x: 0.7, y: 0.4 },
eyeCenter: { x: 0.5, y: 0.4 },
chin: { x: 0.5, y: 0.8 },
forehead: { x: 0.5, y: 0.2 },
crown: { x: 0.5, y: 0.15 },
nose: { x: 0.5, y: 0.6 },
faceCenterX: 0.5,
},
imageWidth: 200,
imageHeight: 150,
},
});
expect(res.statusCode).toBe(400);
});
it("rejects invalid zoom value (400)", async () => {
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/passport-photo/generate",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": "application/json",
},
payload: {
jobId: "test-job",
filename: "test.png",
countryCode: "US",
zoom: 10,
landmarks: {
leftEye: { x: 0.3, y: 0.4 },
rightEye: { x: 0.7, y: 0.4 },
eyeCenter: { x: 0.5, y: 0.4 },
chin: { x: 0.5, y: 0.8 },
forehead: { x: 0.5, y: 0.2 },
crown: { x: 0.5, y: 0.15 },
nose: { x: 0.5, y: 0.6 },
faceCenterX: 0.5,
},
imageWidth: 200,
imageHeight: 150,
},
});
expect(res.statusCode).toBe(400);
});
it("rejects unauthenticated requests to generate (401)", async () => {
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/passport-photo/generate",
headers: { "content-type": "application/json" },
payload: {
jobId: "test-job",
filename: "test.png",
countryCode: "US",
landmarks: {
leftEye: { x: 0.3, y: 0.4 },
rightEye: { x: 0.7, y: 0.4 },
eyeCenter: { x: 0.5, y: 0.4 },
chin: { x: 0.5, y: 0.8 },
forehead: { x: 0.5, y: 0.2 },
crown: { x: 0.5, y: 0.15 },
nose: { x: 0.5, y: 0.6 },
faceCenterX: 0.5,
},
imageWidth: 200,
imageHeight: 150,
},
});
expect(res.statusCode).toBe(401);
});
it("rejects missing landmarks fields (400)", async () => {
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/passport-photo/generate",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": "application/json",
},
payload: {
jobId: "test-job",
filename: "test.png",
countryCode: "US",
landmarks: {
leftEye: { x: 0.3, y: 0.4 },
// Missing required fields
},
imageWidth: 200,
imageHeight: 150,
},
});
expect(res.statusCode).toBe(400);
});
it("returns 422 when jobId workspace does not exist", async () => {
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/passport-photo/generate",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": "application/json",
},
payload: {
jobId: "00000000-0000-0000-0000-000000000000",
filename: "test.png",
countryCode: "US",
landmarks: {
leftEye: { x: 0.3, y: 0.4 },
rightEye: { x: 0.7, y: 0.4 },
eyeCenter: { x: 0.5, y: 0.4 },
chin: { x: 0.5, y: 0.8 },
forehead: { x: 0.5, y: 0.2 },
crown: { x: 0.5, y: 0.15 },
nose: { x: 0.5, y: 0.6 },
faceCenterX: 0.5,
},
imageWidth: 200,
imageHeight: 150,
},
});
// 422 because the workspace directory won't exist for this fake jobId
expect(res.statusCode).toBe(422);
});
});
+330
View File
@@ -0,0 +1,330 @@
/**
* Integration tests for the red-eye-removal tool (/api/v1/tools/red-eye-removal).
*
* This tool requires the Python sidecar (MediaPipe face-detection bundle).
* Tests accept both 200 (sidecar running) and 501 (not installed) for the
* processing path while fully testing validation paths that don't depend on
* the sidecar.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const FIXTURES = join(__dirname, "..", "fixtures");
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
const HEIC = readFileSync(join(FIXTURES, "test-200x150.heic"));
const TINY = readFileSync(join(FIXTURES, "test-1x1.png"));
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);
describe("Red Eye Removal", () => {
// ── Processing (AI-dependent) ────────────────────────────────────
it("route exists and responds to POST", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/red-eye-removal",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts default settings", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/red-eye-removal",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
if (res.statusCode === 200) {
const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined();
expect(result.processedSize).toBeGreaterThan(0);
}
if (res.statusCode === 501) {
const result = JSON.parse(res.body);
expect(result.code).toBe("FEATURE_NOT_INSTALLED");
}
}, 60_000);
it("accepts explicit sensitivity and strength", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ sensitivity: 80, strength: 90 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/red-eye-removal",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts explicit format and quality", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ format: "png", quality: 85 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/red-eye-removal",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("processes JPEG input", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.jpg", contentType: "image/jpeg", content: JPG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/red-eye-removal",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("handles HEIC input", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.heic", contentType: "image/heic", content: HEIC },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/red-eye-removal",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("handles 1x1 pixel input", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.png", contentType: "image/png", content: TINY },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/red-eye-removal",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
// AI tool may return 200, 501 (not installed), or 422 (processing error on tiny image)
expect([200, 422, 501]).toContain(res.statusCode);
}, 60_000);
// ── Validation (always testable) ─────────────────────────────────
it("rejects requests without a file", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "settings", content: JSON.stringify({ sensitivity: 50 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/red-eye-removal",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
// 400 (file check runs before tool-installed check) or 501 (tool-installed check first)
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const result = JSON.parse(res.body);
expect(result.error).toMatch(/no image/i);
}
});
it("rejects invalid settings JSON", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: "not valid json{{{" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/red-eye-removal",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const result = JSON.parse(res.body);
expect(result.error).toMatch(/json/i);
}
});
it("rejects sensitivity out of range", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ sensitivity: 200 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/red-eye-removal",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const result = JSON.parse(res.body);
expect(result.error).toMatch(/invalid settings/i);
}
});
it("rejects strength out of range", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ strength: -10 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/red-eye-removal",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const result = JSON.parse(res.body);
expect(result.error).toMatch(/invalid settings/i);
}
});
it("rejects quality out of range", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ quality: 0 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/red-eye-removal",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const result = JSON.parse(res.body);
expect(result.error).toMatch(/invalid settings/i);
}
});
it("rejects unauthenticated requests", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/red-eye-removal",
headers: { "content-type": contentType },
body,
});
expect(res.statusCode).toBe(401);
});
});
+402
View File
@@ -0,0 +1,402 @@
/**
* Integration tests for the remove-background tool (/api/v1/tools/remove-background).
*
* This tool requires the Python sidecar (rembg). Tests accept both 200
* (sidecar running) and 501 (not installed) for the processing path while
* fully testing validation paths that don't depend on the sidecar.
*
* Also covers the /effects sub-route for Phase 2 compositing.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const FIXTURES = join(__dirname, "..", "fixtures");
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
const HEIC = readFileSync(join(FIXTURES, "test-200x150.heic"));
const TINY = readFileSync(join(FIXTURES, "test-1x1.png"));
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);
describe("Remove Background", () => {
// ── Phase 1: Processing (AI-dependent) ───────────────────────────
it("route exists and responds to POST", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/remove-background",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts default settings", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/remove-background",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
if (res.statusCode === 200) {
const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined();
expect(result.maskUrl).toBeDefined();
expect(result.originalUrl).toBeDefined();
expect(result.processedSize).toBeGreaterThan(0);
}
if (res.statusCode === 501) {
const result = JSON.parse(res.body);
expect(result.code).toBe("FEATURE_NOT_INSTALLED");
}
}, 60_000);
it("accepts transparent backgroundType", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ backgroundType: "transparent" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/remove-background",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts color background with blur and shadow settings", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({
backgroundType: "color",
backgroundColor: "#FF0000",
blurEnabled: true,
blurIntensity: 50,
shadowEnabled: true,
shadowOpacity: 60,
}),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/remove-background",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts gradient background settings", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({
backgroundType: "gradient",
gradientColor1: "#FF0000",
gradientColor2: "#0000FF",
gradientAngle: 45,
}),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/remove-background",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("processes JPEG input", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.jpg", contentType: "image/jpeg", content: JPG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/remove-background",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("handles HEIC input", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.heic", contentType: "image/heic", content: HEIC },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/remove-background",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("handles 1x1 pixel input", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.png", contentType: "image/png", content: TINY },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/remove-background",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 422, 501]).toContain(res.statusCode);
}, 60_000);
// ── Phase 2: Effects sub-route ───────────────────────────────────
it("effects route rejects missing settings", async () => {
const { body, contentType } = createMultipartPayload([]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/remove-background/effects",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(400);
const result = JSON.parse(res.body);
expect(result.error).toMatch(/no settings/i);
});
it("effects route rejects invalid settings JSON", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "settings", content: "not json{{" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/remove-background/effects",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(400);
const result = JSON.parse(res.body);
expect(result.error).toMatch(/json/i);
});
it("effects route rejects settings without jobId", async () => {
const { body, contentType } = createMultipartPayload([
{
name: "settings",
content: JSON.stringify({ filename: "test.png" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/remove-background/effects",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(400);
const result = JSON.parse(res.body);
expect(result.error).toMatch(/invalid settings/i);
});
// ── Validation (always testable) ─────────────────────────────────
it("rejects requests without a file", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/remove-background",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const result = JSON.parse(res.body);
expect(result.error).toMatch(/no image/i);
}
});
it("rejects invalid settings JSON", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: "not valid json{{{" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/remove-background",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const result = JSON.parse(res.body);
expect(result.error).toMatch(/json/i);
}
});
it("rejects invalid backgroundType", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ backgroundType: "sparkles" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/remove-background",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const result = JSON.parse(res.body);
expect(result.error).toMatch(/invalid settings/i);
}
});
it("rejects blurIntensity out of range", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ blurIntensity: 200 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/remove-background",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const result = JSON.parse(res.body);
expect(result.error).toMatch(/invalid settings/i);
}
});
it("rejects unauthenticated requests", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/remove-background",
headers: { "content-type": contentType },
body,
});
expect(res.statusCode).toBe(401);
});
});
+274
View File
@@ -0,0 +1,274 @@
/**
* Integration tests for the resize tool (/api/v1/tools/resize).
*
* This is a Sharp-based tool (no AI sidecar). All processing tests should
* return 200. Output dimensions are verified by downloading the result and
* reading metadata with sharp.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import sharp from "sharp";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const FIXTURES = join(__dirname, "..", "fixtures");
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
const HEIC = readFileSync(join(FIXTURES, "test-200x150.heic"));
const TINY = readFileSync(join(FIXTURES, "test-1x1.png"));
const WEBP = readFileSync(join(FIXTURES, "test-50x50.webp"));
let testApp: TestApp;
let app: TestApp["app"];
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
app = testApp.app;
adminToken = await loginAsAdmin(app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
/** Helper: POST to resize, assert 200, download result, return sharp metadata. */
async function resizeAndMeta(
settings: Record<string, unknown>,
file = PNG,
filename = "test.png",
fileCt = "image/png",
) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: fileCt, content: file },
{ name: "settings", content: JSON.stringify(settings) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/resize",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined();
const dlRes = await app.inject({
method: "GET",
url: result.downloadUrl,
headers: { authorization: `Bearer ${adminToken}` },
});
expect(dlRes.statusCode).toBe(200);
return sharp(dlRes.rawPayload).metadata();
}
describe("Resize", () => {
// ── Processing with dimension verification ───────────────────────
it("route exists and responds to POST", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/resize",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(200);
});
it("resizes to explicit width (contain preserves aspect ratio)", async () => {
const meta = await resizeAndMeta({ width: 100 });
expect(meta.width).toBe(100);
// contain fit: 200x150 -> 100 wide means height = 75
expect(meta.height).toBe(75);
});
it("resizes to explicit height (contain preserves aspect ratio)", async () => {
const meta = await resizeAndMeta({ height: 60 });
// contain fit: 200x150 -> 60 tall means width = 80
expect(meta.width).toBe(80);
expect(meta.height).toBe(60);
});
it("resizes to both width and height with contain fit", async () => {
const meta = await resizeAndMeta({ width: 100, height: 100, fit: "contain" });
// contain: fits within 100x100 box, output dimensions match the box
expect(meta.width).toBe(100);
expect(meta.height).toBe(100);
});
it("resizes with cover fit", async () => {
const meta = await resizeAndMeta({ width: 100, height: 100, fit: "cover" });
expect(meta.width).toBe(100);
expect(meta.height).toBe(100);
});
it("resizes with fill fit (stretches)", async () => {
const meta = await resizeAndMeta({ width: 50, height: 200, fit: "fill" });
expect(meta.width).toBe(50);
expect(meta.height).toBe(200);
});
it("resizes with inside fit", async () => {
const meta = await resizeAndMeta({ width: 100, height: 100, fit: "inside" });
// inside: same as contain but never enlarges
expect(meta.width).toBe(100);
expect(meta.height).toBe(75);
});
it("resizes by percentage", async () => {
const meta = await resizeAndMeta({ percentage: 50 });
// 50% of 200x150 = 100x75
expect(meta.width).toBe(100);
expect(meta.height).toBe(75);
});
it("respects withoutEnlargement flag", async () => {
const meta = await resizeAndMeta({ width: 400, height: 300, withoutEnlargement: true });
// Should not enlarge beyond original 200x150
expect(meta.width).toBeLessThanOrEqual(200);
expect(meta.height).toBeLessThanOrEqual(150);
});
it("works with JPEG input", async () => {
const meta = await resizeAndMeta({ width: 50 }, JPG, "test.jpg", "image/jpeg");
expect(meta.width).toBe(50);
expect(meta.height).toBe(50); // 100x100 -> 50x50
});
it("works with WebP input", async () => {
const meta = await resizeAndMeta({ width: 25 }, WEBP, "test.webp", "image/webp");
expect(meta.width).toBe(25);
expect(meta.height).toBe(25); // 50x50 -> 25x25
});
it("handles HEIC input", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.heic", contentType: "image/heic", content: HEIC },
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/resize",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.processedSize).toBeGreaterThan(0);
});
it("handles 1x1 pixel input", async () => {
const meta = await resizeAndMeta(
{ width: 10, height: 10, fit: "fill" },
TINY,
"tiny.png",
"image/png",
);
expect(meta.width).toBe(10);
expect(meta.height).toBe(10);
});
// ── Validation ───────────────────────────────────────────────────
it("rejects requests without a file", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/resize",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(400);
const result = JSON.parse(res.body);
expect(result.error).toMatch(/no image/i);
});
it("rejects invalid settings JSON", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: "not json{{{" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/resize",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(400);
const result = JSON.parse(res.body);
expect(result.error).toMatch(/json/i);
});
it("rejects invalid fit value", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ width: 100, fit: "stretch" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/resize",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(400);
const result = JSON.parse(res.body);
expect(result.error).toMatch(/invalid settings/i);
});
it("rejects unauthenticated requests", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/resize",
headers: { "content-type": contentType },
body,
});
expect(res.statusCode).toBe(401);
});
});
+364
View File
@@ -0,0 +1,364 @@
/**
* Integration tests for the restore-photo tool (/api/v1/tools/restore-photo).
*
* This tool requires the Python sidecar (LaMa / Real-ESRGAN / face enhancement).
* Tests accept both 200 (sidecar running) and 501 (not installed) for the
* processing path while fully testing validation paths.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const FIXTURES = join(__dirname, "..", "fixtures");
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
const HEIC = readFileSync(join(FIXTURES, "test-200x150.heic"));
const TINY = readFileSync(join(FIXTURES, "test-1x1.png"));
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);
describe("Restore Photo", () => {
// ── Processing (AI-dependent) ────────────────────────────────────
it("route exists and responds to POST", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/restore-photo",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts default settings", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/restore-photo",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
if (res.statusCode === 200) {
const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined();
expect(result.processedSize).toBeGreaterThan(0);
}
if (res.statusCode === 501) {
const result = JSON.parse(res.body);
expect(result.code).toBe("FEATURE_NOT_INSTALLED");
}
}, 60_000);
it("accepts auto mode with all features enabled", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({
mode: "auto",
scratchRemoval: true,
faceEnhancement: true,
denoise: true,
denoiseStrength: 40,
}),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/restore-photo",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts heavy mode with colorize enabled", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({
mode: "heavy",
colorize: true,
fidelity: 0.9,
}),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/restore-photo",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts light mode with features disabled", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({
mode: "light",
scratchRemoval: false,
faceEnhancement: false,
denoise: false,
}),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/restore-photo",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("processes JPEG input", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.jpg", contentType: "image/jpeg", content: JPG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/restore-photo",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("handles HEIC input", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.heic", contentType: "image/heic", content: HEIC },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/restore-photo",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("handles 1x1 pixel input", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.png", contentType: "image/png", content: TINY },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/restore-photo",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 422, 501]).toContain(res.statusCode);
}, 60_000);
// ── Validation (always testable) ─────────────────────────────────
it("rejects requests without a file", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/restore-photo",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const result = JSON.parse(res.body);
expect(result.error).toMatch(/no image/i);
}
});
it("rejects invalid settings JSON", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: "{{invalid json" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/restore-photo",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const result = JSON.parse(res.body);
expect(result.error).toMatch(/json/i);
}
});
it("rejects invalid mode value", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ mode: "turbo" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/restore-photo",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const result = JSON.parse(res.body);
expect(result.error).toMatch(/invalid settings/i);
}
});
it("rejects fidelity out of range", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ fidelity: 5.0 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/restore-photo",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const result = JSON.parse(res.body);
expect(result.error).toMatch(/invalid settings/i);
}
});
it("rejects denoiseStrength out of range", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ denoiseStrength: 150 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/restore-photo",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const result = JSON.parse(res.body);
expect(result.error).toMatch(/invalid settings/i);
}
});
it("rejects unauthenticated requests", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/restore-photo",
headers: { "content-type": contentType },
body,
});
expect(res.statusCode).toBe(401);
});
});
+250
View File
@@ -0,0 +1,250 @@
/**
* Integration tests for the rotate tool (/api/v1/tools/rotate).
*
* This is a Sharp-based tool (no AI sidecar). All processing tests should
* return 200. Output dimensions are verified by downloading the result and
* reading metadata with sharp. Rotation by 90/270 swaps width and height.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import sharp from "sharp";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const FIXTURES = join(__dirname, "..", "fixtures");
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
const HEIC = readFileSync(join(FIXTURES, "test-200x150.heic"));
const TINY = readFileSync(join(FIXTURES, "test-1x1.png"));
let testApp: TestApp;
let app: TestApp["app"];
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
app = testApp.app;
adminToken = await loginAsAdmin(app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
/** Helper: POST to rotate, assert 200, download result, return sharp metadata. */
async function rotateAndMeta(
settings: Record<string, unknown>,
file = PNG,
filename = "test.png",
fileCt = "image/png",
) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: fileCt, content: file },
{ name: "settings", content: JSON.stringify(settings) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/rotate",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined();
const dlRes = await app.inject({
method: "GET",
url: result.downloadUrl,
headers: { authorization: `Bearer ${adminToken}` },
});
expect(dlRes.statusCode).toBe(200);
return sharp(dlRes.rawPayload).metadata();
}
describe("Rotate", () => {
// ── Processing with dimension verification ───────────────────────
it("route exists and responds to POST", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({ angle: 90 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/rotate",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(200);
});
it("rotates 90 degrees (swaps width and height)", async () => {
const meta = await rotateAndMeta({ angle: 90 });
// 200x150 rotated 90 -> 150x200
expect(meta.width).toBe(150);
expect(meta.height).toBe(200);
});
it("rotates 180 degrees (dimensions unchanged)", async () => {
const meta = await rotateAndMeta({ angle: 180 });
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
it("rotates 270 degrees (swaps width and height)", async () => {
const meta = await rotateAndMeta({ angle: 270 });
expect(meta.width).toBe(150);
expect(meta.height).toBe(200);
});
it("rotates 0 degrees (no-op)", async () => {
const meta = await rotateAndMeta({ angle: 0 });
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
it("uses default settings (angle: 0, no flip)", async () => {
const meta = await rotateAndMeta({});
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
it("flips horizontally", async () => {
const meta = await rotateAndMeta({ horizontal: true });
// Horizontal flip does not change dimensions
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
it("flips vertically", async () => {
const meta = await rotateAndMeta({ vertical: true });
// Vertical flip does not change dimensions
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
it("flips both horizontal and vertical", async () => {
const meta = await rotateAndMeta({ horizontal: true, vertical: true });
expect(meta.width).toBe(200);
expect(meta.height).toBe(150);
});
it("rotates 90 degrees and flips horizontally", async () => {
const meta = await rotateAndMeta({ angle: 90, horizontal: true });
// 90 degree rotation swaps dimensions, flip preserves them
expect(meta.width).toBe(150);
expect(meta.height).toBe(200);
});
it("rotates negative angle (-90 = 270)", async () => {
const meta = await rotateAndMeta({ angle: -90 });
// -90 degrees is equivalent to 270 degrees
expect(meta.width).toBe(150);
expect(meta.height).toBe(200);
});
it("works with JPEG input", async () => {
const meta = await rotateAndMeta({ angle: 90 }, JPG, "test.jpg", "image/jpeg");
// 100x100 square stays 100x100 after any rotation
expect(meta.width).toBe(100);
expect(meta.height).toBe(100);
});
it("handles HEIC input", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.heic", contentType: "image/heic", content: HEIC },
{ name: "settings", content: JSON.stringify({ angle: 90 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/rotate",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.processedSize).toBeGreaterThan(0);
});
it("handles 1x1 pixel input", async () => {
const meta = await rotateAndMeta({ angle: 90 }, TINY, "tiny.png", "image/png");
expect(meta.width).toBe(1);
expect(meta.height).toBe(1);
});
// ── Validation ───────────────────────────────────────────────────
it("rejects requests without a file", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "settings", content: JSON.stringify({ angle: 90 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/rotate",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(400);
const result = JSON.parse(res.body);
expect(result.error).toMatch(/no image/i);
});
it("rejects invalid settings JSON", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: "not json{{{" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/rotate",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(400);
const result = JSON.parse(res.body);
expect(result.error).toMatch(/json/i);
});
it("rejects unauthenticated requests", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({ angle: 90 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/rotate",
headers: { "content-type": contentType },
body,
});
expect(res.statusCode).toBe(401);
});
});
+400
View File
@@ -0,0 +1,400 @@
/**
* Integration tests for the smart-crop tool (/api/v1/tools/smart-crop).
*
* Smart crop has three modes:
* - subject (Sharp attention/entropy strategy)
* - face (AI face detection via MediaPipe, falls back to subject)
* - trim (Sharp trim with optional pad-to-square)
*
* The "face" mode requires the Python sidecar. "subject" and "trim" are
* Sharp-only and always work. The tool goes through createToolRoute which
* checks TOOL_BUNDLE_MAP, so 501 is possible when the bundle is not installed.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import sharp from "sharp";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const FIXTURES = join(__dirname, "..", "fixtures");
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
const HEIC = readFileSync(join(FIXTURES, "test-200x150.heic"));
const TINY = readFileSync(join(FIXTURES, "test-1x1.png"));
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);
describe("Smart Crop", () => {
// ── Processing ───────────────────────────────────────────────────
it("route exists and responds to POST", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/smart-crop",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts default settings (subject mode)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/smart-crop",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
if (res.statusCode === 200) {
const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined();
expect(result.processedSize).toBeGreaterThan(0);
}
if (res.statusCode === 501) {
const result = JSON.parse(res.body);
expect(result.code).toBe("FEATURE_NOT_INSTALLED");
}
}, 60_000);
it("subject mode with explicit dimensions", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ mode: "subject", width: 100, height: 100 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/smart-crop",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
if (res.statusCode === 200) {
const result = JSON.parse(res.body);
const dlRes = await app.inject({
method: "GET",
url: result.downloadUrl,
headers: { authorization: `Bearer ${adminToken}` },
});
const meta = await sharp(dlRes.rawPayload).metadata();
expect(meta.width).toBe(100);
expect(meta.height).toBe(100);
}
}, 60_000);
it("subject mode with entropy strategy", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({
mode: "subject",
strategy: "entropy",
width: 120,
height: 120,
}),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/smart-crop",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("subject mode with padding", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ mode: "subject", width: 80, height: 80, padding: 10 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/smart-crop",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
if (res.statusCode === 200) {
const result = JSON.parse(res.body);
const dlRes = await app.inject({
method: "GET",
url: result.downloadUrl,
headers: { authorization: `Bearer ${adminToken}` },
});
const meta = await sharp(dlRes.rawPayload).metadata();
expect(meta.width).toBe(80);
expect(meta.height).toBe(80);
}
}, 60_000);
it("face mode (AI-dependent, falls back to subject)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({
mode: "face",
width: 100,
height: 100,
facePreset: "head-shoulders",
}),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/smart-crop",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("trim mode removes whitespace", async () => {
const BLANK = readFileSync(join(FIXTURES, "test-blank.png"));
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test-blank.png", contentType: "image/png", content: BLANK },
{
name: "settings",
content: JSON.stringify({ mode: "trim", threshold: 30 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/smart-crop",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
// trim on a blank image may 422 or succeed with a tiny result
expect([200, 422, 501]).toContain(res.statusCode);
}, 60_000);
it("trim mode with padToSquare and targetSize", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({
mode: "trim",
padToSquare: true,
targetSize: 256,
padColor: "#ffffff",
}),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/smart-crop",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
if (res.statusCode === 200) {
const result = JSON.parse(res.body);
const dlRes = await app.inject({
method: "GET",
url: result.downloadUrl,
headers: { authorization: `Bearer ${adminToken}` },
});
const meta = await sharp(dlRes.rawPayload).metadata();
expect(meta.width).toBe(256);
expect(meta.height).toBe(256);
}
}, 60_000);
it("handles HEIC input", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.heic", contentType: "image/heic", content: HEIC },
{
name: "settings",
content: JSON.stringify({ mode: "subject", width: 100, height: 100 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/smart-crop",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("handles 1x1 pixel input", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.png", contentType: "image/png", content: TINY },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/smart-crop",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 422, 501]).toContain(res.statusCode);
}, 60_000);
// ── Validation (always testable) ─────────────────────────────────
it("rejects requests without a file", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/smart-crop",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(400);
const result = JSON.parse(res.body);
expect(result.error).toMatch(/no image/i);
});
it("rejects invalid settings JSON", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: "not json{{{" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/smart-crop",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(400);
const result = JSON.parse(res.body);
expect(result.error).toMatch(/json/i);
});
it("rejects padding out of range", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ padding: 100 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/smart-crop",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect(res.statusCode).toBe(400);
const result = JSON.parse(res.body);
expect(result.error).toMatch(/invalid settings/i);
});
it("rejects unauthenticated requests", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/smart-crop",
headers: { "content-type": contentType },
body,
});
expect(res.statusCode).toBe(401);
});
});
+305
View File
@@ -0,0 +1,305 @@
/**
* Integration tests for the upscale tool (/api/v1/tools/upscale).
*
* This tool requires the Python sidecar (Real-ESRGAN). Tests accept both
* 200 (sidecar running) and 501 (not installed) for the processing path
* while fully testing validation paths.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const FIXTURES = join(__dirname, "..", "fixtures");
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
const HEIC = readFileSync(join(FIXTURES, "test-200x150.heic"));
const TINY = readFileSync(join(FIXTURES, "test-1x1.png"));
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);
describe("Upscale", () => {
// ── Processing (AI-dependent) ────────────────────────────────────
it("route exists and responds to POST", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/upscale",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts default settings (2x scale)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/upscale",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
if (res.statusCode === 200) {
const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined();
expect(result.processedSize).toBeGreaterThan(0);
expect(result.width).toBeDefined();
expect(result.height).toBeDefined();
expect(result.method).toBeDefined();
}
if (res.statusCode === 501) {
const result = JSON.parse(res.body);
expect(result.code).toBe("FEATURE_NOT_INSTALLED");
}
}, 60_000);
it("accepts explicit scale factor", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ scale: 4 }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/upscale",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts model and faceEnhance options", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({
scale: 2,
model: "auto",
faceEnhance: true,
}),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/upscale",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts denoise and format options", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({
scale: 2,
denoise: 30,
format: "png",
quality: 90,
}),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/upscale",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("accepts scale as a string (coerced to number)", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{
name: "settings",
content: JSON.stringify({ scale: "2" }),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/upscale",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("processes JPEG input", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.jpg", contentType: "image/jpeg", content: JPG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/upscale",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("handles HEIC input", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "photo.heic", contentType: "image/heic", content: HEIC },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/upscale",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 501]).toContain(res.statusCode);
}, 60_000);
it("handles 1x1 pixel input", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "tiny.png", contentType: "image/png", content: TINY },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/upscale",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([200, 422, 501]).toContain(res.statusCode);
}, 60_000);
// ── Validation (always testable) ─────────────────────────────────
it("rejects requests without a file", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/upscale",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const result = JSON.parse(res.body);
expect(result.error).toMatch(/no image/i);
}
});
it("rejects invalid settings JSON", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: "not valid json{{{" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/upscale",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
body,
});
expect([400, 501]).toContain(res.statusCode);
if (res.statusCode === 400) {
const result = JSON.parse(res.body);
expect(result.error).toMatch(/json/i);
}
});
it("rejects unauthenticated requests", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/upscale",
headers: { "content-type": contentType },
body,
});
expect(res.statusCode).toBe(401);
});
});
@@ -0,0 +1,162 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
// ---------------------------------------------------------------------------
// Global mocks
// ---------------------------------------------------------------------------
const revokeObjectURL = vi.fn();
const createObjectURL = vi.fn((_obj: Blob | MediaSource) => "blob:preview-url");
vi.stubGlobal("URL", {
...globalThis.URL,
createObjectURL,
revokeObjectURL,
});
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
vi.stubGlobal("localStorage", {
getItem: vi.fn(() => null),
setItem: vi.fn(),
removeItem: vi.fn(),
clear: vi.fn(),
get length() {
return 0;
},
key: vi.fn(() => null),
});
// ==========================================================================
// fetchDecodedPreview & revokePreviewUrl (image-preview.ts)
// ==========================================================================
import { fetchDecodedPreview, revokePreviewUrl } from "@/lib/image-preview";
describe("fetchDecodedPreview", () => {
beforeEach(() => {
fetchMock.mockReset();
createObjectURL.mockClear();
});
it("sends POST to /api/v1/preview with the file as FormData", async () => {
const blob = new Blob(["image-data"], { type: "image/png" });
fetchMock.mockResolvedValueOnce({
ok: true,
blob: () => Promise.resolve(blob),
});
const file = new File(["heic-data"], "photo.heic", { type: "image/heic" });
const result = await fetchDecodedPreview(file);
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/v1/preview");
expect(opts.method).toBe("POST");
expect(opts.body).toBeInstanceOf(FormData);
const formData = opts.body as FormData;
expect(formData.get("file")).toBe(file);
expect(result).toBe("blob:preview-url");
expect(createObjectURL).toHaveBeenCalledWith(blob);
});
it("returns null when response is not ok", async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 500,
});
const file = new File(["data"], "photo.heic", { type: "image/heic" });
const result = await fetchDecodedPreview(file);
expect(result).toBeNull();
expect(createObjectURL).not.toHaveBeenCalled();
});
it("returns null when fetch throws", async () => {
fetchMock.mockRejectedValueOnce(new TypeError("Network error"));
const file = new File(["data"], "photo.heic", { type: "image/heic" });
const result = await fetchDecodedPreview(file);
expect(result).toBeNull();
});
});
describe("revokePreviewUrl", () => {
beforeEach(() => {
revokeObjectURL.mockClear();
});
it("calls URL.revokeObjectURL with the given URL", () => {
revokePreviewUrl("blob:some-preview-url");
expect(revokeObjectURL).toHaveBeenCalledWith("blob:some-preview-url");
});
it("calls URL.revokeObjectURL for any string", () => {
revokePreviewUrl("blob:another-url");
expect(revokeObjectURL).toHaveBeenCalledWith("blob:another-url");
expect(revokeObjectURL).toHaveBeenCalledTimes(1);
});
});
// ==========================================================================
// triggerDownload (download.ts)
// ==========================================================================
import { triggerDownload } from "@/lib/download";
describe("triggerDownload", () => {
beforeEach(() => {
// Remove any leftover anchor tags from body
for (const a of document.body.querySelectorAll("a")) {
a.remove();
}
});
it("creates an anchor element, clicks it, and removes it", () => {
const clickSpy = vi.fn();
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
const el = originalCreateElement(tag);
if (tag === "a") {
vi.spyOn(el, "click").mockImplementation(clickSpy);
}
return el;
});
triggerDownload("blob:download-url", "output.png");
expect(clickSpy).toHaveBeenCalledTimes(1);
// The anchor should have been removed from the document after click
expect(document.body.querySelectorAll("a")).toHaveLength(0);
vi.restoreAllMocks();
});
it("sets the href and download attributes on the anchor", () => {
let capturedHref = "";
let capturedDownload = "";
const originalCreateElement = document.createElement.bind(document);
vi.spyOn(document, "createElement").mockImplementation((tag: string) => {
const el = originalCreateElement(tag);
if (tag === "a") {
vi.spyOn(el, "click").mockImplementation(() => {
capturedHref = el.getAttribute("href") ?? "";
capturedDownload = el.getAttribute("download") ?? "";
});
}
return el;
});
triggerDownload("blob:file-url", "result.webp");
expect(capturedHref).toBe("blob:file-url");
expect(capturedDownload).toBe("result.webp");
vi.restoreAllMocks();
});
});
+363
View File
@@ -0,0 +1,363 @@
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
// ---------------------------------------------------------------------------
// Mock all lazy-loaded tool settings components so we don't pull in the
// entire React component tree. Each dynamic import returns a minimal stub.
// ---------------------------------------------------------------------------
vi.mock("@/components/tools/resize-settings", () => ({
ResizeSettings: () => null,
}));
vi.mock("@/components/tools/crop-settings", () => ({
CropSettings: () => null,
}));
vi.mock("@/components/tools/rotate-settings", () => ({
RotateSettings: () => null,
}));
vi.mock("@/components/tools/convert-settings", () => ({
ConvertSettings: () => null,
}));
vi.mock("@/components/tools/compress-settings", () => ({
CompressSettings: () => null,
}));
vi.mock("@/components/tools/optimize-for-web-settings", () => ({
OptimizeForWebSettings: () => null,
}));
vi.mock("@/components/tools/strip-metadata-settings", () => ({
StripMetadataSettings: () => null,
}));
vi.mock("@/components/tools/edit-metadata-settings", () => ({
EditMetadataSettings: () => null,
}));
vi.mock("@/components/tools/color-settings", () => ({
ColorSettings: () => null,
}));
vi.mock("@/components/tools/sharpening-settings", () => ({
SharpeningSettings: () => null,
}));
vi.mock("@/components/tools/watermark-text-settings", () => ({
WatermarkTextSettings: () => null,
}));
vi.mock("@/components/tools/watermark-image-settings", () => ({
WatermarkImageSettings: () => null,
}));
vi.mock("@/components/tools/text-overlay-settings", () => ({
TextOverlaySettings: () => null,
}));
vi.mock("@/components/tools/compose-settings", () => ({
ComposeSettings: () => null,
}));
vi.mock("@/components/tools/info-settings", () => ({
InfoSettings: () => null,
}));
vi.mock("@/components/tools/compare-settings", () => ({
CompareSettings: () => null,
}));
vi.mock("@/components/tools/find-duplicates-settings", () => ({
FindDuplicatesSettings: () => null,
}));
vi.mock("@/components/tools/find-duplicates-results", () => ({
FindDuplicatesResults: () => null,
}));
vi.mock("@/components/tools/color-palette-settings", () => ({
ColorPaletteSettings: () => null,
}));
vi.mock("@/components/tools/qr-generate-settings", () => ({
QrGenerateSettings: () => null,
}));
vi.mock("@/components/tools/qr-generate-preview", () => ({
QrGeneratePreview: () => null,
}));
vi.mock("@/components/tools/barcode-read-settings", () => ({
BarcodeReadSettings: () => null,
}));
vi.mock("@/components/tools/image-to-base64-settings", () => ({
ImageToBase64Settings: () => null,
}));
vi.mock("@/components/tools/image-to-base64-results", () => ({
ImageToBase64Results: () => null,
}));
vi.mock("@/components/tools/collage-settings", () => ({
CollageSettings: () => null,
}));
vi.mock("@/components/tools/collage-preview", () => ({
CollagePreview: () => null,
}));
vi.mock("@/components/tools/stitch-settings", () => ({
StitchSettings: () => null,
}));
vi.mock("@/components/tools/split-settings", () => ({
SplitSettings: () => null,
}));
vi.mock("@/components/tools/split-canvas", () => ({
SplitCanvas: () => null,
}));
vi.mock("@/components/tools/border-settings", () => ({
BorderSettings: () => null,
}));
vi.mock("@/components/tools/svg-to-raster-settings", () => ({
SvgToRasterSettings: () => null,
}));
vi.mock("@/components/tools/vectorize-settings", () => ({
VectorizeSettings: () => null,
}));
vi.mock("@/components/tools/gif-tools-settings", () => ({
GifToolsSettings: () => null,
}));
vi.mock("@/components/tools/bulk-rename-settings", () => ({
BulkRenameSettings: () => null,
}));
vi.mock("@/components/tools/favicon-settings", () => ({
FaviconSettings: () => null,
}));
vi.mock("@/components/tools/image-to-pdf-settings", () => ({
ImageToPdfSettings: () => null,
}));
vi.mock("@/components/tools/pdf-to-image-settings", () => ({
PdfToImageSettings: () => null,
}));
vi.mock("@/components/tools/pdf-to-image-preview", () => ({
PdfToImagePreview: () => null,
}));
vi.mock("@/components/tools/replace-color-settings", () => ({
ReplaceColorSettings: () => null,
}));
vi.mock("@/components/tools/remove-bg-settings", () => ({
RemoveBgSettings: () => null,
}));
vi.mock("@/components/tools/upscale-settings", () => ({
UpscaleSettings: () => null,
}));
vi.mock("@/components/tools/ocr-settings", () => ({
OcrSettings: () => null,
}));
vi.mock("@/components/tools/blur-faces-settings", () => ({
BlurFacesSettings: () => null,
}));
vi.mock("@/components/tools/enhance-faces-settings", () => ({
EnhanceFacesSettings: () => null,
}));
vi.mock("@/components/tools/erase-object-settings", () => ({
EraseObjectSettings: () => null,
}));
vi.mock("@/components/tools/smart-crop-settings", () => ({
SmartCropSettings: () => null,
}));
vi.mock("@/components/tools/image-enhancement-settings", () => ({
ImageEnhancementSettings: () => null,
}));
vi.mock("@/components/tools/colorize-settings", () => ({
ColorizeSettings: () => null,
}));
vi.mock("@/components/tools/noise-removal-settings", () => ({
NoiseRemovalSettings: () => null,
}));
vi.mock("@/components/tools/passport-photo-settings", () => ({
PassportPhotoSettings: () => null,
PassportPhotoPreview: () => null,
}));
vi.mock("@/components/tools/red-eye-removal-settings", () => ({
RedEyeRemovalSettings: () => null,
}));
vi.mock("@/components/tools/restore-photo-settings", () => ({
RestorePhotoSettings: () => null,
}));
// ---------------------------------------------------------------------------
// Import after mocks
// ---------------------------------------------------------------------------
import type { DisplayMode } from "@/lib/tool-registry";
import { getToolRegistryEntry, toolRegistry } from "@/lib/tool-registry";
// ==========================================================================
// toolRegistry (Map)
// ==========================================================================
describe("toolRegistry", () => {
it("is a Map with entries", () => {
expect(toolRegistry).toBeInstanceOf(Map);
expect(toolRegistry.size).toBeGreaterThan(0);
});
it("contains all expected essential tool IDs", () => {
const essentials = [
"resize",
"crop",
"rotate",
"convert",
"compress",
"strip-metadata",
"edit-metadata",
];
for (const id of essentials) {
expect(toolRegistry.has(id), `missing tool: ${id}`).toBe(true);
}
});
it("contains AI tool IDs", () => {
const aiTools = [
"remove-background",
"upscale",
"ocr",
"blur-faces",
"enhance-faces",
"erase-object",
"smart-crop",
"image-enhancement",
"colorize",
"noise-removal",
"passport-photo",
"red-eye-removal",
"restore-photo",
];
for (const id of aiTools) {
expect(toolRegistry.has(id), `missing AI tool: ${id}`).toBe(true);
}
});
it("contains layout and composition tools", () => {
const layoutTools = ["collage", "stitch", "split", "border"];
for (const id of layoutTools) {
expect(toolRegistry.has(id), `missing layout tool: ${id}`).toBe(true);
}
});
it("contains utility tools", () => {
const utilityTools = [
"info",
"compare",
"find-duplicates",
"color-palette",
"qr-generate",
"barcode-read",
"image-to-base64",
];
for (const id of utilityTools) {
expect(toolRegistry.has(id), `missing utility tool: ${id}`).toBe(true);
}
});
it("contains format and conversion tools", () => {
const formatTools = [
"svg-to-raster",
"vectorize",
"gif-tools",
"bulk-rename",
"favicon",
"image-to-pdf",
"pdf-to-image",
"optimize-for-web",
];
for (const id of formatTools) {
expect(toolRegistry.has(id), `missing format tool: ${id}`).toBe(true);
}
});
it("every entry has a valid displayMode", () => {
const validModes: DisplayMode[] = [
"side-by-side",
"before-after",
"live-preview",
"no-comparison",
"interactive-crop",
"interactive-eraser",
"interactive-split",
"no-dropzone",
"custom-results",
];
for (const [toolId, entry] of toolRegistry) {
expect(validModes, `invalid displayMode for ${toolId}`).toContain(entry.displayMode);
}
});
it("every entry has a Settings component", () => {
for (const [toolId, entry] of toolRegistry) {
expect(entry.Settings, `missing Settings for ${toolId}`).toBeDefined();
expect(["function", "object"]).toContain(typeof entry.Settings);
}
});
it("tools with custom-results display mode have a ResultsPanel", () => {
for (const [toolId, entry] of toolRegistry) {
if (entry.displayMode === "custom-results") {
expect(entry.ResultsPanel, `missing ResultsPanel for ${toolId}`).toBeDefined();
}
}
});
it("tools with no-dropzone display mode have a ResultsPanel", () => {
for (const [toolId, entry] of toolRegistry) {
if (entry.displayMode === "no-dropzone") {
expect(entry.ResultsPanel, `missing ResultsPanel for ${toolId}`).toBeDefined();
}
}
});
it("rotate has livePreview enabled", () => {
const rotate = toolRegistry.get("rotate");
expect(rotate?.livePreview).toBe(true);
});
it("adjust-colors has livePreview enabled", () => {
const adjustColors = toolRegistry.get("adjust-colors");
expect(adjustColors?.livePreview).toBe(true);
});
it("border has livePreview enabled", () => {
const border = toolRegistry.get("border");
expect(border?.livePreview).toBe(true);
});
it("crop uses interactive-crop display mode", () => {
const crop = toolRegistry.get("crop");
expect(crop?.displayMode).toBe("interactive-crop");
});
it("erase-object uses interactive-eraser display mode", () => {
const eraseObject = toolRegistry.get("erase-object");
expect(eraseObject?.displayMode).toBe("interactive-eraser");
});
it("split uses interactive-split display mode", () => {
const split = toolRegistry.get("split");
expect(split?.displayMode).toBe("interactive-split");
});
});
// ==========================================================================
// getToolRegistryEntry
// ==========================================================================
describe("getToolRegistryEntry", () => {
it("returns the entry for a known tool", () => {
const entry = getToolRegistryEntry("resize");
expect(entry).toBeDefined();
expect(entry?.displayMode).toBe("side-by-side");
});
it("returns undefined for an unknown tool", () => {
expect(getToolRegistryEntry("nonexistent-tool")).toBeUndefined();
});
it("returns the correct entry for compress", () => {
const entry = getToolRegistryEntry("compress");
expect(entry).toBeDefined();
expect(entry?.displayMode).toBe("before-after");
});
it("returns entry with ResultsPanel for find-duplicates", () => {
const entry = getToolRegistryEntry("find-duplicates");
expect(entry).toBeDefined();
expect(entry?.displayMode).toBe("custom-results");
expect(entry?.ResultsPanel).toBeDefined();
});
it("returns entry with ResultsPanel for qr-generate", () => {
const entry = getToolRegistryEntry("qr-generate");
expect(entry).toBeDefined();
expect(entry?.displayMode).toBe("no-dropzone");
expect(entry?.ResultsPanel).toBeDefined();
});
});