import { afterEach, beforeEach, describe, expect, rs, test, } from "@rstest/core"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { act, cleanup, renderHook } from "@testing-library/react"; import type { PropsWithChildren } from "react"; const mocks = rs.hoisted(() => ({ fetch: rs.fn(), available: true, })); rs.mock("@/core/api/fetcher", () => ({ fetch: mocks.fetch })); rs.mock("@/core/config", () => ({ getBackendBaseURL: () => "" })); rs.mock("@/core/features/hooks", () => ({ useThreadActivityFeature: () => ({ available: mocks.available, isLoading: false, }), })); import { applyThreadActivity, cachedThreadUnread, MARK_THREAD_READ_DEBOUNCE_MS, threadActivityState, useMarkThreadRead, useThreadActivity, } from "@/core/threads/activity"; import { INFINITE_THREADS_QUERY_KEY_PREFIX, type InfiniteThreadsData, } from "@/core/threads/hooks"; import type { AgentThread } from "@/core/threads/types"; const clients: QueryClient[] = []; function thread(id: string, unread: boolean | null | undefined): AgentThread { return { thread_id: id, unread, metadata: {}, values: { title: id }, } as unknown as AgentThread; } function newClient() { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); clients.push(queryClient); const wrapper = ({ children }: PropsWithChildren) => ( {children} ); return { queryClient, wrapper }; } function setup( threadId: string | null, { search = [], infinite = [], }: { search?: AgentThread[]; infinite?: AgentThread[] } = {}, ) { const { queryClient, wrapper } = newClient(); queryClient.setQueryData(["threads", "search", { limit: 50 }], search); queryClient.setQueryData( [...INFINITE_THREADS_QUERY_KEY_PREFIX, {}], { pages: [{ threads: infinite, hiddenCount: 0, hasMore: false }], pageParams: [0], }, ); const hook = renderHook(({ id }) => useMarkThreadRead(id), { wrapper, initialProps: { id: threadId }, }); return { queryClient, hook }; } async function advance(ms = MARK_THREAD_READ_DEBOUNCE_MS) { await act(async () => { await rs.advanceTimersByTimeAsync(ms); }); } function readPosts() { return mocks.fetch.mock.calls.filter( ([, init]) => (init as RequestInit | undefined)?.method === "POST", ); } beforeEach(() => { rs.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); mocks.available = true; mocks.fetch .mockReset() .mockImplementation(async () => Response.json({ unread: false, read_version: 7 }), ); }); afterEach(() => { cleanup(); rs.useRealTimers(); clients.splice(0).forEach((client) => client.clear()); }); describe("useMarkThreadRead", () => { test("debounces repeated calls into one POST after 1 s", async () => { const { hook } = setup("t-1", { search: [thread("t-1", true)] }); act(() => { hook.result.current(); hook.result.current(); }); await advance(MARK_THREAD_READ_DEBOUNCE_MS - 1); expect(readPosts()).toHaveLength(0); act(() => hook.result.current()); await advance(); expect(readPosts()).toHaveLength(1); expect(String(readPosts()[0]![0])).toBe("/api/threads/t-1/read"); }); test("patches the cached unread flag in search and infinite lists", async () => { let finish!: (response: Response) => void; mocks.fetch.mockImplementation( () => new Promise((resolve) => { finish = resolve; }), ); const { queryClient, hook } = setup("t-1", { search: [thread("t-1", true), thread("t-2", true)], infinite: [thread("t-1", true)], }); act(() => hook.result.current()); await advance(); // Optimistic: patched before the POST answers. expect(readPosts()).toHaveLength(1); expect(cachedThreadUnread(queryClient, "t-1")).toBe(false); expect(cachedThreadUnread(queryClient, "t-2")).toBe(true); const infinite = queryClient.getQueryData([ ...INFINITE_THREADS_QUERY_KEY_PREFIX, {}, ]); expect(infinite?.pages[0]?.threads[0]?.unread).toBe(false); await act(async () => { finish(Response.json({ unread: false, read_version: 3 })); await rs.advanceTimersByTimeAsync(0); }); }); test("posts nothing when the cached unread flag is false", async () => { const { hook } = setup("t-1", { search: [thread("t-1", false)], infinite: [thread("t-1", false)], }); act(() => hook.result.current()); await advance(MARK_THREAD_READ_DEBOUNCE_MS * 2); expect(readPosts()).toHaveLength(0); }); test("posts when no loaded list knows the thread", async () => { const { hook } = setup("t-9", { search: [thread("t-1", false)] }); act(() => hook.result.current()); await advance(); expect(readPosts()).toHaveLength(1); }); test("does nothing when thread activity is unavailable", async () => { mocks.available = false; const { hook } = setup("t-1", { search: [thread("t-1", true)] }); act(() => hook.result.current()); await advance(MARK_THREAD_READ_DEBOUNCE_MS * 2); expect(mocks.fetch).not.toHaveBeenCalled(); }); test("leaving the thread before the debounce ends cancels the read", async () => { const { hook } = setup("t-1", { search: [thread("t-1", true)] }); act(() => hook.result.current()); hook.rerender({ id: "t-2" }); await advance(MARK_THREAD_READ_DEBOUNCE_MS * 2); expect(readPosts()).toHaveLength(0); }); test("stores the returned read_version so the next poll does not refetch the lists", async () => { const { queryClient, hook } = setup("t-1", { search: [thread("t-1", true)], }); const state = threadActivityState(queryClient); state.cursor = "5:run-5"; state.readVersion = 6; act(() => hook.result.current()); await advance(); expect(state.readVersion).toBe(7); const outcome = applyThreadActivity(queryClient, state, { cursor: "5:run-5", threads: [], truncated: false, read_version: 7, }); expect(outcome.threads).toBe(false); }); test("a returned version that also covers reads on other devices refetches the lists", async () => { // Version 6 seen here; another device read Y (7), then this tab reads X (8). mocks.fetch.mockImplementation(async () => Response.json({ unread: false, read_version: 8 }), ); const { queryClient, hook } = setup("t-1", { search: [thread("t-1", true)], }); const state = threadActivityState(queryClient); state.cursor = "5:run-5"; state.readVersion = 6; const invalidate = rs.spyOn(queryClient, "invalidateQueries"); act(() => hook.result.current()); await advance(); expect(invalidate).toHaveBeenCalledWith({ queryKey: INFINITE_THREADS_QUERY_KEY_PREFIX, }); expect(state.readVersion).toBe(8); }); test("a failed POST refetches the lists to show the server state again", async () => { mocks.fetch.mockImplementation(async () => Response.json({ detail: "Thread not found" }, { status: 404 }), ); const { queryClient, hook } = setup("t-1", { search: [thread("t-1", true)], }); const invalidate = rs.spyOn(queryClient, "invalidateQueries"); act(() => hook.result.current()); await advance(); expect(invalidate).toHaveBeenCalledWith({ queryKey: ["threads", "search"], }); }); }); describe("useThreadActivity", () => { test("leaving the workspace forgets the cursor and read clock (another account may sign in)", () => { mocks.available = false; const { queryClient, wrapper } = newClient(); const hook = renderHook(() => useThreadActivity(), { wrapper }); const state = threadActivityState(queryClient); state.cursor = "40:run-40"; state.readVersion = 9; hook.rerender(); expect(state).toEqual({ cursor: "40:run-40", readVersion: 9 }); hook.unmount(); expect(threadActivityState(queryClient)).toEqual({ cursor: null, readVersion: null, }); }); });