mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
Comprehensively adds { timeout: 120_000 } to every HEIF/HEIC
integration test across 40 test files. HEIF decoding on CI runners
is significantly slower than local, causing sporadic timeouts at
the default 5s or previous 60s limits.
653 lines
20 KiB
TypeScript
653 lines
20 KiB
TypeScript
/**
|
|
* Integration tests for the content-aware resize (seam carving via caire) API endpoint.
|
|
*
|
|
* This tool uses the caire Go binary. In environments where caire is not
|
|
* installed the route will return 422. Tests gracefully handle both scenarios
|
|
* while still verifying route existence and input validation.
|
|
*/
|
|
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
|
|
|
const FIXTURES = join(__dirname, "..", "fixtures");
|
|
const PNG_200x150 = readFileSync(join(FIXTURES, "test-200x150.png"));
|
|
const HEIC = readFileSync(join(FIXTURES, "test-200x150.heic"));
|
|
const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
|
|
|
|
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);
|
|
|
|
describe("Content-Aware Resize", () => {
|
|
it("route exists and responds to POST", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ width: 150, height: 120, protectFaces: false }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
// 200 = caire available, 422 = caire not found
|
|
expect([200, 422]).toContain(res.statusCode);
|
|
}, 60_000);
|
|
|
|
it("rejects requests without a file", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "settings", content: JSON.stringify({ width: 150 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejects requests without width, height, or square", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: JSON.stringify({ protectFaces: false }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const resBody = JSON.parse(res.body);
|
|
expect(resBody.error).toContain("width, height, or square");
|
|
});
|
|
|
|
it("processes with only width specified", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: JSON.stringify({ width: 150, protectFaces: false }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
// Accept 200 (caire available) or 422 (caire not available)
|
|
expect([200, 422]).toContain(res.statusCode);
|
|
|
|
if (res.statusCode === 200) {
|
|
const resBody = JSON.parse(res.body);
|
|
expect(resBody.downloadUrl).toBeDefined();
|
|
expect(resBody.width).toBe(150);
|
|
}
|
|
}, 60_000);
|
|
|
|
it("processes with only height specified", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: JSON.stringify({ height: 120, protectFaces: false }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect([200, 422]).toContain(res.statusCode);
|
|
|
|
if (res.statusCode === 200) {
|
|
const resBody = JSON.parse(res.body);
|
|
expect(resBody.downloadUrl).toBeDefined();
|
|
expect(resBody.height).toBe(120);
|
|
}
|
|
}, 60_000);
|
|
|
|
it("supports enlargement via seam insertion", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: JSON.stringify({ width: 300, protectFaces: false }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
// 200 = caire enlarged successfully, 422 = caire not available
|
|
expect([200, 422]).toContain(res.statusCode);
|
|
|
|
if (res.statusCode === 200) {
|
|
const resBody = JSON.parse(res.body);
|
|
expect(resBody.downloadUrl).toBeDefined();
|
|
expect(resBody.width).toBe(300);
|
|
}
|
|
}, 60_000);
|
|
|
|
it("accepts blurRadius and sobelThreshold options", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
width: 150,
|
|
protectFaces: false,
|
|
blurRadius: 6,
|
|
sobelThreshold: 4,
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect([200, 422]).toContain(res.statusCode);
|
|
|
|
if (res.statusCode === 200) {
|
|
const resBody = JSON.parse(res.body);
|
|
expect(resBody.downloadUrl).toBeDefined();
|
|
expect(resBody.width).toBe(150);
|
|
}
|
|
}, 60_000);
|
|
|
|
it("supports square mode", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: JSON.stringify({ square: true, protectFaces: false }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect([200, 422]).toContain(res.statusCode);
|
|
|
|
if (res.statusCode === 200) {
|
|
const resBody = JSON.parse(res.body);
|
|
expect(resBody.downloadUrl).toBeDefined();
|
|
// Square mode produces equal width and height
|
|
expect(resBody.width).toBe(resBody.height);
|
|
}
|
|
}, 60_000);
|
|
|
|
it("rejects invalid blurRadius", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ width: 150, blurRadius: 50 }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejects invalid sobelThreshold", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ width: 150, sobelThreshold: 0 }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejects invalid settings JSON", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: "not-json{{{" },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.error).toMatch(/json/i);
|
|
});
|
|
|
|
it("rejects invalid image data", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{
|
|
name: "file",
|
|
filename: "bad.png",
|
|
contentType: "image/png",
|
|
content: Buffer.from("not an image"),
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ width: 150 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.error).toMatch(/invalid image/i);
|
|
});
|
|
|
|
it(
|
|
"handles HEIC input (decodes before processing)",
|
|
{ timeout: 120_000 },
|
|
async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.heic", contentType: "image/heic", content: HEIC },
|
|
{ name: "settings", content: JSON.stringify({ width: 150, protectFaces: false }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
// HEIC decode or caire may not be available
|
|
expect([200, 422]).toContain(res.statusCode);
|
|
|
|
if (res.statusCode === 200) {
|
|
const resBody = JSON.parse(res.body);
|
|
expect(resBody.downloadUrl).toBeDefined();
|
|
}
|
|
},
|
|
60_000,
|
|
);
|
|
|
|
it("processes both width and height simultaneously", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ width: 120, height: 100, protectFaces: false }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect([200, 422]).toContain(res.statusCode);
|
|
|
|
if (res.statusCode === 200) {
|
|
const resBody = JSON.parse(res.body);
|
|
expect(resBody.downloadUrl).toBeDefined();
|
|
expect(resBody.width).toBe(120);
|
|
expect(resBody.height).toBe(100);
|
|
}
|
|
}, 60_000);
|
|
|
|
it("returns expected response fields on success", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: JSON.stringify({ width: 150, protectFaces: false }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect([200, 422]).toContain(res.statusCode);
|
|
|
|
if (res.statusCode === 200) {
|
|
const resBody = JSON.parse(res.body);
|
|
expect(resBody).toHaveProperty("jobId");
|
|
expect(resBody).toHaveProperty("downloadUrl");
|
|
expect(resBody).toHaveProperty("originalSize");
|
|
expect(resBody).toHaveProperty("processedSize");
|
|
expect(resBody).toHaveProperty("width");
|
|
expect(resBody).toHaveProperty("height");
|
|
expect(resBody.originalSize).toBeGreaterThan(0);
|
|
expect(resBody.processedSize).toBeGreaterThan(0);
|
|
}
|
|
}, 60_000);
|
|
|
|
it("returns 400 when file is empty", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "empty.png", contentType: "image/png", content: Buffer.alloc(0) },
|
|
{ name: "settings", content: JSON.stringify({ width: 150 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.error).toMatch(/no image/i);
|
|
});
|
|
|
|
it("processes JPEG input", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.jpg", contentType: "image/jpeg", content: JPG },
|
|
{ name: "settings", content: JSON.stringify({ width: 80, protectFaces: false }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect([200, 422]).toContain(res.statusCode);
|
|
|
|
if (res.statusCode === 200) {
|
|
const resBody = JSON.parse(res.body);
|
|
expect(resBody.downloadUrl).toBeDefined();
|
|
}
|
|
}, 60_000);
|
|
|
|
it("downloads output and verifies it is a valid image", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: JSON.stringify({ width: 180, protectFaces: false }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect([200, 422]).toContain(res.statusCode);
|
|
|
|
if (res.statusCode === 200) {
|
|
const resBody = JSON.parse(res.body);
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: resBody.downloadUrl,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(dlRes.statusCode).toBe(200);
|
|
expect(dlRes.rawPayload.length).toBeGreaterThan(0);
|
|
}
|
|
}, 60_000);
|
|
|
|
it("processes with default blurRadius and sobelThreshold", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: JSON.stringify({ width: 160 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect([200, 422]).toContain(res.statusCode);
|
|
}, 60_000);
|
|
|
|
it("processes a 1x1 pixel image", async () => {
|
|
const { readFileSync } = await import("node:fs");
|
|
const { join } = await import("node:path");
|
|
const TINY = readFileSync(join(__dirname, "..", "fixtures", "test-1x1.png"));
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "tiny.png", contentType: "image/png", content: TINY },
|
|
{ name: "settings", content: JSON.stringify({ square: true, protectFaces: false }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
// 1x1 may fail if caire can't shrink further, or succeed
|
|
expect([200, 422]).toContain(res.statusCode);
|
|
}, 60_000);
|
|
|
|
it("processes a large stress image", async () => {
|
|
const { readFileSync } = await import("node:fs");
|
|
const { join } = await import("node:path");
|
|
const LARGE = readFileSync(join(__dirname, "..", "fixtures", "content", "stress-large.jpg"));
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "large.jpg", contentType: "image/jpeg", content: LARGE },
|
|
{ name: "settings", content: JSON.stringify({ width: 400, protectFaces: false }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect([200, 422]).toContain(res.statusCode);
|
|
}, 120_000);
|
|
|
|
it("processes WebP input", async () => {
|
|
const { readFileSync } = await import("node:fs");
|
|
const { join } = await import("node:path");
|
|
const WEBP = readFileSync(join(__dirname, "..", "fixtures", "test-50x50.webp"));
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.webp", contentType: "image/webp", content: WEBP },
|
|
{ name: "settings", content: JSON.stringify({ width: 40, protectFaces: false }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect([200, 422]).toContain(res.statusCode);
|
|
}, 60_000);
|
|
|
|
it("rejects width of 0", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: JSON.stringify({ width: 0 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejects negative height", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 },
|
|
{ name: "settings", content: JSON.stringify({ height: -50 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("handles square mode with width also specified", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ square: true, width: 100, protectFaces: false }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect([200, 422]).toContain(res.statusCode);
|
|
}, 60_000);
|
|
|
|
it("rejects request without settings field", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG_200x150 },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/content-aware-resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
// No settings means no width/height/square => 400
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
});
|