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.
1196 lines
40 KiB
TypeScript
1196 lines
40 KiB
TypeScript
/**
|
||
* Final adversarial gap-closing integration tests for the SnapOtter image API.
|
||
*
|
||
* Targets 10 specific gap categories identified after auditing all existing
|
||
* adversarial, edge-case, concurrent, and security test files:
|
||
*
|
||
* 1. Concurrent requests: 10 simultaneous uploads with pixel-level output verification
|
||
* 2. Memory pressure: sequential processing of 6.7MB stress-large.jpg with timing checks
|
||
* 3. Zero-byte files: Buffer.alloc(0) through untested tools (beautify, optimize-for-web, etc.)
|
||
* 4. Corrupted magic bytes: PNG header on JPEG data, JPEG header on PNG data cross-injection
|
||
* 5. Wrong extension: .jpg containing PNG data verified through download + Sharp metadata
|
||
* 6. Unicode filenames: combining marks, BOM, supplementary plane, mixed-direction
|
||
* 7. Extreme dimensions: 1x1 pixel through untested tools (beautify, replace-color, split, etc.)
|
||
* 8. Pipeline edge cases: 50-step deep chain, non-existent tool mid-chain, all-same-tool pipeline
|
||
* 9. Batch limits: 50+ images, batch with only corrupted files, batch with large + tiny mix
|
||
* 10. Simultaneous batch + single + pipeline requests with cross-contamination checks
|
||
*/
|
||
|
||
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 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> }>;
|
||
},
|
||
imgContentType = "image/png",
|
||
) {
|
||
const { body, contentType } = createMultipartPayload([
|
||
{ name: "file", filename, content: image, contentType: imgContentType },
|
||
{ 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 REQUESTS: 10 SIMULTANEOUS TO SAME TOOL WITH PIXEL VERIFICATION
|
||
// Prior tests verify job IDs and URLs are unique but only spot-check pixel
|
||
// dimensions on a few. This verifies ALL 10 downloads have correct pixels.
|
||
// ###########################################################################
|
||
describe("Concurrent: 10 simultaneous uploads to compress with output integrity", () => {
|
||
it("10 concurrent compress requests with different quality levels produce distinct file sizes", async () => {
|
||
const qualities = [10, 20, 30, 40, 50, 60, 70, 80, 90, 100];
|
||
|
||
// Use JPEG input so quality settings produce meaningful size differences
|
||
// (PNG is lossless, so quality has no effect on output size)
|
||
const results = await Promise.all(
|
||
qualities.map((quality, i) =>
|
||
app.inject(
|
||
buildToolRequest(
|
||
"compress",
|
||
JPG_100x100,
|
||
`compress-q${quality}-${i}.jpg`,
|
||
{ quality },
|
||
"image/jpeg",
|
||
),
|
||
),
|
||
),
|
||
);
|
||
|
||
// All 10 must succeed
|
||
for (const res of results) {
|
||
expect(res.statusCode).toBe(200);
|
||
}
|
||
|
||
// Unique job IDs
|
||
const jobIds = results.map((r) => JSON.parse(r.body).jobId);
|
||
expect(new Set(jobIds).size).toBe(10);
|
||
|
||
// Download ALL 10 outputs and verify they are valid images
|
||
const downloadedSizes: number[] = [];
|
||
for (const res of results) {
|
||
const downloadUrl = JSON.parse(res.body).downloadUrl;
|
||
const dlRes = await app.inject({
|
||
method: "GET",
|
||
url: downloadUrl,
|
||
headers: { authorization: `Bearer ${adminToken}` },
|
||
});
|
||
expect(dlRes.statusCode).toBe(200);
|
||
const meta = await sharp(dlRes.rawPayload).metadata();
|
||
expect(meta.width).toBeGreaterThan(0);
|
||
expect(meta.height).toBeGreaterThan(0);
|
||
downloadedSizes.push(dlRes.rawPayload.length);
|
||
}
|
||
|
||
// Lower quality JPEG outputs should be smaller than higher quality.
|
||
// q=10 must produce a smaller file than q=100.
|
||
expect(downloadedSizes[0]).toBeLessThanOrEqual(downloadedSizes[9]);
|
||
}, 120_000);
|
||
|
||
it("10 concurrent rotate requests with different angles produce valid rotated outputs", async () => {
|
||
const angles = [0, 36, 72, 108, 144, 180, 216, 252, 288, 324];
|
||
|
||
const results = await Promise.all(
|
||
angles.map((angle, i) =>
|
||
app.inject(buildToolRequest("rotate", PNG_200x150, `rotate-${angle}-${i}.png`, { angle })),
|
||
),
|
||
);
|
||
|
||
for (const res of results) {
|
||
expect(res.statusCode).toBe(200);
|
||
}
|
||
|
||
// All job IDs unique
|
||
const jobIds = results.map((r) => JSON.parse(r.body).jobId);
|
||
expect(new Set(jobIds).size).toBe(10);
|
||
|
||
// Verify 180-degree rotation: width/height should stay the same
|
||
const dl180 = await app.inject({
|
||
method: "GET",
|
||
url: JSON.parse(results[5].body).downloadUrl,
|
||
headers: { authorization: `Bearer ${adminToken}` },
|
||
});
|
||
const meta180 = await sharp(dl180.rawPayload).metadata();
|
||
expect(meta180.width).toBe(200);
|
||
expect(meta180.height).toBe(150);
|
||
}, 120_000);
|
||
});
|
||
|
||
// ###########################################################################
|
||
// 2. MEMORY PRESSURE: SEQUENTIAL STRESS-LARGE.JPG WITH TIMING DEGRADATION
|
||
// Coverage-gaps has 50 sequential uploads. This tests 10 with strict timing
|
||
// ratio enforcement and verifies output correctness of each.
|
||
// ###########################################################################
|
||
describe("Memory pressure: 10 sequential stress-large.jpg with strict timing checks", () => {
|
||
it("processes stress-large.jpg through compress 10 times with bounded response time growth", async () => {
|
||
const responseTimes: number[] = [];
|
||
const outputSizes: number[] = [];
|
||
|
||
for (let i = 0; i < 10; i++) {
|
||
const start = Date.now();
|
||
const res = await postTool("compress", [
|
||
{
|
||
name: "file",
|
||
filename: `stress-compress-${i}.jpg`,
|
||
content: STRESS_LARGE,
|
||
contentType: "image/jpeg",
|
||
},
|
||
{ name: "settings", content: JSON.stringify({ quality: 50 }) },
|
||
]);
|
||
const elapsed = Date.now() - start;
|
||
responseTimes.push(elapsed);
|
||
|
||
expect(res.statusCode).toBe(200);
|
||
const json = JSON.parse(res.body);
|
||
expect(json.processedSize).toBeLessThan(json.originalSize);
|
||
outputSizes.push(json.processedSize);
|
||
}
|
||
|
||
// All output sizes should be identical (same input + same settings = deterministic)
|
||
const uniqueSizes = new Set(outputSizes);
|
||
expect(uniqueSizes.size).toBe(1);
|
||
|
||
// Timing: last 3 requests should not take more than 3x the average of the first 3
|
||
const first3Avg = responseTimes.slice(0, 3).reduce((a, b) => a + b, 0) / 3;
|
||
const last3Avg = responseTimes.slice(7, 10).reduce((a, b) => a + b, 0) / 3;
|
||
expect(last3Avg).toBeLessThan(first3Avg * 3);
|
||
}, 300_000);
|
||
|
||
it("processes stress-large.jpg through border without crash", async () => {
|
||
const res = await postTool("border", [
|
||
{
|
||
name: "file",
|
||
filename: "stress-border.jpg",
|
||
content: STRESS_LARGE,
|
||
contentType: "image/jpeg",
|
||
},
|
||
{ name: "settings", content: JSON.stringify({ borderWidth: 20 }) },
|
||
]);
|
||
|
||
expect(res.statusCode).toBe(200);
|
||
const json = JSON.parse(res.body);
|
||
// Adding a border makes the image larger
|
||
expect(json.processedSize).toBeGreaterThan(0);
|
||
}, 60_000);
|
||
});
|
||
|
||
// ###########################################################################
|
||
// 3. ZERO-BYTE FILES: THROUGH UNTESTED TOOLS
|
||
// Prior tests cover resize, compress, convert, rotate, crop, border,
|
||
// strip-metadata, text-overlay, color-palette, favicon. These hit the rest.
|
||
// ###########################################################################
|
||
describe("Zero-byte files through previously untested tools", () => {
|
||
it("rejects zero-byte file to beautify with 400", async () => {
|
||
const res = await postTool("beautify", [
|
||
{
|
||
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();
|
||
});
|
||
|
||
it("rejects zero-byte file to optimize-for-web with 400", async () => {
|
||
const res = await postTool("optimize-for-web", [
|
||
{
|
||
name: "file",
|
||
filename: "empty.jpg",
|
||
content: Buffer.alloc(0),
|
||
contentType: "image/jpeg",
|
||
},
|
||
{ name: "settings", content: JSON.stringify({}) },
|
||
]);
|
||
|
||
expect(res.statusCode).toBe(400);
|
||
const json = JSON.parse(res.body);
|
||
expect(json.error).toBeDefined();
|
||
});
|
||
|
||
it("rejects zero-byte file to sharpening with 400", async () => {
|
||
const res = await postTool("sharpening", [
|
||
{
|
||
name: "file",
|
||
filename: "empty.png",
|
||
content: Buffer.alloc(0),
|
||
contentType: "image/png",
|
||
},
|
||
{ name: "settings", content: JSON.stringify({ method: "adaptive" }) },
|
||
]);
|
||
|
||
expect(res.statusCode).toBe(400);
|
||
const json = JSON.parse(res.body);
|
||
expect(json.error).toBeDefined();
|
||
});
|
||
|
||
it("rejects zero-byte file to rotate with 400", async () => {
|
||
const res = await postTool("rotate", [
|
||
{
|
||
name: "file",
|
||
filename: "empty.webp",
|
||
content: Buffer.alloc(0),
|
||
contentType: "image/webp",
|
||
},
|
||
{ name: "settings", content: JSON.stringify({ angle: 90 }) },
|
||
]);
|
||
|
||
expect(res.statusCode).toBe(400);
|
||
const json = JSON.parse(res.body);
|
||
expect(json.error).toBeDefined();
|
||
});
|
||
|
||
it("rejects zero-byte file to info with 400", async () => {
|
||
const res = await postTool("info", [
|
||
{
|
||
name: "file",
|
||
filename: "empty.png",
|
||
content: Buffer.alloc(0),
|
||
contentType: "image/png",
|
||
},
|
||
]);
|
||
|
||
expect(res.statusCode).toBe(400);
|
||
const json = JSON.parse(res.body);
|
||
expect(json.error).toBeDefined();
|
||
});
|
||
|
||
it("handles zero-byte file to image-to-base64 gracefully", async () => {
|
||
// image-to-base64 converts the raw buffer to base64 without Sharp decoding,
|
||
// so it may accept an empty file (returning empty base64) rather than 400.
|
||
const res = await postTool("image-to-base64", [
|
||
{
|
||
name: "file",
|
||
filename: "empty.png",
|
||
content: Buffer.alloc(0),
|
||
contentType: "image/png",
|
||
},
|
||
{ name: "settings", content: JSON.stringify({}) },
|
||
]);
|
||
|
||
// Either rejected (400) or accepted with empty result (200)
|
||
expect([200, 400]).toContain(res.statusCode);
|
||
});
|
||
});
|
||
|
||
// ###########################################################################
|
||
// 4. CORRUPTED MAGIC BYTES: CROSS-FORMAT HEADER INJECTION
|
||
// Existing tests cover JPEG-magic+garbage and PNG-magic+garbage. These test
|
||
// the specific cross-injection: PNG header bytes on real JPEG data and
|
||
// JPEG header bytes on real PNG data.
|
||
// ###########################################################################
|
||
describe("Corrupted magic bytes: cross-format header injection", () => {
|
||
it("handles PNG magic bytes prepended to real JPEG data gracefully", async () => {
|
||
// PNG signature (8 bytes) prepended to actual JPEG data
|
||
const pngHeaderOnJpeg = Buffer.concat([
|
||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
||
JPG_100x100,
|
||
]);
|
||
|
||
const res = await postTool("resize", [
|
||
{
|
||
name: "file",
|
||
filename: "png-header-jpeg-data.png",
|
||
content: pngHeaderOnJpeg,
|
||
contentType: "image/png",
|
||
},
|
||
{ name: "settings", content: JSON.stringify({ width: 50 }) },
|
||
]);
|
||
|
||
// Sharp may read the PNG header, fail to parse IHDR, and reject.
|
||
// Or it may skip to the JPEG data. Either way, must not crash.
|
||
expect([200, 400, 422]).toContain(res.statusCode);
|
||
});
|
||
|
||
it("handles JPEG SOI marker prepended to real PNG data gracefully", async () => {
|
||
// JPEG start-of-image (FF D8 FF E0) prepended to actual PNG data
|
||
const jpegHeaderOnPng = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0]), PNG_200x150]);
|
||
|
||
const res = await postTool("resize", [
|
||
{
|
||
name: "file",
|
||
filename: "jpeg-header-png-data.jpg",
|
||
content: jpegHeaderOnPng,
|
||
contentType: "image/jpeg",
|
||
},
|
||
{ name: "settings", content: JSON.stringify({ width: 100 }) },
|
||
]);
|
||
|
||
// Sharp should try to decode as JPEG, fail, and return error gracefully
|
||
expect([200, 400, 422]).toContain(res.statusCode);
|
||
});
|
||
|
||
it("handles WebP RIFF header prepended to real JPEG data gracefully", async () => {
|
||
// WebP starts with "RIFF" + size + "WEBP"
|
||
const webpHeaderOnJpeg = Buffer.concat([
|
||
Buffer.from("RIFF"),
|
||
Buffer.from([0x00, 0x00, 0x00, 0x00]),
|
||
Buffer.from("WEBP"),
|
||
JPG_100x100,
|
||
]);
|
||
|
||
const res = await postTool("compress", [
|
||
{
|
||
name: "file",
|
||
filename: "webp-header-jpeg-data.webp",
|
||
content: webpHeaderOnJpeg,
|
||
contentType: "image/webp",
|
||
},
|
||
{ name: "settings", content: JSON.stringify({ quality: 50 }) },
|
||
]);
|
||
|
||
expect([200, 400, 422]).toContain(res.statusCode);
|
||
});
|
||
|
||
it("handles GIF magic followed by real PNG data gracefully", async () => {
|
||
// GIF89a header on PNG data
|
||
const gifHeaderOnPng = Buffer.concat([Buffer.from("GIF89a"), PNG_200x150]);
|
||
|
||
const res = await postTool("resize", [
|
||
{
|
||
name: "file",
|
||
filename: "gif-header-png-data.gif",
|
||
content: gifHeaderOnPng,
|
||
contentType: "image/gif",
|
||
},
|
||
{ name: "settings", content: JSON.stringify({ width: 50 }) },
|
||
]);
|
||
|
||
expect([200, 400, 422]).toContain(res.statusCode);
|
||
});
|
||
|
||
it("handles truncated PNG signature (only 4 of 8 bytes) with real image after", async () => {
|
||
// Partial PNG signature + real JPEG
|
||
const partialPngOnJpeg = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47]), JPG_100x100]);
|
||
|
||
const res = await postTool("compress", [
|
||
{
|
||
name: "file",
|
||
filename: "partial-png-jpeg.png",
|
||
content: partialPngOnJpeg,
|
||
contentType: "image/png",
|
||
},
|
||
{ name: "settings", content: JSON.stringify({ quality: 70 }) },
|
||
]);
|
||
|
||
expect([200, 400, 422]).toContain(res.statusCode);
|
||
});
|
||
});
|
||
|
||
// ###########################################################################
|
||
// 5. WRONG EXTENSION: .jpg CONTAINING PNG DATA WITH DOWNLOAD VERIFICATION
|
||
// Prior tests check status codes but never download + verify format metadata.
|
||
// ###########################################################################
|
||
describe("Wrong extension: format detection verified through download", () => {
|
||
it("processes .jpg file containing PNG data and output has correct dimensions", async () => {
|
||
const res = await postTool("resize", [
|
||
{
|
||
name: "file",
|
||
filename: "actually-png.jpg",
|
||
content: PNG_200x150,
|
||
contentType: "image/jpeg",
|
||
},
|
||
{ name: "settings", content: JSON.stringify({ width: 75 }) },
|
||
]);
|
||
|
||
expect(res.statusCode).toBe(200);
|
||
const json = JSON.parse(res.body);
|
||
|
||
// Download and verify
|
||
const dlRes = await app.inject({
|
||
method: "GET",
|
||
url: json.downloadUrl,
|
||
headers: { authorization: `Bearer ${adminToken}` },
|
||
});
|
||
expect(dlRes.statusCode).toBe(200);
|
||
const meta = await sharp(dlRes.rawPayload).metadata();
|
||
expect(meta.width).toBe(75);
|
||
});
|
||
|
||
it("processes .png file containing JPEG data and output has correct dimensions", async () => {
|
||
const res = await postTool("resize", [
|
||
{
|
||
name: "file",
|
||
filename: "actually-jpeg.png",
|
||
content: JPG_100x100,
|
||
contentType: "image/png",
|
||
},
|
||
{ name: "settings", content: JSON.stringify({ width: 40 }) },
|
||
]);
|
||
|
||
expect(res.statusCode).toBe(200);
|
||
const json = JSON.parse(res.body);
|
||
|
||
const dlRes = await app.inject({
|
||
method: "GET",
|
||
url: json.downloadUrl,
|
||
headers: { authorization: `Bearer ${adminToken}` },
|
||
});
|
||
expect(dlRes.statusCode).toBe(200);
|
||
const meta = await sharp(dlRes.rawPayload).metadata();
|
||
expect(meta.width).toBe(40);
|
||
});
|
||
|
||
it("processes .gif file containing WebP data through compress", async () => {
|
||
const res = await postTool("compress", [
|
||
{
|
||
name: "file",
|
||
filename: "actually-webp.gif",
|
||
content: WEBP_50x50,
|
||
contentType: "image/gif",
|
||
},
|
||
{ name: "settings", content: JSON.stringify({ quality: 60 }) },
|
||
]);
|
||
|
||
expect(res.statusCode).toBe(200);
|
||
const json = JSON.parse(res.body);
|
||
|
||
const dlRes = await app.inject({
|
||
method: "GET",
|
||
url: json.downloadUrl,
|
||
headers: { authorization: `Bearer ${adminToken}` },
|
||
});
|
||
expect(dlRes.statusCode).toBe(200);
|
||
const meta = await sharp(dlRes.rawPayload).metadata();
|
||
expect(meta.width).toBe(50);
|
||
expect(meta.height).toBe(50);
|
||
});
|
||
});
|
||
|
||
// ###########################################################################
|
||
// 6. UNICODE FILENAMES: PREVIOUSLY UNTESTED UNICODE PATTERNS
|
||
// Prior tests cover: emoji, CJK, RTL, path traversal, null bytes, ZWJ,
|
||
// spaces, special chars, long names. These test remaining patterns.
|
||
// ###########################################################################
|
||
describe("Unicode filenames: remaining untested patterns", () => {
|
||
it("handles filename with Unicode BOM prefix: \\uFEFFphoto.png", async () => {
|
||
const res = await postTool("resize", [
|
||
{
|
||
name: "file",
|
||
filename: "photo.png",
|
||
content: PNG_200x150,
|
||
contentType: "image/png",
|
||
},
|
||
{ name: "settings", content: JSON.stringify({ width: 80 }) },
|
||
]);
|
||
|
||
expect(res.statusCode).toBe(200);
|
||
const json = JSON.parse(res.body);
|
||
expect(json.downloadUrl).toBeDefined();
|
||
});
|
||
|
||
it("handles filename with combining diacritical marks: café.png (e + acute)", async () => {
|
||
const res = await postTool("compress", [
|
||
{
|
||
name: "file",
|
||
filename: "café.png",
|
||
content: PNG_200x150,
|
||
contentType: "image/png",
|
||
},
|
||
{ name: "settings", content: JSON.stringify({ quality: 80 }) },
|
||
]);
|
||
|
||
expect(res.statusCode).toBe(200);
|
||
});
|
||
|
||
it("handles filename with supplementary plane characters: \u{1D11E}.png (musical symbol)", async () => {
|
||
const res = await postTool("resize", [
|
||
{
|
||
name: "file",
|
||
filename: "\u{1D11E}.png",
|
||
content: PNG_200x150,
|
||
contentType: "image/png",
|
||
},
|
||
{ name: "settings", content: JSON.stringify({ width: 100 }) },
|
||
]);
|
||
|
||
expect(res.statusCode).toBe(200);
|
||
});
|
||
|
||
it("handles filename with mixed RTL and LTR with numbers: img_123_ملف.png", async () => {
|
||
const res = await postTool("rotate", [
|
||
{
|
||
name: "file",
|
||
filename: "img_123_ملف.png",
|
||
content: PNG_200x150,
|
||
contentType: "image/png",
|
||
},
|
||
{ name: "settings", content: JSON.stringify({ angle: 90 }) },
|
||
]);
|
||
|
||
expect(res.statusCode).toBe(200);
|
||
});
|
||
|
||
it("handles filename with Thai script: ภาพ.png", async () => {
|
||
const res = await postTool("compress", [
|
||
{
|
||
name: "file",
|
||
filename: "ภาพ.png",
|
||
content: PNG_200x150,
|
||
contentType: "image/png",
|
||
},
|
||
{ name: "settings", content: JSON.stringify({ quality: 70 }) },
|
||
]);
|
||
|
||
expect(res.statusCode).toBe(200);
|
||
});
|
||
|
||
it("handles filename with Cyrillic script: фото.png (foto)", async () => {
|
||
const res = await postTool("resize", [
|
||
{
|
||
name: "file",
|
||
filename: "фото.png",
|
||
content: PNG_200x150,
|
||
contentType: "image/png",
|
||
},
|
||
{ name: "settings", content: JSON.stringify({ width: 50 }) },
|
||
]);
|
||
|
||
expect(res.statusCode).toBe(200);
|
||
});
|
||
});
|
||
|
||
// ###########################################################################
|
||
// 7. EXTREME DIMENSIONS: 1x1 PIXEL THROUGH UNTESTED TOOLS
|
||
// Prior tests cover: resize, crop, rotate, compress, convert, border,
|
||
// strip-metadata, adjust-colors, favicon, color-palette, text-overlay,
|
||
// watermark-text, sharpening, image-to-base64. These hit the remaining ones.
|
||
// ###########################################################################
|
||
describe("1x1 pixel image through previously untested tools", () => {
|
||
it("beautifies a 1x1 pixel image without crash", async () => {
|
||
const res = await postTool("beautify", [
|
||
{
|
||
name: "file",
|
||
filename: "tiny.png",
|
||
content: PNG_1x1,
|
||
contentType: "image/png",
|
||
},
|
||
{ name: "settings", content: JSON.stringify({}) },
|
||
]);
|
||
|
||
expect([200, 422]).toContain(res.statusCode);
|
||
});
|
||
|
||
it("optimizes a 1x1 pixel image for web without crash", async () => {
|
||
const res = await postTool("optimize-for-web", [
|
||
{
|
||
name: "file",
|
||
filename: "tiny.png",
|
||
content: PNG_1x1,
|
||
contentType: "image/png",
|
||
},
|
||
{ name: "settings", content: JSON.stringify({}) },
|
||
]);
|
||
|
||
expect([200, 422]).toContain(res.statusCode);
|
||
});
|
||
|
||
it("replaces color in a 1x1 pixel image without crash", async () => {
|
||
const res = await postTool("replace-color", [
|
||
{
|
||
name: "file",
|
||
filename: "tiny.png",
|
||
content: PNG_1x1,
|
||
contentType: "image/png",
|
||
},
|
||
{
|
||
name: "settings",
|
||
content: JSON.stringify({
|
||
targetColor: "#000000",
|
||
replacementColor: "#FF0000",
|
||
tolerance: 50,
|
||
}),
|
||
},
|
||
]);
|
||
|
||
// May succeed or reject due to tiny image
|
||
expect([200, 400, 422]).toContain(res.statusCode);
|
||
});
|
||
|
||
it("gets info for a 1x1 pixel image", async () => {
|
||
const res = await postTool("info", [
|
||
{
|
||
name: "file",
|
||
filename: "tiny.png",
|
||
content: PNG_1x1,
|
||
contentType: "image/png",
|
||
},
|
||
]);
|
||
|
||
expect(res.statusCode).toBe(200);
|
||
const json = JSON.parse(res.body);
|
||
expect(json.width).toBe(1);
|
||
expect(json.height).toBe(1);
|
||
});
|
||
|
||
it("converts 1x1 pixel PNG to JPEG without crash", async () => {
|
||
const res = await postTool("convert", [
|
||
{
|
||
name: "file",
|
||
filename: "tiny.png",
|
||
content: PNG_1x1,
|
||
contentType: "image/png",
|
||
},
|
||
{ name: "settings", content: JSON.stringify({ format: "jpg" }) },
|
||
]);
|
||
|
||
expect([200, 400]).toContain(res.statusCode);
|
||
});
|
||
|
||
it("adds text overlay to 1x1 pixel image without crash", async () => {
|
||
const res = await postTool("text-overlay", [
|
||
{
|
||
name: "file",
|
||
filename: "tiny.png",
|
||
content: PNG_1x1,
|
||
contentType: "image/png",
|
||
},
|
||
{
|
||
name: "settings",
|
||
content: JSON.stringify({ text: "X", fontSize: 10 }),
|
||
},
|
||
]);
|
||
|
||
expect([200, 422]).toContain(res.statusCode);
|
||
});
|
||
|
||
it("processes 1x1 pixel through pipeline of 3 steps without crash", async () => {
|
||
const res = await executePipeline(PNG_1x1, "tiny-pipeline.png", {
|
||
steps: [
|
||
{ toolId: "resize", settings: { width: 100, height: 100 } },
|
||
{ toolId: "compress", settings: { quality: 80 } },
|
||
{ toolId: "border", settings: { borderWidth: 5 } },
|
||
],
|
||
});
|
||
|
||
expect(res.statusCode).toBe(200);
|
||
const json = JSON.parse(res.body);
|
||
expect(json.stepsCompleted).toBe(3);
|
||
});
|
||
});
|
||
|
||
// ###########################################################################
|
||
// 8. PIPELINE EDGE CASES: DEEP CHAINS, MID-CHAIN FAILURES, ALL-SAME
|
||
// Prior tests cover up to 25 steps, unknown tools, and format conversions.
|
||
// These test 50-step chains, failure recovery, and degenerate pipelines.
|
||
// ###########################################################################
|
||
describe("Pipeline edge cases: deep chains and degenerate cases", () => {
|
||
it("handles pipeline with 50 compress steps (deep chain)", async () => {
|
||
const steps = Array.from({ length: 50 }, () => ({
|
||
toolId: "compress",
|
||
settings: { quality: 99 },
|
||
}));
|
||
|
||
const res = await executePipeline(PNG_200x150, "deep-50.png", { steps });
|
||
|
||
// Should succeed (no MAX_PIPELINE_STEPS in test env) or be rejected
|
||
expect([200, 400]).toContain(res.statusCode);
|
||
if (res.statusCode === 200) {
|
||
const json = JSON.parse(res.body);
|
||
expect(json.stepsCompleted).toBe(50);
|
||
}
|
||
}, 120_000);
|
||
|
||
it("pipeline fails at step 3 (unknown tool) after 2 valid steps", async () => {
|
||
const res = await executePipeline(PNG_200x150, "mid-fail.png", {
|
||
steps: [
|
||
{ toolId: "resize", settings: { width: 100 } },
|
||
{ toolId: "compress", settings: { quality: 80 } },
|
||
{ toolId: "nonexistent-tool-xyz" },
|
||
],
|
||
});
|
||
|
||
// Should fail with clear error about the unknown tool
|
||
expect(res.statusCode).toBe(400);
|
||
const json = JSON.parse(res.body);
|
||
expect(json.error).toBeDefined();
|
||
});
|
||
|
||
it("pipeline with all border steps (10 consecutive borders)", async () => {
|
||
const steps = Array.from({ length: 10 }, () => ({
|
||
toolId: "border",
|
||
settings: { borderWidth: 2 },
|
||
}));
|
||
|
||
const res = await executePipeline(PNG_200x150, "all-borders.png", { steps });
|
||
|
||
expect(res.statusCode).toBe(200);
|
||
const json = JSON.parse(res.body);
|
||
expect(json.stepsCompleted).toBe(10);
|
||
// The processed image has more pixels due to borders, but the PNG
|
||
// compression may produce a smaller file. Just verify it produces output.
|
||
expect(json.processedSize).toBeGreaterThan(0);
|
||
}, 60_000);
|
||
|
||
it("pipeline with alternating format conversions: PNG -> WebP -> PNG -> WebP", async () => {
|
||
const res = await executePipeline(PNG_200x150, "format-flip.png", {
|
||
steps: [
|
||
{ toolId: "convert", settings: { format: "webp" } },
|
||
{ toolId: "convert", settings: { format: "png" } },
|
||
{ toolId: "convert", settings: { format: "webp" } },
|
||
{ toolId: "convert", settings: { format: "png" } },
|
||
],
|
||
});
|
||
|
||
expect(res.statusCode).toBe(200);
|
||
const json = JSON.parse(res.body);
|
||
expect(json.stepsCompleted).toBe(4);
|
||
});
|
||
|
||
it("pipeline where step 2 expects impossibly small crop after resize", async () => {
|
||
// Resize to 5x5, then crop 1x1 from offset 10,10 (out of bounds)
|
||
const res = await executePipeline(PNG_200x150, "impossible-crop.png", {
|
||
steps: [
|
||
{ toolId: "resize", settings: { width: 5, height: 5 } },
|
||
{ toolId: "crop", settings: { left: 10, top: 10, width: 1, height: 1 } },
|
||
],
|
||
});
|
||
|
||
// Crop offset exceeds 5x5 dimensions, should fail at step 2
|
||
expect([200, 422]).toContain(res.statusCode);
|
||
});
|
||
|
||
it("pipeline with single step that is an empty settings object", async () => {
|
||
// Compress with empty settings should use defaults
|
||
const res = await executePipeline(PNG_200x150, "empty-settings.png", {
|
||
steps: [{ toolId: "compress", settings: {} }],
|
||
});
|
||
|
||
// Should either succeed with default quality or fail gracefully
|
||
expect([200, 400, 422]).toContain(res.statusCode);
|
||
});
|
||
});
|
||
|
||
// ###########################################################################
|
||
// 9. BATCH LIMITS: LARGE COUNTS AND EDGE CASES
|
||
// Prior tests cover 11, 15, 20, 50 images. These test specific edge cases
|
||
// not previously covered.
|
||
// ###########################################################################
|
||
describe("Batch limits: additional edge cases", () => {
|
||
it("batch with only corrupted files returns 422 with all errors", async () => {
|
||
const garbage1 = Buffer.from(
|
||
Array.from({ length: 512 }, () => Math.floor(Math.random() * 256)),
|
||
);
|
||
const garbage2 = Buffer.from(
|
||
Array.from({ length: 256 }, () => Math.floor(Math.random() * 256)),
|
||
);
|
||
const garbage3 = Buffer.from("definitely not an image file at all");
|
||
|
||
const res = await postBatch("resize", [
|
||
{ name: "file", filename: "bad1.jpg", contentType: "image/jpeg", content: garbage1 },
|
||
{ name: "file", filename: "bad2.png", contentType: "image/png", content: garbage2 },
|
||
{ name: "file", filename: "bad3.webp", contentType: "image/webp", content: garbage3 },
|
||
{ name: "settings", content: JSON.stringify({ width: 50 }) },
|
||
]);
|
||
|
||
// All files failed -- should return 422
|
||
expect(res.statusCode).toBe(422);
|
||
const json = JSON.parse(res.body);
|
||
expect(json.error).toMatch(/all files failed/i);
|
||
expect(json.errors).toBeDefined();
|
||
expect(json.errors.length).toBe(3);
|
||
});
|
||
|
||
it("batch with mix of 1x1 pixel + large stress file processes both", async () => {
|
||
const res = await postBatch("resize", [
|
||
{
|
||
name: "file",
|
||
filename: "tiny.png",
|
||
contentType: "image/png",
|
||
content: PNG_1x1,
|
||
},
|
||
{
|
||
name: "file",
|
||
filename: "huge.jpg",
|
||
contentType: "image/jpeg",
|
||
content: STRESS_LARGE,
|
||
},
|
||
{ name: "settings", content: JSON.stringify({ width: 50 }) },
|
||
]);
|
||
|
||
expect(res.statusCode).toBe(200);
|
||
expect(res.headers["content-type"]).toBe("application/zip");
|
||
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
|
||
expect(Object.keys(fileResults).length).toBe(2);
|
||
}, 120_000);
|
||
|
||
it("batch with single valid file among 2 zero-byte files", async () => {
|
||
const res = await postBatch("compress", [
|
||
{
|
||
name: "file",
|
||
filename: "empty1.png",
|
||
contentType: "image/png",
|
||
content: Buffer.alloc(0),
|
||
},
|
||
{
|
||
name: "file",
|
||
filename: "valid.png",
|
||
contentType: "image/png",
|
||
content: PNG_200x150,
|
||
},
|
||
{
|
||
name: "file",
|
||
filename: "empty2.jpg",
|
||
contentType: "image/jpeg",
|
||
content: Buffer.alloc(0),
|
||
},
|
||
{ name: "settings", content: JSON.stringify({ quality: 70 }) },
|
||
]);
|
||
|
||
// Zero-byte files are skipped during parsing, only 1 valid file remains
|
||
expect(res.statusCode).toBe(200);
|
||
expect(res.headers["content-type"]).toBe("application/zip");
|
||
});
|
||
|
||
it("batch through convert tool with mixed formats", async () => {
|
||
const res = await postBatch("convert", [
|
||
{
|
||
name: "file",
|
||
filename: "photo.png",
|
||
contentType: "image/png",
|
||
content: PNG_200x150,
|
||
},
|
||
{
|
||
name: "file",
|
||
filename: "photo.jpg",
|
||
contentType: "image/jpeg",
|
||
content: JPG_100x100,
|
||
},
|
||
{
|
||
name: "file",
|
||
filename: "photo.webp",
|
||
contentType: "image/webp",
|
||
content: WEBP_50x50,
|
||
},
|
||
{ name: "settings", content: JSON.stringify({ format: "webp" }) },
|
||
]);
|
||
|
||
expect(res.statusCode).toBe(200);
|
||
expect(res.headers["content-type"]).toBe("application/zip");
|
||
const fileResults = JSON.parse(decodeURIComponent(res.headers["x-file-results"] as string));
|
||
expect(Object.keys(fileResults).length).toBe(3);
|
||
});
|
||
});
|
||
|
||
// ###########################################################################
|
||
// 10. SIMULTANEOUS BATCH + SINGLE + PIPELINE WITH CROSS-CONTAMINATION CHECKS
|
||
// Prior tests run batch+single or batch+single+pipeline but never verify
|
||
// the actual output content across all three. This test downloads and
|
||
// verifies pixel dimensions.
|
||
// ###########################################################################
|
||
describe("Simultaneous batch + single + pipeline with output verification", () => {
|
||
it("runs all three concurrently and verifies each output independently", async () => {
|
||
// Single: resize to 60px
|
||
const singleReq = app.inject(
|
||
buildToolRequest("resize", PNG_200x150, "single-verify.png", { width: 60 }),
|
||
);
|
||
|
||
// Batch: compress 2 images at quality 40
|
||
const batchPayload = createMultipartPayload([
|
||
{ name: "file", filename: "batch-v1.png", content: PNG_200x150, contentType: "image/png" },
|
||
{ name: "file", filename: "batch-v2.jpg", content: JPG_100x100, contentType: "image/jpeg" },
|
||
{ name: "settings", content: JSON.stringify({ quality: 40 }) },
|
||
]);
|
||
const batchReq = app.inject({
|
||
method: "POST",
|
||
url: "/api/v1/tools/image/compress/batch",
|
||
headers: {
|
||
"content-type": batchPayload.contentType,
|
||
authorization: `Bearer ${adminToken}`,
|
||
},
|
||
body: batchPayload.body,
|
||
});
|
||
|
||
// Pipeline: resize 80px + rotate 90deg
|
||
const pipelinePayload = createMultipartPayload([
|
||
{ name: "file", filename: "pipe-verify.png", content: PNG_200x150, contentType: "image/png" },
|
||
{
|
||
name: "pipeline",
|
||
content: JSON.stringify({
|
||
steps: [
|
||
{ toolId: "resize", settings: { width: 80 } },
|
||
{ toolId: "rotate", settings: { angle: 90 } },
|
||
],
|
||
}),
|
||
},
|
||
]);
|
||
const pipelineReq = app.inject({
|
||
method: "POST",
|
||
url: "/api/v1/pipeline/execute",
|
||
headers: {
|
||
"content-type": pipelinePayload.contentType,
|
||
authorization: `Bearer ${adminToken}`,
|
||
},
|
||
body: pipelinePayload.body,
|
||
});
|
||
|
||
const [singleRes, batchRes, pipelineRes] = await Promise.all([
|
||
singleReq,
|
||
batchReq,
|
||
pipelineReq,
|
||
]);
|
||
|
||
// All three must succeed
|
||
expect(singleRes.statusCode).toBe(200);
|
||
expect(batchRes.statusCode).toBe(200);
|
||
expect(pipelineRes.statusCode).toBe(200);
|
||
|
||
// Verify single: download and check width = 60
|
||
const singleBody = JSON.parse(singleRes.body);
|
||
const singleDl = await app.inject({
|
||
method: "GET",
|
||
url: singleBody.downloadUrl,
|
||
headers: { authorization: `Bearer ${adminToken}` },
|
||
});
|
||
expect(singleDl.statusCode).toBe(200);
|
||
const singleMeta = await sharp(singleDl.rawPayload).metadata();
|
||
expect(singleMeta.width).toBe(60);
|
||
|
||
// Verify batch: returns ZIP with 2 files
|
||
expect(batchRes.headers["content-type"]).toBe("application/zip");
|
||
const fileResults = JSON.parse(
|
||
decodeURIComponent(batchRes.headers["x-file-results"] as string),
|
||
);
|
||
expect(Object.keys(fileResults).length).toBe(2);
|
||
|
||
// Verify pipeline: download and check rotated dimensions
|
||
const pipeBody = JSON.parse(pipelineRes.body);
|
||
expect(pipeBody.stepsCompleted).toBe(2);
|
||
const pipeDl = await app.inject({
|
||
method: "GET",
|
||
url: pipeBody.downloadUrl,
|
||
headers: { authorization: `Bearer ${adminToken}` },
|
||
});
|
||
expect(pipeDl.statusCode).toBe(200);
|
||
const pipeMeta = await sharp(pipeDl.rawPayload).metadata();
|
||
// Resized to 80px width, then rotated 90 degrees swaps dimensions
|
||
// Height after resize maintains aspect ratio: 80 * (150/200) = 60
|
||
// After 90deg rotation: width=60, height=80
|
||
expect(pipeMeta.width).toBe(60);
|
||
expect(pipeMeta.height).toBe(80);
|
||
|
||
// Cross-contamination check: all job IDs must be unique
|
||
expect(singleBody.jobId).not.toBe(pipeBody.jobId);
|
||
}, 120_000);
|
||
|
||
it("fires 3 batches + 3 singles + 2 pipelines simultaneously", async () => {
|
||
const reqs = [
|
||
// 3 single requests to different tools
|
||
app.inject(buildToolRequest("resize", PNG_200x150, "s1.png", { width: 50 })),
|
||
app.inject(buildToolRequest("compress", PNG_200x150, "s2.png", { quality: 30 })),
|
||
app.inject(buildToolRequest("rotate", PNG_200x150, "s3.png", { angle: 180 })),
|
||
];
|
||
|
||
// 3 batch requests
|
||
for (let i = 0; i < 3; i++) {
|
||
const payload = createMultipartPayload([
|
||
{ name: "file", filename: `b${i}-1.png`, content: PNG_200x150, contentType: "image/png" },
|
||
{ name: "file", filename: `b${i}-2.jpg`, content: JPG_100x100, contentType: "image/jpeg" },
|
||
{ name: "settings", content: JSON.stringify({ width: 30 + i * 10 }) },
|
||
]);
|
||
reqs.push(
|
||
app.inject({
|
||
method: "POST",
|
||
url: "/api/v1/tools/image/resize/batch",
|
||
headers: {
|
||
"content-type": payload.contentType,
|
||
authorization: `Bearer ${adminToken}`,
|
||
},
|
||
body: payload.body,
|
||
}),
|
||
);
|
||
}
|
||
|
||
// 2 pipeline requests
|
||
for (let i = 0; i < 2; i++) {
|
||
const payload = createMultipartPayload([
|
||
{ name: "file", filename: `p${i}.png`, content: PNG_200x150, contentType: "image/png" },
|
||
{
|
||
name: "pipeline",
|
||
content: JSON.stringify({
|
||
steps: [
|
||
{ toolId: "resize", settings: { width: 60 + i * 20 } },
|
||
{ toolId: "compress", settings: { quality: 50 + i * 20 } },
|
||
],
|
||
}),
|
||
},
|
||
]);
|
||
reqs.push(
|
||
app.inject({
|
||
method: "POST",
|
||
url: "/api/v1/pipeline/execute",
|
||
headers: {
|
||
"content-type": payload.contentType,
|
||
authorization: `Bearer ${adminToken}`,
|
||
},
|
||
body: payload.body,
|
||
}),
|
||
);
|
||
}
|
||
|
||
const results = await Promise.all(reqs);
|
||
|
||
// All 8 requests must succeed
|
||
for (const res of results) {
|
||
expect(res.statusCode).toBe(200);
|
||
}
|
||
|
||
// Single request job IDs must be unique among themselves
|
||
const singleJobIds = results.slice(0, 3).map((r) => JSON.parse(r.body).jobId);
|
||
expect(new Set(singleJobIds).size).toBe(3);
|
||
|
||
// Pipeline job IDs must be unique
|
||
const pipeJobIds = results.slice(6, 8).map((r) => JSON.parse(r.body).jobId);
|
||
expect(new Set(pipeJobIds).size).toBe(2);
|
||
|
||
// No cross-contamination between single and pipeline job IDs
|
||
for (const pipeId of pipeJobIds) {
|
||
expect(singleJobIds).not.toContain(pipeId);
|
||
}
|
||
}, 120_000);
|
||
});
|
||
|
||
// ###########################################################################
|
||
// SERVER STABILITY AFTER ALL GAP-CLOSING TESTS
|
||
// ###########################################################################
|
||
describe("Server stability after final gap-closing tests", () => {
|
||
it("server remains healthy after all tests", 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");
|
||
|
||
// Sanity: a normal 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();
|
||
});
|
||
});
|