Files
SnapOtter/packages/image-engine/tests/operations.test.ts
T
SnapOtterandGitHub d10d0f544f fix: release QA hardening across processing, media, security, and CI gates (#649)
A release-readiness QA pass over the whole product. The commits split into
defects a user would hit and gates that were reporting green while measuring
nothing.

## Fixes that change behaviour

Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so
request.ip came from a client-set header and a forged X-Forwarded-For got past
the login limiter. The default is now a private-network trust list.

A transient Postgres outage stranded in-flight jobs, leaving finished output on
disk with no row pointing at it. A reconciler now resolves those rows and adopts
the bytes rather than dropping the work.

A Redis connection that moved to a new address wedged every read-blocked
consumer, so completions stopped signalling while health still answered 200.
Socket timeouts plus subscriber pings recover it.

Installing more than one AI bundle left the shared venv multi-versioned and
silently broke three tools. The installer now reconciles distributions to one
version each.

Converting an image to JXL at quality 1 through 4 returned a 500, because
libjxl 0.7 rejects the distance those values compute. The quality is floored at
what the encoder honours. A missing ffmpeg was also reported to the user as a
corrupt upload; it now says the engine is unavailable.

RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at
0.22.2, and the release scan was split so it can fail on an unfixed critical
instead of hiding it behind ignore-unfixed.

## Gates that could not fail

Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs
build; coverage discarded its whole report on any failing test; the lint gate
skipped root tests, scripts, and two workspaces; and several generated matrices
counted a host missing ffmpeg as a passing tool. Each now measures what it
claims.

Full evidence and the outstanding release items are tracked locally and are not
part of this branch.
2026-07-27 15:37:30 +08:00

430 lines
13 KiB
TypeScript

import sharp from "sharp";
import { beforeAll, describe, expect, it } from "vitest";
import { processImage } from "../src/engine.js";
import { detectFormat } from "../src/formats/detect.js";
import { brightness } from "../src/operations/brightness.js";
import { colorChannels } from "../src/operations/color-channels.js";
import { compress } from "../src/operations/compress.js";
import { contrast } from "../src/operations/contrast.js";
import { convert } from "../src/operations/convert.js";
import { crop } from "../src/operations/crop.js";
import { flip } from "../src/operations/flip.js";
import { grayscale } from "../src/operations/grayscale.js";
import { invert } from "../src/operations/invert.js";
import { resize } from "../src/operations/resize.js";
import { rotate } from "../src/operations/rotate.js";
import { saturation } from "../src/operations/saturation.js";
import { sepia } from "../src/operations/sepia.js";
import { stripMetadata } from "../src/operations/strip-metadata.js";
import type { OutputFormat, Sharp, SharpFormat } from "../src/types.js";
import { getImageInfo } from "../src/utils/metadata.js";
import { extToMime, formatToExt, formatToMime, mimeToExt } from "../src/utils/mime.js";
// Generate a 100x100 red PNG buffer for testing
let testBuffer: Buffer;
let testImage: () => Sharp;
const SHARP_FORMAT_CASES = [
{ sharpFormat: "webp", operationFormat: "webp", expectedFormat: "webp" },
{ sharpFormat: "avif", operationFormat: "avif", expectedFormat: "avif" },
{ sharpFormat: "png", operationFormat: "png", expectedFormat: "png" },
{ sharpFormat: "jpeg", operationFormat: "jpg", expectedFormat: "jpeg" },
] as const satisfies ReadonlyArray<{
sharpFormat: SharpFormat;
operationFormat: OutputFormat;
expectedFormat: string;
}>;
function tinyTestImage(): Sharp {
return sharp({
create: {
width: 4,
height: 4,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
});
}
async function expectOutputFormat(buffer: Buffer, expectedFormat: string): Promise<void> {
const meta = await sharp(buffer).metadata();
const format = meta.format === "heif" ? "avif" : meta.format;
expect(format).toBe(expectedFormat);
}
beforeAll(async () => {
testBuffer = await sharp({
create: {
width: 100,
height: 100,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
})
.png()
.toBuffer();
testImage = () => sharp(testBuffer);
});
describe("resize", () => {
it("should resize to 50x50", async () => {
const result = await resize(testImage(), { width: 50, height: 50 });
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.width).toBe(50);
expect(meta.height).toBe(50);
});
it("should resize by percentage", async () => {
const result = await resize(testImage(), { percentage: 50 });
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.width).toBe(50);
expect(meta.height).toBe(50);
});
it("should throw on zero width", async () => {
await expect(resize(testImage(), { width: 0 })).rejects.toThrow();
});
it("should throw when no dimensions provided", async () => {
await expect(resize(testImage(), {})).rejects.toThrow();
});
});
describe("crop", () => {
it("should crop 25x25 at (10,10)", async () => {
const result = await crop(testImage(), {
left: 10,
top: 10,
width: 25,
height: 25,
});
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.width).toBe(25);
expect(meta.height).toBe(25);
});
it("should throw on out-of-bounds crop", async () => {
await expect(crop(testImage(), { left: 90, top: 90, width: 20, height: 20 })).rejects.toThrow();
});
it("should throw on zero dimensions", async () => {
await expect(crop(testImage(), { left: 0, top: 0, width: 0, height: 10 })).rejects.toThrow();
});
});
describe("rotate", () => {
it("should rotate 90 degrees and swap dimensions", async () => {
// Create a non-square image to verify dimension swap
const rectBuffer = await sharp({
create: {
width: 100,
height: 50,
channels: 3,
background: { r: 255, g: 0, b: 0 },
},
})
.png()
.toBuffer();
const result = await rotate(sharp(rectBuffer), { angle: 90 });
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.width).toBe(50);
expect(meta.height).toBe(100);
});
it("should rotate non-90 angle with background", async () => {
const result = await rotate(testImage(), {
angle: 45,
background: "#FF0000",
});
const meta = await result.metadata();
expect(meta.width).toBeGreaterThan(0);
expect(meta.height).toBeGreaterThan(0);
});
});
describe("flip", () => {
it("should flip horizontally without error", async () => {
const result = await flip(testImage(), { horizontal: true });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("should flip vertically without error", async () => {
const result = await flip(testImage(), { vertical: true });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("should flip both directions", async () => {
const result = await flip(testImage(), { horizontal: true, vertical: true });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("should throw when neither direction specified", async () => {
await expect(flip(testImage(), {})).rejects.toThrow();
});
});
describe("convert", () => {
it("should convert to webp", async () => {
const result = await convert(testImage(), { format: "webp" });
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.format).toBe("webp");
});
it("should convert to jpg with quality", async () => {
const result = await convert(testImage(), { format: "jpg", quality: 80 });
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
expect(meta.format).toBe("jpeg");
});
it("should throw on invalid quality", async () => {
await expect(convert(testImage(), { format: "png", quality: 0 })).rejects.toThrow();
});
});
describe("compress", () => {
it("should compress at quality 50 and produce smaller output", async () => {
// Use a larger image for better compression ratio visibility
const largeBuffer = await sharp({
create: {
width: 500,
height: 500,
channels: 3,
background: { r: 255, g: 128, b: 0 },
},
})
.jpeg({ quality: 100 })
.toBuffer();
const result = await compress(sharp(largeBuffer), {
quality: 50,
format: "jpg",
});
const buf = await result.toBuffer();
expect(buf.length).toBeLessThan(largeBuffer.length);
});
it("should throw on invalid quality", async () => {
await expect(compress(testImage(), { quality: 0 })).rejects.toThrow();
});
it("should compress to target size", async () => {
const largeBuffer = await sharp({
create: {
width: 500,
height: 500,
channels: 3,
background: { r: 255, g: 128, b: 0 },
},
})
.jpeg({ quality: 100 })
.toBuffer();
const targetSize = Math.round(largeBuffer.length * 0.5);
const result = await compress(sharp(largeBuffer), {
targetSizeBytes: targetSize,
format: "jpg",
});
const buf = await result.toBuffer();
// Should be reasonably close to target (within 50% tolerance for small images)
expect(buf.length).toBeLessThan(largeBuffer.length);
});
});
describe("sharp format compatibility", () => {
it.each(SHARP_FORMAT_CASES)(
"convert accepts $sharpFormat",
async ({ operationFormat, expectedFormat }) => {
const result = await convert(tinyTestImage(), { format: operationFormat });
const buf = await result.toBuffer();
await expectOutputFormat(buf, expectedFormat);
},
);
it.each(SHARP_FORMAT_CASES)(
"compress accepts $sharpFormat",
async ({ operationFormat, expectedFormat }) => {
const result = await compress(tinyTestImage(), { quality: 80, format: operationFormat });
const buf = await result.toBuffer();
await expectOutputFormat(buf, expectedFormat);
},
);
});
describe("grayscale", () => {
it("should convert to grayscale", async () => {
const result = await grayscale(testImage());
const buf = await result.toBuffer();
const meta = await sharp(buf).metadata();
// Grayscale PNG may still report channels as 3 or 1 depending on output
expect(buf.length).toBeGreaterThan(0);
// The image should have no color variation
expect(meta.width).toBe(100);
});
});
describe("sepia", () => {
it("should apply sepia tone without error", async () => {
const result = await sepia(testImage());
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
});
describe("invert", () => {
it("should invert colors without error", async () => {
const result = await invert(testImage());
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
});
describe("brightness", () => {
it("should adjust brightness +50 without error", async () => {
const result = await brightness(testImage(), { value: 50 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("should throw on out-of-range value", async () => {
await expect(brightness(testImage(), { value: 150 })).rejects.toThrow();
});
});
describe("contrast", () => {
it("should adjust contrast +50 without error", async () => {
const result = await contrast(testImage(), { value: 50 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("should throw on out-of-range value", async () => {
await expect(contrast(testImage(), { value: -150 })).rejects.toThrow();
});
});
describe("saturation", () => {
it("should adjust saturation -50 without error", async () => {
const result = await saturation(testImage(), { value: -50 });
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("should throw on out-of-range value", async () => {
await expect(saturation(testImage(), { value: 200 })).rejects.toThrow();
});
});
describe("colorChannels", () => {
it("should adjust color channels without error", async () => {
const result = await colorChannels(testImage(), {
red: 150,
green: 100,
blue: 50,
});
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
it("should throw on out-of-range values", async () => {
await expect(colorChannels(testImage(), { red: 250, green: 100, blue: 100 })).rejects.toThrow();
});
});
describe("stripMetadata", () => {
it("should strip metadata without error", async () => {
const result = await stripMetadata(testImage());
const buf = await result.toBuffer();
expect(buf.length).toBeGreaterThan(0);
});
});
describe("processImage (engine)", () => {
it("should apply multiple operations in sequence", async () => {
const result = await processImage(
testBuffer,
[
{ type: "resize", options: { width: 50, height: 50 } },
{ type: "grayscale", options: {} },
],
"png",
);
expect(result.info.width).toBe(50);
expect(result.info.height).toBe(50);
expect(result.buffer.length).toBeGreaterThan(0);
});
it("should throw on unknown operation", async () => {
await expect(processImage(testBuffer, [{ type: "unknown-op", options: {} }])).rejects.toThrow(
"Unknown operation",
);
});
it("should convert output format", async () => {
const result = await processImage(testBuffer, [], "webp");
expect(result.info.format).toBe("webp");
});
});
describe("detectFormat", () => {
it("should detect PNG format", async () => {
const format = await detectFormat(testBuffer);
expect(format).toBe("png");
});
it("should detect JPEG format", async () => {
const jpegBuffer = await sharp(testBuffer).jpeg().toBuffer();
const format = await detectFormat(jpegBuffer);
expect(format).toBe("jpeg");
});
});
describe("getImageInfo", () => {
it("should return correct image info", async () => {
const info = await getImageInfo(testBuffer);
expect(info.width).toBe(100);
expect(info.height).toBe(100);
expect(info.format).toBe("png");
expect(info.channels).toBe(3);
expect(info.size).toBeGreaterThan(0);
expect(info.hasAlpha).toBe(false);
});
});
describe("mime utilities", () => {
it("should map extension to MIME type", () => {
expect(extToMime("jpg")).toBe("image/jpeg");
expect(extToMime("png")).toBe("image/png");
expect(extToMime("webp")).toBe("image/webp");
expect(extToMime(".jpg")).toBe("image/jpeg");
expect(extToMime("unknown")).toBe("application/octet-stream");
});
it("should map MIME type to extension", () => {
expect(mimeToExt("image/jpeg")).toBe("jpg");
expect(mimeToExt("image/png")).toBe("png");
expect(mimeToExt("application/unknown")).toBe("bin");
});
it("should map format to MIME type", () => {
expect(formatToMime("jpeg")).toBe("image/jpeg");
expect(formatToMime("png")).toBe("image/png");
});
it("should map format to extension", () => {
expect(formatToExt("jpeg")).toBe("jpg");
expect(formatToExt("png")).toBe("png");
});
});