From 06b12f19d2ac773d29a85c92e7f16f094149fde4 Mon Sep 17 00:00:00 2001 From: SnapOtter Date: Sat, 25 Apr 2026 21:53:13 +0800 Subject: [PATCH] =?UTF-8?q?test:=20major=20coverage=20expansion=20?= =?UTF-8?q?=E2=80=94=2018=20new=20test=20files,=20~750=20new=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../integration/adversarial-extended.test.ts | 1341 +++++++++++++++++ tests/integration/blur-faces.test.ts | 273 ++++ tests/integration/colorize.test.ts | 272 ++++ tests/integration/crop.test.ts | 335 ++++ tests/integration/enhance-faces.test.ts | 272 ++++ tests/integration/erase-object.test.ts | 251 +++ tests/integration/noise-removal.test.ts | 279 ++++ tests/integration/ocr.test.ts | 272 ++++ tests/integration/passport-photo.test.ts | 340 +++++ tests/integration/red-eye-removal.test.ts | 330 ++++ tests/integration/remove-background.test.ts | 402 +++++ tests/integration/resize.test.ts | 274 ++++ tests/integration/restore-photo.test.ts | 364 +++++ tests/integration/rotate.test.ts | 250 +++ tests/integration/smart-crop.test.ts | 400 +++++ tests/integration/upscale.test.ts | 305 ++++ tests/unit/web/image-preview-download.test.ts | 162 ++ tests/unit/web/tool-registry.test.ts | 363 +++++ 18 files changed, 6485 insertions(+) create mode 100644 tests/integration/adversarial-extended.test.ts create mode 100644 tests/integration/blur-faces.test.ts create mode 100644 tests/integration/colorize.test.ts create mode 100644 tests/integration/crop.test.ts create mode 100644 tests/integration/enhance-faces.test.ts create mode 100644 tests/integration/erase-object.test.ts create mode 100644 tests/integration/noise-removal.test.ts create mode 100644 tests/integration/ocr.test.ts create mode 100644 tests/integration/passport-photo.test.ts create mode 100644 tests/integration/red-eye-removal.test.ts create mode 100644 tests/integration/remove-background.test.ts create mode 100644 tests/integration/resize.test.ts create mode 100644 tests/integration/restore-photo.test.ts create mode 100644 tests/integration/rotate.test.ts create mode 100644 tests/integration/smart-crop.test.ts create mode 100644 tests/integration/upscale.test.ts create mode 100644 tests/unit/web/image-preview-download.test.ts create mode 100644 tests/unit/web/tool-registry.test.ts diff --git a/tests/integration/adversarial-extended.test.ts b/tests/integration/adversarial-extended.test.ts new file mode 100644 index 00000000..b80c82e9 --- /dev/null +++ b/tests/integration/adversarial-extended.test.ts @@ -0,0 +1,1341 @@ +/** + * Extended adversarial integration tests for the SnapOtter image API. + * + * Covers: zero-byte uploads across tools, corrupted headers / wrong magic + * bytes, unicode filenames, extreme dimensions through various tools, batch + * edge cases, pipeline edge cases, and concurrent stress scenarios. + * + * Complements adversarial.test.ts (32 tests), edge-cases.test.ts (24 tests), + * and concurrent.test.ts. + */ + +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"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- +const FIXTURES = join(__dirname, "..", "fixtures"); +const PNG_200x150 = readFileSync(join(FIXTURES, "test-200x150.png")); +const PNG_1x1 = readFileSync(join(FIXTURES, "test-1x1.png")); +const JPG_100x100 = readFileSync(join(FIXTURES, "test-100x100.jpg")); + +// --------------------------------------------------------------------------- +// Shared state +// --------------------------------------------------------------------------- +let testApp: TestApp; +let app: TestApp["app"]; +let adminToken: string; + +beforeAll(async () => { + testApp = await buildTestApp(); + app = testApp.app; + adminToken = await loginAsAdmin(app); +}, 30_000); + +afterAll(async () => { + await testApp.cleanup(); +}, 10_000); + +/** Helper to POST a multipart payload to a tool endpoint. */ +function postTool( + toolId: string, + fields: Array<{ + name: string; + filename?: string; + contentType?: string; + content: Buffer | string; + }>, +) { + const { body, contentType } = createMultipartPayload(fields); + return app.inject({ + method: "POST", + url: `/api/v1/tools/${toolId}`, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + body, + }); +} + +/** Helper to POST a batch request. */ +function postBatch( + toolId: string, + fields: Array<{ + name: string; + filename?: string; + contentType?: string; + content: Buffer | string; + }>, +) { + const { body, contentType } = createMultipartPayload(fields); + return app.inject({ + method: "POST", + url: `/api/v1/tools/${toolId}/batch`, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + body, + }); +} + +/** Helper to POST a pipeline execution request. */ +function executePipeline( + image: Buffer, + filename: string, + pipeline: { + steps: Array<{ toolId: string; settings?: Record }>; + }, +) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename, content: image, contentType: "image/png" }, + { name: "pipeline", content: JSON.stringify(pipeline) }, + ]); + return app.inject({ + method: "POST", + url: "/api/v1/pipeline/execute", + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + body, + }); +} + +/** Helper to build an inject config for a tool request. */ +function buildToolRequest( + toolId: string, + image: Buffer, + filename: string, + settings: Record, +) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename, content: image, contentType: "image/png" }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + return { + method: "POST" as const, + url: `/api/v1/tools/${toolId}`, + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + body, + }; +} + +// ═══════════════════════════════════════════════════════════════════════════ +// ZERO-BYTE FILES ACROSS MULTIPLE TOOLS +// ═══════════════════════════════════════════════════════════════════════════ +describe("Zero-byte file uploads across tools", () => { + const zeroBuffer = Buffer.alloc(0); + + it("rejects a 0-byte file to /api/v1/tools/resize with 400", async () => { + const res = await postTool("resize", [ + { + name: "file", + filename: "empty.png", + content: zeroBuffer, + contentType: "image/png", + }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toBeDefined(); + }); + + it("rejects a 0-byte file to /api/v1/tools/compress with 400", async () => { + const res = await postTool("compress", [ + { + name: "file", + filename: "empty.jpg", + content: zeroBuffer, + contentType: "image/jpeg", + }, + { name: "settings", content: JSON.stringify({ quality: 80 }) }, + ]); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toBeDefined(); + }); + + it("rejects a 0-byte file to /api/v1/tools/convert with 400", async () => { + const res = await postTool("convert", [ + { + name: "file", + filename: "empty.png", + content: zeroBuffer, + contentType: "image/png", + }, + { name: "settings", content: JSON.stringify({ format: "jpg" }) }, + ]); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toBeDefined(); + }); + + it("rejects a 0-byte file to /api/v1/tools/rotate with 400", async () => { + const res = await postTool("rotate", [ + { + name: "file", + filename: "empty.webp", + content: zeroBuffer, + contentType: "image/webp", + }, + { name: "settings", content: JSON.stringify({ angle: 90 }) }, + ]); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toBeDefined(); + }); + + it("rejects a 0-byte file to /api/v1/tools/crop with 400", async () => { + const res = await postTool("crop", [ + { + name: "file", + filename: "empty.png", + content: zeroBuffer, + contentType: "image/png", + }, + { + name: "settings", + content: JSON.stringify({ + left: 0, + top: 0, + width: 10, + height: 10, + }), + }, + ]); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toBeDefined(); + }); + + it("rejects a 0-byte file to /api/v1/tools/border with 400", async () => { + const res = await postTool("border", [ + { + name: "file", + filename: "empty.png", + content: zeroBuffer, + contentType: "image/png", + }, + { name: "settings", content: JSON.stringify({ borderWidth: 10 }) }, + ]); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toBeDefined(); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// CORRUPTED HEADERS / WRONG MAGIC BYTES +// ═══════════════════════════════════════════════════════════════════════════ +describe("Corrupted headers and wrong magic bytes", () => { + it("handles PNG magic bytes followed by garbage gracefully", async () => { + // PNG signature: 89 50 4E 47 0D 0A 1A 0A + const corruptedPng = Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + Buffer.from( + "THIS IS GARBAGE CONTENT AFTER A VALID PNG HEADER " + + "AAAA BBBB CCCC DDDD EEEE FFFF 0000 1111 2222 3333", + ), + ]); + + const res = await postTool("resize", [ + { + name: "file", + filename: "corrupt-png.png", + content: corruptedPng, + contentType: "image/png", + }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + // Should fail gracefully with a JSON error, NOT crash the server + expect([400, 422]).toContain(res.statusCode); + const json = JSON.parse(res.body); + expect(json.error).toBeDefined(); + }); + + it("handles JPEG SOI marker followed by truncated data gracefully", async () => { + // JPEG starts with FF D8 (SOI), typically followed by FF E0 (APP0) + const truncatedJpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46]); + + const res = await postTool("compress", [ + { + name: "file", + filename: "truncated.jpg", + content: truncatedJpeg, + contentType: "image/jpeg", + }, + { name: "settings", content: JSON.stringify({ quality: 50 }) }, + ]); + + // Must not crash; either 200 (partial decode), 400, or 422 + expect([200, 400, 422]).toContain(res.statusCode); + }); + + it("handles a .jpg file containing actual PNG data (format mismatch)", async () => { + // Sharp auto-detects via magic bytes, so this should process fine + const res = await postTool("compress", [ + { + name: "file", + filename: "actually-png.jpg", + content: PNG_200x150, + contentType: "image/jpeg", + }, + { name: "settings", content: JSON.stringify({ quality: 75 }) }, + ]); + + // Sharp detects the real format via magic bytes regardless of extension + expect(res.statusCode).toBe(200); + }); + + it("rejects a BMP header followed by garbage", async () => { + // BMP magic bytes: 42 4D + const fakeBmp = Buffer.concat([Buffer.from([0x42, 0x4d]), Buffer.alloc(100, 0xff)]); + + const res = await postTool("resize", [ + { + name: "file", + filename: "fake.bmp", + content: fakeBmp, + contentType: "image/bmp", + }, + { name: "settings", content: JSON.stringify({ width: 50 }) }, + ]); + + // BMP is not a supported format OR the garbage content fails validation + expect([400, 422]).toContain(res.statusCode); + }); + + it("rejects a WebP RIFF header followed by garbage", async () => { + // WebP starts with RIFF....WEBP + const fakeWebp = Buffer.concat([ + Buffer.from("RIFF"), + Buffer.from([0x00, 0x00, 0x00, 0x00]), + Buffer.from("WEBP"), + Buffer.alloc(50, 0xab), + ]); + + const res = await postTool("resize", [ + { + name: "file", + filename: "corrupt.webp", + content: fakeWebp, + contentType: "image/webp", + }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + expect([400, 422]).toContain(res.statusCode); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// UNICODE FILENAMES (EXTENDED) +// ═══════════════════════════════════════════════════════════════════════════ +describe("Unicode filenames — extended adversarial", () => { + it("handles filename with emoji: photo_\u{1F389}.png", async () => { + const res = await postTool("resize", [ + { + name: "file", + filename: "photo_\u{1F389}.png", + content: PNG_200x150, + contentType: "image/png", + }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.downloadUrl).toBeDefined(); + }); + + it("handles filename with CJK characters: 写真.png", async () => { + const res = await postTool("resize", [ + { + name: "file", + filename: "写真.png", + content: PNG_200x150, + contentType: "image/png", + }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + expect(res.statusCode).toBe(200); + }); + + it("handles filename with spaces and special chars: my photo (final).png", async () => { + const res = await postTool("resize", [ + { + name: "file", + filename: "my photo (final).png", + content: PNG_200x150, + contentType: "image/png", + }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + expect(res.statusCode).toBe(200); + }); + + it("sanitizes path traversal: ../../../etc/passwd.png", async () => { + const res = await postTool("resize", [ + { + name: "file", + filename: "../../../etc/passwd.png", + content: PNG_200x150, + contentType: "image/png", + }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.downloadUrl).not.toContain(".."); + expect(json.downloadUrl).not.toContain("etc/passwd"); + }); + + it("handles filename with mixed RTL and LTR characters", async () => { + const res = await postTool("resize", [ + { + name: "file", + filename: "ملف_photo_الصورة.png", + content: PNG_200x150, + contentType: "image/png", + }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + expect(res.statusCode).toBe(200); + }); + + it("handles filename with null bytes stripped", async () => { + const res = await postTool("resize", [ + { + name: "file", + filename: "test\x00hidden.png", + content: PNG_200x150, + contentType: "image/png", + }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + // Should succeed — null bytes are stripped by filename sanitization + expect([200, 400]).toContain(res.statusCode); + }); + + it("handles filename with only an extension", async () => { + const res = await postTool("resize", [ + { + name: "file", + filename: ".png", + content: PNG_200x150, + contentType: "image/png", + }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + expect(res.statusCode).toBe(200); + }); + + it("handles filename with double extensions", async () => { + const res = await postTool("resize", [ + { + name: "file", + filename: "exploit.php.png", + content: PNG_200x150, + contentType: "image/png", + }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + // The download URL retains "exploit.php" in the base name but the final + // extension is still a safe image format (.png). The key security check + // is that the URL ends with a recognized image extension. + expect(json.downloadUrl).toMatch(/\.(png|jpg|jpeg|webp|avif|tiff|gif)$/); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// EXTREME DIMENSIONS THROUGH VARIOUS TOOLS +// ═══════════════════════════════════════════════════════════════════════════ +describe("1x1 pixel image through additional tools", () => { + it("upscales a 1x1 pixel image to 100x100 via resize", async () => { + const res = await postTool("resize", [ + { + name: "file", + filename: "tiny.png", + content: PNG_1x1, + contentType: "image/png", + }, + { + name: "settings", + content: JSON.stringify({ width: 100, height: 100 }), + }, + ]); + + expect(res.statusCode).toBe(200); + }); + + it("applies crop to a 1x1 pixel image (1x1 crop region)", async () => { + const res = await postTool("crop", [ + { + name: "file", + filename: "tiny.png", + content: PNG_1x1, + contentType: "image/png", + }, + { + name: "settings", + content: JSON.stringify({ left: 0, top: 0, width: 1, height: 1 }), + }, + ]); + + // Should succeed or fail gracefully + expect([200, 422]).toContain(res.statusCode); + }); + + it("applies border to a 1x1 pixel image", async () => { + const res = await postTool("border", [ + { + name: "file", + filename: "tiny.png", + content: PNG_1x1, + contentType: "image/png", + }, + { + name: "settings", + content: JSON.stringify({ + borderWidth: 20, + borderColor: "#FF0000", + }), + }, + ]); + + expect(res.statusCode).toBe(200); + }); + + it("applies watermark-text to a 1x1 pixel image", async () => { + const res = await postTool("watermark-text", [ + { + name: "file", + filename: "tiny.png", + content: PNG_1x1, + contentType: "image/png", + }, + { + name: "settings", + content: JSON.stringify({ + text: "WATERMARK", + fontSize: 12, + opacity: 50, + }), + }, + ]); + + // The watermark will be much larger than the 1x1 image — may succeed + // or fail depending on how Sharp handles the composite. Must not crash. + expect([200, 400, 422]).toContain(res.statusCode); + }); + + it("applies sharpening to a 1x1 pixel image", async () => { + const res = await postTool("sharpening", [ + { + name: "file", + filename: "tiny.png", + content: PNG_1x1, + contentType: "image/png", + }, + { + name: "settings", + content: JSON.stringify({ method: "adaptive" }), + }, + ]); + + // Sharpening a 1x1 image is a no-op but should not crash + expect([200, 422]).toContain(res.statusCode); + }); + + it("applies text-overlay to a 1x1 pixel image", async () => { + const res = await postTool("text-overlay", [ + { + name: "file", + filename: "tiny.png", + content: PNG_1x1, + contentType: "image/png", + }, + { + name: "settings", + content: JSON.stringify({ + text: "Hello", + fontSize: 24, + }), + }, + ]); + + // Text overlay on 1x1 image — the SVG overlay will dwarf the image + expect([200, 422]).toContain(res.statusCode); + }); + + it("converts a 1x1 pixel image from PNG to WebP", async () => { + const res = await postTool("convert", [ + { + name: "file", + filename: "tiny.png", + content: PNG_1x1, + contentType: "image/png", + }, + { name: "settings", content: JSON.stringify({ format: "webp" }) }, + ]); + + expect([200, 400]).toContain(res.statusCode); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// BATCH EDGE CASES (EXTENDED) +// ═══════════════════════════════════════════════════════════════════════════ +describe("Batch edge cases — extended", () => { + it("rejects batch with 0 images uploaded to resize", async () => { + const res = await postBatch("resize", [ + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toMatch(/no image/i); + }); + + it("handles batch with duplicate filenames", async () => { + const res = await postBatch("resize", [ + { + name: "file", + filename: "duplicate.png", + contentType: "image/png", + content: PNG_200x150, + }, + { + name: "file", + filename: "duplicate.png", + contentType: "image/png", + content: PNG_200x150, + }, + { + name: "file", + filename: "duplicate.png", + contentType: "image/png", + content: PNG_200x150, + }, + { name: "settings", content: JSON.stringify({ width: 50 }) }, + ]); + + // Should succeed and deduplicate filenames in the ZIP + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toBe("application/zip"); + const fileResults = JSON.parse(res.headers["x-file-results"] as string); + // All three entries should have unique names + const names = Object.values(fileResults); + expect(new Set(names).size).toBe(3); + }); + + it("handles batch with mix of valid and invalid files", async () => { + const garbage = Buffer.from("this is not an image at all"); + + const res = await postBatch("resize", [ + { + name: "file", + filename: "valid.png", + contentType: "image/png", + content: PNG_200x150, + }, + { + name: "file", + filename: "invalid.png", + contentType: "image/png", + content: garbage, + }, + { + name: "file", + filename: "also-valid.jpg", + contentType: "image/jpeg", + content: JPG_100x100, + }, + { name: "settings", content: JSON.stringify({ width: 50 }) }, + ]); + + // Batch should process valid files and skip invalid ones. + // Since not all files failed, it should return 200 with a ZIP. + expect(res.statusCode).toBe(200); + expect(res.headers["content-type"]).toBe("application/zip"); + }); + + it("rejects batch where ALL files are invalid", async () => { + const garbage1 = Buffer.from("not an image 1"); + const garbage2 = Buffer.from("not an image 2"); + + const res = await postBatch("resize", [ + { + name: "file", + filename: "bad1.png", + contentType: "image/png", + content: garbage1, + }, + { + name: "file", + filename: "bad2.png", + contentType: "image/png", + content: garbage2, + }, + { name: "settings", content: JSON.stringify({ width: 50 }) }, + ]); + + // All files failed — should return 422 + expect(res.statusCode).toBe(422); + const json = JSON.parse(res.body); + expect(json.error).toMatch(/all files failed/i); + expect(json.errors).toBeDefined(); + expect(json.errors.length).toBe(2); + }); + + it("handles batch with zero-byte files (skipped as empty)", async () => { + // Batch processing silently skips zero-byte parts (buffer.length === 0) + const res = await postBatch("resize", [ + { + name: "file", + filename: "empty.png", + contentType: "image/png", + content: Buffer.alloc(0), + }, + { name: "settings", content: JSON.stringify({ width: 50 }) }, + ]); + + // The zero-byte file is skipped, resulting in 0 valid files + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toMatch(/no image/i); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// PIPELINE EDGE CASES (EXTENDED) +// ═══════════════════════════════════════════════════════════════════════════ +describe("Pipeline edge cases — extended", () => { + it("rejects pipeline with 0 steps", async () => { + const res = await executePipeline(PNG_200x150, "test.png", { + steps: [], + }); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toMatch(/pipeline/i); + }); + + it("handles pipeline with conflicting resize then crop larger than result", async () => { + // Resize to 50x50, then crop region 200x200 — should fail at crop step + const res = await executePipeline(PNG_200x150, "test.png", { + steps: [ + { toolId: "resize", settings: { width: 50, height: 50 } }, + { + toolId: "crop", + settings: { left: 0, top: 0, width: 200, height: 200 }, + }, + ], + }); + + // Crop exceeds resized dimensions — should fail gracefully (422) + // or succeed if sharp auto-clips. Must not crash. + expect([200, 422]).toContain(res.statusCode); + }); + + it("rejects pipeline step with unknown tool name", async () => { + const res = await executePipeline(PNG_200x150, "test.png", { + steps: [{ toolId: "resize", settings: { width: 100 } }, { toolId: "imaginary-tool-xyz" }], + }); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toMatch(/not found/i); + }); + + it("handles pipeline with resize then border then compress chain", async () => { + const res = await executePipeline(PNG_200x150, "test.png", { + steps: [ + { toolId: "resize", settings: { width: 80 } }, + { + toolId: "border", + settings: { borderWidth: 5, borderColor: "#FF0000" }, + }, + { toolId: "compress", settings: { quality: 50 } }, + ], + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.stepsCompleted).toBe(3); + }); + + it("rejects pipeline with empty settings object for a required-field tool", async () => { + // watermark-text requires 'text' (min length 1) + const res = await executePipeline(PNG_200x150, "test.png", { + steps: [{ toolId: "watermark-text", settings: {} }], + }); + + // Zod validation should reject the missing required 'text' field + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toMatch(/step 1/i); + }); + + it("handles pipeline that outputs to a different format mid-chain", async () => { + // Convert to webp, then resize — should work since Sharp handles webp + const res = await executePipeline(PNG_200x150, "test.png", { + steps: [ + { toolId: "convert", settings: { format: "webp" } }, + { toolId: "resize", settings: { width: 50 } }, + ], + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.stepsCompleted).toBe(2); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// CONCURRENT STRESS — EXTENDED +// ═══════════════════════════════════════════════════════════════════════════ +describe("Concurrent stress — 10 simultaneous resize requests", () => { + it("fires 10 simultaneous resize requests with the same image — all return 200", async () => { + const results = await Promise.all( + Array.from({ length: 10 }, (_, i) => + app.inject( + buildToolRequest("resize", PNG_200x150, `stress-${i}.png`, { + width: 50, + }), + ), + ), + ); + + for (const res of results) { + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.jobId).toBeDefined(); + expect(json.downloadUrl).toBeDefined(); + } + + // All 10 must produce unique job IDs + const jobIds = results.map((r) => JSON.parse(r.body).jobId); + expect(new Set(jobIds).size).toBe(10); + }, 120_000); +}); + +describe("Concurrent stress — 5 different tools simultaneously", () => { + it("fires resize, crop, rotate, compress, and border simultaneously — all return 200", async () => { + const [resizeRes, cropRes, rotateRes, compressRes, borderRes] = await Promise.all([ + app.inject( + buildToolRequest("resize", PNG_200x150, "conc-resize.png", { + width: 100, + }), + ), + app.inject( + buildToolRequest("crop", PNG_200x150, "conc-crop.png", { + left: 0, + top: 0, + width: 100, + height: 100, + }), + ), + app.inject( + buildToolRequest("rotate", PNG_200x150, "conc-rotate.png", { + angle: 90, + }), + ), + app.inject( + buildToolRequest("compress", PNG_200x150, "conc-compress.png", { + quality: 60, + }), + ), + app.inject( + buildToolRequest("border", PNG_200x150, "conc-border.png", { + borderWidth: 10, + borderColor: "#0000FF", + }), + ), + ]); + + expect(resizeRes.statusCode).toBe(200); + expect(cropRes.statusCode).toBe(200); + expect(rotateRes.statusCode).toBe(200); + expect(compressRes.statusCode).toBe(200); + expect(borderRes.statusCode).toBe(200); + + // All must have unique job IDs + const ids = [resizeRes, cropRes, rotateRes, compressRes, borderRes].map( + (r) => JSON.parse(r.body).jobId, + ); + expect(new Set(ids).size).toBe(5); + }, 120_000); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// CONCURRENT MIX OF ADVERSARIAL AND VALID REQUESTS +// ═══════════════════════════════════════════════════════════════════════════ +describe("Concurrent adversarial and valid requests", () => { + it("processes valid requests correctly even when invalid ones are fired simultaneously", async () => { + const garbage = Buffer.from( + Array.from({ length: 1024 }, () => Math.floor(Math.random() * 256)), + ); + const emptyBuf = Buffer.alloc(0); + + const [valid1, valid2, invalid1, invalid2, valid3] = await Promise.all([ + app.inject( + buildToolRequest("resize", PNG_200x150, "valid-1.png", { + width: 80, + }), + ), + app.inject( + buildToolRequest("rotate", JPG_100x100, "valid-2.jpg", { + angle: 180, + }), + ), + // Garbage data + app.inject(buildToolRequest("resize", garbage, "garbage.png", { width: 50 })), + // Zero-byte file — manually construct since buildToolRequest uses non-empty image + (() => { + const { body, contentType } = createMultipartPayload([ + { + name: "file", + filename: "empty.png", + content: emptyBuf, + contentType: "image/png", + }, + { + name: "settings", + content: JSON.stringify({ width: 50 }), + }, + ]); + return app.inject({ + method: "POST", + url: "/api/v1/tools/resize", + headers: { + "content-type": contentType, + authorization: `Bearer ${adminToken}`, + }, + body, + }); + })(), + app.inject( + buildToolRequest("compress", PNG_200x150, "valid-3.png", { + quality: 70, + }), + ), + ]); + + // Valid requests must succeed + expect(valid1.statusCode).toBe(200); + expect(valid2.statusCode).toBe(200); + expect(valid3.statusCode).toBe(200); + + // Invalid requests must fail gracefully + expect([400, 422]).toContain(invalid1.statusCode); + expect(invalid2.statusCode).toBe(400); + + // Valid results must have valid job IDs + expect(JSON.parse(valid1.body).jobId).toBeDefined(); + expect(JSON.parse(valid2.body).jobId).toBeDefined(); + expect(JSON.parse(valid3.body).jobId).toBeDefined(); + }, 120_000); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// INJECTION IN VARIOUS SETTINGS FIELDS +// ═══════════════════════════════════════════════════════════════════════════ +describe("Injection attacks in settings fields", () => { + it("handles command injection attempt in border color field", async () => { + const res = await postTool("border", [ + { + name: "file", + filename: "test.png", + content: PNG_200x150, + contentType: "image/png", + }, + { + name: "settings", + content: JSON.stringify({ + borderWidth: 10, + borderColor: "$(rm -rf /)", + }), + }, + ]); + + // Zod hex color regex should reject this + expect(res.statusCode).toBe(400); + }); + + it("handles prototype pollution attempt in settings", async () => { + const res = await postTool("resize", [ + { + name: "file", + filename: "test.png", + content: PNG_200x150, + contentType: "image/png", + }, + { + name: "settings", + content: JSON.stringify({ + width: 100, + __proto__: { admin: true }, + constructor: { prototype: { isAdmin: true } }, + }), + }, + ]); + + // Zod strips unknown keys — the extra fields should be ignored + expect(res.statusCode).toBe(200); + }); + + it("handles extremely long string in watermark text", async () => { + const longText = "A".repeat(501); // exceeds 500 char max + + const res = await postTool("watermark-text", [ + { + name: "file", + filename: "test.png", + content: PNG_200x150, + contentType: "image/png", + }, + { + name: "settings", + content: JSON.stringify({ + text: longText, + fontSize: 12, + }), + }, + ]); + + // z.string().max(500) should reject this + expect(res.statusCode).toBe(400); + }); + + it("handles NaN and Infinity in numeric settings", async () => { + // JSON.stringify(NaN) becomes null, JSON.stringify(Infinity) also becomes null + // So we pass them as strings which should fail Zod number validation + const res = await postTool("resize", [ + { + name: "file", + filename: "test.png", + content: PNG_200x150, + contentType: "image/png", + }, + { + name: "settings", + content: '{"width": "NaN"}', + }, + ]); + + // Zod should reject string "NaN" for a z.number() field + expect(res.statusCode).toBe(400); + }); + + it("handles boolean where number expected", async () => { + const res = await postTool("resize", [ + { + name: "file", + filename: "test.png", + content: PNG_200x150, + contentType: "image/png", + }, + { + name: "settings", + content: JSON.stringify({ width: true }), + }, + ]); + + expect(res.statusCode).toBe(400); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// CONTENT-TYPE MISMATCHES +// ═══════════════════════════════════════════════════════════════════════════ +describe("Content-type header mismatches", () => { + it("processes image when content-type is application/octet-stream", async () => { + const res = await postTool("resize", [ + { + name: "file", + filename: "test.png", + content: PNG_200x150, + contentType: "application/octet-stream", + }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + // Sharp detects format from magic bytes, not content-type + expect(res.statusCode).toBe(200); + }); + + it("processes image when content-type is completely wrong", async () => { + const res = await postTool("resize", [ + { + name: "file", + filename: "test.png", + content: PNG_200x150, + contentType: "text/plain", + }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + // Sharp should still detect the actual format + expect(res.statusCode).toBe(200); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// RAPID-FIRE SEQUENTIAL REQUESTS TO DIFFERENT TOOLS +// ═══════════════════════════════════════════════════════════════════════════ +describe("Rapid sequential requests across tools", () => { + it("handles 8 sequential requests to alternating tools without errors", async () => { + const tools = [ + { id: "resize", settings: { width: 80 } }, + { id: "rotate", settings: { angle: 90 } }, + { id: "compress", settings: { quality: 70 } }, + { id: "border", settings: { borderWidth: 5 } }, + { id: "resize", settings: { width: 60 } }, + { id: "rotate", settings: { angle: 180 } }, + { id: "compress", settings: { quality: 50 } }, + { id: "border", settings: { borderWidth: 10 } }, + ]; + + for (let i = 0; i < tools.length; i++) { + const tool = tools[i]; + const res = await app.inject( + buildToolRequest(tool.id, PNG_200x150, `rapid-${i}.png`, tool.settings), + ); + expect(res.statusCode).toBe(200); + } + }, 60_000); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// SETTINGS BOUNDARY VALUES +// ═══════════════════════════════════════════════════════════════════════════ +describe("Settings boundary values", () => { + it("accepts compress quality at minimum boundary (1)", async () => { + const res = await postTool("compress", [ + { + name: "file", + filename: "test.png", + content: PNG_200x150, + contentType: "image/png", + }, + { name: "settings", content: JSON.stringify({ quality: 1 }) }, + ]); + + expect(res.statusCode).toBe(200); + }); + + it("accepts compress quality at maximum boundary (100)", async () => { + const res = await postTool("compress", [ + { + name: "file", + filename: "test.png", + content: PNG_200x150, + contentType: "image/png", + }, + { name: "settings", content: JSON.stringify({ quality: 100 }) }, + ]); + + expect(res.statusCode).toBe(200); + }); + + it("rejects compress quality below minimum (0)", async () => { + const res = await postTool("compress", [ + { + name: "file", + filename: "test.png", + content: PNG_200x150, + contentType: "image/png", + }, + { name: "settings", content: JSON.stringify({ quality: 0 }) }, + ]); + + expect(res.statusCode).toBe(400); + }); + + it("rejects compress quality above maximum (101)", async () => { + const res = await postTool("compress", [ + { + name: "file", + filename: "test.png", + content: PNG_200x150, + contentType: "image/png", + }, + { name: "settings", content: JSON.stringify({ quality: 101 }) }, + ]); + + expect(res.statusCode).toBe(400); + }); + + it("accepts rotate at 0 degrees", async () => { + const res = await postTool("rotate", [ + { + name: "file", + filename: "test.png", + content: PNG_200x150, + contentType: "image/png", + }, + { name: "settings", content: JSON.stringify({ angle: 0 }) }, + ]); + + // 0-degree rotation is a valid no-op + expect([200, 400]).toContain(res.statusCode); + }); + + it("accepts rotate at 359 degrees", async () => { + const res = await postTool("rotate", [ + { + name: "file", + filename: "test.png", + content: PNG_200x150, + contentType: "image/png", + }, + { name: "settings", content: JSON.stringify({ angle: 359 }) }, + ]); + + expect(res.statusCode).toBe(200); + }); + + it("handles border width at maximum (2000)", async () => { + const res = await postTool("border", [ + { + name: "file", + filename: "test.png", + content: PNG_200x150, + contentType: "image/png", + }, + { + name: "settings", + content: JSON.stringify({ borderWidth: 2000 }), + }, + ]); + + // May succeed or fail at processing, but must not crash + expect([200, 400, 422]).toContain(res.statusCode); + }); + + it("rejects border width above maximum (2001)", async () => { + const res = await postTool("border", [ + { + name: "file", + filename: "test.png", + content: PNG_200x150, + contentType: "image/png", + }, + { + name: "settings", + content: JSON.stringify({ borderWidth: 2001 }), + }, + ]); + + expect(res.statusCode).toBe(400); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// MULTI-FORMAT ZERO-BYTE BATCH +// ═══════════════════════════════════════════════════════════════════════════ +describe("Batch with only zero-byte files", () => { + it("rejects batch where all files are zero-byte", async () => { + const res = await postBatch("resize", [ + { + name: "file", + filename: "empty1.png", + contentType: "image/png", + content: Buffer.alloc(0), + }, + { + name: "file", + filename: "empty2.png", + contentType: "image/png", + content: Buffer.alloc(0), + }, + { name: "settings", content: JSON.stringify({ width: 50 }) }, + ]); + + // Zero-byte files are skipped during parsing, so 0 valid files + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toMatch(/no image/i); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// PIPELINE WITH 1x1 PIXEL IMAGE +// ═══════════════════════════════════════════════════════════════════════════ +describe("Pipeline with 1x1 pixel image", () => { + it("processes 1x1 image through resize + border + compress pipeline", async () => { + const res = await executePipeline(PNG_1x1, "tiny.png", { + steps: [ + { toolId: "resize", settings: { width: 50, height: 50 } }, + { + toolId: "border", + settings: { borderWidth: 5, borderColor: "#00FF00" }, + }, + { toolId: "compress", settings: { quality: 80 } }, + ], + }); + + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.stepsCompleted).toBe(3); + }); +}); + +// ═══════════════════════════════════════════════════════════════════════════ +// NON-EXISTENT TOOL ENDPOINTS +// ═══════════════════════════════════════════════════════════════════════════ +describe("Non-existent tool and route handling", () => { + it("returns 404 for completely non-existent tool route", async () => { + const res = await postTool("this-tool-does-not-exist-at-all", [ + { + name: "file", + filename: "test.png", + content: PNG_200x150, + contentType: "image/png", + }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + expect(res.statusCode).toBe(404); + }); + + it("returns 404 for tool endpoint with SQL injection in URL", async () => { + const res = await postTool("resize'; DROP TABLE users; --", [ + { + name: "file", + filename: "test.png", + content: PNG_200x150, + contentType: "image/png", + }, + { name: "settings", content: JSON.stringify({ width: 100 }) }, + ]); + + expect(res.statusCode).toBe(404); + }); +}); diff --git a/tests/integration/blur-faces.test.ts b/tests/integration/blur-faces.test.ts new file mode 100644 index 00000000..94dbef1d --- /dev/null +++ b/tests/integration/blur-faces.test.ts @@ -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); + }); +}); diff --git a/tests/integration/colorize.test.ts b/tests/integration/colorize.test.ts new file mode 100644 index 00000000..f84fac20 --- /dev/null +++ b/tests/integration/colorize.test.ts @@ -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); + }); +}); diff --git a/tests/integration/crop.test.ts b/tests/integration/crop.test.ts new file mode 100644 index 00000000..12e99eaa --- /dev/null +++ b/tests/integration/crop.test.ts @@ -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, + 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); + }); +}); diff --git a/tests/integration/enhance-faces.test.ts b/tests/integration/enhance-faces.test.ts new file mode 100644 index 00000000..2a414e31 --- /dev/null +++ b/tests/integration/enhance-faces.test.ts @@ -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: "<<>>" }, + ]); + + 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); + }); +}); diff --git a/tests/integration/erase-object.test.ts b/tests/integration/erase-object.test.ts new file mode 100644 index 00000000..8de61d2f --- /dev/null +++ b/tests/integration/erase-object.test.ts @@ -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); +}); diff --git a/tests/integration/noise-removal.test.ts b/tests/integration/noise-removal.test.ts new file mode 100644 index 00000000..89feaa92 --- /dev/null +++ b/tests/integration/noise-removal.test.ts @@ -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); + }); +}); diff --git a/tests/integration/ocr.test.ts b/tests/integration/ocr.test.ts new file mode 100644 index 00000000..e62c220a --- /dev/null +++ b/tests/integration/ocr.test.ts @@ -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); + }); +}); diff --git a/tests/integration/passport-photo.test.ts b/tests/integration/passport-photo.test.ts new file mode 100644 index 00000000..4af45aec --- /dev/null +++ b/tests/integration/passport-photo.test.ts @@ -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); + }); +}); diff --git a/tests/integration/red-eye-removal.test.ts b/tests/integration/red-eye-removal.test.ts new file mode 100644 index 00000000..754e0f63 --- /dev/null +++ b/tests/integration/red-eye-removal.test.ts @@ -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); + }); +}); diff --git a/tests/integration/remove-background.test.ts b/tests/integration/remove-background.test.ts new file mode 100644 index 00000000..78e77230 --- /dev/null +++ b/tests/integration/remove-background.test.ts @@ -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); + }); +}); diff --git a/tests/integration/resize.test.ts b/tests/integration/resize.test.ts new file mode 100644 index 00000000..c32fcfbe --- /dev/null +++ b/tests/integration/resize.test.ts @@ -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, + 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); + }); +}); diff --git a/tests/integration/restore-photo.test.ts b/tests/integration/restore-photo.test.ts new file mode 100644 index 00000000..2d8b4b28 --- /dev/null +++ b/tests/integration/restore-photo.test.ts @@ -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); + }); +}); diff --git a/tests/integration/rotate.test.ts b/tests/integration/rotate.test.ts new file mode 100644 index 00000000..16479df1 --- /dev/null +++ b/tests/integration/rotate.test.ts @@ -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, + 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); + }); +}); diff --git a/tests/integration/smart-crop.test.ts b/tests/integration/smart-crop.test.ts new file mode 100644 index 00000000..40262f44 --- /dev/null +++ b/tests/integration/smart-crop.test.ts @@ -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); + }); +}); diff --git a/tests/integration/upscale.test.ts b/tests/integration/upscale.test.ts new file mode 100644 index 00000000..8b6ec131 --- /dev/null +++ b/tests/integration/upscale.test.ts @@ -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); + }); +}); diff --git a/tests/unit/web/image-preview-download.test.ts b/tests/unit/web/image-preview-download.test.ts new file mode 100644 index 00000000..35f461be --- /dev/null +++ b/tests/unit/web/image-preview-download.test.ts @@ -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(); + }); +}); diff --git a/tests/unit/web/tool-registry.test.ts b/tests/unit/web/tool-registry.test.ts new file mode 100644 index 00000000..c97216a2 --- /dev/null +++ b/tests/unit/web/tool-registry.test.ts @@ -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(); + }); +});