import { create } from "@bufbuild/protobuf";
import { Code, ConnectError } from "@connectrpc/connect";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { memoKeys } from "@/hooks/useMemoQueries";
import MemoDetail from "@/pages/MemoDetail";
import { type Memo, MemoSchema } from "@/types/proto/api/v1/memo_service_pb";
const state = vi.hoisted(() => ({
currentUser: undefined as { name: string } | undefined,
getMemo: vi.fn(),
listMemoComments: vi.fn(),
setMemoDetail: vi.fn(),
toastError: vi.fn(),
}));
vi.mock("@/connect", () => ({ memoServiceClient: state }));
vi.mock("@/contexts/AuthContext", () => ({ useAuth: () => ({ currentUser: state.currentUser, isInitialized: true }) }));
vi.mock("@/contexts/InstanceContext", () => ({ useInstance: () => ({ isInitialized: true }) }));
vi.mock("@/contexts/AppSidebarContext", () => ({ useAppSidebar: () => ({ setMemoDetail: state.setMemoDetail }) }));
vi.mock("@/components/MemoView", () => ({ default: ({ memo }: { memo: Memo }) => {memo.content} }));
vi.mock("@/components/MemoView/MemoViewContext", () => ({ computeCommentAmount: () => 0 }));
vi.mock("@/components/MemoCommentSection", () => ({ default: () => null }));
vi.mock("@/components/MemoContent/MentionResolutionContext", () => ({
MentionResolutionProvider: ({ children }: { children: ReactNode }) => children,
}));
vi.mock("@/utils/i18n", () => ({ useTranslate: () => (key: string) => key }));
vi.mock("react-hot-toast", () => ({ toast: { error: state.toastError } }));
function renderDetail(client: QueryClient, name = "memos/restored") {
return render(
} />
not found} />
,
);
}
const createClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, retryDelay: 0 } } });
describe("memo detail access recovery", () => {
beforeEach(() => {
vi.clearAllMocks();
state.currentUser = undefined;
state.listMemoComments.mockResolvedValue({ memos: [], nextPageToken: "" });
});
it.each(["fresh", "stale"])("revalidates a %s cached denial before showing a restored memo", async (freshness) => {
const client = createClient();
const name = "memos/restored";
client.setQueryData(memoKeys.detail(name), null, { updatedAt: freshness === "fresh" ? Date.now() : 1 });
let resolve!: (memo: Memo) => void;
let signal!: AbortSignal;
state.getMemo.mockImplementation((_, options) => {
signal = options.signal;
return new Promise((done) => {
resolve = done;
});
});
renderDetail(client);
await waitFor(() => expect(state.getMemo).toHaveBeenCalledOnce());
expect(screen.queryByText("not found")).not.toBeInTheDocument();
expect(signal.aborted).toBe(false);
await act(async () => resolve(create(MemoSchema, { name, content: "Restored memo" })));
expect(await screen.findByText("Restored memo")).toBeInTheDocument();
expect(screen.queryByText("not found")).not.toBeInTheDocument();
client.clear();
});
it.each([Code.PermissionDenied, Code.NotFound])("redirects only after revalidation confirms denial (%s)", async (code) => {
const client = createClient();
client.setQueryData(memoKeys.detail("memos/restored"), null, { updatedAt: 1 });
let reject!: (error: Error) => void;
state.getMemo.mockImplementation(
() =>
new Promise((_, fail) => {
reject = fail;
}),
);
renderDetail(client);
await waitFor(() => expect(state.getMemo).toHaveBeenCalledOnce());
expect(screen.queryByText("not found")).not.toBeInTheDocument();
await act(async () => reject(new ConnectError("denied", code)));
expect(await screen.findByText("not found")).toBeInTheDocument();
client.clear();
});
it("does not treat a failed revalidation as a renewed denial", async () => {
const client = createClient();
client.setQueryData(memoKeys.detail("memos/restored"), null, { updatedAt: 1 });
state.getMemo.mockRejectedValue(new ConnectError("offline", Code.Unavailable));
renderDetail(client);
await waitFor(() => expect(state.toastError).toHaveBeenCalled());
expect(screen.queryByText("not found")).not.toBeInTheDocument();
client.clear();
});
it.each([false, true])("classifies parent 401 according to the current viewer (signed in: %s)", async (signedIn) => {
if (signedIn) state.currentUser = { name: "users/alice" };
const client = createClient();
const comment = create(MemoSchema, { name: "memos/comment", content: "Public comment", parent: "memos/private-parent" });
state.getMemo.mockImplementation(({ name }) =>
name === comment.name ? Promise.resolve(comment) : Promise.reject(new ConnectError("authentication required", Code.Unauthenticated)),
);
renderDetail(client, comment.name);
expect(await screen.findByText(signedIn ? "memo.parent-load-error" : "memo.parent-unavailable")).toBeInTheDocument();
expect(screen.getByText("Public comment")).toBeInTheDocument();
expect(screen.queryByText("not found")).not.toBeInTheDocument();
expect(screen.queryByRole("link")).not.toBeInTheDocument();
if (signedIn) expect(screen.getByRole("button", { name: "search.retry" })).toBeInTheDocument();
else expect(screen.queryByRole("button", { name: "search.retry" })).not.toBeInTheDocument();
expect(state.setMemoDetail).toHaveBeenLastCalledWith(
expect.objectContaining({
parentStatus: signedIn ? "error" : "unavailable",
parentMemo: undefined,
}),
);
client.clear();
});
it("lets a guest retry a transient parent error", async () => {
const client = createClient();
const comment = create(MemoSchema, { name: "memos/comment", content: "Public comment", parent: "memos/parent" });
let parentAvailable = false;
state.getMemo.mockImplementation(({ name }) =>
name === comment.name
? Promise.resolve(comment)
: parentAvailable
? Promise.resolve(create(MemoSchema, { name, content: "Parent memo" }))
: Promise.reject(new ConnectError("offline", Code.Unavailable)),
);
renderDetail(client, comment.name);
const retry = await screen.findByRole("button", { name: "search.retry" });
parentAvailable = true;
fireEvent.click(retry);
expect(await screen.findByRole("link", { name: "Parent memo" })).toBeInTheDocument();
expect(screen.getByText("Public comment")).toBeInTheDocument();
client.clear();
});
});