mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
188 lines
5.7 KiB
TypeScript
188 lines
5.7 KiB
TypeScript
/**
|
|||
|
|
* 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" });
|
||
|
|
});
|
||
|
|
});
|