mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
merge: resolve conflict with main branch in tool-registry.tsx
This commit is contained in:
@@ -1000,7 +1000,7 @@ describe("Tool processing", () => {
|
||||
it("rejects unsupported format", async () => {
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "convert.png", contentType: "image/png", content: PNG_1x1 },
|
||||
{ name: "settings", content: JSON.stringify({ format: "bmp" }) },
|
||||
{ name: "settings", content: JSON.stringify({ format: "xyz" }) },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
/**
|
||||
* Integration tests for the beautify tool (/api/v1/tools/beautify).
|
||||
*
|
||||
* Beautify adds polished backgrounds, shadows, device frames, watermarks,
|
||||
* and social media sizing to screenshots. Tests exercise all background types,
|
||||
* frame variants, shadow presets, social presets, watermarks, padding/radius
|
||||
* extremes, format forcing, and error handling through the HTTP layer.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
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 = readFileSync(join(FIXTURES, "test-200x150.png"));
|
||||
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);
|
||||
|
||||
function post(url: string, payload: { body: Buffer; contentType: string }) {
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url,
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": payload.contentType,
|
||||
},
|
||||
body: payload.body,
|
||||
});
|
||||
}
|
||||
|
||||
describe("Beautify", () => {
|
||||
// ── Background types ────────────────────────────────────────────────
|
||||
|
||||
it("default settings produce valid PNG", async () => {
|
||||
const payload = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{ name: "settings", content: JSON.stringify({}) },
|
||||
]);
|
||||
|
||||
const res = await post("/api/v1/tools/beautify", payload);
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
expect(result.jobId).toBeDefined();
|
||||
expect(result.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("solid background", async () => {
|
||||
const payload = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({
|
||||
backgroundType: "solid",
|
||||
backgroundColor: "#ff0000",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await post("/api/v1/tools/beautify", payload);
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
});
|
||||
|
||||
it("linear gradient background", async () => {
|
||||
const payload = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({
|
||||
backgroundType: "linear-gradient",
|
||||
gradientStops: [
|
||||
{ color: "#ff0000", position: 0 },
|
||||
{ color: "#0000ff", position: 100 },
|
||||
],
|
||||
gradientAngle: 45,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await post("/api/v1/tools/beautify", payload);
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
});
|
||||
|
||||
it("radial gradient background", async () => {
|
||||
const payload = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({
|
||||
backgroundType: "radial-gradient",
|
||||
gradientStops: [
|
||||
{ color: "#ffffff", position: 0 },
|
||||
{ color: "#000000", position: 100 },
|
||||
],
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await post("/api/v1/tools/beautify", payload);
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
});
|
||||
|
||||
it("transparent background", async () => {
|
||||
const payload = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ backgroundType: "transparent" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await post("/api/v1/tools/beautify", payload);
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
});
|
||||
|
||||
// ── Image background ────────────────────────────────────────────────
|
||||
|
||||
it("image background with second file", async () => {
|
||||
const bgImage = await sharp({
|
||||
create: {
|
||||
width: 400,
|
||||
height: 300,
|
||||
channels: 4,
|
||||
background: { r: 0, g: 0, b: 255, alpha: 1 },
|
||||
},
|
||||
})
|
||||
.png()
|
||||
.toBuffer();
|
||||
|
||||
const payload = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{ name: "backgroundImage", filename: "bg.png", contentType: "image/png", content: bgImage },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ backgroundType: "image" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await post("/api/v1/tools/beautify", payload);
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
expect(result.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// ── Multi-stop gradient ─────────────────────────────────────────────
|
||||
|
||||
it("three-stop gradient", async () => {
|
||||
const payload = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({
|
||||
backgroundType: "linear-gradient",
|
||||
gradientStops: [
|
||||
{ color: "#ff0000", position: 0 },
|
||||
{ color: "#00ff00", position: 50 },
|
||||
{ color: "#0000ff", position: 100 },
|
||||
],
|
||||
gradientAngle: 90,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await post("/api/v1/tools/beautify", payload);
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
});
|
||||
|
||||
// ── Frames ──────────────────────────────────────────────────────────
|
||||
|
||||
const FRAME_TYPES = [
|
||||
"macos-light",
|
||||
"macos-dark",
|
||||
"windows-light",
|
||||
"windows-dark",
|
||||
"browser-light",
|
||||
"browser-dark",
|
||||
"iphone",
|
||||
"macbook",
|
||||
"ipad",
|
||||
] as const;
|
||||
|
||||
for (const frame of FRAME_TYPES) {
|
||||
it(`frame: ${frame}`, async () => {
|
||||
const payload = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ frame, shadowPreset: "none" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await post("/api/v1/tools/beautify", payload);
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
|
||||
const dlRes = await app.inject({
|
||||
method: "GET",
|
||||
url: result.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(dlRes.statusCode).toBe(200);
|
||||
|
||||
const meta = await sharp(dlRes.rawPayload).metadata();
|
||||
expect(meta.width).toBeGreaterThan(0);
|
||||
expect(meta.height).toBeGreaterThan(0);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Shadows ─────────────────────────────────────────────────────────
|
||||
|
||||
const SHADOW_PRESETS = ["none", "subtle", "medium", "dramatic"] as const;
|
||||
|
||||
for (const shadowPreset of SHADOW_PRESETS) {
|
||||
it(`shadow preset: ${shadowPreset}`, async () => {
|
||||
const payload = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ shadowPreset }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await post("/api/v1/tools/beautify", payload);
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
});
|
||||
}
|
||||
|
||||
it("custom shadow", async () => {
|
||||
const payload = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({
|
||||
shadowPreset: "custom",
|
||||
shadowBlur: 50,
|
||||
shadowOffsetX: 10,
|
||||
shadowOffsetY: 15,
|
||||
shadowColor: "#ff0000",
|
||||
shadowOpacity: 60,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await post("/api/v1/tools/beautify", payload);
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
});
|
||||
|
||||
// ── Social presets with dimension verification ──────────────────────
|
||||
|
||||
const SOCIAL_PRESETS: Record<string, { w: number; h: number }> = {
|
||||
twitter: { w: 1600, h: 900 },
|
||||
linkedin: { w: 1200, h: 627 },
|
||||
"instagram-square": { w: 1080, h: 1080 },
|
||||
"instagram-story": { w: 1080, h: 1920 },
|
||||
facebook: { w: 1200, h: 630 },
|
||||
producthunt: { w: 1270, h: 760 },
|
||||
};
|
||||
|
||||
for (const [preset, dims] of Object.entries(SOCIAL_PRESETS)) {
|
||||
it(`social preset: ${preset} (${dims.w}x${dims.h})`, async () => {
|
||||
const payload = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ socialPreset: preset, shadowPreset: "none" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await post("/api/v1/tools/beautify", payload);
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
|
||||
const dlRes = await app.inject({
|
||||
method: "GET",
|
||||
url: result.downloadUrl,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(dlRes.statusCode).toBe(200);
|
||||
|
||||
const meta = await sharp(dlRes.rawPayload).metadata();
|
||||
expect(meta.width).toBe(dims.w);
|
||||
expect(meta.height).toBe(dims.h);
|
||||
});
|
||||
}
|
||||
|
||||
// ── Watermark ───────────────────────────────────────────────────────
|
||||
|
||||
it("watermark text bottom-right", async () => {
|
||||
const payload = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({
|
||||
watermarkText: "SnapOtter",
|
||||
watermarkPosition: "bottom-right",
|
||||
watermarkOpacity: 80,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await post("/api/v1/tools/beautify", payload);
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
});
|
||||
|
||||
// ── Padding extremes ────────────────────────────────────────────────
|
||||
|
||||
it("padding 0 with no shadow", async () => {
|
||||
const payload = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ padding: 0, shadowPreset: "none" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await post("/api/v1/tools/beautify", payload);
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
});
|
||||
|
||||
it("padding 256", async () => {
|
||||
const payload = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ padding: 256 }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await post("/api/v1/tools/beautify", payload);
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
});
|
||||
|
||||
// ── Border radius ───────────────────────────────────────────────────
|
||||
|
||||
it("border radius 64", async () => {
|
||||
const payload = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ borderRadius: 64 }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await post("/api/v1/tools/beautify", payload);
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
});
|
||||
|
||||
// ── Error cases ─────────────────────────────────────────────────────
|
||||
|
||||
it("missing file returns 400", async () => {
|
||||
const payload = createMultipartPayload([{ name: "settings", content: JSON.stringify({}) }]);
|
||||
|
||||
const res = await post("/api/v1/tools/beautify", payload);
|
||||
expect(res.statusCode).toBe(400);
|
||||
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.error).toMatch(/no image/i);
|
||||
});
|
||||
|
||||
it("invalid parameters return 400", async () => {
|
||||
const payload = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ padding: -1 }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await post("/api/v1/tools/beautify", payload);
|
||||
expect(res.statusCode).toBe(400);
|
||||
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.error).toMatch(/invalid settings/i);
|
||||
});
|
||||
|
||||
// ── Format forcing ──────────────────────────────────────────────────
|
||||
|
||||
it("JPEG input + shadow produces PNG", async () => {
|
||||
const payload = createMultipartPayload([
|
||||
{ name: "file", filename: "photo.jpg", contentType: "image/jpeg", content: JPG },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ shadowPreset: "medium" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await post("/api/v1/tools/beautify", payload);
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toContain(".png");
|
||||
});
|
||||
|
||||
it("JPEG input + opaque settings honors JPEG output", async () => {
|
||||
const payload = createMultipartPayload([
|
||||
{ name: "file", filename: "photo.jpg", contentType: "image/jpeg", content: JPG },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({
|
||||
backgroundType: "solid",
|
||||
shadowPreset: "none",
|
||||
borderRadius: 0,
|
||||
frame: "none",
|
||||
outputFormat: "jpeg",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await post("/api/v1/tools/beautify", payload);
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toContain(".jpeg");
|
||||
});
|
||||
|
||||
// ── Device frame + radius ───────────────────────────────────────────
|
||||
|
||||
it("iPhone frame with borderRadius > 0 (radius silently ignored)", async () => {
|
||||
const payload = createMultipartPayload([
|
||||
{ name: "file", filename: "test.png", contentType: "image/png", content: PNG },
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({
|
||||
frame: "iphone",
|
||||
borderRadius: 32,
|
||||
shadowPreset: "none",
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await post("/api/v1/tools/beautify", payload);
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import sharp from "sharp";
|
||||
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 = readFileSync(join(FIXTURES, "test-200x150.png"));
|
||||
const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
|
||||
|
||||
const ALL_TYPES = [
|
||||
"protanopia",
|
||||
"deuteranopia",
|
||||
"tritanopia",
|
||||
"protanomaly",
|
||||
"deuteranomaly",
|
||||
"tritanomaly",
|
||||
"achromatopsia",
|
||||
"blueConeMonochromacy",
|
||||
] as const;
|
||||
|
||||
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);
|
||||
|
||||
function makePayload(
|
||||
settings: Record<string, unknown>,
|
||||
buffer: Buffer = PNG,
|
||||
filename = "test.png",
|
||||
contentType = "image/png",
|
||||
) {
|
||||
return createMultipartPayload([
|
||||
{ name: "file", filename, contentType, content: buffer },
|
||||
{ name: "settings", content: JSON.stringify(settings) },
|
||||
]);
|
||||
}
|
||||
|
||||
async function postTool(
|
||||
settings: Record<string, unknown>,
|
||||
buffer?: Buffer,
|
||||
filename?: string,
|
||||
ct?: string,
|
||||
) {
|
||||
const { body: payload, contentType } = makePayload(settings, buffer, filename, ct);
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/color-blindness",
|
||||
payload,
|
||||
headers: {
|
||||
"content-type": contentType,
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("Default settings", () => {
|
||||
it("processes with default settings (deuteranomaly)", async () => {
|
||||
const res = await postTool({});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
expect(result.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("All 8 simulation types", () => {
|
||||
for (const type of ALL_TYPES) {
|
||||
it(`processes with simulationType=${type}`, async () => {
|
||||
const res = await postTool({ simulationType: type });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
});
|
||||
}
|
||||
|
||||
it("different types produce different outputs", async () => {
|
||||
const buffers: Buffer[] = [];
|
||||
for (const type of ["protanopia", "tritanopia", "achromatopsia"] as const) {
|
||||
const res = await postTool({ simulationType: type });
|
||||
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}` },
|
||||
});
|
||||
buffers.push(dlRes.rawPayload);
|
||||
}
|
||||
const pixelSets = await Promise.all(
|
||||
buffers.map(async (buf) => {
|
||||
const { data } = await sharp(buf).removeAlpha().raw().toBuffer({ resolveWithObject: true });
|
||||
return `${data[0]},${data[1]},${data[2]}`;
|
||||
}),
|
||||
);
|
||||
const unique = new Set(pixelSets);
|
||||
expect(unique.size).toBeGreaterThan(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Dimension preservation", () => {
|
||||
it("output has same dimensions as input", async () => {
|
||||
const res = await postTool({ simulationType: "protanopia" });
|
||||
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.width).toBe(200);
|
||||
expect(meta.height).toBe(150);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Multiple input formats", () => {
|
||||
it("processes JPEG input", async () => {
|
||||
const res = await postTool({ simulationType: "deuteranopia" }, JPG, "test.jpg", "image/jpeg");
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.downloadUrl).toBeDefined();
|
||||
});
|
||||
|
||||
it("processes WebP input", async () => {
|
||||
const WEBP = readFileSync(join(FIXTURES, "test-50x50.webp"));
|
||||
const res = await postTool({ simulationType: "tritanopia" }, WEBP, "test.webp", "image/webp");
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("processes HEIC input", { timeout: 120_000 }, async () => {
|
||||
const HEIC = readFileSync(join(FIXTURES, "test-200x150.heic"));
|
||||
const res = await postTool({ simulationType: "protanomaly" }, HEIC, "photo.heic", "image/heic");
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("processes SVG input", async () => {
|
||||
const SVG = readFileSync(join(FIXTURES, "test-100x100.svg"));
|
||||
const res = await postTool(
|
||||
{ simulationType: "achromatopsia" },
|
||||
SVG,
|
||||
"icon.svg",
|
||||
"image/svg+xml",
|
||||
);
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
|
||||
it("processes animated GIF input", async () => {
|
||||
const GIF = readFileSync(join(FIXTURES, "animated.gif"));
|
||||
const res = await postTool({ simulationType: "deuteranomaly" }, GIF, "anim.gif", "image/gif");
|
||||
expect(res.statusCode).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Error handling", () => {
|
||||
it("returns 400 when no file is provided", async () => {
|
||||
const { body: payload, contentType } = createMultipartPayload([
|
||||
{ name: "settings", content: JSON.stringify({ simulationType: "protanopia" }) },
|
||||
]);
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/color-blindness",
|
||||
payload,
|
||||
headers: {
|
||||
"content-type": contentType,
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid simulationType value", async () => {
|
||||
const res = await postTool({ simulationType: "invalid-type" });
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Edge cases", () => {
|
||||
it("processes 1x1 pixel image", async () => {
|
||||
const TINY = readFileSync(join(FIXTURES, "test-1x1.png"));
|
||||
const res = await postTool({ simulationType: "deuteranomaly" }, TINY, "tiny.png", "image/png");
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("processes stress-large.jpg", async () => {
|
||||
const LARGE = readFileSync(join(FIXTURES, "content", "stress-large.jpg"));
|
||||
const res = await postTool({ simulationType: "protanopia" }, LARGE, "large.jpg", "image/jpeg");
|
||||
expect(res.statusCode).toBe(200);
|
||||
const result = JSON.parse(res.body);
|
||||
expect(result.processedSize).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Authentication", () => {
|
||||
it("rejects unauthenticated request", async () => {
|
||||
const { body: payload, contentType } = makePayload({ simulationType: "protanopia" });
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/color-blindness",
|
||||
payload,
|
||||
headers: { "content-type": contentType },
|
||||
});
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
@@ -179,6 +179,102 @@ const FORMAT_SAMPLES: FormatSample[] = [
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: true,
|
||||
},
|
||||
{
|
||||
name: "SVGZ",
|
||||
file: "sample.svgz",
|
||||
mime: "image/svg+xml",
|
||||
needsCliDecoder: false,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "JP2",
|
||||
file: "sample.jp2",
|
||||
mime: "image/jp2",
|
||||
needsCliDecoder: true,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "EPS",
|
||||
file: "sample.eps",
|
||||
mime: "application/postscript",
|
||||
needsCliDecoder: true,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "PPM",
|
||||
file: "sample.ppm",
|
||||
mime: "image/x-portable-pixmap",
|
||||
needsCliDecoder: true,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "PGM",
|
||||
file: "sample.pgm",
|
||||
mime: "image/x-portable-graymap",
|
||||
needsCliDecoder: true,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "PBM",
|
||||
file: "sample.pbm",
|
||||
mime: "image/x-portable-bitmap",
|
||||
needsCliDecoder: true,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "DDS",
|
||||
file: "sample.dds",
|
||||
mime: "image/vnd.ms-dds",
|
||||
needsCliDecoder: true,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "CUR",
|
||||
file: "sample.cur",
|
||||
mime: "image/x-icon",
|
||||
needsCliDecoder: true,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "DPX",
|
||||
file: "sample.dpx",
|
||||
mime: "image/x-dpx",
|
||||
needsCliDecoder: true,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "FITS",
|
||||
file: "sample.fits",
|
||||
mime: "image/fits",
|
||||
needsCliDecoder: true,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "APNG",
|
||||
file: "sample.apng",
|
||||
mime: "image/apng",
|
||||
needsCliDecoder: false,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
{
|
||||
name: "QOI",
|
||||
file: "sample.qoi",
|
||||
mime: "image/x-qoi",
|
||||
needsCliDecoder: true,
|
||||
needsHeifDecoder: false,
|
||||
mayFailValidation: false,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Integration tests for new input/output format support.
|
||||
*
|
||||
* - New output formats: convert PNG to jxl, bmp, ico, jp2, qoi
|
||||
* - SVGZ input: verify compressed SVG decodes correctly
|
||||
* - JXL quality: verify lower quality produces smaller files
|
||||
*/
|
||||
|
||||
import { existsSync, 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 FORMATS_DIR = join(__dirname, "..", "fixtures", "formats");
|
||||
|
||||
describe("New format support", () => {
|
||||
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);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// New output format conversions
|
||||
// ---------------------------------------------------------------------------
|
||||
const NEW_OUTPUT_FORMATS = ["jxl", "bmp", "ico", "jp2", "qoi"];
|
||||
|
||||
for (const format of NEW_OUTPUT_FORMATS) {
|
||||
it(`converts PNG to ${format}`, async () => {
|
||||
const fileBuffer = readFileSync(join(FORMATS_DIR, "sample.png"));
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "sample.png",
|
||||
contentType: "image/png",
|
||||
content: fileBuffer,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ format, quality: 80 }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/convert",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
// Accept 200 (success) or 422 (encoder not available in test env)
|
||||
expect([200, 422]).toContain(res.statusCode);
|
||||
if (res.statusCode === 200) {
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.processedSize).toBeGreaterThan(0);
|
||||
expect(json.downloadUrl).toBeTruthy();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVGZ input decoding
|
||||
// ---------------------------------------------------------------------------
|
||||
it("decodes SVGZ input correctly", async () => {
|
||||
const fileBuffer = readFileSync(join(FORMATS_DIR, "sample.svgz"));
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "sample.svgz",
|
||||
contentType: "image/svg+xml",
|
||||
content: fileBuffer,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ format: "png" }),
|
||||
},
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/convert",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
|
||||
expect([200, 422]).toContain(res.statusCode);
|
||||
if (res.statusCode === 200) {
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.processedSize).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// JXL quality affects file size
|
||||
// ---------------------------------------------------------------------------
|
||||
it("JXL quality affects file size", async () => {
|
||||
const fileBuffer = readFileSync(join(FORMATS_DIR, "sample.png"));
|
||||
|
||||
const convert = async (quality: number) => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{
|
||||
name: "file",
|
||||
filename: "sample.png",
|
||||
contentType: "image/png",
|
||||
content: fileBuffer,
|
||||
},
|
||||
{
|
||||
name: "settings",
|
||||
content: JSON.stringify({ format: "jxl", quality }),
|
||||
},
|
||||
]);
|
||||
return app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/convert",
|
||||
headers: {
|
||||
authorization: `Bearer ${adminToken}`,
|
||||
"content-type": contentType,
|
||||
},
|
||||
body,
|
||||
});
|
||||
};
|
||||
|
||||
const lowQ = await convert(30);
|
||||
const highQ = await convert(90);
|
||||
|
||||
// Only compare sizes if both succeeded (JXL encoder may not be available)
|
||||
if (lowQ.statusCode === 200 && highQ.statusCode === 200) {
|
||||
const lowJson = JSON.parse(lowQ.body);
|
||||
const highJson = JSON.parse(highQ.body);
|
||||
expect(lowJson.processedSize).toBeLessThan(highJson.processedSize);
|
||||
}
|
||||
});
|
||||
|
||||
describe("Extended output format matrix", () => {
|
||||
const INPUTS = [
|
||||
{ name: "PNG", file: "sample.png", mime: "image/png" },
|
||||
{ name: "JPEG", file: "sample.jpg", mime: "image/jpeg" },
|
||||
{ name: "WebP", file: "sample.webp", mime: "image/webp" },
|
||||
];
|
||||
const OUTPUTS = [
|
||||
"jpg",
|
||||
"png",
|
||||
"webp",
|
||||
"avif",
|
||||
"tiff",
|
||||
"gif",
|
||||
"heic",
|
||||
"heif",
|
||||
"jxl",
|
||||
"bmp",
|
||||
"ico",
|
||||
"jp2",
|
||||
"qoi",
|
||||
];
|
||||
|
||||
for (const input of INPUTS) {
|
||||
for (const outFmt of OUTPUTS) {
|
||||
const inLower = input.name.toLowerCase();
|
||||
if (inLower === outFmt || (inLower === "jpeg" && outFmt === "jpg")) continue;
|
||||
it(`converts ${input.name} to ${outFmt}`, async () => {
|
||||
const fileBuffer = readFileSync(join(FORMATS_DIR, input.file));
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: input.file, contentType: input.mime, content: fileBuffer },
|
||||
{ name: "settings", content: JSON.stringify({ format: outFmt, quality: 80 }) },
|
||||
]);
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/convert",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
expect([200, 422]).toContain(res.statusCode);
|
||||
if (res.statusCode === 200) {
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.processedSize).toBeGreaterThan(0);
|
||||
expect(json.downloadUrl).toBeTruthy();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("New input format processing via resize", () => {
|
||||
const NEW_INPUT_FORMATS = [
|
||||
{ name: "SVGZ", file: "sample.svgz", mime: "image/svg+xml" },
|
||||
{ name: "JP2", file: "sample.jp2", mime: "image/jp2" },
|
||||
{ name: "EPS", file: "sample.eps", mime: "application/postscript" },
|
||||
{ name: "PPM", file: "sample.ppm", mime: "image/x-portable-pixmap" },
|
||||
{ name: "PGM", file: "sample.pgm", mime: "image/x-portable-graymap" },
|
||||
{ name: "PBM", file: "sample.pbm", mime: "image/x-portable-bitmap" },
|
||||
{ name: "DDS", file: "sample.dds", mime: "image/vnd.ms-dds" },
|
||||
{ name: "CUR", file: "sample.cur", mime: "image/x-icon" },
|
||||
{ name: "DPX", file: "sample.dpx", mime: "image/x-dpx" },
|
||||
{ name: "FITS", file: "sample.fits", mime: "image/fits" },
|
||||
{ name: "APNG", file: "sample.apng", mime: "image/apng" },
|
||||
{ name: "QOI", file: "sample.qoi", mime: "image/x-qoi" },
|
||||
];
|
||||
for (const fmt of NEW_INPUT_FORMATS) {
|
||||
it(`resizes ${fmt.name} input to 25x25`, async () => {
|
||||
const fixturePath = join(FORMATS_DIR, fmt.file);
|
||||
if (!existsSync(fixturePath)) return;
|
||||
const fileBuffer = readFileSync(fixturePath);
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: fmt.file, contentType: fmt.mime, content: fileBuffer },
|
||||
{ name: "settings", content: JSON.stringify({ width: 25, height: 25 }) },
|
||||
]);
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/tools/resize",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
expect([200, 400, 422]).toContain(res.statusCode);
|
||||
if (res.statusCode === 200) {
|
||||
const json = JSON.parse(res.body);
|
||||
expect(json.downloadUrl).toBeTruthy();
|
||||
expect(json.processedSize).toBeGreaterThan(0);
|
||||
}
|
||||
}, 60_000);
|
||||
}
|
||||
});
|
||||
|
||||
describe("Preview generation for special formats", () => {
|
||||
const PREVIEW_FORMATS = [
|
||||
{ name: "HEIC", file: "sample.heic", mime: "image/heic" },
|
||||
{ name: "JXL", file: "sample.jxl", mime: "image/jxl" },
|
||||
{ name: "ICO", file: "sample.ico", mime: "image/x-icon" },
|
||||
{ name: "PSD", file: "sample.psd", mime: "image/vnd.adobe.photoshop" },
|
||||
{ name: "EXR", file: "sample.exr", mime: "image/x-exr" },
|
||||
];
|
||||
for (const fmt of PREVIEW_FORMATS) {
|
||||
it(`generates preview for ${fmt.name}`, async () => {
|
||||
const fixturePath = join(FORMATS_DIR, fmt.file);
|
||||
if (!existsSync(fixturePath)) return;
|
||||
const fileBuffer = readFileSync(fixturePath);
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: fmt.file, contentType: fmt.mime, content: fileBuffer },
|
||||
]);
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/preview",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
expect([200, 422]).toContain(res.statusCode);
|
||||
if (res.statusCode === 200) {
|
||||
const ct = res.headers["content-type"] as string;
|
||||
expect(ct).toMatch(/image\/(webp|png)/);
|
||||
}
|
||||
}, 60_000);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user