memos/web/tests/memo-action-navigation.test.tsx

120 lines
4 KiB
TypeScript

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, renderHook, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useMemoActionHandlers } from "@/components/MemoActionMenu/hooks";
import { State } from "@/types/proto/api/v1/common_pb";
import type { Memo } from "@/types/proto/api/v1/memo_service_pb";
const mocks = vi.hoisted(() => ({
updateMemo: vi.fn(),
deleteMemo: vi.fn(),
}));
vi.mock("@/hooks/useMemoQueries", () => ({
memoKeys: {
comments: (name: string) => ["memos", name, "comments"],
detail: (name: string) => ["memos", name, "detail"],
},
useUpdateMemo: () => ({ mutateAsync: mocks.updateMemo }),
useDeleteMemo: () => ({ mutateAsync: mocks.deleteMemo }),
}));
vi.mock("@/hooks/useUserQueries", () => ({
userKeys: { stats: () => ["users", "stats"] },
}));
vi.mock("@/hooks/useCurrentUser", () => ({ default: () => ({ name: "users/alice" }) }));
vi.mock("@/contexts/InstanceContext", () => ({
useInstance: () => ({ profile: { instanceUrl: "" } }),
}));
vi.mock("@/utils/i18n", () => ({
useTranslate: () => (key: string) => key,
}));
vi.mock("react-hot-toast", () => ({
default: { success: vi.fn(), error: vi.fn() },
}));
const createMemo = (state: State, parent = ""): Memo =>
({
name: "memos/1",
content: "memo",
state,
parent,
}) as Memo;
const renderActions = (state: State, parent = "", parentPage?: string) => {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries");
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/memos/1"]}>{children}</MemoryRouter>
</QueryClientProvider>
);
const rendered = renderHook(
() => {
const location = useLocation();
const handlers = useMemoActionHandlers({
memo: createMemo(state, parent),
parentPage,
setDeleteDialogOpen: vi.fn(),
});
return { handlers, pathname: location.pathname, search: location.search };
},
{ wrapper },
);
return { ...rendered, invalidateQueries };
};
describe("Memo detail mutation navigation", () => {
beforeEach(() => {
mocks.updateMemo.mockReset().mockResolvedValue(undefined);
mocks.deleteMemo.mockReset().mockResolvedValue(undefined);
});
it.each([
[State.NORMAL, "/archived"],
[State.ARCHIVED, "/"],
])("switches a %s memo to its user-level destination", async (state, destination) => {
const { result } = renderActions(state);
await act(async () => {
await result.current.handlers.handleToggleMemoStatusClick();
});
await waitFor(() => expect(result.current.pathname).toBe(destination));
});
it("returns to the full Space origin after deleting a memo detail", async () => {
const origin = "/spaces/product/calendar/2026/09/06?filter=tagSearch%3Awork";
const { result } = renderActions(State.NORMAL, "", origin);
await act(() => result.current.handlers.confirmDeleteMemo());
expect(result.current.pathname + result.current.search).toBe(origin);
});
it("returns directly to a global origin after deleting", async () => {
const { result } = renderActions(State.NORMAL);
await act(async () => {
await result.current.handlers.confirmDeleteMemo();
});
await waitFor(() => expect(result.current.pathname).toBe("/"));
});
it("refreshes a parent memo after deleting one of its comments", async () => {
const { result, invalidateQueries } = renderActions(State.NORMAL, "memos/parent");
await act(async () => {
await result.current.handlers.confirmDeleteMemo();
});
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["memos", "memos/parent", "comments"] });
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["memos", "memos/parent", "detail"] });
});
});