mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
742 lines
32 KiB
TypeScript
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);
|
|
});
|
|
});
|