mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
test: expand coverage across jobs and tools (#380)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user