mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
1383 lines
45 KiB
TypeScript
1383 lines
45 KiB
TypeScript
/**
|
|
* Adversarial coverage gap tests for the SnapOtter image API.
|
|
*
|
|
* Fills the remaining gaps after analyzing all existing adversarial, edge-case,
|
|
* concurrent, and pipeline test files (342 tests total). Focuses on scenarios
|
|
* that are either missing entirely or only partially covered:
|
|
*
|
|
* 1. Concurrent response content integrity (download + verify different outputs)
|
|
* 2. Memory stress: 50 sequential large uploads with memory monitoring
|
|
* 3. Zero-byte files with various MIME types through additional tools
|
|
* 4. Format mismatch: GIF data with .webp extension, animated GIF through resize
|
|
* 5. Extreme output dimensions: resize to 10000x1 and 1x10000
|
|
* 6. Pipeline step limit enforcement (with MAX_PIPELINE_STEPS configured)
|
|
* 7. Batch with 50+ images (beyond configured MAX_BATCH_SIZE=10)
|
|
* 8. Multipart with missing/malformed boundary
|
|
* 9. Multipart with extra unexpected part fields
|
|
* 10. Request body edge cases: empty multipart, form-urlencoded, no content-type
|
|
*/
|
|
|
|
import { apiToolPath } from "@snapotter/shared";
|
|
import sharp from "sharp";
|
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
import { fixtures, readFixture } from "../../fixtures/index.js";
|
|
import {
|
|
buildTestApp,
|
|
createMultipartPayload,
|
|
loginAsAdmin,
|
|
type TestApp,
|
|
} from "../test-server.js";
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Fixtures
|
|
// ---------------------------------------------------------------------------
|
|
const PNG_200x150 = readFixture(fixtures.image.base.png200);
|
|
const PNG_1x1 = readFixture(fixtures.image.edge.px1);
|
|
const JPG_100x100 = readFixture(fixtures.image.base.jpg100);
|
|
const WEBP_50x50 = readFixture(fixtures.image.base.webp50);
|
|
const ANIMATED_GIF = readFixture(fixtures.image.animated.gif);
|
|
const STRESS_LARGE = readFixture(fixtures.image.stressLarge);
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Shared state
|
|
// ---------------------------------------------------------------------------
|
|
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);
|
|
|
|
/** Helper to POST a multipart payload to a tool endpoint. */
|
|
function postTool(
|
|
toolId: string,
|
|
fields: Array<{
|
|
name: string;
|
|
filename?: string;
|
|
contentType?: string;
|
|
content: Buffer | string;
|
|
}>,
|
|
) {
|
|
const { body, contentType } = createMultipartPayload(fields);
|
|
return app.inject({
|
|
method: "POST",
|
|
url: apiToolPath(toolId),
|
|
headers: {
|
|
"content-type": contentType,
|
|
authorization: `Bearer ${adminToken}`,
|
|
},
|
|
body,
|
|
});
|
|
}
|
|
|
|
/** Helper to POST a batch request. */
|
|
function postBatch(
|
|
toolId: string,
|
|
fields: Array<{
|
|
name: string;
|
|
filename?: string;
|
|
contentType?: string;
|
|
content: Buffer | string;
|
|
}>,
|
|
) {
|
|
const { body, contentType } = createMultipartPayload(fields);
|
|
return app.inject({
|
|
method: "POST",
|
|
url: `${apiToolPath(toolId)}/batch`,
|
|
headers: {
|
|
"content-type": contentType,
|
|
authorization: `Bearer ${adminToken}`,
|
|
},
|
|
body,
|
|
});
|
|
}
|
|
|
|
/** Helper to POST a pipeline execution request. */
|
|
function executePipeline(
|
|
image: Buffer,
|
|
filename: string,
|
|
pipeline: {
|
|
steps: Array<{ toolId: string; settings?: Record<string, unknown> }>;
|
|
},
|
|
) {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename, content: image, contentType: "image/png" },
|
|
{ name: "pipeline", content: JSON.stringify(pipeline) },
|
|
]);
|
|
return app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/pipeline/execute",
|
|
headers: {
|
|
"content-type": contentType,
|
|
authorization: `Bearer ${adminToken}`,
|
|
},
|
|
body,
|
|
});
|
|
}
|
|
|
|
/** Helper to build an inject config for a tool request. */
|
|
function buildToolRequest(
|
|
toolId: string,
|
|
image: Buffer,
|
|
filename: string,
|
|
settings: Record<string, unknown>,
|
|
imgContentType = "image/png",
|
|
) {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename, content: image, contentType: imgContentType },
|
|
{ name: "settings", content: JSON.stringify(settings) },
|
|
]);
|
|
return {
|
|
method: "POST" as const,
|
|
url: apiToolPath(toolId),
|
|
headers: {
|
|
"content-type": contentType,
|
|
authorization: `Bearer ${adminToken}`,
|
|
},
|
|
body,
|
|
};
|
|
}
|
|
|
|
// ###########################################################################
|
|
// 1. CONCURRENT RESPONSE CONTENT INTEGRITY VERIFICATION
|
|
// Existing tests verify unique job IDs but never download the actual
|
|
// output files to confirm no data mixing. These tests do.
|
|
// ###########################################################################
|
|
describe("Concurrent response content integrity -- download verification", () => {
|
|
it("10 simultaneous resize requests produce correctly-sized output files", async () => {
|
|
const widths = [20, 40, 60, 80, 100, 120, 140, 160, 180, 199];
|
|
|
|
const results = await Promise.all(
|
|
widths.map((width, i) =>
|
|
app.inject(buildToolRequest("resize", PNG_200x150, `integrity-${i}.png`, { width })),
|
|
),
|
|
);
|
|
|
|
// All must succeed with unique IDs
|
|
for (const res of results) {
|
|
expect(res.statusCode).toBe(200);
|
|
}
|
|
const jobIds = results.map((r) => JSON.parse(r.body).jobId);
|
|
expect(new Set(jobIds).size).toBe(10);
|
|
|
|
// Download each output and verify the actual pixel width matches
|
|
for (let i = 0; i < results.length; i++) {
|
|
const downloadUrl = JSON.parse(results[i].body).downloadUrl;
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: downloadUrl,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
expect(dlRes.statusCode).toBe(200);
|
|
const metadata = await sharp(dlRes.rawPayload).metadata();
|
|
expect(metadata.width).toBe(widths[i]);
|
|
}
|
|
}, 120_000);
|
|
|
|
it("10 simultaneous uploads to DIFFERENT tools all produce correct tool-specific outputs", async () => {
|
|
const requests = [
|
|
buildToolRequest("resize", PNG_200x150, "mt-resize-1.png", { width: 50 }),
|
|
buildToolRequest("resize", PNG_200x150, "mt-resize-2.png", { width: 100 }),
|
|
buildToolRequest("rotate", PNG_200x150, "mt-rotate-1.png", { angle: 90 }),
|
|
buildToolRequest("rotate", PNG_200x150, "mt-rotate-2.png", { angle: 180 }),
|
|
buildToolRequest("compress", PNG_200x150, "mt-compress-1.png", { quality: 30 }),
|
|
buildToolRequest("compress", PNG_200x150, "mt-compress-2.png", { quality: 90 }),
|
|
buildToolRequest("border", PNG_200x150, "mt-border-1.png", { borderWidth: 5 }),
|
|
buildToolRequest("border", PNG_200x150, "mt-border-2.png", { borderWidth: 20 }),
|
|
buildToolRequest("convert", PNG_200x150, "mt-convert-1.png", { format: "webp" }),
|
|
buildToolRequest("convert", PNG_200x150, "mt-convert-2.png", { format: "jpg" }),
|
|
];
|
|
|
|
const results = await Promise.all(requests.map((req) => app.inject(req)));
|
|
|
|
// All succeed
|
|
for (const res of results) {
|
|
expect(res.statusCode).toBe(200);
|
|
}
|
|
|
|
// All unique job IDs
|
|
const jobIds = results.map((r) => JSON.parse(r.body).jobId);
|
|
expect(new Set(jobIds).size).toBe(10);
|
|
|
|
// Download the resize outputs and verify pixel widths
|
|
const resize1Url = JSON.parse(results[0].body).downloadUrl;
|
|
const resize2Url = JSON.parse(results[1].body).downloadUrl;
|
|
|
|
const [dl1, dl2] = await Promise.all([
|
|
app.inject({
|
|
method: "GET",
|
|
url: resize1Url,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
}),
|
|
app.inject({
|
|
method: "GET",
|
|
url: resize2Url,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
}),
|
|
]);
|
|
|
|
const meta1 = await sharp(dl1.rawPayload).metadata();
|
|
const meta2 = await sharp(dl2.rawPayload).metadata();
|
|
|
|
expect(meta1.width).toBe(50);
|
|
expect(meta2.width).toBe(100);
|
|
|
|
// Download a rotate output and verify dimensions swapped
|
|
const rotate90Url = JSON.parse(results[2].body).downloadUrl;
|
|
const dlRotate = await app.inject({
|
|
method: "GET",
|
|
url: rotate90Url,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
const metaRotate = await sharp(dlRotate.rawPayload).metadata();
|
|
// 200x150 rotated 90 degrees becomes 150x200
|
|
expect(metaRotate.width).toBe(150);
|
|
expect(metaRotate.height).toBe(200);
|
|
}, 120_000);
|
|
});
|
|
|
|
// ###########################################################################
|
|
// 2. MEMORY STRESS: 50 SEQUENTIAL LARGE FILE UPLOADS
|
|
// Existing tests only do 5--10 sequential large uploads. This exercises
|
|
// 50 and checks for memory growth and response time degradation.
|
|
// ###########################################################################
|
|
describe("Memory stress -- 50 sequential large-file uploads", () => {
|
|
it("processes stress-large.jpg 50 times sequentially without memory blowup or degradation", async () => {
|
|
const memBefore = process.memoryUsage();
|
|
const responseTimes: number[] = [];
|
|
|
|
for (let i = 0; i < 50; i++) {
|
|
const start = Date.now();
|
|
const res = await postTool("resize", [
|
|
{
|
|
name: "file",
|
|
filename: `mem-stress-${i}.jpg`,
|
|
content: STRESS_LARGE,
|
|
contentType: "image/jpeg",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ width: 200 }) },
|
|
]);
|
|
const elapsed = Date.now() - start;
|
|
responseTimes.push(elapsed);
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.jobId).toBeDefined();
|
|
expect(json.processedSize).toBeLessThan(json.originalSize);
|
|
}
|
|
|
|
const memAfter = process.memoryUsage();
|
|
|
|
// All 50 completed successfully (verified in the loop above)
|
|
|
|
// Memory growth check: RSS should not grow by more than 500MB
|
|
// (50 * 6.7MB = 335MB raw data, but Sharp streams so actual growth
|
|
// should be much less if buffers are properly released)
|
|
const rssGrowthMB = (memAfter.rss - memBefore.rss) / (1024 * 1024);
|
|
expect(rssGrowthMB).toBeLessThan(500);
|
|
|
|
// Response time degradation check: the last 10 requests should not
|
|
// take more than 5x the average of the first 10
|
|
const firstTenAvg = responseTimes.slice(0, 10).reduce((a, b) => a + b, 0) / 10;
|
|
const lastTenAvg = responseTimes.slice(40, 50).reduce((a, b) => a + b, 0) / 10;
|
|
// Allow generous margin -- the key is no exponential blowup
|
|
expect(lastTenAvg).toBeLessThan(firstTenAvg * 5);
|
|
|
|
// Server still responsive
|
|
const healthRes = await app.inject({ method: "GET", url: "/api/v1/health" });
|
|
expect(healthRes.statusCode).toBe(200);
|
|
}, 600_000);
|
|
});
|
|
|
|
// ###########################################################################
|
|
// 3. ZERO-BYTE FILES -- ADDITIONAL TOOLS AND MIME VARIANTS
|
|
// Covers tools and MIME combinations not tested in existing files.
|
|
// ###########################################################################
|
|
describe("Zero-byte files with various MIME types through additional tools", () => {
|
|
it("rejects zero-byte file with image/gif MIME to resize", async () => {
|
|
const res = await postTool("resize", [
|
|
{
|
|
name: "file",
|
|
filename: "empty.gif",
|
|
content: Buffer.alloc(0),
|
|
contentType: "image/gif",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ width: 100 }) },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.error).toBeDefined();
|
|
});
|
|
|
|
it("rejects zero-byte file with image/avif MIME to compress", async () => {
|
|
const res = await postTool("compress", [
|
|
{
|
|
name: "file",
|
|
filename: "empty.avif",
|
|
content: Buffer.alloc(0),
|
|
contentType: "image/avif",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ quality: 50 }) },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.error).toBeDefined();
|
|
});
|
|
|
|
it("rejects zero-byte file with image/tiff MIME to convert", async () => {
|
|
const res = await postTool("convert", [
|
|
{
|
|
name: "file",
|
|
filename: "empty.tiff",
|
|
content: Buffer.alloc(0),
|
|
contentType: "image/tiff",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ format: "png" }) },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.error).toBeDefined();
|
|
});
|
|
|
|
it("rejects zero-byte file to text-overlay", async () => {
|
|
const res = await postTool("text-overlay", [
|
|
{
|
|
name: "file",
|
|
filename: "empty.png",
|
|
content: Buffer.alloc(0),
|
|
contentType: "image/png",
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ text: "Hello", fontSize: 24 }),
|
|
},
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.error).toBeDefined();
|
|
});
|
|
|
|
it("rejects zero-byte file to color-palette", async () => {
|
|
const res = await postTool("color-palette", [
|
|
{
|
|
name: "file",
|
|
filename: "empty.jpg",
|
|
content: Buffer.alloc(0),
|
|
contentType: "image/jpeg",
|
|
},
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.error).toBeDefined();
|
|
});
|
|
|
|
it("rejects zero-byte file to favicon", async () => {
|
|
const res = await postTool("favicon", [
|
|
{
|
|
name: "file",
|
|
filename: "empty.png",
|
|
content: Buffer.alloc(0),
|
|
contentType: "image/png",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({}) },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.error).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ###########################################################################
|
|
// 4. FORMAT MISMATCH: GIF DATA WITH WRONG EXTENSION, ANIMATED GIF
|
|
// Existing tests cover PNG-as-JPG, JPEG-as-PNG, JPEG-as-WebP, WebP-as-PNG.
|
|
// Missing: GIF data with .webp extension, animated GIF through tools.
|
|
// ###########################################################################
|
|
describe("Format mismatch -- GIF data and animated GIF handling", () => {
|
|
it("handles GIF data uploaded with .webp extension", async () => {
|
|
const res = await postTool("resize", [
|
|
{
|
|
name: "file",
|
|
filename: "actually-a-gif.webp",
|
|
content: ANIMATED_GIF,
|
|
contentType: "image/webp",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ width: 50 }) },
|
|
]);
|
|
|
|
// Sharp auto-detects GIF via magic bytes
|
|
expect([200, 400, 422]).toContain(res.statusCode);
|
|
});
|
|
|
|
it("handles GIF data uploaded with .jpg extension", async () => {
|
|
const res = await postTool("resize", [
|
|
{
|
|
name: "file",
|
|
filename: "actually-a-gif.jpg",
|
|
content: ANIMATED_GIF,
|
|
contentType: "image/jpeg",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ width: 50 }) },
|
|
]);
|
|
|
|
// Sharp detects the real GIF format
|
|
expect([200, 400, 422]).toContain(res.statusCode);
|
|
});
|
|
|
|
it("handles WebP data uploaded with .gif extension", async () => {
|
|
const res = await postTool("resize", [
|
|
{
|
|
name: "file",
|
|
filename: "actually-webp.gif",
|
|
content: WEBP_50x50,
|
|
contentType: "image/gif",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ width: 30 }) },
|
|
]);
|
|
|
|
// Sharp detects WebP via magic bytes
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it("handles JPEG data uploaded with .gif extension", async () => {
|
|
const res = await postTool("compress", [
|
|
{
|
|
name: "file",
|
|
filename: "actually-jpeg.gif",
|
|
content: JPG_100x100,
|
|
contentType: "image/gif",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ quality: 50 }) },
|
|
]);
|
|
|
|
// Sharp detects JPEG via magic bytes
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it("resizes an animated GIF without crashing", async () => {
|
|
const res = await postTool("resize", [
|
|
{
|
|
name: "file",
|
|
filename: "animated.gif",
|
|
content: ANIMATED_GIF,
|
|
contentType: "image/gif",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ width: 30 }) },
|
|
]);
|
|
|
|
// Animated GIF may lose frames during processing but must not crash
|
|
expect([200, 400, 422]).toContain(res.statusCode);
|
|
});
|
|
|
|
it("compresses an animated GIF without crashing", async () => {
|
|
const res = await postTool("compress", [
|
|
{
|
|
name: "file",
|
|
filename: "animated.gif",
|
|
content: ANIMATED_GIF,
|
|
contentType: "image/gif",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ quality: 50 }) },
|
|
]);
|
|
|
|
expect([200, 400, 422]).toContain(res.statusCode);
|
|
});
|
|
|
|
it("gets info for an animated GIF", async () => {
|
|
const res = await postTool("info", [
|
|
{
|
|
name: "file",
|
|
filename: "animated.gif",
|
|
content: ANIMATED_GIF,
|
|
contentType: "image/gif",
|
|
},
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.format).toBe("gif");
|
|
expect(json.width).toBeGreaterThan(0);
|
|
expect(json.height).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// ###########################################################################
|
|
// 5. EXTREME OUTPUT DIMENSIONS: RESIZE TO 10000x1 AND 1x10000
|
|
// Existing tests create 1x1000 and 1000x1 INPUT images but never test
|
|
// RESIZING a normal image to extreme aspect ratio OUTPUT dimensions.
|
|
// ###########################################################################
|
|
describe("Extreme output dimensions -- resize to extreme aspect ratios", () => {
|
|
it("resizes 200x150 image to 10000x1 (very wide output)", async () => {
|
|
const res = await postTool("resize", [
|
|
{
|
|
name: "file",
|
|
filename: "to-wide.png",
|
|
content: PNG_200x150,
|
|
contentType: "image/png",
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ width: 10000, height: 1 }),
|
|
},
|
|
]);
|
|
|
|
// Sharp may succeed with fit:fill or reject at processing. Must not crash.
|
|
expect([200, 400, 422]).toContain(res.statusCode);
|
|
}, 60_000);
|
|
|
|
it("resizes 200x150 image to 1x10000 (very tall output)", async () => {
|
|
const res = await postTool("resize", [
|
|
{
|
|
name: "file",
|
|
filename: "to-tall.png",
|
|
content: PNG_200x150,
|
|
contentType: "image/png",
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ width: 1, height: 10000 }),
|
|
},
|
|
]);
|
|
|
|
expect([200, 400, 422]).toContain(res.statusCode);
|
|
}, 60_000);
|
|
|
|
it("resizes 1x1 image to 10000x10000 (massive upscale)", async () => {
|
|
const res = await postTool("resize", [
|
|
{
|
|
name: "file",
|
|
filename: "massive-upscale.png",
|
|
content: PNG_1x1,
|
|
contentType: "image/png",
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ width: 10000, height: 10000 }),
|
|
},
|
|
]);
|
|
|
|
// May be rejected by MAX_MEGAPIXELS (100MP limit, 10000x10000 = 100MP exactly)
|
|
// or succeed. Must not crash.
|
|
expect([200, 400, 422]).toContain(res.statusCode);
|
|
}, 120_000);
|
|
|
|
it("resizes to width=1 only (auto height)", async () => {
|
|
const res = await postTool("resize", [
|
|
{
|
|
name: "file",
|
|
filename: "width-1.png",
|
|
content: PNG_200x150,
|
|
contentType: "image/png",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ width: 1 }) },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const dlUrl = JSON.parse(res.body).downloadUrl;
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: dlUrl,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
const meta = await sharp(dlRes.rawPayload).metadata();
|
|
expect(meta.width).toBe(1);
|
|
});
|
|
|
|
it("resizes to height=1 only (auto width)", async () => {
|
|
const res = await postTool("resize", [
|
|
{
|
|
name: "file",
|
|
filename: "height-1.png",
|
|
content: PNG_200x150,
|
|
contentType: "image/png",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ height: 1 }) },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const dlUrl = JSON.parse(res.body).downloadUrl;
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: dlUrl,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
const meta = await sharp(dlRes.rawPayload).metadata();
|
|
expect(meta.height).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ###########################################################################
|
|
// 6. PIPELINE WITH CONFLICTING OPS AND FORMAT DEPENDENCY
|
|
// Existing tests cover resize-then-crop-larger, but not the specific
|
|
// scenario of step 2 depending on step 1 output format.
|
|
// ###########################################################################
|
|
describe("Pipeline -- format-dependent step chains", () => {
|
|
it("convert to JPEG (lossy) then compress -- compression applies to JPEG data", async () => {
|
|
const res = await executePipeline(PNG_200x150, "fmt-dep-1.png", {
|
|
steps: [
|
|
{ toolId: "convert", settings: { format: "jpg" } },
|
|
{ toolId: "compress", settings: { quality: 10 } },
|
|
],
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.stepsCompleted).toBe(2);
|
|
// Low quality JPEG compression on an already-JPEG buffer should reduce size
|
|
expect(json.processedSize).toBeLessThan(json.originalSize);
|
|
});
|
|
|
|
it("resize to 10x10 then crop 5x5 from center -- succeeds with small output", async () => {
|
|
const res = await executePipeline(PNG_200x150, "resize-crop-ok.png", {
|
|
steps: [
|
|
{ toolId: "resize", settings: { width: 10, height: 10 } },
|
|
{ toolId: "crop", settings: { left: 2, top: 2, width: 5, height: 5 } },
|
|
],
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.stepsCompleted).toBe(2);
|
|
|
|
// Download and verify dimensions
|
|
const dlRes = await app.inject({
|
|
method: "GET",
|
|
url: json.downloadUrl,
|
|
headers: { authorization: `Bearer ${adminToken}` },
|
|
});
|
|
const meta = await sharp(dlRes.rawPayload).metadata();
|
|
expect(meta.width).toBe(5);
|
|
expect(meta.height).toBe(5);
|
|
});
|
|
|
|
it("resize to 10x10 then crop 100x100 -- fails at crop step", async () => {
|
|
const res = await executePipeline(PNG_200x150, "resize-crop-fail.png", {
|
|
steps: [
|
|
{ toolId: "resize", settings: { width: 10, height: 10 } },
|
|
{ toolId: "crop", settings: { left: 0, top: 0, width: 100, height: 100 } },
|
|
],
|
|
});
|
|
|
|
// Crop exceeds resized dimensions
|
|
expect([200, 422]).toContain(res.statusCode);
|
|
});
|
|
|
|
it("convert PNG to AVIF then rotate -- AVIF intermediate works", async () => {
|
|
const res = await executePipeline(PNG_200x150, "avif-rotate.png", {
|
|
steps: [
|
|
{ toolId: "convert", settings: { format: "avif" } },
|
|
{ toolId: "rotate", settings: { angle: 90 } },
|
|
],
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.stepsCompleted).toBe(2);
|
|
});
|
|
|
|
it("triple format conversion: PNG -> JPEG -> WebP -> AVIF", async () => {
|
|
const res = await executePipeline(PNG_200x150, "triple-convert.png", {
|
|
steps: [
|
|
{ toolId: "convert", settings: { format: "jpg" } },
|
|
{ toolId: "convert", settings: { format: "webp" } },
|
|
{ toolId: "convert", settings: { format: "avif" } },
|
|
],
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.stepsCompleted).toBe(3);
|
|
});
|
|
});
|
|
|
|
// ###########################################################################
|
|
// 7. BATCH LIMITS -- EXCEEDING MAX_BATCH_SIZE WITH LARGE COUNT
|
|
// Existing tests go up to 11 images (limit is 10). This tests larger counts
|
|
// to ensure the server handles the rejection gracefully.
|
|
// ###########################################################################
|
|
describe("Batch limits -- large file counts beyond MAX_BATCH_SIZE", () => {
|
|
it("rejects batch with 20 images (double the MAX_BATCH_SIZE=10)", async () => {
|
|
const fields = Array.from({ length: 20 }, (_, i) => ({
|
|
name: "file",
|
|
filename: `batch-20-${i}.png`,
|
|
contentType: "image/png",
|
|
content: PNG_1x1, // Use tiny images to keep payload small
|
|
}));
|
|
fields.push({
|
|
name: "settings",
|
|
filename: undefined as unknown as string,
|
|
contentType: undefined as unknown as string,
|
|
content: JSON.stringify({ width: 10 }) as unknown as Buffer,
|
|
});
|
|
|
|
const res = await postBatch("resize", fields);
|
|
|
|
// @fastify/multipart enforces file limit at parser level
|
|
expect(res.statusCode).toBe(400);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.error).toBeDefined();
|
|
});
|
|
|
|
it("rejects batch with 50 images (5x the limit)", async () => {
|
|
const fields = Array.from({ length: 50 }, (_, i) => ({
|
|
name: "file",
|
|
filename: `batch-50-${i}.png`,
|
|
contentType: "image/png",
|
|
content: PNG_1x1,
|
|
}));
|
|
fields.push({
|
|
name: "settings",
|
|
filename: undefined as unknown as string,
|
|
contentType: undefined as unknown as string,
|
|
content: JSON.stringify({ width: 10 }) as unknown as Buffer,
|
|
});
|
|
|
|
const res = await postBatch("resize", fields);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.error).toBeDefined();
|
|
});
|
|
|
|
it("batch + simultaneous single request -- no corruption when batch is rejected", async () => {
|
|
// Fire a batch that exceeds the limit alongside a valid single request
|
|
const oversizedFields = Array.from({ length: 15 }, (_, i) => ({
|
|
name: "file",
|
|
filename: `over-${i}.png`,
|
|
contentType: "image/png",
|
|
content: PNG_1x1,
|
|
}));
|
|
oversizedFields.push({
|
|
name: "settings",
|
|
filename: undefined as unknown as string,
|
|
contentType: undefined as unknown as string,
|
|
content: JSON.stringify({ width: 10 }) as unknown as Buffer,
|
|
});
|
|
|
|
const [batchRes, singleRes] = await Promise.all([
|
|
postBatch("resize", oversizedFields),
|
|
app.inject(buildToolRequest("resize", PNG_200x150, "single-ok.png", { width: 80 })),
|
|
]);
|
|
|
|
// Batch should be rejected
|
|
expect(batchRes.statusCode).toBe(400);
|
|
|
|
// Single request must succeed unaffected
|
|
expect(singleRes.statusCode).toBe(200);
|
|
const singleBody = JSON.parse(singleRes.body);
|
|
expect(singleBody.jobId).toBeDefined();
|
|
expect(singleBody.downloadUrl).toContain("resize");
|
|
}, 60_000);
|
|
});
|
|
|
|
// ###########################################################################
|
|
// 8. MULTIPART EDGE CASES -- MISSING BOUNDARY, EMPTY BODY, MALFORMED
|
|
// ###########################################################################
|
|
describe("Multipart edge cases -- malformed requests", () => {
|
|
it("rejects request with content-type multipart/form-data but no boundary", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/resize",
|
|
headers: {
|
|
"content-type": "multipart/form-data",
|
|
authorization: `Bearer ${adminToken}`,
|
|
},
|
|
body: Buffer.from("some random data"),
|
|
});
|
|
|
|
// Fastify/multipart should reject malformed multipart
|
|
expect([400, 415]).toContain(res.statusCode);
|
|
});
|
|
|
|
it("rejects request with empty body and multipart content-type", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/resize",
|
|
headers: {
|
|
"content-type": "multipart/form-data; boundary=----TestBoundary",
|
|
authorization: `Bearer ${adminToken}`,
|
|
},
|
|
body: Buffer.alloc(0),
|
|
});
|
|
|
|
expect([400, 415]).toContain(res.statusCode);
|
|
});
|
|
|
|
it("rejects request with form-urlencoded content-type", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/resize",
|
|
headers: {
|
|
"content-type": "application/x-www-form-urlencoded",
|
|
authorization: `Bearer ${adminToken}`,
|
|
},
|
|
body: "width=100&height=100",
|
|
});
|
|
|
|
expect([400, 415]).toContain(res.statusCode);
|
|
});
|
|
|
|
it("rejects request with no content-type header", async () => {
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/resize",
|
|
headers: {
|
|
authorization: `Bearer ${adminToken}`,
|
|
},
|
|
body: Buffer.from("some data"),
|
|
});
|
|
|
|
expect([400, 415]).toContain(res.statusCode);
|
|
});
|
|
});
|
|
|
|
// ###########################################################################
|
|
// 9. MULTIPART WITH EXTRA UNEXPECTED FIELDS
|
|
// Existing tests check extra JSON keys in settings. This tests extra
|
|
// multipart parts that the server should ignore or handle.
|
|
// ###########################################################################
|
|
describe("Multipart with extra unexpected fields", () => {
|
|
it("ignores extra text fields alongside valid file and settings", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
|
|
{ name: "settings", content: JSON.stringify({ width: 100 }) },
|
|
{ name: "extra_field_1", content: "this should be ignored" },
|
|
{ name: "another_extra", content: "also ignored" },
|
|
{ name: "metadata", content: JSON.stringify({ foo: "bar" }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/resize",
|
|
headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` },
|
|
body,
|
|
});
|
|
|
|
// Extra text fields should be ignored; the valid file+settings should process
|
|
expect(res.statusCode).toBe(200);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.jobId).toBeDefined();
|
|
});
|
|
|
|
it("ignores duplicate settings fields (first one wins)", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
|
|
{ name: "settings", content: JSON.stringify({ width: 50 }) },
|
|
{ name: "settings", content: JSON.stringify({ width: 150 }) },
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/resize",
|
|
headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` },
|
|
body,
|
|
});
|
|
|
|
// Should succeed with one of the settings (implementation-defined which)
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it("rejects extra file-like field with non-standard name (treated as second file)", async () => {
|
|
const { body, contentType } = createMultipartPayload([
|
|
{ name: "file", filename: "test.png", content: PNG_200x150, contentType: "image/png" },
|
|
{ name: "settings", content: JSON.stringify({ width: 100 }) },
|
|
{
|
|
name: "attachment",
|
|
filename: "extra.txt",
|
|
content: Buffer.from("text content"),
|
|
contentType: "text/plain",
|
|
},
|
|
]);
|
|
|
|
const res = await app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/resize",
|
|
headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` },
|
|
body,
|
|
});
|
|
|
|
// The tool factory counts any file-like part (with filename) as a file upload.
|
|
// Two file parts trigger the "one image at a time" rejection.
|
|
expect(res.statusCode).toBe(400);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.error).toMatch(/too many files/i);
|
|
});
|
|
});
|
|
|
|
// ###########################################################################
|
|
// 10. CORRUPTED FILE HEADERS -- ADDITIONAL SCENARIOS
|
|
// Fill remaining gaps: valid magic bytes + truncated/corrupted data for
|
|
// GIF and WebP formats, random binary with correct extension.
|
|
// ###########################################################################
|
|
describe("Corrupted file data -- additional formats", () => {
|
|
it("handles GIF magic bytes (GIF89a) followed by garbage", async () => {
|
|
const corruptedGif = Buffer.concat([
|
|
Buffer.from("GIF89a"),
|
|
Buffer.from(Array.from({ length: 100 }, () => Math.floor(Math.random() * 256))),
|
|
]);
|
|
|
|
const res = await postTool("resize", [
|
|
{
|
|
name: "file",
|
|
filename: "corrupt.gif",
|
|
content: corruptedGif,
|
|
contentType: "image/gif",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ width: 50 }) },
|
|
]);
|
|
|
|
// Should fail gracefully
|
|
expect([400, 422]).toContain(res.statusCode);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.error).toBeDefined();
|
|
});
|
|
|
|
it("handles TIFF magic bytes followed by garbage (little-endian)", async () => {
|
|
// TIFF little-endian: 49 49 2A 00
|
|
const corruptedTiff = Buffer.concat([
|
|
Buffer.from([0x49, 0x49, 0x2a, 0x00]),
|
|
Buffer.from(Array.from({ length: 200 }, () => Math.floor(Math.random() * 256))),
|
|
]);
|
|
|
|
const res = await postTool("resize", [
|
|
{
|
|
name: "file",
|
|
filename: "corrupt.tiff",
|
|
content: corruptedTiff,
|
|
contentType: "image/tiff",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ width: 50 }) },
|
|
]);
|
|
|
|
expect([400, 422]).toContain(res.statusCode);
|
|
});
|
|
|
|
it("handles random binary data with .jpg extension (content sniffing rejects it)", async () => {
|
|
// Pure random data -- no valid magic bytes at all
|
|
const randomData = Buffer.from(
|
|
Array.from({ length: 4096 }, () => Math.floor(Math.random() * 256)),
|
|
);
|
|
|
|
const res = await postTool("resize", [
|
|
{
|
|
name: "file",
|
|
filename: "random.jpg",
|
|
content: randomData,
|
|
contentType: "image/jpeg",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ width: 100 }) },
|
|
]);
|
|
|
|
expect([400, 422]).toContain(res.statusCode);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.error).toBeDefined();
|
|
});
|
|
|
|
it("handles a valid PNG truncated at exactly the IHDR chunk", async () => {
|
|
// PNG signature (8 bytes) + IHDR length (4 bytes) + "IHDR" (4 bytes) + partial IHDR data
|
|
// Total: 8 + 4 + 4 + 9 = 25 bytes (IHDR is 13 bytes data, we cut at 9)
|
|
const truncatedAtIHDR = PNG_200x150.subarray(0, 25);
|
|
|
|
const res = await postTool("resize", [
|
|
{
|
|
name: "file",
|
|
filename: "truncated-ihdr.png",
|
|
content: truncatedAtIHDR,
|
|
contentType: "image/png",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ width: 50 }) },
|
|
]);
|
|
|
|
// Has valid PNG signature but incomplete IHDR -- Sharp may reject
|
|
expect([200, 400, 422]).toContain(res.statusCode);
|
|
});
|
|
|
|
it("handles a file that is all 0xFF bytes with .png extension", async () => {
|
|
const allOnes = Buffer.alloc(2048, 0xff);
|
|
|
|
const res = await postTool("compress", [
|
|
{
|
|
name: "file",
|
|
filename: "all-ff.png",
|
|
content: allOnes,
|
|
contentType: "image/png",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ quality: 50 }) },
|
|
]);
|
|
|
|
// 0xFF 0xFF... looks like start of JPEG (0xFF 0xD8...) but is actually garbage
|
|
expect([400, 422]).toContain(res.statusCode);
|
|
});
|
|
});
|
|
|
|
// ###########################################################################
|
|
// 11. UNICODE FILENAMES -- REMAINING UNTESTED PATTERNS
|
|
// Covers RTL Arabic filename, @#$% special chars, picture frame emoji.
|
|
// ###########################################################################
|
|
describe("Unicode filenames -- final coverage", () => {
|
|
it("handles RTL-only filename: صورة.jpg (Arabic for 'image')", async () => {
|
|
const res = await postTool("compress", [
|
|
{
|
|
name: "file",
|
|
filename: "صورة.jpg",
|
|
content: JPG_100x100,
|
|
contentType: "image/jpeg",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ quality: 70 }) },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.downloadUrl).toBeDefined();
|
|
});
|
|
|
|
it("handles image@#$%.jpg filename with special characters", async () => {
|
|
const res = await postTool("resize", [
|
|
{
|
|
name: "file",
|
|
filename: "image@#$%.jpg",
|
|
content: JPG_100x100,
|
|
contentType: "image/jpeg",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ width: 50 }) },
|
|
]);
|
|
|
|
// Should either succeed or be sanitized
|
|
expect(res.statusCode).toBe(200);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.downloadUrl).toBeDefined();
|
|
});
|
|
|
|
it("handles picture frame emoji filename: \u{1F5BC}\u{FE0F}_test.jpg", async () => {
|
|
const res = await postTool("rotate", [
|
|
{
|
|
name: "file",
|
|
filename: "\u{1F5BC}\u{FE0F}_test.jpg",
|
|
content: JPG_100x100,
|
|
contentType: "image/jpeg",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ angle: 90 }) },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.downloadUrl).toBeDefined();
|
|
});
|
|
|
|
it("handles filename with mixed scripts: テスト画像.jpg (Japanese for 'test image')", async () => {
|
|
const res = await postTool("compress", [
|
|
{
|
|
name: "file",
|
|
filename: "テスト画像.jpg",
|
|
content: JPG_100x100,
|
|
contentType: "image/jpeg",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ quality: 60 }) },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it("handles 'my test image.jpg' with spaces through compress", async () => {
|
|
const res = await postTool("compress", [
|
|
{
|
|
name: "file",
|
|
filename: "my test image.jpg",
|
|
content: JPG_100x100,
|
|
contentType: "image/jpeg",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ quality: 50 }) },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it("handles filename with 300+ chars through compress (beyond 255 limit)", async () => {
|
|
const longName = `${"z".repeat(300)}.png`;
|
|
const res = await postTool("compress", [
|
|
{
|
|
name: "file",
|
|
filename: longName,
|
|
content: PNG_200x150,
|
|
contentType: "image/png",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ quality: 50 }) },
|
|
]);
|
|
|
|
// sanitizeFilename truncates but does not crash
|
|
expect(res.statusCode).toBe(200);
|
|
const json = JSON.parse(res.body);
|
|
expect(json.downloadUrl).toBeDefined();
|
|
});
|
|
});
|
|
|
|
// ###########################################################################
|
|
// 12. 1x1 PIXEL IMAGE THROUGH REMAINING UNTESTED TOOLS
|
|
// ###########################################################################
|
|
describe("1x1 pixel image through remaining tools", () => {
|
|
it("applies adjust-colors to 1x1 image", async () => {
|
|
const res = await postTool("adjust-colors", [
|
|
{
|
|
name: "file",
|
|
filename: "tiny.png",
|
|
content: PNG_1x1,
|
|
contentType: "image/png",
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ brightness: 50, contrast: 50 }),
|
|
},
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it("strips metadata from 1x1 image (no-op)", async () => {
|
|
const res = await postTool("strip-metadata", [
|
|
{
|
|
name: "file",
|
|
filename: "tiny.png",
|
|
content: PNG_1x1,
|
|
contentType: "image/png",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ stripAll: true }) },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it("generates favicon from 1x1 image (upscales)", async () => {
|
|
const res = await postTool("favicon", [
|
|
{
|
|
name: "file",
|
|
filename: "tiny.png",
|
|
content: PNG_1x1,
|
|
contentType: "image/png",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({}) },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it("extracts color palette from 1x1 image (single color)", async () => {
|
|
const res = await postTool("color-palette", [
|
|
{
|
|
name: "file",
|
|
filename: "tiny.png",
|
|
content: PNG_1x1,
|
|
contentType: "image/png",
|
|
},
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
|
|
it("converts 1x1 PNG to WebP", async () => {
|
|
const res = await postTool("convert", [
|
|
{
|
|
name: "file",
|
|
filename: "tiny.png",
|
|
content: PNG_1x1,
|
|
contentType: "image/png",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ format: "webp" }) },
|
|
]);
|
|
|
|
expect([200, 400]).toContain(res.statusCode);
|
|
});
|
|
|
|
it("converts 1x1 PNG to AVIF", async () => {
|
|
const res = await postTool("convert", [
|
|
{
|
|
name: "file",
|
|
filename: "tiny.png",
|
|
content: PNG_1x1,
|
|
contentType: "image/png",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ format: "avif" }) },
|
|
]);
|
|
|
|
expect([200, 400]).toContain(res.statusCode);
|
|
});
|
|
});
|
|
|
|
// ###########################################################################
|
|
// 13. SETTINGS WITH TYPE MISMATCHES AND EDGE VALUES
|
|
// ###########################################################################
|
|
describe("Settings with type mismatches and edge values", () => {
|
|
it("rejects resize with width as array", async () => {
|
|
const res = await postTool("resize", [
|
|
{
|
|
name: "file",
|
|
filename: "test.png",
|
|
content: PNG_200x150,
|
|
contentType: "image/png",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ width: [100] }) },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejects resize with width as object", async () => {
|
|
const res = await postTool("resize", [
|
|
{
|
|
name: "file",
|
|
filename: "test.png",
|
|
content: PNG_200x150,
|
|
contentType: "image/png",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ width: { value: 100 } }) },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejects rotate with angle as boolean", async () => {
|
|
const res = await postTool("rotate", [
|
|
{
|
|
name: "file",
|
|
filename: "test.png",
|
|
content: PNG_200x150,
|
|
contentType: "image/png",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ angle: false }) },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejects compress quality as empty string", async () => {
|
|
const res = await postTool("compress", [
|
|
{
|
|
name: "file",
|
|
filename: "test.png",
|
|
content: PNG_200x150,
|
|
contentType: "image/png",
|
|
},
|
|
{ name: "settings", content: '{"quality": ""}' },
|
|
]);
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("rejects convert with truly unsupported format", async () => {
|
|
const res = await postTool("convert", [
|
|
{
|
|
name: "file",
|
|
filename: "test.png",
|
|
content: PNG_200x150,
|
|
contentType: "image/png",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ format: "hevc" }) },
|
|
]);
|
|
|
|
// "hevc" is not in the z.enum list of supported formats
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it("handles settings with unicode key names", async () => {
|
|
const res = await postTool("resize", [
|
|
{
|
|
name: "file",
|
|
filename: "test.png",
|
|
content: PNG_200x150,
|
|
contentType: "image/png",
|
|
},
|
|
{
|
|
name: "settings",
|
|
content: JSON.stringify({ width: 100, 幅: 200 }),
|
|
},
|
|
]);
|
|
|
|
// Zod strips unknown keys; the recognized "width: 100" should process
|
|
expect(res.statusCode).toBe(200);
|
|
});
|
|
});
|
|
|
|
// ###########################################################################
|
|
// 14. CONCURRENT BATCH + SINGLE REQUEST SIMULTANEOUSLY
|
|
// ###########################################################################
|
|
describe("Concurrent batch + single request -- extended scenarios", () => {
|
|
it("runs batch compress + single resize + single rotate simultaneously", async () => {
|
|
const batchPayload = createMultipartPayload([
|
|
{ name: "file", filename: "b1.png", content: PNG_200x150, contentType: "image/png" },
|
|
{ name: "file", filename: "b2.jpg", content: JPG_100x100, contentType: "image/jpeg" },
|
|
{ name: "file", filename: "b3.webp", content: WEBP_50x50, contentType: "image/webp" },
|
|
{ name: "settings", content: JSON.stringify({ quality: 50 }) },
|
|
]);
|
|
|
|
const [batchRes, resizeRes, rotateRes] = await Promise.all([
|
|
app.inject({
|
|
method: "POST",
|
|
url: "/api/v1/tools/image/compress/batch",
|
|
headers: {
|
|
"content-type": batchPayload.contentType,
|
|
authorization: `Bearer ${adminToken}`,
|
|
},
|
|
body: batchPayload.body,
|
|
}),
|
|
app.inject(buildToolRequest("resize", PNG_200x150, "single-resize.png", { width: 60 })),
|
|
app.inject(
|
|
buildToolRequest("rotate", JPG_100x100, "single-rotate.jpg", { angle: 270 }, "image/jpeg"),
|
|
),
|
|
]);
|
|
|
|
// All must succeed
|
|
expect(batchRes.statusCode).toBe(200);
|
|
expect(batchRes.headers["content-type"]).toBe("application/zip");
|
|
|
|
expect(resizeRes.statusCode).toBe(200);
|
|
const resizeBody = JSON.parse(resizeRes.body);
|
|
expect(resizeBody.downloadUrl).toContain("resize");
|
|
|
|
expect(rotateRes.statusCode).toBe(200);
|
|
const rotateBody = JSON.parse(rotateRes.body);
|
|
expect(rotateBody.downloadUrl).toContain("rotate");
|
|
|
|
// Job IDs must be distinct
|
|
expect(resizeBody.jobId).not.toBe(rotateBody.jobId);
|
|
}, 60_000);
|
|
});
|
|
|
|
// ###########################################################################
|
|
// 15. SERVER STABILITY AFTER COMPREHENSIVE COVERAGE GAP TESTS
|
|
// ###########################################################################
|
|
describe("Server stability -- health check after all coverage gap tests", () => {
|
|
it("server remains responsive and healthy", async () => {
|
|
const healthRes = await app.inject({
|
|
method: "GET",
|
|
url: "/api/v1/health",
|
|
});
|
|
expect(healthRes.statusCode).toBe(200);
|
|
const json = JSON.parse(healthRes.body);
|
|
expect(json.status).toBe("healthy");
|
|
|
|
// Verify a normal tool request still works
|
|
const normalRes = await postTool("resize", [
|
|
{
|
|
name: "file",
|
|
filename: "final-sanity.png",
|
|
content: PNG_200x150,
|
|
contentType: "image/png",
|
|
},
|
|
{ name: "settings", content: JSON.stringify({ width: 100 }) },
|
|
]);
|
|
expect(normalRes.statusCode).toBe(200);
|
|
expect(JSON.parse(normalRes.body).jobId).toBeDefined();
|
|
});
|
|
});
|