mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
test: reorganize flat test files into purpose-based subdirectories (phase 6)
Group 245 flat integration tests and 25 loose unit tests into
discoverable subdirectories per spec section 6:
integration/tools/{image,video,audio,document,data}/ (156 files)
integration/platform/ (64 files)
integration/generated/ (14 files)
integration/security/ (10 files)
unit/security/ (8 files, new subdir)
unit/api/ (7 files moved in)
unit/web/ (3 files moved in)
unit/shared/ (6 files moved in)
unit/image-engine/ (1 file moved in)
All moves via git mv (history preserved). Relative imports repaired
for both depth levels (platform/generated/security = +1, tools/ = +2):
static from-imports, dynamic import() calls, vi.mock() paths,
import.meta.dirname joins, and __dirname joins.
Vitest discovery unchanged (no test.include in config, recursive glob
matches subdirs, shard-by-hash unaffected). test-server.ts and
tool-route-drift.test.ts stay at integration root. fixtures/ untouched.
Parity gate: 13189 passing test names before = 13189 after (0 dropped).
This commit is contained in:
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* 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 { 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: `/api/v1/tools/${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/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/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);
|
||||
});
|
||||
Reference in New Issue
Block a user