import { QueryClient } from '@tanstack/react-query' import { describe, expect, it, vi } from 'vitest' import { createPostFeedOptions, PostLoadError } from './use-post-feed' type FeedLoaders = Parameters[1] const loaders = (overrides: Partial = {}): FeedLoaders => ({ loadUser: vi.fn(), loadList: vi.fn(), search: vi.fn(), ...overrides, }) describe('createPostFeedOptions', () => { it('forwards one user cursor in the server function payload', async () => { const loadUser = vi.fn().mockResolvedValue({ ok: true, page: { tweets: [], nextCursor: 'next' }, }) const options = createPostFeedOptions( { connectionId: 'main', kind: 'user', target: '@yuta' }, loaders({ loadUser }), ) await options.queryFn({ pageParam: 'cursor' } as never) expect(loadUser).toHaveBeenCalledOnce() expect(loadUser).toHaveBeenCalledWith({ data: { connectionId: 'main', target: '@yuta', cursor: 'cursor' }, }) }) it('forwards one list cursor in the server function payload', async () => { const loadList = vi.fn().mockResolvedValue({ ok: true, page: { tweets: [], nextCursor: 'next' }, }) const options = createPostFeedOptions( { connectionId: 'main', kind: 'list', target: '1234567890' }, loaders({ loadList }), ) await options.queryFn({ pageParam: 'cursor' } as never) expect(loadList).toHaveBeenCalledOnce() expect(loadList).toHaveBeenCalledWith({ data: { connectionId: 'main', target: '1234567890', cursor: 'cursor' }, }) expect(options.queryKey).toEqual([ 'posts', { connectionId: 'main', kind: 'list', target: '1234567890' }, ]) }) it('does not retry requests automatically', () => { const options = createPostFeedOptions( { connectionId: 'main', kind: 'user', target: 'yuta' }, loaders(), ) expect(options.retry).toBe(false) }) it('does not refetch automatically when the window regains focus', () => { const options = createPostFeedOptions( { connectionId: 'main', kind: 'user', target: 'yuta' }, loaders(), ) expect(options.refetchOnWindowFocus).toBe(false) }) it('does not refetch automatically when the connection returns', () => { const options = createPostFeedOptions( { connectionId: 'main', kind: 'user', target: 'yuta' }, loaders(), ) expect(options.refetchOnReconnect).toBe(false) }) it('takes the next cursor only from the resolved page', () => { const options = createPostFeedOptions( { connectionId: 'main', kind: 'user', target: 'yuta' }, loaders(), ) expect( options.getNextPageParam({ tweets: [], nextCursor: 'page-next' }), ).toBe('page-next') expect(options.getNextPageParam({ tweets: [] })).toBeUndefined() }) it('includes every search control in the query key', () => { const options = createPostFeedOptions( { connectionId: 'main', kind: 'search', query: 'AI', product: 'Top', following: true, }, loaders(), ) expect(options.queryKey).toEqual([ 'posts', { connectionId: 'main', kind: 'search', query: 'AI', product: 'Top', following: true, }, ]) }) it('forwards every search control with the current cursor', async () => { const search = vi.fn().mockResolvedValue({ ok: true, page: { tweets: [], nextCursor: 'search-next' }, }) const options = createPostFeedOptions( { connectionId: 'main', kind: 'search', query: 'AI lang:ja', product: 'Top', following: true, }, loaders({ search }), ) await options.queryFn({ pageParam: 'search-cursor' } as never) expect(search).toHaveBeenCalledOnce() expect(search).toHaveBeenCalledWith({ data: { connectionId: 'main', query: 'AI lang:ja', product: 'Top', following: true, cursor: 'search-cursor', }, }) }) it('preserves a typed load failure for the feed', async () => { const error = { code: 'upstream' as const, message: '取得できませんでした。', retryable: true, } const options = createPostFeedOptions( { connectionId: 'main', kind: 'user', target: 'yuta' }, loaders({ loadUser: vi.fn().mockResolvedValue({ ok: false, error }), }), ) const request = options.queryFn({ pageParam: undefined } as never) await expect(request).rejects.toBeInstanceOf(PostLoadError) await expect(request).rejects.toMatchObject({ detail: error, message: error.message, }) }) }) describe('profile-bound feed cache', () => { it('keeps cached posts and pagination isolated between bound profiles', async () => { const loadUser = vi.fn(async ({ data }) => ({ ok: true as const, page: { tweets: [], nextCursor: `${data.connectionId}-next` }, })) const main = createPostFeedOptions( { kind: 'user', target: 'yuta', connectionId: 'main' }, loaders({ loadUser }), ) const work = createPostFeedOptions( { kind: 'user', target: 'yuta', connectionId: 'work' }, loaders({ loadUser }), ) const client = new QueryClient() await client.ensureInfiniteQueryData(main) expect( (await client.ensureInfiniteQueryData(work)).pages[0]?.nextCursor, ).toBe('work-next') expect( (await client.ensureInfiniteQueryData(main)).pages[0]?.nextCursor, ).toBe('main-next') expect(loadUser).toHaveBeenCalledTimes(2) await work.queryFn({ pageParam: 'work-next' }) expect(loadUser).toHaveBeenLastCalledWith({ data: { target: 'yuta', connectionId: 'work', cursor: 'work-next' }, }) client.clear() }) })