import { notifyManager, QueryClientProvider } from '@tanstack/react-query' import { act, renderHook, waitFor } from '@testing-library/react-native' import React, { Suspense } from 'react' import { chatQueryClient } from '../../contexts/api_provider' import type { MessageResource } from '../../types' import { anchoredPageParam, livePageParam, MessagesWindow } from '../../utils/cache/messages_window' import { getMessagesQueryKey } from '../../utils/request/get_messages' import { dropMessagesWindowIfBehind, useConversationMessages } from '../use_conversation_messages' jest.mock('../../contexts/api_provider', () => { const { QueryClient } = require('@tanstack/react-query') return { chatQueryClient: new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: Infinity } }, }), } }) const mockGet = jest.fn() jest.mock('../use_api_client', () => ({ useApiClient: () => ({ chat: { get: mockGet } }), })) notifyManager.setNotifyFunction(fn => act(fn)) const conversation_id = 7 const queryKey = getMessagesQueryKey({ conversation_id }) const anchor = 'B' const wrapper = ({ children }: { children: React.ReactNode }) => ( {children} ) const message = (id: string) => ({ id, type: 'Message', text: id }) as MessageResource const page = (ids: string[], meta: Record = {}) => ({ data: ids.map(message), links: {}, meta: { count: ids.length, totalCount: ids.length, ...meta }, }) const seed = ( pageParam: MessagesWindow['pageParams'][number], ids: string[], meta: Record = {} ) => chatQueryClient.setQueryData(queryKey, { pages: [page(ids, meta)], pageParams: [pageParam], }) const behindMeta = { prev: { idGt: 'B', order: 'asc' } } const cachedWindow = () => chatQueryClient.getQueryData(queryKey) const requestedWhere = () => mockGet.mock.calls.map(([request]) => request.data.where) const render = (options: Parameters[0]) => renderHook(() => useConversationMessages(options, { refetchOnMount: false }), { wrapper }) beforeEach(() => { chatQueryClient.clear() mockGet.mockReset() mockGet.mockResolvedValue(page(['C', 'B', 'A'])) }) describe('useConversationMessages', () => { it('opens around the anchor', async () => { render({ conversation_id, anchor }) await waitFor(() => expect(requestedWhere()).toEqual([{ id_around: anchor }])) }) it('reads the loaded window as is', async () => { seed(anchoredPageParam('A'), ['B', 'A'], behindMeta) const { result } = render({ conversation_id }) await waitFor(() => expect(result.current.messages.map(m => m.id)).toEqual(['B', 'A'])) expect(result.current.behind).toBe(true) expect(mockGet).not.toHaveBeenCalled() }) it('reports behind and keeps the anchor while newer messages exist', async () => { mockGet.mockResolvedValue(page(['C', 'B', 'A'], { prev: { idGt: 'C', order: 'asc' } })) const { result } = render({ conversation_id, anchor }) await waitFor(() => expect(result.current.behind).toBe(true)) expect(cachedWindow()?.pageParams[0]).toEqual({ where: { id_around: anchor } }) }) it('refetches around the anchor when the cached window stops short of it', async () => { seed(livePageParam, ['Z', 'Y'], { next: { idLt: 'Y' } }) const { result } = render({ conversation_id, anchor }) await waitFor(() => expect(requestedWhere()).toEqual([{ id_around: anchor }])) await waitFor(() => expect(result.current.messages.map(m => m.id)).toContain(anchor)) }) it('reuses a cached window that was opened at the anchor', async () => { seed(anchoredPageParam(anchor), ['Z', anchor], behindMeta) const { result } = render({ conversation_id, anchor }) await waitFor(() => expect(result.current.messages.map(m => m.id)).toContain(anchor)) expect(mockGet).not.toHaveBeenCalled() }) it('refetches around the anchor when a live window still holds a stale copy of it', async () => { seed(livePageParam, ['Z', anchor]) render({ conversation_id, anchor }) await waitFor(() => expect(requestedWhere()).toEqual([{ id_around: anchor }])) }) it('keeps a deleted anchor so the list can mark where it was', async () => { const deleted = { ...message(anchor), text: null, author: null, deletedAt: '2026-10-07T00:00:00Z', } as unknown as MessageResource mockGet.mockResolvedValue({ ...page(['C', 'A']), data: [message('C'), deleted, message('A')] }) const { result } = render({ conversation_id, anchor }) await waitFor(() => expect(result.current.messages.map(m => m.id)).toEqual(['C', 'B', 'A'])) }) it('settles into the live window when the anchored page is already caught up', async () => { const { result } = render({ conversation_id, anchor }) await waitFor(() => expect(cachedWindow()?.pageParams[0]).toEqual(livePageParam)) expect(result.current.behind).toBe(false) }) }) describe('fetchNewer', () => { it('asks for the rows after the newest loaded one, oldest first, and merges them in', async () => { mockGet.mockResolvedValueOnce(page(['C', 'B', 'A'], { prev: { idGt: 'C', order: 'asc' } })) mockGet.mockResolvedValueOnce(page(['D', 'E'])) const { result } = render({ conversation_id, anchor }) await waitFor(() => expect(result.current.behind).toBe(true)) await act(() => result.current.fetchNewer()) const [request] = mockGet.mock.calls[1] expect(request.data).toMatchObject({ where: { id_gt: 'C' }, order: 'asc' }) await waitFor(() => expect(result.current.messages.map(m => m.id)).toEqual(['E', 'D', 'C', 'B', 'A']) ) expect(result.current.behind).toBe(false) await waitFor(() => expect(cachedWindow()?.pageParams[0]).toEqual(livePageParam)) }) it('refetches the same window after paging newer, instead of snapping to the anchor', async () => { mockGet.mockResolvedValueOnce(page(['C', 'B', 'A'], { prev: { idGt: 'C', order: 'asc' } })) mockGet.mockResolvedValueOnce(page(['D', 'E'], { next: { idGt: 'E', order: 'asc' } })) const { result } = render({ conversation_id, anchor }) await waitFor(() => expect(result.current.behind).toBe(true)) await act(() => result.current.fetchNewer()) mockGet.mockResolvedValueOnce(page(['D', 'E'], { next: { idGt: 'E', order: 'asc' } })) mockGet.mockResolvedValueOnce(page(['C', 'B', 'A'])) await act(() => result.current.refetch()) expect(requestedWhere().slice(2)).toEqual([{ id_gt: 'C' }, { id_lt: 'D' }]) await waitFor(() => expect(result.current.messages.map(m => m.id)).toEqual(['E', 'D', 'C', 'B', 'A']) ) expect(result.current.behind).toBe(true) }) it('does nothing while the window is live', async () => { const { result } = render({ conversation_id, anchor }) await waitFor(() => expect(result.current.behind).toBe(false)) await act(() => result.current.fetchNewer()) expect(mockGet).toHaveBeenCalledTimes(1) }) }) describe('fetchNewer and older paging', () => { const deferred = () => { let resolve: (value: unknown) => void = () => {} const promise = new Promise(r => (resolve = r)) return { promise, resolve } } const newerRequests = () => mockGet.mock.calls.filter(([request]) => request.data.where?.id_gt).length const olderRequests = () => mockGet.mock.calls.filter(([request]) => request.data.where?.id_lt).length const openBehind = async () => { mockGet.mockResolvedValueOnce( page(['C', 'B', 'A'], { prev: { idGt: 'C', order: 'asc' }, next: { idLt: 'A' } }) ) const hook = render({ conversation_id, anchor }) await waitFor(() => expect(hook.result.current.behind).toBe(true)) return hook } it('skips a newer fetch while an older page is loading', async () => { const { result } = await openBehind() const older = deferred() mockGet.mockReturnValueOnce(older.promise) act(() => { result.current.fetchNextPage() }) act(() => { result.current.fetchNewer() }) expect(newerRequests()).toBe(0) await act(async () => older.resolve(page(['0']))) }) it('skips an older page while a newer fetch is in flight', async () => { const { result } = await openBehind() const newer = deferred() mockGet.mockReturnValueOnce(newer.promise) let pendingNewer: Promise = Promise.resolve() act(() => { pendingNewer = result.current.fetchNewer() }) act(() => { result.current.fetchNextPage() }) expect(olderRequests()).toBe(0) await act(async () => { newer.resolve(page(['D'])) await pendingNewer }) }) }) describe('fetchLiveWindow and in-flight fetches', () => { const deferred = () => { let resolve: (value: unknown) => void = () => {} const promise = new Promise(r => (resolve = r)) return { promise, resolve } } const openBehind = async () => { mockGet.mockResolvedValueOnce( page(['C', 'B', 'A'], { prev: { idGt: 'C', order: 'asc' }, next: { idLt: 'A' } }) ) const hook = render({ conversation_id, anchor }) await waitFor(() => expect(hook.result.current.behind).toBe(true)) return hook } it('keeps the live window when an older page started first', async () => { const { result } = await openBehind() const older = deferred() mockGet.mockReturnValueOnce(older.promise) mockGet.mockResolvedValueOnce(page(['Z', 'Y'])) act(() => { result.current.fetchNextPage() }) await act(() => result.current.fetchLiveWindow()) await act(async () => older.resolve(page(['0']))) expect(cachedWindow()?.pageParams).toEqual([livePageParam]) await waitFor(() => expect(result.current.messages.map(m => m.id)).toEqual(['Z', 'Y'])) }) it('keeps the live window when a newer page lands after it', async () => { const { result } = await openBehind() const newer = deferred() mockGet.mockReturnValueOnce(newer.promise) mockGet.mockResolvedValueOnce(page(['Z', 'Y'])) let pendingNewer: Promise = Promise.resolve() act(() => { pendingNewer = result.current.fetchNewer() }) await act(() => result.current.fetchLiveWindow()) await act(async () => { newer.resolve(page(['D'])) await pendingNewer }) expect(cachedWindow()?.pageParams).toEqual([livePageParam]) await waitFor(() => expect(result.current.messages.map(m => m.id)).toEqual(['Z', 'Y'])) }) }) describe('fetchLiveWindow', () => { it('replaces a behind window with the newest page', async () => { mockGet.mockResolvedValueOnce(page(['C', 'B', 'A'], { prev: { idGt: 'C', order: 'asc' } })) mockGet.mockResolvedValueOnce(page(['Z', 'Y'])) const { result } = render({ conversation_id, anchor }) await waitFor(() => expect(result.current.behind).toBe(true)) await act(() => result.current.fetchLiveWindow()) const [request] = mockGet.mock.calls[1] expect(request.data.where).toBeUndefined() await waitFor(() => expect(result.current.messages.map(m => m.id)).toEqual(['Z', 'Y'])) expect(cachedWindow()?.pageParams).toEqual([livePageParam]) expect(result.current.behind).toBe(false) }) it('does nothing while the window is live', async () => { const { result } = render({ conversation_id, anchor }) await waitFor(() => expect(result.current.behind).toBe(false)) await act(() => result.current.fetchLiveWindow()) expect(mockGet).toHaveBeenCalledTimes(1) }) }) describe('dropMessagesWindowIfBehind', () => { it('drops a window that is behind the live edge', () => { seed(anchoredPageParam('A'), ['B', 'A'], behindMeta) dropMessagesWindowIfBehind({ conversation_id }) expect(cachedWindow()).toBeUndefined() }) it('keeps a live window', () => { seed(livePageParam, ['Z']) dropMessagesWindowIfBehind({ conversation_id }) expect(cachedWindow()?.pages[0].data.map(m => m.id)).toEqual(['Z']) }) })