mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
- Unit: 1,353 tests (42 files) — +256 new tests covering AI bridge modules, image-engine sharpen/optimize-for-web, Zustand stores, and icon-map validation - Integration: 1,640 tests (57 files) — +826 new tests across all tool routes, pipeline/progress/batch infrastructure, user-files, edit-metadata, and a 321-test cross-format matrix - E2E-Docker: 389 passing (20 spec files) — 6 new spec files for batch processing, format conversion, layout, optimization, watermark/overlay, and pipeline chains. Tests verified against fresh Docker container with all 6 AI bundles installed. Bug fixes discovered during testing: - fix(compress): SVG/BMP/exotic formats crashed Sharp encoder — added format-safety fallback to PNG - fix(rate-limit): increase default login attempt limit from 10 to 500 per minute — previous value caused false test failures and is too restrictive for a self-hosted app - fix(auth.setup): wait for consent button visibility before clicking to prevent flaky E2E-Docker auth setup
516 lines
19 KiB
TypeScript
516 lines
19 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
import { expect, test } from "@playwright/test";
|
|
|
|
// ─── Pipeline Chains ───────────────────────────────────────────────
|
|
// Multi-step pipeline chain tests: combining different tool categories
|
|
// in realistic workflows. Complements pipeline-tools.spec.ts with
|
|
// more complex chains and cross-category combinations.
|
|
|
|
const FIXTURES = join(process.cwd(), "tests", "fixtures");
|
|
const FORMATS = join(FIXTURES, "formats");
|
|
let token: string;
|
|
|
|
test.beforeAll(async ({ request }) => {
|
|
const res = await request.post("/api/auth/login", {
|
|
data: { username: "admin", password: "admin" },
|
|
});
|
|
const body = await res.json();
|
|
token = body.token;
|
|
});
|
|
|
|
function fixture(name: string): Buffer {
|
|
return readFileSync(join(FIXTURES, name));
|
|
}
|
|
|
|
function formatFixture(name: string): Buffer {
|
|
return readFileSync(join(FORMATS, name));
|
|
}
|
|
|
|
const PNG_200x150 = fixture("test-200x150.png");
|
|
const JPG_100x100 = fixture("test-100x100.jpg");
|
|
const HEIC_200x150 = fixture("test-200x150.heic");
|
|
const JPG_SAMPLE = formatFixture("sample.jpg");
|
|
const JPG_WITH_EXIF = fixture("test-with-exif.jpg");
|
|
|
|
// ─── Resize + Watermark + Optimize Chain ───────────────────────────
|
|
|
|
test.describe("Resize + Watermark + Optimize chain", () => {
|
|
test("resize then text watermark then optimize for web", async ({ request }) => {
|
|
const res = await request.post("/api/v1/pipeline/execute", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
multipart: {
|
|
file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE },
|
|
pipeline: JSON.stringify({
|
|
steps: [
|
|
{ toolId: "resize", settings: { width: 800, fit: "contain" } },
|
|
{
|
|
toolId: "watermark-text",
|
|
settings: {
|
|
text: "PREVIEW",
|
|
fontSize: 32,
|
|
color: "#ffffff",
|
|
opacity: 35,
|
|
position: "center",
|
|
},
|
|
},
|
|
{ toolId: "optimize-for-web", settings: { maxWidth: 800, quality: 75 } },
|
|
],
|
|
}),
|
|
},
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const body = await res.json();
|
|
expect(body.downloadUrl).toBeTruthy();
|
|
expect(body.processedSize).toBeGreaterThan(0);
|
|
expect(body.processedSize).toBeLessThan(body.originalSize);
|
|
});
|
|
});
|
|
|
|
// ─── Crop + Enhance + Convert Chain ────────────────────────────────
|
|
|
|
test.describe("Crop + Enhance + Convert chain", () => {
|
|
test("crop then enhance then convert to WebP", async ({ request }) => {
|
|
const res = await request.post("/api/v1/pipeline/execute", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
multipart: {
|
|
file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE },
|
|
pipeline: JSON.stringify({
|
|
steps: [
|
|
{ toolId: "crop", settings: { left: 10, top: 10, width: 200, height: 200 } },
|
|
{ toolId: "image-enhancement", settings: { preset: "auto" } },
|
|
{ toolId: "convert", settings: { format: "webp" } },
|
|
],
|
|
}),
|
|
},
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const body = await res.json();
|
|
expect(body.downloadUrl).toBeTruthy();
|
|
expect(body.downloadUrl).toContain(".webp");
|
|
});
|
|
});
|
|
|
|
// ─── Strip Metadata + Resize + Compress Chain ──────────────────────
|
|
|
|
test.describe("Strip Metadata + Resize + Compress chain", () => {
|
|
test("strip metadata then resize then compress", async ({ request }) => {
|
|
const res = await request.post("/api/v1/pipeline/execute", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
multipart: {
|
|
file: { name: "photo.jpg", mimeType: "image/jpeg", buffer: JPG_WITH_EXIF },
|
|
pipeline: JSON.stringify({
|
|
steps: [
|
|
{ toolId: "strip-metadata", settings: {} },
|
|
{ toolId: "resize", settings: { width: 400, fit: "contain" } },
|
|
{ toolId: "compress", settings: { quality: 60 } },
|
|
],
|
|
}),
|
|
},
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const body = await res.json();
|
|
expect(body.downloadUrl).toBeTruthy();
|
|
expect(body.processedSize).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// ─── Color Adjust + Sharpen + Border Chain ─────────────────────────
|
|
|
|
test.describe("Color Adjust + Sharpen + Border chain", () => {
|
|
test("adjust colors then sharpen then add border", async ({ request }) => {
|
|
const res = await request.post("/api/v1/pipeline/execute", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
multipart: {
|
|
file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 },
|
|
pipeline: JSON.stringify({
|
|
steps: [
|
|
{ toolId: "adjust-colors", settings: { brightness: 10, contrast: 20 } },
|
|
{ toolId: "sharpening", settings: { sigma: 1.0 } },
|
|
{ toolId: "border", settings: { size: 10, color: "#333333" } },
|
|
],
|
|
}),
|
|
},
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const body = await res.json();
|
|
expect(body.downloadUrl).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// ─── Rotate + Crop + Watermark Chain ───────────────────────────────
|
|
|
|
test.describe("Rotate + Crop + Watermark chain", () => {
|
|
test("rotate then crop then watermark text", async ({ request }) => {
|
|
const res = await request.post("/api/v1/pipeline/execute", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
multipart: {
|
|
file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE },
|
|
pipeline: JSON.stringify({
|
|
steps: [
|
|
{ toolId: "rotate", settings: { angle: 90 } },
|
|
{ toolId: "crop", settings: { left: 0, top: 0, width: 300, height: 300 } },
|
|
{
|
|
toolId: "watermark-text",
|
|
settings: {
|
|
text: "ROTATED",
|
|
fontSize: 24,
|
|
color: "#ff0000",
|
|
opacity: 50,
|
|
position: "bottom-right",
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const body = await res.json();
|
|
expect(body.downloadUrl).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// ─── Enhancement + Replace Color + Convert Chain ───────────────────
|
|
|
|
test.describe("Enhancement + Replace Color + Convert chain", () => {
|
|
test("enhance then replace color then convert to AVIF", async ({ request }) => {
|
|
const res = await request.post("/api/v1/pipeline/execute", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
multipart: {
|
|
file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 },
|
|
pipeline: JSON.stringify({
|
|
steps: [
|
|
{ toolId: "image-enhancement", settings: { preset: "vivid" } },
|
|
{
|
|
toolId: "replace-color",
|
|
settings: {
|
|
targetColor: "#ffffff",
|
|
replacementColor: "#f0f0f0",
|
|
tolerance: 20,
|
|
},
|
|
},
|
|
{ toolId: "convert", settings: { format: "avif" } },
|
|
],
|
|
}),
|
|
},
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const body = await res.json();
|
|
expect(body.downloadUrl).toBeTruthy();
|
|
expect(body.downloadUrl).toContain(".avif");
|
|
});
|
|
});
|
|
|
|
// ─── HEIC Pipeline Chains ──────────────────────────────────────────
|
|
|
|
test.describe("HEIC pipeline chains", () => {
|
|
test("HEIC: resize then convert to PNG", async ({ request }) => {
|
|
const res = await request.post("/api/v1/pipeline/execute", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
multipart: {
|
|
file: { name: "test.heic", mimeType: "image/heic", buffer: HEIC_200x150 },
|
|
pipeline: JSON.stringify({
|
|
steps: [
|
|
{ toolId: "resize", settings: { width: 100, fit: "contain" } },
|
|
{ toolId: "convert", settings: { format: "png" } },
|
|
],
|
|
}),
|
|
},
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const body = await res.json();
|
|
expect(body.downloadUrl).toBeTruthy();
|
|
expect(body.downloadUrl).toContain(".png");
|
|
});
|
|
|
|
test("HEIC: enhance then watermark then compress", async ({ request }) => {
|
|
const res = await request.post("/api/v1/pipeline/execute", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
multipart: {
|
|
file: { name: "test.heic", mimeType: "image/heic", buffer: HEIC_200x150 },
|
|
pipeline: JSON.stringify({
|
|
steps: [
|
|
{ toolId: "image-enhancement", settings: { preset: "auto" } },
|
|
{
|
|
toolId: "watermark-text",
|
|
settings: {
|
|
text: "HEIC PIPELINE",
|
|
fontSize: 16,
|
|
color: "#808080",
|
|
opacity: 30,
|
|
position: "bottom-right",
|
|
},
|
|
},
|
|
{ toolId: "compress", settings: { quality: 70 } },
|
|
],
|
|
}),
|
|
},
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const body = await res.json();
|
|
expect(body.downloadUrl).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// ─── Grayscale + Sharpen + Text Overlay Chain ──────────────────────
|
|
|
|
test.describe("Grayscale + Sharpen + Text Overlay chain", () => {
|
|
test("grayscale then sharpen then text overlay", async ({ request }) => {
|
|
const res = await request.post("/api/v1/pipeline/execute", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
multipart: {
|
|
file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE },
|
|
pipeline: JSON.stringify({
|
|
steps: [
|
|
{ toolId: "adjust-colors", settings: { grayscale: true } },
|
|
{ toolId: "sharpening", settings: { sigma: 2.0 } },
|
|
{
|
|
toolId: "text-overlay",
|
|
settings: {
|
|
text: "B&W",
|
|
fontSize: 48,
|
|
color: "#FFFFFF",
|
|
position: "bottom",
|
|
backgroundBox: true,
|
|
backgroundColor: "#000000",
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
},
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const body = await res.json();
|
|
expect(body.downloadUrl).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// ─── Social Media Optimization Chain ───────────────────────────────
|
|
|
|
test.describe("Social media optimization chain", () => {
|
|
test("resize for Instagram then enhance then watermark", async ({ request }) => {
|
|
const res = await request.post("/api/v1/pipeline/execute", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
multipart: {
|
|
file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE },
|
|
pipeline: JSON.stringify({
|
|
steps: [
|
|
{ toolId: "resize", settings: { width: 1080, height: 1080, fit: "cover" } },
|
|
{ toolId: "image-enhancement", settings: { preset: "vivid" } },
|
|
{
|
|
toolId: "watermark-text",
|
|
settings: {
|
|
text: "@snapotter",
|
|
fontSize: 16,
|
|
color: "#ffffff",
|
|
opacity: 30,
|
|
position: "bottom-right",
|
|
},
|
|
},
|
|
{ toolId: "compress", settings: { quality: 85 } },
|
|
],
|
|
}),
|
|
},
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const body = await res.json();
|
|
expect(body.downloadUrl).toBeTruthy();
|
|
expect(body.processedSize).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// ─── Print Preparation Chain ───────────────────────────────────────
|
|
|
|
test.describe("Print preparation chain", () => {
|
|
test("strip metadata then resize for A4 then sharpen then border", async ({ request }) => {
|
|
const res = await request.post("/api/v1/pipeline/execute", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
multipart: {
|
|
file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE },
|
|
pipeline: JSON.stringify({
|
|
steps: [
|
|
{ toolId: "strip-metadata", settings: {} },
|
|
{ toolId: "resize", settings: { width: 2480, fit: "contain" } },
|
|
{ toolId: "sharpening", settings: { sigma: 1.2 } },
|
|
{ toolId: "border", settings: { size: 40, color: "#ffffff" } },
|
|
],
|
|
}),
|
|
},
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const body = await res.json();
|
|
expect(body.downloadUrl).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
// ─── Thumbnail Generation Chain ────────────────────────────────────
|
|
|
|
test.describe("Thumbnail generation chain", () => {
|
|
test("resize small then enhance then convert to WebP", async ({ request }) => {
|
|
const res = await request.post("/api/v1/pipeline/execute", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
multipart: {
|
|
file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE },
|
|
pipeline: JSON.stringify({
|
|
steps: [
|
|
{ toolId: "resize", settings: { width: 150, height: 150, fit: "cover" } },
|
|
{ toolId: "sharpening", settings: { sigma: 0.5 } },
|
|
{ toolId: "convert", settings: { format: "webp" } },
|
|
],
|
|
}),
|
|
},
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const body = await res.json();
|
|
expect(body.downloadUrl).toBeTruthy();
|
|
expect(body.downloadUrl).toContain(".webp");
|
|
expect(body.processedSize).toBeGreaterThan(0);
|
|
// Thumbnail should be much smaller than original
|
|
expect(body.processedSize).toBeLessThan(body.originalSize);
|
|
});
|
|
});
|
|
|
|
// ─── Edit Metadata + Convert Chain ─────────────────────────────────
|
|
|
|
test.describe("Edit Metadata + Convert chain", () => {
|
|
test("edit metadata then convert to PNG", async ({ request }) => {
|
|
const res = await request.post("/api/v1/pipeline/execute", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
multipart: {
|
|
file: { name: "test.jpg", mimeType: "image/jpeg", buffer: JPG_100x100 },
|
|
pipeline: JSON.stringify({
|
|
steps: [
|
|
{
|
|
toolId: "edit-metadata",
|
|
settings: { artist: "Pipeline Test", copyright: "CC0" },
|
|
},
|
|
{ toolId: "convert", settings: { format: "png" } },
|
|
],
|
|
}),
|
|
},
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const body = await res.json();
|
|
expect(body.downloadUrl).toBeTruthy();
|
|
expect(body.downloadUrl).toContain(".png");
|
|
});
|
|
});
|
|
|
|
// ─── Pipeline Chain Validation ─────────────────────────────────────
|
|
|
|
test.describe("Pipeline chain validation", () => {
|
|
test("pipeline with duplicate steps succeeds", async ({ request }) => {
|
|
const res = await request.post("/api/v1/pipeline/execute", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
multipart: {
|
|
file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 },
|
|
pipeline: JSON.stringify({
|
|
steps: [
|
|
{ toolId: "resize", settings: { width: 150, fit: "contain" } },
|
|
{ toolId: "resize", settings: { width: 100, fit: "contain" } },
|
|
],
|
|
}),
|
|
},
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const body = await res.json();
|
|
expect(body.downloadUrl).toBeTruthy();
|
|
});
|
|
|
|
test("pipeline with 6 steps completes", async ({ request }) => {
|
|
const res = await request.post("/api/v1/pipeline/execute", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
multipart: {
|
|
file: { name: "sample.jpg", mimeType: "image/jpeg", buffer: JPG_SAMPLE },
|
|
pipeline: JSON.stringify({
|
|
steps: [
|
|
{ toolId: "strip-metadata", settings: {} },
|
|
{ toolId: "resize", settings: { width: 600, fit: "contain" } },
|
|
{ toolId: "adjust-colors", settings: { brightness: 5, contrast: 10 } },
|
|
{ toolId: "sharpening", settings: { sigma: 0.8 } },
|
|
{
|
|
toolId: "watermark-text",
|
|
settings: {
|
|
text: "6-step",
|
|
fontSize: 14,
|
|
color: "#808080",
|
|
opacity: 20,
|
|
position: "bottom-right",
|
|
},
|
|
},
|
|
{ toolId: "compress", settings: { quality: 75 } },
|
|
],
|
|
}),
|
|
},
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const body = await res.json();
|
|
expect(body.downloadUrl).toBeTruthy();
|
|
expect(body.processedSize).toBeGreaterThan(0);
|
|
expect(body.processedSize).toBeLessThan(body.originalSize);
|
|
});
|
|
|
|
test("pipeline step metadata is returned", async ({ request }) => {
|
|
const res = await request.post("/api/v1/pipeline/execute", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
multipart: {
|
|
file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 },
|
|
pipeline: JSON.stringify({
|
|
steps: [
|
|
{ toolId: "resize", settings: { width: 100, fit: "contain" } },
|
|
{ toolId: "border", settings: { size: 5, color: "#000000" } },
|
|
],
|
|
}),
|
|
},
|
|
});
|
|
expect(res.ok()).toBe(true);
|
|
const body = await res.json();
|
|
expect(body.downloadUrl).toBeTruthy();
|
|
// Pipeline should return step metadata
|
|
if (body.steps) {
|
|
expect(body.steps).toBeInstanceOf(Array);
|
|
expect(body.steps.length).toBe(2);
|
|
}
|
|
});
|
|
|
|
test("saved pipeline can be executed", async ({ request }) => {
|
|
// Save a pipeline
|
|
const saveRes = await request.post("/api/v1/pipeline/save", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
data: {
|
|
name: "Chain Test Pipeline",
|
|
description: "Resize and convert for chain testing",
|
|
steps: [
|
|
{ toolId: "resize", settings: { width: 200, fit: "contain" } },
|
|
{ toolId: "convert", settings: { format: "webp" } },
|
|
],
|
|
},
|
|
});
|
|
expect(saveRes.ok()).toBe(true);
|
|
const { id } = await saveRes.json();
|
|
|
|
// Execute the saved pipeline
|
|
const execRes = await request.post("/api/v1/pipeline/execute", {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
multipart: {
|
|
file: { name: "test.png", mimeType: "image/png", buffer: PNG_200x150 },
|
|
pipeline: JSON.stringify({
|
|
steps: [
|
|
{ toolId: "resize", settings: { width: 200, fit: "contain" } },
|
|
{ toolId: "convert", settings: { format: "webp" } },
|
|
],
|
|
}),
|
|
},
|
|
});
|
|
expect(execRes.ok()).toBe(true);
|
|
const execBody = await execRes.json();
|
|
expect(execBody.downloadUrl).toBeTruthy();
|
|
expect(execBody.downloadUrl).toContain(".webp");
|
|
|
|
// Clean up
|
|
await request.delete(`/api/v1/pipeline/${id}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
});
|
|
});
|