mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 11:10:23 +02:00
* fix: keep issue draft attachment records Co-authored-by: multica-agent <github@multica.ai> * fix: avoid persisting signed draft attachment urls Co-authored-by: multica-agent <github@multica.ai> * fix: reuse resolved media url for draft previews Co-authored-by: multica-agent <github@multica.ai> * fix: address draft attachment review nits - Backfill an empty caller download_url from the in-session upload on id collision so a just-pasted image first-paints from the signed URL instead of detouring through markdown_url. - Prune draft attachments no longer referenced by the persisted description when the create dialog reopens. - Backfill EMPTY_DRAFT defaults on draft-store rehydrate so drafts persisted before the attachments field existed get a stable shape. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: J <j@multica.ai> Co-authored-by: multica-agent <github@multica.ai>
682 lines
24 KiB
TypeScript
682 lines
24 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { act, fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import type { ReactElement } from "react";
|
|
import type { Attachment } from "@multica/core/types";
|
|
|
|
const openExternalMock = vi.hoisted(() => vi.fn());
|
|
|
|
vi.mock("../platform", () => ({
|
|
openExternal: openExternalMock,
|
|
}));
|
|
|
|
// vi.hoisted: factories run before module evaluation, letting us name mocks
|
|
// referenced from inside vi.mock factories below. The Error classes must be
|
|
// hoisted too because vi.mock is itself hoisted above the top-level `class`
|
|
// declarations.
|
|
const {
|
|
getAttachmentTextContentMock,
|
|
downloadMock,
|
|
getBaseUrlMock,
|
|
FakePreviewTooLargeError,
|
|
FakePreviewUnsupportedError,
|
|
} = vi.hoisted(() => {
|
|
class FakePreviewTooLargeError extends Error {
|
|
constructor() {
|
|
super("too large");
|
|
this.name = "PreviewTooLargeError";
|
|
}
|
|
}
|
|
class FakePreviewUnsupportedError extends Error {
|
|
constructor() {
|
|
super("unsupported");
|
|
this.name = "PreviewUnsupportedError";
|
|
}
|
|
}
|
|
return {
|
|
getAttachmentTextContentMock: vi.fn(),
|
|
downloadMock: vi.fn(),
|
|
// Default to the web shape (empty base, same-origin). Tests covering
|
|
// the desktop-renderer / standalone-shell case override per-test.
|
|
getBaseUrlMock: vi.fn(() => ""),
|
|
FakePreviewTooLargeError,
|
|
FakePreviewUnsupportedError,
|
|
};
|
|
});
|
|
|
|
vi.mock("@multica/core/api", () => ({
|
|
api: {
|
|
getAttachmentTextContent: getAttachmentTextContentMock,
|
|
getBaseUrl: getBaseUrlMock,
|
|
},
|
|
PreviewTooLargeError: FakePreviewTooLargeError,
|
|
PreviewUnsupportedError: FakePreviewUnsupportedError,
|
|
}));
|
|
|
|
vi.mock("./use-download-attachment", () => ({
|
|
useDownloadAttachment: () => downloadMock,
|
|
}));
|
|
|
|
// Module-level flags toggled per-test: simulate desktop (openInNewTab
|
|
// adapter present) vs web (omitted), and the no-slug case where the
|
|
// modal sits outside a workspace route.
|
|
const { openInNewTabMock, getShareableUrlMock, navState, slugState } =
|
|
vi.hoisted(() => ({
|
|
openInNewTabMock: vi.fn(),
|
|
getShareableUrlMock: vi.fn((p: string) => `https://app.example${p}`),
|
|
navState: { hasOpenInNewTab: true },
|
|
slugState: { value: "acme" as string | null },
|
|
}));
|
|
|
|
vi.mock("../navigation", () => ({
|
|
useNavigation: () => ({
|
|
push: vi.fn(),
|
|
replace: vi.fn(),
|
|
back: vi.fn(),
|
|
pathname: "/acme/issues",
|
|
searchParams: new URLSearchParams(),
|
|
...(navState.hasOpenInNewTab ? { openInNewTab: openInNewTabMock } : {}),
|
|
getShareableUrl: getShareableUrlMock,
|
|
}),
|
|
}));
|
|
|
|
vi.mock("@multica/core/paths", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@multica/core/paths")>();
|
|
return {
|
|
...actual,
|
|
useWorkspaceSlug: () => slugState.value,
|
|
};
|
|
});
|
|
|
|
// ReadonlyContent has a heavy import surface (lowlight + KaTeX + Mermaid).
|
|
// Stub it so the markdown dispatch test only verifies wiring.
|
|
vi.mock("./readonly-content", () => ({
|
|
ReadonlyContent: ({ content }: { content: string }) => (
|
|
<div data-testid="readonly-content">{content}</div>
|
|
),
|
|
}));
|
|
|
|
vi.mock("../i18n", () => ({
|
|
useT: () => ({
|
|
t: (sel: (s: Record<string, Record<string, string>>) => string) =>
|
|
sel({
|
|
image: { download: "Download" },
|
|
attachment: {
|
|
preview: "Preview",
|
|
preview_loading: "Loading preview…",
|
|
preview_failed: "Couldn't load preview",
|
|
preview_too_large: "File is too large to preview. Please download.",
|
|
preview_unsupported: "This file type can't be previewed.",
|
|
close: "Close",
|
|
download_failed: "",
|
|
open_in_new_tab: "Open in new tab",
|
|
},
|
|
}),
|
|
}),
|
|
}));
|
|
|
|
import {
|
|
AttachmentPreviewModal,
|
|
useAttachmentPreview,
|
|
} from "./attachment-preview-modal";
|
|
import { renderHook, act as hookAct } from "@testing-library/react";
|
|
|
|
// Fresh QueryClient per render — no retries (preview errors are typed,
|
|
// not transient) and no caching across tests so each scenario is hermetic.
|
|
function render(ui: ReactElement) {
|
|
const qc = new QueryClient({
|
|
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
|
});
|
|
return rtlRender(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
|
|
}
|
|
|
|
function makeAttachment(overrides: Partial<Attachment> = {}): Attachment {
|
|
return {
|
|
id: "att-1",
|
|
workspace_id: "ws-1",
|
|
issue_id: null,
|
|
comment_id: null,
|
|
chat_session_id: null,
|
|
chat_message_id: null,
|
|
uploader_type: "member",
|
|
uploader_id: "u-1",
|
|
filename: "test.bin",
|
|
url: "https://cdn.example.test/att-1.bin",
|
|
download_url: "https://cdn.example.test/att-1.bin?Signature=s",
|
|
markdown_url: "https://cdn.example.test/api/attachments/att-1/download",
|
|
content_type: "application/octet-stream",
|
|
size_bytes: 0,
|
|
created_at: "2026-05-13T00:00:00Z",
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
navState.hasOpenInNewTab = true;
|
|
slugState.value = "acme";
|
|
// Default to web's same-origin empty base so existing absolute-URL tests
|
|
// remain unaffected by the relative-URL resolution added in normalize().
|
|
getBaseUrlMock.mockReturnValue("");
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
describe("AttachmentPreviewModal — dispatch", () => {
|
|
it("renders an <img> centered in the modal for image content types", () => {
|
|
const att = makeAttachment({ filename: "shot.png", content_type: "image/png" });
|
|
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
|
const img = document.querySelector("img");
|
|
expect(img).toBeTruthy();
|
|
expect(img?.getAttribute("src")).toBe(att.download_url);
|
|
expect(img?.getAttribute("alt")).toBe(att.filename);
|
|
});
|
|
|
|
it("falls back to durable media URLs when a full attachment has no download_url", () => {
|
|
const att = makeAttachment({
|
|
filename: "shot.png",
|
|
content_type: "image/png",
|
|
download_url: "",
|
|
markdown_url: "https://api.example.test/api/attachments/att-1/download",
|
|
url: "https://cdn.example.test/att-1.png?Signature=old",
|
|
});
|
|
render(
|
|
<AttachmentPreviewModal
|
|
source={{ kind: "full", attachment: att }}
|
|
open
|
|
onClose={() => {}}
|
|
/>,
|
|
);
|
|
const img = document.querySelector("img");
|
|
expect(img?.getAttribute("src")).toBe(att.markdown_url);
|
|
expect(img?.getAttribute("src")).not.toContain("Signature=");
|
|
});
|
|
|
|
it("renders an <img> from a URL-only source for image filenames", () => {
|
|
const url = "https://cdn.example.test/orphan.png?Signature=s";
|
|
render(
|
|
<AttachmentPreviewModal
|
|
source={{ kind: "url", url, filename: "orphan.png" }}
|
|
open
|
|
onClose={() => {}}
|
|
/>,
|
|
);
|
|
const img = document.querySelector("img");
|
|
expect(img?.getAttribute("src")).toBe(url);
|
|
});
|
|
|
|
it("renders a PDF iframe pointing at the signed download URL", () => {
|
|
const att = makeAttachment({ filename: "manual.pdf", content_type: "application/pdf" });
|
|
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
|
const iframe = document.querySelector("iframe");
|
|
expect(iframe).toBeTruthy();
|
|
expect(iframe?.getAttribute("src")).toBe(att.download_url);
|
|
});
|
|
|
|
it("renders a <video> for video/* content types", () => {
|
|
const att = makeAttachment({ filename: "clip.mp4", content_type: "video/mp4" });
|
|
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
|
const video = document.querySelector("video");
|
|
expect(video).toBeTruthy();
|
|
expect(video?.getAttribute("src")).toBe(att.download_url);
|
|
});
|
|
|
|
it("renders an <audio> for audio/* content types", () => {
|
|
const att = makeAttachment({ filename: "note.mp3", content_type: "audio/mpeg" });
|
|
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
|
const audio = document.querySelector("audio");
|
|
expect(audio).toBeTruthy();
|
|
});
|
|
|
|
it("fetches text and hands it to ReadonlyContent for Markdown", async () => {
|
|
getAttachmentTextContentMock.mockResolvedValueOnce({
|
|
text: "# heading\n\nbody\n",
|
|
originalContentType: "text/markdown",
|
|
});
|
|
const att = makeAttachment({ filename: "README.md", content_type: "text/markdown" });
|
|
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
|
|
|
expect(getAttachmentTextContentMock).toHaveBeenCalledWith("att-1");
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByTestId("readonly-content")).toBeTruthy();
|
|
});
|
|
expect(screen.getByTestId("readonly-content").textContent).toContain("# heading");
|
|
});
|
|
|
|
it("renders an iframe with srcdoc + sandbox='allow-scripts' for HTML", async () => {
|
|
getAttachmentTextContentMock.mockResolvedValueOnce({
|
|
text: "<p>hi</p>",
|
|
originalContentType: "text/html",
|
|
});
|
|
const att = makeAttachment({ filename: "page.html", content_type: "text/html" });
|
|
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
|
|
|
await waitFor(() => {
|
|
const frame = document.querySelector("iframe[sandbox]") as HTMLIFrameElement | null;
|
|
expect(frame).toBeTruthy();
|
|
// `allow-scripts` is required so vanilla-JS chart libraries render
|
|
// (MUL-2330). The combination with `allow-same-origin` would defeat
|
|
// the sandbox, so this assertion must stay exact.
|
|
expect(frame?.getAttribute("sandbox")).toBe("allow-scripts");
|
|
// srcdoc carries the original HTML plus the fragment-nav shim
|
|
// appended at the end (see utils/iframe-fragment-nav.ts).
|
|
const srcdoc = frame?.getAttribute("srcdoc") ?? "";
|
|
expect(srcdoc.startsWith("<p>hi</p>")).toBe(true);
|
|
expect(srcdoc).toContain("scrollIntoView");
|
|
});
|
|
});
|
|
|
|
it("renders a code block with lowlight for source files", async () => {
|
|
getAttachmentTextContentMock.mockResolvedValueOnce({
|
|
text: "package main\n",
|
|
originalContentType: "text/plain",
|
|
});
|
|
const att = makeAttachment({ filename: "main.go", content_type: "text/plain" });
|
|
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
|
|
|
await waitFor(() => {
|
|
const code = document.querySelector("code.hljs");
|
|
expect(code).toBeTruthy();
|
|
expect(code?.className).toContain("language-go");
|
|
});
|
|
});
|
|
|
|
it("shows unsupported fallback when no PreviewKind matches", () => {
|
|
const att = makeAttachment({ filename: "blob.zip", content_type: "application/zip" });
|
|
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
|
expect(screen.getByText("This file type can't be previewed.")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
describe("AttachmentPreviewModal — server-relative download_url resolution (MUL-2976)", () => {
|
|
// The unified `/api/attachments/{id}/download` endpoint returns a
|
|
// server-relative path on non-CloudFront deployments. The web app keeps
|
|
// working same-origin because `apiBaseUrl=""`, but the desktop renderer
|
|
// is loaded from `app://` / file: / dev-server origin and needs the
|
|
// absolute URL — otherwise `<img src>`, `<iframe src>`, `<video src>`
|
|
// hit the shell origin and fail.
|
|
it("prefixes the configured API base for image previews when download_url is server-relative", () => {
|
|
getBaseUrlMock.mockReturnValue("https://api.example.test");
|
|
const att = makeAttachment({
|
|
filename: "shot.png",
|
|
content_type: "image/png",
|
|
download_url: "/api/attachments/att-1/download",
|
|
});
|
|
render(
|
|
<AttachmentPreviewModal
|
|
source={{ kind: "full", attachment: att }}
|
|
open
|
|
onClose={() => {}}
|
|
/>,
|
|
);
|
|
const img = document.querySelector("img");
|
|
expect(img?.getAttribute("src")).toBe(
|
|
"https://api.example.test/api/attachments/att-1/download",
|
|
);
|
|
});
|
|
|
|
it("prefixes the configured API base for PDF previews when download_url is server-relative", () => {
|
|
getBaseUrlMock.mockReturnValue("https://api.example.test");
|
|
const att = makeAttachment({
|
|
filename: "manual.pdf",
|
|
content_type: "application/pdf",
|
|
download_url: "/api/attachments/att-1/download",
|
|
});
|
|
render(
|
|
<AttachmentPreviewModal
|
|
source={{ kind: "full", attachment: att }}
|
|
open
|
|
onClose={() => {}}
|
|
/>,
|
|
);
|
|
const iframe = document.querySelector("iframe");
|
|
expect(iframe?.getAttribute("src")).toBe(
|
|
"https://api.example.test/api/attachments/att-1/download",
|
|
);
|
|
});
|
|
|
|
it("keeps a same-origin relative URL untouched when the configured base is empty (web)", () => {
|
|
// Default web shape — empty base. Browser resolves the relative path
|
|
// against the document origin, no prefix needed.
|
|
const att = makeAttachment({
|
|
filename: "shot.png",
|
|
content_type: "image/png",
|
|
download_url: "/api/attachments/att-1/download",
|
|
});
|
|
render(
|
|
<AttachmentPreviewModal
|
|
source={{ kind: "full", attachment: att }}
|
|
open
|
|
onClose={() => {}}
|
|
/>,
|
|
);
|
|
const img = document.querySelector("img");
|
|
expect(img?.getAttribute("src")).toBe("/api/attachments/att-1/download");
|
|
});
|
|
|
|
it("trims a trailing slash on the configured base when joining a relative URL", () => {
|
|
getBaseUrlMock.mockReturnValue("https://api.example.test/");
|
|
const att = makeAttachment({
|
|
filename: "shot.png",
|
|
content_type: "image/png",
|
|
download_url: "/api/attachments/att-1/download",
|
|
});
|
|
render(
|
|
<AttachmentPreviewModal
|
|
source={{ kind: "full", attachment: att }}
|
|
open
|
|
onClose={() => {}}
|
|
/>,
|
|
);
|
|
const img = document.querySelector("img");
|
|
expect(img?.getAttribute("src")).toBe(
|
|
"https://api.example.test/api/attachments/att-1/download",
|
|
);
|
|
});
|
|
|
|
it("passes an already-absolute CloudFront/presigned download_url through unchanged", () => {
|
|
getBaseUrlMock.mockReturnValue("https://api.example.test");
|
|
const att = makeAttachment({
|
|
filename: "shot.png",
|
|
content_type: "image/png",
|
|
download_url: "https://cdn.example.test/att-1.png?Signature=s",
|
|
});
|
|
render(
|
|
<AttachmentPreviewModal
|
|
source={{ kind: "full", attachment: att }}
|
|
open
|
|
onClose={() => {}}
|
|
/>,
|
|
);
|
|
const img = document.querySelector("img");
|
|
expect(img?.getAttribute("src")).toBe(
|
|
"https://cdn.example.test/att-1.png?Signature=s",
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("AttachmentPreviewModal — error states", () => {
|
|
it("shows the too-large fallback on PreviewTooLargeError", async () => {
|
|
getAttachmentTextContentMock.mockRejectedValueOnce(new FakePreviewTooLargeError());
|
|
const att = makeAttachment({ filename: "huge.txt", content_type: "text/plain" });
|
|
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
|
await waitFor(() => {
|
|
expect(screen.getByText("File is too large to preview. Please download.")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("shows the unsupported fallback on PreviewUnsupportedError (server/client drift)", async () => {
|
|
getAttachmentTextContentMock.mockRejectedValueOnce(new FakePreviewUnsupportedError());
|
|
const att = makeAttachment({ filename: "weird.txt", content_type: "text/plain" });
|
|
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
|
await waitFor(() => {
|
|
expect(screen.getByText("This file type can't be previewed.")).toBeTruthy();
|
|
});
|
|
});
|
|
|
|
it("shows the generic failed fallback on a transport error", async () => {
|
|
getAttachmentTextContentMock.mockRejectedValueOnce(new Error("network down"));
|
|
const att = makeAttachment({ filename: "x.md", content_type: "text/markdown" });
|
|
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
|
await waitFor(() => {
|
|
expect(screen.getByText("Couldn't load preview")).toBeTruthy();
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("AttachmentPreviewModal — controls", () => {
|
|
it("ESC closes the modal", () => {
|
|
const onClose = vi.fn();
|
|
const att = makeAttachment({ filename: "manual.pdf", content_type: "application/pdf" });
|
|
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={onClose} />);
|
|
act(() => {
|
|
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
|
});
|
|
expect(onClose).toHaveBeenCalled();
|
|
});
|
|
|
|
it("Download button invokes useDownloadAttachment with the attachment id", () => {
|
|
const att = makeAttachment({ filename: "manual.pdf", content_type: "application/pdf" });
|
|
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
|
// Two Download CTAs may exist (header + unsupported fallback). The header
|
|
// button is always present, look it up by aria-label/title.
|
|
const buttons = screen.getAllByTitle("Download");
|
|
expect(buttons.length).toBeGreaterThan(0);
|
|
fireEvent.click(buttons[0]!);
|
|
expect(downloadMock).toHaveBeenCalledWith("att-1");
|
|
});
|
|
|
|
it("clicking the backdrop closes the modal", () => {
|
|
const onClose = vi.fn();
|
|
const att = makeAttachment({ filename: "manual.pdf", content_type: "application/pdf" });
|
|
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={onClose} />);
|
|
const dialog = screen.getByRole("dialog");
|
|
fireEvent.click(dialog);
|
|
expect(onClose).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("AttachmentPreviewModal — URL-only source", () => {
|
|
it("renders a PDF iframe from the URL when no attachment record is available", () => {
|
|
const url = "https://cdn.example.test/orphan.pdf?Signature=s";
|
|
render(
|
|
<AttachmentPreviewModal
|
|
source={{ kind: "url", url, filename: "orphan.pdf" }}
|
|
open
|
|
onClose={() => {}}
|
|
/>,
|
|
);
|
|
const iframe = document.querySelector("iframe");
|
|
expect(iframe).toBeTruthy();
|
|
expect(iframe?.getAttribute("src")).toBe(url);
|
|
});
|
|
|
|
it("renders <video> from the URL when no attachment record is available", () => {
|
|
const url = "https://cdn.example.test/clip.mp4?Signature=s";
|
|
render(
|
|
<AttachmentPreviewModal
|
|
source={{ kind: "url", url, filename: "clip.mp4" }}
|
|
open
|
|
onClose={() => {}}
|
|
/>,
|
|
);
|
|
const video = document.querySelector("video");
|
|
expect(video?.getAttribute("src")).toBe(url);
|
|
});
|
|
|
|
it("falls back to unsupported when a text kind is forced through a URL source", () => {
|
|
// The tryOpen gate normally prevents this; direct mount tests the
|
|
// defensive branch inside PreviewContent.
|
|
render(
|
|
<AttachmentPreviewModal
|
|
source={{ kind: "url", url: "https://x/y.md", filename: "y.md" }}
|
|
open
|
|
onClose={() => {}}
|
|
/>,
|
|
);
|
|
expect(screen.getByText("This file type can't be previewed.")).toBeTruthy();
|
|
});
|
|
|
|
it("Download button opens the raw URL externally when no attachment id is available", () => {
|
|
const url = "https://cdn.example.test/orphan.pdf?Signature=s";
|
|
render(
|
|
<AttachmentPreviewModal
|
|
source={{ kind: "url", url, filename: "orphan.pdf" }}
|
|
open
|
|
onClose={() => {}}
|
|
/>,
|
|
);
|
|
const button = screen.getAllByTitle("Download")[0]!;
|
|
fireEvent.click(button);
|
|
expect(openExternalMock).toHaveBeenCalledWith(url);
|
|
expect(downloadMock).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe("AttachmentPreviewModal — open-in-new-tab (HTML only)", () => {
|
|
it("renders the open-in-new-tab button in the header for HTML attachments", async () => {
|
|
getAttachmentTextContentMock.mockResolvedValueOnce({
|
|
text: "<p>hi</p>",
|
|
originalContentType: "text/html",
|
|
});
|
|
const att = makeAttachment({
|
|
filename: "report.html",
|
|
content_type: "text/html",
|
|
});
|
|
render(
|
|
<AttachmentPreviewModal
|
|
source={{ kind: "full", attachment: att }}
|
|
open
|
|
onClose={() => {}}
|
|
/>,
|
|
);
|
|
expect(screen.getByTitle("Open in new tab")).toBeTruthy();
|
|
});
|
|
|
|
it("invokes navigation.openInNewTab with the preview path and closes the modal (desktop)", async () => {
|
|
getAttachmentTextContentMock.mockResolvedValueOnce({
|
|
text: "<p>hi</p>",
|
|
originalContentType: "text/html",
|
|
});
|
|
const att = makeAttachment({
|
|
filename: "report.html",
|
|
content_type: "text/html",
|
|
});
|
|
const onClose = vi.fn();
|
|
render(
|
|
<AttachmentPreviewModal
|
|
source={{ kind: "full", attachment: att }}
|
|
open
|
|
onClose={onClose}
|
|
/>,
|
|
);
|
|
fireEvent.click(screen.getByTitle("Open in new tab"));
|
|
expect(openInNewTabMock).toHaveBeenCalledWith(
|
|
"/acme/attachments/att-1/preview?name=report.html",
|
|
"report.html",
|
|
{ activate: true },
|
|
);
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("falls back to window.open against the shareable URL and closes the modal (web)", async () => {
|
|
navState.hasOpenInNewTab = false;
|
|
getAttachmentTextContentMock.mockResolvedValueOnce({
|
|
text: "<p>hi</p>",
|
|
originalContentType: "text/html",
|
|
});
|
|
const windowOpenSpy = vi
|
|
.spyOn(window, "open")
|
|
.mockImplementation(() => null);
|
|
const att = makeAttachment({
|
|
filename: "report.html",
|
|
content_type: "text/html",
|
|
});
|
|
const onClose = vi.fn();
|
|
render(
|
|
<AttachmentPreviewModal
|
|
source={{ kind: "full", attachment: att }}
|
|
open
|
|
onClose={onClose}
|
|
/>,
|
|
);
|
|
fireEvent.click(screen.getByTitle("Open in new tab"));
|
|
expect(openInNewTabMock).not.toHaveBeenCalled();
|
|
expect(windowOpenSpy).toHaveBeenCalledWith(
|
|
"https://app.example/acme/attachments/att-1/preview?name=report.html",
|
|
"_blank",
|
|
"noopener,noreferrer",
|
|
);
|
|
expect(onClose).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("does not render the new-tab button for non-HTML kinds", () => {
|
|
const att = makeAttachment({
|
|
filename: "manual.pdf",
|
|
content_type: "application/pdf",
|
|
});
|
|
render(
|
|
<AttachmentPreviewModal
|
|
source={{ kind: "full", attachment: att }}
|
|
open
|
|
onClose={() => {}}
|
|
/>,
|
|
);
|
|
expect(screen.queryByTitle("Open in new tab")).toBeNull();
|
|
});
|
|
|
|
it("does not render the new-tab button when there is no workspace slug", async () => {
|
|
slugState.value = null;
|
|
getAttachmentTextContentMock.mockResolvedValueOnce({
|
|
text: "<p>hi</p>",
|
|
originalContentType: "text/html",
|
|
});
|
|
const att = makeAttachment({
|
|
filename: "report.html",
|
|
content_type: "text/html",
|
|
});
|
|
render(
|
|
<AttachmentPreviewModal
|
|
source={{ kind: "full", attachment: att }}
|
|
open
|
|
onClose={() => {}}
|
|
/>,
|
|
);
|
|
expect(screen.queryByTitle("Open in new tab")).toBeNull();
|
|
});
|
|
});
|
|
|
|
describe("useAttachmentPreview — tryOpen gate", () => {
|
|
it("accepts a full attachment for a media kind", () => {
|
|
const { result } = renderHook(() => useAttachmentPreview());
|
|
const att = makeAttachment({ filename: "x.pdf", content_type: "application/pdf" });
|
|
let opened = false;
|
|
hookAct(() => {
|
|
opened = result.current.tryOpen({ kind: "full", attachment: att });
|
|
});
|
|
expect(opened).toBe(true);
|
|
});
|
|
|
|
it("accepts a URL source for a media kind", () => {
|
|
const { result } = renderHook(() => useAttachmentPreview());
|
|
let opened = false;
|
|
hookAct(() => {
|
|
opened = result.current.tryOpen({
|
|
kind: "url",
|
|
url: "https://x/y.pdf",
|
|
filename: "y.pdf",
|
|
});
|
|
});
|
|
expect(opened).toBe(true);
|
|
});
|
|
|
|
it("rejects a URL source for a text kind — /content proxy needs an id", () => {
|
|
const { result } = renderHook(() => useAttachmentPreview());
|
|
let opened = true;
|
|
hookAct(() => {
|
|
opened = result.current.tryOpen({
|
|
kind: "url",
|
|
url: "https://x/y.md",
|
|
filename: "y.md",
|
|
});
|
|
});
|
|
expect(opened).toBe(false);
|
|
});
|
|
|
|
it("rejects a source whose filename isn't a previewable type", () => {
|
|
const { result } = renderHook(() => useAttachmentPreview());
|
|
let opened = true;
|
|
hookAct(() => {
|
|
opened = result.current.tryOpen({
|
|
kind: "url",
|
|
url: "https://x/y.zip",
|
|
filename: "y.zip",
|
|
});
|
|
});
|
|
expect(opened).toBe(false);
|
|
});
|
|
});
|