test: settle 202 jobs instead of returning without asserting (#652)

A 202 means the sync window expired while the job was still running. Tests treated it as a terminal pass: `if (isAsyncFallback(res)) return;` checked the envelope and returned, asserting nothing about the outcome and leaving the job running into the next test, which is the leak cancelAcceptedJobAndWait exists to prevent.

Because the window only expires under load, coverage tracked runner load. On CI 44 tests took this path and verified nothing; the same tests on a dev machine asserted in full (one measured 6.4s locally against 31s on CI).

settleAsyncFallback waits for a terminal state and asserts the job finished, and that a failure carries a message rather than being a crash. A clean failure stays valid, since the exotic-format fixtures are meant to be rejected. All 82 call sites moved over.

per-fork-env no longer floors SYNC_WAIT_MS, so forcing it to 0 drives every request through its 202 path. 570 tests were validated that way and matched their normal-window results exactly.

The 29-34s band dropped from 44 tests (23.4% of test time) to 6 (3.0%). Total test time rose 5.8% and CI wall went 12.8 to 13.1 min: the forks were doing real work during that wait, so this buys determinism, not speed. Per-shard totals unchanged at 9903 tests, 9435 passed, 468 skipped.
This commit is contained in:
SnapOtter
2026-07-27 12:16:48 +08:00
committed by GitHub
parent d9978525fe
commit f1ec3beaf7
15 changed files with 177 additions and 167 deletions
@@ -11,13 +11,13 @@ import { join } from "node:path";
import { apiToolPath } from "@snapotter/shared"; import { apiToolPath } from "@snapotter/shared";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { fixtureDir } from "../../fixtures/index.js"; import { fixtureDir } from "../../fixtures/index.js";
import { settleAsyncFallback } from "../settle-job.js";
import { import {
ACCEPTABLE_FALLBACK_CODES, ACCEPTABLE_FALLBACK_CODES,
adminToken, adminToken,
app, app,
buildPayload, buildPayload,
formatSamplesForPart, formatSamplesForPart,
isAsyncFallback,
needsFallback, needsFallback,
setupMatrixApp, setupMatrixApp,
TOOLS, TOOLS,
@@ -68,7 +68,7 @@ describe("Cross-format matrix", () => {
// Assert status code // Assert status code
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// A heavy encode may fall back to async (202) under CI load -- accept it. // A heavy encode may fall back to async (202) under CI load -- accept it.
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
if (needsFallback(fmt)) { if (needsFallback(fmt)) {
// Formats with optional decoders: accept success or graceful error // Formats with optional decoders: accept success or graceful error
@@ -11,13 +11,13 @@ import { join } from "node:path";
import { apiToolPath } from "@snapotter/shared"; import { apiToolPath } from "@snapotter/shared";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { fixtureDir } from "../../fixtures/index.js"; import { fixtureDir } from "../../fixtures/index.js";
import { settleAsyncFallback } from "../settle-job.js";
import { import {
ACCEPTABLE_FALLBACK_CODES, ACCEPTABLE_FALLBACK_CODES,
adminToken, adminToken,
app, app,
buildPayload, buildPayload,
formatSamplesForPart, formatSamplesForPart,
isAsyncFallback,
needsFallback, needsFallback,
setupMatrixApp, setupMatrixApp,
TOOLS, TOOLS,
@@ -68,7 +68,7 @@ describe("Cross-format matrix", () => {
// Assert status code // Assert status code
// ------------------------------------------------------------------ // ------------------------------------------------------------------
// A heavy encode may fall back to async (202) under CI load -- accept it. // A heavy encode may fall back to async (202) under CI load -- accept it.
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
if (needsFallback(fmt)) { if (needsFallback(fmt)) {
// Formats with optional decoders: accept success or graceful error // Formats with optional decoders: accept success or graceful error
@@ -11,13 +11,13 @@ import { join } from "node:path";
import { apiToolPath } from "@snapotter/shared"; import { apiToolPath } from "@snapotter/shared";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { fixtureDir, fixtures } from "../../fixtures/index.js"; import { fixtureDir, fixtures } from "../../fixtures/index.js";
import { settleAsyncFallback } from "../settle-job.js";
import { createMultipartPayload } from "../test-server.js"; import { createMultipartPayload } from "../test-server.js";
import { import {
adminToken, adminToken,
app, app,
buildPayload, buildPayload,
FORMAT_SAMPLES, FORMAT_SAMPLES,
isAsyncFallback,
setupMatrixApp, setupMatrixApp,
TOOLS, TOOLS,
} from "./format-matrix.shared.js"; } from "./format-matrix.shared.js";
@@ -125,7 +125,7 @@ describe("Exotic format error resilience", () => {
}); });
// Must not crash (500) — either succeed or return a clean error // Must not crash (500) — either succeed or return a clean error
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).not.toBe(500); expect(res.statusCode).not.toBe(500);
expect([200, 202, 400, 422]).toContain(res.statusCode); expect([200, 202, 400, 422]).toContain(res.statusCode);
@@ -10,13 +10,13 @@ import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { fixtureDir, fixtures } from "../../fixtures/index.js"; import { fixtureDir, fixtures } from "../../fixtures/index.js";
import { settleAsyncFallback } from "../settle-job.js";
import { createMultipartPayload } from "../test-server.js"; import { createMultipartPayload } from "../test-server.js";
import { import {
ACCEPTABLE_FALLBACK_CODES, ACCEPTABLE_FALLBACK_CODES,
adminToken, adminToken,
app, app,
FORMAT_SAMPLES, FORMAT_SAMPLES,
isAsyncFallback,
needsFallback, needsFallback,
setupMatrixApp, setupMatrixApp,
} from "./format-matrix.shared.js"; } from "./format-matrix.shared.js";
@@ -70,7 +70,7 @@ describe("Cross-format conversion matrix", () => {
body: payload, body: payload,
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
@@ -140,7 +140,7 @@ describe("Watermark-image cross-format matrix", () => {
body: payload, body: payload,
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
if (needsFallback(fmt)) { if (needsFallback(fmt)) {
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode); expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
} else { } else {
@@ -210,7 +210,7 @@ describe("Watermark-image cross-format matrix", () => {
body: payload, body: payload,
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
if (needsFallback(fmt)) { if (needsFallback(fmt)) {
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode); expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
} else { } else {
@@ -279,7 +279,7 @@ describe("Watermark-image cross-format matrix", () => {
body: payload, body: payload,
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.downloadUrl).toBeDefined(); expect(body.downloadUrl).toBeDefined();
@@ -335,7 +335,7 @@ describe("Image-to-PDF cross-format matrix", () => {
body: payload, body: payload,
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
if (needsFallback(fmt)) { if (needsFallback(fmt)) {
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode); expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
} else { } else {
@@ -407,7 +407,7 @@ describe("Image-to-PDF cross-format matrix", () => {
body: payload, body: payload,
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.downloadUrl).toBeDefined(); expect(body.downloadUrl).toBeDefined();
@@ -466,7 +466,7 @@ describe("Image-to-PDF cross-format matrix", () => {
body: payload, body: payload,
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
expect(body.pages).toBe(2); expect(body.pages).toBe(2);
@@ -552,8 +552,8 @@ describe("Image-to-PDF cross-format matrix", () => {
}); });
// Must not crash with 500 // Must not crash with 500
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).not.toBe(500); expect(res.statusCode).not.toBe(500);
expect([200, 202, 400, 422]).toContain(res.statusCode); expect([200, 202, 400, 422]).toContain(res.statusCode);
@@ -664,7 +664,7 @@ describe("Watermark-image exotic format error resilience", () => {
body: payload, body: payload,
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).not.toBe(500); expect(res.statusCode).not.toBe(500);
expect([200, 202, 400, 422]).toContain(res.statusCode); expect([200, 202, 400, 422]).toContain(res.statusCode);
@@ -35,7 +35,7 @@ describe("Strip-metadata across all 16 primary formats", () => {
async () => { async () => {
const res = await callTool("strip-metadata", fmt, { stripAll: true }); const res = await callTool("strip-metadata", fmt, { stripAll: true });
if (!res) return; if (!res) return;
const body = assertDownloadResponse(res, fmt); const body = await assertDownloadResponse(res, fmt);
// For core formats, verify that stripping metadata produces output // For core formats, verify that stripping metadata produces output
// (it may or may not reduce size depending on whether the fixture // (it may or may not reduce size depending on whether the fixture
@@ -109,7 +109,7 @@ describe("Image enhancement across all 16 primary formats", () => {
async () => { async () => {
const res = await callTool("image-enhancement", fmt, { ...cfg.settings }); const res = await callTool("image-enhancement", fmt, { ...cfg.settings });
if (!res) return; if (!res) return;
assertDownloadResponse(res, fmt); await assertDownloadResponse(res, fmt);
}, },
getTimeout(fmt, "image-enhancement"), getTimeout(fmt, "image-enhancement"),
); );
@@ -7,6 +7,7 @@
*/ */
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { settleAsyncFallback } from "../settle-job.js";
import { createMultipartPayload } from "../test-server.js"; import { createMultipartPayload } from "../test-server.js";
import { import {
ACCEPTABLE_FALLBACK_CODES, ACCEPTABLE_FALLBACK_CODES,
@@ -16,7 +17,6 @@ import {
CORE_FORMATS, CORE_FORMATS,
callTool, callTool,
getTimeout, getTimeout,
isAsyncFallback,
needsFallback, needsFallback,
PRIMARY_FORMATS, PRIMARY_FORMATS,
setupMatrixApp, setupMatrixApp,
@@ -41,7 +41,7 @@ describe("Resize across all 16 primary formats", () => {
async () => { async () => {
const res = await callTool("resize", fmt, { ...cfg.settings }); const res = await callTool("resize", fmt, { ...cfg.settings });
if (!res) return; if (!res) return;
assertDownloadResponse(res, fmt); await assertDownloadResponse(res, fmt);
}, },
getTimeout(fmt), getTimeout(fmt),
); );
@@ -74,7 +74,7 @@ describe("Convert: 16 formats -> 3 output targets", () => {
async () => { async () => {
const res = await callTool("convert", fmt, { format: target.format }); const res = await callTool("convert", fmt, { format: target.format });
if (!res) return; if (!res) return;
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
if (needsFallback(fmt)) { if (needsFallback(fmt)) {
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode); expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
@@ -118,7 +118,7 @@ describe("Compress across all 16 primary formats", () => {
async () => { async () => {
const res = await callTool("compress", fmt, { ...cfg.settings }); const res = await callTool("compress", fmt, { ...cfg.settings });
if (!res) return; if (!res) return;
assertDownloadResponse(res, fmt); await assertDownloadResponse(res, fmt);
}, },
getTimeout(fmt), getTimeout(fmt),
); );
@@ -234,7 +234,7 @@ describe("No-crash matrix: 16 formats x 12 tools", () => {
); );
// A heavy encode may fall back to async (202) under CI load -- accept it. // A heavy encode may fall back to async (202) under CI load -- accept it.
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
// Must be a recognized status code // Must be a recognized status code
if (needsFallback(fmt)) { if (needsFallback(fmt)) {
@@ -50,7 +50,7 @@ describe("Color adjustments across all 16 primary formats", () => {
async () => { async () => {
const res = await callTool("adjust-colors", fmt, { ...cfg.settings }); const res = await callTool("adjust-colors", fmt, { ...cfg.settings });
if (!res) return; if (!res) return;
assertDownloadResponse(res, fmt); await assertDownloadResponse(res, fmt);
}, },
getTimeout(fmt), getTimeout(fmt),
); );
@@ -118,7 +118,7 @@ describe("Optimize-for-web across all 16 primary formats", () => {
async () => { async () => {
const res = await callTool("optimize-for-web", fmt, { ...cfg.settings }); const res = await callTool("optimize-for-web", fmt, { ...cfg.settings });
if (!res) return; if (!res) return;
assertDownloadResponse(res, fmt); await assertDownloadResponse(res, fmt);
}, },
getTimeout(fmt), getTimeout(fmt),
); );
@@ -7,13 +7,13 @@
*/ */
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { settleAsyncFallback } from "../settle-job.js";
import { import {
assertDownloadResponse, assertDownloadResponse,
CORE_FORMATS, CORE_FORMATS,
callTool, callTool,
type FormatDef, type FormatDef,
getTimeout, getTimeout,
isAsyncFallback,
PRIMARY_FORMATS, PRIMARY_FORMATS,
setupMatrixApp, setupMatrixApp,
} from "./format-matrix-comprehensive.shared.js"; } from "./format-matrix-comprehensive.shared.js";
@@ -36,7 +36,7 @@ describe("Crop across all 16 primary formats", () => {
async () => { async () => {
const res = await callTool("crop", fmt, { ...cfg.settings }); const res = await callTool("crop", fmt, { ...cfg.settings });
if (!res) return; if (!res) return;
assertDownloadResponse(res, fmt); await assertDownloadResponse(res, fmt);
}, },
getTimeout(fmt), getTimeout(fmt),
); );
@@ -65,7 +65,7 @@ describe("Rotate across all 16 primary formats", () => {
async () => { async () => {
const res = await callTool("rotate", fmt, { ...cfg.settings }); const res = await callTool("rotate", fmt, { ...cfg.settings });
if (!res) return; if (!res) return;
assertDownloadResponse(res, fmt); await assertDownloadResponse(res, fmt);
}, },
getTimeout(fmt), getTimeout(fmt),
); );
@@ -92,7 +92,7 @@ describe("Sharpening across all 16 primary formats", () => {
async () => { async () => {
const res = await callTool("sharpening", fmt, { ...cfg.settings }); const res = await callTool("sharpening", fmt, { ...cfg.settings });
if (!res) return; if (!res) return;
assertDownloadResponse(res, fmt); await assertDownloadResponse(res, fmt);
}, },
getTimeout(fmt), getTimeout(fmt),
); );
@@ -119,7 +119,7 @@ describe("Border across all 16 primary formats", () => {
async () => { async () => {
const res = await callTool("border", fmt, { ...cfg.settings }); const res = await callTool("border", fmt, { ...cfg.settings });
if (!res) return; if (!res) return;
assertDownloadResponse(res, fmt); await assertDownloadResponse(res, fmt);
}, },
getTimeout(fmt), getTimeout(fmt),
); );
@@ -150,7 +150,7 @@ describe("Extended conversion targets (core formats)", () => {
it(`${fmt.name} -> ${target.format}`, { timeout: testTimeout }, async () => { it(`${fmt.name} -> ${target.format}`, { timeout: testTimeout }, async () => {
const res = await callTool("convert", fmt, { format: target.format }); const res = await callTool("convert", fmt, { format: target.format });
if (!res) return; if (!res) return;
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const body = JSON.parse(res.body); const body = JSON.parse(res.body);
@@ -35,6 +35,7 @@ import { join } from "node:path";
import { apiToolPath } from "@snapotter/shared"; import { apiToolPath } from "@snapotter/shared";
import { afterAll, beforeAll, expect } from "vitest"; import { afterAll, beforeAll, expect } from "vitest";
import { fixtureDir } from "../../fixtures/index.js"; import { fixtureDir } from "../../fixtures/index.js";
import { settleAsyncFallback } from "../settle-job.js";
import { import {
buildTestApp, buildTestApp,
createMultipartPayload, createMultipartPayload,
@@ -198,22 +199,6 @@ export function needsFallback(fmt: FormatDef): boolean {
return fmt.needsCliDecoder || fmt.needsHeifDecoder || fmt.mayFailValidation; return fmt.needsCliDecoder || fmt.needsHeifDecoder || fmt.mayFailValidation;
} }
/**
* A CPU-heavy encode can exceed the sync window (SYNC_WAIT_MS, 30s in tests)
* under parallel CI load and fall back to async: 202 {jobId, async: true}. Per
* the documented 200-or-202 contract that is a legitimate "accepted & processing"
* outcome -- the worker runs the same process fn either way -- not a failure.
* Returns true (validating the async body shape) when the response is that
* fallback, so callers can treat it as a pass.
*/
export function isAsyncFallback(res: { statusCode: number; body: string }): boolean {
if (res.statusCode !== 202) return false;
const body = JSON.parse(res.body);
expect(body.async).toBe(true);
expect(body.jobId).toBeDefined();
return true;
}
export function getTimeout(fmt: FormatDef, toolId?: string): number | undefined { export function getTimeout(fmt: FormatDef, toolId?: string): number | undefined {
if ((fmt.needsHeifDecoder || fmt.needsCliDecoder) && toolId === "image-enhancement") if ((fmt.needsHeifDecoder || fmt.needsCliDecoder) && toolId === "image-enhancement")
return 300_000; return 300_000;
@@ -280,8 +265,11 @@ export async function callTool(toolId: string, fmt: FormatDef, settings: Record<
* Assert a standard download response shape (used by most tools). * Assert a standard download response shape (used by most tools).
* For fallback formats, accepts 200/400/422. For core formats, expects 200. * For fallback formats, accepts 200/400/422. For core formats, expects 200.
*/ */
export function assertDownloadResponse(res: { statusCode: number; body: string }, fmt: FormatDef) { export async function assertDownloadResponse(
if (isAsyncFallback(res)) return undefined; res: { statusCode: number; body: string },
fmt: FormatDef,
) {
if (await settleAsyncFallback(res)) return undefined;
if (needsFallback(fmt)) { if (needsFallback(fmt)) {
expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode); expect(ACCEPTABLE_FALLBACK_CODES).toContain(res.statusCode);
} else { } else {
@@ -26,7 +26,7 @@
* holds the fixtures, tool table, and helpers they all share. * holds the fixtures, tool table, and helpers they all share.
*/ */
import { afterAll, beforeAll, expect } from "vitest"; import { afterAll, beforeAll } from "vitest";
import { import {
buildTestApp, buildTestApp,
createMultipartPayload, createMultipartPayload,
@@ -439,20 +439,6 @@ export function needsFallback(fmt: FormatSample): boolean {
return fmt.needsCliDecoder || fmt.needsHeifDecoder || fmt.mayFailValidation; return fmt.needsCliDecoder || fmt.needsHeifDecoder || fmt.mayFailValidation;
} }
/**
* A heavy encode can exceed SYNC_WAIT_MS (30s in tests) under parallel CI load
* and fall back to 202 {jobId, async: true}. Per the 200-or-202 API contract
* that is a legitimate "accepted & processing" outcome, not a failure.
* Returns true (validating the async body shape) so callers can early-return.
*/
export function isAsyncFallback(res: { statusCode: number; body: string }): boolean {
if (res.statusCode !== 202) return false;
const body = JSON.parse(res.body);
expect(body.async).toBe(true);
expect(body.jobId).toBeDefined();
return true;
}
/** /**
* Build multipart payload for a tool request. * Build multipart payload for a tool request.
* Info route does not use a "settings" field; image-to-base64 uses its own * Info route does not use a "settings" field; image-to-base64 uses its own
@@ -10,6 +10,7 @@ import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtureDir, fixtures, readFixture } from "../../fixtures/index.js"; import { fixtureDir, fixtures, readFixture } from "../../fixtures/index.js";
import { settleAsyncFallback } from "../settle-job.js";
import { import {
buildTestApp, buildTestApp,
createMultipartPayload, createMultipartPayload,
@@ -17,14 +18,6 @@ import {
type TestApp, type TestApp,
} from "../test-server.js"; } from "../test-server.js";
function isAsyncFallback(res: { statusCode: number; body: string }): boolean {
if (res.statusCode !== 202) return false;
const body = JSON.parse(res.body);
expect(body.async).toBe(true);
expect(body.jobId).toBeDefined();
return true;
}
describe("New format support", () => { describe("New format support", () => {
let testApp: TestApp; let testApp: TestApp;
let app: TestApp["app"]; let app: TestApp["app"];
@@ -72,7 +65,7 @@ describe("New format support", () => {
}); });
// Accept 200 (success) or 422 (encoder not available in test env) // Accept 200 (success) or 422 (encoder not available in test env)
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect([200, 422]).toContain(res.statusCode); expect([200, 422]).toContain(res.statusCode);
if (res.statusCode === 200) { if (res.statusCode === 200) {
const json = JSON.parse(res.body); const json = JSON.parse(res.body);
@@ -110,7 +103,7 @@ describe("New format support", () => {
body, body,
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect([200, 422]).toContain(res.statusCode); expect([200, 422]).toContain(res.statusCode);
if (res.statusCode === 200) { if (res.statusCode === 200) {
const json = JSON.parse(res.body); const json = JSON.parse(res.body);
@@ -198,7 +191,7 @@ describe("New format support", () => {
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body, body,
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect([200, 422]).toContain(res.statusCode); expect([200, 422]).toContain(res.statusCode);
if (res.statusCode === 200) { if (res.statusCode === 200) {
const json = JSON.parse(res.body); const json = JSON.parse(res.body);
@@ -240,7 +233,7 @@ describe("New format support", () => {
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body, body,
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect([200, 400, 422]).toContain(res.statusCode); expect([200, 400, 422]).toContain(res.statusCode);
if (res.statusCode === 200) { if (res.statusCode === 200) {
const json = JSON.parse(res.body); const json = JSON.parse(res.body);
@@ -273,7 +266,7 @@ describe("New format support", () => {
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType }, headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body, body,
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect([200, 422]).toContain(res.statusCode); expect([200, 422]).toContain(res.statusCode);
if (res.statusCode === 200) { if (res.statusCode === 200) {
const ct = res.headers["content-type"] as string; const ct = res.headers["content-type"] as string;
@@ -18,6 +18,7 @@ import { spawnSync } from "node:child_process";
import { apiToolPath } from "@snapotter/shared"; import { apiToolPath } from "@snapotter/shared";
import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { fixtures, readFixture } from "../../fixtures/index.js"; import { fixtures, readFixture } from "../../fixtures/index.js";
import { settleAsyncFallback } from "../settle-job.js";
import { import {
buildTestApp, buildTestApp,
createMultipartPayload, createMultipartPayload,
@@ -36,14 +37,6 @@ const HAS_QPDF = hasBinary("qpdf");
// ── Helpers ─────────────────────────────────────────────────────────── // ── Helpers ───────────────────────────────────────────────────────────
function isAsyncFallback(res: { statusCode: number; body: string }): boolean {
if (res.statusCode !== 202) return false;
const body = JSON.parse(res.body);
expect(body.async).toBe(true);
expect(body.jobId).toBeDefined();
return true;
}
async function postTool( async function postTool(
app: TestApp["app"], app: TestApp["app"],
token: string, token: string,
@@ -110,7 +103,7 @@ describe("cross-modality launch smoke", () => {
flipH: false, flipH: false,
flipV: false, flipV: false,
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -123,7 +116,7 @@ describe("cross-modality launch smoke", () => {
async () => { async () => {
const file = readFixture(fixtures.video.tiny("mp4")); const file = readFixture(fixtures.video.tiny("mp4"));
const res = await postTool(app, adminToken, "mute-video", file, "tiny.mp4", "video/mp4", {}); const res = await postTool(app, adminToken, "mute-video", file, "tiny.mp4", "video/mp4", {});
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -141,7 +134,7 @@ describe("cross-modality launch smoke", () => {
format: "mp3", format: "mp3",
bitrate: 128, bitrate: 128,
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -164,7 +157,7 @@ describe("cross-modality launch smoke", () => {
"application/pdf", "application/pdf",
{ angle: 90, range: "1-z" }, { angle: 90, range: "1-z" },
); );
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -179,7 +172,7 @@ describe("cross-modality launch smoke", () => {
const res = await postTool(app, adminToken, "csv-json", file, "tiny.csv", "text/csv", { const res = await postTool(app, adminToken, "csv-json", file, "tiny.csv", "text/csv", {
direction: "csv-to-json", direction: "csv-to-json",
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
+59
View File
@@ -1,5 +1,6 @@
import { setTimeout as delay } from "node:timers/promises"; import { setTimeout as delay } from "node:timers/promises";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { expect } from "vitest";
import { db, schema } from "../../apps/api/src/db/index.js"; import { db, schema } from "../../apps/api/src/db/index.js";
import { requestCancel } from "../../apps/api/src/jobs/cancel.js"; import { requestCancel } from "../../apps/api/src/jobs/cancel.js";
import { waitForJob } from "../../apps/api/src/jobs/enqueue.js"; import { waitForJob } from "../../apps/api/src/jobs/enqueue.js";
@@ -66,6 +67,64 @@ export async function cancelAcceptedJobAndWait(
); );
} }
const TERMINAL_DATABASE_STATUSES = new Set(["completed", "failed", "canceled"]);
/**
* Settle a 202 async-fallback response, and assert the job actually finished.
*
* A 202 means the sync window expired while the job was still running. Tests
* used to `return` here, which asserted nothing beyond the envelope and left
* the job running into whatever test came next (the leak
* `cancelAcceptedJobAndWait` exists to prevent). Because the window only
* expires under load, that made coverage depend on how busy the runner was: on
* CI 44 tests took this path and checked nothing, while the same tests on a dev
* machine finished inside the window and asserted in full.
*
* Waits for a terminal state instead and asserts what every caller actually
* cares about: the job finished, and if it failed it failed cleanly with a
* message rather than crashing. A clean failure is a legitimate outcome here,
* since the exotic-format fixtures are expected to be rejected.
*
* Returns true when the response was a 202 so callers keep their existing
* early-return shape; a 202 body carries no result to assert against.
*/
export async function settleAsyncFallback(
res: { statusCode: number; body: string },
timeoutMs = 120_000,
): Promise<boolean> {
if (res.statusCode !== 202) return false;
const body = JSON.parse(res.body) as { async?: boolean; jobId?: string };
expect(body.async).toBe(true);
expect(body.jobId).toBeDefined();
const jobId = body.jobId as string;
const deadline = Date.now() + timeoutMs;
let row: { status: string; pool: string | null; error: { message: string } | null } | undefined;
while (Date.now() < deadline) {
[row] = await db
.select({ status: schema.jobs.status, pool: schema.jobs.pool, error: schema.jobs.error })
.from(schema.jobs)
.where(eq(schema.jobs.id, jobId));
if (row && TERMINAL_DATABASE_STATUSES.has(row.status)) break;
await delay(100);
}
if (!row || !TERMINAL_DATABASE_STATUSES.has(row.status)) {
// Never leave it running: a stuck job starves every later test in the fork.
await cancelAcceptedJobAndWait(jobId, (row?.pool as Pool | undefined) ?? "image");
throw new AcceptedJobTimeoutError(jobId, timeoutMs);
}
if (row.status === "failed") {
expect(typeof row.error?.message, `job ${jobId} failed without a message`).toBe("string");
expect(row.error?.message.length ?? 0).toBeGreaterThan(0);
}
return true;
}
/** /**
* Wait for an accepted job to succeed. If the observation window expires, * Wait for an accepted job to succeed. If the observation window expires,
* cancel and fully drain the job before failing the test so timed-out work can * cancel and fully drain the job before failing the test so timed-out work can
@@ -9,6 +9,7 @@
import sharp from "sharp"; import sharp from "sharp";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { fixtures, readFixture } from "../../../fixtures/index.js"; import { fixtures, readFixture } from "../../../fixtures/index.js";
import { settleAsyncFallback } from "../../settle-job.js";
import { import {
buildTestApp, buildTestApp,
createMultipartPayload, createMultipartPayload,
@@ -69,25 +70,11 @@ async function postTool(
}); });
} }
/**
* Under parallel CI load the sync window (30s) can expire before the worker
* finishes, returning 202 {jobId, async: true}. That is a legitimate "accepted
* & processing" outcome per the API contract. Return true so tests can skip
* assertions that only apply to the synchronous 200 path.
*/
function isAsyncFallback(res: { statusCode: number; body: string }): boolean {
if (res.statusCode !== 202) return false;
const body = JSON.parse(res.body);
expect(body.async).toBe(true);
expect(body.jobId).toBeDefined();
return true;
}
// ── Auto mode (default) ─────────────────────────────────────────── // ── Auto mode (default) ───────────────────────────────────────────
describe("Auto mode", () => { describe("Auto mode", () => {
it("enhances with default settings", async () => { it("enhances with default settings", async () => {
const res = await postTool({}); const res = await postTool({});
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -96,7 +83,7 @@ describe("Auto mode", () => {
it("enhances with explicit auto mode", async () => { it("enhances with explicit auto mode", async () => {
const res = await postTool({ mode: "auto" }); const res = await postTool({ mode: "auto" });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -107,7 +94,7 @@ describe("Auto mode", () => {
describe("Enhancement modes", () => { describe("Enhancement modes", () => {
it("enhances in portrait mode", async () => { it("enhances in portrait mode", async () => {
const res = await postTool({ mode: "portrait" }); const res = await postTool({ mode: "portrait" });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -115,7 +102,7 @@ describe("Enhancement modes", () => {
it("enhances in landscape mode", async () => { it("enhances in landscape mode", async () => {
const res = await postTool({ mode: "landscape" }); const res = await postTool({ mode: "landscape" });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -123,7 +110,7 @@ describe("Enhancement modes", () => {
it("enhances in low-light mode", async () => { it("enhances in low-light mode", async () => {
const res = await postTool({ mode: "low-light" }); const res = await postTool({ mode: "low-light" });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -131,7 +118,7 @@ describe("Enhancement modes", () => {
it("enhances in food mode", async () => { it("enhances in food mode", async () => {
const res = await postTool({ mode: "food" }); const res = await postTool({ mode: "food" });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -139,7 +126,7 @@ describe("Enhancement modes", () => {
it("enhances in document mode", async () => { it("enhances in document mode", async () => {
const res = await postTool({ mode: "document" }); const res = await postTool({ mode: "document" });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -150,7 +137,7 @@ describe("Enhancement modes", () => {
describe("Intensity parameter", () => { describe("Intensity parameter", () => {
it("enhances at minimum intensity (0)", async () => { it("enhances at minimum intensity (0)", async () => {
const res = await postTool({ intensity: 0 }); const res = await postTool({ intensity: 0 });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -158,7 +145,7 @@ describe("Intensity parameter", () => {
it("enhances at maximum intensity (100)", async () => { it("enhances at maximum intensity (100)", async () => {
const res = await postTool({ intensity: 100 }); const res = await postTool({ intensity: 100 });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -166,7 +153,7 @@ describe("Intensity parameter", () => {
it("enhances at mid intensity (50, default)", async () => { it("enhances at mid intensity (50, default)", async () => {
const res = await postTool({ intensity: 50 }); const res = await postTool({ intensity: 50 });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
}); });
}); });
@@ -184,7 +171,7 @@ describe("Selective corrections", () => {
denoise: false, denoise: false,
}, },
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -201,7 +188,7 @@ describe("Selective corrections", () => {
denoise: true, denoise: true,
}, },
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -218,7 +205,7 @@ describe("Selective corrections", () => {
denoise: false, denoise: false,
}, },
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
}); });
}); });
@@ -227,7 +214,7 @@ describe("Selective corrections", () => {
describe("Output verification", () => { describe("Output verification", () => {
it("output differs from input", async () => { it("output differs from input", async () => {
const res = await postTool({ mode: "auto", intensity: 80 }); const res = await postTool({ mode: "auto", intensity: 80 });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
@@ -242,7 +229,7 @@ describe("Output verification", () => {
it("preserves image dimensions", async () => { it("preserves image dimensions", async () => {
const res = await postTool({ mode: "auto" }); const res = await postTool({ mode: "auto" });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
@@ -272,7 +259,7 @@ describe("Analyze endpoint", () => {
authorization: `Bearer ${adminToken}`, authorization: `Bearer ${adminToken}`,
}, },
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
// Analysis should return corrections object // Analysis should return corrections object
@@ -300,7 +287,7 @@ describe("Analyze endpoint", () => {
describe("Multiple input formats", () => { describe("Multiple input formats", () => {
it("enhances JPEG input", async () => { it("enhances JPEG input", async () => {
const res = await postTool({ mode: "auto" }, JPG, "test.jpg", "image/jpeg"); const res = await postTool({ mode: "auto" }, JPG, "test.jpg", "image/jpeg");
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -308,7 +295,7 @@ describe("Multiple input formats", () => {
it("enhances WebP input", async () => { it("enhances WebP input", async () => {
const res = await postTool({ mode: "auto" }, WEBP, "test.webp", "image/webp"); const res = await postTool({ mode: "auto" }, WEBP, "test.webp", "image/webp");
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -319,7 +306,7 @@ describe("Multiple input formats", () => {
describe("Mode and intensity combinations", () => { describe("Mode and intensity combinations", () => {
it("applies portrait mode at high intensity", async () => { it("applies portrait mode at high intensity", async () => {
const res = await postTool({ mode: "portrait", intensity: 90 }); const res = await postTool({ mode: "portrait", intensity: 90 });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
@@ -333,7 +320,7 @@ describe("Mode and intensity combinations", () => {
it("applies low-light mode at low intensity", async () => { it("applies low-light mode at low intensity", async () => {
const res = await postTool({ mode: "low-light", intensity: 10 }); const res = await postTool({ mode: "low-light", intensity: 10 });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.processedSize).toBeGreaterThan(0); expect(result.processedSize).toBeGreaterThan(0);
@@ -341,7 +328,7 @@ describe("Mode and intensity combinations", () => {
it("applies food mode at zero intensity (no-op)", async () => { it("applies food mode at zero intensity (no-op)", async () => {
const res = await postTool({ mode: "food", intensity: 0 }); const res = await postTool({ mode: "food", intensity: 0 });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
}); });
}); });
@@ -361,7 +348,7 @@ describe("Analyze endpoint details", () => {
authorization: `Bearer ${adminToken}`, authorization: `Bearer ${adminToken}`,
}, },
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.corrections).toBeDefined(); expect(result.corrections).toBeDefined();
@@ -381,7 +368,7 @@ describe("Analyze endpoint details", () => {
authorization: `Bearer ${adminToken}`, authorization: `Bearer ${adminToken}`,
}, },
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.corrections).toBeDefined(); expect(result.corrections).toBeDefined();
@@ -403,7 +390,7 @@ describe("Full corrections suite", () => {
denoise: true, denoise: true,
}, },
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -415,7 +402,7 @@ describe("Full corrections suite", () => {
describe("Format preservation", () => { describe("Format preservation", () => {
it("preserves JPEG format for JPEG input", async () => { it("preserves JPEG format for JPEG input", async () => {
const res = await postTool({ mode: "auto" }, JPG, "test.jpg", "image/jpeg"); const res = await postTool({ mode: "auto" }, JPG, "test.jpg", "image/jpeg");
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
@@ -594,7 +581,7 @@ describe("Alpha channel preservation", () => {
"rgba.png", "rgba.png",
"image/png", "image/png",
); );
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
@@ -630,7 +617,7 @@ describe("Alpha channel preservation", () => {
"semi.png", "semi.png",
"image/png", "image/png",
); );
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
@@ -679,7 +666,7 @@ describe("Tiny file handling", () => {
it("enhances a 1x1 pixel image", async () => { it("enhances a 1x1 pixel image", async () => {
const tiny = readFixture(fixtures.image.edge.px1); const tiny = readFixture(fixtures.image.edge.px1);
const res = await postTool({ mode: "auto" }, tiny, "tiny.png", "image/png"); const res = await postTool({ mode: "auto" }, tiny, "tiny.png", "image/png");
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.processedSize).toBeGreaterThan(0); expect(result.processedSize).toBeGreaterThan(0);
@@ -698,7 +685,7 @@ describe("Empty file handling", () => {
describe("Document mode variations", () => { describe("Document mode variations", () => {
it("enhances JPEG in document mode at high intensity", async () => { it("enhances JPEG in document mode at high intensity", async () => {
const res = await postTool({ mode: "document", intensity: 90 }, JPG, "doc.jpg", "image/jpeg"); const res = await postTool({ mode: "document", intensity: 90 }, JPG, "doc.jpg", "image/jpeg");
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -711,7 +698,7 @@ describe("Document mode variations", () => {
"landscape.webp", "landscape.webp",
"image/webp", "image/webp",
); );
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
}); });
}); });
@@ -760,7 +747,7 @@ describe("SVG input", () => {
describe("Animated GIF input", () => { describe("Animated GIF input", () => {
it("enhances animated GIF input", async () => { it("enhances animated GIF input", async () => {
const res = await postTool({ mode: "auto" }, GIF, "animated.gif", "image/gif"); const res = await postTool({ mode: "auto" }, GIF, "animated.gif", "image/gif");
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -781,7 +768,7 @@ describe("Selective correction edge cases", () => {
denoise: false, denoise: false,
}, },
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
}); });
@@ -797,7 +784,7 @@ describe("Selective correction edge cases", () => {
denoise: false, denoise: false,
}, },
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
}); });
}); });
@@ -811,7 +798,7 @@ describe("Partial corrections object", () => {
contrast: false, contrast: false,
}, },
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -821,7 +808,7 @@ describe("Partial corrections object", () => {
const res = await postTool({ const res = await postTool({
corrections: {}, corrections: {},
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -832,7 +819,7 @@ describe("Partial corrections object", () => {
describe("Output format for different input formats", () => { describe("Output format for different input formats", () => {
it("preserves WebP format for WebP input", async () => { it("preserves WebP format for WebP input", async () => {
const res = await postTool({ mode: "auto" }, WEBP, "test.webp", "image/webp"); const res = await postTool({ mode: "auto" }, WEBP, "test.webp", "image/webp");
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
@@ -847,7 +834,7 @@ describe("Output format for different input formats", () => {
it("preserves PNG format for PNG input", async () => { it("preserves PNG format for PNG input", async () => {
const res = await postTool({ mode: "auto" }, PNG, "test.png", "image/png"); const res = await postTool({ mode: "auto" }, PNG, "test.png", "image/png");
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
@@ -865,7 +852,7 @@ describe("Output format for different input formats", () => {
describe("Output dimension verification", () => { describe("Output dimension verification", () => {
it("preserves JPEG input dimensions", async () => { it("preserves JPEG input dimensions", async () => {
const res = await postTool({ mode: "auto" }, JPG, "test.jpg", "image/jpeg"); const res = await postTool({ mode: "auto" }, JPG, "test.jpg", "image/jpeg");
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
@@ -881,7 +868,7 @@ describe("Output dimension verification", () => {
it("preserves WebP input dimensions", async () => { it("preserves WebP input dimensions", async () => {
const res = await postTool({ mode: "landscape" }, WEBP, "test.webp", "image/webp"); const res = await postTool({ mode: "landscape" }, WEBP, "test.webp", "image/webp");
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
@@ -900,7 +887,7 @@ describe("Output dimension verification", () => {
describe("Response structure", () => { describe("Response structure", () => {
it("returns all expected fields in 200 response", async () => { it("returns all expected fields in 200 response", async () => {
const res = await postTool({ mode: "auto" }); const res = await postTool({ mode: "auto" });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
@@ -931,7 +918,7 @@ describe("Analyze endpoint format coverage", () => {
authorization: `Bearer ${adminToken}`, authorization: `Bearer ${adminToken}`,
}, },
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.corrections).toBeDefined(); expect(result.corrections).toBeDefined();
@@ -957,7 +944,7 @@ describe("Analyze endpoint format coverage", () => {
authorization: `Bearer ${adminToken}`, authorization: `Bearer ${adminToken}`,
}, },
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.corrections).toBeDefined(); expect(result.corrections).toBeDefined();
@@ -980,7 +967,7 @@ describe("Mode with selective corrections", () => {
denoise: false, denoise: false,
}, },
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -999,7 +986,7 @@ describe("Mode with selective corrections", () => {
denoise: true, denoise: true,
}, },
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
}); });
}); });
@@ -1027,7 +1014,7 @@ describe("Large file with modes", () => {
"stress-large.jpg", "stress-large.jpg",
"image/jpeg", "image/jpeg",
); );
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.processedSize).toBeGreaterThan(0); expect(result.processedSize).toBeGreaterThan(0);
@@ -1041,7 +1028,7 @@ describe("Large file with modes", () => {
"stress-large.jpg", "stress-large.jpg",
"image/jpeg", "image/jpeg",
); );
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.processedSize).toBeGreaterThan(0); expect(result.processedSize).toBeGreaterThan(0);
@@ -1052,7 +1039,7 @@ describe("Large file with modes", () => {
describe("Deep Enhance", () => { describe("Deep Enhance", () => {
it("accepts deepEnhance setting and returns 200", async () => { it("accepts deepEnhance setting and returns 200", async () => {
const res = await postTool({ deepEnhance: true }); const res = await postTool({ deepEnhance: true });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -1061,7 +1048,7 @@ describe("Deep Enhance", () => {
it("works without deepEnhance (default false)", async () => { it("works without deepEnhance (default false)", async () => {
const res = await postTool({}); const res = await postTool({});
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -1095,7 +1082,7 @@ describe("Darkening regression", () => {
"midgray.jpg", "midgray.jpg",
"image/jpeg", "image/jpeg",
); );
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
@@ -1131,7 +1118,7 @@ describe("Darkening regression", () => {
const originalMean = originalStats.channels[0].mean; const originalMean = originalStats.channels[0].mean;
const res = await postTool({ mode: "auto", intensity: 50 }, bright, "bright.jpg", "image/jpeg"); const res = await postTool({ mode: "auto", intensity: 50 }, bright, "bright.jpg", "image/jpeg");
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
@@ -1158,7 +1145,7 @@ describe("Portrait image enhancement", () => {
"portrait.jpg", "portrait.jpg",
"image/jpeg", "image/jpeg",
); );
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.downloadUrl).toBeDefined(); expect(result.downloadUrl).toBeDefined();
@@ -1182,7 +1169,7 @@ describe("Portrait image enhancement", () => {
"portrait-color.jpg", "portrait-color.jpg",
"image/jpeg", "image/jpeg",
); );
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.processedSize).toBeGreaterThan(0); expect(result.processedSize).toBeGreaterThan(0);
@@ -1212,7 +1199,7 @@ describe("Batch processing", () => {
}, },
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.headers["content-type"]).toBe("application/zip"); expect(res.headers["content-type"]).toBe("application/zip");
@@ -1261,7 +1248,7 @@ describe("Analyze endpoint response structure", () => {
authorization: `Bearer ${adminToken}`, authorization: `Bearer ${adminToken}`,
}, },
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result).toHaveProperty("scores"); expect(result).toHaveProperty("scores");
@@ -1321,7 +1308,7 @@ describe("Low-light image analysis", () => {
authorization: `Bearer ${adminToken}`, authorization: `Bearer ${adminToken}`,
}, },
}); });
if (isAsyncFallback(res)) return; if (await settleAsyncFallback(res)) return;
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
const result = JSON.parse(res.body); const result = JSON.parse(res.body);
expect(result.suggestedMode).toBe("low-light"); expect(result.suggestedMode).toBe("low-light");
+11 -7
View File
@@ -25,13 +25,17 @@ process.env.BULLMQ_PREFIX = `snapotter_test_${suffix}`;
// Heavy format conversions can exceed the 8s production default under parallel // Heavy format conversions can exceed the 8s production default under parallel
// test forks; 30s keeps tool routes synchronous (200) in tests while production // test forks; 30s keeps tool routes synchronous (200) in tests while production
// stays at 8s. The constrained docker test image (macOS Docker VM, where Sharp // stays at 8s.
// and FFmpeg run ~2-3x slower) can request a larger window via SYNC_WAIT_MS; //
// honor it rather than clobbering, but never drop below the 30s test floor. // An explicit SYNC_WAIT_MS is now honored verbatim rather than floored. The
const requestedSyncWait = Number(process.env.SYNC_WAIT_MS); // constrained docker test image (macOS Docker VM, where Sharp and FFmpeg run
process.env.SYNC_WAIT_MS = String( // ~2-3x slower) still widens the window, and forcing it *down* (SYNC_WAIT_MS=0)
Number.isFinite(requestedSyncWait) && requestedSyncWait > 30000 ? requestedSyncWait : 30000, // drives every tool through its 202 path, which is the only way to exercise
); // that branch on a machine fast enough to never hit it naturally.
const requestedSyncWait = process.env.SYNC_WAIT_MS?.trim();
const hasExplicitSyncWait =
Boolean(requestedSyncWait) && Number.isFinite(Number(requestedSyncWait));
process.env.SYNC_WAIT_MS = hasExplicitSyncWait ? (requestedSyncWait as string) : "30000";
const dbName = `snapotter_test_${suffix}`; // pid digits + uuid hex: identifier-safe const dbName = `snapotter_test_${suffix}`; // pid digits + uuid hex: identifier-safe
const admin = new pg.Client({ connectionString: baseUrl }); const admin = new pg.Client({ connectionString: baseUrl });
await admin.connect(); await admin.connect();