Skip to content
File

Blob: tests/client/components/editor/lib/media-actions.test.ts

typescript294 lines
1import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2import { Editor } from "@tiptap/core";
3import { Image } from "@tiptap/extension-image";
4import { StarterKit } from "@tiptap/starter-kit";
5import { NodeSelection } from "@tiptap/pm/state";
6import { MAX_UPLOAD_SIZE } from "@/shared/constants";
7import {
8 IMAGE_TARGET_MISSING_MESSAGE,
9 getLocalImagePreview,
10 insertImagePlaceholderAtPos,
11 insertImagePlaceholderAtRange,
12 replaceImageSourceAtTarget,
13 resolveImageTargetPos,
14 uploadAndReplaceImageAtTarget,
15 type ImageNodeTarget,
16} from "@/client/components/editor/lib/media-actions";
17 
18const uploadFileMock = vi.hoisted(() => vi.fn());
19const toastMocks = vi.hoisted(() => ({
20 error: vi.fn(),
21 info: vi.fn(),
22 success: vi.fn(),
23}));
24 
25vi.mock("@/client/lib/uploads", () => ({
26 uploadFile: uploadFileMock,
27}));
28 
29vi.mock("@/client/components/toast-store", () => ({
30 toast: toastMocks,
31}));
32 
33const ImageWithUploadAttrs = Image.extend({
34 addAttributes() {
35 return {
36 ...this.parent?.(),
37 align: { default: "left" },
38 naturalWidth: { default: null },
39 naturalHeight: { default: null },
40 pendingInsertId: { default: null },
41 };
42 },
43});
44 
45function createEditor(content: Record<string, unknown>, extensions = [StarterKit, Image]) {
46 return new Editor({
47 extensions,
48 content,
49 });
50}
51 
52function stubLoadedImage(width: number, height: number) {
53 class LoadedImage {
54 naturalWidth = width;
55 naturalHeight = height;
56 onload: (() => void) | null = null;
57 onerror: (() => void) | null = null;
58 
59 set src(_src: string) {
60 queueMicrotask(() => this.onload?.());
61 }
62 }
63 
64 vi.stubGlobal("Image", LoadedImage);
65}
66 
67describe("image media actions", () => {
68 beforeEach(() => {
69 uploadFileMock.mockReset();
70 toastMocks.error.mockReset();
71 toastMocks.info.mockReset();
72 toastMocks.success.mockReset();
73 });
74 
75 afterEach(() => {
76 vi.unstubAllGlobals();
77 vi.restoreAllMocks();
78 });
79 
80 it("inserts an image placeholder and selects it", () => {
81 const editor = createEditor({
82 type: "doc",
83 content: [
84 {
85 type: "paragraph",
86 content: [{ type: "text", text: "/img" }],
87 },
88 ],
89 });
90 
91 try {
92 const paragraph = editor.state.doc.firstChild;
93 expect(paragraph).not.toBeNull();
94 
95 const inserted = insertImagePlaceholderAtRange(editor, { from: 1, to: paragraph!.content.size + 1 });
96 
97 expect(inserted).not.toBeNull();
98 const pos = resolveImageTargetPos(editor, inserted!.target);
99 
100 expect(pos).not.toBeNull();
101 expect(editor.state.doc.nodeAt(pos!)?.type.name).toBe("image");
102 expect(editor.state.selection).toBeInstanceOf(NodeSelection);
103 expect(editor.state.selection.from).toBe(pos);
104 expect(inserted?.nextPos).toBe((pos ?? 0) + editor.state.doc.nodeAt(pos!)!.nodeSize);
105 } finally {
106 editor.destroy();
107 }
108 });
109 
110 it("inserts a second placeholder at an explicit position without retargeting the first one", () => {
111 const editor = createEditor({
112 type: "doc",
113 content: [{ type: "paragraph" }],
114 });
115 
116 try {
117 const first = insertImagePlaceholderAtRange(editor, { from: 1, to: 1 });
118 expect(first).not.toBeNull();
119 
120 const second = insertImagePlaceholderAtPos(editor, first!.nextPos);
121 expect(second).not.toBeNull();
122 
123 const firstPos = resolveImageTargetPos(editor, first!.target);
124 const secondPos = resolveImageTargetPos(editor, second!.target);
125 
126 expect(firstPos).toBe(0);
127 expect(secondPos).toBeGreaterThan(firstPos!);
128 } finally {
129 editor.destroy();
130 }
131 });
132 
133 it("replaces the src for the targeted image node", () => {
134 const editor = createEditor({
135 type: "doc",
136 content: [{ type: "image", attrs: { src: "" } }],
137 });
138 
139 try {
140 const target: ImageNodeTarget = { pos: 0, dom: null };
141 
142 expect(replaceImageSourceAtTarget(editor, target, "/uploads/example")).toBe(true);
143 const json = editor.getJSON() as {
144 content?: Array<{ attrs?: { src?: string } }>;
145 };
146 expect(json.content?.[0]?.attrs?.src).toBe("/uploads/example");
147 } finally {
148 editor.destroy();
149 }
150 });
151 
152 it("fails closed when the target is no longer an image node", () => {
153 const editor = createEditor({
154 type: "doc",
155 content: [
156 {
157 type: "paragraph",
158 content: [{ type: "text", text: "hello" }],
159 },
160 ],
161 });
162 
163 try {
164 const target: ImageNodeTarget = { pos: 0, dom: null };
165 
166 expect(replaceImageSourceAtTarget(editor, target, "/uploads/example")).toBe(false);
167 expect(editor.state.doc.firstChild?.type.name).toBe("paragraph");
168 } finally {
169 editor.destroy();
170 }
171 });
172 
173 it("does not treat unknown pending ids as active local uploads", () => {
174 expect(getLocalImagePreview("stale-upload")).toBeNull();
175 });
176 
177 it("rejects unsupported image files before creating local preview URLs", async () => {
178 const editor = createEditor({
179 type: "doc",
180 content: [{ type: "image", attrs: { src: "" } }],
181 });
182 const createObjectURLSpy = vi.spyOn(URL, "createObjectURL");
183 
184 try {
185 const ok = await uploadAndReplaceImageAtTarget(
186 editor,
187 { workspaceId: "workspace-1", pageId: "page-1", shareToken: undefined },
188 new File(["payload"], "vector.svg", { type: "image/svg+xml" }),
189 { pos: 0, dom: null },
190 );
191 
192 expect(ok).toBe(false);
193 expect(toastMocks.error).toHaveBeenCalledWith("File type not allowed");
194 expect(createObjectURLSpy).not.toHaveBeenCalled();
195 expect(uploadFileMock).not.toHaveBeenCalled();
196 } finally {
197 editor.destroy();
198 }
199 });
200 
201 it("rejects oversized image files before creating local preview URLs", async () => {
202 const editor = createEditor({
203 type: "doc",
204 content: [{ type: "image", attrs: { src: "" } }],
205 });
206 const createObjectURLSpy = vi.spyOn(URL, "createObjectURL");
207 
208 try {
209 const ok = await uploadAndReplaceImageAtTarget(
210 editor,
211 { workspaceId: "workspace-1", pageId: "page-1", shareToken: undefined },
212 new File([new Uint8Array(MAX_UPLOAD_SIZE + 1)], "large.png", { type: "image/png" }),
213 { pos: 0, dom: null },
214 );
215 
216 expect(ok).toBe(false);
217 expect(toastMocks.error).toHaveBeenCalledWith("File too large (max 10MB)");
218 expect(createObjectURLSpy).not.toHaveBeenCalled();
219 expect(uploadFileMock).not.toHaveBeenCalled();
220 } finally {
221 editor.destroy();
222 }
223 });
224 
225 it("does not create a preview or upload when the target image no longer exists", async () => {
226 const editor = createEditor({
227 type: "doc",
228 content: [
229 {
230 type: "paragraph",
231 content: [{ type: "text", text: "hello" }],
232 },
233 ],
234 });
235 const createObjectURLSpy = vi.spyOn(URL, "createObjectURL");
236 
237 try {
238 const ok = await uploadAndReplaceImageAtTarget(
239 editor,
240 { workspaceId: "workspace-1", pageId: "page-1", shareToken: undefined },
241 new File(["payload"], "image.png", { type: "image/png" }),
242 { pos: 0, dom: null },
243 );
244 
245 expect(ok).toBe(false);
246 expect(toastMocks.error).toHaveBeenCalledWith(IMAGE_TARGET_MISSING_MESSAGE);
247 expect(createObjectURLSpy).not.toHaveBeenCalled();
248 expect(uploadFileMock).not.toHaveBeenCalled();
249 } finally {
250 editor.destroy();
251 }
252 });
253 
254 it("clears local preview state and the pending marker after upload succeeds", async () => {
255 const editor = createEditor(
256 {
257 type: "doc",
258 content: [{ type: "image", attrs: { src: "" } }],
259 },
260 [StarterKit, ImageWithUploadAttrs],
261 );
262 let pendingDuringUpload: string | null = null;
263 stubLoadedImage(640, 360);
264 uploadFileMock.mockImplementation(async () => {
265 const pendingInsertId = editor.state.doc.nodeAt(0)?.attrs.pendingInsertId;
266 pendingDuringUpload = typeof pendingInsertId === "string" ? pendingInsertId : null;
267 expect(pendingDuringUpload).not.toBeNull();
268 expect(getLocalImagePreview(pendingDuringUpload)).not.toBeNull();
269 return "/uploads/example";
270 });
271 
272 try {
273 const ok = await uploadAndReplaceImageAtTarget(
274 editor,
275 { workspaceId: "workspace-1", pageId: "page-1", shareToken: undefined },
276 new File(["payload"], "image.png", { type: "image/png" }),
277 { pos: 0, dom: null },
278 );
279 const attrs = editor.state.doc.nodeAt(0)?.attrs;
280 
281 expect(ok).toBe(true);
282 expect(uploadFileMock).toHaveBeenCalledOnce();
283 expect(attrs?.src).toBe("/uploads/example");
284 expect(attrs?.pendingInsertId).toBeNull();
285 expect(attrs?.naturalWidth).toBe(640);
286 expect(attrs?.naturalHeight).toBe(360);
287 expect(pendingDuringUpload).not.toBeNull();
288 expect(getLocalImagePreview(pendingDuringUpload)).toBeNull();
289 } finally {
290 editor.destroy();
291 }
292 });
293});