Files
multica/packages/views/editor/extensions/file-upload.test.ts
Multica Eve abf99eb700 fix(attachments): server-driven markdown_url + legacy compat (MUL-3192) (#3991)
Comment / issue / chat images uploaded inside the Desktop app rendered
as the broken-image fallback. The editor was persisting a site-relative
`/api/attachments/<id>/download` URL into markdown — that path only
resolves when the document origin proxies /api to the API host (apps/web
via Next.js rewrite). On Electron's file:// origin it never resolved.

Per GPT-Boy's plan, move the durable-URL choice from the client to the
server so the persisted shape is correct regardless of which client
performed the upload.

Server:
- AttachmentResponse gains a markdown_url field, computed by
  buildMarkdownURL from the deployment policy:
  • storage URL is already absolute + unsigned (public CDN, S3 public
    bucket, LocalStorage with MULTICA_LOCAL_UPLOAD_BASE_URL on https) →
    use it verbatim;
  • CloudFront-signed mode → never expose the raw S3 URL (private
    bucket); return cfg.PublicURL + /api/attachments/<id>/download so
    the server can re-sign on every request;
  • LocalStorage relative + cfg.PublicURL set → same prefixed API
    endpoint;
  • cfg.PublicURL unset → fall back to site-relative path so web's
    Next.js rewrite still works.
- isDurablePublicURL helper rejects URLs carrying CloudFront / S3
  signature query params, so a freshly-signed download_url can never
  leak into persistence — the original MUL-3130 bug stays closed.

Frontend:
- Attachment type + AttachmentResponseSchema (and apps/mobile mirror)
  carry markdown_url. Schema lenient-defaults to '' so a backend old
  enough to predate this field doesn't break clients.
- useFileUpload picks markdownLink with three-layer fallback:
  (1) att.markdown_url (modern server),
  (2) attachmentDownloadPath(att.id) — legacy site-relative shape,
      retained for backends old enough to omit markdown_url,
  (3) att.url — no-workspace avatar branch with no attachment-row id.
- attachment.tsx keeps the relative→absolute absolutize pass, but
  reframed as the legacy-compat fallback for already-persisted
  /api/attachments/<id>/download or /uploads/<key> URLs in old
  bodies. New content writes absolute URLs and skips this path.
- ContentEditor still tracks freshly-uploaded records into
  AttachmentDownloadProvider so Quick Create's editor can swap the URL
  via the resolver during the same session even before the server-side
  binding lands.

Tests:
- server/internal/handler/file_test.go: 5 new buildMarkdownURL matrix
  tests (public CDN passthrough, CloudFront-signed swap, relative
  prefixing, PublicURL unset fallback, trailing-slash strip) + 15
  table-driven isDurablePublicURL cases.
- packages/core/hooks/use-file-upload.test.ts: new file, 4 cases
  covering modern server / legacy server / no-id avatar / oversize.
- packages/views/editor/attachment.test.tsx + content-editor.test.tsx:
  10 cases for the absolutize matrix and in-session attachment merge.
- 6 existing test fixtures updated to include markdown_url.

Verification: 1236 @multica/views tests pass; 514 @multica/core tests
pass (4 new); server handler package tests pass for the new matrix
plus all pre-existing TestAttachmentToResponse* and TestDownload*
cases. Typecheck green for views/core/web/desktop. Lint clean on
touched files.

Quick Create attachment_ids binding (orphaned attachment relationship
on the resulting issue) is a follow-up — it requires a new --attachment-id
CLI flag and daemon prompt-template work and is intentionally scoped
out of this PR.

Refs: MUL-3192

Co-authored-by: Eve <eve@multica-ai.local>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-10 16:00:40 +08:00

227 lines
7.4 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "@tiptap/markdown";
import type { UploadResult } from "@multica/core/hooks/use-file-upload";
import { ImageExtension } from "./index";
import { uploadAndInsertFile } from "./file-upload";
const BLOB_URL = "blob:test-image";
const FINAL_URL = "https://cdn.example.com/photo.png";
let editors: Editor[] = [];
let originalCreateObjectURL: typeof URL.createObjectURL | undefined;
let originalRevokeObjectURL: typeof URL.revokeObjectURL | undefined;
function makeEditor() {
const element = document.createElement("div");
document.body.appendChild(element);
const editor = new Editor({
element,
extensions: [
StarterKit,
ImageExtension,
Markdown.configure({ indentation: { style: "space", size: 3 } }),
],
});
editors.push(editor);
return editor;
}
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
function makeUpload(
overrides: Partial<UploadResult> & {
id: string;
link: string;
filename: string;
},
): UploadResult {
return {
workspace_id: "ws-1",
issue_id: null,
comment_id: null,
chat_session_id: null,
chat_message_id: null,
uploader_type: "member",
uploader_id: "user-1",
url: overrides.link,
download_url: overrides.link,
markdown_url: overrides.link,
content_type: "image/png",
size_bytes: 1,
created_at: new Date(0).toISOString(),
// markdownLink defaults to the same value as `link` so legacy tests
// continue to assert the previous URL shape unless they pass an
// explicit override. Real callers always set it to the stable
// `/api/attachments/<id>/download` path via useFileUpload.
markdownLink: overrides.link,
...overrides,
};
}
beforeEach(() => {
originalCreateObjectURL = URL.createObjectURL;
originalRevokeObjectURL = URL.revokeObjectURL;
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: vi.fn(() => BLOB_URL),
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: vi.fn(),
});
});
afterEach(() => {
for (const editor of editors) editor.destroy();
editors = [];
document.body.innerHTML = "";
if (originalCreateObjectURL) {
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
value: originalCreateObjectURL,
});
} else {
delete (URL as Partial<typeof URL>).createObjectURL;
}
if (originalRevokeObjectURL) {
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
value: originalRevokeObjectURL,
});
} else {
delete (URL as Partial<typeof URL>).revokeObjectURL;
}
});
function firstImageAttrs(editor: Editor): Record<string, unknown> | null {
let attrs: Record<string, unknown> | null = null;
editor.state.doc.descendants((node) => {
if (attrs) return false;
if (node.type.name === "image") {
attrs = node.attrs;
return false;
}
return undefined;
});
return attrs;
}
describe("uploadAndInsertFile", () => {
it("lets typing continue in the trailing paragraph after pasted image upload preview", async () => {
const editor = makeEditor();
const upload = deferred<UploadResult | null>();
const handler = vi.fn(() => upload.promise);
const file = new File(["image"], "photo.png", { type: "image/png" });
const uploadTask = uploadAndInsertFile(editor, file, handler);
expect(handler).toHaveBeenCalledWith(file);
expect(editor.state.selection.$from.parent.type.name).toBe("paragraph");
editor.commands.insertContent("after");
expect(editor.getMarkdown().trimEnd()).toBe(
[`![photo.png](${BLOB_URL})`, "", "after"].join("\n"),
);
upload.resolve(
makeUpload({ id: "attachment-1", link: FINAL_URL, filename: "photo.png" }),
);
await uploadTask;
const saved = editor.getMarkdown().trimEnd();
expect(saved).toBe([`![photo.png](${FINAL_URL})`, "", "after"].join("\n"));
const reparsed = makeEditor();
reparsed.commands.setContent(saved, { contentType: "markdown" });
expect(reparsed.getMarkdown().trimEnd()).toBe(saved);
});
it("reserves the image box by capturing intrinsic dimensions, kept through the URL swap", async () => {
const close = vi.fn();
const createImageBitmap = vi.fn(async () => ({
width: 800,
height: 600,
close,
}));
vi.stubGlobal("createImageBitmap", createImageBitmap);
try {
const editor = makeEditor();
const upload = deferred<UploadResult | null>();
const handler = vi.fn(() => upload.promise);
const file = new File(["image"], "photo.png", { type: "image/png" });
const uploadTask = uploadAndInsertFile(editor, file, handler);
// Dimensions are measured off-thread and patched onto the node before the
// upload resolves, so the blob preview already reserves its box.
await vi.waitFor(() => {
const attrs = firstImageAttrs(editor);
expect(attrs?.width).toBe(800);
expect(attrs?.height).toBe(600);
});
expect(createImageBitmap).toHaveBeenCalledWith(file);
expect(close).toHaveBeenCalled();
upload.resolve(
makeUpload({ id: "attachment-1", link: FINAL_URL, filename: "photo.png" }),
);
await uploadTask;
// The src swap preserves width/height (spread of existing attrs).
const finalAttrs = firstImageAttrs(editor);
expect(finalAttrs?.src).toBe(FINAL_URL);
expect(finalAttrs?.width).toBe(800);
expect(finalAttrs?.height).toBe(600);
// width/height are render-only — they never reach the markdown.
expect(editor.getMarkdown().trimEnd()).toBe(`![photo.png](${FINAL_URL})`);
} finally {
vi.unstubAllGlobals();
}
});
it("persists markdownLink (the stable per-attachment URL) into the markdown body, not the short-lived storage URL", async () => {
// Regression pin for MUL-3130 review feedback. useFileUpload returns
// both `link` (= att.url, short-lived signed `/uploads/<key>?exp&sig`
// on LocalStorage) and `markdownLink` (= /api/attachments/<id>/download).
// The editor must persist `markdownLink` so the comment doesn't
// capture a 30-min signature, while non-markdown callers (avatar
// pickers, logo upload) keep using `link` for backward compatibility.
const editor = makeEditor();
const SIGNED_URL = "/uploads/workspaces/ws-1/photo.png?exp=42&sig=fake";
const STABLE_URL = "/api/attachments/attachment-7/download";
const handler = vi.fn(async () =>
makeUpload({
id: "attachment-7",
link: SIGNED_URL,
markdownLink: STABLE_URL,
filename: "photo.png",
}),
);
const file = new File(["image"], "photo.png", { type: "image/png" });
await uploadAndInsertFile(editor, file, handler);
// The img node ends up with the stable URL as its src — the
// expiring signed URL never makes it into the persisted markdown.
const attrs = firstImageAttrs(editor);
expect(attrs?.src).toBe(STABLE_URL);
expect(editor.getMarkdown().trimEnd()).toBe(`![photo.png](${STABLE_URL})`);
expect(editor.getMarkdown()).not.toContain("?exp=");
expect(editor.getMarkdown()).not.toContain("?sig=");
});
});