diff --git a/tests/integration/sign-pdf.test.ts b/tests/integration/sign-pdf.test.ts index e38ce222..f7fc3f78 100644 --- a/tests/integration/sign-pdf.test.ts +++ b/tests/integration/sign-pdf.test.ts @@ -37,6 +37,18 @@ describe("sign-pdf", () => { }); } + function postFields( + fields: Parameters[0], + ): ReturnType { + const { body, contentType } = createMultipartPayload(fields); + return testApp.app.inject({ + method: "POST", + url: "/api/v1/tools/pdf/sign-pdf", + headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, + body, + }); + } + (hasFitz ? it : it.skip)( "stamps a signature and returns a PDF", async () => { @@ -55,4 +67,91 @@ describe("sign-pdf", () => { const res = await runTool([]); expect(res.statusCode).toBe(400); }); + + it("rejects when the PDF file part is missing", async () => { + const res = await postFields([ + { name: "sig0", filename: "sig0.png", contentType: "image/png", content: SIG }, + { + name: "placements", + content: JSON.stringify([{ sig: 0, page: 0, x: 0, y: 0, w: 0.25, h: 0.1 }]), + }, + ]); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body)).toMatchObject({ error: "No PDF file provided" }); + }); + + it("rejects when the placements field is missing", async () => { + const res = await postFields([ + { name: "file", filename: "in.pdf", contentType: "application/pdf", content: PDF }, + { name: "sig0", filename: "sig0.png", contentType: "image/png", content: SIG }, + ]); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body)).toMatchObject({ error: "No placements provided" }); + }); + + it("rejects malformed placement JSON", async () => { + const res = await postFields([ + { name: "file", filename: "in.pdf", contentType: "application/pdf", content: PDF }, + { name: "sig0", filename: "sig0.png", contentType: "image/png", content: SIG }, + { name: "placements", content: "[not-json" }, + ]); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body)).toMatchObject({ error: "Invalid placements" }); + }); + + it("rejects placements that reference an omitted signature image", async () => { + const res = await postFields([ + { name: "file", filename: "in.pdf", contentType: "application/pdf", content: PDF }, + { + name: "placements", + content: JSON.stringify([{ sig: 0, page: 0, x: 0, y: 0, w: 0.25, h: 0.1 }]), + }, + ]); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body)).toMatchObject({ + error: "Missing signature image for placement (sig 0)", + }); + }); + + it("rejects an invalid PDF before enqueueing work", async () => { + const res = await postFields([ + { + name: "file", + filename: "not-a-pdf.pdf", + contentType: "application/pdf", + content: Buffer.from("not a pdf"), + }, + { name: "sig0", filename: "sig0.png", contentType: "image/png", content: SIG }, + { + name: "placements", + content: JSON.stringify([{ sig: 0, page: 0, x: 0, y: 0, w: 0.25, h: 0.1 }]), + }, + ]); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body)).toMatchObject({ error: "Invalid PDF" }); + }); + + it("rejects an invalid signature image before enqueueing work", async () => { + const res = await postFields([ + { name: "file", filename: "in.pdf", contentType: "application/pdf", content: PDF }, + { + name: "sig0", + filename: "sig0.png", + contentType: "image/png", + content: Buffer.from("not an image"), + }, + { + name: "placements", + content: JSON.stringify([{ sig: 0, page: 0, x: 0, y: 0, w: 0.25, h: 0.1 }]), + }, + ]); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).error).toMatch(/^Invalid signature image:/); + }); }); diff --git a/tests/integration/tools/document/ocr-pdf-route-coverage.test.ts b/tests/integration/tools/document/ocr-pdf-route-coverage.test.ts new file mode 100644 index 00000000..40b99ae8 --- /dev/null +++ b/tests/integration/tools/document/ocr-pdf-route-coverage.test.ts @@ -0,0 +1,153 @@ +/** + * Focused sidecar-free integration coverage for OCR PDF route branches. + * + * The standard ocr-pdf integration file documents the local 501 bundle gate. + * These tests force the gate open and mock enqueueing so route validation and + * async job submission are exercised without running PDF OCR. + */ + +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { fixtures, readFixture } from "../../../fixtures/index.js"; +import { + buildTestApp, + createMultipartPayload, + loginAsAdmin, + type TestApp, +} from "../../test-server.js"; + +const mocks = vi.hoisted(() => ({ + enqueueToolJob: vi.fn(), +})); + +vi.mock("../../../../apps/api/src/lib/feature-status.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isToolInstalled: (toolId: string) => + toolId === "ocr-pdf" ? true : actual.isToolInstalled(toolId), + }; +}); + +vi.mock("../../../../apps/api/src/jobs/enqueue.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + enqueueToolJob: mocks.enqueueToolJob, + }; +}); + +const PDF = readFixture(fixtures.document.pdf3); + +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); + +beforeEach(() => { + mocks.enqueueToolJob.mockReset(); + mocks.enqueueToolJob.mockResolvedValue(undefined); +}); + +function postOcrPdf(parts: Parameters[0]) { + const { body, contentType } = createMultipartPayload(parts); + + return app.inject({ + method: "POST", + url: "/api/v1/tools/pdf/ocr-pdf", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); +} + +describe("ocr-pdf route coverage", () => { + it("rejects requests without a PDF after the bundle gate passes", async () => { + const res = await postOcrPdf([ + { name: "settings", content: JSON.stringify({ quality: "fast" }) }, + ]); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toBe("No PDF file provided"); + expect(mocks.enqueueToolJob).not.toHaveBeenCalled(); + }); + + it("rejects invalid settings JSON after upload parsing", async () => { + const res = await postOcrPdf([ + { + name: "file", + filename: "test.pdf", + contentType: "application/pdf", + content: PDF, + }, + { name: "settings", content: "{{bad json}}" }, + ]); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toBe("Settings must be valid JSON"); + expect(mocks.enqueueToolJob).not.toHaveBeenCalled(); + }); + + it("validates ocr-pdf settings before enqueueing", async () => { + const res = await postOcrPdf([ + { + name: "file", + filename: "test.pdf", + contentType: "application/pdf", + content: PDF, + }, + { name: "settings", content: JSON.stringify({ language: "klingon" }) }, + ]); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toBe("Invalid settings"); + expect(mocks.enqueueToolJob).not.toHaveBeenCalled(); + }); + + it("enqueues valid OCR PDF requests with sanitized settings", async () => { + const clientJobId = "11111111-1111-4111-8111-111111111111"; + const res = await postOcrPdf([ + { + name: "file", + filename: "scan.pdf", + contentType: "application/pdf", + content: PDF, + }, + { + name: "settings", + content: JSON.stringify({ quality: "fast", language: "en", pages: "1-2" }), + }, + { name: "clientJobId", content: clientJobId }, + { name: "fileId", content: "file_123" }, + ]); + + expect(res.statusCode).toBe(202); + expect(JSON.parse(res.body)).toEqual({ jobId: clientJobId, async: true }); + expect(mocks.enqueueToolJob).toHaveBeenCalledTimes(1); + expect(mocks.enqueueToolJob).toHaveBeenCalledWith( + expect.objectContaining({ + toolId: "ocr-pdf", + pool: "ai", + filename: "scan.pdf", + settings: { quality: "fast", language: "en", pages: "1-2" }, + clientJobId, + fileId: "file_123", + kind: "ai-tool", + }), + ); + }); +}); diff --git a/tests/integration/tools/image/ai-async-route-coverage.test.ts b/tests/integration/tools/image/ai-async-route-coverage.test.ts new file mode 100644 index 00000000..8913579b --- /dev/null +++ b/tests/integration/tools/image/ai-async-route-coverage.test.ts @@ -0,0 +1,280 @@ +/** + * Sidecar-free route coverage for custom async AI image tools. + * + * These routes hand-roll multipart parsing and enqueue AI jobs directly, so the + * standard generated matrix mostly stops at the local bundle gate. This file + * forces only these gates open and mocks enqueueing so validation and job + * payload branches are covered without running Python models. + */ + +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { fixtures, readFixture } from "../../../fixtures/index.js"; +import { + buildTestApp, + createMultipartPayload, + loginAsAdmin, + type TestApp, +} from "../../test-server.js"; + +const mocks = vi.hoisted(() => ({ + enqueueToolJob: vi.fn(), + forcedInstalledTools: new Set([ + "ai-canvas-expand", + "background-replace", + "erase-object", + "upscale", + ]), +})); + +vi.mock("../../../../apps/api/src/lib/feature-status.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isToolInstalled: (toolId: string) => + mocks.forcedInstalledTools.has(toolId) ? true : actual.isToolInstalled(toolId), + }; +}); + +vi.mock("../../../../apps/api/src/jobs/enqueue.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + enqueueToolJob: mocks.enqueueToolJob, + }; +}); + +const PNG = readFixture(fixtures.image.base.png200); +const MASK = 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); + +beforeEach(() => { + mocks.enqueueToolJob.mockReset(); + mocks.enqueueToolJob.mockResolvedValue(undefined); +}); + +function postMultipart(url: string, fields: Parameters[0]) { + const { body, contentType } = createMultipartPayload(fields); + return app.inject({ + method: "POST", + url, + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); +} + +describe("custom async AI image routes", () => { + it("upscale validates input, coerces settings, and enqueues an AI job", async () => { + const clientJobId = "22222222-2222-4222-8222-222222222222"; + const res = await postMultipart("/api/v1/tools/image/upscale", [ + { name: "file", filename: "photo.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ scale: "4", model: "auto", faceEnhance: true, denoise: "3" }), + }, + { name: "clientJobId", content: clientJobId }, + { name: "fileId", content: "file-upscale" }, + ]); + + expect(res.statusCode).toBe(202); + expect(JSON.parse(res.body)).toEqual({ jobId: clientJobId, async: true }); + expect(mocks.enqueueToolJob).toHaveBeenCalledWith( + expect.objectContaining({ + toolId: "upscale", + pool: "ai", + filename: "photo.png", + settings: expect.objectContaining({ + scale: 4, + model: "auto", + faceEnhance: true, + denoise: 3, + }), + fileId: "file-upscale", + kind: "ai-tool", + }), + ); + }); + + it("upscale rejects missing files and malformed settings before enqueueing", async () => { + const noFile = await postMultipart("/api/v1/tools/image/upscale", [ + { name: "settings", content: JSON.stringify({}) }, + ]); + expect(noFile.statusCode).toBe(400); + expect(JSON.parse(noFile.body)).toMatchObject({ error: "No image file provided" }); + + const malformedSettings = await postMultipart("/api/v1/tools/image/upscale", [ + { name: "file", filename: "photo.png", contentType: "image/png", content: PNG }, + { name: "settings", content: "{bad json" }, + ]); + expect(malformedSettings.statusCode).toBe(400); + expect(JSON.parse(malformedSettings.body)).toMatchObject({ + error: "Settings must be valid JSON", + }); + expect(mocks.enqueueToolJob).not.toHaveBeenCalled(); + }); + + it("ai-canvas-expand validates directions before enqueueing", async () => { + const noDirection = await postMultipart("/api/v1/tools/image/ai-canvas-expand", [ + { name: "file", filename: "canvas.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({}) }, + ]); + expect(noDirection.statusCode).toBe(400); + expect(JSON.parse(noDirection.body)).toMatchObject({ + error: "At least one extend direction must be greater than 0", + }); + + const invalidTier = await postMultipart("/api/v1/tools/image/ai-canvas-expand", [ + { name: "file", filename: "canvas.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ extendLeft: 20, tier: "ultra" }) }, + ]); + expect(invalidTier.statusCode).toBe(400); + expect(JSON.parse(invalidTier.body)).toMatchObject({ error: "Invalid settings" }); + + expect(mocks.enqueueToolJob).not.toHaveBeenCalled(); + }); + + it("ai-canvas-expand enqueues valid extension requests with client job metadata", async () => { + const clientJobId = "33333333-3333-4333-8333-333333333333"; + const res = await postMultipart("/api/v1/tools/image/ai-canvas-expand", [ + { name: "file", filename: "canvas.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ + extendTop: 12, + extendRight: 24, + tier: "fast", + format: "webp", + quality: 80, + }), + }, + { name: "clientJobId", content: clientJobId }, + { name: "fileId", content: "file-canvas" }, + ]); + + expect(res.statusCode).toBe(202); + expect(JSON.parse(res.body)).toEqual({ jobId: clientJobId, async: true }); + expect(mocks.enqueueToolJob).toHaveBeenCalledWith( + expect.objectContaining({ + toolId: "ai-canvas-expand", + pool: "ai", + filename: "canvas.png", + settings: expect.objectContaining({ + extendTop: 12, + extendRight: 24, + tier: "fast", + format: "webp", + quality: 80, + }), + fileId: "file-canvas", + kind: "ai-tool", + }), + ); + }); + + it("background-replace validates color settings and enqueues gradient jobs", async () => { + const invalidColor = await postMultipart("/api/v1/tools/image/background-replace", [ + { name: "file", filename: "subject.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ color: "red" }) }, + ]); + expect(invalidColor.statusCode).toBe(400); + expect(JSON.parse(invalidColor.body)).toMatchObject({ error: "Invalid settings" }); + expect(mocks.enqueueToolJob).not.toHaveBeenCalled(); + + const res = await postMultipart("/api/v1/tools/image/background-replace", [ + { name: "file", filename: "subject.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: JSON.stringify({ + backgroundType: "gradient", + gradientColor1: "#000000", + gradientColor2: "#ffffff", + gradientAngle: 45, + feather: 4, + format: "webp", + }), + }, + ]); + + expect(res.statusCode).toBe(202); + expect(mocks.enqueueToolJob).toHaveBeenCalledWith( + expect.objectContaining({ + toolId: "background-replace", + pool: "ai", + filename: "subject.png", + settings: { + backgroundType: "gradient", + color: "#ffffff", + gradientColor1: "#000000", + gradientColor2: "#ffffff", + gradientAngle: 45, + feather: 4, + format: "webp", + }, + kind: "ai-tool", + }), + ); + }); + + it("erase-object validates the required mask and output settings", async () => { + const missingMask = await postMultipart("/api/v1/tools/image/erase-object", [ + { name: "file", filename: "subject.png", contentType: "image/png", content: PNG }, + ]); + expect(missingMask.statusCode).toBe(400); + expect(JSON.parse(missingMask.body)).toMatchObject({ + error: "No mask image provided. Upload a mask as a second file with fieldname 'mask'", + }); + + const invalidFormat = await postMultipart("/api/v1/tools/image/erase-object", [ + { name: "file", filename: "subject.png", contentType: "image/png", content: PNG }, + { name: "mask", filename: "mask.png", contentType: "image/png", content: MASK }, + { name: "format", content: "bmp" }, + ]); + expect(invalidFormat.statusCode).toBe(400); + expect(JSON.parse(invalidFormat.body)).toMatchObject({ error: "Invalid settings" }); + + expect(mocks.enqueueToolJob).not.toHaveBeenCalled(); + }); + + it("erase-object enqueues image and mask references for valid requests", async () => { + const clientJobId = "44444444-4444-4444-8444-444444444444"; + const res = await postMultipart("/api/v1/tools/image/erase-object", [ + { name: "file", filename: "subject.png", contentType: "image/png", content: PNG }, + { name: "mask", filename: "mask.png", contentType: "image/png", content: MASK }, + { name: "format", content: "webp" }, + { name: "quality", content: "72" }, + { name: "clientJobId", content: clientJobId }, + ]); + + expect(res.statusCode).toBe(202); + expect(JSON.parse(res.body)).toEqual({ jobId: clientJobId, async: true }); + expect(mocks.enqueueToolJob).toHaveBeenCalledWith( + expect.objectContaining({ + toolId: "erase-object", + pool: "ai", + filename: "subject.png", + inputRefs: expect.arrayContaining([ + expect.stringContaining("subject.png"), + expect.stringContaining("mask.png"), + ]), + settings: { format: "webp", quality: 72 }, + kind: "ai-tool", + }), + ); + }); +}); diff --git a/tests/integration/tools/image/ai-photo-route-coverage.test.ts b/tests/integration/tools/image/ai-photo-route-coverage.test.ts new file mode 100644 index 00000000..45ad7c18 --- /dev/null +++ b/tests/integration/tools/image/ai-photo-route-coverage.test.ts @@ -0,0 +1,270 @@ +/** + * Sidecar-free route coverage for async AI photo tools. + * + * These routes perform their own multipart parsing, image validation, settings + * parsing, object storage writes, and AI queue enqueueing before Python runs. + */ + +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { fixtures, readFixture } from "../../../fixtures/index.js"; +import { + buildTestApp, + createMultipartPayload, + loginAsAdmin, + type TestApp, +} from "../../test-server.js"; + +const mocks = vi.hoisted(() => ({ + enqueueToolJob: vi.fn(), + forcedInstalledTools: new Set([ + "blur-background", + "colorize", + "enhance-faces", + "noise-removal", + "red-eye-removal", + "restore-photo", + "transparency-fixer", + ]), +})); + +vi.mock("../../../../apps/api/src/lib/feature-status.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isToolInstalled: (toolId: string) => + mocks.forcedInstalledTools.has(toolId) ? true : actual.isToolInstalled(toolId), + }; +}); + +vi.mock("../../../../apps/api/src/jobs/enqueue.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + enqueueToolJob: mocks.enqueueToolJob, + }; +}); + +const PNG = readFixture(fixtures.image.base.png200); + +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); + +beforeEach(() => { + mocks.enqueueToolJob.mockReset(); + mocks.enqueueToolJob.mockResolvedValue(undefined); +}); + +function postTool( + toolId: string, + fields: Parameters[0], + modality = "image", +) { + const { body, contentType } = createMultipartPayload(fields); + return app.inject({ + method: "POST", + url: `/api/v1/tools/${modality}/${toolId}`, + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); +} + +function imageField(filename = "photo.png") { + return { name: "file", filename, contentType: "image/png", content: PNG }; +} + +async function postValid(toolId: string, settings: Record, clientJobId?: string) { + return postTool(toolId, [ + imageField(), + { name: "settings", content: JSON.stringify(settings) }, + ...(clientJobId ? [{ name: "clientJobId", content: clientJobId }] : []), + { name: "fileId", content: `file-${toolId}` }, + ]); +} + +function expectEnqueued(toolId: string, settings: Record) { + expect(mocks.enqueueToolJob).toHaveBeenCalledWith( + expect.objectContaining({ + toolId, + pool: "ai", + filename: "photo.png", + inputRefs: [expect.stringContaining("photo.png")], + settings: expect.objectContaining(settings), + fileId: `file-${toolId}`, + kind: "ai-tool", + }), + ); +} + +describe("async AI photo routes", () => { + it("colorize validates JSON settings and enqueues colorization jobs", async () => { + const malformed = await postTool("colorize", [ + imageField(), + { name: "settings", content: "{bad json" }, + ]); + expect(malformed.statusCode).toBe(400); + expect(JSON.parse(malformed.body)).toMatchObject({ error: "Settings must be valid JSON" }); + + const clientJobId = "55555555-5555-4555-8555-555555555555"; + const res = await postValid("colorize", { intensity: 0.45, model: "opencv" }, clientJobId); + expect(res.statusCode).toBe(202); + expect(JSON.parse(res.body)).toEqual({ jobId: clientJobId, async: true }); + expectEnqueued("colorize", { intensity: 0.45, model: "opencv" }); + }); + + it("noise-removal rejects invalid tiers and coerces numeric settings", async () => { + const invalid = await postValid("noise-removal", { tier: "ultra" }); + expect(invalid.statusCode).toBe(400); + expect(JSON.parse(invalid.body)).toMatchObject({ error: "Invalid settings" }); + expect(mocks.enqueueToolJob).not.toHaveBeenCalled(); + + const res = await postValid("noise-removal", { + tier: "quality", + strength: "42", + detailPreservation: "66", + colorNoise: "18", + format: "webp", + quality: "82", + }); + expect(res.statusCode).toBe(202); + expectEnqueued("noise-removal", { + tier: "quality", + strength: 42, + detailPreservation: 66, + colorNoise: 18, + format: "webp", + quality: 82, + }); + }); + + it("restore-photo enforces bounded restoration settings before enqueueing", async () => { + const invalid = await postValid("restore-photo", { fidelity: 1.5 }); + expect(invalid.statusCode).toBe(400); + expect(JSON.parse(invalid.body)).toMatchObject({ error: "Invalid settings" }); + expect(mocks.enqueueToolJob).not.toHaveBeenCalled(); + + const res = await postValid("restore-photo", { + scratchRemoval: false, + faceEnhancement: true, + fidelity: 0.6, + denoise: true, + denoiseStrength: 35, + colorize: true, + colorizeStrength: 75, + }); + expect(res.statusCode).toBe(202); + expectEnqueued("restore-photo", { + scratchRemoval: false, + faceEnhancement: true, + fidelity: 0.6, + denoise: true, + denoiseStrength: 35, + colorize: true, + colorizeStrength: 75, + }); + }); + + it("enhance-faces validates model settings and preserves client file metadata", async () => { + const invalid = await postValid("enhance-faces", { strength: 2 }); + expect(invalid.statusCode).toBe(400); + expect(JSON.parse(invalid.body)).toMatchObject({ error: "Invalid settings" }); + expect(mocks.enqueueToolJob).not.toHaveBeenCalled(); + + const res = await postValid("enhance-faces", { + model: "codeformer", + strength: 0.6, + onlyCenterFace: true, + sensitivity: 0.7, + }); + expect(res.statusCode).toBe(202); + expectEnqueued("enhance-faces", { + model: "codeformer", + strength: 0.6, + onlyCenterFace: true, + sensitivity: 0.7, + }); + }); + + it("red-eye-removal validates quality and enqueues correction settings", async () => { + const invalid = await postValid("red-eye-removal", { quality: 101 }); + expect(invalid.statusCode).toBe(400); + expect(JSON.parse(invalid.body)).toMatchObject({ error: "Invalid settings" }); + expect(mocks.enqueueToolJob).not.toHaveBeenCalled(); + + const res = await postValid("red-eye-removal", { + sensitivity: 40, + strength: 80, + format: "webp", + quality: 75, + }); + expect(res.statusCode).toBe(202); + expectEnqueued("red-eye-removal", { + sensitivity: 40, + strength: 80, + format: "webp", + quality: 75, + }); + }); + + it("blur-background rejects invalid output settings and enqueues valid blur jobs", async () => { + const invalid = await postValid("blur-background", { intensity: 0, format: "jpeg" }); + expect(invalid.statusCode).toBe(400); + expect(JSON.parse(invalid.body)).toMatchObject({ error: "Invalid settings" }); + expect(mocks.enqueueToolJob).not.toHaveBeenCalled(); + + const res = await postValid("blur-background", { + intensity: 65, + feather: 5, + format: "webp", + }); + expect(res.statusCode).toBe(202); + expectEnqueued("blur-background", { + intensity: 65, + feather: 5, + format: "webp", + }); + }); + + it("transparency-fixer validates output format and enqueues defringe settings", async () => { + const invalid = await postValid("transparency-fixer", { outputFormat: "jpg" }); + expect(invalid.statusCode).toBe(400); + expect(JSON.parse(invalid.body)).toMatchObject({ error: "Invalid settings" }); + expect(mocks.enqueueToolJob).not.toHaveBeenCalled(); + + const res = await postValid("transparency-fixer", { + defringe: 45, + outputFormat: "webp", + removeWatermark: true, + }); + expect(res.statusCode).toBe(202); + expectEnqueued("transparency-fixer", { + defringe: 45, + outputFormat: "webp", + removeWatermark: true, + }); + }); + + it("shared multipart guard rejects missing files before enqueueing", async () => { + const res = await postTool("enhance-faces", [ + { name: "settings", content: JSON.stringify({}) }, + ]); + + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body)).toMatchObject({ error: "No image file provided" }); + expect(mocks.enqueueToolJob).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/integration/tools/image/ocr-sidecar-fallback.test.ts b/tests/integration/tools/image/ocr-sidecar-fallback.test.ts new file mode 100644 index 00000000..2517cc99 --- /dev/null +++ b/tests/integration/tools/image/ocr-sidecar-fallback.test.ts @@ -0,0 +1,132 @@ +/** + * Focused sidecar-free integration coverage for OCR route fallback behavior. + * + * The route retries lower-quality OCR tiers when the Python process crashes or + * a higher tier returns empty text. These tests mock only the AI bridge and the + * installation gate so they exercise the Fastify route without running models. + */ + +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { fixtures, readFixture } from "../../../fixtures/index.js"; +import { + buildTestApp, + createMultipartPayload, + loginAsAdmin, + type TestApp, +} from "../../test-server.js"; + +const mocks = vi.hoisted(() => ({ + extractText: vi.fn(), +})); + +vi.mock("@snapotter/ai", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + extractText: mocks.extractText, + }; +}); + +vi.mock("../../../../apps/api/src/lib/feature-status.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isToolInstalled: (toolId: string) => (toolId === "ocr" ? true : actual.isToolInstalled(toolId)), + }; +}); + +const PNG = readFixture(fixtures.image.ocr.clean); + +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); + +beforeEach(() => { + mocks.extractText.mockReset(); +}); + +function postOcr(settings: Record) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "ocr-clean.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify(settings) }, + ]); + + return app.inject({ + method: "POST", + url: "/api/v1/tools/image/ocr", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); +} + +describe("ocr sidecar fallback coverage", () => { + it("falls back from crashed best tier to empty balanced tier to fast tier", async () => { + mocks.extractText + .mockRejectedValueOnce(new Error("Python process exited unexpectedly")) + .mockResolvedValueOnce({ text: "", engine: "paddleocr-v5" }) + .mockResolvedValueOnce({ text: "SnapOtter OCR", engine: "tesseract" }); + + const res = await postOcr({ quality: "best", language: "en", enhance: false }); + + expect(res.statusCode).toBe(200); + expect(mocks.extractText).toHaveBeenCalledTimes(3); + expect(mocks.extractText.mock.calls.map((call) => call[2].quality)).toEqual([ + "best", + "balanced", + "fast", + ]); + + const json = JSON.parse(res.body); + expect(json.text).toBe("SnapOtter OCR"); + expect(json.engine).toBe("tesseract"); + }); + + it("does not retry non-crash OCR errors", async () => { + mocks.extractText.mockRejectedValueOnce(new Error("language data missing")); + + const res = await postOcr({ quality: "balanced", language: "en" }); + + expect(res.statusCode).toBe(422); + expect(mocks.extractText).toHaveBeenCalledTimes(1); + + const json = JSON.parse(res.body); + expect(json.error).toBe("OCR failed"); + expect(json.details).toContain("language data missing"); + }); + + it("validates settings after the bundle gate passes", async () => { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "ocr-clean.png", contentType: "image/png", content: PNG }, + { name: "settings", content: JSON.stringify({ quality: "ultra" }) }, + ]); + + const res = await app.inject({ + method: "POST", + url: "/api/v1/tools/image/ocr", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); + + expect(res.statusCode).toBe(400); + const json = JSON.parse(res.body); + expect(json.error).toBe("Invalid settings"); + expect(mocks.extractText).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/integration/tools/image/smart-crop-sidecar-free.test.ts b/tests/integration/tools/image/smart-crop-sidecar-free.test.ts new file mode 100644 index 00000000..290ac9b6 --- /dev/null +++ b/tests/integration/tools/image/smart-crop-sidecar-free.test.ts @@ -0,0 +1,187 @@ +/** + * Sidecar-free smart-crop route coverage. + * + * Smart crop is feature-gated as an AI tool, but its subject and trim paths + * are pure Sharp. These tests force only the smart-crop gate open and mock face + * detection so the route exercises real processing without Python sidecars. + */ + +import sharp from "sharp"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { waitForJob } from "../../../../apps/api/src/jobs/enqueue.js"; +import { fixtures, readFixture } from "../../../fixtures/index.js"; +import { + buildTestApp, + createMultipartPayload, + loginAsAdmin, + type TestApp, +} from "../../test-server.js"; + +const mocks = vi.hoisted(() => ({ + detectFaces: vi.fn(), +})); + +vi.mock("@snapotter/ai", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + detectFaces: mocks.detectFaces, + }; +}); + +vi.mock("../../../../apps/api/src/lib/feature-status.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + isToolInstalled: (toolId: string) => + toolId === "smart-crop" ? true : actual.isToolInstalled(toolId), + }; +}); + +const PNG = readFixture(fixtures.image.base.png200); + +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); + +beforeEach(() => { + mocks.detectFaces.mockReset(); + mocks.detectFaces.mockResolvedValue({ facesDetected: 0, faces: [] }); +}); + +async function postSmartCrop(settings: Record | string) { + const { body, contentType } = createMultipartPayload([ + { name: "file", filename: "photo.png", contentType: "image/png", content: PNG }, + { + name: "settings", + content: typeof settings === "string" ? settings : JSON.stringify(settings), + }, + ]); + + return app.inject({ + method: "POST", + url: "/api/v1/tools/image/smart-crop", + headers: { + authorization: `Bearer ${adminToken}`, + "content-type": contentType, + }, + body, + }); +} + +async function downloadOutput(downloadUrl: string) { + return app.inject({ + method: "GET", + url: downloadUrl, + headers: { authorization: `Bearer ${adminToken}` }, + }); +} + +async function outputUrlFor(response: Awaited>): Promise { + const body = JSON.parse(response.body); + if (response.statusCode === 200) { + return body.downloadUrl; + } + + expect(response.statusCode).toBe(202); + const result = await waitForJob("ai", body.jobId, 20_000); + if (!result) { + throw new Error(`smart-crop job ${body.jobId} did not finish within the test window`); + } + return `/api/v1/download/${body.jobId}/${encodeURIComponent(result.filename)}`; +} + +describe("smart-crop sidecar-free processing", () => { + it("runs subject mode and returns the requested output dimensions", async () => { + const res = await postSmartCrop({ + mode: "subject", + width: 96, + height: 64, + strategy: "entropy", + }); + + const outputUrl = await outputUrlFor(res); + const download = await downloadOutput(outputUrl); + const meta = await sharp(download.rawPayload).metadata(); + + expect(meta.width).toBe(96); + expect(meta.height).toBe(64); + expect(outputUrl).toContain("_smartcrop."); + }); + + it("maps legacy attention/content modes to subject and trim behavior", async () => { + const attention = await postSmartCrop({ mode: "attention", width: 80, height: 80 }); + await expect(outputUrlFor(attention)).resolves.toContain("_smartcrop."); + + const content = await postSmartCrop({ + mode: "content", + padToSquare: true, + targetSize: 72, + padColor: "#eeeeee", + }); + const download = await downloadOutput(await outputUrlFor(content)); + const meta = await sharp(download.rawPayload).metadata(); + expect(meta.width).toBe(72); + expect(meta.height).toBe(72); + }); + + it("falls back to subject cropping when face detection finds no faces", async () => { + mocks.detectFaces.mockResolvedValueOnce({ facesDetected: 0, faces: [] }); + + const res = await postSmartCrop({ + mode: "face", + width: 90, + height: 90, + sensitivity: 0.75, + }); + + const outputUrl = await outputUrlFor(res); + expect(mocks.detectFaces).toHaveBeenCalledWith(expect.any(Buffer), { sensitivity: 0.75 }); + + const download = await downloadOutput(outputUrl); + const meta = await sharp(download.rawPayload).metadata(); + expect(meta.width).toBe(90); + expect(meta.height).toBe(90); + }); + + it("uses detected face bounds for face mode crops", async () => { + mocks.detectFaces.mockResolvedValueOnce({ + facesDetected: 1, + faces: [{ x: 40, y: 30, w: 80, h: 70 }], + }); + + const res = await postSmartCrop({ + mode: "face", + width: 120, + height: 80, + facePreset: "closeup", + padding: 10, + }); + + const download = await downloadOutput(await outputUrlFor(res)); + const meta = await sharp(download.rawPayload).metadata(); + expect(meta.width).toBe(120); + expect(meta.height).toBe(80); + }); + + it("rejects malformed JSON and invalid setting ranges before enqueueing", async () => { + const malformed = await postSmartCrop("{bad json"); + expect(malformed.statusCode).toBe(400); + expect(JSON.parse(malformed.body)).toMatchObject({ error: "Settings must be valid JSON" }); + + const invalid = await postSmartCrop({ mode: "trim", threshold: 999 }); + expect(invalid.statusCode).toBe(400); + expect(JSON.parse(invalid.body)).toMatchObject({ error: "Invalid settings" }); + }); +}); diff --git a/tests/unit/api/ai-image-job-handlers.test.ts b/tests/unit/api/ai-image-job-handlers.test.ts new file mode 100644 index 00000000..f092f2cd --- /dev/null +++ b/tests/unit/api/ai-image-job-handlers.test.ts @@ -0,0 +1,362 @@ +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + colorize, + enhanceFaces, + isMemoryAllocError, + noiseRemoval, + removeBackground, + removeRedEye, + restorePhoto, +} from "@snapotter/ai"; +import type { FastifyInstance } from "fastify"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { runAiToolJob } from "../../../apps/api/src/jobs/ai-handlers.js"; +import type { ToolJobData } from "../../../apps/api/src/jobs/types.js"; +import type { ToolProcessCtx } from "../../../apps/api/src/routes/tool-factory.js"; +import { getToolConfig } from "../../../apps/api/src/routes/tool-factory.js"; +import { registerColorize } from "../../../apps/api/src/routes/tools/colorize.js"; +import { registerEnhanceFaces } from "../../../apps/api/src/routes/tools/enhance-faces.js"; +import { registerNoiseRemoval } from "../../../apps/api/src/routes/tools/noise-removal.js"; +import { registerRedEyeRemoval } from "../../../apps/api/src/routes/tools/red-eye-removal.js"; +import { registerRestorePhoto } from "../../../apps/api/src/routes/tools/restore-photo.js"; +import { registerTransparencyFixer } from "../../../apps/api/src/routes/tools/transparency-fixer.js"; +import { fixtures, readFixture } from "../../fixtures/index.js"; + +const aiMocks = vi.hoisted(() => ({ + colorize: vi.fn(), + enhanceFaces: vi.fn(), + isMemoryAllocError: vi.fn(), + noiseRemoval: vi.fn(), + removeBackground: vi.fn(), + removeRedEye: vi.fn(), + restorePhoto: vi.fn(), +})); + +vi.mock("@snapotter/ai", () => ({ + colorize: aiMocks.colorize, + enhanceFaces: aiMocks.enhanceFaces, + isMemoryAllocError: aiMocks.isMemoryAllocError, + noiseRemoval: aiMocks.noiseRemoval, + removeBackground: aiMocks.removeBackground, + removeRedEye: aiMocks.removeRedEye, + restorePhoto: aiMocks.restorePhoto, +})); + +const PNG = readFixture(fixtures.image.base.png200); +const SCRATCH_DIR = join(tmpdir(), "snapotter-ai-handler-test"); + +const ctx: ToolProcessCtx = { + signal: new AbortController().signal, + scratchDir: SCRATCH_DIR, + report: vi.fn(), +}; + +const fakeApp = { + post: vi.fn(), +} as unknown as FastifyInstance; + +function job(toolId: string, settings: unknown, filename = "photo.png"): ToolJobData { + return { + jobId: `job-${toolId}`, + toolId, + userId: null, + pool: "ai", + inputRefs: [`uploads/job-${toolId}/${filename}`], + filename, + settings, + kind: "ai-tool", + }; +} + +function resetAiMocks() { + vi.mocked(colorize).mockResolvedValue({ + buffer: PNG, + width: 200, + height: 150, + method: "mock-colorizer", + }); + vi.mocked(noiseRemoval).mockResolvedValue({ + buffer: PNG, + format: "jpeg", + }); + vi.mocked(restorePhoto).mockResolvedValue({ + buffer: PNG, + width: 200, + height: 150, + steps: ["denoise"], + scratchCoverage: 0.12, + facesEnhanced: 1, + isGrayscale: false, + colorized: true, + }); + vi.mocked(enhanceFaces).mockResolvedValue({ + buffer: PNG, + facesDetected: 2, + faces: [{ x: 1, y: 2, width: 20, height: 30 }], + model: "codeformer", + }); + vi.mocked(removeRedEye).mockResolvedValue({ + buffer: PNG, + facesDetected: 1, + eyesCorrected: 2, + }); + vi.mocked(removeBackground).mockResolvedValue(PNG); + vi.mocked(isMemoryAllocError).mockReturnValue(false); +} + +beforeEach(() => { + vi.clearAllMocks(); + resetAiMocks(); + ctx.report = vi.fn(); +}); + +describe("AI image job handlers", () => { + it("runs colorize jobs with parsed settings and output metadata", async () => { + vi.mocked(colorize).mockImplementation(async (_input, _scratch, _settings, report) => { + report?.(40, "colorizing"); + return { buffer: PNG, width: 200, height: 150, method: "mock-colorizer" }; + }); + + const result = await runAiToolJob( + job("colorize", { intensity: 0.5, model: "opencv" }), + PNG, + ctx, + ); + + expect(colorize).toHaveBeenCalledWith( + PNG, + SCRATCH_DIR, + { intensity: 0.5, model: "opencv" }, + expect.any(Function), + ); + expect(ctx.report).toHaveBeenCalledWith(40, "colorizing"); + expect(result).toMatchObject({ + filename: "photo_colorized.png", + contentType: "image/png", + resultPayload: { width: 200, height: 150, method: "mock-colorizer" }, + }); + }); + + it("runs denoise jobs and maps jpeg outputs to jpg filenames", async () => { + const result = await runAiToolJob( + job("noise-removal", { + tier: "quality", + strength: "60", + detailPreservation: 70, + colorNoise: 10, + format: "jpeg", + quality: 82, + }), + PNG, + ctx, + ); + + expect(noiseRemoval).toHaveBeenCalledWith( + PNG, + SCRATCH_DIR, + { + tier: "quality", + strength: 60, + detailPreservation: 70, + colorNoise: 10, + format: "jpeg", + quality: 82, + }, + expect.any(Function), + ); + expect(result).toMatchObject({ + filename: "photo_denoised.jpg", + contentType: "image/jpeg", + }); + }); + + it("runs restoration jobs and returns worker result payload details", async () => { + const result = await runAiToolJob( + job("restore-photo", { + scratchRemoval: true, + faceEnhancement: true, + fidelity: 0.75, + denoise: true, + denoiseStrength: 35, + colorize: true, + colorizeStrength: 80, + }), + PNG, + ctx, + ); + + expect(restorePhoto).toHaveBeenCalledWith( + PNG, + SCRATCH_DIR, + { + scratchRemoval: true, + faceEnhancement: true, + fidelity: 0.75, + denoise: true, + denoiseStrength: 35, + colorize: true, + colorizeStrength: 80, + }, + expect.any(Function), + ); + expect(result).toMatchObject({ + filename: "photo_restored.png", + contentType: "image/png", + resultPayload: { + steps: ["denoise"], + scratchCoverage: 0.12, + facesEnhanced: 1, + isGrayscale: false, + colorized: true, + }, + }); + }); + + it("runs face enhancement and red-eye handlers with AI result payloads", async () => { + const enhanced = await runAiToolJob( + job("enhance-faces", { + model: "codeformer", + strength: 0.65, + onlyCenterFace: true, + sensitivity: 0.7, + }), + PNG, + ctx, + ); + const redEye = await runAiToolJob( + job("red-eye-removal", { + sensitivity: 45, + strength: 80, + format: "png", + quality: 90, + }), + PNG, + ctx, + ); + + expect(enhanceFaces).toHaveBeenCalledWith( + PNG, + SCRATCH_DIR, + { model: "codeformer", strength: 0.65, onlyCenterFace: true, sensitivity: 0.7 }, + expect.any(Function), + ); + expect(enhanced).toMatchObject({ + filename: "photo_enhanced.png", + contentType: "image/png", + resultPayload: { facesDetected: 2, model: "codeformer" }, + }); + expect(removeRedEye).toHaveBeenCalledWith( + PNG, + SCRATCH_DIR, + { sensitivity: 45, strength: 80, format: "png", quality: 90 }, + expect.any(Function), + ); + expect(redEye).toMatchObject({ + filename: "photo_redeye_fixed.png", + contentType: "image/png", + resultPayload: { facesDetected: 1, eyesCorrected: 2 }, + }); + }); + + it("falls back to the lower-memory transparency model on OOM", async () => { + vi.mocked(removeBackground) + .mockRejectedValueOnce(new Error("out of memory")) + .mockResolvedValueOnce(PNG); + vi.mocked(isMemoryAllocError).mockReturnValue(true); + + const result = await runAiToolJob( + job("transparency-fixer", { + defringe: 0, + outputFormat: "png", + removeWatermark: false, + }), + PNG, + ctx, + ); + + expect(removeBackground).toHaveBeenNthCalledWith( + 1, + PNG, + SCRATCH_DIR, + { model: "birefnet-hr-matting" }, + expect.any(Function), + ); + expect(removeBackground).toHaveBeenNthCalledWith( + 2, + PNG, + SCRATCH_DIR, + { model: "birefnet-general" }, + expect.any(Function), + ); + expect(ctx.report).toHaveBeenCalledWith(5, "Retrying with fallback model (birefnet-general)"); + expect(result).toMatchObject({ + filename: "photo_fixed.png", + contentType: "image/png", + resultPayload: { filename: "photo.png" }, + }); + }); +}); + +describe("AI image pipeline process registrations", () => { + beforeEach(() => { + registerColorize(fakeApp); + registerNoiseRemoval(fakeApp); + registerRestorePhoto(fakeApp); + registerEnhanceFaces(fakeApp); + registerRedEyeRemoval(fakeApp); + registerTransparencyFixer(fakeApp); + }); + + it("registers pipeline processors for custom AI photo routes", async () => { + const colorizeConfig = getToolConfig("colorize"); + const noiseConfig = getToolConfig("noise-removal"); + const restoreConfig = getToolConfig("restore-photo"); + const enhanceConfig = getToolConfig("enhance-faces"); + const redEyeConfig = getToolConfig("red-eye-removal"); + const transparencyConfig = getToolConfig("transparency-fixer"); + + expect(colorizeConfig).toBeDefined(); + expect(noiseConfig).toBeDefined(); + expect(restoreConfig).toBeDefined(); + expect(enhanceConfig).toBeDefined(); + expect(redEyeConfig).toBeDefined(); + expect(transparencyConfig).toBeDefined(); + + await expect( + colorizeConfig?.process(PNG, { intensity: 0.9, model: "opencv" }, "photo.png", ctx), + ).resolves.toMatchObject({ filename: "photo_colorized.png", contentType: "image/png" }); + await expect( + noiseConfig?.process( + PNG, + { + tier: "balanced", + strength: 50, + detailPreservation: 40, + colorNoise: 20, + format: "jpeg", + quality: 90, + }, + "photo.png", + ctx, + ), + ).resolves.toMatchObject({ filename: "photo_denoised.jpg", contentType: "image/jpeg" }); + await expect( + restoreConfig?.process(PNG, { fidelity: 0.7 }, "photo.png", ctx), + ).resolves.toMatchObject({ filename: "photo_restored.png", contentType: "image/png" }); + await expect( + enhanceConfig?.process(PNG, { model: "auto" }, "photo.png", ctx), + ).resolves.toMatchObject({ filename: "photo_enhanced.png", contentType: "image/png" }); + await expect( + redEyeConfig?.process(PNG, { sensitivity: 50 }, "photo.png", ctx), + ).resolves.toMatchObject({ filename: "photo_redeye_fixed.png", contentType: "image/png" }); + await expect( + transparencyConfig?.process( + PNG, + { defringe: 0, outputFormat: "png", removeWatermark: false }, + "photo.png", + ctx, + ), + ).resolves.toMatchObject({ filename: "photo_fixed.png", contentType: "image/png" }); + }); +}); diff --git a/tests/unit/api/document-route-processors.test.ts b/tests/unit/api/document-route-processors.test.ts new file mode 100644 index 00000000..6c0050a1 --- /dev/null +++ b/tests/unit/api/document-route-processors.test.ts @@ -0,0 +1,175 @@ +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +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, + RATE_LIMIT_PER_MIN: 0, + }, +})); + +vi.mock("@snapotter/doc-engine", () => ({ + htmlToPdfPy: vi.fn(), + pdfFlattenPy: vi.fn(), + pdfTextPy: vi.fn(async () => ({ chars: 12 })), + qpdfAvailable: vi.fn(() => false), + qpdfCheck: vi.fn(), + qpdfPageCount: vi.fn(), + resolveGs: vi.fn(() => null), + resolveQpdf: vi.fn(() => null), + resolveSoffice: vi.fn(() => null), + sofficeAvailable: vi.fn(() => false), +})); + +import { htmlToPdfPy, pdfFlattenPy, pdfTextPy } from "@snapotter/doc-engine"; +import type { FastifyInstance } from "fastify"; +import { getToolConfig } from "../../../apps/api/src/routes/tool-factory.js"; +import { registerFlattenPdf } from "../../../apps/api/src/routes/tools/flatten-pdf.js"; +import { registerHtmlToPdf } from "../../../apps/api/src/routes/tools/html-to-pdf.js"; +import { registerMarkdownToPdf } from "../../../apps/api/src/routes/tools/markdown-to-pdf.js"; +import { registerPdfToText } from "../../../apps/api/src/routes/tools/pdf-to-text.js"; + +function createMockApp(): FastifyInstance { + return { + post: vi.fn(), + } as unknown as FastifyInstance; +} + +async function withScratch(fn: (scratchDir: string) => Promise): Promise { + const scratchDir = await mkdtemp(join(tmpdir(), "snapotter-doc-route-")); + try { + return await fn(scratchDir); + } finally { + await rm(scratchDir, { recursive: true, force: true }); + } +} + +function createCtx(scratchDir: string, filename: string) { + return { + inputs: [{ buffer: Buffer.from("input"), filename, ref: "uploads/job/input" }], + settings: {}, + scratchDir, + signal: new AbortController().signal, + report: vi.fn(), + }; +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("document route processors", () => { + it("extracts PDF text through pdfTextPy and returns text metadata", async () => { + registerPdfToText(createMockApp()); + const config = getToolConfig("pdf-to-text"); + + await withScratch(async (scratchDir) => { + const ctx = createCtx(scratchDir, "Quarterly Report.pdf"); + const result = await config?.processV2?.(ctx); + + expect(pdfTextPy).toHaveBeenCalledWith( + join(scratchDir, "in-Quarterly_Report.pdf"), + join(scratchDir, "Quarterly Report.txt"), + ); + expect(await readFile(join(scratchDir, "in-Quarterly_Report.pdf"))).toEqual( + Buffer.from("input"), + ); + expect(ctx.report).toHaveBeenNthCalledWith(1, 10, "Extracting text"); + expect(ctx.report).toHaveBeenNthCalledWith(2, 90, "Done"); + expect(result).toEqual({ + scratchPath: join(scratchDir, "Quarterly Report.txt"), + filename: "Quarterly Report.txt", + contentType: "text/plain", + resultPayload: { chars: 12 }, + }); + }); + }); + + it("flattens PDFs through pdfFlattenPy", async () => { + registerFlattenPdf(createMockApp()); + const config = getToolConfig("flatten-pdf"); + + await withScratch(async (scratchDir) => { + const ctx = createCtx(scratchDir, "form.v2.pdf"); + const result = await config?.processV2?.(ctx); + + expect(pdfFlattenPy).toHaveBeenCalledWith( + join(scratchDir, "in-form.v2.pdf"), + join(scratchDir, "form.v2_flattened.pdf"), + ); + expect(ctx.report).toHaveBeenNthCalledWith(1, 10, "Flattening"); + expect(ctx.report).toHaveBeenNthCalledWith(2, 90, "Done"); + expect(result).toEqual({ + scratchPath: join(scratchDir, "form.v2_flattened.pdf"), + filename: "form.v2_flattened.pdf", + contentType: "application/pdf", + }); + }); + }); + + it("converts HTML input through htmlToPdfPy in html mode", async () => { + registerHtmlToPdf(createMockApp()); + const config = getToolConfig("html-to-pdf"); + + await withScratch(async (scratchDir) => { + const ctx = createCtx(scratchDir, "landing page.html"); + const result = await config?.processV2?.(ctx); + + expect(htmlToPdfPy).toHaveBeenCalledWith( + join(scratchDir, "in-landing_page.html"), + join(scratchDir, "landing page.pdf"), + "html", + ); + expect(ctx.report).toHaveBeenNthCalledWith(1, 10, "Converting"); + expect(ctx.report).toHaveBeenNthCalledWith(2, 90, "Done"); + expect(result).toEqual({ + scratchPath: join(scratchDir, "landing page.pdf"), + filename: "landing page.pdf", + contentType: "application/pdf", + }); + }); + }); + + it("converts Markdown input through htmlToPdfPy in markdown mode", async () => { + registerMarkdownToPdf(createMockApp()); + const config = getToolConfig("markdown-to-pdf"); + + await withScratch(async (scratchDir) => { + const ctx = createCtx(scratchDir, "release-notes.md"); + const result = await config?.processV2?.(ctx); + + expect(htmlToPdfPy).toHaveBeenCalledWith( + join(scratchDir, "in-release-notes.md"), + join(scratchDir, "release-notes.pdf"), + "markdown", + ); + expect(ctx.report).toHaveBeenNthCalledWith(1, 10, "Converting"); + expect(ctx.report).toHaveBeenNthCalledWith(2, 90, "Done"); + expect(result).toEqual({ + scratchPath: join(scratchDir, "release-notes.pdf"), + filename: "release-notes.pdf", + contentType: "application/pdf", + }); + }); + }); +}); diff --git a/tests/unit/api/jobs/alert-evaluator.behavior.test.ts b/tests/unit/api/jobs/alert-evaluator.behavior.test.ts new file mode 100644 index 00000000..fe2a66a1 --- /dev/null +++ b/tests/unit/api/jobs/alert-evaluator.behavior.test.ts @@ -0,0 +1,153 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const statfsMock = vi.hoisted(() => vi.fn()); +const getSettingStringMock = vi.hoisted(() => vi.fn()); +const deliverWebhookMock = vi.hoisted(() => vi.fn()); +const decryptMock = vi.hoisted(() => vi.fn()); +const isEncryptedMock = vi.hoisted(() => vi.fn()); +const getActiveLicenseMock = vi.hoisted(() => vi.fn()); +const selectMock = vi.hoisted(() => vi.fn()); + +function queryChain(result: T) { + const chain = { + from: vi.fn(() => chain), + where: vi.fn(() => Promise.resolve(result)), + }; + return chain; +} + +async function loadAlertEvaluator() { + vi.resetModules(); + statfsMock.mockReset(); + getSettingStringMock.mockReset(); + deliverWebhookMock.mockReset(); + decryptMock.mockReset(); + isEncryptedMock.mockReset(); + getActiveLicenseMock.mockReset(); + selectMock.mockReset(); + + vi.doMock("node:fs/promises", () => ({ + statfs: statfsMock, + })); + + vi.doMock("drizzle-orm", () => ({ + and: vi.fn(() => "and"), + eq: vi.fn(() => "eq"), + gte: vi.fn(() => "gte"), + sql: vi.fn(() => "sql"), + })); + + vi.doMock("../../../../apps/api/src/config.js", () => ({ + env: { + WORKSPACE_PATH: "/workspace", + DATA_ENCRYPTION_KEY: "test-key", + }, + })); + + vi.doMock("../../../../apps/api/src/db/index.js", () => ({ + db: { + select: selectMock, + }, + schema: { + auditLog: { + action: "action", + createdAt: "createdAt", + }, + }, + })); + + vi.doMock("../../../../apps/api/src/lib/settings-helpers.js", () => ({ + getSettingString: getSettingStringMock, + })); + + vi.doMock("../../../../apps/api/src/lib/encryption.js", () => ({ + decrypt: decryptMock, + isEncrypted: isEncryptedMock, + })); + + vi.doMock("../../../../apps/api/src/lib/webhook-delivery.js", () => ({ + deliverWebhook: deliverWebhookMock, + })); + + vi.doMock("@snapotter/enterprise", () => ({ + getActiveLicense: getActiveLicenseMock, + })); + + return import("../../../../apps/api/src/jobs/alert-evaluator.js"); +} + +describe("alert evaluator behavior", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns early when webhook destination settings are invalid JSON", async () => { + const { evaluateAlerts } = await loadAlertEvaluator(); + getSettingStringMock.mockResolvedValueOnce("{invalid"); + + await evaluateAlerts(); + + expect(statfsMock).not.toHaveBeenCalled(); + expect(deliverWebhookMock).not.toHaveBeenCalled(); + }); + + it("returns early when no enabled alert destinations are configured", async () => { + const { evaluateAlerts } = await loadAlertEvaluator(); + getSettingStringMock.mockResolvedValueOnce( + JSON.stringify([ + { url: "https://example.test/siem", authHeader: "", enabled: true, type: "siem" }, + { url: "https://example.test/alerts", authHeader: "", enabled: false, type: "alerts" }, + ]), + ); + + await evaluateAlerts(); + + expect(statfsMock).not.toHaveBeenCalled(); + expect(deliverWebhookMock).not.toHaveBeenCalled(); + }); + + it("delivers triggered alerts to enabled alert webhooks with decrypted auth", async () => { + vi.spyOn(Date, "now").mockReturnValue(new Date("2026-06-29T12:00:00.000Z").getTime()); + const { evaluateAlerts } = await loadAlertEvaluator(); + + getSettingStringMock + .mockResolvedValueOnce( + JSON.stringify([ + { + url: "https://example.test/alerts", + authHeader: "enc:token", + enabled: true, + type: "alerts", + }, + { + url: "https://example.test/ignored", + authHeader: "", + enabled: true, + type: "siem", + }, + ]), + ) + .mockResolvedValueOnce(JSON.stringify({ timestamp: "2026-06-27T11:59:00.000Z" })); + statfsMock.mockResolvedValue({ bfree: 100, bsize: 1024 }); + selectMock.mockReturnValue(queryChain([{ count: 21 }])); + getActiveLicenseMock.mockReturnValue({ expiresAt: "2026-07-05T12:00:00.000Z" }); + isEncryptedMock.mockReturnValue(true); + decryptMock.mockResolvedValue("Bearer decrypted"); + deliverWebhookMock.mockResolvedValue({ success: true }); + + await evaluateAlerts(); + + expect(deliverWebhookMock).toHaveBeenCalledTimes(1); + expect(deliverWebhookMock).toHaveBeenCalledWith( + "https://example.test/alerts", + "Bearer decrypted", + expect.arrayContaining([ + expect.objectContaining({ condition: "disk_space_low" }), + expect.objectContaining({ condition: "auth_anomaly", failedLogins: 21 }), + expect.objectContaining({ condition: "backup_stale" }), + expect.objectContaining({ condition: "license_expiring", daysLeft: 6 }), + ]), + { maxRetries: 1 }, + ); + }); +}); diff --git a/tests/unit/api/jobs/audit-archive.behavior.test.ts b/tests/unit/api/jobs/audit-archive.behavior.test.ts new file mode 100644 index 00000000..cd7e6697 --- /dev/null +++ b/tests/unit/api/jobs/audit-archive.behavior.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const isFeatureEnabledMock = vi.hoisted(() => vi.fn()); +const selectMock = vi.hoisted(() => vi.fn()); +const deleteMock = vi.hoisted(() => vi.fn()); +const upsertSettingMock = vi.hoisted(() => vi.fn()); +const mkdirMock = vi.hoisted(() => vi.fn()); + +function queryChain(result: T) { + const chain = { + from: vi.fn(() => chain), + where: vi.fn(() => Promise.resolve(result)), + }; + return chain; +} + +async function loadAuditArchive() { + vi.resetModules(); + isFeatureEnabledMock.mockReset(); + selectMock.mockReset(); + deleteMock.mockReset(); + upsertSettingMock.mockReset(); + mkdirMock.mockReset(); + + vi.doMock("node:fs/promises", () => ({ + mkdir: mkdirMock, + stat: vi.fn(), + })); + + vi.doMock("node:fs", () => ({ + createWriteStream: vi.fn(), + })); + + vi.doMock("node:stream/promises", () => ({ + pipeline: vi.fn(), + })); + + vi.doMock("drizzle-orm", () => ({ + eq: vi.fn(() => "eq"), + lt: vi.fn(() => "lt"), + })); + + vi.doMock("@snapotter/enterprise", () => ({ + isFeatureEnabled: isFeatureEnabledMock, + })); + + vi.doMock("../../../../apps/api/src/config.js", () => ({ + env: { FILES_STORAGE_PATH: "/data/files" }, + })); + + vi.doMock("../../../../apps/api/src/db/index.js", () => ({ + db: { + select: selectMock, + delete: deleteMock, + }, + schema: { + settings: { + key: "settings.key", + value: "settings.value", + }, + auditLog: { + createdAt: "auditLog.createdAt", + }, + }, + })); + + vi.doMock("../../../../apps/api/src/lib/settings-helpers.js", () => ({ + upsertSetting: upsertSettingMock, + })); + + return import("../../../../apps/api/src/jobs/audit-archive.js"); +} + +describe("audit archive job behavior", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns before reading archive settings when the enterprise feature is disabled", async () => { + const { runAuditArchive } = await loadAuditArchive(); + isFeatureEnabledMock.mockReturnValue(false); + + await runAuditArchive(); + + expect(selectMock).not.toHaveBeenCalled(); + expect(upsertSettingMock).not.toHaveBeenCalled(); + }); + + it("returns when archive months is missing or disabled", async () => { + const { runAuditArchive } = await loadAuditArchive(); + isFeatureEnabledMock.mockReturnValue(true); + selectMock.mockReturnValueOnce(queryChain([{ value: "0" }])); + + await runAuditArchive(); + + expect(mkdirMock).not.toHaveBeenCalled(); + expect(upsertSettingMock).not.toHaveBeenCalled(); + }); + + it("clears archival state when there are no rows older than the boundary", async () => { + const deleteWhere = vi.fn().mockResolvedValue(undefined); + const { runAuditArchive } = await loadAuditArchive(); + deleteMock.mockReturnValue({ where: deleteWhere }); + isFeatureEnabledMock.mockReturnValue(true); + selectMock + .mockReturnValueOnce(queryChain([{ value: "1" }])) + .mockReturnValueOnce(queryChain([])) + .mockReturnValueOnce(queryChain([])); + + await runAuditArchive(); + + expect(upsertSettingMock).toHaveBeenCalledWith( + "audit_archival_state", + expect.stringContaining('"state":"EXPORTING"'), + ); + expect(mkdirMock).toHaveBeenCalledWith("/data/audit-archives", { recursive: true }); + expect(deleteWhere).toHaveBeenCalled(); + }); +}); diff --git a/tests/unit/api/jobs/enqueue.behavior.test.ts b/tests/unit/api/jobs/enqueue.behavior.test.ts new file mode 100644 index 00000000..783363be --- /dev/null +++ b/tests/unit/api/jobs/enqueue.behavior.test.ts @@ -0,0 +1,168 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const insertedValues = vi.hoisted(() => vi.fn()); +const queueAdd = vi.hoisted(() => vi.fn()); +const getJob = vi.hoisted(() => vi.fn()); +const queueEventClose = vi.hoisted(() => vi.fn()); +const flowProducerClose = vi.hoisted(() => vi.fn()); + +async function loadEnqueueModule() { + vi.resetModules(); + insertedValues.mockReset(); + queueAdd.mockReset(); + getJob.mockReset(); + queueEventClose.mockReset(); + flowProducerClose.mockReset(); + + queueAdd.mockResolvedValue({ id: "job-1" }); + queueEventClose.mockResolvedValue(undefined); + flowProducerClose.mockResolvedValue(undefined); + + vi.doMock("bullmq", () => ({ + QueueEvents: vi.fn(() => ({ + close: queueEventClose, + waitUntilReady: vi.fn().mockResolvedValue(undefined), + })), + FlowProducer: vi.fn(() => ({ + close: flowProducerClose, + })), + })); + + vi.doMock("../../../../apps/api/src/config.js", () => ({ + env: { SYNC_WAIT_MS: 50 }, + })); + + vi.doMock("../../../../apps/api/src/db/index.js", () => ({ + db: { + insert: vi.fn(() => ({ + values: insertedValues.mockResolvedValue(undefined), + })), + }, + schema: { + jobs: {}, + }, + })); + + vi.doMock("../../../../apps/api/src/jobs/connection.js", () => ({ + createBullMQConnection: vi.fn(() => ({ mocked: "connection" })), + })); + + vi.doMock("../../../../apps/api/src/jobs/queues.js", () => ({ + getQueue: vi.fn(() => ({ + add: queueAdd, + getJob, + })), + })); + + return import("../../../../apps/api/src/jobs/enqueue.js"); +} + +describe("job enqueue helpers", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("strips NUL bytes recursively before persisting settings but keeps queue data intact", async () => { + const { enqueueToolJob } = await loadEnqueueModule(); + const data = { + jobId: "job-1", + userId: null, + toolId: "tool-a", + pool: "image", + kind: "single", + inputRefs: ["uploads/job-1/input.png"], + filename: "input.png", + settings: { + title: "a\0b", + nested: { value: "c\0d" }, + list: ["e\0f", 1], + }, + } as never; + + await enqueueToolJob(data); + + expect(insertedValues).toHaveBeenCalledWith( + expect.objectContaining({ + id: "job-1", + settings: { + title: "ab", + nested: { value: "cd" }, + list: ["ef", 1], + }, + }), + ); + expect(queueAdd).toHaveBeenCalledWith( + "tool-a", + expect.objectContaining({ + settings: { + title: "a\0b", + nested: { value: "c\0d" }, + list: ["e\0f", 1], + }, + }), + { jobId: "job-1" }, + ); + }); + + it("persists redacted dbSettings while enqueueing real settings", async () => { + const { enqueueToolJob } = await loadEnqueueModule(); + + await enqueueToolJob({ + jobId: "job-2", + userId: null, + toolId: "ftp-upload", + pool: "system", + kind: "single", + inputRefs: [], + filename: "file.txt", + settings: { password: "secret" }, + dbSettings: { password: "[redacted]" }, + } as never); + + expect(insertedValues).toHaveBeenCalledWith( + expect.objectContaining({ settings: { password: "[redacted]" } }), + ); + expect(queueAdd).toHaveBeenCalledWith( + "ftp-upload", + expect.objectContaining({ settings: { password: "secret" } }), + { jobId: "job-2" }, + ); + }); + + it("waitForJob returns null when the job is missing or the sync window times out", async () => { + const { waitForJob } = await loadEnqueueModule(); + + getJob.mockResolvedValueOnce(undefined); + await expect(waitForJob("image", "missing")).resolves.toBeNull(); + + getJob.mockResolvedValueOnce({ + waitUntilFinished: vi.fn().mockRejectedValue(new Error("job timed out before finishing")), + }); + await expect(waitForJob("image", "slow", 25)).resolves.toBeNull(); + }); + + it("waitForJob rethrows real job failures", async () => { + const { waitForJob } = await loadEnqueueModule(); + getJob.mockResolvedValueOnce({ + waitUntilFinished: vi.fn().mockRejectedValue(new Error("processor failed")), + }); + + await expect(waitForJob("image", "failed")).rejects.toThrow("processor failed"); + }); + + it("closes lazy QueueEvents and FlowProducer singletons", async () => { + const { closeFlowProducer, closeQueueEvents, getFlowProducer, warmQueueEvents, waitForJob } = + await loadEnqueueModule(); + + await warmQueueEvents(); + getFlowProducer(); + getJob.mockResolvedValueOnce(undefined); + await waitForJob("image", "job-1"); + + await closeQueueEvents(); + await closeFlowProducer(); + + expect(queueEventClose).toHaveBeenCalled(); + expect(flowProducerClose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/unit/api/jobs/gdpr-export.behavior.test.ts b/tests/unit/api/jobs/gdpr-export.behavior.test.ts new file mode 100644 index 00000000..654c2d16 --- /dev/null +++ b/tests/unit/api/jobs/gdpr-export.behavior.test.ts @@ -0,0 +1,144 @@ +import AdmZip from "adm-zip"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const selectMock = vi.hoisted(() => vi.fn()); +const readStoredFileMock = vi.hoisted(() => vi.fn()); +const putObjectMock = vi.hoisted(() => vi.fn()); + +function queryChain(result: T) { + const chain = { + from: vi.fn(() => chain), + where: vi.fn(() => Promise.resolve(result)), + }; + return chain; +} + +async function loadGdprExport() { + vi.resetModules(); + selectMock.mockReset(); + readStoredFileMock.mockReset(); + putObjectMock.mockReset(); + + vi.doMock("drizzle-orm", () => ({ + eq: vi.fn(() => "eq"), + })); + + vi.doMock("../../../../apps/api/src/db/index.js", () => ({ + db: { + select: selectMock, + }, + schema: { + users: { id: "users.id" }, + userFiles: { userId: "userFiles.userId" }, + jobs: { userId: "jobs.userId" }, + auditLog: { actorId: "auditLog.actorId" }, + }, + })); + + vi.doMock("../../../../apps/api/src/lib/file-storage.js", () => ({ + readStoredFile: readStoredFileMock, + })); + + vi.doMock("../../../../apps/api/src/lib/object-storage.js", () => ({ + putObject: putObjectMock, + })); + + return import("../../../../apps/api/src/jobs/gdpr-export.js"); +} + +describe("GDPR export job behavior", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("throws before writing output when the user does not exist", async () => { + const { gdprExportJob } = await loadGdprExport(); + selectMock.mockReturnValueOnce(queryChain([])); + + await expect(gdprExportJob("missing-user", "job-1")).rejects.toThrow( + "User missing-user not found", + ); + + expect(putObjectMock).not.toHaveBeenCalled(); + }); + + it("writes a ZIP without passwordHash and skips missing library file contents", async () => { + const { gdprExportJob } = await loadGdprExport(); + selectMock + .mockReturnValueOnce( + queryChain([ + { + id: "user-1", + email: "ada@example.test", + passwordHash: "do-not-export", + createdAt: new Date("2026-06-01T00:00:00.000Z"), + }, + ]), + ) + .mockReturnValueOnce( + queryChain([ + { + id: "file-1", + userId: "user-1", + storedName: "stored/a", + originalName: "a.txt", + createdAt: new Date("2026-06-02T00:00:00.000Z"), + }, + { + id: "file-2", + userId: "user-1", + storedName: "stored/missing", + originalName: "missing.txt", + createdAt: new Date("2026-06-03T00:00:00.000Z"), + }, + ]), + ) + .mockReturnValueOnce( + queryChain([ + { + id: "job-a", + userId: "user-1", + createdAt: new Date("2026-06-04T00:00:00.000Z"), + startedAt: null, + completedAt: new Date("2026-06-04T00:01:00.000Z"), + deleteAfter: null, + }, + ]), + ) + .mockReturnValueOnce( + queryChain([ + { + id: "audit-1", + actorId: "user-1", + action: "LOGIN", + createdAt: new Date("2026-06-05T00:00:00.000Z"), + }, + ]), + ); + readStoredFileMock + .mockResolvedValueOnce(Buffer.from("file contents")) + .mockRejectedValueOnce(new Error("missing")); + + await expect(gdprExportJob("user-1", "export-job")).resolves.toEqual({ + outputRef: "outputs/export-job/gdpr-export.zip", + }); + + expect(putObjectMock).toHaveBeenCalledTimes(1); + const [outputRef, zipBuffer] = putObjectMock.mock.calls[0]; + expect(outputRef).toBe("outputs/export-job/gdpr-export.zip"); + + const zip = new AdmZip(zipBuffer); + const profile = JSON.parse(zip.readAsText("profile.json")); + const files = JSON.parse(zip.readAsText("files.json")); + const jobs = JSON.parse(zip.readAsText("jobs.json")); + const audit = JSON.parse(zip.readAsText("audit-log.json")); + + expect(profile).toMatchObject({ id: "user-1", email: "ada@example.test" }); + expect(profile).not.toHaveProperty("passwordHash"); + expect(files[0].createdAt).toBe("2026-06-02T00:00:00.000Z"); + expect(jobs[0].completedAt).toBe("2026-06-04T00:01:00.000Z"); + expect(audit[0].createdAt).toBe("2026-06-05T00:00:00.000Z"); + expect(zip.readAsText("library-files/file-1_a.txt")).toBe("file contents"); + expect(zip.getEntry("library-files/file-2_missing.txt")).toBeNull(); + }); +}); diff --git a/tests/unit/api/jobs/queues.behavior.test.ts b/tests/unit/api/jobs/queues.behavior.test.ts new file mode 100644 index 00000000..23aa9cdb --- /dev/null +++ b/tests/unit/api/jobs/queues.behavior.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const queueInstances: Array<{ + name: string; + options: Record; + close: ReturnType; + getJobCounts: ReturnType; + getJobs: ReturnType; +}> = []; + +async function loadQueuesModule() { + vi.resetModules(); + queueInstances.length = 0; + + vi.doMock("bullmq", () => ({ + Queue: vi.fn((name: string, options: Record) => { + const queue = { + name, + options, + close: vi.fn().mockResolvedValue(undefined), + getJobCounts: vi.fn().mockResolvedValue({ active: 0, waiting: 0, delayed: 0, failed: 0 }), + getJobs: vi.fn().mockResolvedValue([]), + }; + queueInstances.push(queue); + return queue; + }), + })); + + vi.doMock("../../../../apps/api/src/jobs/connection.js", () => ({ + createBullMQConnection: vi.fn(() => ({ mocked: "connection" })), + })); + + return import("../../../../apps/api/src/jobs/queues.js"); +} + +describe("job queues", () => { + beforeEach(() => { + vi.useRealTimers(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("creates one cached queue per pool with pool-specific retry attempts", async () => { + const { getQueue } = await loadQueuesModule(); + + const imageQueue = getQueue("image"); + const sameImageQueue = getQueue("image"); + const aiQueue = getQueue("ai"); + + expect(sameImageQueue).toBe(imageQueue); + expect(aiQueue).not.toBe(imageQueue); + expect(queueInstances).toHaveLength(2); + expect(queueInstances[0].name).toContain("image"); + expect(queueInstances[1].name).toContain("ai"); + expect(queueInstances[0].options).toMatchObject({ + defaultJobOptions: { attempts: 2 }, + }); + expect(queueInstances[1].options).toMatchObject({ + defaultJobOptions: { attempts: 1 }, + }); + }); + + it("aggregates counts only from queues that have been created", async () => { + const { getQueue, queueCounts, perPoolCounts } = await loadQueuesModule(); + getQueue("image"); + getQueue("docs"); + + queueInstances[0].getJobCounts.mockResolvedValueOnce({ active: 2, waiting: 3, delayed: 4 }); + queueInstances[1].getJobCounts.mockResolvedValueOnce({ active: 5, waiting: 7, delayed: 11 }); + + await expect(queueCounts()).resolves.toEqual({ active: 7, waiting: 10, delayed: 15 }); + + queueInstances[0].getJobCounts.mockResolvedValueOnce({ active: 13, waiting: 17 }); + queueInstances[1].getJobCounts.mockResolvedValueOnce({ active: 19, waiting: 23 }); + + await expect(perPoolCounts()).resolves.toMatchObject({ + image: { active: 13, waiting: 17 }, + docs: { active: 19, waiting: 23 }, + ai: { active: 0, waiting: 0 }, + media: { active: 0, waiting: 0 }, + system: { active: 0, waiting: 0 }, + }); + }); + + it("reports oldest waiting age for per-pool health when waiting jobs exist", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-29T12:00:00.000Z")); + + const { getQueue, perPoolHealth } = await loadQueuesModule(); + getQueue("media"); + queueInstances[0].getJobCounts.mockResolvedValueOnce({ active: 1, waiting: 1, failed: 2 }); + queueInstances[0].getJobs.mockResolvedValueOnce([ + { timestamp: new Date("2026-06-29T11:59:45.000Z").getTime() }, + ]); + + await expect(perPoolHealth()).resolves.toMatchObject({ + media: { active: 1, waiting: 1, failed: 2, oldestWaitingMs: 15_000 }, + image: { active: 0, waiting: 0, failed: 0, oldestWaitingMs: null }, + }); + }); + + it("closes cached queues and clears counts", async () => { + const { getQueue, closeQueues, queueCounts } = await loadQueuesModule(); + getQueue("image"); + getQueue("ai"); + + await closeQueues(); + + expect(queueInstances[0].close).toHaveBeenCalledTimes(1); + expect(queueInstances[1].close).toHaveBeenCalledTimes(1); + await expect(queueCounts()).resolves.toEqual({ active: 0, waiting: 0, delayed: 0 }); + }); +}); diff --git a/tests/unit/api/jobs/siem-forward.behavior.test.ts b/tests/unit/api/jobs/siem-forward.behavior.test.ts new file mode 100644 index 00000000..0a658b91 --- /dev/null +++ b/tests/unit/api/jobs/siem-forward.behavior.test.ts @@ -0,0 +1,181 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const readSiemConfigMock = vi.hoisted(() => vi.fn()); +const deliverWebhookMock = vi.hoisted(() => vi.fn()); +const upsertSettingMock = vi.hoisted(() => vi.fn()); +const decryptMock = vi.hoisted(() => vi.fn()); +const isEncryptedMock = vi.hoisted(() => vi.fn()); +const selectMock = vi.hoisted(() => vi.fn()); + +function queryChain(result: T, terminalWhere = false) { + const chain = { + from: vi.fn(() => chain), + where: vi.fn(() => (terminalWhere ? Promise.resolve(result) : chain)), + orderBy: vi.fn(() => chain), + limit: vi.fn(() => Promise.resolve(result)), + }; + return chain; +} + +async function loadSiemForward() { + vi.resetModules(); + readSiemConfigMock.mockReset(); + deliverWebhookMock.mockReset(); + upsertSettingMock.mockReset(); + decryptMock.mockReset(); + isEncryptedMock.mockReset(); + selectMock.mockReset(); + + vi.doMock("drizzle-orm", () => ({ + asc: vi.fn(() => "asc"), + eq: vi.fn(() => "eq"), + gte: vi.fn(() => "gte"), + })); + + vi.doMock("../../../../apps/api/src/config.js", () => ({ + env: { DATA_ENCRYPTION_KEY: "test-key" }, + })); + + vi.doMock("../../../../apps/api/src/db/index.js", () => ({ + db: { + select: selectMock, + }, + schema: { + auditLog: { + action: "action", + actorId: "actorId", + actorUsername: "actorUsername", + targetType: "targetType", + targetId: "targetId", + ipAddress: "ipAddress", + details: "details", + createdAt: "createdAt", + }, + settings: { + key: "key", + value: "value", + }, + }, + })); + + vi.doMock("../../../../apps/api/src/lib/encryption.js", () => ({ + decrypt: decryptMock, + isEncrypted: isEncryptedMock, + })); + + vi.doMock("../../../../apps/api/src/lib/settings-helpers.js", () => ({ + upsertSetting: upsertSettingMock, + })); + + vi.doMock("../../../../apps/api/src/lib/webhook-delivery.js", () => ({ + deliverWebhook: deliverWebhookMock, + })); + + vi.doMock("../../../../apps/api/src/routes/enterprise/siem.js", () => ({ + readSiemConfig: readSiemConfigMock, + })); + + return import("../../../../apps/api/src/jobs/siem-forward.js"); +} + +describe("SIEM forwarding behavior", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("returns without querying audit rows when SIEM is disabled", async () => { + const { runSiemForward } = await loadSiemForward(); + readSiemConfigMock.mockResolvedValue({ enabled: false, webhookUrl: "https://siem.test" }); + + await expect(runSiemForward()).resolves.toBeUndefined(); + + expect(selectMock).not.toHaveBeenCalled(); + expect(deliverWebhookMock).not.toHaveBeenCalled(); + }); + + it("opens the circuit breaker at five consecutive failures", async () => { + const { runSiemForward } = await loadSiemForward(); + readSiemConfigMock.mockResolvedValue({ enabled: true, webhookUrl: "https://siem.test" }); + selectMock.mockReturnValueOnce(queryChain([{ value: "5" }], true)); + + await expect(runSiemForward()).resolves.toBeUndefined(); + + expect(selectMock).toHaveBeenCalledTimes(1); + expect(deliverWebhookMock).not.toHaveBeenCalled(); + }); + + it("maps audit rows, decrypts auth, advances cursor, and resets failures after success", async () => { + const { runSiemForward } = await loadSiemForward(); + const createdAt = new Date("2026-06-29T12:00:00.000Z"); + readSiemConfigMock.mockResolvedValue({ + enabled: true, + webhookUrl: "https://siem.test/events", + authHeader: "enc:auth", + }); + selectMock + .mockReturnValueOnce(queryChain([{ value: "2" }], true)) + .mockReturnValueOnce(queryChain([{ value: "2026-06-29T11:00:00.000Z" }], true)) + .mockReturnValueOnce( + queryChain([ + { + createdAt, + action: "LOGIN_FAILED", + actorId: "user-1", + actorUsername: "ada", + targetType: "session", + targetId: "session-1", + ipAddress: "203.0.113.10", + details: { reason: "bad_password" }, + }, + ]), + ); + isEncryptedMock.mockReturnValue(true); + decryptMock.mockResolvedValue("Bearer clear"); + deliverWebhookMock.mockResolvedValue({ success: true }); + + await expect(runSiemForward()).resolves.toEqual({ forwarded: 1 }); + + expect(deliverWebhookMock).toHaveBeenCalledWith("https://siem.test/events", "Bearer clear", [ + { + timestamp: "2026-06-29T12:00:00.000Z", + event: "LOGIN_FAILED", + actorId: "user-1", + actorUsername: "ada", + targetType: "session", + targetId: "session-1", + ip: "203.0.113.10", + details: { reason: "bad_password" }, + }, + ]); + expect(upsertSettingMock).toHaveBeenCalledWith( + "siem_last_forwarded_at", + "2026-06-29T12:00:00.000Z", + ); + expect(upsertSettingMock).toHaveBeenCalledWith("siem_consecutive_failures", "0"); + }); + + it("increments failure counter when delivery fails", async () => { + const { runSiemForward } = await loadSiemForward(); + readSiemConfigMock.mockResolvedValue({ + enabled: true, + webhookUrl: "https://siem.test/events", + authHeader: "", + }); + selectMock + .mockReturnValueOnce(queryChain([{ value: "4" }], true)) + .mockReturnValueOnce(queryChain([], true)) + .mockReturnValueOnce( + queryChain([ + { + createdAt: new Date("2026-06-29T12:00:00.000Z"), + action: "FILE_DELETED", + }, + ]), + ); + deliverWebhookMock.mockResolvedValue({ success: false, error: "downstream 500" }); + + await expect(runSiemForward()).resolves.toBeUndefined(); + + expect(upsertSettingMock).toHaveBeenCalledWith("siem_consecutive_failures", "5"); + }); +}); diff --git a/tests/unit/api/jobs/system-jobs.behavior.test.ts b/tests/unit/api/jobs/system-jobs.behavior.test.ts new file mode 100644 index 00000000..61c6c906 --- /dev/null +++ b/tests/unit/api/jobs/system-jobs.behavior.test.ts @@ -0,0 +1,195 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +const getQueueMock = vi.hoisted(() => vi.fn()); +const runSiemForwardMock = vi.hoisted(() => vi.fn()); +const runAuditArchiveMock = vi.hoisted(() => vi.fn()); +const dbExecuteMock = vi.hoisted(() => vi.fn()); +const dbUpdateMock = vi.hoisted(() => vi.fn()); +const storageReconciliationJobMock = vi.hoisted(() => vi.fn()); +const gdprExportJobMock = vi.hoisted(() => vi.fn()); +const evaluateAlertsMock = vi.hoisted(() => vi.fn()); + +async function loadSystemJobs(cleanupIntervalMinutes = 15) { + vi.resetModules(); + getQueueMock.mockReset(); + runSiemForwardMock.mockReset(); + runAuditArchiveMock.mockReset(); + dbExecuteMock.mockReset(); + dbUpdateMock.mockReset(); + storageReconciliationJobMock.mockReset(); + gdprExportJobMock.mockReset(); + evaluateAlertsMock.mockReset(); + + vi.doMock("drizzle-orm", () => ({ + and: vi.fn(() => "and"), + eq: vi.fn(() => "eq"), + inArray: vi.fn(() => "inArray"), + isNotNull: vi.fn(() => "isNotNull"), + lt: vi.fn(() => "lt"), + sql: vi.fn(() => "sql"), + })); + + vi.doMock("../../../../apps/api/src/config.js", () => ({ + env: { + CLEANUP_INTERVAL_MINUTES: cleanupIntervalMinutes, + JOBS_RETENTION_DAYS: 30, + AUDIT_RETENTION_DAYS: 90, + }, + })); + + vi.doMock("../../../../apps/api/src/db/index.js", () => ({ + db: { + execute: dbExecuteMock.mockResolvedValue(undefined), + update: dbUpdateMock, + }, + schema: { + jobs: { + id: "jobs.id", + }, + }, + })); + + vi.doMock("../../../../apps/api/src/lib/cleanup.js", () => ({ + getMaxAgeMs: vi.fn().mockResolvedValue(0), + })); + + vi.doMock("../../../../apps/api/src/lib/object-storage.js", () => ({ + deletePrefix: vi.fn(), + listJobDirs: vi.fn().mockResolvedValue([]), + })); + + vi.doMock("../../../../apps/api/src/lib/settings-helpers.js", () => ({ + getSettingNumber: vi.fn(), + })); + + vi.doMock("../../../../apps/api/src/jobs/audit-archive.js", () => ({ + runAuditArchive: runAuditArchiveMock, + })); + + vi.doMock("../../../../apps/api/src/jobs/queues.js", () => ({ + getQueue: getQueueMock, + })); + + vi.doMock("../../../../apps/api/src/jobs/siem-forward.js", () => ({ + runSiemForward: runSiemForwardMock, + })); + + vi.doMock("../../../../apps/api/src/jobs/storage-reconciliation.js", () => ({ + storageReconciliationJob: storageReconciliationJobMock, + })); + + vi.doMock("../../../../apps/api/src/jobs/gdpr-export.js", () => ({ + gdprExportJob: gdprExportJobMock, + })); + + vi.doMock("../../../../apps/api/src/jobs/alert-evaluator.js", () => ({ + evaluateAlerts: evaluateAlertsMock, + })); + + return import("../../../../apps/api/src/jobs/system-jobs.js"); +} + +describe("system jobs behavior", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("decides expiry from local mtimes, S3 job rows, and rowless S3 directories", async () => { + const { decideExpiry } = await loadSystemJobs(); + const cutoffMs = new Date("2026-06-29T12:00:00.000Z").getTime(); + const rowsById = new Map([ + [ + "job-old", + { + createdAt: new Date("2026-06-20T12:00:00.000Z"), + completedAt: null, + }, + ], + [ + "job-new", + { + createdAt: new Date("2026-06-20T12:00:00.000Z"), + completedAt: new Date("2026-06-29T12:01:00.000Z"), + }, + ], + ]); + + expect(decideExpiry({ key: "uploads/job-a", mtimeMs: cutoffMs - 1 }, cutoffMs, rowsById)).toBe( + "expired", + ); + expect(decideExpiry({ key: "outputs/job-b", mtimeMs: cutoffMs }, cutoffMs, rowsById)).toBe( + "keep", + ); + expect(decideExpiry({ key: "uploads/job-old", mtimeMs: 0 }, cutoffMs, rowsById)).toBe( + "expired", + ); + expect(decideExpiry({ key: "outputs/job-new", mtimeMs: 0 }, cutoffMs, rowsById)).toBe("keep"); + expect(decideExpiry({ key: "uploads/orphan", mtimeMs: 0 }, cutoffMs, rowsById)).toBe("skip"); + }); + + it("schedules repeatable jobs and removes storage TTL scheduler when cleanup is disabled", async () => { + const queue = { + upsertJobScheduler: vi.fn().mockResolvedValue(undefined), + removeJobScheduler: vi.fn().mockResolvedValue(undefined), + }; + const { SYSTEM_JOBS, scheduleSystemJobs } = await loadSystemJobs(0); + getQueueMock.mockReturnValue(queue); + + await scheduleSystemJobs(); + + expect(queue.removeJobScheduler).toHaveBeenCalledWith(SYSTEM_JOBS.storageTtl); + expect(queue.upsertJobScheduler).toHaveBeenCalledWith(SYSTEM_JOBS.sessionPurge, { + every: 60 * 60_000, + }); + expect(queue.upsertJobScheduler).toHaveBeenCalledWith(SYSTEM_JOBS.retention, { + every: 6 * 60 * 60_000, + }); + expect(queue.upsertJobScheduler).toHaveBeenCalledWith(SYSTEM_JOBS.auditArchive, { + pattern: "0 2 1 * *", + }); + expect(queue.upsertJobScheduler).toHaveBeenCalledWith(SYSTEM_JOBS.storageReconciliation, { + pattern: "0 3 * * 0", + }); + expect(queue.upsertJobScheduler).toHaveBeenCalledWith(SYSTEM_JOBS.alertEvaluator, { + every: 60_000, + }); + }); + + it("dispatches one-shot system jobs and updates GDPR export job rows", async () => { + const updateWhere = vi.fn().mockResolvedValue(undefined); + const updateSet = vi.fn(() => ({ where: updateWhere })); + const { SYSTEM_JOBS, runSystemJob } = await loadSystemJobs(); + dbUpdateMock.mockReturnValue({ set: updateSet }); + runSiemForwardMock.mockResolvedValue({ forwarded: 2 }); + gdprExportJobMock.mockResolvedValue({ outputRef: "outputs/export-job/gdpr-export.zip" }); + storageReconciliationJobMock.mockResolvedValue(undefined); + evaluateAlertsMock.mockResolvedValue(undefined); + + await expect(runSystemJob({ name: SYSTEM_JOBS.siemForward } as never)).resolves.toEqual({ + forwarded: 2, + }); + await expect(runSystemJob({ name: SYSTEM_JOBS.storageReconciliation } as never)).resolves.toBe( + undefined, + ); + await expect( + runSystemJob({ + name: SYSTEM_JOBS.gdprExport, + data: { userId: "user-1", jobId: "export-job" }, + } as never), + ).resolves.toEqual({ outputRef: "outputs/export-job/gdpr-export.zip" }); + await expect(runSystemJob({ name: SYSTEM_JOBS.alertEvaluator } as never)).resolves.toBe( + undefined, + ); + + expect(gdprExportJobMock).toHaveBeenCalledWith("user-1", "export-job"); + expect(updateSet).toHaveBeenCalledWith( + expect.objectContaining({ + status: "completed", + outputRefs: ["outputs/export-job/gdpr-export.zip"], + }), + ); + await expect(runSystemJob({ name: "system:unknown" } as never)).rejects.toThrow( + "Unknown system job: system:unknown", + ); + }); +}); diff --git a/tests/unit/api/jobs/worker.behavior.test.ts b/tests/unit/api/jobs/worker.behavior.test.ts new file mode 100644 index 00000000..009b0374 --- /dev/null +++ b/tests/unit/api/jobs/worker.behavior.test.ts @@ -0,0 +1,171 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +async function loadWorker() { + vi.resetModules(); + + vi.doMock("node:fs/promises", () => ({ + mkdir: vi.fn(), + readFile: vi.fn(), + rm: vi.fn(), + })); + + vi.doMock("@snapotter/shared", () => ({ + ANALYTICS_EVENTS: {}, + TOOLS: [], + getBundleForTool: vi.fn(() => null), + })); + + vi.doMock("bullmq", () => ({ + UnrecoverableError: class UnrecoverableError extends Error {}, + Worker: vi.fn(() => ({ + on: vi.fn(), + close: vi.fn().mockResolvedValue(undefined), + })), + })); + + vi.doMock("drizzle-orm", () => ({ + eq: vi.fn(() => "eq"), + })); + + vi.doMock("../../../../apps/api/src/config.js", () => ({ + env: { + SCRATCH_PATH: "", + JOB_TIMEOUT_LONG_S: 60, + JOB_TIMEOUT_FAST_S: 15, + }, + })); + + vi.doMock("../../../../apps/api/src/db/index.js", () => ({ + db: {}, + schema: { jobs: {} }, + })); + + vi.doMock("../../../../apps/api/src/lib/analytics.js", () => ({ + captureException: vi.fn(), + trackEvent: vi.fn(), + })); + + vi.doMock("../../../../apps/api/src/lib/analytics-gate.js", () => ({ + analyticsEnabled: vi.fn(() => false), + })); + + vi.doMock("../../../../apps/api/src/lib/env.js", () => ({ + resolveConcurrency: vi.fn(() => 2), + })); + + vi.doMock("../../../../apps/api/src/lib/errors.js", () => ({ + friendlyError: vi.fn((message: string) => message), + })); + + vi.doMock("../../../../apps/api/src/lib/logger.js", () => ({ + logger: { + error: vi.fn(), + info: vi.fn(), + }, + })); + + vi.doMock("../../../../apps/api/src/lib/metrics.js", () => ({ + jobDuration: { observe: vi.fn() }, + jobsTotal: { inc: vi.fn() }, + })); + + vi.doMock("../../../../apps/api/src/lib/object-storage.js", () => ({ + getObjectBuffer: vi.fn(), + putObject: vi.fn(), + })); + + vi.doMock("../../../../apps/api/src/routes/progress.js", () => ({ + publishEphemeral: vi.fn(), + updateSingleFileProgress: vi.fn(), + })); + + vi.doMock("../../../../apps/api/src/routes/tool-factory.js", () => ({ + getToolConfig: vi.fn(), + })); + + vi.doMock("../../../../apps/api/src/jobs/ai-handlers.js", () => ({ + hasAiJobHandler: vi.fn(() => false), + runAiToolJob: vi.fn(), + })); + + vi.doMock("../../../../apps/api/src/jobs/batch-progress.js", () => ({ + recordChildOutcome: vi.fn(), + })); + + vi.doMock("../../../../apps/api/src/jobs/cancel.js", () => ({ + registerCancelable: vi.fn(() => new AbortController()), + unregisterCancelable: vi.fn(), + })); + + vi.doMock("../../../../apps/api/src/jobs/connection.js", () => ({ + createBullMQConnection: vi.fn(() => ({})), + })); + + vi.doMock("../../../../apps/api/src/jobs/postprocess.js", () => ({ + autoSaveToLibrary: vi.fn(), + buildOutputName: vi.fn(), + generatePreview: vi.fn(), + })); + + vi.doMock("../../../../apps/api/src/jobs/system-jobs.js", () => ({ + runSystemJob: vi.fn(), + })); + + return import("../../../../apps/api/src/jobs/worker.js"); +} + +describe("worker result payload behavior", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("builds legacy download, preview, saved-file, and tool payload fields", async () => { + const { buildLegacyResultPayload } = await loadWorker(); + + expect( + buildLegacyResultPayload( + { + outputRefs: ["outputs/job-1/report final.pdf"], + filename: "report final.pdf", + contentType: "application/pdf", + originalSize: 100, + processedSize: 80, + previewRef: "outputs/job-1/preview.png", + savedFileId: "file-2", + resultPayload: { pageCount: 3 }, + }, + "job-1", + ), + ).toEqual({ + jobId: "job-1", + downloadUrl: "/api/v1/download/job-1/report%20final.pdf", + previewUrl: "/api/v1/download/job-1/preview.png", + originalSize: 100, + processedSize: 80, + savedFileId: "file-2", + pageCount: 3, + }); + }); + + it("omits optional legacy payload fields when the job result does not include them", async () => { + const { buildLegacyResultPayload } = await loadWorker(); + + expect( + buildLegacyResultPayload( + { + outputRefs: ["outputs/job-2/out.png"], + filename: "out.png", + contentType: "image/png", + originalSize: 10, + processedSize: 8, + }, + "job-2", + ), + ).toEqual({ + jobId: "job-2", + downloadUrl: "/api/v1/download/job-2/out.png", + originalSize: 10, + processedSize: 8, + }); + }); +}); diff --git a/tests/unit/api/lib/env-edge.test.ts b/tests/unit/api/lib/env-edge.test.ts new file mode 100644 index 00000000..14856d55 --- /dev/null +++ b/tests/unit/api/lib/env-edge.test.ts @@ -0,0 +1,107 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + loadEnv, + resolveConcurrency, + resolveWorkerThreads, +} from "../../../../apps/api/src/lib/env.js"; + +const originalEnv = { ...process.env }; + +function restoreEnv(): void { + for (const key of Object.keys(process.env)) { + delete process.env[key]; + } + Object.assign(process.env, originalEnv); +} + +function expectLoadEnvError(overrides: Record, message: string): void { + restoreEnv(); + for (const [key, value] of Object.entries(overrides)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } + + expect(() => loadEnv()).toThrow(message); +} + +afterEach(() => { + restoreEnv(); +}); + +describe("loadEnv edge validation", () => { + it("rejects S3 storage mode when required credentials are absent", () => { + expectLoadEnvError( + { + STORAGE_MODE: "s3", + S3_BUCKET: "", + S3_ACCESS_KEY_ID: "", + S3_SECRET_ACCESS_KEY: "", + }, + "S3_BUCKET is required when STORAGE_MODE=s3", + ); + }); + + it("rejects enabled OIDC without issuer, client credentials, and external URL", () => { + expectLoadEnvError( + { + OIDC_ENABLED: "true", + OIDC_ISSUER_URL: "", + OIDC_CLIENT_ID: "", + OIDC_CLIENT_SECRET: "", + EXTERNAL_URL: "", + }, + "OIDC_ISSUER_URL is required when OIDC_ENABLED=true", + ); + }); + + it("rejects enabled SAML without IdP settings and external URL", () => { + expectLoadEnvError( + { + SAML_ENABLED: "true", + SAML_IDP_SSO_URL: "", + SAML_IDP_CERTIFICATE: "", + EXTERNAL_URL: "", + }, + "SAML_IDP_SSO_URL is required when SAML_ENABLED=true", + ); + }); + + it("rejects malformed encryption keys before settings encryption is used", () => { + expectLoadEnvError( + { + DATA_ENCRYPTION_KEY: "not-hex", + }, + "DATA_ENCRYPTION_KEY must be a 64-character hex string", + ); + }); + + it("accepts valid encryption key material", () => { + restoreEnv(); + process.env.DATA_ENCRYPTION_KEY = "a".repeat(64); + process.env.DATA_ENCRYPTION_KEY_PREVIOUS = "b".repeat(64); + + const env = loadEnv(); + + expect(env.DATA_ENCRYPTION_KEY).toBe("a".repeat(64)); + expect(env.DATA_ENCRYPTION_KEY_PREVIOUS).toBe("b".repeat(64)); + }); +}); + +describe("worker sizing helpers", () => { + it("honors explicit concurrency and thread overrides", () => { + const env = loadEnv(); + + expect(resolveConcurrency({ ...env, CONCURRENT_JOBS: 7 })).toBe(7); + expect(resolveWorkerThreads({ ...env, MAX_WORKER_THREADS: 9 })).toBe(9); + }); + + it("falls back to at least two workers when overrides are zero", () => { + const env = loadEnv(); + + expect(resolveConcurrency({ ...env, CONCURRENT_JOBS: 0 })).toBeGreaterThanOrEqual(2); + expect(resolveWorkerThreads({ ...env, MAX_WORKER_THREADS: 0 })).toBeGreaterThanOrEqual(2); + }); +}); diff --git a/tests/unit/api/lib/formatting-edge.test.ts b/tests/unit/api/lib/formatting-edge.test.ts new file mode 100644 index 00000000..2c916777 --- /dev/null +++ b/tests/unit/api/lib/formatting-edge.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { sanitizeEventProperties } from "../../../../apps/api/src/lib/analytics-allowlist.js"; +import { toSrt, toVtt } from "../../../../apps/api/src/lib/subtitle-format.js"; + +describe("subtitle timestamp edge formatting", () => { + it("rounds millisecond overflow into the next second", () => { + expect(toSrt([{ startS: 1.9996, endS: 3661.2344, text: " rounded " }])).toBe( + "1\n00:00:02,000 --> 01:01:01,234\nrounded\n", + ); + }); + + it("clamps negative timestamps to zero in WebVTT output", () => { + expect(toVtt([{ startS: -3.25, endS: 0.0044, text: "early" }])).toBe( + "WEBVTT\n\n00:00:00.000 --> 00:00:00.004\nearly\n", + ); + }); +}); + +describe("analytics allowlist edge filtering", () => { + it("keeps pipeline tool id arrays while dropping mixed arrays and nulls", () => { + const out = sanitizeEventProperties("pipeline_executed", { + tool_ids: ["resize", "compress"], + status: "completed", + file_count: null, + step_count: ["not", 2], + is_batch: true, + }); + + expect(out).toEqual({ + tool_ids: ["resize", "compress"], + status: "completed", + is_batch: true, + }); + }); + + it("drops allowlisted object-shaped values instead of serializing free-form data", () => { + const out = sanitizeEventProperties("tool_used", { + tool_id: "resize", + error_code: { nested: "E_SECRET" }, + duration_ms: 42, + }); + + expect(out).toEqual({ + tool_id: "resize", + duration_ms: 42, + }); + }); +}); diff --git a/tests/unit/api/lib/security-edge.test.ts b/tests/unit/api/lib/security-edge.test.ts new file mode 100644 index 00000000..f2ef8717 --- /dev/null +++ b/tests/unit/api/lib/security-edge.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { decrypt, encrypt, isEncrypted } from "../../../../apps/api/src/lib/encryption.js"; +import { isPrivateIp, validateFetchUrl } from "../../../../apps/api/src/lib/ssrf.js"; + +const primaryKey = "1".repeat(64); +const previousKey = "2".repeat(64); +const wrongKey = "3".repeat(64); + +describe("settings encryption edge behavior", () => { + it("does not use the previous key when the primary key decrypts successfully", async () => { + const encrypted = await encrypt("current-secret", primaryKey); + + await expect(decrypt(encrypted, primaryKey, wrongKey)).resolves.toBe("current-secret"); + }); + + it("returns null when neither current nor previous key can authenticate the ciphertext", async () => { + const encrypted = await encrypt("old-secret", previousKey); + + await expect(decrypt(encrypted, primaryKey, wrongKey)).resolves.toBeNull(); + }); + + it("treats the exact encryption prefix as encrypted even without a payload", async () => { + expect(isEncrypted("$ENC$")).toBe(true); + await expect(decrypt("$ENC$", primaryKey)).resolves.toBeNull(); + }); +}); + +describe("SSRF IP classification edges", () => { + it("blocks malformed IP strings by failing closed", () => { + expect(isPrivateIp("not-an-ip")).toBe(true); + }); + + it("blocks IPv6 transition and local-only ranges", () => { + expect(isPrivateIp("64:ff9b::808:808")).toBe(true); + expect(isPrivateIp("2002:0808:0808::1")).toBe(true); + expect(isPrivateIp("fc00::1")).toBe(true); + expect(isPrivateIp("ff02::1")).toBe(true); + }); + + it("allows public IPv4 and IPv6 literals", () => { + expect(isPrivateIp("8.8.8.8")).toBe(false); + expect(isPrivateIp("2606:4700:4700::1111")).toBe(false); + }); + + it("rejects non-http schemes before DNS resolution", async () => { + await expect(validateFetchUrl("gopher://8.8.8.8/resource")).rejects.toThrow( + "Only HTTP and HTTPS URLs are supported", + ); + }); +}); diff --git a/tests/unit/web/stores/editor-store-branches.test.ts b/tests/unit/web/stores/editor-store-branches.test.ts new file mode 100644 index 00000000..d9a1997e --- /dev/null +++ b/tests/unit/web/stores/editor-store-branches.test.ts @@ -0,0 +1,244 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("zustand/middleware", async (importOriginal) => { + const actual: Record = await importOriginal(); + return { ...actual, persist: (config: unknown) => config }; +}); + +vi.stubGlobal("URL", { + ...globalThis.URL, + revokeObjectURL: vi.fn(), +}); + +import { dashStyleToArray, hexToRgba, useEditorStore } from "@/stores/editor-store"; +import type { CanvasObject, SelectionState } from "@/types/editor"; + +const INITIAL_STATE = useEditorStore.getState(); + +function state() { + return useEditorStore.getState(); +} + +function makeRect(id: string, attrs: Record = {}): CanvasObject { + return { + id, + type: "rect", + layerId: state().activeLayerId, + attrs: { + x: 10, + y: 20, + width: 30, + height: 40, + strokeWidth: 2, + rotation: 0, + ...attrs, + }, + } as CanvasObject; +} + +function makeLine(id: string, points = [0, 0, 10, 10]): CanvasObject { + return { + id, + type: "line", + layerId: state().activeLayerId, + attrs: { + points, + strokeWidth: 2, + rotation: 0, + }, + } as CanvasObject; +} + +function makeEllipse(id: string): CanvasObject { + return { + id, + type: "ellipse", + layerId: state().activeLayerId, + attrs: { + x: 30, + y: 40, + radiusX: 10, + radiusY: 20, + rotation: 0, + }, + } as CanvasObject; +} + +describe("editor store branch helpers", () => { + beforeEach(() => { + useEditorStore.setState({ ...INITIAL_STATE }, true); + }); + + it("converts hex colors to rgba strings", () => { + expect(hexToRgba("#336699", 0.5)).toBe("rgba(51, 102, 153, 0.5)"); + }); + + it("converts dash styles to canvas dash arrays", () => { + expect(dashStyleToArray("dashed", 3)).toEqual([12, 6]); + expect(dashStyleToArray("dotted", 3)).toEqual([3, 6]); + expect(dashStyleToArray("solid", 3)).toBeUndefined(); + }); + + it("initializes crop bounds when entering crop mode and clears them when leaving", () => { + state().setTool("crop"); + + expect(state().cropState).toEqual({ + x: 192, + y: 108, + width: 1536, + height: 864, + aspectRatio: null, + }); + expect(state().isCropping).toBe(true); + + state().setTool("move"); + + expect(state().cropState).toBeNull(); + expect(state().isCropping).toBe(false); + }); + + it("resizes canvas from a bottom-right anchor by offsetting objects", () => { + state().addObject(makeRect("rect")); + state().addObject(makeLine("line")); + + state().resizeCanvas(2000, 1100, "bottom-right", "#abcdef"); + + expect(state().canvasBackground).toBe("#abcdef"); + expect(state().objects[0].attrs).toMatchObject({ x: 90, y: 40 }); + expect((state().objects[1].attrs as { points: number[] }).points).toEqual([80, 20, 90, 30]); + }); + + it("rotates point and center-based objects for 90 and 270 degrees", () => { + useEditorStore.setState({ + canvasSize: { width: 100, height: 50 }, + objects: [makeLine("line"), makeEllipse("ellipse")], + }); + + state().rotateCanvas(90); + expect((state().objects[0].attrs as { points: number[] }).points).toEqual([50, 0, 40, 10]); + expect(state().objects[1].attrs).toMatchObject({ + x: 10, + y: 30, + radiusX: 20, + radiusY: 10, + rotation: 90, + }); + + state().rotateCanvas(270); + expect((state().objects[0].attrs as { points: number[] }).points).toEqual([0, 0, 10, 10]); + expect(state().objects[1].attrs).toMatchObject({ + x: 30, + y: 40, + radiusX: 10, + radiusY: 20, + rotation: 0, + }); + }); + + it("flips point and center-based objects horizontally and vertically", () => { + useEditorStore.setState({ + canvasSize: { width: 100, height: 80 }, + objects: [makeLine("line"), makeEllipse("ellipse")], + }); + + state().flipCanvasHorizontal(); + expect((state().objects[0].attrs as { points: number[] }).points).toEqual([100, 0, 90, 10]); + expect(state().objects[1].attrs).toMatchObject({ x: 70, rotation: 0 }); + + state().flipCanvasVertical(); + expect((state().objects[0].attrs as { points: number[] }).points).toEqual([100, 80, 90, 70]); + expect(state().objects[1].attrs).toMatchObject({ y: 40, rotation: 0 }); + }); + + it("inverts geometric selections and masked selections", () => { + useEditorStore.setState({ canvasSize: { width: 4, height: 3 } }); + const geometricSelection: SelectionState = { + type: "rect", + bounds: { x: 1, y: 1, width: 2, height: 1 }, + }; + + state().setSelection(geometricSelection); + state().invertSelection(); + + expect(state().selection?.bounds).toEqual({ x: 0, y: 0, width: 4, height: 3 }); + expect(Array.from(state().selection?.mask ?? [])).toEqual([1, 1, 1, 1, 1, 0, 0, 1, 1, 1, 1, 1]); + + const maskedSelection: SelectionState = { + type: "wand", + bounds: { x: 1, y: 0, width: 2, height: 2 }, + mask: new Uint8Array([1, 0, 0, 1]), + }; + state().setSelection(maskedSelection); + state().invertSelection(); + + expect(Array.from(state().selection?.mask ?? [])).toEqual([1, 0, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1]); + }); + + it("ignores crop and clipboard operations when there is no state to apply", () => { + state().applyCrop(); + state().cutObjects(); + state().pasteObjects(); + state().pasteInPlace(); + + expect(state().canvasSize).toEqual({ width: 1920, height: 1080 }); + expect(state().objects).toEqual([]); + expect(state().clipboard).toBeNull(); + }); + + it("pastes copied objects offset or in place onto the active layer", () => { + state().addObject(makeRect("rect")); + state().setSelectedObjects(["rect"]); + state().copyObjects(); + + state().pasteObjects(); + const offsetPaste = state().objects[1]; + expect(offsetPaste.id).not.toBe("rect"); + expect(offsetPaste.layerId).toBe(state().activeLayerId); + expect(offsetPaste.attrs).toMatchObject({ x: 20, y: 30 }); + expect(state().selectedObjectIds).toEqual([offsetPaste.id]); + + state().pasteInPlace(); + const inPlacePaste = state().objects[2]; + expect(inPlacePaste.id).not.toBe("rect"); + expect(inPlacePaste.attrs).toMatchObject({ x: 10, y: 20 }); + expect(state().selectedObjectIds).toEqual([inPlacePaste.id]); + }); + + it("batch nudges positioned and point-based objects in one history entry", () => { + state().addObject(makeRect("rect")); + state().addObject(makeLine("line")); + const version = state()._historyVersion; + + state().batchNudge(["rect", "line"], 5, -3); + + expect(state().objects[0].attrs).toMatchObject({ x: 15, y: 17 }); + expect((state().objects[1].attrs as { points: number[] }).points).toEqual([5, -3, 15, 7]); + expect(state().lastAction).toBe("Nudge"); + expect(state()._historyVersion).toBe(version + 1); + }); + + it("clamps editor control ranges at their documented limits", () => { + state().setBrushSize(0); + state().setBrushOpacity(2); + state().setBrushHardness(-1); + state().setBrushFlow(2); + state().setShapeFillOpacity(-1); + state().setShapeStrokeOpacity(2); + state().setFillTolerance(999); + state().setGradientOpacity(-1); + state().setPixelBrushStrength(0); + + expect(state()).toMatchObject({ + brushSize: 1, + brushOpacity: 1, + brushHardness: 0, + brushFlow: 1, + shapeFillOpacity: 0, + shapeStrokeOpacity: 1, + fillTolerance: 255, + gradientOpacity: 0, + pixelBrushStrength: 1, + }); + }); +}); diff --git a/tests/unit/web/stores/file-store-branches.test.ts b/tests/unit/web/stores/file-store-branches.test.ts new file mode 100644 index 00000000..a967fc44 --- /dev/null +++ b/tests/unit/web/stores/file-store-branches.test.ts @@ -0,0 +1,140 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const revokeObjectURL = vi.fn(); +const createObjectURL = vi.fn((_obj: Blob | MediaSource) => "blob:fake-url"); + +vi.stubGlobal("URL", { + ...globalThis.URL, + createObjectURL, + revokeObjectURL, +}); + +const imagePreviewMock = vi.hoisted(() => ({ + needsServerPreview: vi.fn(() => false), + fetchDecodedPreview: vi.fn(() => Promise.resolve(null)), +})); + +vi.mock("@/lib/image-preview", () => imagePreviewMock); + +vi.mock("@/lib/analytics", () => ({ + track: vi.fn(), +})); + +import { previewKindFor, useFileStore } from "@/stores/file-store"; + +function makeFile(name: string, size = 1024, type = "image/png"): File { + const buf = new ArrayBuffer(size); + return new File([buf], name, { type }); +} + +describe("useFileStore branch coverage", () => { + beforeEach(() => { + useFileStore.getState().reset(); + vi.clearAllMocks(); + imagePreviewMock.needsServerPreview.mockReturnValue(false); + imagePreviewMock.fetchDecodedPreview.mockResolvedValue(null); + let urlCounter = 0; + createObjectURL.mockImplementation((_obj: Blob | MediaSource) => `blob:url-${++urlCounter}`); + }); + + it("maps unknown modalities to no preview", () => { + expect(previewKindFor("unknown" as never)).toBe("none"); + }); + + it("removeFile is a no-op for missing indexes", () => { + useFileStore.getState().setFiles([makeFile("a.png")]); + const before = useFileStore.getState().entries; + revokeObjectURL.mockClear(); + + useFileStore.getState().removeFile(3); + + expect(useFileStore.getState().entries).toBe(before); + expect(revokeObjectURL).not.toHaveBeenCalled(); + }); + + it("removeFile revokes processed preview URLs", () => { + useFileStore.getState().setFiles([makeFile("a.png")]); + useFileStore.getState().updateEntry(0, { + processedUrl: "blob:processed", + processedPreviewUrl: "blob:processed-preview", + }); + revokeObjectURL.mockClear(); + + useFileStore.getState().removeFile(0); + + expect(revokeObjectURL).toHaveBeenCalledWith("blob:processed"); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:processed-preview"); + }); + + it("setError stops processing only when an error is present", () => { + useFileStore.getState().setProcessing(true); + useFileStore.getState().setError(null); + expect(useFileStore.getState()).toMatchObject({ error: null, processing: true }); + + useFileStore.getState().setError("failed"); + expect(useFileStore.getState()).toMatchObject({ error: "failed", processing: false }); + }); + + it("setProcessedUrl and setSizes are no-ops without a selected entry", () => { + expect(() => useFileStore.getState().setProcessedUrl("blob:result")).not.toThrow(); + expect(() => useFileStore.getState().setSizes(1, 2)).not.toThrow(); + expect(useFileStore.getState().entries).toEqual([]); + expect(useFileStore.getState().processedUrl).toBeNull(); + expect(useFileStore.getState().processedSize).toBeNull(); + }); + + it("stores processed preview URLs on the selected entry", () => { + useFileStore.getState().setFiles([makeFile("a.png")]); + + useFileStore.getState().setProcessedUrl("blob:result", "blob:preview"); + + expect(useFileStore.getState().entries[0]).toMatchObject({ + processedUrl: "blob:result", + processedPreviewUrl: "blob:preview", + processedFilename: null, + status: "completed", + }); + expect(useFileStore.getState().processedPreviewUrl).toBe("blob:preview"); + }); + + it("applies decoded previews only when the entry still contains the same file", async () => { + imagePreviewMock.needsServerPreview.mockReturnValue(true); + imagePreviewMock.fetchDecodedPreview.mockImplementation((file: File) => + Promise.resolve( + file.name === "a.heic" + ? { url: "blob:decoded-a", originalWidth: 640, originalHeight: 480 } + : { url: "blob:decoded-b", originalWidth: 320, originalHeight: 240 }, + ), + ); + + const firstFile = makeFile("a.heic", 100, "image/heic"); + const replacementFile = makeFile("b.heic", 100, "image/heic"); + useFileStore.getState().setFiles([firstFile]); + useFileStore.getState().setFiles([replacementFile]); + + await vi.waitFor(() => { + expect(useFileStore.getState().entries[0].blobUrl).toBe("blob:decoded-b"); + }); + + expect(useFileStore.getState().entries[0]).toMatchObject({ + file: replacementFile, + originalWidth: 320, + originalHeight: 240, + previewLoading: false, + }); + expect(useFileStore.getState().entries[0].blobUrl).not.toBe("blob:decoded-a"); + }); + + it("clears previewLoading when decoded preview returns null", async () => { + imagePreviewMock.needsServerPreview.mockReturnValue(true); + imagePreviewMock.fetchDecodedPreview.mockResolvedValue(null); + + useFileStore.getState().setFiles([makeFile("a.heic", 100, "image/heic")]); + + await vi.waitFor(() => { + expect(useFileStore.getState().entries[0].previewLoading).toBe(false); + }); + expect(useFileStore.getState().entries[0].blobUrl).toBe("blob:url-1"); + }); +}); diff --git a/tests/unit/web/stores/html-to-image-store.test.ts b/tests/unit/web/stores/html-to-image-store.test.ts new file mode 100644 index 00000000..1234dcf9 --- /dev/null +++ b/tests/unit/web/stores/html-to-image-store.test.ts @@ -0,0 +1,199 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@/lib/api", () => ({ + formatHeaders: vi.fn((headers: HeadersInit) => new Headers(headers)), +})); + +const fetchMock = vi.fn(); +vi.stubGlobal("fetch", fetchMock); + +import { formatHeaders } from "@/lib/api"; +import { useHtmlToImageStore } from "@/stores/html-to-image-store"; + +const DEFAULT_STATE = useHtmlToImageStore.getState(); + +function state() { + return useHtmlToImageStore.getState(); +} + +function okJson(data: unknown) { + return Promise.resolve({ + ok: true, + json: () => Promise.resolve(data), + } as Response); +} + +function failJson(data: unknown) { + return Promise.resolve({ + ok: false, + json: () => Promise.resolve(data), + } as Response); +} + +describe("useHtmlToImageStore", () => { + beforeEach(() => { + useHtmlToImageStore.setState({ ...DEFAULT_STATE }, true); + fetchMock.mockReset(); + vi.mocked(formatHeaders).mockClear(); + }); + + it("clears stale errors when switching input mode and editing input", () => { + useHtmlToImageStore.setState({ error: "old error" }); + + state().setMode("html"); + expect(state().mode).toBe("html"); + expect(state().error).toBeNull(); + + useHtmlToImageStore.setState({ error: "old error" }); + state().setHtmlContent("
Test
"); + expect(state().htmlContent).toBe("
Test
"); + expect(state().error).toBeNull(); + + useHtmlToImageStore.setState({ error: "old error" }); + state().setUrl("https://example.com"); + expect(state().url).toBe("https://example.com"); + expect(state().error).toBeNull(); + }); + + it("updates capture settings without clearing unrelated state", () => { + state().setFormat("webp"); + state().setQuality(82); + state().setFullPage(true); + state().setDevicePreset("custom"); + state().setViewportWidth(390); + state().setViewportHeight(844); + + expect(state()).toMatchObject({ + format: "webp", + quality: 82, + fullPage: true, + devicePreset: "custom", + viewportWidth: 390, + viewportHeight: 844, + }); + }); + + it("does not capture when the current mode has no input", async () => { + await state().capture(); + expect(fetchMock).not.toHaveBeenCalled(); + + state().setMode("html"); + state().setUrl("https://example.com"); + await state().capture(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("does not start a second capture while already capturing", async () => { + state().setUrl("https://example.com"); + useHtmlToImageStore.setState({ capturing: true }); + + await state().capture(); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("posts URL capture options and stores successful result metadata", async () => { + fetchMock.mockResolvedValueOnce( + await okJson({ downloadUrl: "/downloads/result.png", processedSize: 1234 }), + ); + state().setUrl("https://example.com"); + state().setFormat("jpg"); + state().setQuality(75); + state().setFullPage(true); + state().setDevicePreset("mobile"); + state().setViewportWidth(414); + state().setViewportHeight(896); + + await state().capture(); + + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, options] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe("/api/v1/tools/image/html-to-image"); + expect(options.method).toBe("POST"); + expect(JSON.parse(options.body as string)).toEqual({ + url: "https://example.com", + format: "jpg", + quality: 75, + fullPage: true, + devicePreset: "mobile", + viewportWidth: 414, + viewportHeight: 896, + }); + expect(state().resultUrl).toBe("/downloads/result.png"); + expect(state().resultSize).toBe(1234); + expect(state().capturing).toBe(false); + expect(state().error).toBeNull(); + }); + + it("posts HTML content instead of URL in html mode", async () => { + fetchMock.mockResolvedValueOnce(await okJson({ downloadUrl: "/out.png", processedSize: 10 })); + state().setMode("html"); + state().setHtmlContent("

Hello

"); + + await state().capture(); + + const options = fetchMock.mock.calls[0][1] as RequestInit; + expect(JSON.parse(options.body as string)).toMatchObject({ + html: "

Hello

", + format: "png", + }); + expect(JSON.parse(options.body as string)).not.toHaveProperty("url"); + }); + + it("prefers details then error then fallback text for failed captures", async () => { + state().setUrl("https://example.com"); + fetchMock.mockResolvedValueOnce(await failJson({ details: "Invalid URL" })); + await state().capture(); + expect(state().error).toBe("Invalid URL"); + + fetchMock.mockResolvedValueOnce(await failJson({ error: "Timed out" })); + await state().capture(); + expect(state().error).toBe("Timed out"); + + fetchMock.mockResolvedValueOnce(await failJson({})); + await state().capture(); + expect(state().error).toBe("Capture failed"); + }); + + it("stores network error messages and non-Error fallback text", async () => { + state().setUrl("https://example.com"); + fetchMock.mockRejectedValueOnce(new Error("Network down")); + + await state().capture(); + expect(state().error).toBe("Network down"); + expect(state().capturing).toBe(false); + + fetchMock.mockRejectedValueOnce("offline"); + await state().capture(); + expect(state().error).toBe("Network error"); + expect(state().capturing).toBe(false); + }); + + it("reset restores defaults after a completed capture", async () => { + fetchMock.mockResolvedValueOnce(await okJson({ downloadUrl: "/out.png", processedSize: 10 })); + state().setMode("html"); + state().setHtmlContent("

Done

"); + state().setQuality(40); + await state().capture(); + + state().reset(); + + expect(state()).toMatchObject({ + mode: "url", + url: "", + htmlContent: "", + format: "png", + quality: 90, + fullPage: false, + devicePreset: "desktop", + viewportWidth: 1280, + viewportHeight: 720, + capturing: false, + resultUrl: null, + resultSize: null, + error: null, + }); + }); +});