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'])
})
})