c5481a05f0
Branches were the weakest metric and had the least headroom above the 85% floor. These are tests for decision paths that were genuinely unexercised, not padding. The largest gaps were systemic rather than per-file: the `err instanceof Error ? ... : fallback` branch was untested across eight data hooks, useVacationDays had no dedicated test file at all, and Calendar.tsx's missing-workspace/child/personalData fallbacks were never hit. Left deliberately uncovered, with reasons: `?? fallback` guards whose fallback cannot occur given how the value is constructed upstream, and guards behind buttons that are disabled whenever the guard would matter. Reaching those means calling handlers directly, which tests the test rather than the app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
577 lines
18 KiB
TypeScript
577 lines
18 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
import { DevFetch } from './DevFetch'
|
|
import * as api from '@/services/api'
|
|
import * as errors from '@/services/errors'
|
|
import { useScraperFetch } from '@/hooks'
|
|
|
|
vi.mock('@/services/api')
|
|
vi.mock('@/services/errors')
|
|
vi.mock('@/hooks', () => ({
|
|
useScraperFetch: vi.fn(),
|
|
}))
|
|
|
|
describe('DevFetch', () => {
|
|
const mockUserContext: api.UserContext = {
|
|
workspace_id: 'ws-test-123',
|
|
personal_data_uuid: 'pd-test-456',
|
|
parent_name: 'Test Parent',
|
|
children: [
|
|
{
|
|
id: 'child-1',
|
|
name: 'Test Child',
|
|
birth_date: '2022-01-15',
|
|
},
|
|
],
|
|
}
|
|
|
|
const emptyUserContext: api.UserContext = {
|
|
workspace_id: 'ws-test-123',
|
|
personal_data_uuid: 'pd-test-456',
|
|
parent_name: 'Test Parent',
|
|
children: [],
|
|
}
|
|
|
|
const mockScraperFetchResult = {
|
|
sessionId: null,
|
|
pollData: null,
|
|
resolvedIds: null,
|
|
error: null,
|
|
isUnauthorized: false,
|
|
start: vi.fn(),
|
|
cancel: vi.fn(),
|
|
isRunning: false,
|
|
isTerminal: false,
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
vi.mocked(api.getUserContext).mockResolvedValue(mockUserContext)
|
|
vi.mocked(errors.isUnauthorized).mockReturnValue(false)
|
|
vi.mocked(useScraperFetch).mockReturnValue(mockScraperFetchResult)
|
|
})
|
|
|
|
describe('initial load', () => {
|
|
it('loads user context on mount', async () => {
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
expect(api.getUserContext).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
it('displays workspace and personal data when context loads', async () => {
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/ws-test-123/)).toBeInTheDocument()
|
|
expect(screen.getByText(/pd-test-456/)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('displays children when context includes them', async () => {
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Test Child \(2022-01-15\)/)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('displays no children message when context has no children', async () => {
|
|
vi.mocked(api.getUserContext).mockResolvedValue(emptyUserContext)
|
|
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Inga barn registrerade/)).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('error handling', () => {
|
|
it('displays unauthorized error when getUserContext fails with 401', async () => {
|
|
const unauthorizedError = new Error('Unauthorized')
|
|
vi.mocked(api.getUserContext).mockRejectedValue(unauthorizedError)
|
|
vi.mocked(errors.isUnauthorized).mockReturnValue(true)
|
|
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Setup-fel:/)).toBeInTheDocument()
|
|
expect(screen.getByText(/Not authenticated/)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('displays login link when unauthorized error occurs', async () => {
|
|
const unauthorizedError = new Error('Unauthorized')
|
|
vi.mocked(api.getUserContext).mockRejectedValue(unauthorizedError)
|
|
vi.mocked(errors.isUnauthorized).mockReturnValue(true)
|
|
vi.mocked(useScraperFetch).mockReturnValue({
|
|
...mockScraperFetchResult,
|
|
isUnauthorized: true,
|
|
})
|
|
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
const loginLink = screen.getByText(/Logga in först/)
|
|
expect(loginLink).toBeInTheDocument()
|
|
expect(loginLink).toHaveAttribute('href', '/auth')
|
|
})
|
|
})
|
|
|
|
it('displays generic error when getUserContext fails with other errors', async () => {
|
|
const genericError = new Error('Network error')
|
|
vi.mocked(api.getUserContext).mockRejectedValue(genericError)
|
|
vi.mocked(errors.isUnauthorized).mockReturnValue(false)
|
|
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Setup-fel:/)).toBeInTheDocument()
|
|
expect(screen.getByText(/Network error/)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('handles non-Error exception in getUserContext', async () => {
|
|
vi.mocked(api.getUserContext).mockRejectedValue('Unknown error')
|
|
vi.mocked(errors.isUnauthorized).mockReturnValue(false)
|
|
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Setup-fel:/)).toBeInTheDocument()
|
|
expect(screen.getByText(/Failed to load context/)).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('refresh button', () => {
|
|
it('disables refresh button when no children', async () => {
|
|
vi.mocked(api.getUserContext).mockResolvedValue(emptyUserContext)
|
|
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
const refreshButton = screen.getByRole('button', { name: /Refresh/ })
|
|
expect(refreshButton).toBeDisabled()
|
|
})
|
|
})
|
|
|
|
it('enables refresh button when children exist', async () => {
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
const refreshButton = screen.getByRole('button', { name: /Refresh/ })
|
|
expect(refreshButton).not.toBeDisabled()
|
|
})
|
|
})
|
|
|
|
it('calls scraper.start when refresh is clicked', async () => {
|
|
const mockStart = vi.fn()
|
|
vi.mocked(useScraperFetch).mockReturnValue({
|
|
...mockScraperFetchResult,
|
|
start: mockStart,
|
|
})
|
|
|
|
render(<DevFetch />)
|
|
|
|
const refreshButton = await screen.findByRole('button', { name: /Refresh/ })
|
|
fireEvent.click(refreshButton)
|
|
|
|
expect(mockStart).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('create child', () => {
|
|
beforeEach(() => {
|
|
vi.mocked(api.getUserContext).mockResolvedValue(emptyUserContext)
|
|
})
|
|
|
|
it('shows child creation form when no children exist', async () => {
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByPlaceholderText(/Barnets namn/)).toBeInTheDocument()
|
|
expect(screen.getByPlaceholderText(/Födelsedatum/)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('hides child creation form when children exist', async () => {
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByPlaceholderText(/Barnets namn/)).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('disables create button when name or date is empty', async () => {
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
const createButton = screen.getByRole('button', { name: /Skapa barn/ })
|
|
expect(createButton).toBeDisabled()
|
|
})
|
|
})
|
|
|
|
it('enables create button when both name and date are filled', async () => {
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
const nameInput = screen.getByPlaceholderText(/Barnets namn/) as HTMLInputElement
|
|
const dateInput = screen.getByPlaceholderText(/Födelsedatum/) as HTMLInputElement
|
|
fireEvent.change(nameInput, { target: { value: 'New Child' } })
|
|
fireEvent.change(dateInput, { target: { value: '2024-01-01' } })
|
|
})
|
|
|
|
const createButton = screen.getByRole('button', { name: /Skapa barn/ })
|
|
expect(createButton).not.toBeDisabled()
|
|
})
|
|
|
|
it('calls api.createChild with correct parameters', async () => {
|
|
const mockCreateChild = vi.fn().mockResolvedValue({ id: 'new-child', name: 'New Child' })
|
|
vi.mocked(api.createChild).mockImplementation(mockCreateChild)
|
|
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
const nameInput = screen.getByPlaceholderText(/Barnets namn/) as HTMLInputElement
|
|
const dateInput = screen.getByPlaceholderText(/Födelsedatum/) as HTMLInputElement
|
|
fireEvent.change(nameInput, { target: { value: 'New Child' } })
|
|
fireEvent.change(dateInput, { target: { value: '2024-01-01' } })
|
|
})
|
|
|
|
const createButton = screen.getByRole('button', { name: /Skapa barn/ })
|
|
fireEvent.click(createButton)
|
|
|
|
await waitFor(() => {
|
|
expect(mockCreateChild).toHaveBeenCalledWith({
|
|
workspace_id: 'ws-test-123',
|
|
name: 'New Child',
|
|
birth_date: '2024-01-01',
|
|
})
|
|
})
|
|
})
|
|
|
|
it('shows loading state while creating child', async () => {
|
|
vi.mocked(api.createChild).mockImplementation(
|
|
() => new Promise(resolve => setTimeout(() => resolve({ id: 'new-child', name: 'New Child' }), 100))
|
|
)
|
|
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
const nameInput = screen.getByPlaceholderText(/Barnets namn/) as HTMLInputElement
|
|
const dateInput = screen.getByPlaceholderText(/Födelsedatum/) as HTMLInputElement
|
|
fireEvent.change(nameInput, { target: { value: 'New Child' } })
|
|
fireEvent.change(dateInput, { target: { value: '2024-01-01' } })
|
|
})
|
|
|
|
const createButton = screen.getByRole('button', { name: /Skapa barn/ })
|
|
fireEvent.click(createButton)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByRole('button', { name: /Skapar…/ })).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('reloads context after successful child creation', async () => {
|
|
vi.mocked(api.createChild).mockResolvedValue({ id: 'new-child', name: 'New Child' })
|
|
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
const nameInput = screen.getByPlaceholderText(/Barnets namn/) as HTMLInputElement
|
|
const dateInput = screen.getByPlaceholderText(/Födelsedatum/) as HTMLInputElement
|
|
fireEvent.change(nameInput, { target: { value: 'New Child' } })
|
|
fireEvent.change(dateInput, { target: { value: '2024-01-01' } })
|
|
})
|
|
|
|
const createButton = screen.getByRole('button', { name: /Skapa barn/ })
|
|
fireEvent.click(createButton)
|
|
|
|
await waitFor(() => {
|
|
expect(api.getUserContext).toHaveBeenCalledTimes(2)
|
|
})
|
|
})
|
|
|
|
it('clears form inputs and hides form after successful child creation', async () => {
|
|
vi.mocked(api.getUserContext)
|
|
.mockResolvedValueOnce(emptyUserContext)
|
|
.mockResolvedValueOnce(mockUserContext) // Return non-empty after create
|
|
|
|
vi.mocked(api.createChild).mockResolvedValue({ id: 'new-child', name: 'New Child' })
|
|
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByPlaceholderText(/Barnets namn/)).toBeInTheDocument()
|
|
})
|
|
|
|
const nameInput = screen.getByPlaceholderText(/Barnets namn/) as HTMLInputElement
|
|
const dateInput = screen.getByPlaceholderText(/Födelsedatum/) as HTMLInputElement
|
|
fireEvent.change(nameInput, { target: { value: 'New Child' } })
|
|
fireEvent.change(dateInput, { target: { value: '2024-01-01' } })
|
|
|
|
const createButton = screen.getByRole('button', { name: /Skapa barn/ })
|
|
fireEvent.click(createButton)
|
|
|
|
// After creation succeeds, the form should be hidden because context now includes children
|
|
await waitFor(() => {
|
|
expect(screen.queryByPlaceholderText(/Barnets namn/)).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('handles error when child creation fails', async () => {
|
|
vi.mocked(api.createChild).mockRejectedValue(new Error('Creation failed'))
|
|
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
const nameInput = screen.getByPlaceholderText(/Barnets namn/) as HTMLInputElement
|
|
const dateInput = screen.getByPlaceholderText(/Födelsedatum/) as HTMLInputElement
|
|
fireEvent.change(nameInput, { target: { value: 'New Child' } })
|
|
fireEvent.change(dateInput, { target: { value: '2024-01-01' } })
|
|
})
|
|
|
|
const createButton = screen.getByRole('button', { name: /Skapa barn/ })
|
|
fireEvent.click(createButton)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Setup-fel:/)).toBeInTheDocument()
|
|
expect(screen.getByText(/Creation failed/)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('handles non-Error exception in child creation', async () => {
|
|
vi.mocked(api.createChild).mockRejectedValue('Unknown error')
|
|
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
const nameInput = screen.getByPlaceholderText(/Barnets namn/) as HTMLInputElement
|
|
const dateInput = screen.getByPlaceholderText(/Födelsedatum/) as HTMLInputElement
|
|
fireEvent.change(nameInput, { target: { value: 'New Child' } })
|
|
fireEvent.change(dateInput, { target: { value: '2024-01-01' } })
|
|
})
|
|
|
|
const createButton = screen.getByRole('button', { name: /Skapa barn/ })
|
|
fireEvent.click(createButton)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Setup-fel:/)).toBeInTheDocument()
|
|
expect(screen.getByText(/Failed to create child/)).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('scraper integration', () => {
|
|
it('displays resolved IDs when scraper has them', async () => {
|
|
vi.mocked(useScraperFetch).mockReturnValue({
|
|
...mockScraperFetchResult,
|
|
resolvedIds: {
|
|
workspace_id: 'ws-resolved',
|
|
personal_data_uuid: 'pd-resolved',
|
|
child_id: 'c-resolved',
|
|
},
|
|
})
|
|
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/workspace_id: ws-resolved/)).toBeInTheDocument()
|
|
expect(screen.getByText(/personal_data_uuid: pd-resolved/)).toBeInTheDocument()
|
|
expect(screen.getByText(/child_id: c-resolved/)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('displays scraper error when present', async () => {
|
|
vi.mocked(useScraperFetch).mockReturnValue({
|
|
...mockScraperFetchResult,
|
|
error: 'Scraper session failed',
|
|
})
|
|
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Fel:/)).toBeInTheDocument()
|
|
expect(screen.getByText(/Scraper session failed/)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('displays polling status when scraper is active', async () => {
|
|
vi.mocked(useScraperFetch).mockReturnValue({
|
|
...mockScraperFetchResult,
|
|
pollData: {
|
|
status: 'authenticating',
|
|
message: 'Scanning BankID...',
|
|
},
|
|
isRunning: true,
|
|
})
|
|
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Status:/)).toBeInTheDocument()
|
|
expect(screen.getByText(/authenticating/)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('displays QR code when awaiting scan', async () => {
|
|
vi.mocked(useScraperFetch).mockReturnValue({
|
|
...mockScraperFetchResult,
|
|
pollData: {
|
|
status: 'awaiting_scan',
|
|
qr_data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==',
|
|
},
|
|
isRunning: true,
|
|
})
|
|
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
const qrImage = screen.getByAltText(/BankID QR-kod/) as HTMLImageElement
|
|
expect(qrImage).toBeInTheDocument()
|
|
expect(qrImage.src).toContain('data:image/png;base64,')
|
|
})
|
|
})
|
|
|
|
it('colors the status red when the scraper session errored', async () => {
|
|
vi.mocked(useScraperFetch).mockReturnValue({
|
|
...mockScraperFetchResult,
|
|
pollData: {
|
|
status: 'error',
|
|
message: 'Something broke',
|
|
},
|
|
})
|
|
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
const statusValue = screen.getByText('error')
|
|
expect(statusValue).toHaveStyle({ color: 'rgb(255, 0, 0)' })
|
|
})
|
|
})
|
|
|
|
it('displays fetching message during data fetch', async () => {
|
|
vi.mocked(useScraperFetch).mockReturnValue({
|
|
...mockScraperFetchResult,
|
|
pollData: {
|
|
status: 'fetching',
|
|
},
|
|
isRunning: true,
|
|
})
|
|
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Hämtar data från FK…/)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('displays success state with result data', async () => {
|
|
vi.mocked(useScraperFetch).mockReturnValue({
|
|
...mockScraperFetchResult,
|
|
pollData: {
|
|
status: 'done',
|
|
result: {
|
|
children: [
|
|
{
|
|
name: 'Scraped Child',
|
|
birth_date: '2023-06-15',
|
|
},
|
|
],
|
|
},
|
|
},
|
|
})
|
|
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Klar! Hämtad data:/)).toBeInTheDocument()
|
|
expect(screen.getByText(/Scraped Child/)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('displays cancel button when scraper is running', async () => {
|
|
const mockCancel = vi.fn()
|
|
vi.mocked(useScraperFetch).mockReturnValue({
|
|
...mockScraperFetchResult,
|
|
pollData: {
|
|
status: 'authenticating',
|
|
},
|
|
isRunning: true,
|
|
cancel: mockCancel,
|
|
})
|
|
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
const cancelButton = screen.getByRole('button', { name: /Avbryt/ })
|
|
expect(cancelButton).toBeInTheDocument()
|
|
fireEvent.click(cancelButton)
|
|
expect(mockCancel).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
it('displays cancelled message when scraper session is cancelled', async () => {
|
|
vi.mocked(useScraperFetch).mockReturnValue({
|
|
...mockScraperFetchResult,
|
|
pollData: {
|
|
status: 'cancelled',
|
|
},
|
|
})
|
|
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Sessionen avbröts./)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('does not display cancel button when scraper is not running', async () => {
|
|
vi.mocked(useScraperFetch).mockReturnValue({
|
|
...mockScraperFetchResult,
|
|
pollData: {
|
|
status: 'done',
|
|
},
|
|
isRunning: false,
|
|
})
|
|
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.queryByRole('button', { name: /Avbryt/ })).not.toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('page structure and styling', () => {
|
|
it('renders the header with tool name', async () => {
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/🔧 FK Scraper Test Harness/)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('renders the description text', async () => {
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Dev-only./)).toBeInTheDocument()
|
|
})
|
|
})
|
|
|
|
it('renders context information box when context is loaded', async () => {
|
|
render(<DevFetch />)
|
|
|
|
await waitFor(() => {
|
|
expect(screen.getByText(/Workspace:/)).toBeInTheDocument()
|
|
expect(screen.getByText(/PersonalData:/)).toBeInTheDocument()
|
|
expect(screen.getByText(/Barn:/)).toBeInTheDocument()
|
|
})
|
|
})
|
|
})
|
|
})
|