diff --git a/tests/integration/content-aware-crop.test.ts b/tests/integration/content-aware-crop.test.ts new file mode 100644 index 00000000..1d50f253 --- /dev/null +++ b/tests/integration/content-aware-crop.test.ts @@ -0,0 +1,230 @@ +/** + * Integration tests for the content-aware-crop AI tool (/api/v1/tools/content-aware-crop). + * + * This tool uses async processing: valid requests return 202 with a jobId, + * and the result is delivered via SSE. The Python sidecar is not available + * in the test environment, so the route returns 501 (not installed). + * Validation paths are always testable regardless of sidecar availability. + */ + +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")); + +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("Content-Aware Crop", () => { + // -- Processing (sidecar-dependent) ------------------------------------ + + it("returns 501 when AI sidecar is not installed", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ + extendTop: 100, + extendRight: 0, + extendBottom: 100, + extendLeft: 0, + }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/content-aware-crop", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect([202, 501]).toContain(res.statusCode); + if (res.statusCode === 501) { + const json = JSON.parse(res.body); + expect(json.code).toBe("FEATURE_NOT_INSTALLED"); + } + }, 60_000); + + // -- Validation (always testable) -------------------------------------- + + it("returns 400 when no image is provided", async () => { + const { body, contentType } = createMultipartPayload([ + { + name: "settings", + content: JSON.stringify({ extendTop: 100, extendRight: 0, extendBottom: 0, extendLeft: 0 }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/content-aware-crop", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + // 400 when sidecar is available (validation runs), 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("returns 400 when no settings are provided", 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/content-aware-crop", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + // Without settings, defaults apply: all extends = 0 => "at least one direction" error + // But 501 fires first if sidecar is not installed + expect([400, 501]).toContain(res.statusCode); + if (res.statusCode === 400) { + const json = JSON.parse(res.body); + expect(json.error).toMatch(/at least one extend direction/i); + } + }); + + it("returns 400 when all extensions are zero", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ extendTop: 0, extendRight: 0, extendBottom: 0, extendLeft: 0 }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/content-aware-crop", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect([400, 501]).toContain(res.statusCode); + if (res.statusCode === 400) { + const json = JSON.parse(res.body); + expect(json.error).toMatch(/at least one extend direction/i); + } + }); + + it("returns 400 with negative extension values", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ + extendTop: -10, + extendRight: -5, + extendBottom: -20, + extendLeft: -15, + }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/content-aware-crop", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect([400, 501]).toContain(res.statusCode); + if (res.statusCode === 400) { + const json = JSON.parse(res.body); + expect(json.error).toMatch(/invalid settings/i); + } + }); + + it("accepts valid request format (202 or 501)", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "test.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ + extendTop: 100, + extendRight: 0, + extendBottom: 100, + extendLeft: 0, + }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/content-aware-crop", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect([202, 501]).toContain(res.statusCode); + if (res.statusCode === 202) { + const json = JSON.parse(res.body); + expect(json.jobId).toBeDefined(); + expect(json.async).toBe(true); + } + }, 60_000); + + 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({ + extendTop: 50, + extendRight: 50, + extendBottom: 50, + extendLeft: 50, + }), + }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/content-aware-crop", + headers: { "content-type": contentType }, + body, + }); + + expect(res.statusCode).toBe(401); + }); +});