fix(pdf): pdf-to-image presets no longer 404 on 2+ files (#643)

Upload two PDFs to pdf-to-jpg and it answered `Tool "pdf-to-jpg" not
found`. pdf-to-jpg, pdf-to-png and pdf-to-tiff share
registerPdfToImageRoute, which registered a single-file endpoint and
nothing else, so the shared preset settings component's 2+-file
submission fell through to the generic `:section/:toolId/batch` route,
whose registry lookup misses every tool outside
createToolRoute/registerToolProcessFn.

Mirror of #627, different fix. image-to-pdf is many-to-one, so #633 sent
every file in one request. This direction is one-to-many: separate PDFs
want separate conversions, which is what /batch is for. The route now
serves its own /batch, the shape svg-to-raster already uses, and the
literal path beats the generic parametric one.

One PDF fans out to many page images, so a per-file result is a ZIP, same
as the single-file route. A batch returns a ZIP of per-document ZIPs in
upload order, keyed by X-File-Results so each result pairs with the file
it came from. A document that is unreadable, locked, empty, short of the
requested page range, or carrying no pages at all fails alone; 422 with a
reason per file when none survive.

That literal path also shadows the generic route's requireToolAccess
call, which would have turned a 403 into a converted ZIP for roles
without tools:use. All four endpoints in this file now gate.

Four ways the batch path could have reported something untrue are closed
with it: a storage fault blamed on the document (statusCode-carrying
errors now reach the error handler, the rest are logged before being
reduced to a generic message), per-file reasons stranded in a field
parseApiError never reads, a zero-byte upload dropped so that later
results landed on the wrong file, and a mid-stream failure ended cleanly
enough to pass for success (the socket is destroyed instead).

Page rendering and ZIP assembly are shared helpers now, createUniqueNamer
moves to lib/filename.ts next to its two existing copies, and
tool-route-drift fails if any batch-dispatched preset loses its /batch
route. Follow-up for the same defects in the sibling custom routes: #645.

Fixes #632
This commit is contained in:
SnapOtter
2026-07-26 01:35:42 +08:00
committed by GitHub
parent d690a6e26d
commit a7137958a1
7 changed files with 917 additions and 99 deletions
+29 -2
View File
@@ -1,7 +1,8 @@
import { apiToolPath, TOOLS } from "@snapotter/shared";
import { apiToolPath, CONVERSION_PRESETS, TOOLS } from "@snapotter/shared";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { MULTI_FILE_TOOLS } from "@/lib/tool-display-modes";
import { getRegisteredToolIds, getToolConfig } from "../../apps/api/src/routes/tool-factory.js";
import { buildTestApp, loginAsAdmin, type TestApp } from "./test-server.js";
import { buildTestApp, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
/**
* Drift guards between the shared TOOLS catalog and the API.
@@ -121,4 +122,30 @@ describe("tool route drift", () => {
expect(res.statusCode, `tool "${tool.id}" has no live POST route (got 404)`).not.toBe(404);
}
}, 60_000);
/**
* ConversionPresetSettings posts 2+ files to `<toolPath>/batch` for every
* preset outside MULTI_FILE_TOOLS. Presets on a custom base (image-to-pdf,
* pdf-to-image, svg-to-raster) never enter the registry the generic
* `:section/:toolId/batch` route reads, so a base that neither joins
* MULTI_FILE_TOOLS nor registers its own /batch 404s on the second file.
* That shipped twice: issue #627 (image-to-pdf) and issue #632
* (pdf-to-image). An empty body is enough to prove the route resolves.
*/
it("every batch-dispatched conversion preset answers on POST .../batch", async () => {
const { body, contentType } = createMultipartPayload([{ name: "settings", content: "{}" }]);
for (const preset of CONVERSION_PRESETS) {
if (MULTI_FILE_TOOLS.has(preset.id)) continue;
const res = await testApp.app.inject({
method: "POST",
url: `${apiToolPath(preset.id)}/batch`,
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(
res.statusCode,
`preset "${preset.id}" (base "${preset.base}") has no live /batch route: ${res.body.slice(0, 200)}`,
).not.toBe(404);
}
}, 60_000);
});