feat(library): wire save-mode into the five custom-client tool submitters (#577)

Closes #565. Wires the fileId/saveMode pair into the ocr, erase-object, remove-background, background-replace, and blur-background submitters so the library save-mode selector works for them; remove-background's two-phase effects route now auto-saves the final composite instead of the transparent intermediate.
This commit is contained in:
SnapOtter
2026-07-19 22:35:25 +08:00
committed by GitHub
parent 4fea434859
commit 1113c761ea
9 changed files with 472 additions and 34 deletions
@@ -8,6 +8,7 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { registerAiJobHandler } from "../../jobs/ai-handlers.js";
import { enqueueToolJob } from "../../jobs/enqueue.js";
import { autoSaveToLibrary } from "../../jobs/postprocess.js";
import { INVALID_SAVE_MODE_ERROR, parseSaveModeField } from "../../jobs/types.js";
import { autoOrient } from "../../lib/auto-orient.js";
import {
@@ -243,6 +244,8 @@ export function registerRemoveBackground(app: FastifyInstance) {
let settingsRaw: string | null = null;
let bgImageBuffer: Buffer | null = null;
let bgFilename = "background";
let fileId: string | null = null;
let saveModeRaw: string | null = null;
try {
const parts = request.parts();
@@ -254,6 +257,10 @@ export function registerRemoveBackground(app: FastifyInstance) {
bgFilename = sanitizeFilename(part.filename ?? "background");
} else if (part.type === "field" && part.fieldname === "settings") {
settingsRaw = part.value as string;
} else if (part.type === "field" && part.fieldname === "fileId") {
fileId = part.value as string;
} else if (part.type === "field" && part.fieldname === "saveMode") {
saveModeRaw = part.value as string;
}
}
} catch (err) {
@@ -263,6 +270,13 @@ export function registerRemoveBackground(app: FastifyInstance) {
});
}
// Same 400 gate as the Phase 1 route: this is the FINAL request when the
// user applies effects, so it carries the library save choice (#565).
const saveMode = parseSaveModeField(saveModeRaw);
if (saveMode === null) {
return reply.status(400).send({ error: INVALID_SAVE_MODE_ERROR });
}
if (!settingsRaw) {
return reply.status(400).send({ error: "No settings provided" });
}
@@ -340,10 +354,23 @@ export function registerRemoveBackground(app: FastifyInstance) {
const outputFilename = `${baseName}_nobg.${fmt}`;
await putObject(`outputs/${jobId}/${outputFilename}`, resultBuffer);
// Auto-save the composited result (not the Phase 1 transparent
// intermediate) to the library when the run referenced a library file.
const savedFileId = await autoSaveToLibrary({
fileId: fileId ?? undefined,
saveMode,
userId: getAuthUser(request)?.id ?? null,
buffer: resultBuffer,
outName: outputFilename,
contentType: BG_FORMAT_CONTENT_TYPES[fmt],
toolId: "remove-background",
});
return reply.send({
jobId,
downloadUrl: `/api/v1/download/${jobId}/${encodeURIComponent(outputFilename)}`,
processedSize: resultBuffer.length,
savedFileId,
});
} catch (err) {
request.log.error({ err }, "Effects processing failed");
@@ -271,6 +271,11 @@ export function EraseObjectSettings({
if (files.length === 0 || !eraserRef.current) return;
const capturedIndex = useFileStore.getState().selectedIndex;
// Library file this single-file run derives from (#565). Batch runs
// (handleProcessAll) never auto-save, matching the standard processor.
const capturedEntry = useFileStore.getState().entries[capturedIndex];
const saveMode = useFileStore.getState().librarySaveMode;
useFileStore.getState().setLastSavedLibraryFileId(null);
const maskBlob = await eraserRef.current.exportMask();
if (!maskBlob) return;
@@ -295,6 +300,9 @@ export function EraseObjectSettings({
const clientJobId = generateId();
const applyResult = (r: Record<string, unknown>) => {
if (r.savedFileId) {
useFileStore.getState().setLastSavedLibraryFileId(r.savedFileId as string);
}
useFileStore.getState().updateEntry(capturedIndex, {
processedUrl: r.downloadUrl as string,
processedPreviewUrl: (r.previewUrl as string) ?? null,
@@ -302,6 +310,9 @@ export function EraseObjectSettings({
status: "completed",
originalSize: r.originalSize as number,
processedSize: r.processedSize as number,
...(r.savedFileId && saveMode === "overwrite"
? { serverFileId: r.savedFileId as string }
: {}),
});
};
@@ -346,6 +357,10 @@ export function EraseObjectSettings({
formData.append("format", outputFormat);
formData.append("quality", String(quality));
formData.append("qualityMode", qualityMode);
if (capturedEntry?.serverFileId) {
formData.append("fileId", capturedEntry.serverFileId);
formData.append("saveMode", saveMode);
}
const xhr = new XMLHttpRequest();
xhr.timeout = 600_000;
+51 -7
View File
@@ -1,5 +1,7 @@
import { Check, ChevronDown, ChevronRight, Copy, Download, Info } from "lucide-react";
import type { LibrarySaveMode } from "@snapotter/shared";
import { Check, CheckCircle2, ChevronDown, ChevronRight, Copy, Download, Info } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { ProgressCard } from "@/components/common/progress-card";
import { useTranslation } from "@/contexts/i18n-context";
import { formatHeaders } from "@/lib/api";
@@ -50,7 +52,10 @@ export function ocrOneFile(
networkError?: string;
processingFailed?: string;
} = {},
): Promise<string> {
// When the file came from the library, forward the save choice so the
// extracted-text artifact auto-saves (#565). Only sent for single-file runs.
library?: { fileId: string; saveMode: LibrarySaveMode },
): Promise<{ text: string; savedFileId?: string }> {
return new Promise((resolve, reject) => {
const clientJobId = generateId();
let settled = false;
@@ -65,11 +70,11 @@ export function ocrOneFile(
es = null;
};
const resolveOnce = (text: string) => {
const resolveOnce = (result: { text: string; savedFileId?: string }) => {
if (settled) return;
settled = true;
cleanup();
resolve(text);
resolve(result);
};
const rejectOnce = (error: Error) => {
@@ -107,7 +112,11 @@ export function ocrOneFile(
if (data.type !== "single") return;
armStallTimer();
if (data.phase === "complete" && data.result) {
resolveOnce(typeof data.result.text === "string" ? data.result.text : "");
resolveOnce({
text: typeof data.result.text === "string" ? data.result.text : "",
savedFileId:
typeof data.result.savedFileId === "string" ? data.result.savedFileId : undefined,
});
return;
}
if (data.phase === "failed") {
@@ -127,6 +136,10 @@ export function ocrOneFile(
formData.append("file", file);
formData.append("settings", JSON.stringify(settings));
formData.append("clientJobId", clientJobId);
if (library) {
formData.append("fileId", library.fileId);
formData.append("saveMode", library.saveMode);
}
const xhr = new XMLHttpRequest();
xhr.timeout = 600_000;
@@ -144,7 +157,10 @@ export function ocrOneFile(
if (xhr.status >= 200 && xhr.status < 300) {
try {
const body = JSON.parse(xhr.responseText);
resolveOnce(typeof body.text === "string" ? body.text : "");
resolveOnce({
text: typeof body.text === "string" ? body.text : "",
savedFileId: typeof body.savedFileId === "string" ? body.savedFileId : undefined,
});
} catch {
rejectOnce(new Error(messages.processingFailed ?? "Invalid response"));
}
@@ -179,6 +195,10 @@ export function OcrSettings() {
const [langOpen, setLangOpen] = useState(false);
const [text, setText] = useState<string | null>(null);
// Library file id the extracted text was auto-saved as (single-file runs from
// the library only). OCR renders its own text result, not the shared
// ReviewPanel, so it surfaces the "saved to Files" confirmation inline (#565).
const [savedLibraryFileId, setSavedLibraryFileId] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const [progressPhase, setProgressPhase] = useState<"idle" | "uploading" | "processing">("idle");
const [progressPercent, setProgressPercent] = useState(0);
@@ -205,6 +225,7 @@ export function OcrSettings() {
setError(null);
setText(null);
setSavedLibraryFileId(null);
setProcessing(true);
setProgressPhase("uploading");
setProgressPercent(0);
@@ -228,8 +249,16 @@ export function OcrSettings() {
const fileBase = (i / total) * 100;
const fileShare = 100 / total;
// Only a single-file run auto-saves to the library; a multi-file batch
// never sends a fileId (matching the standard batch processor).
const serverFileId =
total === 1 ? useFileStore.getState().entries[i]?.serverFileId : undefined;
const library = serverFileId
? { fileId: serverFileId, saveMode: useFileStore.getState().librarySaveMode }
: undefined;
try {
const text = await ocrOneFile(
const { text, savedFileId } = await ocrOneFile(
file,
settings,
{
@@ -249,7 +278,13 @@ export function OcrSettings() {
networkError: t.errors.networkError,
processingFailed: t.errors.processingFailed,
},
library,
);
// Only single-file runs send a fileId, so savedFileId is single-file only.
if (savedFileId) {
setSavedLibraryFileId(savedFileId);
useFileStore.getState().setLastSavedLibraryFileId(savedFileId);
}
results.push(total > 1 ? `--- ${file.name} ---\n${text || "(no text detected)"}` : text);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
@@ -382,6 +417,15 @@ export function OcrSettings() {
{/* Result */}
{text !== null && (
<div className="space-y-2">
{savedLibraryFileId && (
<div className="flex items-center gap-1.5 text-xs text-success-ink">
<CheckCircle2 className="h-3 w-3" />
{t.toolPage.savedToFiles}
<Link to="/files" className="underline underline-offset-2 hover:text-foreground">
{t.toolPage.viewInFiles}
</Link>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-muted-foreground">
{t.toolSettings.ocr.extractedText}
@@ -651,7 +651,7 @@ interface RemoveBgSettingsProps {
export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
const { t } = useTranslation();
const { files } = useFileStore();
const { files, currentEntry } = useFileStore();
const {
processFiles,
processAllFiles,
@@ -722,6 +722,22 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
const hasFile = files.length > 0;
const bgRemoved = bgJobId !== null && !processing;
// Whether the user has configured any compositing effect.
const hasEffectsToApply =
settings.blurEnabled ||
settings.shadowEnabled ||
((settings.backgroundType as string) || "transparent") !== "transparent";
// The Phase 2 effects request is the single owner of the library save (#565):
// it produces the final artifact the user downloads, whether that is the
// composite (effects on) or the transparent result (effects off). Route the
// download through it whenever effects apply, or whenever the file came from
// the library and so needs saving. Phase 1 never saves an intermediate. This
// keeps the skip and save decisions reading the same live state at one point,
// so toggling effects after Phase 1 can neither drop nor double the save.
const fromLibrary = Boolean(currentEntry?.serverFileId);
const needsEffectsRequest = Boolean(hasEffectsToApply) || fromLibrary;
// Build CSS preview state from current settings and send to tool-page
useEffect(() => {
if (!bgRemoved || !onBgPreview) return;
@@ -817,7 +833,9 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
// Use processFiles for the progress/SSE flow - it handles everything
// But we need the extended response. Override via a fetch after processFiles completes.
// Actually, let's use processFiles and then fetch the job info.
processFiles(files, { model: settings.model });
// Phase 1 is always an intermediate here: the Phase 2 effects request owns
// the library save (#565), so never auto-save the transparent result.
processFiles(files, { model: settings.model }, { skipLibrarySave: true });
};
// After processFiles completes, extract jobId from downloadUrl
@@ -842,7 +860,14 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
const handleDownloadWithEffects = async () => {
if (!bgJobId || !bgFilename) return;
// Library file this run derives from (when opened from the file library):
// the effects request is the FINAL step, so it carries the save choice.
const { entries, selectedIndex, librarySaveMode } = useFileStore.getState();
const capturedEntry = entries[selectedIndex];
setApplyingEffects(true);
setEffectsError(null);
useFileStore.getState().setLastSavedLibraryFileId(null);
try {
const formData = new FormData();
const effectSettings: Record<string, unknown> = {
@@ -866,6 +891,11 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
formData.append("backgroundImage", bgImageFile);
}
if (capturedEntry?.serverFileId) {
formData.append("fileId", capturedEntry.serverFileId);
formData.append("saveMode", librarySaveMode);
}
const headers = formatHeaders();
const response = await fetch("/api/v1/tools/image/remove-background/effects", {
method: "POST",
@@ -882,6 +912,16 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
setEffectsDownloadUrl(result.downloadUrl);
setEffectsError(null);
// Surface the "saved to your files" indicator and, on overwrite,
// re-anchor so a subsequent run derives from the saved version (#565).
if (result.savedFileId) {
const store = useFileStore.getState();
store.setLastSavedLibraryFileId(result.savedFileId as string);
if (librarySaveMode === "overwrite") {
store.updateEntry(selectedIndex, { serverFileId: result.savedFileId as string });
}
}
// Auto-trigger download
const a = document.createElement("a");
a.href = result.downloadUrl;
@@ -896,11 +936,6 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
}
};
const hasEffectsToApply =
settings.blurEnabled ||
settings.shadowEnabled ||
((settings.backgroundType as string) || "transparent") !== "transparent";
return (
<div className="space-y-4">
<RemoveBgControls settings={settings} onChange={setSettings} />
@@ -941,10 +976,12 @@ export function RemoveBgSettings({ onBgPreview }: RemoveBgSettingsProps = {}) {
</button>
) : null}
{/* Phase 2: Single smart download button */}
{/* Phase 2: Single smart download button. Routes through the effects
request (which saves) when effects apply or the file came from the
library; otherwise a plain instant download with no save needed. */}
{bgRemoved && files.length <= 1 && (
<div className="space-y-2">
{hasEffectsToApply ? (
{needsEffectsRequest ? (
<button
type="button"
data-testid="remove-background-download-effects"
+5 -2
View File
@@ -265,7 +265,7 @@ export function useToolProcessor(toolId: string) {
}, [reconnectSSE]);
const processFiles = useCallback(
(files: File[], settings: Record<string, unknown>) => {
(files: File[], settings: Record<string, unknown>, opts?: { skipLibrarySave?: boolean }) => {
if (files.length === 0) {
setError("No files selected");
return;
@@ -437,7 +437,10 @@ export function useToolProcessor(toolId: string) {
const capturedEntry = useFileStore.getState().entries[capturedIndex];
saveModeRef.current = useFileStore.getState().librarySaveMode;
if (capturedEntry?.serverFileId) {
// skipLibrarySave lets a multi-phase tool suppress auto-saving an
// intermediate output (e.g. remove-background's Phase 1 transparent
// result) so the final phase owns the library save instead.
if (!opts?.skipLibrarySave && capturedEntry?.serverFileId) {
formData.append("fileId", capturedEntry.serverFileId);
formData.append("saveMode", saveModeRef.current);
}
+6 -11
View File
@@ -2,16 +2,17 @@ import { BASE_CONFIG, CONVERSION_PRESETS } from "./conversion-presets.js";
/**
* Tools where the library save-mode choice (#495) does not work end to end,
* so the selector must stay hidden: either the tool's hand-written route
* ignores the multipart fileId/saveMode pair, or its settings component
* submits through its own XHR without sending them. For these tools a
* library-sourced edit is never auto-saved (the pre-#495 status quo).
* so the selector must stay hidden: the tool's hand-written route ignores the
* multipart fileId/saveMode pair, so a library-sourced edit is never
* auto-saved (the pre-#495 status quo).
*
* When wiring one of these up (parse fileId/saveMode in the route like
* tool-factory.ts, append them in the client like sign-pdf-settings.tsx),
* remove it from this set so the selector appears. The integration suite
* pins the other direction: every tool NOT listed here returns 400 for an
* invalid saveMode.
* invalid saveMode. (#565 wired the five custom-client submitters that had a
* ready route but a submitter that dropped the pair: ocr, erase-object,
* remove-background, background-replace, blur-background.)
*/
export const LIBRARY_SAVE_MODE_UNSUPPORTED_TOOLS: ReadonlySet<string> = new Set([
// Custom routes without fileId/saveMode handling
@@ -43,12 +44,6 @@ export const LIBRARY_SAVE_MODE_UNSUPPORTED_TOOLS: ReadonlySet<string> = new Set(
"strip-metadata",
"vectorize",
"watermark-image",
// Routes accept saveMode but the custom client submitters do not send it yet
"ocr",
"remove-background",
"erase-object",
"background-replace",
"blur-background",
// Conversion presets served by the custom image-to-pdf / pdf-to-image /
// svg-to-raster routes (the registry group rides the factory and works)
...CONVERSION_PRESETS.filter((p) => BASE_CONFIG[p.base]?.group !== "registry").map((p) => p.id),
@@ -8,7 +8,7 @@
* enqueueToolJob mocked so no Python model or worker runs.
*/
import { apiToolPath } from "@snapotter/shared";
import { apiToolPath, LIBRARY_SAVE_MODE_UNSUPPORTED_TOOLS } from "@snapotter/shared";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { INVALID_SAVE_MODE_ERROR } from "../../../apps/api/src/jobs/types.js";
import { fixtures, readFixture } from "../../fixtures/index.js";
@@ -136,6 +136,122 @@ describe("saveMode is ignored by routes outside the feature", () => {
}
});
describe("custom-client tools participate in the saveMode feature (#565)", () => {
// Wiring their submitters to send fileId/saveMode means the selector must
// now show for them, so they must NOT be in the unsupported set.
for (const toolId of [
"ocr",
"erase-object",
"remove-background",
"background-replace",
"blur-background",
]) {
it(`${toolId} is not in LIBRARY_SAVE_MODE_UNSUPPORTED_TOOLS`, () => {
expect(LIBRARY_SAVE_MODE_UNSUPPORTED_TOOLS.has(toolId)).toBe(false);
});
}
});
describe("custom-client routes forward fileId and saveMode into the job", () => {
it("erase-object forwards the pair", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "img.png", contentType: "image/png", content: PNG },
{ name: "mask", filename: "mask.png", contentType: "image/png", content: PNG },
{ name: "fileId", content: "lib-erase" },
{ name: "saveMode", content: "overwrite" },
]);
const res = await app.inject({
method: "POST",
url: apiToolPath("erase-object"),
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(202);
expect(mocks.enqueueToolJob).toHaveBeenCalledTimes(1);
expect(mocks.enqueueToolJob.mock.calls[0][0]).toMatchObject({
toolId: "erase-object",
fileId: "lib-erase",
saveMode: "overwrite",
});
});
it("blur-background forwards the pair", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "img.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({ intensity: 40 }) },
{ name: "fileId", content: "lib-blur" },
{ name: "saveMode", content: "new" },
]);
const res = await app.inject({
method: "POST",
url: apiToolPath("blur-background"),
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(202);
expect(mocks.enqueueToolJob).toHaveBeenCalledTimes(1);
expect(mocks.enqueueToolJob.mock.calls[0][0]).toMatchObject({
toolId: "blur-background",
fileId: "lib-blur",
saveMode: "new",
});
});
it("background-replace forwards the pair", async () => {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "img.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({ backgroundType: "color", color: "#ffffff" }) },
{ name: "fileId", content: "lib-bgr" },
{ name: "saveMode", content: "overwrite" },
]);
const res = await app.inject({
method: "POST",
url: apiToolPath("background-replace"),
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(202);
expect(mocks.enqueueToolJob).toHaveBeenCalledTimes(1);
expect(mocks.enqueueToolJob.mock.calls[0][0]).toMatchObject({
toolId: "background-replace",
fileId: "lib-bgr",
saveMode: "overwrite",
});
});
it("ocr forwards the pair", async () => {
// quality "fast" + a non-Korean language reaches enqueue without any OCR
// runtime bundle (resolveOcrIngressSettings short-circuits before the gate).
const { body, contentType } = createMultipartPayload([
{ name: "file", filename: "img.png", contentType: "image/png", content: PNG },
{ name: "settings", content: JSON.stringify({ quality: "fast", language: "en" }) },
{ name: "fileId", content: "lib-ocr" },
{ name: "saveMode", content: "new" },
]);
const res = await app.inject({
method: "POST",
url: apiToolPath("ocr"),
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(202);
expect(mocks.enqueueToolJob).toHaveBeenCalledTimes(1);
expect(mocks.enqueueToolJob.mock.calls[0][0]).toMatchObject({
toolId: "ocr",
fileId: "lib-ocr",
saveMode: "new",
});
});
});
describe("sign-pdf saveMode pass-through", () => {
it("forwards fileId and saveMode into the enqueued job", async () => {
const { body, contentType } = createMultipartPayload([
@@ -8,7 +8,10 @@
* Also covers the /effects sub-route for Phase 2 compositing.
*/
import { randomUUID } from "node:crypto";
import sharp from "sharp";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { putObject } from "../../../../apps/api/src/lib/object-storage.js";
import { fixtures, readFixture } from "../../../fixtures/index.js";
import {
buildTestApp,
@@ -502,3 +505,184 @@ describe("Remove Background", () => {
expect(res.statusCode).toBe(401);
});
});
// ── Phase 2 effects route: library auto-save (#495 / #565) ─────────
//
// The compositing route is pure Sharp (no AI sidecar), so its library
// auto-save is testable end to end in CI. It saves the FINAL composited
// image (not the transparent Phase 1 intermediate) under the chosen
// saveMode when the request references a library file.
/** Upload a PNG into the library, return its file id. */
async function uploadLibraryFile(filename: string): Promise<string> {
const { body, contentType } = createMultipartPayload([
{ name: "file", filename, contentType: "image/png", content: PNG },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/files/upload",
headers: { "content-type": contentType, authorization: `Bearer ${adminToken}` },
body,
});
expect(res.statusCode).toBe(201);
return JSON.parse(res.body).files[0].id;
}
/** Fetch file detail (metadata + version chain). */
async function getFileDetail(id: string) {
const res = await app.inject({
method: "GET",
url: `/api/v1/files/${id}`,
headers: { authorization: `Bearer ${adminToken}` },
});
expect(res.statusCode).toBe(200);
return JSON.parse(res.body);
}
/**
* Seed the cached Phase 1 artifacts the effects route reads: a transparent
* subject (`_mask.png`) and the original (`_original.png`) under the job's
* output prefix, both matching the `<base>` derived from the filename.
*/
async function seedPhase1Cache(jobId: string, base: string): Promise<void> {
const mask = await sharp({
create: { width: 20, height: 20, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
})
.png()
.toBuffer();
const original = await sharp({
create: { width: 20, height: 20, channels: 3, background: { r: 10, g: 20, b: 30 } },
})
.png()
.toBuffer();
await putObject(`outputs/${jobId}/${base}_mask.png`, mask);
await putObject(`outputs/${jobId}/${base}_original.png`, original);
}
function effectsPayload(fields: Array<{ name: string; content: string }>) {
return createMultipartPayload(fields);
}
describe("Remove Background effects route: library saveMode", () => {
it("saves the composited result as an independent new library file", async () => {
const originalId = await uploadLibraryFile("rbfxnew.png");
const jobId = randomUUID();
await seedPhase1Cache(jobId, "rbfxnew");
const { body, contentType } = effectsPayload([
{
name: "settings",
content: JSON.stringify({
jobId,
filename: "rbfxnew.png",
backgroundType: "color",
backgroundColor: "#FF0000",
outputFormat: "png",
}),
},
{ name: "fileId", content: originalId },
{ name: "saveMode", content: "new" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/remove-background/effects",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(200);
const parsed = JSON.parse(res.body);
expect(parsed.savedFileId).toBeDefined();
expect(parsed.savedFileId).not.toBe(originalId);
const detail = await getFileDetail(parsed.savedFileId);
expect(detail.file.version).toBe(1);
expect(detail.file.parentId).toBeNull();
expect(detail.file.toolChain).toContain("remove-background");
});
it("overwrite creates a superseding version linked to the original", async () => {
const originalId = await uploadLibraryFile("rbfxover.png");
const jobId = randomUUID();
await seedPhase1Cache(jobId, "rbfxover");
const { body, contentType } = effectsPayload([
{
name: "settings",
content: JSON.stringify({
jobId,
filename: "rbfxover.png",
backgroundType: "color",
backgroundColor: "#00FF00",
outputFormat: "png",
}),
},
{ name: "fileId", content: originalId },
{ name: "saveMode", content: "overwrite" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/remove-background/effects",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(200);
const parsed = JSON.parse(res.body);
expect(parsed.savedFileId).toBeDefined();
const detail = await getFileDetail(parsed.savedFileId);
expect(detail.file.version).toBe(2);
expect(detail.file.parentId).toBe(originalId);
});
it("does not save when no fileId is sent", async () => {
const jobId = randomUUID();
await seedPhase1Cache(jobId, "rbfxnolib");
const { body, contentType } = effectsPayload([
{
name: "settings",
content: JSON.stringify({
jobId,
filename: "rbfxnolib.png",
backgroundType: "color",
backgroundColor: "#0000FF",
outputFormat: "png",
}),
},
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/remove-background/effects",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).savedFileId).toBeUndefined();
});
it("rejects an invalid saveMode with 400", async () => {
const { body, contentType } = effectsPayload([
{
name: "settings",
content: JSON.stringify({ jobId: randomUUID(), filename: "x.png" }),
},
{ name: "saveMode", content: "destroy-everything" },
]);
const res = await app.inject({
method: "POST",
url: "/api/v1/tools/image/remove-background/effects",
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
body,
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/saveMode/i);
});
});
+21 -4
View File
@@ -90,7 +90,7 @@ describe("OCR async response handling", () => {
result: { text: "queued OCR text", actualQuality: "fast" },
});
await expect(promise).resolves.toBe("queued OCR text");
await expect(promise).resolves.toMatchObject({ text: "queued OCR text" });
expect(events.close).toHaveBeenCalledTimes(1);
events.emit({ type: "single", phase: "failed", error: "late duplicate" });
@@ -109,7 +109,7 @@ describe("OCR async response handling", () => {
phase: "complete",
result: { text: "fast worker result" },
});
await expect(promise).resolves.toBe("fast worker result");
await expect(promise).resolves.toMatchObject({ text: "fast worker result" });
xhr.status = 202;
xhr.onload?.();
@@ -127,7 +127,24 @@ describe("OCR async response handling", () => {
xhr.responseText = JSON.stringify({ text: "sync OCR text" });
xhr.onload?.();
await expect(promise).resolves.toBe("sync OCR text");
await expect(promise).resolves.toMatchObject({ text: "sync OCR text" });
expect(events.close).toHaveBeenCalledTimes(1);
});
it("surfaces the saved library file id from the terminal worker result", async () => {
const promise = runOcr();
const xhr = xhrs[0];
const events = MockEventSource.instances[0];
xhr.status = 202;
xhr.onload?.();
events.emit({
type: "single",
phase: "complete",
result: { text: "saved text", savedFileId: "lib-42" },
});
await expect(promise).resolves.toEqual({ text: "saved text", savedFileId: "lib-42" });
expect(events.close).toHaveBeenCalledTimes(1);
});
@@ -186,6 +203,6 @@ describe("OCR async response handling", () => {
phase: "complete",
result: { text: "still queued safely" },
});
await expect(promise).resolves.toBe("still queued safely");
await expect(promise).resolves.toMatchObject({ text: "still queued safely" });
});
});