import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { renderHook, waitFor } from "@testing-library/react"; import type { PropsWithChildren, ReactNode } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { MentionResolutionProvider, useResolvedMentionUsernames, useResolvedUser } from "@/components/MemoContent/MentionResolutionContext"; import { useResolvedRelationMemos } from "@/components/MemoMetadata/Relation/useResolvedRelationMemos"; import { memoKeys } from "@/hooks/useMemoQueries"; import { useMemoViews, userKeys, useUser, useUsersByNames, useUsersByUsernames } from "@/hooks/useUserQueries"; import type { Memo } from "@/types/proto/api/v1/memo_service_pb"; import type { User } from "@/types/proto/api/v1/user_service_pb"; const clients = vi.hoisted(() => ({ batchGetUsers: vi.fn(), getMemo: vi.fn(), getUser: vi.fn(), listMemoViews: vi.fn(), })); vi.mock("@/connect", () => ({ memoServiceClient: { getMemo: clients.getMemo, }, memoViewServiceClient: { listMemoViews: clients.listMemoViews, }, userServiceClient: { batchGetUsers: clients.batchGetUsers, getUser: clients.getUser, }, })); const createQueryClient = () => new QueryClient({ defaultOptions: { queries: { retry: false, }, }, }); const createWrapper = (queryClient: QueryClient) => function QueryWrapper({ children }: PropsWithChildren) { return {children}; }; describe("query deduplication", () => { beforeEach(() => { clients.batchGetUsers.mockReset(); clients.getMemo.mockReset(); clients.getUser.mockReset(); clients.listMemoViews.mockReset(); }); it("scopes memo-view queries by parent and skips requests without one", async () => { clients.listMemoViews.mockImplementation(async ({ parent }: { parent: string }) => ({ memoViews: [{ name: `${parent}/views/work`, title: parent, filter: "pinned" }], })); const queryClient = createQueryClient(); const wrapper = createWrapper(queryClient); const disabled = renderHook(() => useMemoViews(undefined), { wrapper }); expect(disabled.result.current.fetchStatus).toBe("idle"); const alice = renderHook(() => useMemoViews("users/alice"), { wrapper }); const bob = renderHook(() => useMemoViews("users/bob"), { wrapper }); await waitFor(() => { expect(alice.result.current.isSuccess).toBe(true); expect(bob.result.current.isSuccess).toBe(true); }); expect(clients.listMemoViews.mock.calls.map(([request]) => request.parent)).toEqual(["users/alice", "users/bob"]); expect(queryClient.getQueryData(userKeys.memoViews("users/alice"))).toEqual(alice.result.current.data); expect(queryClient.getQueryData(userKeys.memoViews("users/bob"))).toEqual(bob.result.current.data); }); it("fetches each user name once across individual and overlapping group queries", async () => { clients.getUser.mockImplementation(async ({ name }: { name: string }) => ({ name, username: name }) as User); const queryClient = createQueryClient(); const { result } = renderHook( () => ({ individual: useUser("users/1"), firstGroup: useUsersByNames(["users/1", "users/2"]), secondGroup: useUsersByNames(["users/2", "users/3"]), }), { wrapper: createWrapper(queryClient) }, ); await waitFor(() => { expect(result.current.individual.isSuccess).toBe(true); expect(result.current.firstGroup.isSuccess).toBe(true); expect(result.current.secondGroup.isSuccess).toBe(true); }); expect(clients.getUser.mock.calls.map(([request]) => request.name).sort()).toEqual(["users/1", "users/2", "users/3"]); }); it("seeds user detail queries from a username batch response", async () => { const alice = { name: "users/1", username: "alice" } as User; clients.batchGetUsers.mockResolvedValue({ users: [alice] }); const queryClient = createQueryClient(); const wrapper = createWrapper(queryClient); const batch = renderHook(() => useUsersByUsernames(["alice"]), { wrapper }); await waitFor(() => expect(batch.result.current.isSuccess).toBe(true)); expect(queryClient.getQueryData(userKeys.detail(alice.name))).toBe(alice); const detail = renderHook(() => useUser(alice.name), { wrapper }); await waitFor(() => expect(detail.result.current.isSuccess).toBe(true)); expect(clients.getUser).not.toHaveBeenCalled(); }); it("reuses cached user details instead of issuing another username batch", async () => { const alice = { name: "users/alice", username: "alice" } as User; const queryClient = createQueryClient(); queryClient.setQueryData(userKeys.detail(alice.name), alice); const batch = renderHook(() => useUsersByUsernames(["alice"]), { wrapper: createWrapper(queryClient) }); await waitFor(() => expect(batch.result.current.isSuccess).toBe(true)); expect(batch.result.current.data?.get("alice")).toBe(alice); expect(clients.batchGetUsers).not.toHaveBeenCalled(); }); it("resolves feed creators and mentions through one shared username batch", async () => { const alice = { name: "users/alice", username: "alice" } as User; const bob = { name: "users/bob", username: "bob" } as User; clients.batchGetUsers.mockResolvedValue({ users: [alice, bob] }); const queryClient = createQueryClient(); const wrapper = ({ children }: { children: ReactNode }) => ( {children} ); const { result } = renderHook( () => ({ creator: useResolvedUser(alice.name), mentions: useResolvedMentionUsernames("Hello @bob"), }), { wrapper }, ); await waitFor(() => { expect(result.current.creator).toBe(alice); expect(result.current.mentions.has("bob")).toBe(true); }); expect(clients.batchGetUsers).toHaveBeenCalledTimes(1); expect(clients.batchGetUsers).toHaveBeenCalledWith({ usernames: ["bob", "alice"] }); expect(clients.getUser).not.toHaveBeenCalled(); }); it("reuses memo list data and canonical detail queries when resolving relations", async () => { const cachedMemo = { name: "memos/cached", snippet: "Already in the list" } as Memo; const missingMemo = { name: "memos/missing", snippet: "Fetched once" } as Memo; clients.getMemo.mockResolvedValue(missingMemo); const queryClient = createQueryClient(); queryClient.setQueryData(memoKeys.list({}), { pages: [{ memos: [cachedMemo], nextPageToken: "" }], pageParams: [""], }); const { result } = renderHook( () => ({ first: useResolvedRelationMemos([cachedMemo.name, missingMemo.name]), second: useResolvedRelationMemos([missingMemo.name]), }), { wrapper: createWrapper(queryClient) }, ); await waitFor(() => { expect(result.current.first[cachedMemo.name]?.snippet).toBe(cachedMemo.snippet); expect(result.current.first[missingMemo.name]?.snippet).toBe(missingMemo.snippet); expect(result.current.second[missingMemo.name]?.snippet).toBe(missingMemo.snippet); }); expect(clients.getMemo).toHaveBeenCalledTimes(1); expect(clients.getMemo).toHaveBeenCalledWith({ name: missingMemo.name }); }); });