Files
multica/packages/views/editor/use-editor-upload.test.tsx
Naiyuan Qing 3b08456264 feat(views): block draft-fixing actions while attachments upload (#5465)
Submitting a composer mid-upload silently lost the file: the editor holds a
`blob:` placeholder that gets stripped during serialization, and the
attachment id does not exist yet, so the send succeeded without the file.
Chat and Quick Create gated this; manual issue create, Feedback's button,
comments, replies and comment edit did not.

Gate every action that FIXES a draft — Send/Create/Save, Cmd/Ctrl+Enter,
Enter on the title, and the manual/agent mode switches — behind one source
of truth: the editor document, which IS the upload queue. ContentEditor
publishes queue transitions via `onUploadingChange` (a transaction listener,
not `onUpdate` — that path is debounced and skips no-change emissions, and a
failed upload leaves byte-identical markdown, so the un-gate would never
fire). `useUploadGate` pairs that render state with an `isBlocked()` re-read
at submit time, since the shortcut paths never consult the button.

The publisher emits its current answer on subscribe rather than only on
flips: hosts outlive editor instances (comment edit remounts on cancel,
chat swaps by key on agent switch), and an editor torn down mid-upload would
otherwise leave submit wedged shut with no pending node left to reopen it.

Also fixes the failure toast that never fired: `uploadWithToast` only
reported errors if a caller passed `onError`, and none did, so failed uploads
removed their placeholder and vanished unexplained. `useEditorUpload` now
supplies it once for every composer.

Per MUL-4808: drops Quick Create's upload-time lock on the attach button
(files can queue), and leaves description autosave ungated by design.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-07-16 09:56:24 +08:00

88 lines
2.9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, act } from "@testing-library/react";
import type { ReactNode } from "react";
import { I18nProvider } from "@multica/core/i18n/react";
import enEditor from "../locales/en/editor.json";
const mockToastError = vi.hoisted(() => vi.fn());
const mockUploadFile = vi.hoisted(() => vi.fn());
vi.mock("sonner", () => ({
toast: { error: mockToastError },
}));
vi.mock("@multica/core/api", () => ({
api: { uploadFile: mockUploadFile },
}));
import { useEditorUpload } from "./use-editor-upload";
function wrapper({ children }: { children: ReactNode }) {
return (
<I18nProvider locale="en" resources={{ en: { editor: enEditor } }}>
{children}
</I18nProvider>
);
}
// MUL-4808 — `uploadWithToast` only ever toasted if its caller passed
// `onError`, and no composer did. So a failed upload silently removed its
// placeholder and the file simply vanished with no explanation. The gate's
// minimum failure fallback ("drop the placeholder, SAY SO, allow submit
// again") depends on this hook supplying that missing toast.
describe("useEditorUpload", () => {
beforeEach(() => {
mockToastError.mockReset();
mockUploadFile.mockReset();
});
it("toasts the filename and reason when an upload fails", async () => {
mockUploadFile.mockRejectedValue(new Error("Network unreachable"));
const { result } = renderHook(() => useEditorUpload(), { wrapper });
let returned: unknown;
await act(async () => {
returned = await result.current.uploadWithToast(
new File(["x"], "diagram.png", { type: "image/png" }),
);
});
// Null is what tells the editor extension to drop the placeholder.
expect(returned).toBeNull();
expect(mockToastError).toHaveBeenCalledWith(
"Couldn't upload diagram.png: Network unreachable",
);
});
it("surfaces the size-limit rejection by name too", async () => {
const { result } = renderHook(() => useEditorUpload(), { wrapper });
// The 100 MB guard throws before any request goes out.
const huge = new File(["x"], "huge.mov", { type: "video/quicktime" });
Object.defineProperty(huge, "size", { value: 200 * 1024 * 1024 });
await act(async () => {
await result.current.uploadWithToast(huge);
});
expect(mockToastError).toHaveBeenCalledWith(
"Couldn't upload huge.mov: File exceeds 100 MB limit",
);
});
it("does not toast on a successful upload", async () => {
mockUploadFile.mockResolvedValue({
id: "att-1",
url: "https://cdn.example/att-1.png",
markdown_url: "https://api.example/api/attachments/att-1/download",
filename: "ok.png",
});
const { result } = renderHook(() => useEditorUpload(), { wrapper });
await act(async () => {
await result.current.uploadWithToast(new File(["x"], "ok.png", { type: "image/png" }));
});
expect(mockToastError).not.toHaveBeenCalled();
});
});