penpot/media-processor/test/image.test.ts
Andrey Antukh aeedb96260
Add media-processor service for image and font processing (#10767)
*  Add media-processor service for image and font processing

Externalizes ImageMagick and FontForge subprocess invocations into a
separate Node.js HTTP service (media-processor/). Backend dispatches
via feature flag :use-remote-media-processing.

Key changes:
- media-processor module (TypeScript, Express 5, Sharp, FontForge/woff)
  - POST /api/image/info, /api/image/thumbnail, /api/font/generate
  - Resource limits: 128MP rejection, prlimit (512MB + 30s CPU)
  - Streaming multipart via SequenceInputStream
- app.media split into validation (leaf), local (shell impls), remote (HTTP)
- Schema enforcement: :upload and :input schemas in validation namespace
- Configurable timeout (PENPOT_MEDIA_PROCESSING_SERVICE_TIMEOUT)
- 78 tests across 4 files (image, font, middleware, config)
- FontForge path escaping for command injection prevention
- Parallel font variant conversions with Promise.all

AI-assisted-by: mimo-v2.5-pro

* 🐳 Revert docker-compose changes from media-processor commit

Remove docker-compose.yaml modifications that were part of the media-processor
service commit. The media-processor service definition, flags, and environment
variables are reverted to their previous state.

AI-assisted-by: qwen3.7-plus

* ⬆️ Update dependencies

* 🐛 Fix PR review issues in media-processor

- Font path bug: sfntToWoff and woff2ToSfnt now copy input to temp dir
  when input is a file path, ensuring output lands in expected location
- Error preservation: execCommand preserves killed/signal/code properties
  from child process errors for OOM detection
- Content-Length: service-multipart-request calculates and includes
  Content-Length header for streaming multipart requests

AI-assisted-by: qwen3.7-plus

* 🐛 Fix code review issues in media-processor

- Rename PENPOT_MEDIA_PROCESSOR_SECRET_KEY to PENPOT_MEDIA_PROCESSOR_SHARED_KEY
  in devenv to match backend config key
- Fix timeout middleware to destroy request AFTER response finishes,
  preventing truncated 504 responses
- Fix quality=0 parsing to preserve explicit zero (was silently overridden to 85)
- Replace require('fs') with proper ES module import in upload-storage.ts
- Refactor font conversion temp-dir boilerplate into withTempInput helper
- Document FontForge escaping limitations (single quotes only)
- Fix misleading comment in image.ts about sharp metadata decoding

AI-assisted-by: qwen3.7-plus

* 🐛 Fix code review issues in media-processor (round 2)

- Fix queue middleware to skip next() when response already ended,
  preventing orphaned work after timeout
- Fix hybrid storage to use disk when Content-Length is absent (chunked
  transfer), preventing unbounded memory allocation
- Add source image format validation in generateThumbnail to reject
  unsupported formats (TIFF, BMP, etc.) with 400 instead of 500
- Remove dead code in convertFont for unreachable woff→woff path
- Remove unused isEnabled() method from LokiLogTransport
- Fix sfntToWoff to use correct extension (.ttf/.otf) based on source type
- Extract queue middleware to separate file for testability
- Add comprehensive tests for queue middleware and upload storage

AI-assisted-by: qwen3.7-plus

* 🐛 Fix code review issues in media-processor (round 3)

- Fix disk-backed upload cleanup after successful requests by adding
  cleanup middleware that removes temp files on response finish/close
- Wrap sharp metadata/decoding errors as 400 validation errors instead
  of 500 internal errors
- Only apply flatten() for JPEG output to preserve alpha channel in
  PNG and WebP outputs

AI-assisted-by: qwen3.7-plus

*  Add comprehensive tests for media-processor

Phase 1 - Cleanup verification:
- Add cleanup middleware unit tests (6 tests)
- Add HTTP upload cleanup integration tests (5 tests)

Phase 2 - Error handling & alpha preservation:
- Add sharp error wrapping tests (4 tests)
- Add HTTP malformed image tests (2 tests)
- Add alpha preservation tests (3 tests)

Phase 3 - Edge cases:
- Add upload storage edge case tests (3 tests)
- Add queue middleware edge case tests (4 tests)

Phase 4 - Backend mock verification:
- Fix backend mocks to include :mtype field in image info responses
- Verify all error codes match actual service behavior

Total: 27 new tests added (160 tests passing)

AI-assisted-by: qwen3.7-plus

* 🐛 Fix code review issues in media-processor (round 4)

- Add Zod validation constraints for config values (int, positive, min)
- Fix auth middleware to compare Buffer byte lengths instead of string lengths
- Validate requested output dimensions in generateThumbnail (crop mode)
- Change queue middleware to release slot via callback in finally block
- Add comprehensive tests for all fixes

AI-assisted-by: qwen3.7-plus

* 🐛 Close HTTP response streams in backend media remote

- Wrap stream consumption in try/finally with .close() calls
- Add tests to verify stream closure for info, font-convert, and thumbnail

AI-assisted-by: qwen3.7-plus

* 🐛 Fix queue slot leak on upload failures

Make releaseQueue idempotent and attach fallback listener to release
slot when response finishes. This covers Multer errors that bypass
the route handler's finally block, preventing permanent queue stall.

AI-assisted-by: qwen3.7-plus

* 🐛 Cancel processing on timeout

Create AbortController in timeout middleware and abort signal when
timeout fires. Pass signal to Sharp and FontForge to cancel ongoing
processing and release resources when request is cancelled.

AI-assisted-by: qwen3.7-plus

* 🐛 Fix code review issues in media-processor (round 6)

- Error handler: check headersSent before writing response to prevent
  ERR_HTTP_HEADERS_SENT when timeout already sent 504
- Timeout config: increase default requestTimeout from 60s to 180s to
  match font processing timeout (120s) and backend request timeout
- Image processing: check abort signal before starting Sharp operations
  to cancel processing when timeout fires
- Queue lifecycle: remove res.on('close', release) fallback to hold
  queue slot until processing completes, preventing concurrency limit
  violation when client disconnects

AI-assisted-by: qwen3.7-plus

* 🐛 Close HTTP response stream in download-image

Wrap response body in with-open to ensure stream is closed after
writing to temp file, preventing HTTP connection leaks on repeated
URL imports.

AI-assisted-by: qwen3.7-plus

* 🐛 Close HTTP response stream on validation errors in download-image

Move with-open to wrap the entire validation and processing block,
ensuring the response body stream is closed even when validation fails
(non-2xx status, missing size, invalid media type). This prevents
HTTP connection leaks on repeated failed downloads.

Add test to verify stream closure on validation errors.

AI-assisted-by: qwen3.7-plus

* 🐛 Pass abort signal to Sharp toBuffer for timeout cancellation

Wrap Sharp's toBuffer() with Promise.race to check abort signal during
processing. This ensures large thumbnails stop processing when the
request times out, preventing wasted CPU/memory and queue capacity.

Add test to verify abort during toBuffer operation.

AI-assisted-by: qwen3.7-plus

* 🐛 Hold queue slot until Sharp completes and handle client disconnect

- Remove Promise.race from generateThumbnail — Sharp processing now
  completes fully before queue slot is released, preventing concurrency
  limit violations under timeout conditions
- Remove res.on("finish", release) fallback from queue middleware —
  error handler now explicitly calls releaseQueue in all error paths
- Add res.on("close") handler in timeout middleware to abort signal
  when client disconnects, ensuring processing stops early
- Add tests for client disconnect handling and queue slot lifecycle

AI-assisted-by: qwen3.7-plus

* 🐛 Address round 9 review findings

- Document Sharp 0.35.3 cancellation limitation in image.ts
- Add integration test for timeout cleanup with large images
- Fix font tools (sfntToWoff, woffToSfnt, woff2ToSfnt) to throw
  ProcessingError on resource limit kills instead of returning null
- Validate font signatures for same-format conversions to prevent
  arbitrary files from being persisted as valid fonts
- Fix concurrent mkdtemp race in upload-storage by using shared
  initialization promise

AI-assisted-by: qwen3.7-plus

* 🐛 Address round 10 review findings

- Add tmpdir assertion in font.ts to prevent path injection
- Preserve original error in queue middleware catch handler
- Change auth middleware response type from "internal" to "authorization"
- Add cleanup flag to prevent double cleanup in cleanup middleware
- Move quality clamping into parseQuality function for consistency
- Add integration tests for quality parameter clamping at route level
- Update existing tests to match new auth response type

AI-assisted-by: qwen3.7-plus

* 🐛 Address round 11 review findings

- Extract releaseSlot helper in error-handler to reduce duplication
- Remove redundant try/catch in font.ts withTempDir cleanup
- Improve font path validation error message for clarity
- Move path validation before try/catch to prevent swallowing
- Add debug logging for cleanup failures in cleanup middleware
- Inline TransportTargetSpec type alias in logger.ts
- Extract logging middleware to separate file for consistency
- Remove duplicate MIME validation in image thumbnail route
- Add test for font path validation (outside tmpdir rejection)
- Add tests for error handler queue release across all branches

AI-assisted-by: qwen3.7-plus

* 🐛 Remove Content-Length header from multipart requests

The JDK's HttpClient rejects Content-Length as a restricted header,
causing IllegalArgumentException when sending multipart requests to the
media-processor. Remove the explicit Content-Length header and let the
JDK use chunked transfer encoding. The media-processor will use disk
storage for all multipart requests (safe default behavior).

Remove unused size computations (file-size, header-bytes, footer-bytes,
total-size) that were only used for Content-Length.

Update test to verify Content-Length is not present in request headers.

AI-assisted-by: qwen3.7-plus

* 🐛 Fix pino ESM bundling for media-processor

Mark pino and its transports (pino-pretty, pino-loki) as external to
avoid bundling issues with worker thread modules that reference
__dirname (not available in ES modules).

AI-assisted-by: qwen3.7-plus
2026-08-05 09:41:48 +02:00

910 lines
27 KiB
TypeScript

import { describe, it, expect, afterEach } from "vitest";
import { getImageInfo, generateThumbnail, configureImageLimits } from "../src/services/image.js";
import { parseQuality } from "../src/routes/image.js";
import { ProcessingError } from "../src/middleware/error-handler.js";
import sharp from "sharp";
import { writeFile, rm } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
const DEFAULT_LIMITS = {
maxPixels: 128_000_000,
maxWidth: 16384,
maxHeight: 16384,
};
afterEach(() => {
configureImageLimits(DEFAULT_LIMITS);
});
describe("getImageInfo", () => {
it("returns correct info for a PNG", async () => {
const buffer = await sharp({
create: { width: 100, height: 80, channels: 4, background: { r: 255, g: 0, b: 0, alpha: 1 } },
})
.png()
.toBuffer();
const info = await getImageInfo(buffer, buffer.length);
expect(info.width).toBe(100);
expect(info.height).toBe(80);
expect(info.mtype).toBe("image/png");
expect(info.size).toBe(buffer.length);
expect(info.orientation).toBe(1);
});
it("returns correct info for a JPEG", async () => {
const buffer = await sharp({
create: { width: 200, height: 150, channels: 3, background: { r: 0, g: 255, b: 0 } },
})
.jpeg()
.toBuffer();
const info = await getImageInfo(buffer, buffer.length);
expect(info.width).toBe(200);
expect(info.height).toBe(150);
expect(info.mtype).toBe("image/jpeg");
});
it("returns correct info for a WebP", async () => {
const buffer = await sharp({
create: { width: 300, height: 250, channels: 3, background: { r: 0, g: 0, b: 255 } },
})
.webp()
.toBuffer();
const info = await getImageInfo(buffer, buffer.length);
expect(info.width).toBe(300);
expect(info.height).toBe(250);
expect(info.mtype).toBe("image/webp");
});
it("throws on invalid image data", async () => {
const buffer = Buffer.from("not an image");
await expect(getImageInfo(buffer, buffer.length)).rejects.toThrow();
});
it("returns correct info for a GIF", async () => {
// sharp create doesn't support GIF directly, so create PNG then convert
const pngBuffer = await sharp({
create: { width: 120, height: 90, channels: 3, background: { r: 200, g: 100, b: 50 } },
})
.png()
.toBuffer();
const gifBuffer = await sharp(pngBuffer).gif().toBuffer();
const info = await getImageInfo(gifBuffer, gifBuffer.length);
expect(info.width).toBe(120);
expect(info.height).toBe(90);
expect(info.mtype).toBe("image/gif");
});
it("returns size equal to buffer length", async () => {
const buffer = await sharp({
create: { width: 50, height: 50, channels: 3, background: { r: 0, g: 0, b: 0 } },
})
.jpeg()
.toBuffer();
const info = await getImageInfo(buffer, buffer.length);
expect(info.size).toBe(buffer.length);
});
it("defaults orientation to 1 when no EXIF data", async () => {
const buffer = await sharp({
create: { width: 60, height: 40, channels: 3, background: { r: 0, g: 0, b: 0 } },
})
.png()
.toBuffer();
const info = await getImageInfo(buffer, buffer.length);
expect(info.orientation).toBe(1);
});
it("throws on garbage data (sharp unsupported format)", async () => {
const buffer = Buffer.alloc(100, 0xff);
await expect(getImageInfo(buffer, buffer.length)).rejects.toThrow();
});
it("accepts file path input and returns correct info", async () => {
const buffer = await sharp({
create: { width: 100, height: 80, channels: 3, background: { r: 0, g: 128, b: 255 } },
})
.jpeg()
.toBuffer();
const tempPath = join(tmpdir(), `test-image-${Date.now()}.jpg`);
try {
await writeFile(tempPath, buffer);
const info = await getImageInfo(tempPath, buffer.length);
expect(info.width).toBe(100);
expect(info.height).toBe(80);
expect(info.mtype).toBe("image/jpeg");
expect(info.size).toBe(buffer.length);
} finally {
await rm(tempPath, { force: true });
}
});
it("throws restriction when width exceeds limit", async () => {
configureImageLimits({ maxPixels: 128_000_000, maxWidth: 100, maxHeight: 16384 });
const buffer = await sharp({
create: { width: 200, height: 50, channels: 3, background: { r: 0, g: 0, b: 0 } },
})
.jpeg()
.toBuffer();
try {
await getImageInfo(buffer, buffer.length);
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(ProcessingError);
const pe = err as import("../src/middleware/error-handler.js").ProcessingError;
expect(pe.statusCode).toBe(413);
expect(pe.errorBody.code).toBe("image-dimensions-exceeded");
}
});
it("throws restriction when height exceeds limit", async () => {
configureImageLimits({ maxPixels: 128_000_000, maxWidth: 16384, maxHeight: 100 });
const buffer = await sharp({
create: { width: 50, height: 200, channels: 3, background: { r: 0, g: 0, b: 0 } },
})
.jpeg()
.toBuffer();
try {
await getImageInfo(buffer, buffer.length);
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(ProcessingError);
const pe = err as import("../src/middleware/error-handler.js").ProcessingError;
expect(pe.statusCode).toBe(413);
expect(pe.errorBody.code).toBe("image-dimensions-exceeded");
}
});
it("throws restriction when pixel count exceeds limit", async () => {
configureImageLimits({ maxPixels: 1000, maxWidth: 16384, maxHeight: 16384 });
const buffer = await sharp({
create: { width: 50, height: 50, channels: 3, background: { r: 0, g: 0, b: 0 } },
})
.jpeg()
.toBuffer();
try {
await getImageInfo(buffer, buffer.length);
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(ProcessingError);
const pe = err as import("../src/middleware/error-handler.js").ProcessingError;
expect(pe.statusCode).toBe(413);
expect(pe.errorBody.code).toBe("image-pixel-count-exceeded");
}
});
it("passes when dimensions are exactly at the limit", async () => {
configureImageLimits({ maxPixels: 10000, maxWidth: 100, maxHeight: 100 });
const buffer = await sharp({
create: { width: 100, height: 100, channels: 3, background: { r: 0, g: 0, b: 0 } },
})
.jpeg()
.toBuffer();
const info = await getImageInfo(buffer, buffer.length);
expect(info.width).toBe(100);
expect(info.height).toBe(100);
});
it("throws when pixel count is exactly 1 over limit", async () => {
configureImageLimits({ maxPixels: 9999, maxWidth: 16384, maxHeight: 16384 });
const buffer = await sharp({
create: { width: 100, height: 100, channels: 3, background: { r: 0, g: 0, b: 0 } },
})
.jpeg()
.toBuffer();
try {
await getImageInfo(buffer, buffer.length);
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(ProcessingError);
const pe = err as import("../src/middleware/error-handler.js").ProcessingError;
expect(pe.errorBody.code).toBe("image-pixel-count-exceeded");
}
});
it("throws when signal is aborted before processing", async () => {
const buffer = await sharp({
create: { width: 100, height: 80, channels: 3, background: { r: 255, g: 0, b: 0 } },
})
.png()
.toBuffer();
const controller = new AbortController();
controller.abort();
try {
await getImageInfo(buffer, buffer.length, controller.signal);
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toBe("Request cancelled");
}
});
});
describe("generateThumbnail", () => {
const createImage = (w: number, h: number) =>
sharp({
create: { width: w, height: h, channels: 3, background: { r: 128, g: 128, b: 128 } },
})
.jpeg()
.toBuffer();
it("mode=fit produces thumbnail fitting within dimensions (no upscale)", async () => {
const buffer = await createImage(1000, 800);
const { data, mtype } = await generateThumbnail(buffer, {
width: 200,
height: 200,
quality: 85,
format: "jpeg",
mode: "fit",
});
const meta = await sharp(data).metadata();
expect(meta.width).toBeLessThanOrEqual(200);
expect(meta.height).toBeLessThanOrEqual(200);
expect(mtype).toBe("image/jpeg");
});
it("mode=crop produces center-cropped thumbnail at exact dimensions", async () => {
const buffer = await createImage(1000, 800);
const { data, mtype } = await generateThumbnail(buffer, {
width: 200,
height: 200,
quality: 85,
format: "jpeg",
mode: "crop",
});
const meta = await sharp(data).metadata();
expect(meta.width).toBe(200);
expect(meta.height).toBe(200);
expect(mtype).toBe("image/jpeg");
});
it("supports webp output", async () => {
const buffer = await createImage(500, 400);
const { data, mtype } = await generateThumbnail(buffer, {
width: 100,
height: 100,
quality: 80,
format: "webp",
mode: "fit",
});
expect(mtype).toBe("image/webp");
const meta = await sharp(data).metadata();
expect(meta.width).toBeLessThanOrEqual(100);
});
it("supports png output", async () => {
const buffer = await createImage(500, 400);
const { data, mtype } = await generateThumbnail(buffer, {
width: 100,
height: 100,
quality: 80,
format: "png",
mode: "fit",
});
expect(mtype).toBe("image/png");
});
it("fit mode does not upscale small source", async () => {
const buffer = await createImage(50, 40);
const { data } = await generateThumbnail(buffer, {
width: 200,
height: 200,
quality: 85,
format: "jpeg",
mode: "fit",
});
const meta = await sharp(data).metadata();
expect(meta.width).toBe(50);
expect(meta.height).toBe(40);
});
it("crop mode with non-square target", async () => {
const buffer = await createImage(1000, 500);
const { data, mtype } = await generateThumbnail(buffer, {
width: 200,
height: 100,
quality: 85,
format: "jpeg",
mode: "crop",
});
const meta = await sharp(data).metadata();
expect(meta.width).toBe(200);
expect(meta.height).toBe(100);
expect(mtype).toBe("image/jpeg");
});
it("png output with crop mode", async () => {
const buffer = await createImage(800, 600);
const { data, mtype } = await generateThumbnail(buffer, {
width: 150,
height: 150,
quality: 80,
format: "png",
mode: "crop",
});
const meta = await sharp(data).metadata();
expect(meta.width).toBe(150);
expect(meta.height).toBe(150);
expect(mtype).toBe("image/png");
});
it("webp output with crop mode", async () => {
const buffer = await createImage(800, 600);
const { data, mtype } = await generateThumbnail(buffer, {
width: 150,
height: 150,
quality: 80,
format: "webp",
mode: "crop",
});
const meta = await sharp(data).metadata();
expect(meta.width).toBe(150);
expect(meta.height).toBe(150);
expect(mtype).toBe("image/webp");
});
it("source at exact target dimensions (fit mode) returns same size", async () => {
const buffer = await createImage(200, 200);
const { data } = await generateThumbnail(buffer, {
width: 200,
height: 200,
quality: 85,
format: "jpeg",
mode: "fit",
});
const meta = await sharp(data).metadata();
expect(meta.width).toBe(200);
expect(meta.height).toBe(200);
});
it("source at exact target dimensions (crop mode) returns same size", async () => {
const buffer = await createImage(200, 200);
const { data } = await generateThumbnail(buffer, {
width: 200,
height: 200,
quality: 85,
format: "jpeg",
mode: "crop",
});
const meta = await sharp(data).metadata();
expect(meta.width).toBe(200);
expect(meta.height).toBe(200);
});
it("very small source (1x1) with fit mode returns 1x1", async () => {
const buffer = await createImage(1, 1);
const { data } = await generateThumbnail(buffer, {
width: 200,
height: 200,
quality: 85,
format: "jpeg",
mode: "fit",
});
const meta = await sharp(data).metadata();
expect(meta.width).toBe(1);
expect(meta.height).toBe(1);
});
it("throws restriction when source exceeds dimension limits", async () => {
configureImageLimits({ maxPixels: 1000, maxWidth: 50, maxHeight: 50 });
const buffer = await createImage(200, 200);
try {
await generateThumbnail(buffer, {
width: 100,
height: 100,
quality: 85,
format: "jpeg",
mode: "fit",
});
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(ProcessingError);
const pe = err as import("../src/middleware/error-handler.js").ProcessingError;
expect(pe.statusCode).toBe(413);
}
});
it("removes alpha channel from PNG source", async () => {
const pngBuffer = await sharp({
create: { width: 100, height: 100, channels: 4, background: { r: 255, g: 0, b: 0, alpha: 0.5 } },
})
.png()
.toBuffer();
const { data } = await generateThumbnail(pngBuffer, {
width: 50,
height: 50,
quality: 85,
format: "jpeg",
mode: "fit",
});
const meta = await sharp(data).metadata();
// JPEG output should not have alpha
expect(meta.channels).toBe(3);
});
it("composites transparent PNG onto white background for JPEG", async () => {
// Create a fully transparent PNG — removeAlpha() would produce black,
// but the local ImageMagick path composites onto white.
const pngBuffer = await sharp({
create: { width: 10, height: 10, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
})
.png()
.toBuffer();
const { data } = await generateThumbnail(pngBuffer, {
width: 10,
height: 10,
quality: 85,
format: "jpeg",
mode: "fit",
});
// Sample a pixel — should be white (255,255,255), not black (0,0,0)
const pixel = await sharp(data).raw().toBuffer();
const r = pixel[0];
const g = pixel[1];
const b = pixel[2];
expect(r).toBe(255);
expect(g).toBe(255);
expect(b).toBe(255);
});
it("GIF source works with thumbnail generation", async () => {
const pngBuffer = await sharp({
create: { width: 200, height: 200, channels: 3, background: { r: 100, g: 100, b: 100 } },
})
.png()
.toBuffer();
const gifBuffer = await sharp(pngBuffer).gif().toBuffer();
const { data, mtype } = await generateThumbnail(gifBuffer, {
width: 100,
height: 100,
quality: 85,
format: "jpeg",
mode: "fit",
});
const meta = await sharp(data).metadata();
expect(meta.width).toBeLessThanOrEqual(100);
expect(meta.height).toBeLessThanOrEqual(100);
expect(mtype).toBe("image/jpeg");
});
it("JPEG quality affects output file size", async () => {
// Create an image with actual detail (gradient) so quality matters
const width = 200;
const height = 200;
const channels = 3;
const rawBuffer = Buffer.alloc(width * height * channels);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const idx = (y * width + x) * channels;
rawBuffer[idx] = (x * 255) / width;
rawBuffer[idx + 1] = (y * 255) / height;
rawBuffer[idx + 2] = ((x + y) * 255) / (width + height);
}
}
const buffer = await sharp(rawBuffer, { raw: { width, height, channels } }).jpeg().toBuffer();
const low = await generateThumbnail(buffer, {
width: 100,
height: 100,
quality: 10,
format: "jpeg",
mode: "fit",
});
const high = await generateThumbnail(buffer, {
width: 100,
height: 100,
quality: 100,
format: "jpeg",
mode: "fit",
});
expect(low.data.length).toBeLessThan(high.data.length);
});
it("accepts quality=1 (minimum valid quality)", async () => {
const buffer = await sharp({
create: { width: 100, height: 80, channels: 3, background: { r: 128, g: 128, b: 128 } },
})
.jpeg()
.toBuffer();
const { data, mtype } = await generateThumbnail(buffer, {
width: 50,
height: 40,
quality: 1,
format: "jpeg",
mode: "fit",
});
expect(data).toBeInstanceOf(Buffer);
expect(data.length).toBeGreaterThan(0);
expect(mtype).toBe("image/jpeg");
});
it("accepts file path input for thumbnail generation", async () => {
const buffer = await sharp({
create: { width: 100, height: 80, channels: 3, background: { r: 128, g: 128, b: 128 } },
})
.jpeg()
.toBuffer();
const tempPath = join(tmpdir(), `test-image-${Date.now()}.jpg`);
try {
await writeFile(tempPath, buffer);
const { data, mtype } = await generateThumbnail(tempPath, {
width: 50,
height: 40,
quality: 85,
format: "jpeg",
mode: "fit",
});
const meta = await sharp(data).metadata();
expect(meta.width).toBeLessThanOrEqual(50);
expect(meta.height).toBeLessThanOrEqual(40);
expect(mtype).toBe("image/jpeg");
} finally {
await rm(tempPath, { force: true });
}
});
it("throws validation error for unsupported source format (TIFF)", async () => {
const pngBuffer = await sharp({
create: { width: 100, height: 80, channels: 3, background: { r: 128, g: 128, b: 128 } },
})
.png()
.toBuffer();
const tiffBuffer = await sharp(pngBuffer).tiff().toBuffer();
try {
await generateThumbnail(tiffBuffer, {
width: 50,
height: 40,
quality: 85,
format: "jpeg",
mode: "fit",
});
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(ProcessingError);
const pe = err as import("../src/middleware/error-handler.js").ProcessingError;
expect(pe.statusCode).toBe(400);
expect(pe.errorBody.code).toBe("unsupported-image-format");
}
});
it("throws invalid-image for corrupted image data", async () => {
// Create corrupted image by truncating valid image
const validBuffer = await sharp({
create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } },
})
.jpeg()
.toBuffer();
// Truncate to create corrupted data
const corruptedBuffer = validBuffer.subarray(0, Math.floor(validBuffer.length / 2));
try {
await getImageInfo(corruptedBuffer, corruptedBuffer.length);
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(ProcessingError);
const pe = err as import("../src/middleware/error-handler.js").ProcessingError;
expect(pe.statusCode).toBe(400);
expect(pe.errorBody.code).toBe("invalid-image");
}
});
it("throws invalid-image for truncated image data in generateThumbnail", async () => {
const validBuffer = await sharp({
create: { width: 100, height: 100, channels: 3, background: { r: 255, g: 0, b: 0 } },
})
.jpeg()
.toBuffer();
// Truncate to create corrupted data
const corruptedBuffer = validBuffer.subarray(0, Math.floor(validBuffer.length / 2));
try {
await generateThumbnail(corruptedBuffer, {
width: 50,
height: 50,
quality: 85,
format: "jpeg",
mode: "fit",
});
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(ProcessingError);
const pe = err as import("../src/middleware/error-handler.js").ProcessingError;
expect(pe.statusCode).toBe(400);
expect(pe.errorBody.code).toBe("invalid-image");
}
});
it("preserves alpha channel in PNG output from transparent PNG", async () => {
// Create a transparent PNG with alpha < 1
const transparentPng = await sharp({
create: { width: 100, height: 100, channels: 4, background: { r: 255, g: 0, b: 0, alpha: 0.5 } },
})
.png()
.toBuffer();
const { data, mtype } = await generateThumbnail(transparentPng, {
width: 50,
height: 50,
quality: 85,
format: "png",
mode: "fit",
});
expect(mtype).toBe("image/png");
const meta = await sharp(data).metadata();
// PNG should preserve alpha channel (4 channels)
expect(meta.channels).toBe(4);
});
it("preserves alpha channel in WebP output from transparent PNG", async () => {
const transparentPng = await sharp({
create: { width: 100, height: 100, channels: 4, background: { r: 0, g: 255, b: 0, alpha: 0.5 } },
})
.png()
.toBuffer();
const { data, mtype } = await generateThumbnail(transparentPng, {
width: 50,
height: 50,
quality: 85,
format: "webp",
mode: "fit",
});
expect(mtype).toBe("image/webp");
const meta = await sharp(data).metadata();
// WebP should preserve alpha channel (4 channels)
expect(meta.channels).toBe(4);
});
it("preserves alpha channel in PNG output from transparent WebP", async () => {
// Create a transparent WebP
const transparentWebp = await sharp({
create: { width: 100, height: 100, channels: 4, background: { r: 0, g: 0, b: 255, alpha: 0.5 } },
})
.webp()
.toBuffer();
const { data, mtype } = await generateThumbnail(transparentWebp, {
width: 50,
height: 50,
quality: 85,
format: "png",
mode: "fit",
});
expect(mtype).toBe("image/png");
const meta = await sharp(data).metadata();
// PNG should preserve alpha channel (4 channels)
expect(meta.channels).toBe(4);
});
it("throws restriction when requested width exceeds limit (crop mode)", async () => {
configureImageLimits({ maxPixels: 128_000_000, maxWidth: 100, maxHeight: 16384 });
const buffer = await createImage(50, 50);
try {
await generateThumbnail(buffer, {
width: 200,
height: 50,
quality: 85,
format: "jpeg",
mode: "crop",
});
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(ProcessingError);
const pe = err as import("../src/middleware/error-handler.js").ProcessingError;
expect(pe.statusCode).toBe(413);
expect(pe.errorBody.code).toBe("output-dimensions-exceeded");
}
});
it("throws restriction when requested height exceeds limit (crop mode)", async () => {
configureImageLimits({ maxPixels: 128_000_000, maxWidth: 16384, maxHeight: 100 });
const buffer = await createImage(50, 50);
try {
await generateThumbnail(buffer, {
width: 50,
height: 200,
quality: 85,
format: "jpeg",
mode: "crop",
});
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(ProcessingError);
const pe = err as import("../src/middleware/error-handler.js").ProcessingError;
expect(pe.statusCode).toBe(413);
expect(pe.errorBody.code).toBe("output-dimensions-exceeded");
}
});
it("throws restriction when requested pixel count exceeds limit (crop mode)", async () => {
configureImageLimits({ maxPixels: 10000, maxWidth: 16384, maxHeight: 16384 });
const buffer = await createImage(50, 50);
try {
await generateThumbnail(buffer, {
width: 200,
height: 200,
quality: 85,
format: "jpeg",
mode: "crop",
});
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(ProcessingError);
const pe = err as import("../src/middleware/error-handler.js").ProcessingError;
expect(pe.statusCode).toBe(413);
expect(pe.errorBody.code).toBe("output-pixel-count-exceeded");
}
});
it("accepts requested dimensions at the limit (crop mode)", async () => {
configureImageLimits({ maxPixels: 10000, maxWidth: 100, maxHeight: 100 });
const buffer = await createImage(50, 50);
const { data, mtype } = await generateThumbnail(buffer, {
width: 100,
height: 100,
quality: 85,
format: "jpeg",
mode: "crop",
});
expect(mtype).toBe("image/jpeg");
const meta = await sharp(data).metadata();
expect(meta.width).toBe(100);
expect(meta.height).toBe(100);
});
it("throws when signal is aborted before processing", async () => {
const buffer = await createImage(100, 80);
const controller = new AbortController();
controller.abort();
try {
await generateThumbnail(
buffer,
{
width: 50,
height: 40,
quality: 85,
format: "jpeg",
mode: "fit",
},
controller.signal
);
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toBe("Request cancelled");
}
});
it("aborts during toBuffer when signal fires", async () => {
const buffer = await createImage(2000, 2000);
const controller = new AbortController();
const signal = controller.signal;
const promise = generateThumbnail(
buffer,
{
width: 1000,
height: 1000,
quality: 85,
format: "jpeg",
mode: "fit",
},
signal
);
setTimeout(() => controller.abort(), 10);
try {
await promise;
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toBe("Request cancelled");
}
});
it("waits for Sharp to complete before checking signal", async () => {
const buffer = await createImage(100, 80);
const controller = new AbortController();
const signal = controller.signal;
const promise = generateThumbnail(
buffer,
{
width: 50,
height: 40,
quality: 85,
format: "jpeg",
mode: "fit",
},
signal
);
controller.abort();
try {
await promise;
expect.fail("should have thrown");
} catch (err) {
expect(err).toBeInstanceOf(Error);
expect((err as Error).message).toBe("Request cancelled");
}
});
});
describe("parseQuality", () => {
it("returns default when value is undefined", () => {
expect(parseQuality(undefined)).toBe(85);
});
it("returns default when value is empty string", () => {
expect(parseQuality("")).toBe(85);
});
it("returns default when value is not a number", () => {
expect(parseQuality("abc")).toBe(85);
});
it("returns parsed value when valid", () => {
expect(parseQuality("50")).toBe(50);
});
it("clamps quality=0 to 1 (minimum valid)", () => {
expect(parseQuality("0")).toBe(1);
});
it("preserves quality=1 (minimum valid)", () => {
expect(parseQuality("1")).toBe(1);
});
it("clamps quality=101 to 100 (maximum valid)", () => {
expect(parseQuality("101")).toBe(100);
});
it("preserves quality=100 (maximum valid)", () => {
expect(parseQuality("100")).toBe(100);
});
it("uses custom default value", () => {
expect(parseQuality(undefined, 75)).toBe(75);
expect(parseQuality("abc", 75)).toBe(75);
});
});