Files
twitter-lite/src/features/posts/use-post-feed.test.ts
T

201 lines
5.7 KiB
TypeScript

import { QueryClient } from '@tanstack/react-query'
import { describe, expect, it, vi } from 'vitest'
import { createPostFeedOptions, PostLoadError } from './use-post-feed'
type FeedLoaders = Parameters<typeof createPostFeedOptions>[1]
const loaders = (overrides: Partial<FeedLoaders> = {}): 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()
})
})