Files
2026-10-19 16:04:17 +02:00

125 lines
4.4 KiB
TypeScript

import { act, renderHook, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { SWRConfig } from 'swr'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useItemActions, useItems } from '../src/hooks/useItems'
import type { Item } from '../src/api/items'
/* The api module is the seam: hooks are tested against a mocked module,
exactly like pages will consume the hooks. */
vi.mock('../src/api/items', () => ({
listItems: vi.fn(),
getItem: vi.fn(),
createItem: vi.fn(),
updateItem: vi.fn(),
deleteItem: vi.fn(),
}))
import * as itemsApi from '../src/api/items'
/* Fresh cache per test so revalidation assertions never share state. */
function wrapper({ children }: { children: ReactNode }) {
return <SWRConfig value={{ provider: () => new Map(), dedupingInterval: 0 }}>{children}</SWRConfig>
}
function item(overrides: Partial<Item> = {}): Item {
return {
id: 1,
title: 'First',
description: '',
status: 'active',
createdAt: '2026-09-15T10:00:00Z',
updatedAt: '2026-09-15T10:00:00Z',
...overrides,
}
}
/* useItems and useItemActions must share one SWR cache for action
revalidation to be observable — render both through one harness. */
function useItemsHarness() {
const list = useItems()
const actions = useItemActions()
return { list, actions }
}
beforeEach(() => {
vi.mocked(itemsApi.listItems).mockResolvedValue([item()])
vi.mocked(itemsApi.createItem).mockResolvedValue(item({ id: 2, title: 'Second' }))
vi.mocked(itemsApi.updateItem).mockResolvedValue(item({ status: 'archived' }))
vi.mocked(itemsApi.deleteItem).mockResolvedValue(undefined)
})
afterEach(() => {
vi.clearAllMocks()
})
describe('useItems', () => {
it('loads the list through the api module', async () => {
const { result } = renderHook(() => useItems(), { wrapper })
await waitFor(() => expect(result.current.items).toHaveLength(1))
expect(itemsApi.listItems).toHaveBeenCalledWith({})
expect(result.current.isLoading).toBe(false)
expect(result.current.error).toBeUndefined()
})
it('feeds the filter into the api query', async () => {
const { result } = renderHook(() => useItems({ status: 'archived' }), { wrapper })
await waitFor(() => expect(result.current.items).toHaveLength(1))
expect(itemsApi.listItems).toHaveBeenCalledWith({ status: 'archived' })
})
it('exposes load failures as errors', async () => {
vi.mocked(itemsApi.listItems).mockRejectedValueOnce(new Error('backend down'))
const { result } = renderHook(() => useItems(), { wrapper })
await waitFor(() => expect(result.current.error).toBeInstanceOf(Error))
expect(result.current.items).toBeUndefined()
})
})
describe('useItemActions', () => {
it('creates through the api module and revalidates the list', async () => {
const { result } = renderHook(() => useItemsHarness(), { wrapper })
await waitFor(() => expect(result.current.list.items).toHaveLength(1))
const fetchesBefore = vi.mocked(itemsApi.listItems).mock.calls.length
await act(() => result.current.actions.create({ title: 'Second' }))
expect(itemsApi.createItem).toHaveBeenCalledWith({ title: 'Second' })
await waitFor(() =>
expect(vi.mocked(itemsApi.listItems).mock.calls.length).toBeGreaterThan(fetchesBefore),
)
})
it('updates through the api module and revalidates the list', async () => {
const { result } = renderHook(() => useItemsHarness(), { wrapper })
await waitFor(() => expect(result.current.list.items).toHaveLength(1))
const fetchesBefore = vi.mocked(itemsApi.listItems).mock.calls.length
await act(() => result.current.actions.update(1, { status: 'archived' }))
expect(itemsApi.updateItem).toHaveBeenCalledWith(1, { status: 'archived' })
await waitFor(() =>
expect(vi.mocked(itemsApi.listItems).mock.calls.length).toBeGreaterThan(fetchesBefore),
)
})
it('removes through the api module and revalidates the list', async () => {
const { result } = renderHook(() => useItemsHarness(), { wrapper })
await waitFor(() => expect(result.current.list.items).toHaveLength(1))
const fetchesBefore = vi.mocked(itemsApi.listItems).mock.calls.length
await act(() => result.current.actions.remove(1))
expect(itemsApi.deleteItem).toHaveBeenCalledWith(1)
await waitFor(() =>
expect(vi.mocked(itemsApi.listItems).mock.calls.length).toBeGreaterThan(fetchesBefore),
)
})
})