mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user