Raise frontend branch coverage from 86.84% to 93.78%

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
This commit is contained in:
2026-09-14 18:54:56 +02:00
parent 8869ea8e55
commit c5481a05f0
23 changed files with 1063 additions and 5 deletions
+13
View File
@@ -49,6 +49,19 @@ describe('useAnalytics', () => {
expect(result.current.error).toContain('API error')
})
it('falls back to a generic message when a non-Error value is thrown', async () => {
vi.mocked(api.getWorkspaceOverview).mockRejectedValue('some string failure')
const { result } = renderHook(() => useAnalytics('ws1', 'child1'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(result.current.error).toBe('Failed to fetch analytics')
expect(result.current.data).toBeNull()
})
it('does not fetch when workspaceId is null', () => {
vi.mocked(api.getWorkspaceOverview).mockResolvedValue({} as any)
@@ -154,4 +154,10 @@ describe('getLastLeaveType / setLastLeaveType', () => {
expect(getLastLeaveType()).toBe(type)
}
})
it('ignores an invalid leave type and leaves storage untouched', () => {
setLastLeaveType('not-a-real-type' as unknown as Parameters<typeof setLastLeaveType>[0])
expect(sessionStorage.getItem(LAST_LEAVE_TYPE_KEY)).toBeNull()
expect(getLastLeaveType()).toBeNull()
})
})
@@ -93,4 +93,13 @@ describe('useDayOverrides', () => {
expect(result.current.error).toBe('Network error')
expect(result.current.data).toEqual([])
})
it('falls back to a generic message when a non-Error value is thrown', async () => {
vi.mocked(api.listDayOverridesByWorkspace).mockRejectedValueOnce('boom')
const { result } = renderHook(() => useDayOverrides('ws-1'))
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.error).toBe('Failed to load day overrides')
})
})
@@ -49,6 +49,18 @@ describe('useOptimizationTips', () => {
expect(result.current.error).toContain('API error')
})
it('falls back to a generic message when a non-Error value is thrown', async () => {
vi.mocked(api.getTips).mockRejectedValue('boom')
const { result } = renderHook(() => useOptimizationTips('ws1'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(result.current.error).toBe('Failed to fetch tips')
})
it('dismisses tips locally', async () => {
const mockTips = [
{ id: 'tip1', severity: 'info', messageKey: 'msg1', data: {} },
@@ -59,6 +59,18 @@ describe('useParentalLeave', () => {
expect(result.current.error).toContain('Fetch failed')
})
it('falls back to a generic message when a non-Error value is thrown', async () => {
vi.mocked(api.getParentalLeave).mockRejectedValue('boom')
const { result } = renderHook(() => useParentalLeave('pd1', 'child1'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(result.current.error).toBe('Failed to fetch parental leave data')
})
it('does not fetch when personalDataUuid or childId is null', () => {
vi.mocked(api.getParentalLeave).mockResolvedValue({} as any)
+27
View File
@@ -81,6 +81,33 @@ describe('usePlanApply', () => {
expect(result.current.response).toBeNull()
})
it('falls back to a generic message when a non-Error value is thrown', async () => {
vi.mocked(api.applyPlan).mockRejectedValue('boom')
const { result } = renderHook(() => usePlanApply())
const days = [
{
date: '2026-06-15',
parentPersonalDataUuid: 'pd-1',
childId: 'c-1',
dayType: 'sjukpenning' as const,
},
]
try {
await result.current.apply('ws-1', days, false)
} catch {
// Expected to throw
}
await waitFor(() => {
expect(result.current.isLoading).toBe(false)
})
expect(result.current.error).toBe('Kunde inte använda plan')
expect(result.current.response).toBeNull()
})
it('handles replace parameter correctly', async () => {
const mockResponse = {
accepted: true,
@@ -63,4 +63,17 @@ describe('usePlanProposal', () => {
expect(result.current.error).toContain('kaputt')
expect(result.current.data).toBeNull()
})
it('falls back to a generic message when a non-Error value is thrown', async () => {
vi.mocked(api.proposePlan).mockRejectedValue('boom')
const { result } = renderHook(() => usePlanProposal('ws1'))
await act(async () => {
await result.current.propose()
})
expect(result.current.error).toBe('Failed to compute plan proposal')
expect(result.current.data).toBeNull()
})
})
+165
View File
@@ -147,6 +147,171 @@ describe('useScraperFetch', () => {
expect(result.current.error).toBe('Not authenticated')
})
it('falls back to a generic message when polling rejects with a non-Error value', async () => {
vi.mocked(api.startScraperSession).mockResolvedValue({
session_id: 'sess-123',
workspace_id: 'ws-1',
personal_data_uuid: 'pd-1',
child_id: 'c-1',
} as any)
vi.mocked(api.pollScraperSession).mockRejectedValue('not an Error object')
const { result } = renderHook(() => useScraperFetch())
await act(async () => {
await result.current.start()
})
await act(async () => {
vi.advanceTimersByTime(1000)
})
expect(result.current.error).toBe('Poll error')
})
it('falls back to a generic message when start rejects with a non-Error value', async () => {
vi.mocked(api.startScraperSession).mockRejectedValue('not an Error object')
vi.mocked(errors.isUnauthorized).mockReturnValue(false)
const { result } = renderHook(() => useScraperFetch())
await act(async () => {
await result.current.start()
})
expect(result.current.error).toBe('Start error')
expect(result.current.isUnauthorized).toBe(false)
})
it('cancels a previous non-terminal session before starting a new one', async () => {
vi.mocked(api.startScraperSession)
.mockResolvedValueOnce({
session_id: 'sess-old',
workspace_id: 'ws-1',
personal_data_uuid: 'pd-1',
child_id: 'c-1',
} as any)
.mockResolvedValueOnce({
session_id: 'sess-new',
workspace_id: 'ws-1',
personal_data_uuid: 'pd-1',
child_id: 'c-1',
} as any)
vi.mocked(api.cancelScraperSession).mockResolvedValue(undefined)
const { result } = renderHook(() => useScraperFetch())
// First session starts and remains in a non-terminal state.
await act(async () => {
await result.current.start()
})
expect(result.current.sessionId).toBe('sess-old')
// Starting again while the old session is still running (not done/error/
// cancelled) must cancel it first before launching the new one.
await act(async () => {
await result.current.start()
})
expect(api.cancelScraperSession).toHaveBeenCalledWith('sess-old')
expect(result.current.sessionId).toBe('sess-new')
})
it('ignores a failure to cancel the previous session before starting a new one', async () => {
vi.mocked(api.startScraperSession)
.mockResolvedValueOnce({
session_id: 'sess-old',
workspace_id: 'ws-1',
personal_data_uuid: 'pd-1',
child_id: 'c-1',
} as any)
.mockResolvedValueOnce({
session_id: 'sess-new',
workspace_id: 'ws-1',
personal_data_uuid: 'pd-1',
child_id: 'c-1',
} as any)
vi.mocked(api.cancelScraperSession).mockRejectedValue(new Error('cancel failed'))
const { result } = renderHook(() => useScraperFetch())
await act(async () => {
await result.current.start()
})
expect(result.current.sessionId).toBe('sess-old')
// The old session is still non-terminal; cancelling it fails, but the
// new session must still be started rather than the failure propagating.
await act(async () => {
await result.current.start()
})
expect(api.cancelScraperSession).toHaveBeenCalledWith('sess-old')
expect(result.current.sessionId).toBe('sess-new')
})
it('does not try to cancel a previous session that already finished', async () => {
vi.mocked(api.startScraperSession)
.mockResolvedValueOnce({
session_id: 'sess-old',
workspace_id: 'ws-1',
personal_data_uuid: 'pd-1',
child_id: 'c-1',
} as any)
.mockResolvedValueOnce({
session_id: 'sess-new',
workspace_id: 'ws-1',
personal_data_uuid: 'pd-1',
child_id: 'c-1',
} as any)
vi.mocked(api.pollScraperSession).mockResolvedValue({ status: 'done', result: {} } as any)
vi.mocked(api.cancelScraperSession).mockResolvedValue(undefined)
const { result } = renderHook(() => useScraperFetch())
await act(async () => {
await result.current.start()
})
// Let it reach the terminal 'done' status.
await act(async () => {
vi.advanceTimersByTime(1000)
})
expect(result.current.isTerminal).toBe(true)
await act(async () => {
await result.current.start()
})
expect(api.cancelScraperSession).not.toHaveBeenCalled()
expect(result.current.sessionId).toBe('sess-new')
})
it('ignores a failure to cancel the running session', async () => {
vi.mocked(api.startScraperSession).mockResolvedValue({
session_id: 'sess-123',
workspace_id: 'ws-1',
personal_data_uuid: 'pd-1',
child_id: 'c-1',
} as any)
vi.mocked(api.pollScraperSession).mockResolvedValue({ status: 'awaiting_scan' } as any)
vi.mocked(api.cancelScraperSession).mockRejectedValue(new Error('cancel failed'))
const { result } = renderHook(() => useScraperFetch())
await act(async () => {
await result.current.start()
})
await act(async () => {
await result.current.cancel()
})
expect(result.current.pollData?.status).toBe('cancelled')
expect(result.current.sessionId).toBeNull()
})
it('handles poll error', async () => {
vi.mocked(api.startScraperSession).mockResolvedValue({
session_id: 'sess-123',
@@ -0,0 +1,84 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, waitFor, act } from '@testing-library/react'
import { useVacationDays } from './useVacationDays'
import * as api from '@/services/api'
vi.mock('@/services/api')
const mockRow = {
id: 'vd-1',
personalDataUuid: 'pd-1',
workspaceId: 'ws-1',
year: 2026,
totalDays: 25,
savedDaysIn: 0,
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
}
describe('useVacationDays', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(api.getVacationDaysByWorkspace).mockResolvedValue([mockRow])
vi.mocked(api.upsertVacationDays).mockResolvedValue(mockRow)
})
it('fetches vacation days on mount', async () => {
const { result } = renderHook(() => useVacationDays('ws-1'))
expect(result.current.isLoading).toBe(true)
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.data).toEqual([mockRow])
expect(api.getVacationDaysByWorkspace).toHaveBeenCalledWith('ws-1')
})
it('does not fetch when workspaceId is empty', async () => {
renderHook(() => useVacationDays(''))
await waitFor(() => {
expect(api.getVacationDaysByWorkspace).not.toHaveBeenCalled()
})
})
it('sets error when fetch fails with an Error', async () => {
vi.mocked(api.getVacationDaysByWorkspace).mockRejectedValueOnce(new Error('Network down'))
const { result } = renderHook(() => useVacationDays('ws-1'))
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.error).toBe('Network down')
expect(result.current.data).toEqual([])
})
it('falls back to a generic message when a non-Error value is thrown', async () => {
vi.mocked(api.getVacationDaysByWorkspace).mockRejectedValueOnce('boom')
const { result } = renderHook(() => useVacationDays('ws-1'))
await waitFor(() => expect(result.current.isLoading).toBe(false))
expect(result.current.error).toBe('Failed to load vacation days')
})
it('upserts and refetches', async () => {
const { result } = renderHook(() => useVacationDays('ws-1'))
await waitFor(() => expect(result.current.isLoading).toBe(false))
await act(async () => {
await result.current.upsert({
personalDataUuid: 'pd-1',
workspaceId: 'ws-1',
year: 2027,
totalDays: 25,
savedDaysIn: 0,
})
})
expect(api.upsertVacationDays).toHaveBeenCalledWith({
personalDataUuid: 'pd-1',
workspaceId: 'ws-1',
year: 2027,
totalDays: 25,
savedDaysIn: 0,
})
expect(api.getVacationDaysByWorkspace).toHaveBeenCalledTimes(2)
})
})
@@ -48,4 +48,16 @@ describe('useWorkspaceMembers', () => {
expect(result.current.error).toContain('boom')
})
it('falls back to a generic message when a non-Error value is thrown', async () => {
vi.mocked(api.listWorkspaceMembers).mockRejectedValue('not an error object')
const { result } = renderHook(() => useWorkspaceMembers('ws1'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(result.current.error).toBe('Failed to load workspace members')
})
})