mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
The contain branch chained a zoom resize into the contain resize on one Sharp pipeline; Sharp keeps a single set of resize options, so per-cell zoom was silently discarded and pan was equally dead. Splitting the chain would not have fixed it: the contain step scales the zoomed image straight back down. The backend now implements the preview's semantics exactly: contain-fit, scale about the cell centre by zoom, offset by pan as a fraction of the cell, clip, background behind. Defaults keep the old single contain resize (now PNG-encoded so JPEG inputs no longer flatten cornerRadius masks to black corners), and the general path centres with floor to match Sharp's contain placement. Nine marker-geometry tests, seven mutants each killed by the test built for them, window math fuzzed over 2.59M combinations. Fixes #711
2472 lines
79 KiB
TypeScript
2472 lines
79 KiB
TypeScript
/**
|
|
* Integration tests for the collage tool (/api/v1/tools/image/collage).
|
|
*
|
|
* Collage accepts multiple images (any field name, type === "file") and
|
|
* arranges them in a template-based grid layout. It uses a custom route
|
|
* rather than createToolRoute.
|
|
*/
|
|
|
|
import sharp from "sharp";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { fixtures, readFixture } from "../../../fixtures/index.js";
|
|
import {
|
|
buildTestApp,
|
|
createMultipartPayload,
|
|
loginAsAdmin,
|
|
type TestApp,
|
|
} from "../../test-server.js";
|
|
|
|
const PNG = readFixture(fixtures.image.base.png200);
|
|
const JPG = readFixture(fixtures.image.base.jpg100);
|
|
|
|
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("Collage", () => {
|
|
it("creates a 2-image horizontal collage", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "file2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("creates a 2-image vertical collage", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "file2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-v-equal" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
});
|
|
|
|
it("creates a 4-image grid collage", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{ name: "f3", filename: "c.png", contentType: "image/png", content: PNG },
|
|
{ name: "f4", filename: "d.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "4-grid" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("handles single image with a 2-image template (fills first cell only)", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "solo.png", contentType: "image/png", content: PNG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
});
|
|
|
|
it("applies custom gap between cells", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal", gap: 30 }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("uses a specific aspect ratio", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal", aspectRatio: "1:1" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
|
|
// Download and verify square aspect ratio
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: result.downloadUrl,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
const meta = await sharp(dlRes.rawPayload).metadata();
|
|
expect(meta.width).toBe(meta.height);
|
|
});
|
|
|
|
it("outputs in JPEG format when requested", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
templateId: "2-h-equal",
|
|
outputFormat: "jpeg",
|
|
quality: 80,
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: result.downloadUrl,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
const meta = await sharp(dlRes.rawPayload).metadata();
|
|
expect(meta.format).toBe("jpeg");
|
|
});
|
|
|
|
it("applies corner radius to cells", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal", cornerRadius: 20 }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("uses transparent background", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
templateId: "2-h-equal",
|
|
backgroundColor: "transparent",
|
|
outputFormat: "png",
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: result.downloadUrl,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
const meta = await sharp(dlRes.rawPayload).metadata();
|
|
expect(meta.channels).toBe(4); // alpha channel for transparency
|
|
});
|
|
|
|
// ── Validation ──────────────────────────────────────────────────────
|
|
|
|
it("rejects requests with no images", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.error).toMatch(/no images/i);
|
|
});
|
|
|
|
it("rejects unknown template ID", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "nonexistent-layout" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.error).toMatch(/unknown template/i);
|
|
});
|
|
|
|
it("rejects gap exceeding max (500)", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal", gap: 600 }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejects invalid output format", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal", outputFormat: "bmp" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejects unauthenticated requests", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: { "content-type": contentType },
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(401);
|
|
});
|
|
|
|
// ── Extended coverage: layout modes & edge cases ────────────────────
|
|
|
|
it("creates a 3-image horizontal collage (3-h-equal)", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{ name: "f3", filename: "c.png", contentType: "image/png", content: PNG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "3-h-equal" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("creates a 3-image left-large layout", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{ name: "f3", filename: "c.png", contentType: "image/png", content: PNG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "3-left-large" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
});
|
|
|
|
it("creates a 5-image collage (5-top2-bottom3)", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{ name: "f3", filename: "c.png", contentType: "image/png", content: PNG },
|
|
{ name: "f4", filename: "d.jpg", contentType: "image/jpeg", content: JPG },
|
|
{ name: "f5", filename: "e.png", contentType: "image/png", content: PNG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "5-top2-bottom3" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("creates a 6-image grid (6-grid-3x2) with mixed formats", async () => {
|
|
const WEBP = readFixture(fixtures.image.base.webp50);
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{ name: "f3", filename: "c.webp", contentType: "image/webp", content: WEBP },
|
|
{ name: "f4", filename: "d.png", contentType: "image/png", content: PNG },
|
|
{ name: "f5", filename: "e.jpg", contentType: "image/jpeg", content: JPG },
|
|
{ name: "f6", filename: "f.webp", contentType: "image/webp", content: WEBP },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "6-grid-3x2" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
});
|
|
|
|
it("creates a 9-image grid collage", async () => {
|
|
const images = Array.from({ length: 9 }, (_, i) => ({
|
|
name: `f${i + 1}`,
|
|
filename: `${i}.${i % 2 === 0 ? "png" : "jpg"}`,
|
|
contentType: i % 2 === 0 ? "image/png" : "image/jpeg",
|
|
content: i % 2 === 0 ? PNG : JPG,
|
|
}));
|
|
|
|
const { body, contentType } = createMultipartPayload([
|
|
...images,
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "9-grid" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("outputs in webp format when requested", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
templateId: "2-h-equal",
|
|
outputFormat: "webp",
|
|
quality: 75,
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: result.downloadUrl,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
const meta = await sharp(dlRes.rawPayload).metadata();
|
|
expect(meta.format).toBe("webp");
|
|
});
|
|
|
|
it("outputs in avif format when requested", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
templateId: "2-h-equal",
|
|
outputFormat: "avif",
|
|
quality: 60,
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: result.downloadUrl,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
const meta = await sharp(dlRes.rawPayload).metadata();
|
|
expect(meta.format).toBe("heif");
|
|
});
|
|
|
|
it("applies contain object fit via cell settings", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
templateId: "2-h-equal",
|
|
cells: [
|
|
{ imageIndex: 0, objectFit: "contain", panX: 0, panY: 0, zoom: 1 },
|
|
{ imageIndex: 1, objectFit: "contain", panX: 0, panY: 0, zoom: 1 },
|
|
],
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("applies pan and zoom via cell settings", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
templateId: "2-h-equal",
|
|
cells: [
|
|
{ imageIndex: 0, panX: 50, panY: -50, zoom: 2, objectFit: "cover" },
|
|
{ imageIndex: 1, panX: -30, panY: 30, zoom: 1.5, objectFit: "cover" },
|
|
],
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("uses 16:9 aspect ratio", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal", aspectRatio: "16:9" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: result.downloadUrl,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
const meta = await sharp(dlRes.rawPayload).metadata();
|
|
// 16:9 ratio: width/height should be ~1.78
|
|
const ratio = (meta.width ?? 1) / (meta.height ?? 1);
|
|
expect(ratio).toBeCloseTo(16 / 9, 1);
|
|
});
|
|
|
|
it("uses 9:16 (portrait) aspect ratio", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-v-equal", aspectRatio: "9:16" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: result.downloadUrl,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
const meta = await sharp(dlRes.rawPayload).metadata();
|
|
// Portrait: height > width
|
|
expect(meta.height).toBeGreaterThan(meta.width!);
|
|
});
|
|
|
|
it("applies custom hex background color", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
templateId: "2-h-equal",
|
|
backgroundColor: "#FF0000",
|
|
gap: 20,
|
|
outputFormat: "png",
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("handles HEIC input images", { timeout: 120_000 }, async () => {
|
|
const HEIC = readFixture(fixtures.image.base.heic200);
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.heic", contentType: "image/heic", content: HEIC },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
});
|
|
|
|
it("reports originalSize as the sum of all input buffers", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.originalSize).toBeGreaterThan(0);
|
|
expect(result.jobId).toBeDefined();
|
|
});
|
|
|
|
it("rejects invalid JSON in settings", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "settings", content: "{not valid json" },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
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);
|
|
});
|
|
|
|
// ── Branch coverage: invalid file validation (line 463) ─────────────
|
|
|
|
it("rejects an invalid/corrupt image file", async () => {
|
|
const corruptBuffer = Buffer.from("this is not an image at all!!!");
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "corrupt.png", contentType: "image/png", content: corruptBuffer },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.error).toMatch(/invalid file/i);
|
|
});
|
|
|
|
// ── Branch coverage: contain + transparent bg (line 548) ────────────
|
|
|
|
it("applies contain fit with transparent background", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
templateId: "2-h-equal",
|
|
backgroundColor: "transparent",
|
|
outputFormat: "png",
|
|
cells: [
|
|
{ imageIndex: 0, objectFit: "contain", panX: 0, panY: 0, zoom: 1 },
|
|
{ imageIndex: 1, objectFit: "contain", panX: 0, panY: 0, zoom: 1 },
|
|
],
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: result.downloadUrl,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
const meta = await sharp(dlRes.rawPayload).metadata();
|
|
expect(meta.channels).toBe(4);
|
|
});
|
|
|
|
// ── Branch coverage: contain + opaque bg (line 548 alternate) ───────
|
|
|
|
it("applies contain fit with opaque hex background", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
templateId: "2-h-equal",
|
|
backgroundColor: "#00FF00",
|
|
cells: [
|
|
{ imageIndex: 0, objectFit: "contain", panX: 0, panY: 0, zoom: 1 },
|
|
{ imageIndex: 1, objectFit: "contain", panX: 0, panY: 0, zoom: 1 },
|
|
],
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ── Branch coverage: contain + zoom > 1 ─────────────────────────────
|
|
|
|
it("applies contain fit with zoom greater than 1", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
templateId: "2-h-equal",
|
|
cells: [
|
|
{ imageIndex: 0, objectFit: "contain", panX: 0, panY: 0, zoom: 2 },
|
|
{ imageIndex: 1, objectFit: "contain", panX: 0, panY: 0, zoom: 1.5 },
|
|
],
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ── Branch coverage: 4:3 and 3:2 aspect ratios ─────────────────────
|
|
|
|
it("uses 4:3 aspect ratio", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal", aspectRatio: "4:3" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: result.downloadUrl,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
const meta = await sharp(dlRes.rawPayload).metadata();
|
|
const ratio = (meta.width ?? 1) / (meta.height ?? 1);
|
|
expect(ratio).toBeCloseTo(4 / 3, 1);
|
|
});
|
|
|
|
it("uses 3:2 aspect ratio", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal", aspectRatio: "3:2" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: result.downloadUrl,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
const meta = await sharp(dlRes.rawPayload).metadata();
|
|
const ratio = (meta.width ?? 1) / (meta.height ?? 1);
|
|
expect(ratio).toBeCloseTo(3 / 2, 1);
|
|
});
|
|
|
|
it("uses 4:5 portrait aspect ratio", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-v-equal", aspectRatio: "4:5" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: result.downloadUrl,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
const meta = await sharp(dlRes.rawPayload).metadata();
|
|
// 4:5 is portrait: height > width
|
|
expect(meta.height).toBeGreaterThan(meta.width!);
|
|
});
|
|
|
|
// ── Branch coverage: 1x1 tiny image input ───────────────────────────
|
|
|
|
it("handles 1x1 pixel input images", async () => {
|
|
const TINY = readFixture(fixtures.image.edge.px1);
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "tiny1.png", contentType: "image/png", content: TINY },
|
|
{ name: "f2", filename: "tiny2.png", contentType: "image/png", content: TINY },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ── Branch coverage: corner radius + transparent bg ─────────────────
|
|
|
|
it("applies corner radius with transparent background", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
templateId: "2-h-equal",
|
|
cornerRadius: 30,
|
|
backgroundColor: "transparent",
|
|
outputFormat: "png",
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: result.downloadUrl,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
const meta = await sharp(dlRes.rawPayload).metadata();
|
|
expect(meta.channels).toBe(4);
|
|
});
|
|
|
|
// ── Branch coverage: large file handling ────────────────────────────
|
|
|
|
it("handles a large content image", async () => {
|
|
const LARGE = readFixture(fixtures.image.stressLarge);
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "large.jpg", contentType: "image/jpeg", content: LARGE },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ── Branch coverage: 2-h-left-large template ───────────────────────
|
|
|
|
it("creates a 2-image left-large horizontal collage", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-left-large" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ── Branch coverage: 2-h-right-large template ──────────────────────
|
|
|
|
it("creates a 2-image right-large horizontal collage", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-right-large" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
});
|
|
|
|
// ── Branch coverage: 3-right-large template ────────────────────────
|
|
|
|
it("creates a 3-image right-large collage", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{ name: "f3", filename: "c.png", contentType: "image/png", content: PNG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "3-right-large" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
});
|
|
|
|
// ── Branch coverage: 3-top-large template ──────────────────────────
|
|
|
|
it("creates a 3-image top-large collage", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{ name: "f3", filename: "c.png", contentType: "image/png", content: PNG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "3-top-large" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
});
|
|
|
|
// ── Branch coverage: 3-v-equal template ────────────────────────────
|
|
|
|
it("creates a 3-image vertical equal collage", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{ name: "f3", filename: "c.png", contentType: "image/png", content: PNG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "3-v-equal" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
});
|
|
|
|
// ── Branch coverage: quality below min rejects ─────────────────────
|
|
|
|
it("rejects quality below minimum (1)", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal", quality: 0 }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
// ── Branch coverage: corner radius above max rejects ───────────────
|
|
|
|
it("rejects cornerRadius above maximum (500)", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal", cornerRadius: 600 }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
// ── Branch coverage: missing templateId rejects ────────────────────
|
|
|
|
it("rejects missing templateId in settings", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
// ── Branch coverage: portrait aspect ratio arMultiplier > 1 ────────
|
|
|
|
it("uses free aspect ratio (default 4:3)", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal", aspectRatio: "free" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: result.downloadUrl,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
const meta = await sharp(dlRes.rawPayload).metadata();
|
|
// Free uses 4:3 default: w=2400, h=1800
|
|
const ratio = (meta.width ?? 1) / (meta.height ?? 1);
|
|
expect(ratio).toBeCloseTo(4 / 3, 1);
|
|
});
|
|
|
|
// ── Branch coverage: HEIF content format input ─────────────────────
|
|
|
|
it("handles portrait HEIC input images", { timeout: 120_000 }, async () => {
|
|
const HEIC_PORTRAIT = readFixture(fixtures.image.portraitHeic);
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.heic", contentType: "image/heic", content: HEIC_PORTRAIT },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
});
|
|
|
|
// ── HEIF format input ─────────────────────────────────────────────
|
|
|
|
it("handles HEIF input images", { timeout: 120_000 }, async () => {
|
|
const HEIF = readFixture(fixtures.image.motorcycle);
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.heif", contentType: "image/heif", content: HEIF },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ── Animated GIF input ────────────────────────────────────────────
|
|
|
|
it("handles animated GIF input images", async () => {
|
|
const GIF = readFixture(fixtures.image.animated.gif);
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.gif", contentType: "image/gif", content: GIF },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ── SVG input ─────────────────────────────────────────────────────
|
|
|
|
it("handles SVG input images in collage", async () => {
|
|
const SVG = readFixture(fixtures.image.base.svg100);
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "icon.svg", contentType: "image/svg+xml", content: SVG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ── Remaining template coverage ─────────────────────────────────────
|
|
|
|
it("creates a 4-image left-large collage (4-left-large)", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{ name: "f3", filename: "c.png", contentType: "image/png", content: PNG },
|
|
{ name: "f4", filename: "d.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "4-left-large" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("creates a 4-image top-large collage (4-top-large)", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{ name: "f3", filename: "c.png", contentType: "image/png", content: PNG },
|
|
{ name: "f4", filename: "d.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "4-top-large" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("creates a 4-image bottom-large collage (4-bottom-large)", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{ name: "f3", filename: "c.png", contentType: "image/png", content: PNG },
|
|
{ name: "f4", filename: "d.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "4-bottom-large" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("creates a 5-image top3-bottom2 collage", async () => {
|
|
const images = Array.from({ length: 5 }, (_, i) => ({
|
|
name: `f${i + 1}`,
|
|
filename: `${i}.${i % 2 === 0 ? "png" : "jpg"}`,
|
|
contentType: i % 2 === 0 ? "image/png" : "image/jpeg",
|
|
content: i % 2 === 0 ? PNG : JPG,
|
|
}));
|
|
|
|
const { body, contentType } = createMultipartPayload([
|
|
...images,
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "5-top3-bottom2" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("creates a 5-image left-large collage (5-left-large)", async () => {
|
|
const images = Array.from({ length: 5 }, (_, i) => ({
|
|
name: `f${i + 1}`,
|
|
filename: `${i}.png`,
|
|
contentType: "image/png",
|
|
content: PNG,
|
|
}));
|
|
|
|
const { body, contentType } = createMultipartPayload([
|
|
...images,
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "5-left-large" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("creates a 5-image center-large collage (5-center-large)", async () => {
|
|
const images = Array.from({ length: 5 }, (_, i) => ({
|
|
name: `f${i + 1}`,
|
|
filename: `${i}.jpg`,
|
|
contentType: "image/jpeg",
|
|
content: JPG,
|
|
}));
|
|
|
|
const { body, contentType } = createMultipartPayload([
|
|
...images,
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "5-center-large" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("creates a 6-image grid-2x3 collage (6-grid-2x3)", async () => {
|
|
const images = Array.from({ length: 6 }, (_, i) => ({
|
|
name: `f${i + 1}`,
|
|
filename: `${i}.png`,
|
|
contentType: "image/png",
|
|
content: PNG,
|
|
}));
|
|
|
|
const { body, contentType } = createMultipartPayload([
|
|
...images,
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "6-grid-2x3" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("creates a 6-image top-large collage (6-top-large)", async () => {
|
|
const images = Array.from({ length: 6 }, (_, i) => ({
|
|
name: `f${i + 1}`,
|
|
filename: `${i}.jpg`,
|
|
contentType: "image/jpeg",
|
|
content: JPG,
|
|
}));
|
|
|
|
const { body, contentType } = createMultipartPayload([
|
|
...images,
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "6-top-large" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("creates a 7-image mosaic collage (7-mosaic)", async () => {
|
|
const images = Array.from({ length: 7 }, (_, i) => ({
|
|
name: `f${i + 1}`,
|
|
filename: `${i}.${i % 2 === 0 ? "png" : "jpg"}`,
|
|
contentType: i % 2 === 0 ? "image/png" : "image/jpeg",
|
|
content: i % 2 === 0 ? PNG : JPG,
|
|
}));
|
|
|
|
const { body, contentType } = createMultipartPayload([
|
|
...images,
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "7-mosaic" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("creates an 8-image mosaic collage (8-mosaic)", async () => {
|
|
const images = Array.from({ length: 8 }, (_, i) => ({
|
|
name: `f${i + 1}`,
|
|
filename: `${i}.${i % 2 === 0 ? "png" : "jpg"}`,
|
|
contentType: i % 2 === 0 ? "image/png" : "image/jpeg",
|
|
content: i % 2 === 0 ? PNG : JPG,
|
|
}));
|
|
|
|
const { body, contentType } = createMultipartPayload([
|
|
...images,
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "8-mosaic" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ── JXL output format ─────────────────────────────────────────────
|
|
|
|
it("accepts jxl output format (succeeds if Sharp supports JXL)", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
templateId: "2-h-equal",
|
|
outputFormat: "jxl",
|
|
quality: 75,
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
// JXL support depends on the Sharp build; either succeeds or fails gracefully
|
|
expect([200, 422]).toContain(res.statusCode);
|
|
if (res.statusCode === 200) {
|
|
const result = JSON.parse(res.body);
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
expect(result.downloadUrl).toContain(".jxl");
|
|
}
|
|
});
|
|
|
|
// ── Extra images beyond template capacity are truncated ───────────
|
|
|
|
it("truncates extra images beyond template capacity", async () => {
|
|
// 4-grid only takes 4 images; sending 6 should still succeed
|
|
const images = Array.from({ length: 6 }, (_, i) => ({
|
|
name: `f${i + 1}`,
|
|
filename: `${i}.png`,
|
|
contentType: "image/png",
|
|
content: PNG,
|
|
}));
|
|
|
|
const { body, contentType } = createMultipartPayload([
|
|
...images,
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "4-grid" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ── AVIF format input ─────────────────────────────────────────────
|
|
|
|
it("handles AVIF input images in collage", async () => {
|
|
const AVIF = readFixture(fixtures.image.formats("avif"));
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.avif", contentType: "image/avif", content: AVIF },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ── SVGZ input ───────────────────────────────────────────────────
|
|
|
|
it("handles SVGZ (compressed SVG) input images", async () => {
|
|
const SVGZ = readFixture(fixtures.image.formats("svgz"));
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "icon.svgz", contentType: "image/svg+xml", content: SVGZ },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal" }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.downloadUrl).toBeDefined();
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ── Cell zoom at maximum (10) ─────────────────────────────────────
|
|
|
|
it("applies maximum zoom (10) via cell settings", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
templateId: "2-h-equal",
|
|
cells: [
|
|
{ imageIndex: 0, objectFit: "cover", panX: 0, panY: 0, zoom: 10 },
|
|
{ imageIndex: 1, objectFit: "cover", panX: 0, panY: 0, zoom: 1 },
|
|
],
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ── Cell zoom above max rejects ──────────────────────────────────
|
|
|
|
it("rejects zoom above maximum (10)", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
templateId: "2-h-equal",
|
|
cells: [{ imageIndex: 0, objectFit: "cover", panX: 0, panY: 0, zoom: 11 }],
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
// ── Pan at boundary values (-100, 100) ───────────────────────────
|
|
|
|
it("applies pan at max boundary values (-100, 100)", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
templateId: "2-h-equal",
|
|
cells: [
|
|
{ imageIndex: 0, panX: -100, panY: -100, zoom: 2, objectFit: "cover" },
|
|
{ imageIndex: 1, panX: 100, panY: 100, zoom: 2, objectFit: "cover" },
|
|
],
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ── Quality at max boundary (100) ────────────────────────────────
|
|
|
|
it("accepts quality at maximum (100)", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
templateId: "2-h-equal",
|
|
outputFormat: "jpeg",
|
|
quality: 100,
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ── Gap at zero (no gap) ─────────────────────────────────────────
|
|
|
|
it("applies zero gap between cells", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ templateId: "2-h-equal", gap: 0 }),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
// ── Combined settings: gap + cornerRadius + backgroundColor + cells + format
|
|
|
|
it("combines all settings: gap, cornerRadius, backgroundColor, cells, webp", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "a.png", contentType: "image/png", content: PNG },
|
|
{ name: "f2", filename: "b.jpg", contentType: "image/jpeg", content: JPG },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
templateId: "2-h-equal",
|
|
gap: 15,
|
|
cornerRadius: 10,
|
|
backgroundColor: "#0000FF",
|
|
aspectRatio: "1:1",
|
|
outputFormat: "webp",
|
|
quality: 85,
|
|
cells: [
|
|
{ imageIndex: 0, objectFit: "contain", panX: 20, panY: -20, zoom: 1.5 },
|
|
{ imageIndex: 1, objectFit: "cover", panX: -10, panY: 10, zoom: 2 },
|
|
],
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
"content-type": contentType,
|
|
},
|
|
body,
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const result = JSON.parse(res.body);
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: result.downloadUrl,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
const meta = await sharp(dlRes.rawPayload).metadata();
|
|
expect(meta.format).toBe("webp");
|
|
expect(meta.width).toBe(meta.height); // 1:1 aspect
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Contain-mode zoom and pan (#711).
|
|
*
|
|
* The contain branch chained two resizes on one Sharp pipeline; Sharp keeps a
|
|
* single set of resize options, so the zoom carried by the first call was
|
|
* silently discarded and every contain cell rendered as if zoom were 1. Even
|
|
* split into two pipelines the old shape was a no-op, because the second
|
|
* contain-resize scales the zoomed image straight back down. The preview
|
|
* defines the intended semantics: object-contain, then
|
|
* `translate(panX%, panY%) scale(zoom)` clipped at the cell, background
|
|
* behind.
|
|
*
|
|
* The marker image makes the geometry provable: a 200x100 blue field with a
|
|
* red 10x10 square at the exact centre. In a 1200x1800 cell ("2-h-equal",
|
|
* gap 0, free aspect = 2400x1800 canvas) contain scales it 6x, so the red
|
|
* square lands at a known place and a known size for every zoom and pan.
|
|
*/
|
|
describe("Collage contain-mode zoom and pan (#711)", () => {
|
|
const CELL_W = 1200;
|
|
const CELL_H = 1800;
|
|
|
|
let marker: Buffer;
|
|
|
|
beforeAll(async () => {
|
|
const raw = Buffer.alloc(200 * 100 * 3);
|
|
for (let i = 0; i < raw.length; i += 3) raw[i + 2] = 255;
|
|
for (let y = 45; y < 55; y++) {
|
|
for (let x = 95; x < 105; x++) {
|
|
const i = (y * 200 + x) * 3;
|
|
raw[i] = 255;
|
|
raw[i + 2] = 0;
|
|
}
|
|
}
|
|
marker = await sharp(raw, { raw: { width: 200, height: 100, channels: 3 } })
|
|
.png()
|
|
.toBuffer();
|
|
});
|
|
|
|
async function runCollage(cell: Record<string, unknown>, extra: Record<string, unknown> = {}) {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "marker.png", contentType: "image/png", content: marker },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
templateId: "2-h-equal",
|
|
gap: 0,
|
|
cells: [{ imageIndex: 0, objectFit: "contain", panX: 0, panY: 0, zoom: 1, ...cell }],
|
|
...extra,
|
|
}),
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: JSON.parse(res.body).downloadUrl,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
return dlRes.rawPayload;
|
|
}
|
|
|
|
const isRed = (px: number[]) => px[0] > 200 && px[1] < 60 && px[2] < 60;
|
|
const isBlue = (px: number[]) => px[2] > 200 && px[0] < 60 && px[1] < 60;
|
|
|
|
/** RGBA pixels of the left cell plus a point probe. */
|
|
async function leftCell(output: Buffer) {
|
|
const meta = await sharp(output).metadata();
|
|
expect([meta.width, meta.height]).toEqual([2400, 1800]);
|
|
const data = await sharp(output)
|
|
.extract({ left: 0, top: 0, width: CELL_W, height: CELL_H })
|
|
.ensureAlpha()
|
|
.raw()
|
|
.toBuffer();
|
|
const at = (x: number, y: number) => [
|
|
data[(y * CELL_W + x) * 4],
|
|
data[(y * CELL_W + x) * 4 + 1],
|
|
data[(y * CELL_W + x) * 4 + 2],
|
|
data[(y * CELL_W + x) * 4 + 3],
|
|
];
|
|
return { data, at };
|
|
}
|
|
|
|
function redStats(data: Buffer, width = CELL_W, height = CELL_H) {
|
|
let count = 0;
|
|
let sumX = 0;
|
|
let sumY = 0;
|
|
for (let y = 0; y < height; y++) {
|
|
for (let x = 0; x < width; x++) {
|
|
const i = (y * width + x) * 4;
|
|
if (isRed([data[i], data[i + 1], data[i + 2]])) {
|
|
count++;
|
|
sumX += x;
|
|
sumY += y;
|
|
}
|
|
}
|
|
}
|
|
return {
|
|
count,
|
|
centroidX: count ? sumX / count : Number.NaN,
|
|
centroidY: count ? sumY / count : Number.NaN,
|
|
};
|
|
}
|
|
|
|
it("zoom changes the rendered cell", async () => {
|
|
const zoom1 = await runCollage({ zoom: 1 });
|
|
const zoom2 = await runCollage({ zoom: 2 });
|
|
const [a, b] = await Promise.all([
|
|
sharp(zoom1).raw().toBuffer(),
|
|
sharp(zoom2).raw().toBuffer(),
|
|
]);
|
|
expect(a.equals(b)).toBe(false);
|
|
});
|
|
|
|
it("zoom 2 doubles the marker and covers more of the cell", async () => {
|
|
const { data: z1 } = await leftCell(await runCollage({ zoom: 1 }));
|
|
const { data: z2, at } = await leftCell(await runCollage({ zoom: 2 }));
|
|
|
|
// Red square is 60x60 at zoom 1 and 120x120 at zoom 2 (about 4x the area).
|
|
const r1 = redStats(z1);
|
|
const r2 = redStats(z2);
|
|
expect(r1.count).toBeGreaterThan(2000);
|
|
expect(r2.count).toBeGreaterThan(r1.count * 2.5);
|
|
|
|
// At zoom 1 the content band starts at y=600; at zoom 2 it starts at
|
|
// y=300, so this point flips from background to image.
|
|
const z1At = (x: number, y: number) => [
|
|
z1[(y * CELL_W + x) * 4],
|
|
z1[(y * CELL_W + x) * 4 + 1],
|
|
z1[(y * CELL_W + x) * 4 + 2],
|
|
];
|
|
expect(isBlue(z1At(600, 450)) || isRed(z1At(600, 450))).toBe(false);
|
|
expect(isBlue(at(600, 450))).toBe(true);
|
|
});
|
|
|
|
it("pan shifts the contained image inside the cell", async () => {
|
|
// panX 25 translates by 25% of the cell width: 300px right.
|
|
const { data: centred } = await leftCell(await runCollage({ zoom: 1, panX: 0 }));
|
|
const { data: panned } = await leftCell(await runCollage({ zoom: 1, panX: 25 }));
|
|
|
|
const before = redStats(centred);
|
|
const after = redStats(panned);
|
|
expect(Math.abs(before.centroidX - 600)).toBeLessThan(15);
|
|
expect(Math.abs(after.centroidX - 900)).toBeLessThan(15);
|
|
});
|
|
|
|
it("zoomed content over a transparent background keeps real alpha", async () => {
|
|
const { at } = await leftCell(
|
|
await runCollage({ zoom: 2 }, { backgroundColor: "transparent", outputFormat: "png" }),
|
|
);
|
|
|
|
// Inside the zoomed content: opaque blue. Above it: still fully clear.
|
|
const content = at(600, 450);
|
|
expect(content[3]).toBe(255);
|
|
expect(isBlue(content)).toBe(true);
|
|
expect(at(600, 100)[3]).toBe(0);
|
|
});
|
|
|
|
it("pan does not scale with zoom and panY moves vertically", async () => {
|
|
// The preview contract: translate(panX%, panY%) of the CELL, applied
|
|
// independently of the zoom. panX 25 is 300px and panY 10 is 180px, so
|
|
// the zoom-2 marker centre lands at (600+300, 900+180). If pan scaled
|
|
// with zoom the centroid would land around (1170, 1260) instead.
|
|
const { data } = await leftCell(await runCollage({ zoom: 2, panX: 25, panY: 10 }));
|
|
|
|
const stats = redStats(data);
|
|
expect(stats.count).toBeGreaterThan(8000);
|
|
expect(Math.abs(stats.centroidX - 900)).toBeLessThan(15);
|
|
expect(Math.abs(stats.centroidY - 1080)).toBeLessThan(15);
|
|
});
|
|
|
|
it("pan at the schema limit empties the cell to plain background", async () => {
|
|
// panX 100 shifts the full-width content exactly one cell to the right,
|
|
// so nothing remains visible and the branch that skips the extract runs.
|
|
const { data, at } = await leftCell(await runCollage({ zoom: 1, panX: 100 }));
|
|
|
|
expect(redStats(data).count).toBe(0);
|
|
expect(at(600, 900)).toEqual([255, 255, 255, 255]);
|
|
});
|
|
|
|
it("height-driven cells scale the contained image by the limiting axis", async () => {
|
|
// In the 2400x900 top cell of 2-v-equal, the 200x100 marker is limited by
|
|
// height (fitScale 9, not 12), so at zoom 2 the red square renders 180px
|
|
// tall, about 29000 red pixels. A width-driven fitScale would give 240px
|
|
// and about 52000; the ceiling separates them with wide margin.
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "marker.png", contentType: "image/png", content: marker },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
templateId: "2-v-equal",
|
|
gap: 0,
|
|
cells: [{ imageIndex: 0, objectFit: "contain", panX: 0, panY: 0, zoom: 2 }],
|
|
}),
|
|
},
|
|
]);
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: JSON.parse(res.body).downloadUrl,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
|
|
const topCell = await sharp(dlRes.rawPayload)
|
|
.extract({ left: 0, top: 0, width: 2400, height: 900 })
|
|
.ensureAlpha()
|
|
.raw()
|
|
.toBuffer();
|
|
const stats = redStats(topCell, 2400, 900);
|
|
expect(stats.count).toBeGreaterThan(20000);
|
|
expect(stats.count).toBeLessThan(40000);
|
|
});
|
|
|
|
it("rounded corners stay clean for JPEG inputs on the default path", async () => {
|
|
// The fast path used to keep the input format, so a JPEG cell buffer hit
|
|
// the cornerRadius dest-in mask, JPEG cannot store the masked alpha, and
|
|
// the corners flattened to black. The general path already emitted PNG,
|
|
// which made corners snap between black and correct as zoom crossed 1.
|
|
const markerJpeg = await sharp(marker).jpeg({ quality: 95 }).toBuffer();
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "f1", filename: "marker.jpg", contentType: "image/jpeg", content: markerJpeg },
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({
|
|
templateId: "2-h-equal",
|
|
gap: 0,
|
|
cornerRadius: 200,
|
|
cells: [{ imageIndex: 0, objectFit: "contain", panX: 0, panY: 0, zoom: 1 }],
|
|
}),
|
|
},
|
|
]);
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/collage",
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
|
body,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: JSON.parse(res.body).downloadUrl,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
|
|
// The masked corner must show the white canvas, not a black block.
|
|
const raw = await sharp(dlRes.rawPayload).removeAlpha().raw().toBuffer();
|
|
const meta = await sharp(dlRes.rawPayload).metadata();
|
|
const w = meta.width ?? 0;
|
|
const corner = [raw[(5 * w + 5) * 3], raw[(5 * w + 5) * 3 + 1], raw[(5 * w + 5) * 3 + 2]];
|
|
expect(corner[0]).toBeGreaterThan(200);
|
|
expect(corner[1]).toBeGreaterThan(200);
|
|
expect(corner[2]).toBeGreaterThan(200);
|
|
});
|
|
|
|
it("zoom 1 with no pan stays byte-identical to a plain contain resize", async () => {
|
|
const output = await runCollage({ zoom: 1 });
|
|
|
|
const cell = await sharp(marker)
|
|
.resize(CELL_W, CELL_H, { fit: "contain", background: { r: 255, g: 255, b: 255, alpha: 1 } })
|
|
.toBuffer();
|
|
const expected = await sharp({
|
|
create: { width: 2400, height: 1800, channels: 3, background: { r: 255, g: 255, b: 255 } },
|
|
})
|
|
.composite([{ input: cell, left: 0, top: 0 }])
|
|
.png()
|
|
.toBuffer();
|
|
|
|
const [got, want] = await Promise.all([
|
|
sharp(output).removeAlpha().raw().toBuffer(),
|
|
sharp(expected).removeAlpha().raw().toBuffer(),
|
|
]);
|
|
expect(got.equals(want)).toBe(true);
|
|
});
|
|
});
|