2026-04-23 17:12:02 +08:00
|
|
|
/**
|
|
|
|
|
* Integration tests for the compress tool.
|
|
|
|
|
*
|
|
|
|
|
* Tests quality-based and target-size compression modes. Verifies that
|
|
|
|
|
* output is actually smaller than input, and that format is preserved.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
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");
|
2026-05-11 17:33:51 +08:00
|
|
|
const FORMATS = join(FIXTURES, "formats");
|
2026-04-23 17:12:02 +08:00
|
|
|
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
|
2026-05-09 09:02:29 +08:00
|
|
|
const _JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
|
2026-04-23 17:12:02 +08:00
|
|
|
const WEBP = readFileSync(join(FIXTURES, "test-50x50.webp"));
|
|
|
|
|
|
|
|
|
|
let testApp: TestApp;
|
|
|
|
|
let app: TestApp["app"];
|
|
|
|
|
let adminToken: string;
|
|
|
|
|
let largeJpg: Buffer;
|
|
|
|
|
|
|
|
|
|
beforeAll(async () => {
|
|
|
|
|
testApp = await buildTestApp();
|
|
|
|
|
app = testApp.app;
|
|
|
|
|
adminToken = await loginAsAdmin(app);
|
|
|
|
|
|
|
|
|
|
// Create a larger JPEG at max quality for compression tests
|
|
|
|
|
largeJpg = await sharp(PNG).jpeg({ quality: 100 }).toBuffer();
|
|
|
|
|
}, 30_000);
|
|
|
|
|
|
|
|
|
|
afterAll(async () => {
|
|
|
|
|
await testApp.cleanup();
|
|
|
|
|
}, 10_000);
|
|
|
|
|
|
|
|
|
|
function makePayload(
|
|
|
|
|
settings: Record<string, unknown>,
|
|
|
|
|
buffer: Buffer = largeJpg,
|
|
|
|
|
filename = "test.jpg",
|
|
|
|
|
contentType = "image/jpeg",
|
|
|
|
|
) {
|
|
|
|
|
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 ?? largeJpg, filename, ct);
|
|
|
|
|
return app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/api/v1/tools/compress",
|
|
|
|
|
payload,
|
|
|
|
|
headers: {
|
|
|
|
|
"content-type": contentType,
|
|
|
|
|
authorization: `Bearer ${adminToken}`,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ── Quality mode ──────────────────────────────────────────────────
|
|
|
|
|
describe("Quality mode compression", () => {
|
|
|
|
|
it("compresses JPEG at quality 50", async () => {
|
|
|
|
|
const res = await postTool({ mode: "quality", quality: 50 });
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const result = JSON.parse(res.body);
|
|
|
|
|
expect(result.downloadUrl).toBeDefined();
|
|
|
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
|
|
|
expect(result.processedSize).toBeLessThan(result.originalSize);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("compresses JPEG at quality 10 (very low)", async () => {
|
|
|
|
|
const res = await postTool({ mode: "quality", quality: 10 });
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const result = JSON.parse(res.body);
|
|
|
|
|
expect(result.processedSize).toBeLessThan(result.originalSize);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("uses default quality (80) when quality not specified", async () => {
|
|
|
|
|
const res = await postTool({ mode: "quality" });
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const result = JSON.parse(res.body);
|
|
|
|
|
expect(result.downloadUrl).toBeDefined();
|
|
|
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("lower quality produces smaller output", async () => {
|
|
|
|
|
const res50 = await postTool({ mode: "quality", quality: 50 });
|
|
|
|
|
const res10 = await postTool({ mode: "quality", quality: 10 });
|
|
|
|
|
expect(res50.statusCode).toBe(200);
|
|
|
|
|
expect(res10.statusCode).toBe(200);
|
|
|
|
|
const result50 = JSON.parse(res50.body);
|
|
|
|
|
const result10 = JSON.parse(res10.body);
|
|
|
|
|
expect(result10.processedSize).toBeLessThan(result50.processedSize);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Target size mode ──────────────────────────────────────────────
|
|
|
|
|
describe("Target size mode", () => {
|
|
|
|
|
it("compresses to a target file size", async () => {
|
|
|
|
|
const res = await postTool({ mode: "targetSize", targetSizeKb: 5 });
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const result = JSON.parse(res.body);
|
|
|
|
|
expect(result.downloadUrl).toBeDefined();
|
|
|
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
2026-05-11 16:19:40 +08:00
|
|
|
expect(result.processedSize).toBeLessThanOrEqual(5 * 1024);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("output is at or below target for aggressive reduction", async () => {
|
|
|
|
|
const largeBuf = await sharp({
|
|
|
|
|
create: { width: 1200, height: 900, channels: 3, background: "#4488cc" },
|
|
|
|
|
})
|
|
|
|
|
.jpeg({ quality: 100 })
|
|
|
|
|
.toBuffer();
|
|
|
|
|
const targetKb = 10;
|
|
|
|
|
const res = await postTool(
|
|
|
|
|
{ mode: "targetSize", targetSizeKb: targetKb },
|
|
|
|
|
largeBuf,
|
|
|
|
|
"large.jpg",
|
|
|
|
|
"image/jpeg",
|
|
|
|
|
);
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const result = JSON.parse(res.body);
|
|
|
|
|
expect(result.processedSize).toBeLessThanOrEqual(targetKb * 1024);
|
|
|
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("output is at or below target for PNG input", async () => {
|
|
|
|
|
const largePng = await sharp({
|
|
|
|
|
create: { width: 800, height: 600, channels: 4, background: "#cc2266" },
|
|
|
|
|
})
|
|
|
|
|
.png()
|
|
|
|
|
.toBuffer();
|
|
|
|
|
const targetKb = 5;
|
|
|
|
|
const res = await postTool(
|
|
|
|
|
{ mode: "targetSize", targetSizeKb: targetKb },
|
|
|
|
|
largePng,
|
|
|
|
|
"large.png",
|
|
|
|
|
"image/png",
|
|
|
|
|
);
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const result = JSON.parse(res.body);
|
|
|
|
|
expect(result.processedSize).toBeLessThanOrEqual(targetKb * 1024);
|
|
|
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
2026-04-23 17:12:02 +08:00
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Format preservation ───────────────────────────────────────────
|
|
|
|
|
describe("Format preservation", () => {
|
|
|
|
|
it("preserves PNG format", async () => {
|
|
|
|
|
const res = await postTool({ mode: "quality", quality: 50 }, PNG, "test.png", "image/png");
|
|
|
|
|
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("png");
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("preserves JPEG format", async () => {
|
|
|
|
|
const res = await postTool({ mode: "quality", quality: 50 });
|
|
|
|
|
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("preserves image dimensions after compression", async () => {
|
|
|
|
|
const res = await postTool({ mode: "quality", quality: 30 });
|
|
|
|
|
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);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ── Multiple input formats ────────────────────────────────────────
|
|
|
|
|
describe("Multiple input formats", () => {
|
|
|
|
|
it("compresses WebP input", async () => {
|
|
|
|
|
const res = await postTool({ mode: "quality", quality: 50 }, WEBP, "test.webp", "image/webp");
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const result = JSON.parse(res.body);
|
|
|
|
|
expect(result.downloadUrl).toBeDefined();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-11 17:33:51 +08:00
|
|
|
// ── Exotic input formats ─────────────────────────────────────────
|
|
|
|
|
describe("Exotic format processing", () => {
|
|
|
|
|
const exoticFormats = [
|
|
|
|
|
{ ext: "pbm", mime: "image/x-portable-bitmap" },
|
|
|
|
|
{ ext: "pgm", mime: "image/x-portable-graymap" },
|
|
|
|
|
{ ext: "ppm", mime: "image/x-portable-pixmap" },
|
|
|
|
|
{ ext: "tiff", mime: "image/tiff" },
|
|
|
|
|
{ ext: "qoi", mime: "image/x-qoi" },
|
|
|
|
|
{ ext: "jp2", mime: "image/jp2" },
|
|
|
|
|
{ ext: "svgz", mime: "image/svg+xml" },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
for (const { ext, mime } of exoticFormats) {
|
|
|
|
|
it(`processes ${ext.toUpperCase()} input without error`, async () => {
|
|
|
|
|
const buf = readFileSync(join(FORMATS, `sample.${ext}`));
|
|
|
|
|
const res = await postTool({ mode: "quality", quality: 50 }, buf, `sample.${ext}`, mime);
|
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
|
|
|
const result = JSON.parse(res.body);
|
|
|
|
|
expect(result.downloadUrl).toBeDefined();
|
|
|
|
|
expect(result.processedSize).toBeGreaterThan(0);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
it("rejects PDF files", async () => {
|
|
|
|
|
const pdfHeader = Buffer.from("%PDF-1.4 fake pdf content for test");
|
|
|
|
|
const res = await postTool(
|
|
|
|
|
{ mode: "quality", quality: 50 },
|
|
|
|
|
pdfHeader,
|
|
|
|
|
"document.pdf",
|
|
|
|
|
"application/pdf",
|
|
|
|
|
);
|
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-23 17:12:02 +08:00
|
|
|
// ── Error handling ────────────────────────────────────────────────
|
|
|
|
|
describe("Error handling", () => {
|
|
|
|
|
it("returns 400 when no file is provided", async () => {
|
|
|
|
|
const { body: payload, contentType } = createMultipartPayload([
|
|
|
|
|
{ name: "settings", content: JSON.stringify({ mode: "quality", quality: 50 }) },
|
|
|
|
|
]);
|
|
|
|
|
const res = await app.inject({
|
|
|
|
|
method: "POST",
|
|
|
|
|
url: "/api/v1/tools/compress",
|
|
|
|
|
payload,
|
|
|
|
|
headers: {
|
|
|
|
|
"content-type": contentType,
|
|
|
|
|
authorization: `Bearer ${adminToken}`,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns 400 for quality out of range (0)", async () => {
|
|
|
|
|
const res = await postTool({ mode: "quality", quality: 0 });
|
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns 400 for quality out of range (101)", async () => {
|
|
|
|
|
const res = await postTool({ mode: "quality", quality: 101 });
|
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns 400 for invalid mode", async () => {
|
|
|
|
|
const res = await postTool({ mode: "supercompress" });
|
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
it("returns 400 for negative targetSizeKb", async () => {
|
|
|
|
|
const res = await postTool({ mode: "targetSize", targetSizeKb: -10 });
|
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
|
|
|
});
|
|
|
|
|
});
|