fe120ee222
ESLint turned `no-explicit-any` off for test files, and 219 casts had accumulated across 12 of them. Two shapes, very different risks: most were `(useWorkspace as any).mockReturnValue(...)`, mere laziness that `vi.mocked()` does properly; the rest cast partial fixtures onto API types, which erases the contract exactly where a test should enforce it. That second kind matters here specifically. This project's recurring bug is a field silently dropped between backend and frontend -- the hand-written mapping in models/plan.py has done it twice, and apiFetch mishandling structured errors was the same shape. A test that casts an incomplete fixture to `any` keeps passing through precisely that change. A typed fixture layer already existed at tests/fixtures/mockData.ts and was imported by exactly zero tests: the right tool was there and invisible, so everyone reached for a cast. It is now a set of `make*(overrides: Partial<T>) => T` factories returning complete, valid objects, split by domain with a barrel. When a type gains a field, one factory fails to compile instead of thirty tests silently passing. With the count at zero the ESLint exemption is deleted, so this cannot regrow. Removing it immediately caught two `}: any` prop stubs that a grep for "as any" had missed; those mocks now use the real components' exported prop types. `as unknown as T` survives in five places, each a test deliberately feeding invalid input (an undefined breakdown, missing tierData, a bogus leave type). CLAUDE.md now documents that as the only accepted escape, and points at the factories. interface PollResponse in useScraperFetch.ts is exported so its shape can be derived rather than duplicated -- pollScraperSession declares Promise<unknown>, so there was no other honest way to type it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
364 lines
10 KiB
TypeScript
364 lines
10 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import { renderHook, act } from '@testing-library/react'
|
|
import { usePlannedDays } from './usePlannedDays'
|
|
import * as api from '@/services/api'
|
|
import { makePlannedLeaveDay } from '../../tests/fixtures'
|
|
|
|
vi.mock('@/services/api')
|
|
|
|
describe('usePlannedDays', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
})
|
|
|
|
it('initializes with null data', () => {
|
|
const { result } = renderHook(() => usePlannedDays())
|
|
expect(result.current.data).toBeNull()
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
|
|
it('fetches planned days when childId provided', async () => {
|
|
const mockData = [
|
|
makePlannedLeaveDay({
|
|
id: '1',
|
|
workspaceId: 'ws1',
|
|
childId: 'child1',
|
|
parentPersonalDataUuid: 'parent1',
|
|
date: '2026-06-15',
|
|
dayType: 'vacation',
|
|
isDubbeldag: false,
|
|
}),
|
|
]
|
|
|
|
vi.mocked(api.getPlannedDays).mockResolvedValue(mockData)
|
|
|
|
renderHook(() => usePlannedDays('child1'))
|
|
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
})
|
|
|
|
expect(api.getPlannedDays).toHaveBeenCalledWith('child1')
|
|
})
|
|
|
|
it('does not fetch without childId', () => {
|
|
vi.mocked(api.getPlannedDays).mockResolvedValue([])
|
|
|
|
renderHook(() => usePlannedDays())
|
|
|
|
expect(api.getPlannedDays).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('adds planned day and refreshes', async () => {
|
|
const mockAdded = makePlannedLeaveDay({
|
|
id: '2',
|
|
workspaceId: 'ws1',
|
|
childId: 'child1',
|
|
parentPersonalDataUuid: 'parent1',
|
|
date: '2026-06-16',
|
|
dayType: 'vacation',
|
|
isDubbeldag: false,
|
|
})
|
|
|
|
vi.mocked(api.createPlannedDay).mockResolvedValue(mockAdded)
|
|
vi.mocked(api.getPlannedDays).mockResolvedValue([mockAdded])
|
|
|
|
renderHook(() => usePlannedDays('child1'))
|
|
|
|
await act(async () => {
|
|
// just test that add can be called; mocks handle the result
|
|
})
|
|
|
|
expect(api.createPlannedDay).not.toHaveBeenCalled() // won't be called without user action
|
|
})
|
|
|
|
it('removes planned day and refreshes', async () => {
|
|
vi.mocked(api.deletePlannedDay).mockResolvedValue(undefined)
|
|
vi.mocked(api.getPlannedDays).mockResolvedValue([])
|
|
|
|
const { result } = renderHook(() => usePlannedDays('child1'))
|
|
|
|
await act(async () => {
|
|
await result.current.remove('pd1')
|
|
})
|
|
|
|
expect(api.deletePlannedDay).toHaveBeenCalledWith('pd1')
|
|
expect(api.getPlannedDays).toHaveBeenCalled()
|
|
})
|
|
|
|
it('handles fetch error and sets error state', async () => {
|
|
const errorMessage = 'Network error'
|
|
vi.mocked(api.getPlannedDays).mockRejectedValue(new Error(errorMessage))
|
|
|
|
const { result } = renderHook(() => usePlannedDays('child1'))
|
|
|
|
await act(async () => {
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
})
|
|
|
|
expect(result.current.error).toBe(errorMessage)
|
|
expect(result.current.data).toBeNull()
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
|
|
it('handles fetch error with non-Error object', async () => {
|
|
vi.mocked(api.getPlannedDays).mockRejectedValue('Unknown error')
|
|
|
|
const { result } = renderHook(() => usePlannedDays('child1'))
|
|
|
|
await act(async () => {
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
})
|
|
|
|
expect(result.current.error).toBe('Failed to fetch planned days')
|
|
expect(result.current.data).toBeNull()
|
|
})
|
|
|
|
it('updates planned day and refreshes', async () => {
|
|
const mockUpdated = makePlannedLeaveDay({
|
|
id: '1',
|
|
workspaceId: 'ws1',
|
|
childId: 'child1',
|
|
parentPersonalDataUuid: 'parent1',
|
|
date: '2026-06-16',
|
|
dayType: 'lagsta',
|
|
isDubbeldag: false,
|
|
})
|
|
|
|
vi.mocked(api.updatePlannedDay).mockResolvedValue(mockUpdated)
|
|
vi.mocked(api.getPlannedDays).mockResolvedValue([mockUpdated])
|
|
|
|
const { result } = renderHook(() => usePlannedDays('child1'))
|
|
|
|
await act(async () => {
|
|
await result.current.update('1', { day_type: 'lagsta' })
|
|
})
|
|
|
|
expect(api.updatePlannedDay).toHaveBeenCalledWith('1', { day_type: 'lagsta' })
|
|
expect(api.getPlannedDays).toHaveBeenCalled()
|
|
})
|
|
|
|
it('updates data after successful add', async () => {
|
|
const initialData = [
|
|
makePlannedLeaveDay({
|
|
id: '1',
|
|
workspaceId: 'ws1',
|
|
childId: 'child1',
|
|
parentPersonalDataUuid: 'parent1',
|
|
date: '2026-06-15',
|
|
dayType: 'vacation',
|
|
isDubbeldag: false,
|
|
}),
|
|
]
|
|
|
|
const newData = makePlannedLeaveDay({
|
|
id: '2',
|
|
workspaceId: 'ws1',
|
|
childId: 'child1',
|
|
parentPersonalDataUuid: 'parent1',
|
|
date: '2026-06-16',
|
|
dayType: 'vacation',
|
|
isDubbeldag: false,
|
|
})
|
|
|
|
vi.mocked(api.getPlannedDays).mockResolvedValueOnce(initialData)
|
|
vi.mocked(api.createPlannedDay).mockResolvedValue(newData)
|
|
vi.mocked(api.getPlannedDays).mockResolvedValueOnce([...initialData, newData])
|
|
|
|
const { result } = renderHook(() => usePlannedDays('child1'))
|
|
|
|
await act(async () => {
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
})
|
|
|
|
expect(result.current.data).toEqual(initialData)
|
|
|
|
await act(async () => {
|
|
await result.current.add({ workspace_id: 'ws1', child_id: 'child1', date: '2026-06-16', day_type: 'vacation', parent_personal_data_uuid: 'parent1' })
|
|
})
|
|
|
|
expect(result.current.data).toEqual([...initialData, newData])
|
|
})
|
|
|
|
it('updates data after successful remove', async () => {
|
|
const initialData = [
|
|
makePlannedLeaveDay({
|
|
id: '1',
|
|
workspaceId: 'ws1',
|
|
childId: 'child1',
|
|
parentPersonalDataUuid: 'parent1',
|
|
date: '2026-06-15',
|
|
dayType: 'vacation',
|
|
isDubbeldag: false,
|
|
}),
|
|
makePlannedLeaveDay({
|
|
id: '2',
|
|
workspaceId: 'ws1',
|
|
childId: 'child1',
|
|
parentPersonalDataUuid: 'parent1',
|
|
date: '2026-06-16',
|
|
dayType: 'vacation',
|
|
isDubbeldag: false,
|
|
}),
|
|
]
|
|
|
|
vi.mocked(api.getPlannedDays).mockResolvedValueOnce(initialData)
|
|
vi.mocked(api.deletePlannedDay).mockResolvedValue(undefined)
|
|
vi.mocked(api.getPlannedDays).mockResolvedValueOnce([initialData[0]!])
|
|
|
|
const { result } = renderHook(() => usePlannedDays('child1'))
|
|
|
|
await act(async () => {
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
})
|
|
|
|
expect(result.current.data).toEqual(initialData)
|
|
|
|
await act(async () => {
|
|
await result.current.remove('2')
|
|
})
|
|
|
|
expect(result.current.data).toEqual([initialData[0]])
|
|
})
|
|
|
|
it('refreshes data explicitly', async () => {
|
|
const mockData = [
|
|
makePlannedLeaveDay({
|
|
id: '1',
|
|
workspaceId: 'ws1',
|
|
childId: 'child1',
|
|
parentPersonalDataUuid: 'parent1',
|
|
date: '2026-06-15',
|
|
dayType: 'vacation',
|
|
isDubbeldag: false,
|
|
}),
|
|
]
|
|
|
|
vi.mocked(api.getPlannedDays).mockResolvedValue(mockData)
|
|
|
|
const { result } = renderHook(() => usePlannedDays('child1'))
|
|
|
|
await act(async () => {
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
})
|
|
|
|
expect(api.getPlannedDays).toHaveBeenCalledWith('child1')
|
|
const callCount = vi.mocked(api.getPlannedDays).mock.calls.length
|
|
|
|
await act(async () => {
|
|
await result.current.refresh()
|
|
})
|
|
|
|
expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBeGreaterThan(callCount)
|
|
})
|
|
|
|
it('sets loading state during fetch', async () => {
|
|
let resolveGetPlannedDays: (value: Awaited<ReturnType<typeof api.getPlannedDays>>) => void
|
|
const getPlannedDaysPromise: ReturnType<typeof api.getPlannedDays> = new Promise(resolve => {
|
|
resolveGetPlannedDays = resolve
|
|
})
|
|
|
|
vi.mocked(api.getPlannedDays).mockReturnValue(getPlannedDaysPromise)
|
|
|
|
const { result } = renderHook(() => usePlannedDays('child1'))
|
|
|
|
expect(result.current.isLoading).toBe(true)
|
|
|
|
await act(async () => {
|
|
resolveGetPlannedDays!([])
|
|
await getPlannedDaysPromise
|
|
})
|
|
|
|
expect(result.current.isLoading).toBe(false)
|
|
})
|
|
|
|
it('clears error on successful fetch after error', async () => {
|
|
vi.mocked(api.getPlannedDays).mockRejectedValueOnce(new Error('Network error'))
|
|
vi.mocked(api.getPlannedDays).mockResolvedValueOnce([])
|
|
|
|
const { result, rerender } = renderHook(({ childId }: { childId: string }) => usePlannedDays(childId), {
|
|
initialProps: { childId: 'child1' },
|
|
})
|
|
|
|
await act(async () => {
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
})
|
|
|
|
expect(result.current.error).toBe('Network error')
|
|
|
|
rerender({ childId: 'child2' })
|
|
|
|
await act(async () => {
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
})
|
|
|
|
expect(result.current.error).toBeNull()
|
|
})
|
|
|
|
it('handles add error gracefully', async () => {
|
|
vi.mocked(api.getPlannedDays).mockResolvedValue([])
|
|
vi.mocked(api.createPlannedDay).mockRejectedValue(new Error('Create failed'))
|
|
|
|
const { result } = renderHook(() => usePlannedDays('child1'))
|
|
|
|
await act(async () => {
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
})
|
|
|
|
let addError: Error | undefined
|
|
await act(async () => {
|
|
try {
|
|
await result.current.add({ workspace_id: 'ws1', child_id: 'child1', date: '2026-06-16', day_type: 'vacation', parent_personal_data_uuid: 'parent1' })
|
|
} catch (err) {
|
|
addError = err as Error
|
|
}
|
|
})
|
|
|
|
expect(addError?.message).toBe('Create failed')
|
|
})
|
|
|
|
it('handles update error gracefully', async () => {
|
|
vi.mocked(api.getPlannedDays).mockResolvedValue([])
|
|
vi.mocked(api.updatePlannedDay).mockRejectedValue(new Error('Update failed'))
|
|
|
|
const { result } = renderHook(() => usePlannedDays('child1'))
|
|
|
|
await act(async () => {
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
})
|
|
|
|
let updateError: Error | undefined
|
|
await act(async () => {
|
|
try {
|
|
await result.current.update('1', { day_type: 'lagsta' })
|
|
} catch (err) {
|
|
updateError = err as Error
|
|
}
|
|
})
|
|
|
|
expect(updateError?.message).toBe('Update failed')
|
|
})
|
|
|
|
it('handles remove error gracefully', async () => {
|
|
vi.mocked(api.getPlannedDays).mockResolvedValue([])
|
|
vi.mocked(api.deletePlannedDay).mockRejectedValue(new Error('Delete failed'))
|
|
|
|
const { result } = renderHook(() => usePlannedDays('child1'))
|
|
|
|
await act(async () => {
|
|
await new Promise(resolve => setTimeout(resolve, 0))
|
|
})
|
|
|
|
let removeError: Error | undefined
|
|
await act(async () => {
|
|
try {
|
|
await result.current.remove('1')
|
|
} catch (err) {
|
|
removeError = err as Error
|
|
}
|
|
})
|
|
|
|
expect(removeError?.message).toBe('Delete failed')
|
|
})
|
|
})
|