import { create } from "@bufbuild/protobuf";
import { fireEvent, render, screen } 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 currentUserState = vi.hoisted(() => ({ value: { name: "users/alice" } as { name: string } | undefined }));
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: () => currentUserState.value }));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string, values?: { source?: string }) => (values?.source ? `${key}:${values.source}` : key),
}));
const renderSidebar = (sidebar: React.ReactNode, route = "/memos/detail") =>
render({sidebar});
const createIncomingReference = (memoName: string, sourceName = "memos/incoming", snippet = "Incoming backlink") =>
create(MemoRelationSchema, {
type: MemoRelation_Type.REFERENCE,
memo: create(MemoRelation_MemoSchema, { name: sourceName, snippet }),
relatedMemo: create(MemoRelation_MemoSchema, { name: memoName }),
});
describe("MemoDetailSidebar", () => {
beforeEach(() => {
copyToClipboard.mockReset();
currentUserState.value = { name: "users/alice" };
});
it("organizes source, in-page navigation, connections, and frequent actions without repeating 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" }),
}),
createIncomingReference("memos/detail"),
],
});
const parentMemo = create(MemoSchema, { name: "memos/parent", content: "Parent context" });
const onEdit = vi.fn();
const onCommentsOpen = vi.fn();
const onCommentCreate = vi.fn();
const onShareImageOpen = vi.fn();
renderSidebar(
,
);
expect(screen.getByRole("link", { name: "memo.back-to:common.explore" })).toHaveAttribute("href", "/explore?filter=tagSearch%3Awork");
expect(screen.getByText("memo.on-this-memo")).toBeInTheDocument();
expect(screen.getByTestId("outline")).toHaveTextContent("2");
expect(screen.getByRole("link", { name: /memo.comment.self/ })).toHaveTextContent("3");
expect(screen.getByRole("link", { name: /memo.comment.self/ })).toHaveAttribute("href", "#memo-comments");
expect(screen.getByText("memo.connections")).toBeInTheDocument();
expect(screen.getByRole("link", { name: "memo.parent-memo: Parent context" })).toHaveAttribute("href", "/memos/parent");
expect(screen.getByRole("link", { name: "common.referenced-by: Incoming backlink" })).toHaveAttribute("href", "/memos/incoming");
expect(screen.queryByText("Outgoing reference")).not.toBeInTheDocument();
expect(screen.getByText("common.actions")).toBeInTheDocument();
fireEvent.click(screen.getByRole("link", { name: /memo.comment.self/ }));
expect(onCommentsOpen).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "common.edit" }));
expect(onEdit).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "memo.comment.write-a-comment" }));
expect(onCommentCreate).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("button", { name: "common.pin" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "common.unpin" })).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();
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("lets an authenticated visitor comment while reserving edit for the author", () => {
currentUserState.value = { name: "users/bob" };
const memo = create(MemoSchema, {
name: "memos/public",
creator: "users/alice",
state: State.NORMAL,
visibility: Visibility.PUBLIC,
content: "Body",
relations: [createIncomingReference("memos/public")],
});
renderSidebar();
expect(screen.queryByRole("button", { name: "common.edit" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "memo.comment.write-a-comment" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "common.referenced-by: Incoming backlink" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: "memo.go-to:common.explore" })).toHaveAttribute("href", "/explore");
});
it("keeps a simple memo useful without rendering empty navigation or connections", () => {
const memo = create(MemoSchema, {
name: "memos/simple",
creator: "users/alice",
state: State.NORMAL,
content: "Body",
});
renderSidebar();
expect(screen.getByRole("link", { name: "memo.go-to:common.home" })).toHaveAttribute("href", "/");
expect(screen.queryByText("memo.on-this-memo")).not.toBeInTheDocument();
expect(screen.queryByText("memo.connections")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.edit" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "memo.comment.write-a-comment" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.share" })).toBeInTheDocument();
});
it.each([
["an archived memo", State.ARCHIVED, Visibility.PUBLIC],
["a Space memo", State.NORMAL, Visibility.SPACE],
])("does not offer share-link management for %s", async (_label, state, visibility) => {
const memo = create(MemoSchema, {
name: "memos/restricted-share",
creator: "users/alice",
state,
visibility,
content: "Body",
});
renderSidebar();
fireEvent.click(screen.getByRole("button", { name: "common.share" }));
expect(await screen.findByRole("menuitem", { name: "memo.copy-link" })).toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: "memo.share.open-panel" })).not.toBeInTheDocument();
});
it("keeps archived memo navigation and comments readable without offering write actions", () => {
const memo = create(MemoSchema, {
name: "memos/archived",
creator: "users/alice",
state: State.ARCHIVED,
content: "Archived body",
});
renderSidebar();
expect(screen.getByRole("link", { name: "memo.go-to:common.archived" })).toBeInTheDocument();
expect(screen.getByRole("link", { name: /memo.comment.self/ })).toHaveTextContent("2");
expect(screen.queryByRole("button", { name: "common.edit" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "memo.comment.write-a-comment" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "common.share" })).toBeInTheDocument();
});
it("keeps shared memos private to their token context and copies the usable share URL", async () => {
const memo = create(MemoSchema, {
name: "memos/shared",
creator: "users/alice",
state: State.NORMAL,
visibility: Visibility.PUBLIC,
content: "# Overview\n\nBody\n\n## Details\n\nMore",
parent: "memos/parent",
relations: [createIncomingReference("memos/shared", "memos/private", "Private backlink")],
});
const parentMemo = create(MemoSchema, { name: "memos/parent", content: "Private parent" });
renderSidebar(
,
"/memos/shares/share-token",
);
expect(screen.queryByRole("link", { name: /memo.back-to/ })).not.toBeInTheDocument();
expect(screen.getByText("memo.on-this-memo")).toBeInTheDocument();
expect(screen.getByTestId("outline")).toHaveTextContent("2");
expect(screen.queryByRole("link", { name: /memo.comment.self/ })).not.toBeInTheDocument();
expect(screen.queryByText("memo.connections")).not.toBeInTheDocument();
expect(screen.queryByText("Private parent")).not.toBeInTheDocument();
expect(screen.queryByText("Private backlink")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "common.edit" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "memo.comment.write-a-comment" })).not.toBeInTheDocument();
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/shares/share-token");
expect(screen.queryByRole("menuitem", { name: "memo.share.open-panel" })).not.toBeInTheDocument();
});
});