const test = require("node:test"); const assert = require("node:assert/strict"); const crypto = require("node:crypto"); const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); const sharp = require("sharp"); const { DEFAULT_IMAGE_DERIVATIVE_OPTIONS, ImageProcessingError, createImageProcessor, } = require("../src/image-processing"); const MAX_INPUT_PIXELS = 12_000_000; const CONTENT_TYPES = Object.freeze({ jpeg: "image/jpeg", png: "image/png", webp: "image/webp", }); function createProcessor(overrides = {}) { return createImageProcessor({ maxInputPixels: MAX_INPUT_PIXELS, ...overrides, }); } function hash(buffer) { return crypto.createHash("sha256").update(buffer).digest("hex"); } async function solidImage(format, width, height, background = "#4a76b8") { return sharp({ create: { width, height, channels: 4, background, }, }) .toFormat(format) .toBuffer(); } async function twoFrameImage(format) { const width = 20; const frameHeight = 10; const channels = 4; const firstFrame = Buffer.alloc(width * frameHeight * channels); const secondFrame = Buffer.alloc(width * frameHeight * channels); for (let offset = 0; offset < firstFrame.length; offset += channels) { firstFrame.set([255, 0, 0, 255], offset); secondFrame.set([0, 0, 255, 255], offset); } const image = sharp(Buffer.concat([firstFrame, secondFrame]), { raw: { width, height: frameHeight * 2, channels, pageHeight: frameHeight, }, }); if (format === "tiff") { return image.tiff().toBuffer(); } return image[format]({ delay: [100, 100], loop: 0 }).toBuffer(); } test("accepts decoded still JPEG, PNG, and WebP content", async (t) => { const processor = createProcessor(); for (const format of ["jpeg", "png", "webp"]) { await t.test(format, async () => { const source = await solidImage(format, 640, 360); const result = await processor.process(source); assert.equal(result.original.format, format); assert.equal(result.original.contentType, CONTENT_TYPES[format]); assert.equal(result.original.byteSize, source.length); assert.equal(result.original.sha256, hash(source)); assert.equal(result.original.width, 640); assert.equal(result.original.height, 360); assert.equal(result.original.pages, 1); for (const derivative of [result.display, result.thumbnail]) { assert.ok(Buffer.isBuffer(derivative.buffer)); assert.equal(derivative.format, "webp"); assert.equal(derivative.contentType, "image/webp"); assert.equal(derivative.byteSize, derivative.buffer.length); assert.equal(derivative.sha256, hash(derivative.buffer)); assert.match(derivative.sha256, /^[0-9a-f]{64}$/); } }); } }); test("uses bounding boxes without cropping or changing source aspect ratio", async () => { const source = await solidImage("png", 3000, 1000); const result = await createProcessor().process(source); assert.deepEqual( { width: result.display.width, height: result.display.height }, { width: 1920, height: 640 }, ); assert.deepEqual( { width: result.thumbnail.width, height: result.thumbnail.height }, { width: 480, height: 160 }, ); assert.equal(result.display.width / result.display.height, 3); assert.equal(result.thumbnail.width / result.thumbnail.height, 3); }); test("does not enlarge a source that fits within both derivative bounds", async () => { const source = await solidImage("jpeg", 320, 180); const result = await createProcessor().process(source); assert.deepEqual( { width: result.display.width, height: result.display.height }, { width: 320, height: 180 }, ); assert.deepEqual( { width: result.thumbnail.width, height: result.thumbnail.height }, { width: 320, height: 180 }, ); }); test("applies EXIF orientation and strips it from derivative metadata", async () => { const source = await sharp({ create: { width: 120, height: 80, channels: 3, background: "#cb7235", }, }) .jpeg() .withMetadata({ orientation: 6 }) .toBuffer(); const result = await createProcessor().process(source); const outputMetadata = await sharp(result.display.buffer).metadata(); assert.equal(result.original.orientation, 6); assert.equal(result.original.encodedWidth, 120); assert.equal(result.original.encodedHeight, 80); assert.equal(result.original.width, 80); assert.equal(result.original.height, 120); assert.equal(result.display.width, 80); assert.equal(result.display.height, 120); assert.equal(outputMetadata.orientation, undefined); assert.equal(outputMetadata.exif, undefined); }); test("flattens transparent pixels against black in both derivatives", async () => { const source = await solidImage("png", 40, 30, { r: 255, g: 0, b: 0, alpha: 0, }); const result = await createProcessor().process(source); assert.equal(result.original.hasAlpha, true); for (const derivative of [result.display, result.thumbnail]) { const metadata = await sharp(derivative.buffer).metadata(); const pixels = await sharp(derivative.buffer).raw().toBuffer(); assert.equal(metadata.hasAlpha, false); assert.ok( pixels.every((channel) => channel <= 2), "fully transparent source pixels should become black", ); } }); test("supports a temporary file path without changing source identity", async (t) => { const temporaryDirectory = fs.mkdtempSync( path.join(os.tmpdir(), "announcements-image-processing-"), ); t.after(() => fs.rmSync(temporaryDirectory, { recursive: true, force: true })); const source = await solidImage("webp", 800, 600); const filename = path.join(temporaryDirectory, "upload.tmp"); fs.writeFileSync(filename, source); const result = await createProcessor().process(filename); assert.equal(result.original.sha256, hash(source)); assert.equal(result.original.byteSize, source.length); assert.equal(result.original.format, "webp"); }); test("rejects malformed and unsupported decoded content with typed errors", async () => { const processor = createProcessor(); const gif = await solidImage("gif", 64, 64); await assert.rejects( processor.process(Buffer.from("not an image")), (error) => error instanceof ImageProcessingError && error.code === "MALFORMED_IMAGE" && error.statusCode === 422, ); await assert.rejects( processor.process(gif), (error) => error instanceof ImageProcessingError && error.code === "UNSUPPORTED_IMAGE_FORMAT" && error.statusCode === 415 && error.details.detectedFormat === "gif", ); }); test("rejects animated WebP and GIF instead of selecting the first frame", async (t) => { for (const format of ["webp", "gif"]) { await t.test(format, async () => { const animated = await twoFrameImage(format); const metadata = await sharp(animated, { animated: true }).metadata(); assert.equal(metadata.pages, 2, "test fixture must contain two frames"); await assert.rejects( createProcessor().process(animated), (error) => error instanceof ImageProcessingError && error.code === "ANIMATED_IMAGE_NOT_SUPPORTED" && error.details.pages === 2, ); }); } }); test("rejects unexpected multipage inputs with a distinct typed error", async () => { const multipageTiff = await twoFrameImage("tiff"); const metadata = await sharp(multipageTiff, { animated: true }).metadata(); assert.equal(metadata.pages, 2, "test fixture must contain two pages"); await assert.rejects( createProcessor().process(multipageTiff), (error) => error instanceof ImageProcessingError && error.code === "MULTIPAGE_IMAGE_NOT_SUPPORTED" && error.details.pages === 2, ); }); test("enforces the injected decoded-pixel limit", async () => { const source = await solidImage("png", 100, 100); const processor = createImageProcessor({ maxInputPixels: 9_999 }); await assert.rejects( processor.process(source), (error) => error instanceof ImageProcessingError && error.code === "IMAGE_PIXEL_LIMIT_EXCEEDED" && error.statusCode === 413 && error.details.maxInputPixels === 9_999, ); }); test("uses documented defaults while allowing validated target overrides", async () => { assert.deepEqual(DEFAULT_IMAGE_DERIVATIVE_OPTIONS, { display: { maxWidth: 1920, maxHeight: 1080, quality: 82 }, thumbnail: { maxWidth: 480, maxHeight: 270, quality: 72 }, }); const source = await solidImage("png", 900, 600); const processor = createProcessor({ display: { maxWidth: 300, maxHeight: 300, quality: 90 }, thumbnail: { maxWidth: 100, maxHeight: 50, quality: 65 }, }); const result = await processor.process(source); assert.deepEqual( { width: result.display.width, height: result.display.height }, { width: 300, height: 200 }, ); assert.deepEqual( { width: result.thumbnail.width, height: result.thumbnail.height }, { width: 75, height: 50 }, ); assert.throws( () => createImageProcessor({ maxInputPixels: 0 }), /maxInputPixels must be at least 1/, ); assert.throws( () => createImageProcessor({ maxInputPixels: 10_000, display: { quality: 101 }, }), /display\.quality must be at most 100/, ); }); test("rejects invalid input interfaces with structured errors", async () => { await assert.rejects( createProcessor().process({ path: "/tmp/not-an-interface" }), (error) => error instanceof ImageProcessingError && error.code === "INVALID_IMAGE_INPUT" && error.statusCode === 400, ); });