mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-28 22:17:48 +02:00
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>
88 lines
2.9 KiB
TypeScript
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();
|
|
});
|
|
});
|