Files
SnapOtter/tests/e2e-docker/batch-workflows.spec.ts
T
SnapOtterandGitHub d10d0f544f fix: release QA hardening across processing, media, security, and CI gates (#649)
A release-readiness QA pass over the whole product. The commits split into
defects a user would hit and gates that were reporting green while measuring
nothing.

## Fixes that change behaviour

Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so
request.ip came from a client-set header and a forged X-Forwarded-For got past
the login limiter. The default is now a private-network trust list.

A transient Postgres outage stranded in-flight jobs, leaving finished output on
disk with no row pointing at it. A reconciler now resolves those rows and adopts
the bytes rather than dropping the work.

A Redis connection that moved to a new address wedged every read-blocked
consumer, so completions stopped signalling while health still answered 200.
Socket timeouts plus subscriber pings recover it.

Installing more than one AI bundle left the shared venv multi-versioned and
silently broke three tools. The installer now reconciles distributions to one
version each.

Converting an image to JXL at quality 1 through 4 returned a 500, because
libjxl 0.7 rejects the distance those values compute. The quality is floored at
what the encoder honours. A missing ffmpeg was also reported to the user as a
corrupt upload; it now says the engine is unavailable.

RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at
0.22.2, and the release scan was split so it can fail on an unfixed critical
instead of hiding it behind ignore-unfixed.

## Gates that could not fail

Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs
build; coverage discarded its whole report on any failing test; the lint gate
skipped root tests, scripts, and two workspaces; and several generated matrices
counted a host missing ffmpeg as a passing tool. Each now measures what it
claims.

Full evidence and the outstanding release items are tracked locally and are not
part of this branch.
2026-07-27 15:37:30 +08:00

742 lines
32 KiB
TypeScript

import { readFileSync } from "node:fs";
import { join } from "node:path";
import { expect, test } from "@playwright/test";
// ─── Batch Workflows ──────────────────────────────────────────────
// Comprehensive batch processing tests for every tool category.
// Each batch uploads 3-5 files, processes them, and verifies all
// outputs are valid (ZIP download or JSON with downloadUrl).
const FIXTURES = join(process.cwd(), "tests", "fixtures", "image", "valid");
const FORMATS = join(process.cwd(), "tests", "fixtures", "image", "formats");
const CONTENT = FIXTURES;
let token: string;
test.beforeAll(async ({ request }) => {
const res = await request.post("/api/auth/login", {
data: { username: "admin", password: "admin" },
});
const body = await res.json();
token = body.token;
});
function fixture(name: string): Buffer {
return readFileSync(join(FIXTURES, name));
}
function formatFixture(name: string): Buffer {
return readFileSync(join(FORMATS, name));
}
function contentFixture(name: string): Buffer {
return readFileSync(join(CONTENT, name));
}
/**
* Build a raw multipart/form-data body. Playwright's `multipart` option
* does not support arrays for the same field name, so multi-file uploads
* must be assembled manually.
*/
function buildMultipart(
files: Array<{ name: string; filename: string; contentType: string; buffer: Buffer }>,
fields: Array<{ name: string; value: string }>,
): { body: Buffer; contentType: string } {
const boundary = `----PlaywrightBoundary${Date.now()}`;
const parts: Buffer[] = [];
for (const file of files) {
parts.push(
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="${file.name}"; filename="${file.filename}"\r\nContent-Type: ${file.contentType}\r\n\r\n`,
),
);
parts.push(file.buffer);
parts.push(Buffer.from("\r\n"));
}
for (const field of fields) {
parts.push(
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="${field.name}"\r\n\r\n${field.value}\r\n`,
),
);
}
parts.push(Buffer.from(`--${boundary}--\r\n`));
return {
body: Buffer.concat(parts),
contentType: `multipart/form-data; boundary=${boundary}`,
};
}
/**
* Assert a batch response is valid: either a JSON with downloadUrl
* or a raw ZIP binary (PK magic bytes).
*/
async function expectBatchSuccess(res: import("@playwright/test").APIResponse): Promise<void> {
expect(res.ok()).toBe(true);
const resContentType = res.headers()["content-type"] ?? "";
if (resContentType.includes("application/json")) {
const json = await res.json();
expect(json.downloadUrl).toBeTruthy();
} else {
const buffer = Buffer.from(await res.body());
expect(buffer.length).toBeGreaterThan(0);
// ZIP magic bytes: PK\x03\x04
expect(buffer[0]).toBe(0x50);
expect(buffer[1]).toBe(0x4b);
}
}
const PNG_200x150 = fixture("test-200x150.png");
const JPG_100x100 = fixture("test-100x100.jpg");
const WEBP_50x50 = fixture("test-50x50.webp");
const HEIC_200x150 = fixture("test-200x150.heic");
const JPG_SAMPLE = formatFixture("sample.jpg");
// ─── Essential: Batch Resize 5 Images ─────────────────────────────
test.describe("Essential: batch resize 5 images", () => {
test("resize 5 mixed-format images to 120px wide", async ({ request }) => {
const avifSample = formatFixture("sample.avif");
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 },
{ name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 },
{ name: "file", filename: "d.heic", contentType: "image/heic", buffer: HEIC_200x150 },
{ name: "file", filename: "e.avif", contentType: "image/avif", buffer: avifSample },
],
[{ name: "settings", value: JSON.stringify({ width: 120, fit: "contain" }) }],
);
const res = await request.post("/api/v1/tools/image/resize/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
test("resize 5 images with explicit width and height", async ({ request }) => {
const tiffSample = formatFixture("sample.tiff");
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 },
{ name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 },
{ name: "file", filename: "d.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE },
{ name: "file", filename: "e.tiff", contentType: "image/tiff", buffer: tiffSample },
],
[{ name: "settings", value: JSON.stringify({ width: 64, height: 64, fit: "cover" }) }],
);
const res = await request.post("/api/v1/tools/image/resize/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
});
// ─── Essential: Batch Compress ────────────────────────────────────
test.describe("Essential: batch compress", () => {
test("compress 5 images with aggressive quality", async ({ request }) => {
const pngSample = formatFixture("sample.png");
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 },
{ name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 },
{ name: "file", filename: "d.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE },
{ name: "file", filename: "e.png", contentType: "image/png", buffer: pngSample },
],
[{ name: "settings", value: JSON.stringify({ quality: 20 }) }],
);
const res = await request.post("/api/v1/tools/image/compress/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
test("compress 3 images with moderate quality", async ({ request }) => {
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE },
{ name: "file", filename: "c.heic", contentType: "image/heic", buffer: HEIC_200x150 },
],
[{ name: "settings", value: JSON.stringify({ quality: 60 }) }],
);
const res = await request.post("/api/v1/tools/image/compress/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
});
// ─── Essential: Batch Rotate ──────────────────────────────────────
test.describe("Essential: batch rotate", () => {
test("rotate 4 images by 90 degrees", async ({ request }) => {
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 },
{ name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 },
{ name: "file", filename: "d.heic", contentType: "image/heic", buffer: HEIC_200x150 },
],
[{ name: "settings", value: JSON.stringify({ angle: 90 }) }],
);
const res = await request.post("/api/v1/tools/image/rotate/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
test("rotate 3 images by 45 degrees with white background", async ({ request }) => {
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 },
{ name: "file", filename: "c.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE },
],
[{ name: "settings", value: JSON.stringify({ angle: 45, background: "#ffffff" }) }],
);
const res = await request.post("/api/v1/tools/image/rotate/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
});
// ─── Adjustment: Batch Color Adjustments ──────────────────────────
test.describe("Adjustment: batch color adjustments", () => {
test("adjust brightness and contrast on 4 images", async ({ request }) => {
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 },
{ name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 },
{ name: "file", filename: "d.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE },
],
[
{
name: "settings",
value: JSON.stringify({ brightness: 25, contrast: 15, saturation: -10 }),
},
],
);
const res = await request.post("/api/v1/tools/image/adjust-colors/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
test("batch grayscale conversion on 5 images", async ({ request }) => {
const avifSample = formatFixture("sample.avif");
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 },
{ name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 },
{ name: "file", filename: "d.heic", contentType: "image/heic", buffer: HEIC_200x150 },
{ name: "file", filename: "e.avif", contentType: "image/avif", buffer: avifSample },
],
[{ name: "settings", value: JSON.stringify({ grayscale: true }) }],
);
const res = await request.post("/api/v1/tools/image/adjust-colors/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
test("batch negative brightness on 3 images", async ({ request }) => {
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 },
{ name: "file", filename: "c.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE },
],
[{ name: "settings", value: JSON.stringify({ brightness: -30, contrast: 20 }) }],
);
const res = await request.post("/api/v1/tools/image/adjust-colors/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
});
// ─── Adjustment: Batch Sharpening ─────────────────────────────────
test.describe("Adjustment: batch sharpening", () => {
test("sharpen 4 images with sigma 2.0", async ({ request }) => {
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 },
{ name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 },
{ name: "file", filename: "d.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE },
],
[{ name: "settings", value: JSON.stringify({ sigma: 2.0 }) }],
);
const res = await request.post("/api/v1/tools/image/sharpening/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
test("sharpen 3 images with low sigma for subtle effect", async ({ request }) => {
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.heic", contentType: "image/heic", buffer: HEIC_200x150 },
{ name: "file", filename: "b.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "c.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE },
],
[{ name: "settings", value: JSON.stringify({ sigma: 0.5 }) }],
);
const res = await request.post("/api/v1/tools/image/sharpening/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
});
// ─── Overlay: Batch Watermark Text ────────────────────────────────
test.describe("Overlay: batch watermark text", () => {
test("watermark 5 images with tiled text", async ({ request }) => {
const webpSample = formatFixture("sample.webp");
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 },
{ name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 },
{ name: "file", filename: "d.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE },
{ name: "file", filename: "e.webp", contentType: "image/webp", buffer: webpSample },
],
[
{
name: "settings",
value: JSON.stringify({
text: "CONFIDENTIAL",
fontSize: 18,
color: "#ff0000",
opacity: 30,
position: "tiled",
rotation: -45,
}),
},
],
);
const res = await request.post("/api/v1/tools/image/watermark-text/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
test("watermark 3 images at bottom-right", async ({ request }) => {
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE },
{ name: "file", filename: "c.heic", contentType: "image/heic", buffer: HEIC_200x150 },
],
[
{
name: "settings",
value: JSON.stringify({
text: "@snapotter",
fontSize: 14,
color: "#ffffff",
opacity: 50,
position: "bottom-right",
}),
},
],
);
const res = await request.post("/api/v1/tools/image/watermark-text/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
});
// ─── Overlay: Batch Watermark Image ───────────────────────────────
test.describe("Overlay: batch watermark image", () => {
test("watermark 3 images with a logo overlay", async ({ request }) => {
const watermarkImg = contentFixture("watermark.jpg");
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE },
{ name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 },
{
name: "watermark",
filename: "logo.jpg",
contentType: "image/jpeg",
buffer: watermarkImg,
},
],
[
{
name: "settings",
value: JSON.stringify({ position: "bottom-right", opacity: 50, scale: 20 }),
},
],
);
const res = await request.post("/api/v1/tools/image/watermark-image/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
// watermark-image batch may not be registered
if (res.status() === 404) {
const json = await res.json();
expect(json.error).toBeDefined();
return;
}
await expectBatchSuccess(res);
});
});
// ─── Format: Batch Convert JPEG to PNG ────────────────────────────
test.describe("Format: batch convert JPEG to PNG", () => {
test("convert 5 JPEG files to PNG", async ({ request }) => {
const portrait = contentFixture("portrait-color.jpg");
const portraitBw = contentFixture("portrait-bw.jpeg");
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.jpg", contentType: "image/jpeg", buffer: JPG_100x100 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE },
{
name: "file",
filename: "c.jpg",
contentType: "image/jpeg",
buffer: fixture("test-with-exif.jpg"),
},
{ name: "file", filename: "d.jpg", contentType: "image/jpeg", buffer: portrait },
{ name: "file", filename: "e.jpeg", contentType: "image/jpeg", buffer: portraitBw },
],
[{ name: "settings", value: JSON.stringify({ format: "png" }) }],
);
const res = await request.post("/api/v1/tools/image/convert/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
test("convert 4 mixed formats to WebP", async ({ request }) => {
const tiffSample = formatFixture("sample.tiff");
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE },
{ name: "file", filename: "c.heic", contentType: "image/heic", buffer: HEIC_200x150 },
{ name: "file", filename: "d.tiff", contentType: "image/tiff", buffer: tiffSample },
],
[{ name: "settings", value: JSON.stringify({ format: "webp", quality: 80 }) }],
);
const res = await request.post("/api/v1/tools/image/convert/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
test("convert 3 formats to AVIF", async ({ request }) => {
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 },
{ name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 },
],
[{ name: "settings", value: JSON.stringify({ format: "avif" }) }],
);
const res = await request.post("/api/v1/tools/image/convert/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
});
// ─── Utility: Batch Info ──────────────────────────────────────────
test.describe("Utility: batch info", () => {
test("get info for 5 images of different formats", async ({ request }) => {
const tiffSample = formatFixture("sample.tiff");
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 },
{ name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 },
{ name: "file", filename: "d.heic", contentType: "image/heic", buffer: HEIC_200x150 },
{ name: "file", filename: "e.tiff", contentType: "image/tiff", buffer: tiffSample },
],
[],
);
const res = await request.post("/api/v1/tools/image/info/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
// info/batch may not exist as a batch endpoint
if (res.status() === 404) {
const json = await res.json();
expect(json.error).toBeDefined();
return;
}
expect(res.ok()).toBe(true);
const json = await res.json();
// Batch info should return metadata for each file
if (json.results) {
expect(json.results).toBeInstanceOf(Array);
expect(json.results.length).toBe(5);
} else if (json.downloadUrl) {
expect(json.downloadUrl).toBeTruthy();
}
});
});
// ─── Utility: Batch Strip Metadata ────────────────────────────────
test.describe("Utility: batch strip metadata", () => {
test("strip metadata from 4 images", async ({ request }) => {
const jpgExif = fixture("test-with-exif.jpg");
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.jpg", contentType: "image/jpeg", buffer: jpgExif },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 },
{ name: "file", filename: "c.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "d.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE },
],
[{ name: "settings", value: JSON.stringify({}) }],
);
const res = await request.post("/api/v1/tools/image/strip-metadata/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
test("strip metadata from 3 images including HEIC", async ({ request }) => {
const jpgExif = fixture("test-with-exif.jpg");
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.jpg", contentType: "image/jpeg", buffer: jpgExif },
{ name: "file", filename: "b.heic", contentType: "image/heic", buffer: HEIC_200x150 },
{ name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 },
],
[{ name: "settings", value: JSON.stringify({}) }],
);
const res = await request.post("/api/v1/tools/image/strip-metadata/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
});
// ─── Essential: Batch Crop ────────────────────────────────────────
test.describe("Essential: batch crop", () => {
test("crop 4 images to a small center region", async ({ request }) => {
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 },
{ name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 },
{ name: "file", filename: "d.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE },
],
[
{
name: "settings",
value: JSON.stringify({ left: 5, top: 5, width: 40, height: 40 }),
},
],
);
const res = await request.post("/api/v1/tools/image/crop/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
});
// ─── Overlay: Batch Text Overlay ──────────────────────────────────
test.describe("Overlay: batch text overlay", () => {
test("text overlay on 4 images with background box", async ({ request }) => {
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 },
{ name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 },
{ name: "file", filename: "d.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE },
],
[
{
name: "settings",
value: JSON.stringify({
text: "BATCH CAPTION",
fontSize: 16,
color: "#ffffff",
position: "bottom",
backgroundBox: true,
backgroundColor: "#000000",
}),
},
],
);
const res = await request.post("/api/v1/tools/image/text-overlay/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
});
// ─── Format: Batch Convert to TIFF ────────────────────────────────
test.describe("Format: batch convert to TIFF", () => {
test("convert 3 images to TIFF", async ({ request }) => {
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 },
{ name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 },
],
[{ name: "settings", value: JSON.stringify({ format: "tiff" }) }],
);
const res = await request.post("/api/v1/tools/image/convert/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
});
// ─── Adjustment: Batch Enhance ────────────────────────────────────
test.describe("Adjustment: batch enhance", () => {
test("enhance 4 images with vivid preset", async ({ request }) => {
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 },
{ name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 },
{ name: "file", filename: "d.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE },
],
[{ name: "settings", value: JSON.stringify({ preset: "vivid" }) }],
);
const res = await request.post("/api/v1/tools/image/image-enhancement/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
});
// ─── Utility: Batch Optimize for Web ──────────────────────────────
test.describe("Utility: batch optimize for web", () => {
test("optimize 5 images for web delivery", async ({ request }) => {
const webpSample = formatFixture("sample.webp");
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 },
{ name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 },
{ name: "file", filename: "d.jpg", contentType: "image/jpeg", buffer: JPG_SAMPLE },
{ name: "file", filename: "e.webp", contentType: "image/webp", buffer: webpSample },
],
[{ name: "settings", value: JSON.stringify({ maxWidth: 600, quality: 65 }) }],
);
const res = await request.post("/api/v1/tools/image/optimize-for-web/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
});
// ─── Overlay: Batch Border ────────────────────────────────────────
test.describe("Overlay: batch border", () => {
test("add 20px red border to 4 images", async ({ request }) => {
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 },
{ name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 },
{ name: "file", filename: "d.heic", contentType: "image/heic", buffer: HEIC_200x150 },
],
[{ name: "settings", value: JSON.stringify({ size: 20, color: "#ff0000" }) }],
);
const res = await request.post("/api/v1/tools/image/border/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
});
// ─── Format: Batch Convert to GIF ─────────────────────────────────
test.describe("Format: batch convert to GIF", () => {
test("convert 3 images to GIF", async ({ request }) => {
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 },
{ name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 },
],
[{ name: "settings", value: JSON.stringify({ format: "gif" }) }],
);
const res = await request.post("/api/v1/tools/image/convert/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
});
// ─── Adjustment: Batch Replace Color ──────────────────────────────
test.describe("Adjustment: batch replace color", () => {
test("replace white with light gray in 3 images", async ({ request }) => {
const { body: reqBody, contentType } = buildMultipart(
[
{ name: "file", filename: "a.png", contentType: "image/png", buffer: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", buffer: JPG_100x100 },
{ name: "file", filename: "c.webp", contentType: "image/webp", buffer: WEBP_50x50 },
],
[
{
name: "settings",
value: JSON.stringify({
targetColor: "#ffffff",
replacementColor: "#e0e0e0",
tolerance: 30,
}),
},
],
);
const res = await request.post("/api/v1/tools/image/replace-color/batch", {
headers: { Authorization: `Bearer ${token}`, "Content-Type": contentType },
data: reqBody,
});
await expectBatchSuccess(res);
});
});