import { create } from "@bufbuild/protobuf";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import MemoDetailSidebar from "@/components/MemoDetailSidebar/MemoDetailSidebar";
import { AttachmentSchema } from "@/types/proto/api/v1/attachment_service_pb";
import { State } from "@/types/proto/api/v1/common_pb";
import {
LocationSchema,
MemoRelation_MemoSchema,
MemoRelation_Type,
MemoRelationSchema,
MemoSchema,
Visibility,
} from "@/types/proto/api/v1/memo_service_pb";
const copyToClipboard = vi.hoisted(() => vi.fn());
const updateMemo = vi.hoisted(() => vi.fn());
vi.mock("copy-to-clipboard", () => ({ default: copyToClipboard }));
vi.mock("react-hot-toast", () => ({ default: { success: vi.fn() } }));
vi.mock("@/components/MemoDetailSidebar/MemoOutline", () => ({
default: ({ headings }: { headings: unknown[] }) =>
{headings.length}
,
}));
vi.mock("@/components/MemoDetailSidebar/MemoSharePanel", () => ({ default: () => }));
vi.mock("@/components/MemoMetadata/Relation/useResolvedRelationMemos", () => ({ useResolvedRelationMemos: () => ({}) }));
vi.mock("@/contexts/InstanceContext", () => ({ useInstance: () => ({ profile: { instanceUrl: "https://memos.example" } }) }));
vi.mock("@/hooks/useCurrentUser", () => ({ default: () => ({ name: "users/alice" }) }));
vi.mock("@/hooks/useMemoQueries", () => ({ useUpdateMemo: () => ({ mutateAsync: updateMemo }) }));
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
describe("MemoDetailSidebar", () => {
beforeEach(() => {
copyToClipboard.mockReset();
updateMemo.mockReset();
updateMemo.mockResolvedValue(undefined);
});
it("renders concise actions, outline, and backlinks without repeating body metadata", async () => {
const memo = create(MemoSchema, {
name: "memos/detail",
creator: "users/alice",
state: State.NORMAL,
visibility: Visibility.PUBLIC,
content: "# Overview\n\n## Details",
tags: ["release/0.30"],
location: create(LocationSchema, { placeholder: "Singapore" }),
attachments: [
create(AttachmentSchema, {
name: "attachments/spec",
filename: "release-notes.pdf",
type: "application/pdf",
size: 2048n,
}),
],
relations: [
create(MemoRelationSchema, {
type: MemoRelation_Type.REFERENCE,
memo: create(MemoRelation_MemoSchema, { name: "memos/detail" }),
relatedMemo: create(MemoRelation_MemoSchema, { name: "memos/outgoing", snippet: "Outgoing reference" }),
}),
create(MemoRelationSchema, {
type: MemoRelation_Type.REFERENCE,
memo: create(MemoRelation_MemoSchema, { name: "memos/incoming", snippet: "Incoming backlink" }),
relatedMemo: create(MemoRelation_MemoSchema, { name: "memos/detail" }),
}),
],
});
const onShareImageOpen = vi.fn();
render(
,
);
expect(screen.getByText("common.actions")).toBeInTheDocument();
expect(screen.getByText("memo.outline")).toBeInTheDocument();
expect(screen.getByText("common.referenced-by")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "Incoming backlink" })).toBeInTheDocument();
expect(screen.queryByText("Outgoing reference")).not.toBeInTheDocument();
expect(screen.queryByText("common.visibility")).not.toBeInTheDocument();
expect(screen.queryByText("common.created-at")).not.toBeInTheDocument();
expect(screen.queryByText("common.location")).not.toBeInTheDocument();
expect(screen.queryByText("common.attachments")).not.toBeInTheDocument();
expect(screen.queryByText("release-notes.pdf")).not.toBeInTheDocument();
expect(screen.queryByText("release/0.30")).not.toBeInTheDocument();
expect(screen.getByTestId("outline")).toHaveTextContent("2");
fireEvent.click(screen.getByRole("button", { name: "common.pin" }));
await waitFor(() =>
expect(updateMemo).toHaveBeenCalledWith({ update: { name: "memos/detail", pinned: true }, updateMask: ["pinned"] }),
);
fireEvent.click(screen.getByRole("button", { name: "common.share" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "memo.copy-link" }));
expect(copyToClipboard).toHaveBeenCalledWith("https://memos.example/memos/detail");
fireEvent.click(screen.getByRole("button", { name: "common.share" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "memo.share.open-image" }));
expect(onShareImageOpen).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "common.share" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "memo.share.open-panel" }));
expect(screen.getByTestId("share-panel")).toBeInTheDocument();
});
it("keeps a readonly simple memo useful with only the share action", () => {
const memo = create(MemoSchema, {
name: "memos/readonly",
creator: "users/alice",
state: State.NORMAL,
visibility: Visibility.PUBLIC,
content: "# Overview\n\nBody",
relations: [
create(MemoRelationSchema, {
type: MemoRelation_Type.REFERENCE,
memo: create(MemoRelation_MemoSchema, { name: "memos/incoming", snippet: "Private backlink" }),
relatedMemo: create(MemoRelation_MemoSchema, { name: "memos/readonly" }),
}),
],
});
render(
,
);
expect(screen.getByRole("button", { name: "common.share" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "common.pin" })).not.toBeInTheDocument();
expect(screen.queryByText("memo.outline")).not.toBeInTheDocument();
expect(screen.queryByText("common.referenced-by")).not.toBeInTheDocument();
expect(screen.queryByText("Private backlink")).not.toBeInTheDocument();
});
it("keeps the outline available for readonly memos with multiple headings", () => {
const memo = create(MemoSchema, {
name: "memos/readonly-outline",
creator: "users/alice",
state: State.NORMAL,
visibility: Visibility.PUBLIC,
content: "# Overview\n\nBody\n\n## Details\n\nMore",
});
render(
,
);
expect(screen.getByText("memo.outline")).toBeInTheDocument();
expect(screen.getByTestId("outline")).toHaveTextContent("2");
});
});