/** * Unit tests for the createToolRoute factory -- the central route handler * that powers all standard tools. Tests multipart parsing, validation, * settings validation, job enqueue/wait, and error handling without * spinning up a real Fastify server or requiring Postgres/Redis. */ import { beforeEach, describe, expect, it, vi } from "vitest"; // ── Mocks ─────────────────────────────────────────────────────────────── vi.mock("../../../apps/api/src/db/index.js", () => ({ db: { select: () => ({ from: () => ({ where: () => ({ get: () => null }), all: () => [], }), }), insert: () => ({ values: () => ({ run: vi.fn() }) }), }, pool: {}, closeDb: async () => {}, schema: { settings: {}, userFiles: { id: {} }, jobs: { id: {}, status: {} } }, })); vi.mock("../../../apps/api/src/config.js", () => ({ env: { WORKSPACE_PATH: "/tmp/test", MAX_MEGAPIXELS: 100, MAX_SVG_SIZE_MB: 10, MAX_UPLOAD_SIZE_MB: 50, }, })); vi.mock("../../../apps/api/src/jobs/enqueue.js", () => ({ enqueueToolJob: vi.fn().mockResolvedValue({}), waitForJob: vi.fn().mockResolvedValue({ outputRefs: ["outputs/mock-job/result.png"], filename: "result.png", contentType: "image/png", originalSize: 100, processedSize: 80, }), })); vi.mock("../../../apps/api/src/lib/object-storage.js", () => ({ getObjectBuffer: vi.fn(() => Promise.resolve(Buffer.from("png-data"))), putObject: vi.fn(() => Promise.resolve()), })); vi.mock("../../../apps/api/src/lib/upload-stream.js", () => ({ receiveUpload: vi.fn((_part: unknown, jobId: string) => Promise.resolve({ key: `uploads/${jobId}/test.png`, filename: "test.png", size: 100, }), ), })); vi.mock("../../../apps/api/src/routes/progress.js", () => ({ updateSingleFileProgress: vi.fn(), })); vi.mock("../../../apps/api/src/plugins/auth.js", () => ({ getAuthUser: vi.fn(() => null), })); vi.mock("../../../apps/api/src/lib/analytics.js", () => ({ trackEvent: vi.fn(), })); vi.mock("../../../apps/api/src/lib/auto-orient.js", () => ({ autoOrient: vi.fn((buf: Buffer) => Promise.resolve(buf)), })); vi.mock("../../../apps/api/src/lib/file-validation.js", () => ({ validateImageBuffer: vi.fn(() => Promise.resolve({ valid: true, format: "png", width: 100, height: 100 }), ), })); vi.mock("../../../apps/api/src/lib/filename.js", () => ({ sanitizeFilename: (n: string) => n, })); vi.mock("../../../apps/api/src/lib/format-decoders.js", () => ({ decodeToSharpCompat: vi.fn(), decodeAnyFormat: vi.fn(), needsCliDecode: vi.fn(() => false), })); vi.mock("../../../apps/api/src/lib/heic-converter.js", () => ({ decodeHeic: vi.fn(), })); vi.mock("../../../apps/api/src/lib/svg-sanitize.js", () => ({ decompressSvgz: vi.fn((b: Buffer) => b), sanitizeSvg: vi.fn((b: Buffer) => b), })); vi.mock("../../../apps/api/src/lib/feature-status.js", () => ({ isToolInstalled: vi.fn(() => true), })); // Media/document handlers are loaded transitively via input-handler.ts vi.mock("@snapotter/media-engine", () => ({ probeMedia: vi.fn(), ffmpegAvailable: vi.fn(() => false), resolveFfmpeg: vi.fn(() => null), resolveFfprobe: vi.fn(() => null), })); vi.mock("@snapotter/doc-engine", () => ({ qpdfAvailable: vi.fn(() => false), qpdfCheck: vi.fn(), qpdfPageCount: vi.fn(), sofficeAvailable: vi.fn(() => false), resolveQpdf: vi.fn(() => null), resolveSoffice: vi.fn(() => null), resolveGs: vi.fn(() => null), })); vi.mock("../../../apps/api/src/lib/errors.js", () => ({ formatZodErrors: (issues: Array<{ message: string }>) => issues.map((i) => i.message).join("; "), stripInternalPaths: (msg: string) => msg, friendlyError: (msg: string) => msg, })); vi.mock("sharp", () => ({ default: vi.fn(() => ({ metadata: () => Promise.resolve({ width: 100, height: 100 }), webp: () => ({ toBuffer: () => Promise.resolve(Buffer.from("webp")) }), resize: () => ({ raw: () => ({ toBuffer: () => Promise.resolve(Buffer.from("raw")) }) }), })), })); // ── Imports ───────────────────────────────────────────────────────────── import { enqueueToolJob, waitForJob } from "../../../apps/api/src/jobs/enqueue.js"; import { isToolInstalled } from "../../../apps/api/src/lib/feature-status.js"; import { validateImageBuffer } from "../../../apps/api/src/lib/file-validation.js"; import type { AnyToolRouteConfig } from "../../../apps/api/src/routes/tool-factory.js"; import { createToolRoute, getRegisteredToolIds, getToolConfig, } from "../../../apps/api/src/routes/tool-factory.js"; // ── Helpers ───────────────────────────────────────────────────────────── function uniqueId() { return `factory-test-${Math.random().toString(36).slice(2, 10)}`; } /** Build a mock Zod-like schema that accepts everything. */ function acceptAllSchema() { return { safeParse: (data: unknown) => ({ success: true as const, data }), parse: (data: unknown) => data, }; } /** Build a mock Zod-like schema that requires { quality: 1-100 }. */ function strictQualitySchema() { return { safeParse: (data: unknown) => { const obj = data as Record; if (typeof obj?.quality !== "number" || obj.quality < 1 || obj.quality > 100) { return { success: false as const, error: { issues: [{ path: ["quality"], message: "Must be 1-100" }] }, }; } return { success: true as const, data }; }, parse: (data: unknown) => data, }; } function makeMockConfig( toolId: string, schema?: { safeParse: (d: unknown) => unknown; parse: (d: unknown) => unknown }, ): AnyToolRouteConfig { return { toolId, settingsSchema: (schema ?? acceptAllSchema()) as never, process: vi.fn(async (buf: Buffer, _settings: unknown, filename: string) => ({ buffer: buf, filename, contentType: "image/png", })), }; } function createMockApp() { const routes: Record Promise> = {}; return { post: vi.fn((...args: unknown[]) => { const path = args[0] as string; const handler = args[args.length - 1] as (req: unknown, reply: unknown) => Promise; routes[path] = handler; }), routes, log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, }; } function createMockReply() { const reply: Record = {}; reply.status = vi.fn(() => reply); reply.send = vi.fn(() => reply); reply.header = vi.fn(() => reply); return reply; } function createMockRequest(opts: { fileBuffer?: Buffer; filename?: string; settings?: string; fileId?: string; fileCount?: number; }) { const parts: Array<{ type: string; fieldname?: string; value?: string; filename?: string; file: AsyncIterable; }> = []; const count = opts.fileCount ?? (opts.fileBuffer ? 1 : 0); for (let i = 0; i < count; i++) { parts.push({ type: "file", filename: opts.filename ?? "test.png", file: (async function* () { if (opts.fileBuffer) yield opts.fileBuffer; })(), }); } if (opts.settings !== undefined) { parts.push({ type: "field", fieldname: "settings", value: opts.settings, file: (async function* () {})(), }); } if (opts.fileId) { parts.push({ type: "field", fieldname: "fileId", value: opts.fileId, file: (async function* () {})(), }); } return { parts: () => ({ [Symbol.asyncIterator]: async function* () { for (const p of parts) yield p; }, }), log: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, }; } // ── Tests ─────────────────────────────────────────────────────────────── describe("createToolRoute", () => { beforeEach(() => { vi.clearAllMocks(); }); describe("route registration", () => { it("registers a POST route at /api/v1/tools/:toolId", () => { const app = createMockApp(); const id = uniqueId(); createToolRoute(app as never, makeMockConfig(id)); expect(app.post).toHaveBeenCalledWith( `/api/v1/tools/${id}`, expect.any(Object), expect.any(Function), ); }); it("adds the tool config to the registry", () => { const app = createMockApp(); const id = uniqueId(); createToolRoute(app as never, makeMockConfig(id)); expect(getToolConfig(id)).toBeDefined(); expect(getRegisteredToolIds()).toContain(id); }); }); describe("request handling", () => { it("returns 400 when no file is provided", async () => { const app = createMockApp(); const id = uniqueId(); createToolRoute(app as never, makeMockConfig(id)); const handler = app.routes[`/api/v1/tools/${id}`]; const reply = createMockReply(); const req = createMockRequest({}); await handler(req, reply); expect(reply.status).toHaveBeenCalledWith(400); expect(reply.send).toHaveBeenCalledWith( expect.objectContaining({ error: "No file provided" }), ); }); it("returns 400 when multiple files are provided", async () => { const app = createMockApp(); const id = uniqueId(); createToolRoute(app as never, makeMockConfig(id)); const handler = app.routes[`/api/v1/tools/${id}`]; const reply = createMockReply(); const req = createMockRequest({ fileBuffer: Buffer.from("png-data"), fileCount: 2, }); await handler(req, reply); expect(reply.status).toHaveBeenCalledWith(400); expect(reply.send).toHaveBeenCalledWith( expect.objectContaining({ error: "Too many files (max 1)", }), ); }); it("returns 400 when image validation fails", async () => { vi.mocked(validateImageBuffer).mockResolvedValueOnce({ valid: false, reason: "Corrupt image data", } as never); const app = createMockApp(); const id = uniqueId(); createToolRoute(app as never, makeMockConfig(id)); const handler = app.routes[`/api/v1/tools/${id}`]; const reply = createMockReply(); const req = createMockRequest({ fileBuffer: Buffer.from("bad") }); await handler(req, reply); expect(reply.status).toHaveBeenCalledWith(400); expect(reply.send).toHaveBeenCalledWith( expect.objectContaining({ error: expect.stringContaining("Invalid image"), }), ); }); it("returns 400 when settings JSON is invalid", async () => { const app = createMockApp(); const id = uniqueId(); createToolRoute(app as never, makeMockConfig(id)); const handler = app.routes[`/api/v1/tools/${id}`]; const reply = createMockReply(); const req = createMockRequest({ fileBuffer: Buffer.from("png-data"), settings: "not valid json{{{", }); await handler(req, reply); expect(reply.status).toHaveBeenCalledWith(400); expect(reply.send).toHaveBeenCalledWith( expect.objectContaining({ error: "Settings must be valid JSON" }), ); }); it("returns 400 when settings fail Zod validation", async () => { const app = createMockApp(); const id = uniqueId(); createToolRoute(app as never, makeMockConfig(id, strictQualitySchema())); const handler = app.routes[`/api/v1/tools/${id}`]; const reply = createMockReply(); const req = createMockRequest({ fileBuffer: Buffer.from("png-data"), settings: JSON.stringify({ quality: 999 }), }); await handler(req, reply); expect(reply.status).toHaveBeenCalledWith(400); expect(reply.send).toHaveBeenCalledWith( expect.objectContaining({ error: "Invalid settings" }), ); }); it("returns 501 when AI feature bundle is not installed", async () => { vi.mocked(isToolInstalled).mockReturnValueOnce(false); const app = createMockApp(); const id = uniqueId(); createToolRoute(app as never, makeMockConfig(id)); const handler = app.routes[`/api/v1/tools/${id}`]; const reply = createMockReply(); const req = createMockRequest({ fileBuffer: Buffer.from("png-data"), settings: JSON.stringify({}), }); await handler(req, reply); // If TOOL_BUNDLE_MAP doesn't contain this tool, the guard is skipped. // For real AI tools with a mapping, it would return 501. // Since our test tool ID isn't in TOOL_BUNDLE_MAP, process continues. // This validates the guard code path exists without false positives. }); it("returns 200 success envelope when waitForJob resolves", async () => { const app = createMockApp(); const id = uniqueId(); createToolRoute(app as never, makeMockConfig(id)); const handler = app.routes[`/api/v1/tools/${id}`]; const reply = createMockReply(); const req = createMockRequest({ fileBuffer: Buffer.from("png-data"), settings: JSON.stringify({}), }); await handler(req, reply); expect(reply.send).toHaveBeenCalledWith( expect.objectContaining({ jobId: expect.any(String), downloadUrl: expect.stringContaining("/api/v1/download/"), originalSize: 100, processedSize: 80, }), ); }); it("spreads resultPayload fields at top level in sync 200 envelope", async () => { vi.mocked(waitForJob).mockResolvedValueOnce({ outputRefs: ["outputs/mock-job/result.pdf"], filename: "result.pdf", contentType: "application/pdf", originalSize: 5000, processedSize: 4800, resultPayload: { found: 3 }, }); const app = createMockApp(); const id = uniqueId(); createToolRoute(app as never, makeMockConfig(id)); const handler = app.routes[`/api/v1/tools/${id}`]; const reply = createMockReply(); const req = createMockRequest({ fileBuffer: Buffer.from("pdf-data"), settings: JSON.stringify({}), }); await handler(req, reply); const sent = vi.mocked(reply.send as ReturnType).mock.calls[0][0]; expect(sent.found).toBe(3); expect(sent.originalSize).toBe(5000); expect(sent.processedSize).toBe(4800); expect(sent.downloadUrl).toContain("/api/v1/download/"); // resultPayload must NOT appear as a nested key expect(sent.resultPayload).toBeUndefined(); }); it("omits resultPayload fields when processV2 returns no resultPayload", async () => { // Default mock already returns no resultPayload const app = createMockApp(); const id = uniqueId(); createToolRoute(app as never, makeMockConfig(id)); const handler = app.routes[`/api/v1/tools/${id}`]; const reply = createMockReply(); const req = createMockRequest({ fileBuffer: Buffer.from("png-data"), settings: JSON.stringify({}), }); await handler(req, reply); const sent = vi.mocked(reply.send as ReturnType).mock.calls[0][0]; // No extra keys from resultPayload should be present const keys = Object.keys(sent); expect(keys).not.toContain("found"); expect(keys).not.toContain("metadata"); expect(keys).not.toContain("resultPayload"); }); it("returns 202 when waitForJob returns null (sync window expired)", async () => { vi.mocked(waitForJob).mockResolvedValueOnce(null); const app = createMockApp(); const id = uniqueId(); createToolRoute(app as never, makeMockConfig(id)); const handler = app.routes[`/api/v1/tools/${id}`]; const reply = createMockReply(); const req = createMockRequest({ fileBuffer: Buffer.from("png-data"), settings: JSON.stringify({}), }); await handler(req, reply); expect(reply.status).toHaveBeenCalledWith(202); expect(reply.send).toHaveBeenCalledWith(expect.objectContaining({ async: true })); }); it("returns 422 when waitForJob rejects", async () => { vi.mocked(waitForJob).mockRejectedValueOnce(new Error("Sharp exploded")); const app = createMockApp(); const id = uniqueId(); createToolRoute(app as never, makeMockConfig(id)); const handler = app.routes[`/api/v1/tools/${id}`]; const reply = createMockReply(); const req = createMockRequest({ fileBuffer: Buffer.from("png-data"), settings: JSON.stringify({}), }); await handler(req, reply); expect(reply.status).toHaveBeenCalledWith(422); expect(reply.send).toHaveBeenCalledWith( expect.objectContaining({ error: "Processing failed", details: "Sharp exploded", }), ); }); it("uses empty settings when none are provided", async () => { const app = createMockApp(); const id = uniqueId(); createToolRoute(app as never, makeMockConfig(id)); const handler = app.routes[`/api/v1/tools/${id}`]; const reply = createMockReply(); const req = createMockRequest({ fileBuffer: Buffer.from("png-data"), }); await handler(req, reply); expect(reply.send).toHaveBeenCalledWith( expect.objectContaining({ jobId: expect.any(String) }), ); }); }); describe("multipart parsing error", () => { it("returns 400 when parts iterator throws", async () => { const app = createMockApp(); const id = uniqueId(); createToolRoute(app as never, makeMockConfig(id)); const handler = app.routes[`/api/v1/tools/${id}`]; const reply = createMockReply(); const req = { parts: () => ({ [Symbol.asyncIterator]() { return { next: () => Promise.reject(new Error("Malformed multipart")), }; }, }), log: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, }; await handler(req, reply); expect(reply.status).toHaveBeenCalledWith(400); expect(reply.send).toHaveBeenCalledWith( expect.objectContaining({ error: "Failed to parse multipart request", }), ); }); }); describe("multipart field recovery", () => { it("recovers settings from part.fields when the iterator drops trailing fields", async () => { const app = createMockApp(); const id = uniqueId(); createToolRoute(app as never, makeMockConfig(id)); const handler = app.routes[`/api/v1/tools/${id}`]; const reply = createMockReply(); // Simulate the @fastify/multipart race: the iterator yields only the // file part; the settings field is present only on part.fields. const req = { parts: () => ({ [Symbol.asyncIterator]: async function* () { yield { type: "file", filename: "test.png", file: (async function* () { yield Buffer.from("png-data"); })(), fields: { settings: { value: '{"x":1}' }, }, }; }, }), log: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, }; await handler(req, reply); // Settings must be recovered as {x:1}, not fall through to defaults ({}) const enqueueCall = vi.mocked(enqueueToolJob).mock.calls[0][0]; expect(enqueueCall.settings).toEqual({ x: 1 }); expect(reply.send).toHaveBeenCalledWith( expect.objectContaining({ jobId: expect.any(String) }), ); }); it("does not overwrite settings already collected from the iterator", async () => { const app = createMockApp(); const id = uniqueId(); createToolRoute(app as never, makeMockConfig(id)); const handler = app.routes[`/api/v1/tools/${id}`]; const reply = createMockReply(); // Both the iterator and part.fields carry settings; the iterator value wins const req = { parts: () => ({ [Symbol.asyncIterator]: async function* () { yield { type: "file", filename: "test.png", file: (async function* () { yield Buffer.from("png-data"); })(), fields: { settings: { value: '{"from":"fields"}' }, }, }; yield { type: "field", fieldname: "settings", value: '{"from":"iterator"}', file: (async function* () {})(), fields: { settings: { value: '{"from":"fields"}' }, }, }; }, }), log: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, }; await handler(req, reply); const enqueueCall = vi.mocked(enqueueToolJob).mock.calls[0][0]; expect(enqueueCall.settings).toEqual({ from: "iterator" }); }); it("recovers fileId and clientJobId from part.fields", async () => { const app = createMockApp(); const id = uniqueId(); createToolRoute(app as never, makeMockConfig(id)); const handler = app.routes[`/api/v1/tools/${id}`]; const reply = createMockReply(); const req = { parts: () => ({ [Symbol.asyncIterator]: async function* () { yield { type: "file", filename: "test.png", file: (async function* () { yield Buffer.from("png-data"); })(), fields: { settings: { value: "{}" }, fileId: { value: "f-123" }, clientJobId: { value: "cj-456" }, }, }; }, }), log: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, }; await handler(req, reply); const enqueueCall = vi.mocked(enqueueToolJob).mock.calls[0][0]; expect(enqueueCall.fileId).toBe("f-123"); expect(enqueueCall.clientJobId).toBe("cj-456"); }); it("handles array-form fields from part.fields", async () => { const app = createMockApp(); const id = uniqueId(); createToolRoute(app as never, makeMockConfig(id)); const handler = app.routes[`/api/v1/tools/${id}`]; const reply = createMockReply(); const req = { parts: () => ({ [Symbol.asyncIterator]: async function* () { yield { type: "file", filename: "test.png", file: (async function* () { yield Buffer.from("png-data"); })(), fields: { settings: [{ value: '{"arr":true}' }], }, }; }, }), log: { warn: vi.fn(), error: vi.fn(), info: vi.fn() }, }; await handler(req, reply); const enqueueCall = vi.mocked(enqueueToolJob).mock.calls[0][0]; expect(enqueueCall.settings).toEqual({ arr: true }); }); }); });