mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
feat(files): add save-as-new vs overwrite choice for library file edits (#564)
Editing a file from the library used to silently supersede it: the worker auto-saved every result as a new version and the leaf-only listing hid the original, which read as a destructive overwrite. Tool pages now show a per-edit choice for library-sourced files. The default saves the result as an independent new file and keeps the original; picking overwrite keeps the old superseding-version behavior. The client sends a saveMode multipart field next to fileId, validated with a 400 on unknown values, and autoSaveToLibrary branches on it. Every hand-written route that honors fileId parses the field the same way as the factory. The review panel shows where an auto-saved result went instead of offering a second, duplicate save. Tools whose route or submitter ignores fileId keep the selector hidden via a shared unsupported-tools set, and the choice resets to the non-destructive default whenever a new file is staged. Closes #495
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
import fs from "node:fs";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect, getTestImagePath, test, uploadTestImage, waitForProcessing } from "./helpers";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Library save-mode choice (issue #495)
|
||||
//
|
||||
// When a tool input comes from the file library, the tool page offers a
|
||||
// per-edit choice: save the result as a new file (default, original stays)
|
||||
// or overwrite the original (superseding version). Serial bucket: these
|
||||
// tests assert on the global library list.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function authHeaders(page: Page): Promise<Record<string, string>> {
|
||||
const token = await page
|
||||
.evaluate(() => localStorage.getItem("snapotter-token"))
|
||||
.catch(() => null);
|
||||
return token ? { authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
/** Upload a PNG into the library via the API, return its file id. */
|
||||
async function seedLibraryFile(page: Page, name: string): Promise<string> {
|
||||
const res = await page.request.post("/api/v1/files/upload", {
|
||||
headers: await authHeaders(page),
|
||||
multipart: {
|
||||
file: { name, mimeType: "image/png", buffer: fs.readFileSync(getTestImagePath()) },
|
||||
},
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
return (await res.json()).files[0].id;
|
||||
}
|
||||
|
||||
/** List library file ids matching a search term. */
|
||||
async function listLibraryIds(page: Page, search: string): Promise<string[]> {
|
||||
const res = await page.request.get(`/api/v1/files?search=${encodeURIComponent(search)}`, {
|
||||
headers: await authHeaders(page),
|
||||
});
|
||||
expect(res.ok()).toBeTruthy();
|
||||
return ((await res.json()).files as Array<{ id: string }>).map((f) => f.id);
|
||||
}
|
||||
|
||||
/** Import a seeded library file into the resize tool via the files page. */
|
||||
async function importIntoResize(page: Page, filename: string) {
|
||||
await page.goto("/image/resize");
|
||||
await page.getByRole("link", { name: /import from files/i }).click();
|
||||
await expect(page).toHaveURL(/\/files/);
|
||||
|
||||
// Search (300ms debounce), then select the file row and confirm
|
||||
await page.getByPlaceholder("Search files...").fill(filename.replace(".png", ""));
|
||||
await page.getByText(filename, { exact: true }).first().click();
|
||||
await page.getByRole("button", { name: /select file/i }).click();
|
||||
|
||||
// Back on the tool page with the file staged and library-linked
|
||||
await expect(page).toHaveURL(/\/image\/resize/);
|
||||
await expect(page.getByText("This file is from your Files")).toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
async function processResize(page: Page) {
|
||||
await page.locator("input[placeholder='Auto']").first().fill("200");
|
||||
await page.getByTestId("resize-submit").click();
|
||||
await waitForProcessing(page);
|
||||
await expect(page.getByTestId("resize-download")).toBeVisible({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
test.describe("Library save mode", () => {
|
||||
test("default keeps the original and saves the result as a new file", async ({
|
||||
loggedInPage: page,
|
||||
}) => {
|
||||
const filename = `lsm-default-${Date.now()}.png`;
|
||||
const originalId = await seedLibraryFile(page, filename);
|
||||
|
||||
await importIntoResize(page, filename);
|
||||
|
||||
// Non-destructive default is preselected
|
||||
await expect(page.getByRole("radio", { name: /save result as a new file/i })).toBeChecked();
|
||||
|
||||
await processResize(page);
|
||||
|
||||
// The review panel reflects the auto-save instead of the manual link
|
||||
await expect(page.getByText("Saved to Files")).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: /view in files/i })).toBeVisible();
|
||||
// The manual save button is suppressed: it would create a duplicate copy
|
||||
await expect(page.getByRole("button", { name: /save to files/i })).toHaveCount(0);
|
||||
|
||||
// Library now holds BOTH the original and the edited copy
|
||||
const searchTerm = filename.replace(".png", "");
|
||||
await expect
|
||||
.poll(async () => (await listLibraryIds(page, searchTerm)).length, { timeout: 10_000 })
|
||||
.toBe(2);
|
||||
expect(await listLibraryIds(page, searchTerm)).toContain(originalId);
|
||||
});
|
||||
|
||||
test("overwrite replaces the original in the library list", async ({ loggedInPage: page }) => {
|
||||
const filename = `lsm-overwrite-${Date.now()}.png`;
|
||||
const originalId = await seedLibraryFile(page, filename);
|
||||
|
||||
await importIntoResize(page, filename);
|
||||
|
||||
await page.getByRole("radio", { name: /overwrite the original/i }).check();
|
||||
|
||||
await processResize(page);
|
||||
|
||||
// Only the superseding version remains listed; it links back to the original
|
||||
const searchTerm = filename.replace(".png", "");
|
||||
await expect
|
||||
.poll(async () => (await listLibraryIds(page, searchTerm)).length, { timeout: 10_000 })
|
||||
.toBe(1);
|
||||
const [survivorId] = await listLibraryIds(page, searchTerm);
|
||||
expect(survivorId).not.toBe(originalId);
|
||||
|
||||
const detailRes = await page.request.get(`/api/v1/files/${survivorId}`, {
|
||||
headers: await authHeaders(page),
|
||||
});
|
||||
expect(detailRes.ok()).toBeTruthy();
|
||||
const detail = await detailRes.json();
|
||||
expect(detail.file.version).toBe(2);
|
||||
expect(detail.file.parentId).toBe(originalId);
|
||||
});
|
||||
|
||||
test("plain uploads show no save-mode choice", async ({ loggedInPage: page }) => {
|
||||
await page.goto("/image/resize");
|
||||
await uploadTestImage(page);
|
||||
|
||||
await expect(page.getByText(/test-image/i).first()).toBeVisible();
|
||||
await expect(page.getByText("This file is from your Files")).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Integration tests for the library saveMode choice (issue #495).
|
||||
*
|
||||
* When a tool run references a library file (multipart fileId), the worker
|
||||
* auto-saves the result. The saveMode field controls how:
|
||||
* - "new" (default): insert an independent root file; the original stays
|
||||
* visible in the library list.
|
||||
* - "overwrite": insert a new version linked to the original, which then
|
||||
* supersedes it in the leaf-only listing (pre-#495 behavior).
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { sharedRedis } from "../../../apps/api/src/jobs/connection.js";
|
||||
import { bullPrefix } from "../../../apps/api/src/jobs/types.js";
|
||||
import { fixtures, readFixture } from "../../fixtures/index.js";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../test-server.js";
|
||||
|
||||
const PNG = readFixture(fixtures.image.base.png200);
|
||||
|
||||
let testApp: TestApp;
|
||||
let app: TestApp["app"];
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
interface ToolRunResult {
|
||||
statusCode: number;
|
||||
savedFileId?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the resize tool with optional fileId/saveMode fields. Tolerates the
|
||||
* 202 async fallback by polling the terminal SSE replay key in Redis.
|
||||
*/
|
||||
async function runResize(opts: {
|
||||
filename: string;
|
||||
fileId?: string;
|
||||
saveMode?: string;
|
||||
toolPath?: string;
|
||||
settings?: Record<string, unknown>;
|
||||
}): Promise<ToolRunResult> {
|
||||
const parts: Parameters<typeof createMultipartPayload>[0] = [
|
||||
{
|
||||
name: "file",
|
||||
filename: opts.filename,
|
||||
contentType: "image/png",
|
||||
content: PNG,
|
||||
},
|
||||
{ name: "settings", content: JSON.stringify(opts.settings ?? { width: 100 }) },
|
||||
];
|
||||
if (opts.fileId) parts.push({ name: "fileId", content: opts.fileId });
|
||||
if (opts.saveMode !== undefined) parts.push({ name: "saveMode", content: opts.saveMode });
|
||||
|
||||
const { body, contentType } = createMultipartPayload(parts);
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: opts.toolPath ?? "/api/v1/tools/image/resize",
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
if (res.statusCode === 200) {
|
||||
const parsed = JSON.parse(res.body);
|
||||
return { statusCode: 200, savedFileId: parsed.savedFileId };
|
||||
}
|
||||
if (res.statusCode === 202) {
|
||||
// Async fallback: wait for the terminal SSE replay frame in Redis
|
||||
const jobId = JSON.parse(res.body).jobId;
|
||||
const key = `${bullPrefix()}:terminal:${jobId}`;
|
||||
for (let i = 0; i < 150; i++) {
|
||||
const cached = await sharedRedis().get(key);
|
||||
if (cached) {
|
||||
const frame = JSON.parse(cached);
|
||||
expect(frame.phase).toBe("complete");
|
||||
return { statusCode: 200, savedFileId: frame.result?.savedFileId };
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
}
|
||||
throw new Error("Timed out waiting for async job result");
|
||||
}
|
||||
let error: string | undefined;
|
||||
try {
|
||||
error = JSON.parse(res.body).error;
|
||||
} catch {
|
||||
// Non-JSON error body
|
||||
}
|
||||
return { statusCode: res.statusCode, error };
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/** List library files matching a search term, return their ids. */
|
||||
async function listFileIds(search: string): Promise<string[]> {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/api/v1/files?search=${encodeURIComponent(search)}`,
|
||||
headers: { authorization: `Bearer ${adminToken}` },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
return JSON.parse(res.body).files.map((f: { id: string }) => f.id);
|
||||
}
|
||||
|
||||
describe("Library saveMode: default (save as new file)", () => {
|
||||
it("saves the result as an independent file and keeps the original listed", async () => {
|
||||
const originalId = await uploadLibraryFile("lsmdef.png");
|
||||
|
||||
const run = await runResize({ filename: "lsmdef.png", fileId: originalId });
|
||||
expect(run.statusCode).toBe(200);
|
||||
expect(run.savedFileId).toBeDefined();
|
||||
expect(run.savedFileId).not.toBe(originalId);
|
||||
|
||||
const detail = await getFileDetail(run.savedFileId!);
|
||||
expect(detail.file.version).toBe(1);
|
||||
expect(detail.file.parentId).toBeNull();
|
||||
expect(detail.file.toolChain).toContain("resize");
|
||||
|
||||
// Both the original and the new file remain visible in the library list
|
||||
const listed = await listFileIds("lsmdef");
|
||||
expect(listed).toContain(originalId);
|
||||
expect(listed).toContain(run.savedFileId!);
|
||||
});
|
||||
|
||||
it("treats an explicit saveMode=new like the default", async () => {
|
||||
const originalId = await uploadLibraryFile("lsmexp.png");
|
||||
|
||||
const run = await runResize({ filename: "lsmexp.png", fileId: originalId, saveMode: "new" });
|
||||
expect(run.statusCode).toBe(200);
|
||||
expect(run.savedFileId).toBeDefined();
|
||||
|
||||
const detail = await getFileDetail(run.savedFileId!);
|
||||
expect(detail.file.version).toBe(1);
|
||||
expect(detail.file.parentId).toBeNull();
|
||||
|
||||
const listed = await listFileIds("lsmexp");
|
||||
expect(listed).toContain(originalId);
|
||||
expect(listed).toContain(run.savedFileId!);
|
||||
});
|
||||
|
||||
it("carries the parent's toolChain into the new file for provenance", async () => {
|
||||
const originalId = await uploadLibraryFile("lsmchain.png");
|
||||
|
||||
// First edit overwrites the original (version chain)
|
||||
const first = await runResize({
|
||||
filename: "lsmchain.png",
|
||||
fileId: originalId,
|
||||
saveMode: "overwrite",
|
||||
});
|
||||
expect(first.statusCode).toBe(200);
|
||||
expect(first.savedFileId).toBeDefined();
|
||||
|
||||
// Second edit saves as new from the version; chain accumulates
|
||||
const second = await runResize({
|
||||
filename: "lsmchain.png",
|
||||
fileId: first.savedFileId!,
|
||||
saveMode: "new",
|
||||
toolPath: "/api/v1/tools/image/compress",
|
||||
settings: { mode: "quality", quality: 90 },
|
||||
});
|
||||
expect(second.statusCode).toBe(200);
|
||||
expect(second.savedFileId).toBeDefined();
|
||||
|
||||
const detail = await getFileDetail(second.savedFileId!);
|
||||
expect(detail.file.version).toBe(1);
|
||||
expect(detail.file.parentId).toBeNull();
|
||||
expect(detail.file.toolChain).toEqual(["resize", "compress"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Library saveMode: overwrite", () => {
|
||||
it("creates a new version that supersedes the original in the list", async () => {
|
||||
const originalId = await uploadLibraryFile("lsmover.png");
|
||||
|
||||
const run = await runResize({
|
||||
filename: "lsmover.png",
|
||||
fileId: originalId,
|
||||
saveMode: "overwrite",
|
||||
});
|
||||
expect(run.statusCode).toBe(200);
|
||||
expect(run.savedFileId).toBeDefined();
|
||||
expect(run.savedFileId).not.toBe(originalId);
|
||||
|
||||
const detail = await getFileDetail(run.savedFileId!);
|
||||
expect(detail.file.version).toBe(2);
|
||||
expect(detail.file.parentId).toBe(originalId);
|
||||
expect(detail.file.toolChain).toContain("resize");
|
||||
|
||||
// The original is superseded: only the new version appears in the list
|
||||
const listed = await listFileIds("lsmover");
|
||||
expect(listed).toContain(run.savedFileId!);
|
||||
expect(listed).not.toContain(originalId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Library saveMode: validation and guards", () => {
|
||||
it("rejects an invalid saveMode with 400", async () => {
|
||||
const originalId = await uploadLibraryFile("lsmbad.png");
|
||||
|
||||
const run = await runResize({
|
||||
filename: "lsmbad.png",
|
||||
fileId: originalId,
|
||||
saveMode: "destroy-everything",
|
||||
});
|
||||
expect(run.statusCode).toBe(400);
|
||||
expect(run.error).toMatch(/saveMode/i);
|
||||
});
|
||||
|
||||
it("does not save to the library when no fileId is sent", async () => {
|
||||
const run = await runResize({ filename: "lsmnone.png" });
|
||||
expect(run.statusCode).toBe(200);
|
||||
expect(run.savedFileId).toBeUndefined();
|
||||
|
||||
const listed = await listFileIds("lsmnone");
|
||||
expect(listed).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("silently skips the save for an unknown fileId", async () => {
|
||||
const run = await runResize({
|
||||
filename: "lsmghost.png",
|
||||
fileId: "00000000-0000-0000-0000-000000000000",
|
||||
});
|
||||
expect(run.statusCode).toBe(200);
|
||||
expect(run.savedFileId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Per-route coverage for the saveMode multipart field (#495) on the 20
|
||||
* hand-written tool routes that honor it. The parse-and-400 gate runs before
|
||||
* file and bundle validation, so a lone bogus saveMode field pins each
|
||||
* route's field capture and error contract without needing AI bundles.
|
||||
*
|
||||
* Mirrors ai-async-route-coverage.test.ts: bundle gates forced open and
|
||||
* enqueueToolJob mocked so no Python model or worker runs.
|
||||
*/
|
||||
|
||||
import { apiToolPath } 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";
|
||||
import {
|
||||
buildTestApp,
|
||||
createMultipartPayload,
|
||||
loginAsAdmin,
|
||||
type TestApp,
|
||||
} from "../test-server.js";
|
||||
|
||||
/** Custom routes that parse and forward saveMode. */
|
||||
const SAVE_MODE_ROUTES = [
|
||||
"ai-canvas-expand",
|
||||
"auto-subtitles",
|
||||
"background-replace",
|
||||
"blur-background",
|
||||
"blur-faces",
|
||||
"colorize",
|
||||
"enhance-faces",
|
||||
"erase-object",
|
||||
"noise-removal",
|
||||
"ocr",
|
||||
"ocr-pdf",
|
||||
"red-eye-removal",
|
||||
"remove-background",
|
||||
"remove-gif-background",
|
||||
"restore-photo",
|
||||
"sign-pdf",
|
||||
"transcribe-audio",
|
||||
"transparency-fixer",
|
||||
"upscale",
|
||||
];
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
enqueueToolJob: vi.fn(),
|
||||
waitForJob: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../../apps/api/src/lib/feature-status.js", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("../../../apps/api/src/lib/feature-status.js")>();
|
||||
return {
|
||||
...actual,
|
||||
isToolInstalled: () => true,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../../../apps/api/src/jobs/enqueue.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../../apps/api/src/jobs/enqueue.js")>();
|
||||
return {
|
||||
...actual,
|
||||
enqueueToolJob: mocks.enqueueToolJob,
|
||||
waitForJob: mocks.waitForJob,
|
||||
};
|
||||
});
|
||||
|
||||
const PDF = readFixture(fixtures.document.pdf2);
|
||||
const PNG = readFixture(fixtures.image.base.png200);
|
||||
|
||||
let testApp: TestApp;
|
||||
let app: TestApp["app"];
|
||||
let adminToken: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
testApp = await buildTestApp();
|
||||
app = testApp.app;
|
||||
adminToken = await loginAsAdmin(app);
|
||||
}, 30_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await testApp.cleanup();
|
||||
}, 10_000);
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.enqueueToolJob.mockReset().mockResolvedValue({});
|
||||
mocks.waitForJob.mockReset().mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe("saveMode 400 gate on custom routes", () => {
|
||||
for (const toolId of SAVE_MODE_ROUTES) {
|
||||
it(`${toolId} rejects an invalid saveMode with 400`, async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "saveMode", content: "bogus" },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: apiToolPath(toolId),
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(JSON.parse(res.body).error).toBe(INVALID_SAVE_MODE_ERROR);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("saveMode is ignored by routes outside the feature", () => {
|
||||
// Representative custom routes without fileId/saveMode handling: they are
|
||||
// listed in LIBRARY_SAVE_MODE_UNSUPPORTED_TOOLS, so the selector never
|
||||
// shows for them and a stray saveMode field must not change their errors.
|
||||
for (const toolId of ["watermark-image", "edit-metadata"]) {
|
||||
it(`${toolId} does not return the saveMode error`, async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "saveMode", content: "bogus" },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: apiToolPath(toolId),
|
||||
headers: { authorization: `Bearer ${adminToken}`, "content-type": contentType },
|
||||
body,
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBeGreaterThanOrEqual(400);
|
||||
let error: string | undefined;
|
||||
try {
|
||||
error = JSON.parse(res.body).error;
|
||||
} catch {
|
||||
// Non-JSON error body is fine; it is certainly not the saveMode error
|
||||
}
|
||||
expect(error).not.toBe(INVALID_SAVE_MODE_ERROR);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("sign-pdf saveMode pass-through", () => {
|
||||
it("forwards fileId and saveMode into the enqueued job", async () => {
|
||||
const { body, contentType } = createMultipartPayload([
|
||||
{ name: "file", filename: "contract.pdf", contentType: "application/pdf", content: PDF },
|
||||
{
|
||||
name: "placements",
|
||||
content: JSON.stringify([{ sig: 0, page: 0, x: 0.1, y: 0.1, w: 0.2, h: 0.1 }]),
|
||||
},
|
||||
{ name: "sig0", filename: "sig0.png", contentType: "image/png", content: PNG },
|
||||
{ name: "fileId", content: "lib-contract" },
|
||||
{ name: "saveMode", content: "overwrite" },
|
||||
]);
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: apiToolPath("sign-pdf"),
|
||||
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: "sign-pdf",
|
||||
fileId: "lib-contract",
|
||||
saveMode: "overwrite",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -220,6 +220,7 @@ function createMockRequest(opts: {
|
||||
filename?: string;
|
||||
settings?: string;
|
||||
fileId?: string;
|
||||
saveMode?: string;
|
||||
clientJobId?: string;
|
||||
fileCount?: number;
|
||||
}) {
|
||||
@@ -260,6 +261,15 @@ function createMockRequest(opts: {
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.saveMode) {
|
||||
parts.push({
|
||||
type: "field",
|
||||
fieldname: "saveMode",
|
||||
value: opts.saveMode,
|
||||
file: (async function* () {})(),
|
||||
});
|
||||
}
|
||||
|
||||
if (opts.clientJobId) {
|
||||
parts.push({
|
||||
type: "field",
|
||||
@@ -410,6 +420,65 @@ describe("createToolRoute", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 400 for an invalid saveMode field", async () => {
|
||||
const app = createMockApp();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
const req = createMockRequest({
|
||||
fileBuffer: Buffer.from("png-data"),
|
||||
settings: JSON.stringify({}),
|
||||
fileId: "lib-1",
|
||||
saveMode: "bogus",
|
||||
});
|
||||
|
||||
await handler(req, reply);
|
||||
|
||||
expect(reply.status).toHaveBeenCalledWith(400);
|
||||
expect(reply.send).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: expect.stringContaining("saveMode") }),
|
||||
);
|
||||
});
|
||||
|
||||
it("passes a valid saveMode through to the enqueued job", async () => {
|
||||
const app = createMockApp();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
const req = createMockRequest({
|
||||
fileBuffer: Buffer.from("png-data"),
|
||||
settings: JSON.stringify({}),
|
||||
fileId: "lib-1",
|
||||
saveMode: "overwrite",
|
||||
});
|
||||
|
||||
await handler(req, reply);
|
||||
|
||||
expect(vi.mocked(enqueueToolJob).mock.calls[0][0]).toMatchObject({
|
||||
fileId: "lib-1",
|
||||
saveMode: "overwrite",
|
||||
});
|
||||
});
|
||||
|
||||
it("enqueues no saveMode when the field is absent", async () => {
|
||||
const app = createMockApp();
|
||||
const id = "resize";
|
||||
createToolRoute(app as never, makeMockConfig(id));
|
||||
const handler = app.routes[apiToolPath(id)];
|
||||
const reply = createMockReply();
|
||||
const req = createMockRequest({
|
||||
fileBuffer: Buffer.from("png-data"),
|
||||
settings: JSON.stringify({}),
|
||||
fileId: "lib-1",
|
||||
});
|
||||
|
||||
await handler(req, reply);
|
||||
|
||||
expect(vi.mocked(enqueueToolJob).mock.calls[0][0].saveMode).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns 501 when AI feature bundle is not installed", async () => {
|
||||
vi.mocked(isToolInstalled).mockReturnValueOnce(false);
|
||||
const app = createMockApp();
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.stubGlobal("URL", {
|
||||
...globalThis.URL,
|
||||
createObjectURL: vi.fn(() => "blob:fake-url"),
|
||||
revokeObjectURL: vi.fn(),
|
||||
});
|
||||
|
||||
vi.mock("@/lib/image-preview", () => ({
|
||||
needsServerPreview: vi.fn(() => false),
|
||||
fetchDecodedPreview: vi.fn(() => Promise.resolve(null)),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/analytics", () => ({
|
||||
track: vi.fn(),
|
||||
}));
|
||||
|
||||
import { LibrarySaveModeSelector } from "@/components/common/library-save-mode-selector";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function makeFile(name: string): File {
|
||||
return new File([new ArrayBuffer(64)], name, { type: "image/png" });
|
||||
}
|
||||
|
||||
function stageLibraryFile() {
|
||||
useFileStore.getState().setFiles([makeFile("photo.png")]);
|
||||
useFileStore.getState().updateEntry(0, { serverFileId: "lib-1" });
|
||||
}
|
||||
|
||||
describe("LibrarySaveModeSelector", () => {
|
||||
beforeEach(() => {
|
||||
useFileStore.getState().reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders nothing when the selected file is not from the library", () => {
|
||||
useFileStore.getState().setFiles([makeFile("plain.png")]);
|
||||
const { container } = render(<LibrarySaveModeSelector toolId="resize" />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("shows both choices with 'save as new' selected by default", () => {
|
||||
stageLibraryFile();
|
||||
render(<LibrarySaveModeSelector toolId="resize" />);
|
||||
|
||||
const saveAsNew = screen.getByRole("radio", { name: /save result as a new file/i });
|
||||
const overwrite = screen.getByRole("radio", { name: /overwrite the original/i });
|
||||
expect((saveAsNew as HTMLInputElement).checked).toBe(true);
|
||||
expect((overwrite as HTMLInputElement).checked).toBe(false);
|
||||
});
|
||||
|
||||
it("updates the store when overwrite is chosen", () => {
|
||||
stageLibraryFile();
|
||||
render(<LibrarySaveModeSelector toolId="resize" />);
|
||||
|
||||
fireEvent.click(screen.getByRole("radio", { name: /overwrite the original/i }));
|
||||
|
||||
expect(useFileStore.getState().librarySaveMode).toBe("overwrite");
|
||||
});
|
||||
|
||||
it("disables the choice while processing", () => {
|
||||
stageLibraryFile();
|
||||
useFileStore.getState().setProcessing(true);
|
||||
render(<LibrarySaveModeSelector toolId="resize" />);
|
||||
|
||||
const saveAsNew = screen.getByRole("radio", { name: /save result as a new file/i });
|
||||
const overwrite = screen.getByRole("radio", { name: /overwrite the original/i });
|
||||
expect((saveAsNew as HTMLInputElement).disabled).toBe(true);
|
||||
expect((overwrite as HTMLInputElement).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("renders nothing for tools that do not honor the save mode", () => {
|
||||
stageLibraryFile();
|
||||
const { container } = render(<LibrarySaveModeSelector toolId="watermark-image" />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("renders nothing when multiple files would go through the batch path", () => {
|
||||
useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]);
|
||||
useFileStore.getState().updateEntry(0, { serverFileId: "lib-1" });
|
||||
useFileStore.getState().updateEntry(1, { serverFileId: "lib-2" });
|
||||
|
||||
const { container } = render(<LibrarySaveModeSelector toolId="compress" />);
|
||||
expect(container.innerHTML).toBe("");
|
||||
});
|
||||
|
||||
it("still renders for multi-input tools that process all files in one run", () => {
|
||||
useFileStore.getState().setFiles([makeFile("a.pdf"), makeFile("b.pdf")]);
|
||||
useFileStore.getState().updateEntry(0, { serverFileId: "lib-1" });
|
||||
|
||||
render(<LibrarySaveModeSelector toolId="merge-pdf" />);
|
||||
expect(screen.getByRole("radio", { name: /save result as a new file/i })).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/analytics", () => ({
|
||||
track: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/feedback/tool-feedback-prompt", () => ({
|
||||
ToolFeedbackPrompt: () => null,
|
||||
}));
|
||||
|
||||
import { ReviewPanel } from "@/components/common/review-panel";
|
||||
|
||||
function renderPanel(props: Partial<Parameters<typeof ReviewPanel>[0]> = {}) {
|
||||
return render(
|
||||
<MemoryRouter>
|
||||
<ReviewPanel
|
||||
filename="photo_resize.png"
|
||||
fileSize={512}
|
||||
fileType="PNG"
|
||||
originalSize={1024}
|
||||
downloadUrl="/api/v1/download/job-1/photo_resize.png"
|
||||
onUndo={() => {}}
|
||||
onStartOver={() => {}}
|
||||
currentToolId="resize"
|
||||
{...props}
|
||||
/>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("ReviewPanel library saved state (issue #495)", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("shows the manual save link when the result was not auto-saved", () => {
|
||||
renderPanel();
|
||||
|
||||
expect(screen.getByRole("button", { name: /save to files/i })).toBeDefined();
|
||||
expect(screen.queryByRole("link", { name: /view in files/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("replaces the manual save link with the saved indicator after an auto-save", () => {
|
||||
renderPanel({ savedLibraryFileId: "lib-copy" });
|
||||
|
||||
expect(screen.getByText("Saved to Files")).toBeDefined();
|
||||
expect(screen.getByRole("link", { name: /view in files/i })).toBeDefined();
|
||||
// The manual save button must be gone: clicking it would create a duplicate
|
||||
expect(screen.queryByRole("button", { name: /save to files/i })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows neither control for data-output tools", () => {
|
||||
renderPanel({ currentToolId: "ocr", savedLibraryFileId: "lib-copy" });
|
||||
|
||||
expect(screen.queryByRole("button", { name: /save to files/i })).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: /view in files/i })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
// @vitest-environment jsdom
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const revokeObjectURL = vi.fn();
|
||||
const createObjectURL = vi.fn((_obj: Blob | MediaSource) => "blob:fake-url");
|
||||
|
||||
vi.stubGlobal("URL", {
|
||||
...globalThis.URL,
|
||||
createObjectURL,
|
||||
revokeObjectURL,
|
||||
});
|
||||
|
||||
const imagePreviewMock = vi.hoisted(() => ({
|
||||
needsServerPreview: vi.fn(() => false),
|
||||
fetchDecodedPreview: vi.fn(() => Promise.resolve(null)),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/image-preview", () => imagePreviewMock);
|
||||
|
||||
vi.mock("@/lib/analytics", () => ({
|
||||
track: vi.fn(),
|
||||
}));
|
||||
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
function makeFile(name: string, size = 1024, type = "image/png"): File {
|
||||
const buf = new ArrayBuffer(size);
|
||||
return new File([buf], name, { type });
|
||||
}
|
||||
|
||||
describe("useFileStore library save mode (issue #495)", () => {
|
||||
beforeEach(() => {
|
||||
useFileStore.getState().reset();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("defaults librarySaveMode to non-destructive 'new'", () => {
|
||||
expect(useFileStore.getState().librarySaveMode).toBe("new");
|
||||
});
|
||||
|
||||
it("setLibrarySaveMode switches the mode", () => {
|
||||
useFileStore.getState().setLibrarySaveMode("overwrite");
|
||||
expect(useFileStore.getState().librarySaveMode).toBe("overwrite");
|
||||
});
|
||||
|
||||
it("reset restores the 'new' default and clears the last saved file id", () => {
|
||||
useFileStore.getState().setLibrarySaveMode("overwrite");
|
||||
useFileStore.getState().setLastSavedLibraryFileId("lib-1");
|
||||
|
||||
useFileStore.getState().reset();
|
||||
|
||||
expect(useFileStore.getState().librarySaveMode).toBe("new");
|
||||
expect(useFileStore.getState().lastSavedLibraryFileId).toBeNull();
|
||||
});
|
||||
|
||||
it("undoProcessing keeps the chosen mode but clears the last saved file id", () => {
|
||||
useFileStore.getState().setFiles([makeFile("a.png")]);
|
||||
useFileStore.getState().setLibrarySaveMode("overwrite");
|
||||
useFileStore.getState().setLastSavedLibraryFileId("lib-2");
|
||||
|
||||
useFileStore.getState().undoProcessing();
|
||||
|
||||
expect(useFileStore.getState().librarySaveMode).toBe("overwrite");
|
||||
expect(useFileStore.getState().lastSavedLibraryFileId).toBeNull();
|
||||
});
|
||||
|
||||
it("staging a new file set restores the non-destructive default", () => {
|
||||
useFileStore.getState().setFiles([makeFile("a.png")]);
|
||||
useFileStore.getState().setLibrarySaveMode("overwrite");
|
||||
|
||||
// A later library import stages a different file; the overwrite choice
|
||||
// made for the previous file must not carry over (#495 review finding).
|
||||
useFileStore.getState().setFiles([makeFile("b.png")]);
|
||||
|
||||
expect(useFileStore.getState().librarySaveMode).toBe("new");
|
||||
});
|
||||
|
||||
it("tracks the last saved library file id", () => {
|
||||
expect(useFileStore.getState().lastSavedLibraryFileId).toBeNull();
|
||||
useFileStore.getState().setLastSavedLibraryFileId("lib-3");
|
||||
expect(useFileStore.getState().lastSavedLibraryFileId).toBe("lib-3");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/image-preview", () => ({
|
||||
needsServerPreview: vi.fn(() => false),
|
||||
fetchDecodedPreview: vi.fn(() => Promise.resolve(null)),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/analytics", () => ({
|
||||
track: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => ({
|
||||
formatHeaders: () => new Map<string, string>(),
|
||||
parseApiError: () => "error",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/utils", async (importOriginal) => {
|
||||
const actual: Record<string, unknown> = await importOriginal();
|
||||
return { ...actual, generateId: () => "11111111-1111-4111-8111-111111111111" };
|
||||
});
|
||||
|
||||
import { useToolProcessor } from "@/hooks/use-tool-processor";
|
||||
import { useFileStore } from "@/stores/file-store";
|
||||
|
||||
interface MockXhr {
|
||||
status: number;
|
||||
responseText: string;
|
||||
timeout: number;
|
||||
upload: { onprogress?: unknown; onload?: unknown };
|
||||
onload?: () => void;
|
||||
onerror?: (() => void) | null;
|
||||
ontimeout?: (() => void) | null;
|
||||
open: ReturnType<typeof vi.fn>;
|
||||
send: ReturnType<typeof vi.fn>;
|
||||
setRequestHeader: ReturnType<typeof vi.fn>;
|
||||
abort: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
class MockEventSource {
|
||||
static instances: MockEventSource[] = [];
|
||||
onmessage: ((event: MessageEvent) => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
readyState = 1;
|
||||
close = vi.fn();
|
||||
|
||||
constructor(readonly url: string) {
|
||||
MockEventSource.instances.push(this);
|
||||
}
|
||||
}
|
||||
|
||||
let xhrs: MockXhr[];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("URL", {
|
||||
...globalThis.URL,
|
||||
createObjectURL: vi.fn(() => "blob:fake-url"),
|
||||
revokeObjectURL: vi.fn(),
|
||||
});
|
||||
useFileStore.getState().reset();
|
||||
xhrs = [];
|
||||
MockEventSource.instances = [];
|
||||
vi.stubGlobal("EventSource", MockEventSource);
|
||||
vi.stubGlobal(
|
||||
"XMLHttpRequest",
|
||||
vi.fn(() => {
|
||||
const xhr: MockXhr = {
|
||||
status: 0,
|
||||
responseText: "",
|
||||
timeout: 0,
|
||||
upload: {},
|
||||
open: vi.fn(),
|
||||
send: vi.fn(),
|
||||
setRequestHeader: vi.fn(),
|
||||
abort: vi.fn(),
|
||||
};
|
||||
xhrs.push(xhr);
|
||||
return xhr;
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function stageLibraryFile() {
|
||||
const file = new File([new ArrayBuffer(64)], "photo.png", { type: "image/png" });
|
||||
useFileStore.getState().setFiles([file]);
|
||||
useFileStore.getState().updateEntry(0, { serverFileId: "lib-original" });
|
||||
return file;
|
||||
}
|
||||
|
||||
function completeRun(xhr: MockXhr, savedFileId: string) {
|
||||
xhr.status = 200;
|
||||
xhr.responseText = JSON.stringify({
|
||||
jobId: "job-1",
|
||||
downloadUrl: "/api/v1/download/job-1/photo_resize.png",
|
||||
originalSize: 64,
|
||||
processedSize: 32,
|
||||
savedFileId,
|
||||
});
|
||||
xhr.onload?.();
|
||||
}
|
||||
|
||||
describe("useToolProcessor library save mode (issue #495)", () => {
|
||||
it("sends the chosen saveMode with the library fileId", async () => {
|
||||
const file = stageLibraryFile();
|
||||
useFileStore.getState().setLibrarySaveMode("overwrite");
|
||||
const { result, unmount } = renderHook(() => useToolProcessor("resize"));
|
||||
|
||||
act(() => {
|
||||
result.current.processFiles([file], {});
|
||||
});
|
||||
|
||||
const sent = xhrs[0].send.mock.calls[0][0] as FormData;
|
||||
expect(sent.get("fileId")).toBe("lib-original");
|
||||
expect(sent.get("saveMode")).toBe("overwrite");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("keeps serverFileId anchored to the original in 'new' mode", async () => {
|
||||
const file = stageLibraryFile();
|
||||
const { result, unmount } = renderHook(() => useToolProcessor("resize"));
|
||||
|
||||
act(() => {
|
||||
result.current.processFiles([file], {});
|
||||
});
|
||||
act(() => {
|
||||
completeRun(xhrs[0], "lib-copy");
|
||||
});
|
||||
|
||||
expect(useFileStore.getState().entries[0].serverFileId).toBe("lib-original");
|
||||
expect(useFileStore.getState().lastSavedLibraryFileId).toBe("lib-copy");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("re-anchors serverFileId to the saved version in 'overwrite' mode", async () => {
|
||||
const file = stageLibraryFile();
|
||||
useFileStore.getState().setLibrarySaveMode("overwrite");
|
||||
const { result, unmount } = renderHook(() => useToolProcessor("resize"));
|
||||
|
||||
act(() => {
|
||||
result.current.processFiles([file], {});
|
||||
});
|
||||
act(() => {
|
||||
completeRun(xhrs[0], "lib-v2");
|
||||
});
|
||||
|
||||
expect(useFileStore.getState().entries[0].serverFileId).toBe("lib-v2");
|
||||
expect(useFileStore.getState().lastSavedLibraryFileId).toBe("lib-v2");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("sends no saveMode for files that are not from the library", async () => {
|
||||
const file = new File([new ArrayBuffer(64)], "plain.png", { type: "image/png" });
|
||||
useFileStore.getState().setFiles([file]);
|
||||
const { result, unmount } = renderHook(() => useToolProcessor("resize"));
|
||||
|
||||
act(() => {
|
||||
result.current.processFiles([file], {});
|
||||
});
|
||||
|
||||
const sent = xhrs[0].send.mock.calls[0][0] as FormData;
|
||||
expect(sent.get("fileId")).toBeNull();
|
||||
expect(sent.get("saveMode")).toBeNull();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("keeps serverFileId anchored to the original on the async SSE path in 'new' mode", async () => {
|
||||
const file = stageLibraryFile();
|
||||
const { result, unmount } = renderHook(() => useToolProcessor("resize"));
|
||||
|
||||
act(() => {
|
||||
result.current.processFiles([file], {});
|
||||
});
|
||||
// 202 accepted: completion arrives via the SSE progress stream instead
|
||||
act(() => {
|
||||
xhrs[0].status = 202;
|
||||
xhrs[0].responseText = JSON.stringify({ jobId: "job-1", async: true });
|
||||
xhrs[0].onload?.();
|
||||
});
|
||||
act(() => {
|
||||
MockEventSource.instances[0].onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: "single",
|
||||
phase: "complete",
|
||||
result: {
|
||||
jobId: "job-1",
|
||||
downloadUrl: "/api/v1/download/job-1/photo_resize.png",
|
||||
originalSize: 64,
|
||||
processedSize: 32,
|
||||
savedFileId: "lib-copy",
|
||||
},
|
||||
}),
|
||||
} as MessageEvent);
|
||||
});
|
||||
|
||||
expect(useFileStore.getState().entries[0].serverFileId).toBe("lib-original");
|
||||
expect(useFileStore.getState().lastSavedLibraryFileId).toBe("lib-copy");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("re-anchors serverFileId on the async SSE path in 'overwrite' mode", async () => {
|
||||
const file = stageLibraryFile();
|
||||
useFileStore.getState().setLibrarySaveMode("overwrite");
|
||||
const { result, unmount } = renderHook(() => useToolProcessor("resize"));
|
||||
|
||||
act(() => {
|
||||
result.current.processFiles([file], {});
|
||||
});
|
||||
act(() => {
|
||||
xhrs[0].status = 202;
|
||||
xhrs[0].responseText = JSON.stringify({ jobId: "job-1", async: true });
|
||||
xhrs[0].onload?.();
|
||||
});
|
||||
act(() => {
|
||||
MockEventSource.instances[0].onmessage?.({
|
||||
data: JSON.stringify({
|
||||
type: "single",
|
||||
phase: "complete",
|
||||
result: {
|
||||
jobId: "job-1",
|
||||
downloadUrl: "/api/v1/download/job-1/photo_resize.png",
|
||||
originalSize: 64,
|
||||
processedSize: 32,
|
||||
savedFileId: "lib-v2",
|
||||
},
|
||||
}),
|
||||
} as MessageEvent);
|
||||
});
|
||||
|
||||
expect(useFileStore.getState().entries[0].serverFileId).toBe("lib-v2");
|
||||
expect(useFileStore.getState().lastSavedLibraryFileId).toBe("lib-v2");
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("clears the saved indicator when a batch run starts", async () => {
|
||||
const fileA = new File([new ArrayBuffer(64)], "a.png", { type: "image/png" });
|
||||
const fileB = new File([new ArrayBuffer(64)], "b.png", { type: "image/png" });
|
||||
useFileStore.getState().setFiles([fileA, fileB]);
|
||||
useFileStore.getState().setLastSavedLibraryFileId("lib-stale");
|
||||
const { result, unmount } = renderHook(() => useToolProcessor("compress"));
|
||||
|
||||
act(() => {
|
||||
result.current.processAllFiles([fileA, fileB], {});
|
||||
});
|
||||
|
||||
expect(useFileStore.getState().lastSavedLibraryFileId).toBeNull();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it("clears the previous saved indicator when a new run starts", async () => {
|
||||
const file = stageLibraryFile();
|
||||
const { result, unmount } = renderHook(() => useToolProcessor("resize"));
|
||||
|
||||
act(() => {
|
||||
result.current.processFiles([file], {});
|
||||
});
|
||||
act(() => {
|
||||
completeRun(xhrs[0], "lib-copy");
|
||||
});
|
||||
expect(useFileStore.getState().lastSavedLibraryFileId).toBe("lib-copy");
|
||||
|
||||
act(() => {
|
||||
result.current.processFiles([file], {});
|
||||
});
|
||||
expect(useFileStore.getState().lastSavedLibraryFileId).toBeNull();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user