mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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
152 lines
5.2 KiB
TypeScript
152 lines
5.2 KiB
TypeScript
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, createMultipartPayload, loginAsAdmin, type TestApp } from "./test-server.js";
|
|
|
|
/**
|
|
* Drift guards between the shared TOOLS catalog and the API.
|
|
*
|
|
* Two intentional asymmetries exist and are pinned exactly:
|
|
* - REGISTRY_EXEMPT: tools whose contract does not fit the single-buffer
|
|
* process fn (multi-file, ZIP/JSON output, no-input generators, custom AI
|
|
* routes). They expose an HTTP route but are not in the pipeline/batch
|
|
* registry. If one of these gains registry support, remove it here.
|
|
* - LEGACY_ALIASES: extra registered toolIds kept for backwards-compatible
|
|
* URLs (consolidated into adjust-colors).
|
|
*/
|
|
const REGISTRY_EXEMPT = new Set([
|
|
"auto-subtitles",
|
|
"background-replace",
|
|
"barcode-generate",
|
|
"barcode-read",
|
|
"blur-background",
|
|
"bulk-rename",
|
|
"collage",
|
|
"color-palette",
|
|
"compare",
|
|
"compose",
|
|
"erase-object",
|
|
"favicon",
|
|
"find-duplicates",
|
|
"html-to-image",
|
|
"image-to-base64",
|
|
"image-to-pdf",
|
|
"info",
|
|
"pdf-to-image",
|
|
"qr-generate",
|
|
"sign-pdf",
|
|
"stitch",
|
|
"svg-to-raster",
|
|
"transcribe-audio",
|
|
"watermark-image",
|
|
// Conversion presets riding the three custom ZIP routes above (image-to-pdf,
|
|
// pdf-to-image, svg-to-raster). They share those routes' contracts, so like
|
|
// their bases they are not in the process-fn registry.
|
|
"jpg-to-pdf",
|
|
"png-to-pdf",
|
|
"heic-to-pdf",
|
|
"tiff-to-pdf",
|
|
"webp-to-pdf",
|
|
"gif-to-pdf",
|
|
"eps-to-pdf",
|
|
"pdf-to-jpg",
|
|
"pdf-to-png",
|
|
"pdf-to-tiff",
|
|
"svg-to-png",
|
|
"svg-to-jpg",
|
|
]);
|
|
|
|
const LEGACY_ALIASES = new Set([
|
|
"brightness-contrast",
|
|
"saturation",
|
|
"color-channels",
|
|
"color-effects",
|
|
]);
|
|
|
|
describe("tool route drift", () => {
|
|
let testApp: TestApp;
|
|
let adminToken: string;
|
|
|
|
beforeAll(async () => {
|
|
testApp = await buildTestApp();
|
|
adminToken = await loginAsAdmin(testApp.app);
|
|
}, 30_000);
|
|
|
|
afterAll(async () => {
|
|
await testApp.cleanup();
|
|
}, 10_000);
|
|
|
|
it("every non-exempt TOOLS entry has a registered process fn", () => {
|
|
const registered = new Set(getRegisteredToolIds());
|
|
const missing = TOOLS.filter((t) => !REGISTRY_EXEMPT.has(t.id) && !registered.has(t.id)).map(
|
|
(t) => t.id,
|
|
);
|
|
expect(missing, `tools not registered on the API: ${missing.join(", ")}`).toEqual([]);
|
|
});
|
|
|
|
it("registry-exempt list is not stale", () => {
|
|
const registered = new Set(getRegisteredToolIds());
|
|
for (const id of REGISTRY_EXEMPT) {
|
|
expect(
|
|
registered.has(id),
|
|
`"${id}" is in REGISTRY_EXEMPT but IS registered now; remove it from the exempt list`,
|
|
).toBe(false);
|
|
}
|
|
});
|
|
|
|
it("every registered tool exposes a settings schema and process fn", () => {
|
|
for (const id of getRegisteredToolIds()) {
|
|
const config = getToolConfig(id);
|
|
expect(config?.settingsSchema, `tool "${id}" has no settings schema`).toBeTruthy();
|
|
expect(typeof config?.process, `tool "${id}" has no process fn`).toBe("function");
|
|
}
|
|
});
|
|
|
|
it("no orphan registrations (registered but missing from TOOLS, excluding legacy aliases)", () => {
|
|
const ids = new Set(TOOLS.map((t) => t.id));
|
|
for (const id of getRegisteredToolIds()) {
|
|
if (LEGACY_ALIASES.has(id)) continue;
|
|
expect(ids.has(id), `registered tool "${id}" has no TOOLS definition`).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("every TOOLS entry answers on POST /api/v1/tools/:section/:toolId (no dead routes)", async () => {
|
|
for (const tool of TOOLS) {
|
|
const res = await testApp.app.inject({
|
|
method: "POST",
|
|
url: apiToolPath(tool.id),
|
|
headers: { authorization: `Bearer ${adminToken}`, "content-type": "application/json" },
|
|
payload: {},
|
|
});
|
|
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);
|
|
});
|