Files
SnapOtter/tests/integration/color-palette.test.ts
T
SnapOtter b8759ee581 fix(security): update integration tests for stricter validation
Corrupt image data now returns 400 (invalid image) instead of 422
(processing failure) because validation catches it earlier.
Long usernames now return 400 (Zod max length) instead of 401.
2026-05-14 17:42:07 +08:00

661 lines
24 KiB
TypeScript

/**
* Integration tests for the color-palette tool.
*
* This tool extracts dominant colors from an image and returns JSON
* (not an image). Tests verify response shape, color count, and format handling.
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import sharp from "sharp";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
const FIXTURES = join(__dirname, "..", "fixtures");
const PNG = readFileSync(join(FIXTURES, "test-200x150.png"));
const JPG = readFileSync(join(FIXTURES, "test-100x100.jpg"));
const WEBP = readFileSync(join(FIXTURES, "test-50x50.webp"));
let testApp: TestApp;
let app: TestApp["app"];
let adminToken: string;
beforeAll(async () => {
testApp = await buildTestApp();
app = testApp.app;
adminToken = await loginAsAdmin(app);
}, 30_000);
afterAll(async () => {
await testApp.cleanup();
}, 10_000);
function makeFilePayload(buffer: Buffer, filename: string, contentType: string) {
return createMultipartPayload([{ name: "file", filename, contentType, content: buffer }]);
}
// ── Basic extraction ──────────────────────────────────────────────
describe("Color extraction", () => {
it("extracts palette from PNG and returns colors array", async () => {
const { body: payload, contentType } = makeFilePayload(PNG, "test.png", "image/png");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.colors).toBeDefined();
expect(Array.isArray(result.colors)).toBe(true);
expect(result.colors.length).toBeGreaterThan(0);
expect(result.colors.length).toBeLessThanOrEqual(8);
expect(result.count).toBe(result.colors.length);
expect(result.filename).toBeDefined();
});
it("returns hex color strings in #RRGGBB format", async () => {
const { body: payload, contentType } = makeFilePayload(PNG, "test.png", "image/png");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
for (const color of result.colors) {
expect(color).toMatch(/^#[0-9a-f]{6}$/);
}
});
});
// ── Solid color image ─────────────────────────────────────────────
describe("Solid color image", () => {
it("returns a single dominant color for a solid red image", async () => {
const redBuffer = await sharp({
create: { width: 50, height: 50, channels: 3, background: { r: 255, g: 0, b: 0 } },
})
.png()
.toBuffer();
const { body: payload, contentType } = makeFilePayload(redBuffer, "red.png", "image/png");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.colors.length).toBe(1);
// The quantized red should be close to #f00000 or #ff0000
expect(result.colors[0]).toMatch(/^#[ef][0f]0000$/);
});
});
// ── Format support ────────────────────────────────────────────────
describe("Multiple input formats", () => {
it("extracts palette from JPEG", async () => {
const { body: payload, contentType } = makeFilePayload(JPG, "test.jpg", "image/jpeg");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.colors.length).toBeGreaterThan(0);
});
it("extracts palette from WebP", async () => {
const { body: payload, contentType } = makeFilePayload(WEBP, "test.webp", "image/webp");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.colors.length).toBeGreaterThan(0);
});
});
// ── Error handling ────────────────────────────────────────────────
describe("Error handling", () => {
it("returns 400 when no file is provided", async () => {
const { body: payload, contentType } = createMultipartPayload([
{ name: "other", content: "nothing" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(400);
const result = JSON.parse(res.body);
expect(result.error).toBeDefined();
});
it("returns 400 for corrupted image data", async () => {
const badBuffer = Buffer.from("not an image at all");
const { body: payload, contentType } = makeFilePayload(badBuffer, "bad.png", "image/png");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
// validateImageBuffer catches corrupt data before processing
expect(res.statusCode).toBe(400);
});
});
// ── Branch coverage: lines 62-66 (multipart parse error) ────────
describe("Multipart error handling", () => {
it("returns 400 for empty file buffer", async () => {
const { body: payload, contentType } = createMultipartPayload([
{ name: "file", filename: "empty.png", contentType: "image/png", content: Buffer.alloc(0) },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(400);
const result = JSON.parse(res.body);
expect(result.error).toBeDefined();
});
});
// ── HEIC input handling ─────────────────────────────────────────
describe("HEIC input", () => {
it("extracts palette from HEIC image", { timeout: 120_000 }, async () => {
const HEIC = readFileSync(join(FIXTURES, "test-200x150.heic"));
const { body: payload, contentType } = makeFilePayload(HEIC, "photo.heic", "image/heic");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.colors.length).toBeGreaterThan(0);
expect(result.filename).toBe("photo.heic");
});
});
// ── Multi-color image ───────────────────────────────────────────
describe("Multi-color extraction", () => {
it("extracts multiple colors from a multi-color image", async () => {
// Create a 2-color image (half red, half blue)
const halfWidth = 25;
const halfBuffer = await sharp({
create: { width: 50, height: 50, channels: 3, background: { r: 255, g: 0, b: 0 } },
})
.composite([
{
input: await sharp({
create: {
width: halfWidth,
height: 50,
channels: 3,
background: { r: 0, g: 0, b: 255 },
},
})
.png()
.toBuffer(),
left: halfWidth,
top: 0,
},
])
.png()
.toBuffer();
const { body: payload, contentType } = makeFilePayload(halfBuffer, "bicolor.png", "image/png");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.colors.length).toBeGreaterThanOrEqual(2);
});
});
// ── Tiny and stress inputs ──────────────────────────────────────
describe("Edge size inputs", () => {
it("extracts palette from 1x1 pixel image", async () => {
const TINY = readFileSync(join(FIXTURES, "test-1x1.png"));
const { body: payload, contentType } = makeFilePayload(TINY, "tiny.png", "image/png");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.colors.length).toBeGreaterThan(0);
});
it("extracts palette from stress-large.jpg", async () => {
const LARGE = readFileSync(join(FIXTURES, "content", "stress-large.jpg"));
const { body: payload, contentType } = makeFilePayload(LARGE, "large.jpg", "image/jpeg");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.colors.length).toBeGreaterThan(0);
expect(result.colors.length).toBeLessThanOrEqual(8);
});
});
// ── Unauthenticated request ──────────────────────────────────────
describe("Authentication", () => {
it("rejects unauthenticated request", async () => {
const { body: payload, contentType } = makeFilePayload(PNG, "test.png", "image/png");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
},
});
expect(res.statusCode).toBe(401);
});
});
// ── Gradient image (many unique colors) ──────────────────────────
describe("Gradient image palette", () => {
it("extracts palette from a gradient image (max 8 colors)", async () => {
// Create a horizontal gradient image
const w = 100;
const h = 50;
const raw = Buffer.alloc(w * h * 3);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = (y * w + x) * 3;
raw[idx] = Math.round((x / w) * 255);
raw[idx + 1] = Math.round((y / h) * 255);
raw[idx + 2] = 128;
}
}
const gradientBuffer = await sharp(raw, { raw: { width: w, height: h, channels: 3 } })
.png()
.toBuffer();
const { body: payload, contentType } = makeFilePayload(
gradientBuffer,
"gradient.png",
"image/png",
);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.colors.length).toBeGreaterThanOrEqual(2);
expect(result.colors.length).toBeLessThanOrEqual(8);
});
});
// ── Solid white image ──────────────────────────────────────────
describe("Solid white image", () => {
it("returns a single dominant color for a solid white image", async () => {
const whiteBuffer = await sharp({
create: { width: 50, height: 50, channels: 3, background: { r: 255, g: 255, b: 255 } },
})
.png()
.toBuffer();
const { body: payload, contentType } = makeFilePayload(whiteBuffer, "white.png", "image/png");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.colors.length).toBe(1);
// Quantized white should be #f0f0f0 or #ffffff
expect(result.colors[0]).toMatch(/^#[ef][0f][ef][0f][ef][0f]$/);
});
});
// ── HEIF input ─────────────────────────────────────────────────
describe("HEIF input", () => {
it("extracts palette from HEIF image", { timeout: 120_000 }, async () => {
const HEIF = readFileSync(join(FIXTURES, "content", "motorcycle.heif"));
const { body: payload, contentType } = makeFilePayload(HEIF, "photo.heif", "image/heif");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.colors.length).toBeGreaterThan(0);
});
});
// ── Animated GIF input ──────────────────────────────────────────
describe("Animated GIF input", () => {
it("extracts palette from animated GIF", async () => {
const GIF = readFileSync(join(FIXTURES, "animated.gif"));
const { body: payload, contentType } = makeFilePayload(GIF, "anim.gif", "image/gif");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.colors.length).toBeGreaterThan(0);
});
});
// ── SVG input ───────────────────────────────────────────────────
describe("SVG input", () => {
it("extracts palette from SVG image", async () => {
const SVG = readFileSync(join(FIXTURES, "test-100x100.svg"));
const { body: payload, contentType } = makeFilePayload(SVG, "icon.svg", "image/svg+xml");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.colors.length).toBeGreaterThan(0);
});
});
// ── TIFF input ─────────────────────────────────────────────────
describe("TIFF input", () => {
it("extracts palette from TIFF image", async () => {
const TIFF = readFileSync(join(FIXTURES, "formats", "sample.tiff"));
const { body: payload, contentType } = makeFilePayload(TIFF, "test.tiff", "image/tiff");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.colors.length).toBeGreaterThan(0);
expect(result.filename).toBe("test.tiff");
});
});
// ── Real photo with many colors ────────────────────────────────
describe("Real photo palette", () => {
it("extracts palette from portrait-color.jpg", async () => {
const PHOTO = readFileSync(join(FIXTURES, "content", "portrait-color.jpg"));
const { body: payload, contentType } = makeFilePayload(PHOTO, "photo.jpg", "image/jpeg");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.colors.length).toBeGreaterThanOrEqual(2);
expect(result.colors.length).toBeLessThanOrEqual(8);
// Verify all colors are valid hex
for (const color of result.colors) {
expect(color).toMatch(/^#[0-9a-f]{6}$/);
}
});
});
// ── Solid black image ──────────────────────────────────────────
describe("Solid black image", () => {
it("returns a single dominant color for a solid black image", async () => {
const blackBuffer = await sharp({
create: { width: 50, height: 50, channels: 3, background: { r: 0, g: 0, b: 0 } },
})
.png()
.toBuffer();
const { body: payload, contentType } = makeFilePayload(blackBuffer, "black.png", "image/png");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.colors.length).toBe(1);
expect(result.colors[0]).toBe("#000000");
});
});
// ── Color count is between 1 and 8 ────────────────────────────
describe("Color count bounds", () => {
it("count field matches colors array length", async () => {
const { body: payload, contentType } = makeFilePayload(PNG, "test.png", "image/png");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.count).toBe(result.colors.length);
expect(result.count).toBeGreaterThanOrEqual(1);
expect(result.count).toBeLessThanOrEqual(8);
});
});
// ── AVIF input ─────────────────────────────────────────────────
describe("AVIF input", () => {
it("extracts palette from AVIF image", async () => {
const AVIF = readFileSync(join(FIXTURES, "formats", "sample.avif"));
const { body: payload, contentType } = makeFilePayload(AVIF, "test.avif", "image/avif");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.colors.length).toBeGreaterThan(0);
});
});
// ── Filename preserved in response ──────────────────────────────
describe("Filename tracking", () => {
it("returns the original filename in the response", async () => {
const { body: payload, contentType } = makeFilePayload(PNG, "my-image-2024.png", "image/png");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.filename).toBe("my-image-2024.png");
});
});
// ── Extracted colors are unique ────────────────────────────────
describe("Color uniqueness", () => {
it("returns only unique colors (no duplicates)", async () => {
const PHOTO = readFileSync(join(FIXTURES, "content", "portrait-color.jpg"));
const { body: payload, contentType } = makeFilePayload(PHOTO, "photo.jpg", "image/jpeg");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
const uniqueColors = new Set(result.colors);
expect(uniqueColors.size).toBe(result.colors.length);
});
});
// ── SVG logo from content fixtures ────────────────────────────
describe("SVG logo input", () => {
it("extracts palette from svg-logo.svg", async () => {
const SVG_LOGO = readFileSync(join(FIXTURES, "content", "svg-logo.svg"));
const { body: payload, contentType } = makeFilePayload(SVG_LOGO, "logo.svg", "image/svg+xml");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.colors.length).toBeGreaterThan(0);
expect(result.filename).toBe("logo.svg");
});
});
// ── Three-color image ─────────────────────────────────────────
describe("Three-color image", () => {
it("extracts 3 colors from a 3-stripe image", async () => {
const w = 60;
const h = 30;
const raw = Buffer.alloc(w * h * 3);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const idx = (y * w + x) * 3;
if (x < 20) {
raw[idx] = 255;
raw[idx + 1] = 0;
raw[idx + 2] = 0;
} else if (x < 40) {
raw[idx] = 0;
raw[idx + 1] = 255;
raw[idx + 2] = 0;
} else {
raw[idx] = 0;
raw[idx + 1] = 0;
raw[idx + 2] = 255;
}
}
}
const tricolorBuffer = await sharp(raw, { raw: { width: w, height: h, channels: 3 } })
.png()
.toBuffer();
const { body: payload, contentType } = makeFilePayload(tricolorBuffer, "tri.png", "image/png");
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/color-palette",
payload,
headers: {
"content-type": contentType,
authorization: `Bearer ${adminToken}`,
},
});
expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body);
expect(result.colors.length).toBeGreaterThanOrEqual(3);
});
});