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

## Fixes that change behaviour

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

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

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

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

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

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

## Gates that could not fail

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

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

415 lines
19 KiB
TypeScript

/**
* Concurrent request integration tests for the SnapOtter image API.
*
* Tests that the server handles parallel and rapid sequential requests
* correctly without data corruption, crashes, or race conditions.
*/
import { apiToolPath } from "@snapotter/shared";
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 JPG_100x100 = readFixture(fixtures.image.base.jpg100);
// ---------------------------------------------------------------------------
// 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 build an inject config for a tool request. */
function buildToolRequest(
toolId: string,
image: Buffer,
filename: string,
settings: Record<string, unknown>,
) {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, content: image, contentType: "image/png" },
{ name: "settings", content: JSON.stringify(settings) },
]);
return {
method: "POST" as const,
url: apiToolPath(toolId),
headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` },
body,
};
}
// ═══════════════════════════════════════════════════════════════════════════
// PARALLEL UPLOADS TO SAME TOOL
// ═══════════════════════════════════════════════════════════════════════════
describe("Parallel uploads to the same tool", () => {
it("handles 5 simultaneous resize requests", async () => {
const results = await Promise.all(
Array.from({ length: 5 }, (_, i) =>
app.inject(
buildToolRequest("resize", PNG_200x150, `parallel-${i}.png`, { width: 50 + i * 10 }),
),
),
);
for (const res of results) {
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.jobId).toBeDefined();
expect(json.downloadUrl).toBeDefined();
}
// Each request should get a unique jobId
const jobIds = results.map((r) => JSON.parse(r.body).jobId);
const uniqueJobIds = new Set(jobIds);
expect(uniqueJobIds.size).toBe(5);
}, 30_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// PARALLEL UPLOADS TO DIFFERENT TOOLS
// ═══════════════════════════════════════════════════════════════════════════
describe("Parallel uploads to different tools", () => {
it("handles resize + rotate + compress simultaneously", async () => {
const [resizeRes, rotateRes, compressRes] = await Promise.all([
app.inject(buildToolRequest("resize", PNG_200x150, "resize.png", { width: 100 })),
app.inject(buildToolRequest("rotate", PNG_200x150, "rotate.png", { angle: 90 })),
app.inject(buildToolRequest("compress", PNG_200x150, "compress.png", { quality: 50 })),
]);
expect(resizeRes.statusCode).toBe(200);
expect(rotateRes.statusCode).toBe(200);
expect(compressRes.statusCode).toBe(200);
// Each result should have a distinct jobId
const ids = [resizeRes, rotateRes, compressRes].map((r) => JSON.parse(r.body).jobId);
expect(new Set(ids).size).toBe(3);
}, 30_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// RAPID SEQUENTIAL REQUESTS
// ═══════════════════════════════════════════════════════════════════════════
describe("Rapid sequential requests", () => {
it("handles 10 requests in quick succession", async () => {
const results = [];
for (let i = 0; i < 10; i++) {
const res = await app.inject(
buildToolRequest("resize", PNG_200x150, `rapid-${i}.png`, { width: 80 }),
);
results.push(res);
}
for (const res of results) {
expect(res.statusCode).toBe(200);
}
// All should produce unique job IDs
const jobIds = results.map((r) => JSON.parse(r.body).jobId);
expect(new Set(jobIds).size).toBe(10);
}, 30_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// INTERLEAVED UPLOADS
// ═══════════════════════════════════════════════════════════════════════════
describe("Interleaved uploads", () => {
it("processes two resize requests started nearly simultaneously", async () => {
// Start both at the same time — both should complete independently
const [first, second] = await Promise.all([
app.inject(buildToolRequest("resize", PNG_200x150, "interleave-a.png", { width: 120 })),
app.inject(buildToolRequest("resize", JPG_100x100, "interleave-b.jpg", { width: 60 })),
]);
expect(first.statusCode).toBe(200);
expect(second.statusCode).toBe(200);
const firstBody = JSON.parse(first.body);
const secondBody = JSON.parse(second.body);
// Each should get its own job ID and download URL
expect(firstBody.jobId).not.toBe(secondBody.jobId);
expect(firstBody.downloadUrl).not.toBe(secondBody.downloadUrl);
});
});
// ═══════════════════════════════════════════════════════════════════════════
// CONCURRENT MIX OF VALID AND INVALID REQUESTS
// ═══════════════════════════════════════════════════════════════════════════
describe("Concurrent mix of valid and invalid requests", () => {
it("handles a mix of valid and invalid requests without cross-contamination", async () => {
const corruptedJpeg = Buffer.concat([
Buffer.from([0xff, 0xd8, 0xff, 0xe0]),
Buffer.from("garbage data that is not a real image"),
]);
const [validRes, invalidRes, anotherValidRes] = await Promise.all([
app.inject(buildToolRequest("resize", PNG_200x150, "valid.png", { width: 100 })),
app.inject(buildToolRequest("resize", corruptedJpeg, "invalid.jpg", { width: 100 })),
app.inject(buildToolRequest("rotate", PNG_200x150, "also-valid.png", { angle: 45 })),
]);
// Valid requests should succeed
expect(validRes.statusCode).toBe(200);
expect(anotherValidRes.statusCode).toBe(200);
// Invalid request should fail gracefully
expect([400, 422]).toContain(invalidRes.statusCode);
// The invalid request should not affect the valid ones
expect(JSON.parse(validRes.body).jobId).toBeDefined();
expect(JSON.parse(anotherValidRes.body).jobId).toBeDefined();
});
});
// ═══════════════════════════════════════════════════════════════════════════
// 10 SIMULTANEOUS UPLOADS TO SAME ENDPOINT
// ═══════════════════════════════════════════════════════════════════════════
describe("10 simultaneous uploads to the same tool", () => {
it("handles 10 concurrent resize requests with distinct widths", async () => {
const results = await Promise.all(
Array.from({ length: 10 }, (_, i) =>
app.inject(
buildToolRequest("resize", PNG_200x150, `concurrent-${i}.png`, { width: 20 + i * 10 }),
),
),
);
for (const res of results) {
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.jobId).toBeDefined();
expect(json.downloadUrl).toBeDefined();
}
// All 10 must produce unique job IDs
const jobIds = results.map((r) => JSON.parse(r.body).jobId);
expect(new Set(jobIds).size).toBe(10);
}, 60_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// MIXED TOOL REQUESTS IN PARALLEL — VERIFY NO RESPONSE CORRUPTION
// ═══════════════════════════════════════════════════════════════════════════
describe("Mixed tool requests in parallel with result verification", () => {
it("resize + crop + rotate + compress simultaneously produce correct results", async () => {
const [resizeRes, cropRes, rotateRes, compressRes] = await Promise.all([
app.inject(buildToolRequest("resize", PNG_200x150, "mix-resize.png", { width: 50 })),
app.inject(
buildToolRequest("crop", PNG_200x150, "mix-crop.png", {
left: 0,
top: 0,
width: 100,
height: 100,
}),
),
app.inject(buildToolRequest("rotate", PNG_200x150, "mix-rotate.png", { angle: 180 })),
app.inject(buildToolRequest("compress", PNG_200x150, "mix-compress.png", { quality: 30 })),
]);
// All must succeed
expect(resizeRes.statusCode).toBe(200);
expect(cropRes.statusCode).toBe(200);
expect(rotateRes.statusCode).toBe(200);
expect(compressRes.statusCode).toBe(200);
// All must have unique job IDs
const ids = [resizeRes, cropRes, rotateRes, compressRes].map((r) => JSON.parse(r.body).jobId);
expect(new Set(ids).size).toBe(4);
// Resize output should be smaller than original
const resizeBody = JSON.parse(resizeRes.body);
expect(resizeBody.processedSize).toBeLessThan(resizeBody.originalSize);
// Crop output should be smaller than original
const cropBody = JSON.parse(cropRes.body);
expect(cropBody.processedSize).toBeLessThan(cropBody.originalSize);
// Download URLs must reference the correct tool suffix
expect(resizeBody.downloadUrl).toContain("resize");
expect(cropBody.downloadUrl).toContain("crop");
expect(JSON.parse(rotateRes.body).downloadUrl).toContain("rotate");
expect(JSON.parse(compressRes.body).downloadUrl).toContain("compress");
}, 30_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// CONCURRENT REQUESTS WITH DIFFERENT IMAGE FORMATS
// ═══════════════════════════════════════════════════════════════════════════
describe("Concurrent requests with different image formats", () => {
it("processes PNG and JPEG simultaneously without cross-format contamination", async () => {
const results = await Promise.all([
app.inject(buildToolRequest("resize", PNG_200x150, "format-a.png", { width: 80 })),
app.inject(buildToolRequest("resize", JPG_100x100, "format-b.jpg", { width: 50 })),
app.inject(buildToolRequest("resize", PNG_200x150, "format-c.png", { width: 60 })),
app.inject(buildToolRequest("resize", JPG_100x100, "format-d.jpg", { width: 40 })),
]);
for (const res of results) {
expect(res.statusCode).toBe(200);
}
// All unique job IDs
const ids = results.map((r) => JSON.parse(r.body).jobId);
expect(new Set(ids).size).toBe(4);
}, 30_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// 10 CONCURRENT COMPRESS REQUESTS
// ═══════════════════════════════════════════════════════════════════════════
describe("10 concurrent compress requests", () => {
it("fires 10 simultaneous compress requests — all succeed with unique job IDs", async () => {
const results = await Promise.all(
Array.from({ length: 10 }, (_, i) =>
app.inject(
buildToolRequest("compress", PNG_200x150, `compress-${i}.png`, {
quality: 30 + i * 5,
}),
),
),
);
for (const res of results) {
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.jobId).toBeDefined();
expect(json.downloadUrl).toBeDefined();
}
const jobIds = results.map((r) => JSON.parse(r.body).jobId);
expect(new Set(jobIds).size).toBe(10);
}, 60_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// SIMULTANEOUS BATCH + SINGLE REQUESTS
// ═══════════════════════════════════════════════════════════════════════════
describe("Simultaneous batch + single requests — no corruption", () => {
it("runs a batch resize and a single resize at the same time", async () => {
// Build batch request
const batchPayload = createMultipartPayload([
{ name: "file", filename: "batch-a.png", content: PNG_200x150, contentType: "image/png" },
{ name: "file", filename: "batch-b.png", content: PNG_200x150, contentType: "image/png" },
{ name: "file", filename: "batch-c.jpg", content: JPG_100x100, contentType: "image/jpeg" },
{ name: "settings", content: JSON.stringify({ width: 60 }) },
]);
const [batchRes, singleRes] = await Promise.all([
app.inject({
method: "POST",
url: "/api/v1/tools/image/resize/batch",
headers: {
"content-type": batchPayload.contentType,
authorization: `Bearer ${adminToken}`,
},
body: batchPayload.body,
}),
app.inject(buildToolRequest("resize", PNG_200x150, "single.png", { width: 40 })),
]);
// Single request must succeed
expect(singleRes.statusCode).toBe(200);
const singleBody = JSON.parse(singleRes.body);
expect(singleBody.jobId).toBeDefined();
expect(singleBody.downloadUrl).toContain("resize");
// Batch request must succeed
expect(batchRes.statusCode).toBe(200);
expect(batchRes.headers["content-type"]).toBe("application/zip");
}, 60_000);
it("runs a batch compress and a single rotate at the same time — no cross-contamination", 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: "settings", content: JSON.stringify({ quality: 50 }) },
]);
const [batchRes, 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("rotate", PNG_200x150, "rotate-single.png", { angle: 270 })),
]);
expect(rotateRes.statusCode).toBe(200);
const rotateBody = JSON.parse(rotateRes.body);
expect(rotateBody.downloadUrl).toContain("rotate");
expect(batchRes.statusCode).toBe(200);
expect(batchRes.headers["content-type"]).toBe("application/zip");
}, 60_000);
});
// ═══════════════════════════════════════════════════════════════════════════
// CONCURRENT PIPELINE EXECUTIONS
// ═══════════════════════════════════════════════════════════════════════════
describe("Concurrent pipeline executions", () => {
it("fires 5 simultaneous pipeline requests — all succeed", async () => {
const pipelineDef = {
steps: [
{ toolId: "resize", settings: { width: 80 } },
{ toolId: "compress", settings: { quality: 60 } },
],
};
const results = await Promise.all(
Array.from({ length: 5 }, (_, i) => {
const payload = createMultipartPayload([
{
name: "file",
filename: `pipe-${i}.png`,
content: PNG_200x150,
contentType: "image/png",
},
{ name: "pipeline", content: JSON.stringify(pipelineDef) },
]);
return app.inject({
method: "POST",
url: "/api/v1/pipeline/execute",
headers: {
"content-type": payload.contentType,
authorization: `Bearer ${adminToken}`,
},
body: payload.body,
});
}),
);
for (const res of results) {
expect(res.statusCode).toBe(200);
const json = JSON.parse(res.body);
expect(json.jobId).toBeDefined();
expect(json.stepsCompleted).toBe(2);
}
const jobIds = results.map((r) => JSON.parse(r.body).jobId);
expect(new Set(jobIds).size).toBe(5);
}, 120_000);
});