feat: extract auto-orient utility and expand test coverage

Extract EXIF auto-orientation logic into a shared auto-orient module
used by both single-tool and batch routes. This ensures camera photos
display correctly after processing regardless of entry point.

Also expands e2e and integration tests significantly.
This commit is contained in:
Siddharth Kumar Sah
2026-03-24 20:37:14 +08:00
parent 06eabdd045
commit e5086ada6e
7 changed files with 1378 additions and 45 deletions
+25
View File
@@ -0,0 +1,25 @@
import sharp from "sharp";
/**
* Auto-orient an image buffer based on EXIF orientation metadata.
*
* Camera photos embed an EXIF Orientation tag (values 2-8) that viewers
* respect when displaying. Sharp strips this tag during processing but
* does NOT auto-rotate the pixels, so the output appears rotated.
*
* This function physically rotates the pixels to match the EXIF orientation,
* then strips the tag so downstream processing produces correct results.
*
* Returns the original buffer unchanged if no rotation is needed.
*/
export async function autoOrient(buffer: Buffer): Promise<Buffer> {
try {
const meta = await sharp(buffer).metadata();
if (meta.orientation && meta.orientation > 1) {
return await sharp(buffer).rotate().toBuffer();
}
} catch {
// If metadata reading fails, return the original buffer
}
return buffer;
}
+3 -1
View File
@@ -14,6 +14,7 @@ import PQueue from "p-queue";
import { getToolConfig } from "./tool-factory.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import { autoOrient } from "../lib/auto-orient.js";
import { env } from "../config.js";
import { updateJobProgress, type JobProgress } from "./progress.js";
@@ -191,8 +192,9 @@ export async function registerBatchRoutes(
}
try {
const orientedBuffer = await autoOrient(file.buffer);
const result = await toolConfig.process(
file.buffer,
orientedBuffer,
settings,
file.filename,
);
+2 -14
View File
@@ -3,10 +3,10 @@ import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import type { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { z } from "zod";
import sharp from "sharp";
import { createWorkspace } from "../lib/workspace.js";
import { validateImageBuffer } from "../lib/file-validation.js";
import { sanitizeFilename } from "../lib/filename.js";
import { autoOrient } from "../lib/auto-orient.js";
export interface ToolRouteConfig<T> {
/** Unique tool identifier, used as the URL path segment. */
@@ -127,19 +127,7 @@ export function createToolRoute<T>(
}
// Auto-orient based on EXIF metadata before processing.
// Camera photos often have EXIF orientation tags (values 2-8) that browsers
// respect when displaying, but Sharp does NOT apply by default. Without this,
// processed images appear rotated because the output (PNG) strips EXIF data.
// Only re-encodes when orientation correction is actually needed.
let processBuffer = fileBuffer;
try {
const meta = await sharp(fileBuffer).metadata();
if (meta.orientation && meta.orientation > 1) {
processBuffer = await sharp(fileBuffer).rotate().toBuffer();
}
} catch {
// If metadata reading fails, proceed with original buffer
}
const processBuffer = await autoOrient(fileBuffer);
// Process the image
try {
+1 -1
View File
@@ -71,7 +71,7 @@ This VitePress site. Deployed to GitHub Pages automatically on push to `main`.
1. The user picks a tool in the web UI and uploads an image.
2. The frontend sends a multipart POST to `/api/v1/tools/:toolId` with the file and settings.
3. The API route validates the input with Zod, then calls the appropriate package function -- either `@stirling-image/image-engine` for standard operations or `@stirling-image/ai` for ML tasks.
3. The API route validates the input with Zod, auto-orients the image based on EXIF metadata (so camera photos display correctly after processing), then calls the appropriate package function -- either `@stirling-image/image-engine` for standard operations or `@stirling-image/ai` for ML tasks.
4. For AI tools, the TypeScript bridge spawns a Python subprocess, waits for it to finish, and reads the output file.
5. The API returns a `jobId` and `downloadUrl`. The frontend can poll `/api/v1/jobs/:jobId/progress` via SSE for real time status on longer tasks.
6. The user downloads the processed image from `/api/v1/download/:jobId/:filename`.
+1 -1
View File
@@ -47,7 +47,7 @@ See [Configuration](./configuration) for the full list of environment variables.
## Build from source
Requirements: Node.js 20+, pnpm 9+, Python 3.10+
Requirements: Node.js 22+, pnpm 9+, Python 3.10+
```bash
git clone https://github.com/siddharthksah/Stirling-Image.git
+531 -28
View File
@@ -1,48 +1,551 @@
import { test, expect } from "./helpers";
import { test, expect, getTestImagePath } from "./helpers";
test.describe("Automate Page", () => {
// Retry flaky tests caused by dev server timing
test.describe.configure({ retries: 3 });
/**
* Navigate to /automate and wait for the page to fully render.
* Uses multiple retry strategies for blank-page flakes.
*/
async function gotoAutomate(page: import("@playwright/test").Page) {
const heading = page.getByRole("heading", {
name: /automation pipeline/i,
});
for (let attempt = 0; attempt < 3; attempt++) {
if (attempt === 0) {
await page.goto("/automate", { waitUntil: "networkidle" });
} else {
// On retry, wait then reload
await page.waitForTimeout(500);
await page.goto("/automate", { waitUntil: "networkidle" });
}
try {
await expect(heading).toBeVisible({ timeout: 8_000 });
return; // Page loaded successfully
} catch {
// Continue to next attempt
}
}
// Final attempt — let it throw if it fails
await page.goto("/automate", { waitUntil: "networkidle" });
await expect(heading).toBeVisible({ timeout: 10_000 });
}
/** Wait for pipeline steps to render after a template click. */
async function waitForSteps(
page: import("@playwright/test").Page,
count: number,
) {
// Step numbers (1, 2, 3...) appear inside the step cards
await expect(page.getByTitle("Remove")).toHaveCount(count, {
timeout: 5_000,
});
}
const testImagePath = getTestImagePath();
/** Upload the test image via file chooser. */
async function uploadTestFile(page: import("@playwright/test").Page) {
const fileChooserPromise = page.waitForEvent("filechooser");
await page
.getByRole("button", { name: /upload image to process/i })
.click();
const fileChooser = await fileChooserPromise;
await fileChooser.setFiles(testImagePath);
await page.waitForTimeout(500);
}
// ─── Page Rendering ───────────────────────────────────────────────────
test("automate page renders pipeline builder", async ({
loggedInPage: page,
}) => {
await page.goto("/automate");
// Should show the pipeline builder section
await gotoAutomate(page);
await expect(
page.getByText(/pipeline|automation|workflow/i).first(),
page.getByText(/chain multiple tools/i).first(),
).toBeVisible();
});
test("shows suggested templates", async ({ loggedInPage: page }) => {
await page.goto("/automate");
test("shows all five pipeline templates", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await expect(page.getByText("Social Media Ready")).toBeVisible();
await expect(page.getByText("Privacy Clean")).toBeVisible();
await expect(page.getByText("Web Optimization")).toBeVisible();
await expect(page.getByText("Profile Picture")).toBeVisible();
await expect(page.getByText("Watermark Batch")).toBeVisible();
});
// Should show at least one template
test("shows template descriptions", async ({ loggedInPage: page }) => {
await gotoAutomate(page);
await expect(
page
.getByText(/social media|privacy|web optimization|profile|watermark/i)
.first(),
page.getByText(/resize 1080x1080/i).first(),
).toBeVisible();
await expect(
page.getByText(/strip all metadata/i).first(),
).toBeVisible();
});
test("can add a step to pipeline", async ({ loggedInPage: page }) => {
await page.goto("/automate");
// Look for add step button
const addBtn = page.getByRole("button", { name: /add|step|\+/i }).first();
if (await addBtn.isVisible()) {
await addBtn.click();
// Should show tool picker or added step
await page.waitForTimeout(500);
}
test("shows empty state message when no steps", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await expect(
page.getByText(/add steps to build your automation pipeline/i),
).toBeVisible();
});
test("has save pipeline button", async ({ loggedInPage: page }) => {
await page.goto("/automate");
test("shows upload image button", async ({ loggedInPage: page }) => {
await gotoAutomate(page);
await expect(
page.getByRole("button", { name: /upload image to process/i }),
).toBeVisible();
});
// Look for save button
const saveBtn = page
.getByRole("button", { name: /save/i })
.first();
// Save might be disabled until there are steps, but should exist
test("has Add Step button", async ({ loggedInPage: page }) => {
await gotoAutomate(page);
await expect(
page.getByRole("button", { name: /add step/i }),
).toBeVisible();
});
test("has Process button (disabled when no steps or file)", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
const processBtn = page.getByRole("button", {
name: "Process",
exact: true,
});
await expect(processBtn).toBeVisible();
await expect(processBtn).toBeDisabled();
});
test("has Save Pipeline button (disabled when no steps)", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
const saveBtn = page.getByRole("button", { name: "Save Pipeline" });
await expect(saveBtn).toBeVisible();
await expect(saveBtn).toBeDisabled();
});
// ─── Template Loading ─────────────────────────────────────────────────
test("clicking Social Media Ready template loads 4 steps", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await page.getByText("Social Media Ready").click();
await waitForSteps(page, 4);
// Verify empty state is gone
await expect(
page.getByText(/add steps to build your automation pipeline/i),
).not.toBeVisible();
});
test("clicking Privacy Clean template loads 2 steps", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await page.getByText("Privacy Clean").click();
await waitForSteps(page, 2);
});
test("clicking Web Optimization template loads 3 steps", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await page.getByText("Web Optimization").click();
await waitForSteps(page, 3);
});
test("clicking Profile Picture template loads 2 steps", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await page.getByText("Profile Picture").click();
await waitForSteps(page, 2);
});
test("clicking Watermark Batch template loads 3 steps", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await page.getByText("Watermark Batch").click();
await waitForSteps(page, 3);
});
test("loading a template replaces previous steps", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await page.getByText("Social Media Ready").click();
await waitForSteps(page, 4);
await page.getByText("Privacy Clean").click();
await waitForSteps(page, 2);
});
// ─── Add Step ─────────────────────────────────────────────────────────
test("clicking Add Step opens tool picker", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await page.getByRole("button", { name: /add step/i }).click();
await expect(page.getByText("Add a step")).toBeVisible();
});
test("tool picker shows available tools", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await page.getByRole("button", { name: /add step/i }).click();
const pickerArea = page.locator(".max-h-64.overflow-y-auto");
await expect(pickerArea.getByText("Resize").first()).toBeVisible();
await expect(pickerArea.getByText("Convert").first()).toBeVisible();
});
test("selecting a tool from picker adds a step", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await page.getByRole("button", { name: /add step/i }).click();
const pickerArea = page.locator(".max-h-64.overflow-y-auto");
await pickerArea
.getByText("Resize", { exact: false })
.first()
.click();
await waitForSteps(page, 1);
});
test("can add multiple steps", async ({ loggedInPage: page }) => {
await gotoAutomate(page);
await page.getByRole("button", { name: /add step/i }).click();
let picker = page.locator(".max-h-64.overflow-y-auto");
await picker.getByText("Resize", { exact: false }).first().click();
await waitForSteps(page, 1);
await page.getByRole("button", { name: /add step/i }).click();
picker = page.locator(".max-h-64.overflow-y-auto");
await picker.getByText("Convert", { exact: false }).first().click();
await waitForSteps(page, 2);
});
// ─── Step Controls ────────────────────────────────────────────────────
test("can remove a step", async ({ loggedInPage: page }) => {
await gotoAutomate(page);
await page.getByText("Privacy Clean").click();
await waitForSteps(page, 2);
await page.getByTitle("Remove").first().click();
await waitForSteps(page, 1);
});
test("can expand step settings", async ({ loggedInPage: page }) => {
await gotoAutomate(page);
await page.getByText("Privacy Clean").click();
await waitForSteps(page, 2);
await page.getByTitle("Settings").first().click();
await expect(
page.getByText(/default settings will be used/i),
).toBeVisible();
});
test("move up button disabled on first step", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await page.getByText("Social Media Ready").click();
await waitForSteps(page, 4);
await expect(page.getByTitle("Move up").first()).toBeDisabled();
});
test("move down button disabled on last step", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await page.getByText("Social Media Ready").click();
await waitForSteps(page, 4);
await expect(page.getByTitle("Move down").last()).toBeDisabled();
});
// ─── File Upload ──────────────────────────────────────────────────────
test("can upload a file via file chooser", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await uploadTestFile(page);
// File name and size should be visible in the upload area
await expect(page.getByText("test-image.png")).toBeVisible();
// The file size text is inside the dashed border area
const uploadArea = page.locator("[class*='border-dashed']").first();
await expect(uploadArea.getByText(/KB\)/)).toBeVisible();
});
test("can remove uploaded file", async ({ loggedInPage: page }) => {
await gotoAutomate(page);
await uploadTestFile(page);
await expect(page.getByText("test-image.png")).toBeVisible();
// Remove file — the X button inside the dashed upload area
const uploadArea = page.locator("[class*='border-dashed']").first();
await uploadArea.locator("button").click();
await expect(
page.getByRole("button", { name: /upload image to process/i }),
).toBeVisible();
});
// ─── Save Pipeline ────────────────────────────────────────────────────
test("Save Pipeline button enables after adding steps", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await page.getByText("Privacy Clean").click();
await waitForSteps(page, 2);
await expect(
page.getByRole("button", { name: "Save Pipeline" }),
).toBeEnabled();
});
test("clicking Save Pipeline shows name input form", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await page.getByText("Privacy Clean").click();
await waitForSteps(page, 2);
await page.getByRole("button", { name: "Save Pipeline" }).click();
await expect(
page.getByPlaceholder("Pipeline name"),
).toBeVisible();
});
test("Save button disabled when name is empty", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await page.getByText("Privacy Clean").click();
await waitForSteps(page, 2);
await page.getByRole("button", { name: "Save Pipeline" }).click();
const saveSubmitBtn = page.getByRole("button", {
name: "Save",
exact: true,
});
await expect(saveSubmitBtn).toBeDisabled();
});
test("can save a pipeline with name and see it in sidebar", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await page.getByText("Privacy Clean").click();
await waitForSteps(page, 2);
const uniqueName = `E2E Pipeline ${Date.now()}`;
await page.getByRole("button", { name: "Save Pipeline" }).click();
await page.getByPlaceholder("Pipeline name").fill(uniqueName);
await page
.getByRole("button", { name: "Save", exact: true })
.click();
// Wait for the pipeline to appear in sidebar
await expect(page.getByText(uniqueName).first()).toBeVisible({
timeout: 5_000,
});
});
test("can close save form without saving", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await page.getByText("Privacy Clean").click();
await waitForSteps(page, 2);
await page.getByRole("button", { name: "Save Pipeline" }).click();
await expect(
page.getByPlaceholder("Pipeline name"),
).toBeVisible();
// Close the form — the last button in the save form row
const formRow = page.locator(".flex.items-center.gap-2.flex-1");
await formRow.locator("button").last().click();
await expect(
page.getByRole("button", { name: "Save Pipeline" }),
).toBeVisible();
});
// ─── Pipeline Execution ───────────────────────────────────────────────
test("Process button enables when steps and file are set", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await page.getByText("Privacy Clean").click();
await waitForSteps(page, 2);
await uploadTestFile(page);
await expect(
page.getByRole("button", { name: "Process", exact: true }),
).toBeEnabled();
});
test("executing pipeline shows success result", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await page.getByText("Privacy Clean").click();
await waitForSteps(page, 2);
await uploadTestFile(page);
await page
.getByRole("button", { name: "Process", exact: true })
.click();
// Wait for result (pipeline completed text)
await expect(
page.getByText(/pipeline completed/i),
).toBeVisible({ timeout: 30_000 });
// Should show original/processed sizes
await expect(page.getByText(/original/i)).toBeVisible();
await expect(page.getByText(/processed/i)).toBeVisible();
// Should show download button
await expect(
page.getByRole("link", { name: /download result/i }),
).toBeVisible();
});
test("executing Social Media Ready template works", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await page.getByText("Social Media Ready").click();
await waitForSteps(page, 4);
await uploadTestFile(page);
await page
.getByRole("button", { name: "Process", exact: true })
.click();
await expect(
page.getByText(/pipeline completed/i),
).toBeVisible({ timeout: 30_000 });
});
// ─── Saved Pipeline Interactions ──────────────────────────────────────
test("can load a saved pipeline into builder", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
const uniqueName = `Load Pipeline ${Date.now()}`;
// Save a pipeline
await page.getByText("Web Optimization").click();
await waitForSteps(page, 3);
await page.getByRole("button", { name: "Save Pipeline" }).click();
await page.getByPlaceholder("Pipeline name").fill(uniqueName);
await page
.getByRole("button", { name: "Save", exact: true })
.click();
await expect(page.getByText(uniqueName).first()).toBeVisible({
timeout: 5_000,
});
// Switch to different template
await page.getByText("Privacy Clean").click();
await waitForSteps(page, 2);
// Click on the saved pipeline to load it
await page
.getByRole("button", { name: uniqueName })
.first()
.click();
await waitForSteps(page, 3);
});
test("can delete a saved pipeline", async ({ loggedInPage: page }) => {
await gotoAutomate(page);
const uniqueName = `Delete Pipeline ${Date.now()}`;
// Save a pipeline
await page.getByText("Privacy Clean").click();
await waitForSteps(page, 2);
await page.getByRole("button", { name: "Save Pipeline" }).click();
await page.getByPlaceholder("Pipeline name").fill(uniqueName);
await page
.getByRole("button", { name: "Save", exact: true })
.click();
await expect(page.getByText(uniqueName).first()).toBeVisible({
timeout: 5_000,
});
// Hover to reveal delete, then click
const pipelineEntry = page
.locator(".group")
.filter({ hasText: uniqueName })
.first();
await pipelineEntry.hover();
await pipelineEntry
.locator("button")
.filter({ has: page.locator("svg") })
.last()
.click();
await expect(pipelineEntry).not.toBeVisible({ timeout: 5_000 });
});
// ─── Sidebar ──────────────────────────────────────────────────────────
test("sidebar Templates section is visible", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await expect(
page.getByRole("heading", { name: "Templates" }),
).toBeVisible();
});
test("sidebar shows Saved Automations when pipelines exist", async ({
loggedInPage: page,
}) => {
await gotoAutomate(page);
await page.getByText("Privacy Clean").click();
await waitForSteps(page, 2);
const uniqueName = `Sidebar Pipeline ${Date.now()}`;
await page.getByRole("button", { name: "Save Pipeline" }).click();
await page.getByPlaceholder("Pipeline name").fill(uniqueName);
await page
.getByRole("button", { name: "Save", exact: true })
.click();
await expect(page.getByText("Saved Automations")).toBeVisible({
timeout: 5_000,
});
});
});
+815
View File
@@ -1629,6 +1629,450 @@ describe("Pipeline", () => {
});
expect(res.statusCode).toBe(400);
});
it("executes Social Media Ready template pipeline", async () => {
const pipeline = {
steps: [
{ toolId: "resize", settings: { width: 1080, height: 1080, fit: "cover" } },
{ toolId: "compress", settings: { quality: 80 } },
{ toolId: "strip-metadata", settings: {} },
{ toolId: "convert", settings: { format: "webp" } },
],
};
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "social.png", contentType: "image/png", content: PNG_200x150 },
{ name: "pipeline", content: JSON.stringify(pipeline) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/pipeline/execute",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.stepsCompleted).toBe(4);
expect(body.steps).toHaveLength(4);
expect(body.steps[0].toolId).toBe("resize");
expect(body.steps[1].toolId).toBe("compress");
expect(body.steps[2].toolId).toBe("strip-metadata");
expect(body.steps[3].toolId).toBe("convert");
expect(body.downloadUrl).toContain(".webp");
// Note: upscaling from 200x150 to 1080x1080 increases size
expect(body.processedSize).toBeGreaterThan(0);
});
it("executes Privacy Clean template pipeline", async () => {
const pipeline = {
steps: [
{ toolId: "strip-metadata", settings: {} },
{ toolId: "convert", settings: { format: "jpg" } },
],
};
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "privacy.png", contentType: "image/png", content: PNG_200x150 },
{ name: "pipeline", content: JSON.stringify(pipeline) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/pipeline/execute",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.stepsCompleted).toBe(2);
expect(body.downloadUrl).toContain(".jpg");
});
it("executes Web Optimization template pipeline", async () => {
const pipeline = {
steps: [
{ toolId: "resize", settings: { width: 1920, fit: "inside" } },
{ toolId: "convert", settings: { format: "webp" } },
{ toolId: "compress", settings: { quality: 80 } },
],
};
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "web.png", contentType: "image/png", content: PNG_200x150 },
{ name: "pipeline", content: JSON.stringify(pipeline) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/pipeline/execute",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.stepsCompleted).toBe(3);
});
it("executes Profile Picture template pipeline", async () => {
const pipeline = {
steps: [
{ toolId: "resize", settings: { width: 400, height: 400, fit: "cover" } },
{ toolId: "compress", settings: { quality: 85 } },
],
};
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "profile.png", contentType: "image/png", content: PNG_200x150 },
{ name: "pipeline", content: JSON.stringify(pipeline) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/pipeline/execute",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.stepsCompleted).toBe(2);
});
it("executes Watermark Batch template pipeline", async () => {
const pipeline = {
steps: [
{ toolId: "watermark-text", settings: { text: "SAMPLE", opacity: 30 } },
{ toolId: "strip-metadata", settings: {} },
{ toolId: "compress", settings: { quality: 85 } },
],
};
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "watermark.png", contentType: "image/png", content: PNG_200x150 },
{ name: "pipeline", content: JSON.stringify(pipeline) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/pipeline/execute",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.stepsCompleted).toBe(3);
});
it("executes a single-step pipeline", async () => {
const pipeline = {
steps: [{ toolId: "convert", settings: { format: "webp" } }],
};
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "single.png", contentType: "image/png", content: PNG_200x150 },
{ name: "pipeline", content: JSON.stringify(pipeline) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/pipeline/execute",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.stepsCompleted).toBe(1);
expect(body.downloadUrl).toContain(".webp");
});
it("returns full step details in response", async () => {
const pipeline = {
steps: [
{ toolId: "resize", settings: { width: 50 } },
{ toolId: "convert", settings: { format: "jpg" } },
{ toolId: "compress", settings: { quality: 60 } },
],
};
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "details.png", contentType: "image/png", content: PNG_200x150 },
{ name: "pipeline", content: JSON.stringify(pipeline) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/pipeline/execute",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.jobId).toBeDefined();
expect(typeof body.jobId).toBe("string");
expect(body.originalSize).toBeGreaterThan(0);
expect(body.processedSize).toBeGreaterThan(0);
expect(body.steps).toHaveLength(3);
for (const step of body.steps) {
expect(step.step).toBeGreaterThan(0);
expect(step.toolId).toBeDefined();
expect(step.size).toBeGreaterThan(0);
}
});
it("returns 400 for invalid JSON pipeline", async () => {
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "bad.png", contentType: "image/png", content: PNG_1x1 },
{ name: "pipeline", content: "not valid json{{{" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/pipeline/execute",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toContain("JSON");
});
it("returns 400 when no file is provided", async () => {
const pipeline = {
steps: [{ toolId: "resize", settings: { width: 100 } }],
};
const { body: payload, contentType } = createMultipartPayload([
{ name: "pipeline", content: JSON.stringify(pipeline) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/pipeline/execute",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(400);
});
it("handles pipeline with different image formats (JPG input)", async () => {
const pipeline = {
steps: [
{ toolId: "resize", settings: { width: 50 } },
{ toolId: "convert", settings: { format: "png" } },
],
};
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "test.jpg", contentType: "image/jpeg", content: JPG_100x100 },
{ name: "pipeline", content: JSON.stringify(pipeline) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/pipeline/execute",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.stepsCompleted).toBe(2);
expect(body.downloadUrl).toContain(".png");
});
it("handles pipeline with WebP input", async () => {
const pipeline = {
steps: [
{ toolId: "resize", settings: { width: 25 } },
{ toolId: "convert", settings: { format: "jpg" } },
],
};
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "test.webp", contentType: "image/webp", content: WEBP_50x50 },
{ name: "pipeline", content: JSON.stringify(pipeline) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/pipeline/execute",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.stepsCompleted).toBe(2);
});
it("executes a pipeline with border + rotate combo", async () => {
const pipeline = {
steps: [
{ toolId: "border", settings: { borderWidth: 10, borderColor: "#ff0000" } },
{ toolId: "rotate", settings: { angle: 45 } },
],
};
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "bordered.png", contentType: "image/png", content: PNG_200x150 },
{ name: "pipeline", content: JSON.stringify(pipeline) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/pipeline/execute",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).stepsCompleted).toBe(2);
});
it("executes pipeline with 5 steps chained", async () => {
const pipeline = {
steps: [
{ toolId: "resize", settings: { width: 150 } },
{ toolId: "rotate", settings: { angle: 90 } },
{ toolId: "strip-metadata", settings: {} },
{ toolId: "compress", settings: { quality: 70 } },
{ toolId: "convert", settings: { format: "webp" } },
],
};
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "chain.png", contentType: "image/png", content: PNG_200x150 },
{ name: "pipeline", content: JSON.stringify(pipeline) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/pipeline/execute",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
expect(body.stepsCompleted).toBe(5);
expect(body.steps).toHaveLength(5);
});
it("rejects pipeline exceeding 20 steps", async () => {
const steps = Array.from({ length: 21 }, () => ({
toolId: "resize",
settings: { width: 100 },
}));
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "too-many.png", contentType: "image/png", content: PNG_1x1 },
{ name: "pipeline", content: JSON.stringify({ steps }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/pipeline/execute",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(400);
});
it("processes result file is downloadable", async () => {
const pipeline = {
steps: [{ toolId: "resize", settings: { width: 50 } }],
};
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "downloadable.png", contentType: "image/png", content: PNG_200x150 },
{ name: "pipeline", content: JSON.stringify(pipeline) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/pipeline/execute",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body);
// Verify the download URL is valid
const dlRes = await app.inject({
method: "GET",
url: body.downloadUrl,
headers: { authorization: `Bearer ${adminToken}` },
});
expect(dlRes.statusCode).toBe(200);
expect(dlRes.rawPayload.length).toBeGreaterThan(0);
});
it("returns 400 with mixed valid and invalid tool IDs", async () => {
const pipeline = {
steps: [
{ toolId: "resize", settings: { width: 100 } },
{ toolId: "nonexistent-tool", settings: {} },
{ toolId: "convert", settings: { format: "jpg" } },
],
};
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "mixed.png", contentType: "image/png", content: PNG_1x1 },
{ name: "pipeline", content: JSON.stringify(pipeline) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/pipeline/execute",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toContain("nonexistent-tool");
});
});
describe("Pipeline CRUD", () => {
@@ -1711,6 +2155,160 @@ describe("Pipeline", () => {
});
expect(res.statusCode).toBe(400);
});
it("saves pipeline without description", async () => {
const res = await app.inject({
method: "POST",
url: "/api/v1/pipeline/save",
headers: { authorization: `Bearer ${adminToken}` },
payload: {
name: "No Description Pipeline",
steps: [{ toolId: "resize", settings: { width: 100 } }],
},
});
expect(res.statusCode).toBe(201);
const body = JSON.parse(res.body);
expect(body.name).toBe("No Description Pipeline");
expect(body.description).toBeNull();
// Cleanup
await app.inject({
method: "DELETE",
url: `/api/v1/pipeline/${body.id}`,
headers: { authorization: `Bearer ${adminToken}` },
});
});
it("saves and retrieves pipeline with correct step data", async () => {
const steps = [
{ toolId: "resize", settings: { width: 200, height: 200, fit: "cover" } },
{ toolId: "compress", settings: { quality: 75 } },
{ toolId: "convert", settings: { format: "webp" } },
];
const saveRes = await app.inject({
method: "POST",
url: "/api/v1/pipeline/save",
headers: { authorization: `Bearer ${adminToken}` },
payload: { name: "Full Pipeline", description: "With all data", steps },
});
expect(saveRes.statusCode).toBe(201);
const saved = JSON.parse(saveRes.body);
// List and verify the pipeline steps are correctly stored
const listRes = await app.inject({
method: "GET",
url: "/api/v1/pipeline/list",
headers: { authorization: `Bearer ${adminToken}` },
});
const listed = JSON.parse(listRes.body);
const found = listed.pipelines.find((p: { id: string }) => p.id === saved.id);
expect(found).toBeDefined();
expect(found.steps).toHaveLength(3);
expect(found.steps[0].toolId).toBe("resize");
expect(found.steps[0].settings.width).toBe(200);
expect(found.steps[1].toolId).toBe("compress");
expect(found.steps[2].toolId).toBe("convert");
expect(found.createdAt).toBeDefined();
// Cleanup
await app.inject({
method: "DELETE",
url: `/api/v1/pipeline/${saved.id}`,
headers: { authorization: `Bearer ${adminToken}` },
});
});
it("rejects pipeline name exceeding 100 characters", async () => {
const res = await app.inject({
method: "POST",
url: "/api/v1/pipeline/save",
headers: { authorization: `Bearer ${adminToken}` },
payload: {
name: "A".repeat(101),
steps: [{ toolId: "resize", settings: {} }],
},
});
expect(res.statusCode).toBe(400);
});
it("rejects pipeline description exceeding 500 characters", async () => {
const res = await app.inject({
method: "POST",
url: "/api/v1/pipeline/save",
headers: { authorization: `Bearer ${adminToken}` },
payload: {
name: "Long Desc",
description: "D".repeat(501),
steps: [{ toolId: "resize", settings: {} }],
},
});
expect(res.statusCode).toBe(400);
});
it("rejects saving pipeline with more than 20 steps", async () => {
const steps = Array.from({ length: 21 }, () => ({
toolId: "resize",
settings: { width: 100 },
}));
const res = await app.inject({
method: "POST",
url: "/api/v1/pipeline/save",
headers: { authorization: `Bearer ${adminToken}` },
payload: { name: "Too Many Steps", steps },
});
expect(res.statusCode).toBe(400);
});
it("can save and delete multiple pipelines", async () => {
// Save two pipelines
const res1 = await app.inject({
method: "POST",
url: "/api/v1/pipeline/save",
headers: { authorization: `Bearer ${adminToken}` },
payload: {
name: "Pipeline A",
steps: [{ toolId: "resize", settings: { width: 100 } }],
},
});
const res2 = await app.inject({
method: "POST",
url: "/api/v1/pipeline/save",
headers: { authorization: `Bearer ${adminToken}` },
payload: {
name: "Pipeline B",
steps: [{ toolId: "convert", settings: { format: "jpg" } }],
},
});
expect(res1.statusCode).toBe(201);
expect(res2.statusCode).toBe(201);
const id1 = JSON.parse(res1.body).id;
const id2 = JSON.parse(res2.body).id;
// List should have both
const listRes = await app.inject({
method: "GET",
url: "/api/v1/pipeline/list",
headers: { authorization: `Bearer ${adminToken}` },
});
const pipelines = JSON.parse(listRes.body).pipelines;
expect(pipelines.some((p: { id: string }) => p.id === id1)).toBe(true);
expect(pipelines.some((p: { id: string }) => p.id === id2)).toBe(true);
// Delete both
await app.inject({
method: "DELETE",
url: `/api/v1/pipeline/${id1}`,
headers: { authorization: `Bearer ${adminToken}` },
});
await app.inject({
method: "DELETE",
url: `/api/v1/pipeline/${id2}`,
headers: { authorization: `Bearer ${adminToken}` },
});
});
});
});
@@ -1775,6 +2373,223 @@ describe("Batch processing", () => {
});
expect(res.statusCode).toBe(400);
});
it("batch converts multiple images to webp", async () => {
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "img1.png", contentType: "image/png", content: PNG_200x150 },
{ name: "file", filename: "img2.jpg", contentType: "image/jpeg", content: JPG_100x100 },
{ name: "settings", content: JSON.stringify({ format: "webp" }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/convert/batch",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
});
it("batch compresses images with quality setting", async () => {
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "a.png", contentType: "image/png", content: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", content: JPG_100x100 },
{ name: "settings", content: JSON.stringify({ quality: 50 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/compress/batch",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
});
it("batch strips metadata from images", async () => {
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "meta1.png", contentType: "image/png", content: PNG_200x150 },
{ name: "file", filename: "meta2.jpg", contentType: "image/jpeg", content: JPG_100x100 },
{ name: "settings", content: JSON.stringify({}) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/strip-metadata/batch",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
});
it("batch rotates images", async () => {
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "rot1.png", contentType: "image/png", content: PNG_200x150 },
{ name: "file", filename: "rot2.webp", contentType: "image/webp", content: WEBP_50x50 },
{ name: "settings", content: JSON.stringify({ angle: 180 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/rotate/batch",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
});
it("returns 400 for batch with invalid settings", async () => {
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "a.png", contentType: "image/png", content: PNG_1x1 },
{ name: "settings", content: JSON.stringify({ format: "invalid-format" }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/convert/batch",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(400);
});
it("batch with single file works", async () => {
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "solo.png", contentType: "image/png", content: PNG_200x150 },
{ name: "settings", content: JSON.stringify({ width: 100 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/resize/batch",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
});
it("batch uses default settings when none provided", async () => {
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "default.png", contentType: "image/png", content: PNG_200x150 },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/strip-metadata/batch",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
});
it("batch includes X-File-Order header", async () => {
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "first.png", contentType: "image/png", content: PNG_1x1 },
{ name: "file", filename: "second.png", contentType: "image/png", content: PNG_200x150 },
{ name: "settings", content: JSON.stringify({ width: 50 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/resize/batch",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(200);
expect(res.headers["x-file-order"]).toBeDefined();
});
it("returns ZIP with content-disposition attachment header", async () => {
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "a.png", contentType: "image/png", content: PNG_1x1 },
{ name: "settings", content: JSON.stringify({ width: 50 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/resize/batch",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(200);
expect(res.headers["content-disposition"]).toContain("attachment");
expect(res.headers["content-disposition"]).toContain("batch-resize");
});
it("batch with clientJobId uses provided ID", async () => {
const clientJobId = "my-custom-job-id-12345";
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "a.png", contentType: "image/png", content: PNG_1x1 },
{ name: "settings", content: JSON.stringify({ width: 50 }) },
{ name: "clientJobId", content: clientJobId },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/resize/batch",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(200);
expect(res.headers["x-job-id"]).toBe(clientJobId);
});
it("batch handles mixed file formats", async () => {
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "a.png", contentType: "image/png", content: PNG_200x150 },
{ name: "file", filename: "b.jpg", contentType: "image/jpeg", content: JPG_100x100 },
{ name: "file", filename: "c.webp", contentType: "image/webp", content: WEBP_50x50 },
{ name: "settings", content: JSON.stringify({ width: 50 }) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/resize/batch",
headers: {
authorization: `Bearer ${adminToken}`,
"content-type": contentType,
},
payload,
});
expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip");
});
});
});