2026-06-29 22:06:24 +08:00
|
|
|
/**
|
|
|
|
|
* 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,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-01 12:32:33 +08:00
|
|
|
function expectAsyncAccepted(body: string, clientJobId: string) {
|
|
|
|
|
const artifactJobId = mocks.enqueueToolJob.mock.calls.at(-1)?.[0].jobId;
|
|
|
|
|
expect(artifactJobId).toBeDefined();
|
|
|
|
|
expect(JSON.parse(body)).toEqual({
|
|
|
|
|
jobId: clientJobId,
|
|
|
|
|
progressJobId: clientJobId,
|
|
|
|
|
artifactJobId,
|
|
|
|
|
async: true,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-29 22:06:24 +08:00
|
|
|
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);
|
2026-07-01 12:32:33 +08:00
|
|
|
expectAsyncAccepted(res.body, clientJobId);
|
2026-06-29 22:06:24 +08:00
|
|
|
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);
|
2026-07-01 12:32:33 +08:00
|
|
|
expectAsyncAccepted(res.body, clientJobId);
|
2026-06-29 22:06:24 +08:00
|
|
|
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);
|
2026-07-01 12:32:33 +08:00
|
|
|
expectAsyncAccepted(res.body, clientJobId);
|
2026-06-29 22:06:24 +08:00
|
|
|
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",
|
|
|
|
|
}),
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
});
|