test: expand coverage across jobs and tools (#380)

This commit is contained in:
SnapOtter
2026-06-29 22:06:24 +08:00
committed by GitHub
parent ef342c268b
commit fd6ebe77b5
22 changed files with 3692 additions and 0 deletions
+99
View File
@@ -37,6 +37,18 @@ describe("sign-pdf", () => {
});
}
function postFields(
fields: Parameters<typeof createMultipartPayload>[0],
): ReturnType<typeof testApp.app.inject> {
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:/);
});
});
@@ -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<typeof import("../../../../apps/api/src/lib/feature-status.js")>();
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<typeof import("../../../../apps/api/src/jobs/enqueue.js")>();
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<typeof createMultipartPayload>[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",
}),
);
});
});
@@ -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<typeof import("../../../../apps/api/src/lib/feature-status.js")>();
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<typeof import("../../../../apps/api/src/jobs/enqueue.js")>();
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<typeof createMultipartPayload>[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",
}),
);
});
});
@@ -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<typeof import("../../../../apps/api/src/lib/feature-status.js")>();
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<typeof import("../../../../apps/api/src/jobs/enqueue.js")>();
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<typeof createMultipartPayload>[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<string, unknown>, 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<string, unknown>) {
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();
});
});
@@ -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<typeof import("@snapotter/ai")>();
return {
...actual,
extractText: mocks.extractText,
};
});
vi.mock("../../../../apps/api/src/lib/feature-status.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../../../apps/api/src/lib/feature-status.js")>();
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<string, unknown>) {
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();
});
});
@@ -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<typeof import("@snapotter/ai")>();
return {
...actual,
detectFaces: mocks.detectFaces,
};
});
vi.mock("../../../../apps/api/src/lib/feature-status.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../../../apps/api/src/lib/feature-status.js")>();
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, unknown> | 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<ReturnType<typeof postSmartCrop>>): Promise<string> {
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" });
});
});
@@ -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" });
});
});
@@ -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<T>(fn: (scratchDir: string) => Promise<T>): Promise<T> {
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",
});
});
});
});
@@ -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<T>(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 },
);
});
});
@@ -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<T>(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();
});
});
@@ -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);
});
});
@@ -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<T>(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();
});
});
+115
View File
@@ -0,0 +1,115 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const queueInstances: Array<{
name: string;
options: Record<string, unknown>;
close: ReturnType<typeof vi.fn>;
getJobCounts: ReturnType<typeof vi.fn>;
getJobs: ReturnType<typeof vi.fn>;
}> = [];
async function loadQueuesModule() {
vi.resetModules();
queueInstances.length = 0;
vi.doMock("bullmq", () => ({
Queue: vi.fn((name: string, options: Record<string, unknown>) => {
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 });
});
});
@@ -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<T>(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");
});
});
@@ -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",
);
});
});
+171
View File
@@ -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,
});
});
});
+107
View File
@@ -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<string, string | undefined>, 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);
});
});
@@ -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,
});
});
});
+50
View File
@@ -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",
);
});
});
@@ -0,0 +1,244 @@
// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("zustand/middleware", async (importOriginal) => {
const actual: Record<string, unknown> = 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<string, number | string> = {}): 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,
});
});
});
@@ -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");
});
});
@@ -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("<main>Test</main>");
expect(state().htmlContent).toBe("<main>Test</main>");
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("<h1>Hello</h1>");
await state().capture();
const options = fetchMock.mock.calls[0][1] as RequestInit;
expect(JSON.parse(options.body as string)).toMatchObject({
html: "<h1>Hello</h1>",
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("<p>Done</p>");
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,
});
});
});