diff --git a/CLAUDE.md b/CLAUDE.md index 31c4382..52ed77e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -173,7 +173,7 @@ that were in fact failing. ## ANTI-PATTERNS (CRITICAL GUARDRAILS) -* **Type Safety (TS):** No type suppression — no `as any`, `@ts-ignore`, or `@ts-expect-error`. `no-explicit-any` is an ESLint error and CI fails on warnings. +* **Type Safety (TS):** No type suppression — no `as any`, `@ts-ignore`, or `@ts-expect-error`. `no-explicit-any` is an ESLint error and CI fails on warnings. **This applies to test files too** — there is no exemption. Build mock data with the typed factories in `frontend/tests/fixtures/` (`makeWorkspaceOverview`, `makePlanProposal`, `make*Result`, …), which return complete, correctly-typed objects you override with a `Partial`, and mock functions with `vi.mocked(fn)` rather than casting. A cast in a test defeats the type check exactly where it guards the frontend/backend contract — which is this project's most common bug. The one accepted escape is `as unknown as T` in a test that *deliberately* exercises invalid input (a missing field, a bogus enum value); keep it to that expression and comment why. * **Type Safety (Python):** No bare `# type: ignore` to silence the type checker; fix the underlying type or model instead. Prefer precise Pydantic models over `dict[str, Any]` at API boundaries. * **Error Handling:** No empty `catch`/`except` blocks. Explicit handling/logging is required. Backend errors should flow through `app/utils/errors.py`; frontend errors through `src/services/errors.ts`. * **Testing Integrity:** NEVER delete or comment out failing tests to make a suite "pass". Fix the code, or update the test properly with a clear reason. diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js index c00648a..d1dfbe1 100644 --- a/frontend/eslint.config.js +++ b/frontend/eslint.config.js @@ -35,10 +35,4 @@ export default [ react: { version: 'detect' }, }, }, - { - files: ['src/**/*.test.ts', 'src/**/*.test.tsx'], - rules: { - '@typescript-eslint/no-explicit-any': 'off', - }, - }, ] diff --git a/frontend/src/components/common/CalendarWeek.test.tsx b/frontend/src/components/common/CalendarWeek.test.tsx index c870f5a..b2e3b9d 100644 --- a/frontend/src/components/common/CalendarWeek.test.tsx +++ b/frontend/src/components/common/CalendarWeek.test.tsx @@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { render, screen, fireEvent } from '@testing-library/react' import { CalendarWeek } from './CalendarWeek' import type { PlannedLeaveDay } from '@/types' +import type { CalendarDayProps } from './CalendarDay' +import type { CalendarEventProps } from './CalendarEvent' vi.mock('@/i18n', () => ({ useTranslation: () => ({ @@ -30,7 +32,7 @@ vi.mock('./CalendarDay', () => ({ holidayName, isOverride, overrideIsWorkingDay, - }: any) => ( + }: CalendarDayProps) => (
({ })) vi.mock('./CalendarEvent', () => ({ - CalendarEvent: ({ leaveType, isDubbeldag, parentLabel }: any) => ( + CalendarEvent: ({ leaveType, isDubbeldag, parentLabel }: CalendarEventProps) => (
{parentLabel} · {leaveType}
diff --git a/frontend/src/hooks/useAnalytics.test.ts b/frontend/src/hooks/useAnalytics.test.ts index 038e013..645c8cf 100644 --- a/frontend/src/hooks/useAnalytics.test.ts +++ b/frontend/src/hooks/useAnalytics.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { renderHook, act } from '@testing-library/react' import { useAnalytics } from './useAnalytics' import * as api from '@/services/api' +import { makeWorkspaceOverview } from '../../tests/fixtures' vi.mock('@/services/api') @@ -17,16 +18,16 @@ describe('useAnalytics', () => { }) it('fetches analytics data on mount', async () => { - const mockData = { + const mockData = makeWorkspaceOverview({ workspaceId: 'ws1', childId: 'child1', parentProjections: [], combinedDaysUsed: 10, combinedDaysLeft: 15, combinedExpiringAtB4: 50, - } + }) - vi.mocked(api.getWorkspaceOverview).mockResolvedValue(mockData as any) + vi.mocked(api.getWorkspaceOverview).mockResolvedValue(mockData) renderHook(() => useAnalytics('ws1', 'child1')) @@ -63,7 +64,7 @@ describe('useAnalytics', () => { }) it('does not fetch when workspaceId is null', () => { - vi.mocked(api.getWorkspaceOverview).mockResolvedValue({} as any) + vi.mocked(api.getWorkspaceOverview).mockResolvedValue(makeWorkspaceOverview()) renderHook(() => useAnalytics(null)) @@ -71,7 +72,7 @@ describe('useAnalytics', () => { }) it('does not fetch when childId is missing', () => { - vi.mocked(api.getWorkspaceOverview).mockResolvedValue({} as any) + vi.mocked(api.getWorkspaceOverview).mockResolvedValue(makeWorkspaceOverview()) renderHook(() => useAnalytics('ws1')) @@ -79,7 +80,7 @@ describe('useAnalytics', () => { }) it('provides refresh function', async () => { - vi.mocked(api.getWorkspaceOverview).mockResolvedValue({} as any) + vi.mocked(api.getWorkspaceOverview).mockResolvedValue(makeWorkspaceOverview()) const { result } = renderHook(() => useAnalytics('ws1', 'child1')) @@ -91,15 +92,15 @@ describe('useAnalytics', () => { }) it('clears stale data instead of leaving it rendered when the workspace/child identity is lost', async () => { - const mockData = { + const mockData = makeWorkspaceOverview({ workspaceId: 'ws1', childId: 'child1', parentProjections: [], combinedDaysUsed: 76, combinedDaysLeft: 164, combinedExpiringAtB4: 50, - } - vi.mocked(api.getWorkspaceOverview).mockResolvedValue(mockData as any) + }) + vi.mocked(api.getWorkspaceOverview).mockResolvedValue(mockData) const { result, rerender } = renderHook( ({ workspaceId, childId }: { workspaceId: string | null; childId: string | undefined }) => diff --git a/frontend/src/hooks/useDayOverrides.test.ts b/frontend/src/hooks/useDayOverrides.test.ts index 8c1ede1..62f211a 100644 --- a/frontend/src/hooks/useDayOverrides.test.ts +++ b/frontend/src/hooks/useDayOverrides.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { renderHook, waitFor } from '@testing-library/react' import { useDayOverrides } from './useDayOverrides' import * as api from '@/services/api' +import { makeDayOverride } from '../../tests/fixtures' vi.mock('@/services/api') @@ -30,7 +31,7 @@ describe('useDayOverrides', () => { beforeEach(() => { vi.clearAllMocks() vi.mocked(api.listDayOverridesByWorkspace).mockResolvedValue(mockOverrides) - vi.mocked(api.upsertDayOverride).mockResolvedValue(mockOverrides[0] as any) + vi.mocked(api.upsertDayOverride).mockResolvedValue(makeDayOverride(mockOverrides[0])) vi.mocked(api.deleteDayOverride).mockResolvedValue(undefined) }) diff --git a/frontend/src/hooks/useOptimizationTips.test.ts b/frontend/src/hooks/useOptimizationTips.test.ts index 00d9f0b..a9f3f83 100644 --- a/frontend/src/hooks/useOptimizationTips.test.ts +++ b/frontend/src/hooks/useOptimizationTips.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { renderHook, act } from '@testing-library/react' import { useOptimizationTips } from './useOptimizationTips' import * as api from '@/services/api' +import { makeTip } from '../../tests/fixtures' vi.mock('@/services/api') @@ -18,15 +19,14 @@ describe('useOptimizationTips', () => { it('fetches tips on mount', async () => { const mockTips = [ - { + makeTip({ id: 'tip1', severity: 'info', messageKey: 'wrap_red_days', - data: {}, - }, + }), ] - vi.mocked(api.getTips).mockResolvedValue(mockTips as any) + vi.mocked(api.getTips).mockResolvedValue(mockTips) renderHook(() => useOptimizationTips('ws1')) @@ -63,11 +63,11 @@ describe('useOptimizationTips', () => { it('dismisses tips locally', async () => { const mockTips = [ - { id: 'tip1', severity: 'info', messageKey: 'msg1', data: {} }, - { id: 'tip2', severity: 'warning', messageKey: 'msg2', data: {} }, + makeTip({ id: 'tip1', severity: 'info', messageKey: 'msg1' }), + makeTip({ id: 'tip2', severity: 'warning', messageKey: 'msg2' }), ] - vi.mocked(api.getTips).mockResolvedValue(mockTips as any) + vi.mocked(api.getTips).mockResolvedValue(mockTips) const { result } = renderHook(() => useOptimizationTips('ws1')) diff --git a/frontend/src/hooks/useParentalLeave.test.ts b/frontend/src/hooks/useParentalLeave.test.ts index 9fbabc0..871198a 100644 --- a/frontend/src/hooks/useParentalLeave.test.ts +++ b/frontend/src/hooks/useParentalLeave.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { renderHook, act } from '@testing-library/react' import { useParentalLeave } from './useParentalLeave' import * as api from '@/services/api' +import { makeParentalLeaveFetch } from '../../tests/fixtures' vi.mock('@/services/api') @@ -17,13 +18,12 @@ describe('useParentalLeave', () => { }) it('fetches parental leave data on mount', async () => { - const mockData = { + const mockData = makeParentalLeaveFetch({ id: 'pf1', personalDataUuid: 'pd1', workspaceId: 'ws1', childId: 'child1', - source: 'manual' as const, - fetchedAt: new Date().toISOString(), + source: 'manual', totalDays: 300, daysUsed: 20, daysLeft: 280, @@ -31,12 +31,9 @@ describe('useParentalLeave', () => { reservedDaysLeft: 80, transferableDaysLeft: 50, dubbeldagarLeft: 60, - tierData: { sjukpenningTotal: 390, sjukpenningUsed: 10, sjukpenningLeft: 380, lagstaNivaTotal: 90, lagstaNivaUsed: 0, lagstaNivaLeft: 90 }, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } + }) - vi.mocked(api.getParentalLeave).mockResolvedValue(mockData as any) + vi.mocked(api.getParentalLeave).mockResolvedValue(mockData) renderHook(() => useParentalLeave('pd1', 'child1')) @@ -72,7 +69,7 @@ describe('useParentalLeave', () => { }) it('does not fetch when personalDataUuid or childId is null', () => { - vi.mocked(api.getParentalLeave).mockResolvedValue({} as any) + vi.mocked(api.getParentalLeave).mockResolvedValue(makeParentalLeaveFetch()) renderHook(() => useParentalLeave(null, null)) @@ -80,7 +77,7 @@ describe('useParentalLeave', () => { }) it('provides refresh function', async () => { - vi.mocked(api.getParentalLeave).mockResolvedValue({} as any) + vi.mocked(api.getParentalLeave).mockResolvedValue(makeParentalLeaveFetch()) const { result } = renderHook(() => useParentalLeave('pd1', 'child1')) @@ -92,13 +89,12 @@ describe('useParentalLeave', () => { }) it('clears stale data instead of leaving it rendered when the parent/child identity is lost', async () => { - const mockData = { + const mockData = makeParentalLeaveFetch({ id: 'pf1', personalDataUuid: 'pd1', workspaceId: 'ws1', childId: 'child1', - source: 'manual' as const, - fetchedAt: new Date().toISOString(), + source: 'manual', totalDays: 300, daysUsed: 20, daysLeft: 280, @@ -106,11 +102,8 @@ describe('useParentalLeave', () => { reservedDaysLeft: 80, transferableDaysLeft: 50, dubbeldagarLeft: 60, - tierData: { sjukpenningTotal: 390, sjukpenningUsed: 10, sjukpenningLeft: 380, lagstaNivaTotal: 90, lagstaNivaUsed: 0, lagstaNivaLeft: 90 }, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } - vi.mocked(api.getParentalLeave).mockResolvedValue(mockData as any) + }) + vi.mocked(api.getParentalLeave).mockResolvedValue(mockData) const { result, rerender } = renderHook( ({ personalDataUuid, childId }: { personalDataUuid: string | null; childId: string | null }) => diff --git a/frontend/src/hooks/usePlannedDays.test.ts b/frontend/src/hooks/usePlannedDays.test.ts index f675d9b..c3902ec 100644 --- a/frontend/src/hooks/usePlannedDays.test.ts +++ b/frontend/src/hooks/usePlannedDays.test.ts @@ -2,6 +2,7 @@ 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') @@ -18,7 +19,7 @@ describe('usePlannedDays', () => { it('fetches planned days when childId provided', async () => { const mockData = [ - { + makePlannedLeaveDay({ id: '1', workspaceId: 'ws1', childId: 'child1', @@ -26,12 +27,10 @@ describe('usePlannedDays', () => { date: '2026-06-15', dayType: 'vacation', isDubbeldag: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, + }), ] - vi.mocked(api.getPlannedDays).mockResolvedValue(mockData as any) + vi.mocked(api.getPlannedDays).mockResolvedValue(mockData) renderHook(() => usePlannedDays('child1')) @@ -51,19 +50,17 @@ describe('usePlannedDays', () => { }) it('adds planned day and refreshes', async () => { - const mockAdded = { + const mockAdded = makePlannedLeaveDay({ id: '2', workspaceId: 'ws1', childId: 'child1', parentPersonalDataUuid: 'parent1', date: '2026-06-16', - dayType: 'vacation' as const, + dayType: 'vacation', isDubbeldag: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } + }) - vi.mocked(api.createPlannedDay).mockResolvedValue(mockAdded as any) + vi.mocked(api.createPlannedDay).mockResolvedValue(mockAdded) vi.mocked(api.getPlannedDays).mockResolvedValue([mockAdded]) renderHook(() => usePlannedDays('child1')) @@ -118,19 +115,17 @@ describe('usePlannedDays', () => { }) it('updates planned day and refreshes', async () => { - const mockUpdated = { + const mockUpdated = makePlannedLeaveDay({ id: '1', workspaceId: 'ws1', childId: 'child1', parentPersonalDataUuid: 'parent1', date: '2026-06-16', - dayType: 'lagsta' as const, + dayType: 'lagsta', isDubbeldag: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } + }) - vi.mocked(api.updatePlannedDay).mockResolvedValue(mockUpdated as any) + vi.mocked(api.updatePlannedDay).mockResolvedValue(mockUpdated) vi.mocked(api.getPlannedDays).mockResolvedValue([mockUpdated]) const { result } = renderHook(() => usePlannedDays('child1')) @@ -145,34 +140,30 @@ describe('usePlannedDays', () => { it('updates data after successful add', async () => { const initialData = [ - { + makePlannedLeaveDay({ id: '1', workspaceId: 'ws1', childId: 'child1', parentPersonalDataUuid: 'parent1', date: '2026-06-15', - dayType: 'vacation' as const, + dayType: 'vacation', isDubbeldag: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, + }), ] - const newData = { + const newData = makePlannedLeaveDay({ id: '2', workspaceId: 'ws1', childId: 'child1', parentPersonalDataUuid: 'parent1', date: '2026-06-16', - dayType: 'vacation' as const, + dayType: 'vacation', isDubbeldag: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - } + }) - vi.mocked(api.getPlannedDays).mockResolvedValueOnce(initialData as any) - vi.mocked(api.createPlannedDay).mockResolvedValue(newData as any) - vi.mocked(api.getPlannedDays).mockResolvedValueOnce([...initialData, newData] as any) + vi.mocked(api.getPlannedDays).mockResolvedValueOnce(initialData) + vi.mocked(api.createPlannedDay).mockResolvedValue(newData) + vi.mocked(api.getPlannedDays).mockResolvedValueOnce([...initialData, newData]) const { result } = renderHook(() => usePlannedDays('child1')) @@ -191,33 +182,29 @@ describe('usePlannedDays', () => { it('updates data after successful remove', async () => { const initialData = [ - { + makePlannedLeaveDay({ id: '1', workspaceId: 'ws1', childId: 'child1', parentPersonalDataUuid: 'parent1', date: '2026-06-15', - dayType: 'vacation' as const, + dayType: 'vacation', isDubbeldag: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - { + }), + makePlannedLeaveDay({ id: '2', workspaceId: 'ws1', childId: 'child1', parentPersonalDataUuid: 'parent1', date: '2026-06-16', - dayType: 'vacation' as const, + dayType: 'vacation', isDubbeldag: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, + }), ] - vi.mocked(api.getPlannedDays).mockResolvedValueOnce(initialData as any) + vi.mocked(api.getPlannedDays).mockResolvedValueOnce(initialData) vi.mocked(api.deletePlannedDay).mockResolvedValue(undefined) - vi.mocked(api.getPlannedDays).mockResolvedValueOnce([initialData[0]] as any) + vi.mocked(api.getPlannedDays).mockResolvedValueOnce([initialData[0]!]) const { result } = renderHook(() => usePlannedDays('child1')) @@ -236,20 +223,18 @@ describe('usePlannedDays', () => { it('refreshes data explicitly', async () => { const mockData = [ - { + makePlannedLeaveDay({ id: '1', workspaceId: 'ws1', childId: 'child1', parentPersonalDataUuid: 'parent1', date: '2026-06-15', - dayType: 'vacation' as const, + dayType: 'vacation', isDubbeldag: false, - createdAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, + }), ] - vi.mocked(api.getPlannedDays).mockResolvedValue(mockData as any) + vi.mocked(api.getPlannedDays).mockResolvedValue(mockData) const { result } = renderHook(() => usePlannedDays('child1')) @@ -268,12 +253,12 @@ describe('usePlannedDays', () => { }) it('sets loading state during fetch', async () => { - let resolveGetPlannedDays: (value: any) => void - const getPlannedDaysPromise = new Promise(resolve => { + let resolveGetPlannedDays: (value: Awaited>) => void + const getPlannedDaysPromise: ReturnType = new Promise(resolve => { resolveGetPlannedDays = resolve }) - vi.mocked(api.getPlannedDays).mockReturnValue(getPlannedDaysPromise as any) + vi.mocked(api.getPlannedDays).mockReturnValue(getPlannedDaysPromise) const { result } = renderHook(() => usePlannedDays('child1')) diff --git a/frontend/src/hooks/useScraperFetch.test.ts b/frontend/src/hooks/useScraperFetch.test.ts index 9aa95bc..9170869 100644 --- a/frontend/src/hooks/useScraperFetch.test.ts +++ b/frontend/src/hooks/useScraperFetch.test.ts @@ -3,6 +3,7 @@ import { renderHook, act } from '@testing-library/react' import { useScraperFetch } from './useScraperFetch' import * as api from '@/services/api' import * as errors from '@/services/errors' +import { makeScraperSessionStartResponse, makePollResponse } from '../../tests/fixtures' vi.mock('@/services/api') vi.mock('@/services/errors') @@ -27,12 +28,14 @@ describe('useScraperFetch', () => { }) it('starts scraping 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.startScraperSession).mockResolvedValue( + makeScraperSessionStartResponse({ + session_id: 'sess-123', + workspace_id: 'ws-1', + personal_data_uuid: 'pd-1', + child_id: 'c-1', + }) + ) const { result } = renderHook(() => useScraperFetch()) @@ -46,18 +49,20 @@ describe('useScraperFetch', () => { }) it('transitions through polling states', 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.startScraperSession).mockResolvedValue( + makeScraperSessionStartResponse({ + session_id: 'sess-123', + workspace_id: 'ws-1', + personal_data_uuid: 'pd-1', + child_id: 'c-1', + }) + ) vi.mocked(api.pollScraperSession) - .mockResolvedValueOnce({ status: 'awaiting_scan', qr_data: 'qr-123' } as any) - .mockResolvedValueOnce({ status: 'authenticating' } as any) - .mockResolvedValueOnce({ status: 'fetching' } as any) - .mockResolvedValueOnce({ status: 'done', result: { data: 'fetched' } } as any) + .mockResolvedValueOnce(makePollResponse({ status: 'awaiting_scan', qr_data: 'qr-123' })) + .mockResolvedValueOnce(makePollResponse({ status: 'authenticating' })) + .mockResolvedValueOnce(makePollResponse({ status: 'fetching' })) + .mockResolvedValueOnce(makePollResponse({ status: 'done', result: { data: 'fetched' } })) const { result } = renderHook(() => useScraperFetch()) @@ -94,14 +99,16 @@ describe('useScraperFetch', () => { }) it('cancels scraping 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.startScraperSession).mockResolvedValue( + makeScraperSessionStartResponse({ + session_id: 'sess-123', + workspace_id: 'ws-1', + personal_data_uuid: 'pd-1', + child_id: 'c-1', + }) + ) - vi.mocked(api.pollScraperSession).mockResolvedValue({ status: 'awaiting_scan' } as any) + vi.mocked(api.pollScraperSession).mockResolvedValue(makePollResponse({ status: 'awaiting_scan' })) vi.mocked(api.cancelScraperSession).mockResolvedValue(undefined) const { result } = renderHook(() => useScraperFetch()) @@ -148,12 +155,14 @@ describe('useScraperFetch', () => { }) 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.startScraperSession).mockResolvedValue( + makeScraperSessionStartResponse({ + session_id: 'sess-123', + workspace_id: 'ws-1', + personal_data_uuid: 'pd-1', + child_id: 'c-1', + }) + ) vi.mocked(api.pollScraperSession).mockRejectedValue('not an Error object') @@ -186,18 +195,22 @@ describe('useScraperFetch', () => { 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) + .mockResolvedValueOnce( + makeScraperSessionStartResponse({ + session_id: 'sess-old', + workspace_id: 'ws-1', + personal_data_uuid: 'pd-1', + child_id: 'c-1', + }) + ) + .mockResolvedValueOnce( + makeScraperSessionStartResponse({ + session_id: 'sess-new', + workspace_id: 'ws-1', + personal_data_uuid: 'pd-1', + child_id: 'c-1', + }) + ) vi.mocked(api.cancelScraperSession).mockResolvedValue(undefined) const { result } = renderHook(() => useScraperFetch()) @@ -220,18 +233,22 @@ describe('useScraperFetch', () => { 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) + .mockResolvedValueOnce( + makeScraperSessionStartResponse({ + session_id: 'sess-old', + workspace_id: 'ws-1', + personal_data_uuid: 'pd-1', + child_id: 'c-1', + }) + ) + .mockResolvedValueOnce( + makeScraperSessionStartResponse({ + session_id: 'sess-new', + workspace_id: 'ws-1', + personal_data_uuid: 'pd-1', + child_id: 'c-1', + }) + ) vi.mocked(api.cancelScraperSession).mockRejectedValue(new Error('cancel failed')) const { result } = renderHook(() => useScraperFetch()) @@ -253,19 +270,23 @@ describe('useScraperFetch', () => { 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) + .mockResolvedValueOnce( + makeScraperSessionStartResponse({ + session_id: 'sess-old', + workspace_id: 'ws-1', + personal_data_uuid: 'pd-1', + child_id: 'c-1', + }) + ) + .mockResolvedValueOnce( + makeScraperSessionStartResponse({ + session_id: 'sess-new', + workspace_id: 'ws-1', + personal_data_uuid: 'pd-1', + child_id: 'c-1', + }) + ) + vi.mocked(api.pollScraperSession).mockResolvedValue(makePollResponse({ status: 'done', result: {} })) vi.mocked(api.cancelScraperSession).mockResolvedValue(undefined) const { result } = renderHook(() => useScraperFetch()) @@ -289,13 +310,15 @@ describe('useScraperFetch', () => { }) 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.startScraperSession).mockResolvedValue( + makeScraperSessionStartResponse({ + session_id: 'sess-123', + workspace_id: 'ws-1', + personal_data_uuid: 'pd-1', + child_id: 'c-1', + }) + ) + vi.mocked(api.pollScraperSession).mockResolvedValue(makePollResponse({ status: 'awaiting_scan' })) vi.mocked(api.cancelScraperSession).mockRejectedValue(new Error('cancel failed')) const { result } = renderHook(() => useScraperFetch()) @@ -313,12 +336,14 @@ describe('useScraperFetch', () => { }) it('handles poll error', 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.startScraperSession).mockResolvedValue( + makeScraperSessionStartResponse({ + session_id: 'sess-123', + workspace_id: 'ws-1', + personal_data_uuid: 'pd-1', + child_id: 'c-1', + }) + ) vi.mocked(api.pollScraperSession).mockRejectedValue(new Error('Poll failed')) diff --git a/frontend/src/hooks/useScraperFetch.ts b/frontend/src/hooks/useScraperFetch.ts index b5e4e81..f2f9723 100644 --- a/frontend/src/hooks/useScraperFetch.ts +++ b/frontend/src/hooks/useScraperFetch.ts @@ -4,7 +4,9 @@ import { isUnauthorized } from '@/services/errors' export type ScraperStatus = 'idle' | 'launching' | 'awaiting_scan' | 'authenticating' | 'fetching' | 'done' | 'error' | 'cancelled' -interface PollResponse { +// Exported so test fixtures (frontend/tests/fixtures/scraper.ts) can build a +// fully-typed poll response instead of casting a partial object to `any`. +export interface PollResponse { status: ScraperStatus qr_data?: string result?: Record diff --git a/frontend/src/pages/Calendar.test.tsx b/frontend/src/pages/Calendar.test.tsx index 7dcfe38..e4de4d4 100644 --- a/frontend/src/pages/Calendar.test.tsx +++ b/frontend/src/pages/Calendar.test.tsx @@ -4,6 +4,14 @@ import { Calendar } from './Calendar' import { useWorkspace } from '@/context' import { usePlannedDays, useCalendarDays, useDayOverrides, useShowWeekNumbers, getLastLeaveType } from '@/hooks' import { LanguageProvider } from '@/i18n' +import { + makeWorkspaceContextValue, + makePlannedDaysResult, + makeCalendarDaysResult, + makeDayOverridesResult, + makeShowWeekNumbersTuple, +} from '../../tests/fixtures' +import { makeCalendarDayEntry, makeCalendarWindow } from '../../tests/fixtures/calendar' vi.mock('@/context', () => ({ useWorkspace: vi.fn(), @@ -43,52 +51,33 @@ describe('Calendar', () => { vi.clearAllMocks() }) - const mockWorkspace = { - isLoading: false, + const mockWorkspace = makeWorkspaceContextValue({ selectedChildId: 'child-1', - workspace: { id: 'ws-1' }, personalDataUuid: 'person-1', - } + }) - const mockPlannedDays = { - data: [], - isLoading: false, - add: vi.fn(), - update: vi.fn(), - remove: vi.fn(), - } + const mockPlannedDays = makePlannedDaysResult() - const mockCalendarWindow = { - data: { startYear: 2026, endYear: 2026, minYear: 2026, maxYear: 2035, days: [] }, - isLoading: false, - error: null, - refetch: vi.fn(), - } + const mockCalendarWindow = makeCalendarDaysResult() - const mockDayOverrides = { - data: [], - isLoading: false, - error: null, - upsert: vi.fn(), - remove: vi.fn(), - } + const mockDayOverrides = makeDayOverridesResult() beforeEach(() => { - ;(useCalendarDays as any).mockReturnValue(mockCalendarWindow) - ;(useDayOverrides as any).mockReturnValue(mockDayOverrides) + vi.mocked(useCalendarDays).mockReturnValue(mockCalendarWindow) + vi.mocked(useDayOverrides).mockReturnValue(mockDayOverrides) }) it('renders loading state while data is loading', () => { - ;(useWorkspace as any).mockReturnValue({ ...mockWorkspace, isLoading: true }) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) + vi.mocked(useWorkspace).mockReturnValue({ ...mockWorkspace, isLoading: true }) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) render() expect(screen.getByText('Loading...')).toBeInTheDocument() }) it('renders 7 weekday headers in Monday-first Swedish order', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) render() const weekdays = screen.getAllByText(/^(Mån|Tis|Ons|Tor|Fre|Lör|Sön)$/) @@ -101,8 +90,8 @@ describe('Calendar', () => { }) it('renders day cells for all weeks in a month', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) render() @@ -112,8 +101,8 @@ describe('Calendar', () => { }) it('marks Sunday-starting month (March 2026) correctly with 7-column grid', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) const { rerender } = render() expect(screen.getByTestId('calendar')).toBeInTheDocument() @@ -124,8 +113,8 @@ describe('Calendar', () => { }) it('opens modal when clicking a current-month day', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) render() @@ -139,8 +128,8 @@ describe('Calendar', () => { }) it('does not open modal when clicking disabled (non-current-month) day', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) render() @@ -154,8 +143,8 @@ describe('Calendar', () => { }) it('displays current month title', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) render() @@ -164,8 +153,8 @@ describe('Calendar', () => { }) it('handles next/prev month navigation', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) render() @@ -179,8 +168,8 @@ describe('Calendar', () => { }) it('highlights date range live while dragging, before pointerup', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) render() @@ -207,8 +196,8 @@ describe('Calendar', () => { }) it('jumps forward to any month via month dropdown', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) render() @@ -225,8 +214,8 @@ describe('Calendar', () => { }) it('jumps backward to any month via month dropdown', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) render() @@ -242,8 +231,8 @@ describe('Calendar', () => { }) it('jumps forward to any year via year dropdown', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) render() @@ -259,8 +248,8 @@ describe('Calendar', () => { }) it('jumps backward to any year via year dropdown', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) render() @@ -276,8 +265,8 @@ describe('Calendar', () => { }) it('year dropdown includes all supported years from 2026 to 2055', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) render() @@ -293,8 +282,8 @@ describe('Calendar', () => { }) it('month and year selects have proper accessibility labels', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) render() @@ -318,29 +307,24 @@ describe('Calendar', () => { describe('§3.4 red day / klämdag shading', () => { it('shades a statutory red day and shows its name', () => { const today = todayDateString() - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) - ;(useCalendarDays as any).mockReturnValue({ - data: { - startYear: 2026, - endYear: 2026, - minYear: 2026, - maxYear: 2035, - days: [ - { - date: today, - name: 'Påskdagen; Söndag', - dayType: 'red_day', - isOverride: false, - overrideIsWorkingDay: null, - isFree: true, - }, - ], - }, - isLoading: false, - error: null, - refetch: vi.fn(), - }) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) + vi.mocked(useCalendarDays).mockReturnValue( + makeCalendarDaysResult({ + data: makeCalendarWindow({ + days: [ + makeCalendarDayEntry({ + date: today, + name: 'Påskdagen; Söndag', + dayType: 'red_day', + isOverride: false, + overrideIsWorkingDay: null, + isFree: true, + }), + ], + }), + }) + ) render() @@ -353,29 +337,24 @@ describe('Calendar', () => { it('marks an overridden day distinctly from a plain statutory day', () => { const today = todayDateString() - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) - ;(useCalendarDays as any).mockReturnValue({ - data: { - startYear: 2026, - endYear: 2026, - minYear: 2026, - maxYear: 2035, - days: [ - { - date: today, - name: null, - dayType: 'working_day', - isOverride: true, - overrideIsWorkingDay: false, - isFree: true, - }, - ], - }, - isLoading: false, - error: null, - refetch: vi.fn(), - }) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) + vi.mocked(useCalendarDays).mockReturnValue( + makeCalendarDaysResult({ + data: makeCalendarWindow({ + days: [ + makeCalendarDayEntry({ + date: today, + name: null, + dayType: 'working_day', + isOverride: true, + overrideIsWorkingDay: false, + isFree: true, + }), + ], + }), + }) + ) render() @@ -387,8 +366,8 @@ describe('Calendar', () => { describe('§3.6 click-to-override', () => { it('shows an override-mode toggle button', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) render() @@ -396,8 +375,8 @@ describe('Calendar', () => { }) it('clicking a day while override mode is active does not open the planning modal', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) render() @@ -412,8 +391,8 @@ describe('Calendar', () => { }) it('toggling override mode off restores normal click-to-plan behaviour', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) render() @@ -432,30 +411,28 @@ describe('Calendar', () => { const today = todayDateString() const upsert = vi.fn().mockResolvedValue(undefined) const refetchCalendarWindow = vi.fn().mockResolvedValue(undefined) - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) - ;(useCalendarDays as any).mockReturnValue({ - data: { - startYear: 2026, - endYear: 2026, - minYear: 2026, - maxYear: 2035, - days: [ - { - date: today, - name: null, - dayType: 'weekend', - isOverride: false, - overrideIsWorkingDay: null, - isFree: true, - }, - ], - }, - isLoading: false, - error: null, - refetch: refetchCalendarWindow, - }) - ;(useDayOverrides as any).mockReturnValue({ data: [], isLoading: false, error: null, upsert, remove: vi.fn() }) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) + vi.mocked(useCalendarDays).mockReturnValue( + makeCalendarDaysResult({ + data: makeCalendarWindow({ + days: [ + makeCalendarDayEntry({ + date: today, + name: null, + dayType: 'weekend', + isOverride: false, + overrideIsWorkingDay: null, + isFree: true, + }), + ], + }), + refetch: refetchCalendarWindow, + }) + ) + vi.mocked(useDayOverrides).mockReturnValue( + makeDayOverridesResult({ data: [], upsert, remove: vi.fn() }) + ) render() fireEvent.click(screen.getByTestId('calendar-override-toggle')) @@ -483,36 +460,32 @@ describe('Calendar', () => { const today = todayDateString() const removeOverride = vi.fn().mockResolvedValue(undefined) const refetchCalendarWindow = vi.fn().mockResolvedValue(undefined) - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) - ;(useCalendarDays as any).mockReturnValue({ - data: { - startYear: 2026, - endYear: 2026, - minYear: 2026, - maxYear: 2035, - days: [ - { - date: today, - name: null, - dayType: 'working_day', - isOverride: true, - overrideIsWorkingDay: false, - isFree: true, - }, - ], - }, - isLoading: false, - error: null, - refetch: refetchCalendarWindow, - }) - ;(useDayOverrides as any).mockReturnValue({ - data: [{ id: 'ov-1', workspaceId: 'ws-1', date: today, isWorkingDay: false, note: null }], - isLoading: false, - error: null, - upsert: vi.fn(), - remove: removeOverride, - }) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) + vi.mocked(useCalendarDays).mockReturnValue( + makeCalendarDaysResult({ + data: makeCalendarWindow({ + days: [ + makeCalendarDayEntry({ + date: today, + name: null, + dayType: 'working_day', + isOverride: true, + overrideIsWorkingDay: false, + isFree: true, + }), + ], + }), + refetch: refetchCalendarWindow, + }) + ) + vi.mocked(useDayOverrides).mockReturnValue( + makeDayOverridesResult({ + data: [{ id: 'ov-1', workspaceId: 'ws-1', date: today, isWorkingDay: false, note: null, createdAt: '2026-01-01', updatedAt: '2026-01-01' }], + upsert: vi.fn(), + remove: removeOverride, + }) + ) render() fireEvent.click(screen.getByTestId('calendar-override-toggle')) @@ -530,12 +503,18 @@ describe('Calendar', () => { describe('defensive fallbacks and additional branches', () => { it('falls back to safe defaults when workspace/child/personalData are unavailable', () => { - const noWorkspace = { isLoading: false, selectedChildId: null, workspace: null, personalDataUuid: null } - ;(useWorkspace as any).mockReturnValue(noWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) + const noWorkspace = makeWorkspaceContextValue({ + selectedChildId: null, + workspace: null, + personalDataUuid: null, + }) + vi.mocked(useWorkspace).mockReturnValue(noWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) const upsert = vi.fn() const removeOverride = vi.fn() - ;(useDayOverrides as any).mockReturnValue({ data: [], isLoading: false, error: null, upsert, remove: removeOverride }) + vi.mocked(useDayOverrides).mockReturnValue( + makeDayOverridesResult({ data: [], upsert: vi.fn(), remove: removeOverride }) + ) render() @@ -557,14 +536,14 @@ describe('Calendar', () => { }) it('treats a missing calendar window as having no days yet', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) - ;(useCalendarDays as any).mockReturnValue({ - data: undefined, - isLoading: true, - error: null, - refetch: vi.fn(), - }) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) + vi.mocked(useCalendarDays).mockReturnValue( + makeCalendarDaysResult({ + data: null, + isLoading: true, + }) + ) render() @@ -574,8 +553,8 @@ describe('Calendar', () => { }) it('treats missing planned days as an empty list for lookups and bar rendering', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue({ ...mockPlannedDays, data: undefined }) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue({ ...mockPlannedDays, data: null }) render() @@ -589,8 +568,8 @@ describe('Calendar', () => { }) it('renders the month/year header in English when the locale is English', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true }) vi.stubGlobal('localStorage', { @@ -616,9 +595,9 @@ describe('Calendar', () => { }) it('shows the week-number column when the preference is enabled', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) - ;(useShowWeekNumbers as any).mockReturnValueOnce([true]) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) + vi.mocked(useShowWeekNumbers).mockReturnValueOnce(makeShowWeekNumbersTuple(true)) render() @@ -627,9 +606,9 @@ describe('Calendar', () => { }) it('defaults the new-day modal to "vacation" when there is no last-used leave type', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) - ;(getLastLeaveType as any).mockReturnValueOnce(null) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) + vi.mocked(getLastLeaveType).mockReturnValueOnce(null) render() @@ -641,8 +620,8 @@ describe('Calendar', () => { }) it('ignores pointer-drag gestures entirely while override mode is active', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) render() @@ -666,39 +645,38 @@ describe('Calendar', () => { const today = todayDateString() const removeOverride = vi.fn() const refetchCalendarWindow = vi.fn().mockResolvedValue(undefined) - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) - ;(useCalendarDays as any).mockReturnValue({ - data: { - startYear: 2026, - endYear: 2026, - minYear: 2026, - maxYear: 2035, - days: [ - { - date: today, - name: null, - dayType: 'working_day', - isOverride: true, - overrideIsWorkingDay: false, - isFree: true, - }, - ], - }, - isLoading: false, - error: null, - refetch: refetchCalendarWindow, - }) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) + vi.mocked(useCalendarDays).mockReturnValue( + makeCalendarDaysResult({ + data: makeCalendarWindow({ + days: [ + makeCalendarDayEntry({ + date: today, + name: null, + dayType: 'working_day', + isOverride: true, + overrideIsWorkingDay: false, + isFree: true, + }), + ], + }), + refetch: refetchCalendarWindow, + }) + ) // data is undefined here (e.g. before the overrides fetch resolves) — // the (dayOverrides ?? []).find(...) fallback must kick in rather than // throwing, and since nothing is found there is nothing to remove. - ;(useDayOverrides as any).mockReturnValue({ - data: undefined, - isLoading: true, - error: null, - upsert: vi.fn(), - remove: removeOverride, - }) + vi.mocked(useDayOverrides).mockReturnValue( + { + ...makeDayOverridesResult({ + isLoading: true, + upsert: vi.fn(), + remove: removeOverride, + }), + data: undefined, + } as unknown as ReturnType + ) render() fireEvent.click(screen.getByTestId('calendar-override-toggle')) @@ -715,30 +693,27 @@ describe('Calendar', () => { it('shows the Error message directly when the override mutation rejects with an Error', async () => { const today = todayDateString() const upsert = vi.fn().mockRejectedValue(new Error('quota exceeded')) - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) - ;(useCalendarDays as any).mockReturnValue({ - data: { - startYear: 2026, - endYear: 2026, - minYear: 2026, - maxYear: 2035, - days: [ - { - date: today, - name: null, - dayType: 'weekend', - isOverride: false, - overrideIsWorkingDay: null, - isFree: true, - }, - ], - }, - isLoading: false, - error: null, - refetch: vi.fn(), - }) - ;(useDayOverrides as any).mockReturnValue({ data: [], isLoading: false, error: null, upsert, remove: vi.fn() }) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) + vi.mocked(useCalendarDays).mockReturnValue( + makeCalendarDaysResult({ + data: makeCalendarWindow({ + days: [ + makeCalendarDayEntry({ + date: today, + name: null, + dayType: 'weekend', + isOverride: false, + overrideIsWorkingDay: null, + isFree: true, + }), + ], + }), + }) + ) + vi.mocked(useDayOverrides).mockReturnValue( + makeDayOverridesResult({ data: [], upsert, remove: vi.fn() }) + ) render() fireEvent.click(screen.getByTestId('calendar-override-toggle')) @@ -754,8 +729,8 @@ describe('Calendar', () => { it('passes an empty personalDataUuid through to the planning modal when it is unavailable', () => { const noPersonalData = { ...mockWorkspace, personalDataUuid: null } - ;(useWorkspace as any).mockReturnValue(noPersonalData) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) + vi.mocked(useWorkspace).mockReturnValue(noPersonalData) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) render() @@ -769,29 +744,30 @@ describe('Calendar', () => { }) it('lets the modal offer deletion when the clicked date already has a planned day', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) render() const calendarDays = screen.getAllByTestId('calendar-day') const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled')) const clickedDate = currentMonthDay!.getAttribute('aria-label')! - ;(usePlannedDays as any).mockReturnValue({ - ...mockPlannedDays, - data: [ - { - id: 'pd-1', - workspaceId: 'ws-1', - childId: 'child-1', - parentPersonalDataUuid: 'person-1', - date: clickedDate, - dayType: 'vacation', - isDubbeldag: false, - createdAt: '2026-01-01', - updatedAt: '2026-01-01', - }, - ], - }) + vi.mocked(usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [ + { + id: 'pd-1', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'person-1', + date: clickedDate, + dayType: 'vacation', + isDubbeldag: false, + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + ], + }) + ) const { rerender } = render() rerender() @@ -804,8 +780,8 @@ describe('Calendar', () => { }) it('forces existingDay to null while a multi-day range is being planned', () => { - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) render() @@ -826,30 +802,27 @@ describe('Calendar', () => { it('shows a generic override error when the mutation rejects with a non-Error value', async () => { const today = todayDateString() const upsert = vi.fn().mockRejectedValue('network exploded') - ;(useWorkspace as any).mockReturnValue(mockWorkspace) - ;(usePlannedDays as any).mockReturnValue(mockPlannedDays) - ;(useCalendarDays as any).mockReturnValue({ - data: { - startYear: 2026, - endYear: 2026, - minYear: 2026, - maxYear: 2035, - days: [ - { - date: today, - name: null, - dayType: 'weekend', - isOverride: false, - overrideIsWorkingDay: null, - isFree: true, - }, - ], - }, - isLoading: false, - error: null, - refetch: vi.fn(), - }) - ;(useDayOverrides as any).mockReturnValue({ data: [], isLoading: false, error: null, upsert, remove: vi.fn() }) + vi.mocked(useWorkspace).mockReturnValue(mockWorkspace) + vi.mocked(usePlannedDays).mockReturnValue(mockPlannedDays) + vi.mocked(useCalendarDays).mockReturnValue( + makeCalendarDaysResult({ + data: makeCalendarWindow({ + days: [ + makeCalendarDayEntry({ + date: today, + name: null, + dayType: 'weekend', + isOverride: false, + overrideIsWorkingDay: null, + isFree: true, + }), + ], + }), + }) + ) + vi.mocked(useDayOverrides).mockReturnValue( + makeDayOverridesResult({ data: [], upsert, remove: vi.fn() }) + ) render() fireEvent.click(screen.getByTestId('calendar-override-toggle')) diff --git a/frontend/src/pages/Dashboard.test.tsx b/frontend/src/pages/Dashboard.test.tsx index 11819c2..77a0f51 100644 --- a/frontend/src/pages/Dashboard.test.tsx +++ b/frontend/src/pages/Dashboard.test.tsx @@ -5,6 +5,15 @@ import { BrowserRouter } from 'react-router-dom' import { Dashboard } from './Dashboard' import * as hooks from '@/hooks' import { useWorkspace, useAuth } from '@/context' +import { + makeAuthContextValue, + makeWorkspaceContextValue, + makeAnalyticsResult, + makeParentalLeaveResult, + makePlannedDaysResult, + makeScraperFetchResult, + makeWorkspaceMembersResult, +} from '../../tests/fixtures' vi.mock('@/hooks') vi.mock('@/context') @@ -22,126 +31,106 @@ const renderWithRouter = (component: React.ReactElement) => { } describe('Dashboard', () => { - const mockWorkspaceContext = { - workspace: { id: 'ws-1', name: 'Test Workspace', planningGoal: 'balanced' as const, createdAt: '2026-01-01' }, - isLoading: false, - error: null, - workspaceId: 'ws-1', - setWorkspaceId: vi.fn(), - refetch: vi.fn(), - children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }], - selectedChildId: 'c-1', - selectChild: vi.fn(), - personalDataUuid: 'pd-1', - parentName: 'Test Parent', - refreshContext: vi.fn(), - } - - const mockAnalyticsData = { - workspaceId: 'ws-1', - childId: 'c-1', - parentProjections: [], - combinedDaysUsed: 30, - combinedDaysLeft: 270, - combinedExpiringAtB4: 100, - } - - const mockParentalLeaveData = { - id: 'pf-1', - personalDataUuid: 'pd-1', - workspaceId: 'ws-1', - childId: 'c-1', - source: 'scraped' as const, - fetchedAt: '2026-06-13T10:00:00Z', - totalDays: 300, - daysUsed: 30, - daysLeft: 270, - reservedDaysTotal: 90, - reservedDaysLeft: 80, - transferableDaysLeft: 50, - dubbeldagarLeft: 60, - tierData: { - sjukpenningTotal: 100, - sjukpenningUsed: 10, - sjukpenningLeft: 90, - lagstaNivaTotal: 200, - lagstaNivaUsed: 20, - lagstaNivaLeft: 180, - }, - createdAt: '2026-06-13T10:00:00Z', - updatedAt: '2026-06-13T10:00:00Z', - } - beforeEach(() => { vi.clearAllMocks() - vi.mocked(useAuth).mockReturnValue({ - accessToken: 'token', - isAuthenticated: true, - isLoading: false, - login: vi.fn(), - register: vi.fn(), - logout: vi.fn(), - } as any) - vi.mocked(useWorkspace).mockReturnValue(mockWorkspaceContext as any) - vi.mocked(hooks.useAnalytics).mockReturnValue({ - data: mockAnalyticsData, - isLoading: false, - error: null, - refresh: vi.fn(), - } as any) - vi.mocked(hooks.useParentalLeave).mockReturnValue({ - data: mockParentalLeaveData, - isLoading: false, - error: null, - refresh: vi.fn(), - } as any) - vi.mocked(hooks.usePlannedDays).mockReturnValue({ - data: [ - { id: '1', workspaceId: 'ws-1', childId: 'c-1', parentPersonalDataUuid: 'pd-1', date: '2026-06-15', dayType: 'vacation', isDubbeldag: false, createdAt: '', updatedAt: '' }, - { id: '2', workspaceId: 'ws-1', childId: 'c-1', parentPersonalDataUuid: 'pd-1', date: '2026-06-16', dayType: 'sjukpenning', isDubbeldag: false, createdAt: '', updatedAt: '' }, - ], - isLoading: false, - error: null, - add: vi.fn(), - remove: vi.fn(), - } as any) - vi.mocked(hooks.useScraperFetch).mockReturnValue({ - sessionId: null, - pollData: null, - resolvedIds: null, - error: null, - isUnauthorized: false, - start: vi.fn(), - cancel: vi.fn(), - isRunning: false, - isTerminal: false, - } as any) - vi.mocked(hooks.useWorkspaceMembers).mockReturnValue({ - data: [ - { uuid: 'pd-1', workspaceId: 'ws-1', name: 'Alex', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }, - { uuid: 'pd-2', workspaceId: 'ws-1', name: 'Sam', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }, - ], - isLoading: false, - error: null, - refresh: vi.fn(), - } as any) + vi.mocked(useAuth).mockReturnValue(makeAuthContextValue()) + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: { id: 'ws-1', name: 'Test Workspace', planningGoal: 'balanced', planningMode: 'solo', createdAt: '2026-01-01' }, + workspaceId: 'ws-1', + children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }], + selectedChildId: 'c-1', + personalDataUuid: 'pd-1', + parentName: 'Test Parent', + }) + ) + vi.mocked(hooks.useAnalytics).mockReturnValue( + makeAnalyticsResult({ + data: { + workspaceId: 'ws-1', + childId: 'c-1', + parentProjections: [], + combinedDaysUsed: 30, + combinedDaysLeft: 270, + combinedExpiringAtB4: 100, + }, + }) + ) + vi.mocked(hooks.useParentalLeave).mockReturnValue( + makeParentalLeaveResult({ + data: { + id: 'pf-1', + personalDataUuid: 'pd-1', + workspaceId: 'ws-1', + childId: 'c-1', + source: 'scraped', + fetchedAt: '2026-06-13T10:00:00Z', + totalDays: 300, + daysUsed: 30, + daysLeft: 270, + reservedDaysTotal: 90, + reservedDaysLeft: 80, + transferableDaysLeft: 50, + dubbeldagarLeft: 60, + tierData: { + sjukpenningTotal: 100, + sjukpenningUsed: 10, + sjukpenningLeft: 90, + lagstaNivaTotal: 200, + lagstaNivaUsed: 20, + lagstaNivaLeft: 180, + }, + createdAt: '2026-06-13T10:00:00Z', + updatedAt: '2026-06-13T10:00:00Z', + }, + }) + ) + vi.mocked(hooks.usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [ + { id: '1', workspaceId: 'ws-1', childId: 'c-1', parentPersonalDataUuid: 'pd-1', date: '2026-06-15', dayType: 'vacation', isDubbeldag: false, createdAt: '', updatedAt: '' }, + { id: '2', workspaceId: 'ws-1', childId: 'c-1', parentPersonalDataUuid: 'pd-1', date: '2026-06-16', dayType: 'sjukpenning', isDubbeldag: false, createdAt: '', updatedAt: '' }, + ], + }) + ) + vi.mocked(hooks.useScraperFetch).mockReturnValue(makeScraperFetchResult()) + vi.mocked(hooks.useWorkspaceMembers).mockReturnValue( + makeWorkspaceMembersResult({ + data: [ + { uuid: 'pd-1', workspaceId: 'ws-1', name: 'Alex', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }, + { uuid: 'pd-2', workspaceId: 'ws-1', name: 'Sam', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }, + ], + }) + ) }) it('renders loading spinner initially', () => { - vi.mocked(useWorkspace).mockReturnValue({ - ...mockWorkspaceContext, - isLoading: true, - } as any) + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: { id: 'ws-1', name: 'Test Workspace', planningGoal: 'balanced', planningMode: 'solo', createdAt: '2026-01-01' }, + workspaceId: 'ws-1', + children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }], + selectedChildId: 'c-1', + personalDataUuid: 'pd-1', + parentName: 'Test Parent', + isLoading: true, + }) + ) renderWithRouter() expect(screen.getByText(/Laddar/)).toBeInTheDocument() }) it('shows add child form when no children', () => { - vi.mocked(useWorkspace).mockReturnValue({ - ...mockWorkspaceContext, - children: [], - selectedChildId: null, - } as any) + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: { id: 'ws-1', name: 'Test Workspace', planningGoal: 'balanced', planningMode: 'solo', createdAt: '2026-01-01' }, + workspaceId: 'ws-1', + children: [], + selectedChildId: null, + personalDataUuid: 'pd-1', + parentName: 'Test Parent', + }) + ) renderWithRouter() expect(screen.getByText(/Du har inga barn registrerade ännu/)).toBeInTheDocument() expect(screen.getByLabelText(/Barnets namn/)).toBeInTheDocument() @@ -169,10 +158,16 @@ describe('Dashboard', () => { }) it('disables FK fetch button when no child selected', () => { - vi.mocked(useWorkspace).mockReturnValue({ - ...mockWorkspaceContext, - selectedChildId: null, - } as any) + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: { id: 'ws-1', name: 'Test Workspace', planningGoal: 'balanced', planningMode: 'solo', createdAt: '2026-01-01' }, + workspaceId: 'ws-1', + children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }], + selectedChildId: null, + personalDataUuid: 'pd-1', + parentName: 'Test Parent', + }) + ) renderWithRouter() const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ }) expect(button).toBeDisabled() @@ -213,19 +208,21 @@ describe('Dashboard', () => { }) it('labels the per-parent expiring-at-4 metric as reflecting the current saved plan', () => { - vi.mocked(hooks.useAnalytics).mockReturnValue({ - data: { - ...mockAnalyticsData, - parentProjections: [ - { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60 }, - { personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44 }, - ], - combinedExpiringAtB4: 104, - }, - isLoading: false, - error: null, - refresh: vi.fn(), - } as any) + vi.mocked(hooks.useAnalytics).mockReturnValue( + makeAnalyticsResult({ + data: { + workspaceId: 'ws-1', + childId: 'c-1', + parentProjections: [ + { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60 }, + { personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44 }, + ], + combinedDaysUsed: 50, + combinedDaysLeft: 190, + combinedExpiringAtB4: 104, + }, + }) + ) renderWithRouter() expect(screen.getByTestId('metric-expiring-parent-pd-1')).toHaveTextContent( @@ -234,23 +231,22 @@ describe('Dashboard', () => { }) it('displays error message if analytics fails', () => { - vi.mocked(hooks.useAnalytics).mockReturnValue({ - data: null, - isLoading: false, - error: 'Failed to load analytics', - refresh: vi.fn(), - } as any) + vi.mocked(hooks.useAnalytics).mockReturnValue( + makeAnalyticsResult({ + data: null, + error: 'Failed to load analytics', + }) + ) renderWithRouter() expect(screen.getByText(/Failed to load analytics/)).toBeInTheDocument() }) it('shows empty state for parental leave when data is null', () => { - vi.mocked(hooks.useParentalLeave).mockReturnValue({ - data: null, - isLoading: false, - error: null, - refresh: vi.fn(), - } as any) + vi.mocked(hooks.useParentalLeave).mockReturnValue( + makeParentalLeaveResult({ + data: null, + }) + ) renderWithRouter() expect(screen.getByText(/Ingen data hämtad ännu/)).toBeInTheDocument() }) @@ -258,17 +254,11 @@ describe('Dashboard', () => { it('opens QR code modal on FK fetch button click', async () => { const user = userEvent.setup() const mockStart = vi.fn() - vi.mocked(hooks.useScraperFetch).mockReturnValue({ - sessionId: null, - pollData: null, - resolvedIds: null, - error: null, - isUnauthorized: false, - start: mockStart, - cancel: vi.fn(), - isRunning: false, - isTerminal: false, - } as any) + vi.mocked(hooks.useScraperFetch).mockReturnValue( + makeScraperFetchResult({ + start: mockStart, + }) + ) renderWithRouter() const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ }) @@ -289,12 +279,17 @@ describe('Dashboard', () => { const mockCreateChild = vi.fn().mockResolvedValue({ id: 'c-2', name: 'New Child', birth_date: '2024-01-15' }) const mockRefreshContext = vi.fn().mockResolvedValue(undefined) - vi.mocked(useWorkspace).mockReturnValue({ - ...mockWorkspaceContext, - children: [], - selectedChildId: null, - refreshContext: mockRefreshContext, - } as any) + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: { id: 'ws-1', name: 'Test Workspace', planningGoal: 'balanced', planningMode: 'solo', createdAt: '2026-01-01' }, + workspaceId: 'ws-1', + children: [], + selectedChildId: null, + personalDataUuid: 'pd-1', + parentName: 'Test Parent', + refreshContext: mockRefreshContext, + }) + ) const mockApi = await import('@/services/api') vi.mocked(mockApi.createChild).mockImplementation(mockCreateChild) @@ -321,12 +316,16 @@ describe('Dashboard', () => { const user = userEvent.setup() const mockCreateChild = vi.fn().mockRejectedValue(new Error('API error')) - vi.mocked(useWorkspace).mockReturnValue({ - ...mockWorkspaceContext, - children: [], - selectedChildId: null, - refreshContext: vi.fn(), - } as any) + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: { id: 'ws-1', name: 'Test Workspace', planningGoal: 'balanced', planningMode: 'solo', createdAt: '2026-01-01' }, + workspaceId: 'ws-1', + children: [], + selectedChildId: null, + personalDataUuid: 'pd-1', + parentName: 'Test Parent', + }) + ) const mockApi = await import('@/services/api') vi.mocked(mockApi.createChild).mockImplementation(mockCreateChild) @@ -350,12 +349,17 @@ describe('Dashboard', () => { () => new Promise(resolve => setTimeout(() => resolve({ id: 'c-2' }), 100)) ) - vi.mocked(useWorkspace).mockReturnValue({ - ...mockWorkspaceContext, - children: [], - selectedChildId: null, - refreshContext: vi.fn().mockResolvedValue(undefined), - } as any) + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: { id: 'ws-1', name: 'Test Workspace', planningGoal: 'balanced', planningMode: 'solo', createdAt: '2026-01-01' }, + workspaceId: 'ws-1', + children: [], + selectedChildId: null, + personalDataUuid: 'pd-1', + parentName: 'Test Parent', + refreshContext: vi.fn().mockResolvedValue(undefined), + }) + ) const mockApi = await import('@/services/api') vi.mocked(mockApi.createChild).mockImplementation(mockCreateChild) @@ -374,13 +378,13 @@ describe('Dashboard', () => { }) it('validates add child form with workspace check', async () => { - vi.mocked(useWorkspace).mockReturnValue({ - ...mockWorkspaceContext, - children: [], - selectedChildId: null, - workspace: null, - refreshContext: vi.fn(), - } as any) + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: null, + children: [], + selectedChildId: null, + }) + ) renderWithRouter() @@ -406,10 +410,16 @@ describe('Dashboard', () => { }) it('disables manual fetch button when no child selected', () => { - vi.mocked(useWorkspace).mockReturnValue({ - ...mockWorkspaceContext, - selectedChildId: null, - } as any) + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: { id: 'ws-1', name: 'Test Workspace', planningGoal: 'balanced', planningMode: 'solo', createdAt: '2026-01-01' }, + workspaceId: 'ws-1', + children: [{ id: 'c-1', name: 'Child 1', birth_date: '2022-01-01' }], + selectedChildId: null, + personalDataUuid: 'pd-1', + parentName: 'Test Parent', + }) + ) renderWithRouter() const manualButton = screen.getByRole('button', { name: /Ange uppgifter manuellt/ }) @@ -417,17 +427,13 @@ describe('Dashboard', () => { }) it('renders QRCodeModal when scraper is active', () => { - vi.mocked(hooks.useScraperFetch).mockReturnValue({ - sessionId: 'session1', - pollData: { status: 'in_progress' }, - resolvedIds: null, - error: null, - isUnauthorized: false, - start: vi.fn(), - cancel: vi.fn(), - isRunning: true, - isTerminal: false, - } as any) + vi.mocked(hooks.useScraperFetch).mockReturnValue( + makeScraperFetchResult({ + sessionId: 'session1', + pollData: { status: 'authenticating' }, + isRunning: true, + }) + ) renderWithRouter() // QRCodeModal is rendered inside Dashboard @@ -436,90 +442,86 @@ describe('Dashboard', () => { }) it('renders planned days breakdown with multiple day types', () => { - vi.mocked(hooks.usePlannedDays).mockReturnValue({ - data: [ - { id: '1', workspaceId: 'ws-1', childId: 'c-1', parentPersonalDataUuid: 'pd-1', date: '2026-06-15', dayType: 'vacation', isDubbeldag: false, createdAt: '', updatedAt: '' }, - { id: '2', workspaceId: 'ws-1', childId: 'c-1', parentPersonalDataUuid: 'pd-1', date: '2026-06-16', dayType: 'sjukpenning', isDubbeldag: false, createdAt: '', updatedAt: '' }, - { id: '3', workspaceId: 'ws-1', childId: 'c-1', parentPersonalDataUuid: 'pd-1', date: '2026-06-17', dayType: 'lagsta', isDubbeldag: false, createdAt: '', updatedAt: '' }, - ], - isLoading: false, - error: null, - add: vi.fn(), - remove: vi.fn(), - } as any) + vi.mocked(hooks.usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [ + { id: '1', workspaceId: 'ws-1', childId: 'c-1', parentPersonalDataUuid: 'pd-1', date: '2026-06-15', dayType: 'vacation', isDubbeldag: false, createdAt: '', updatedAt: '' }, + { id: '2', workspaceId: 'ws-1', childId: 'c-1', parentPersonalDataUuid: 'pd-1', date: '2026-06-16', dayType: 'sjukpenning', isDubbeldag: false, createdAt: '', updatedAt: '' }, + { id: '3', workspaceId: 'ws-1', childId: 'c-1', parentPersonalDataUuid: 'pd-1', date: '2026-06-17', dayType: 'lagsta', isDubbeldag: false, createdAt: '', updatedAt: '' }, + ], + }) + ) renderWithRouter() expect(screen.getByTestId('days-breakdown')).toBeInTheDocument() }) it('shows analytics loading state', () => { - vi.mocked(hooks.useAnalytics).mockReturnValue({ - data: null, - isLoading: true, - error: null, - refresh: vi.fn(), - } as any) + vi.mocked(hooks.useAnalytics).mockReturnValue( + makeAnalyticsResult({ + data: null, + isLoading: true, + }) + ) renderWithRouter() expect(screen.getByText(/Laddar/)).toBeInTheDocument() }) it('shows planned days loading state', () => { - vi.mocked(hooks.usePlannedDays).mockReturnValue({ - data: null, - isLoading: true, - error: null, - add: vi.fn(), - remove: vi.fn(), - } as any) + vi.mocked(hooks.usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: null, + isLoading: true, + }) + ) renderWithRouter() expect(screen.getByText(/Laddar/)).toBeInTheDocument() }) it('renders with zero planned days', () => { - vi.mocked(hooks.usePlannedDays).mockReturnValue({ - data: [], - isLoading: false, - error: null, - add: vi.fn(), - remove: vi.fn(), - } as any) + vi.mocked(hooks.usePlannedDays).mockReturnValue( + makePlannedDaysResult({ + data: [], + }) + ) renderWithRouter() expect(screen.getByTestId('days-breakdown')).toBeInTheDocument() }) it('displays metrics with expiring days at B4', () => { - vi.mocked(hooks.useAnalytics).mockReturnValue({ - data: { - workspaceId: 'ws-1', - childId: 'c-1', - parentProjections: [], - combinedDaysUsed: 50, - combinedDaysLeft: 200, - combinedExpiringAtB4: 104, - }, - isLoading: false, - error: null, - refresh: vi.fn(), - } as any) + vi.mocked(hooks.useAnalytics).mockReturnValue( + makeAnalyticsResult({ + data: { + workspaceId: 'ws-1', + childId: 'c-1', + parentProjections: [], + combinedDaysUsed: 50, + combinedDaysLeft: 200, + combinedExpiringAtB4: 104, + }, + }) + ) renderWithRouter() expect(screen.getByTestId('overview-metrics')).toBeInTheDocument() }) it('shows only the combined expiring figure for a single-parent workspace', () => { - vi.mocked(hooks.useAnalytics).mockReturnValue({ - data: { - ...mockAnalyticsData, - parentProjections: [ - { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 104 }, - ], - combinedExpiringAtB4: 104, - }, - isLoading: false, - error: null, - refresh: vi.fn(), - } as any) + vi.mocked(hooks.useAnalytics).mockReturnValue( + makeAnalyticsResult({ + data: { + workspaceId: 'ws-1', + childId: 'c-1', + parentProjections: [ + { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 104 }, + ], + combinedDaysUsed: 30, + combinedDaysLeft: 210, + combinedExpiringAtB4: 104, + }, + }) + ) renderWithRouter() expect(screen.getByTestId('metric-expiring-combined')).toBeInTheDocument() @@ -527,19 +529,21 @@ describe('Dashboard', () => { }) it('breaks down expiring days per parent alongside the combined total', () => { - vi.mocked(hooks.useAnalytics).mockReturnValue({ - data: { - ...mockAnalyticsData, - parentProjections: [ - { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60 }, - { personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44 }, - ], - combinedExpiringAtB4: 104, - }, - isLoading: false, - error: null, - refresh: vi.fn(), - } as any) + vi.mocked(hooks.useAnalytics).mockReturnValue( + makeAnalyticsResult({ + data: { + workspaceId: 'ws-1', + childId: 'c-1', + parentProjections: [ + { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60 }, + { personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44 }, + ], + combinedDaysUsed: 50, + combinedDaysLeft: 190, + combinedExpiringAtB4: 104, + }, + }) + ) renderWithRouter() const alexMetric = screen.getByTestId('metric-expiring-parent-pd-1') @@ -553,25 +557,27 @@ describe('Dashboard', () => { }) it('falls back to a numbered parent label while members are still loading', () => { - vi.mocked(hooks.useWorkspaceMembers).mockReturnValue({ - data: [], - isLoading: true, - error: null, - refresh: vi.fn(), - } as any) - vi.mocked(hooks.useAnalytics).mockReturnValue({ - data: { - ...mockAnalyticsData, - parentProjections: [ - { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60 }, - { personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44 }, - ], - combinedExpiringAtB4: 104, - }, - isLoading: false, - error: null, - refresh: vi.fn(), - } as any) + vi.mocked(hooks.useWorkspaceMembers).mockReturnValue( + makeWorkspaceMembersResult({ + data: [], + isLoading: true, + }) + ) + vi.mocked(hooks.useAnalytics).mockReturnValue( + makeAnalyticsResult({ + data: { + workspaceId: 'ws-1', + childId: 'c-1', + parentProjections: [ + { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60 }, + { personalDataUuid: 'pd-2', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44 }, + ], + combinedDaysUsed: 50, + combinedDaysLeft: 190, + combinedExpiringAtB4: 104, + }, + }) + ) renderWithRouter() expect(screen.getByTestId('metric-expiring-parent-pd-1')).toHaveTextContent('Förälder 1') @@ -579,19 +585,21 @@ describe('Dashboard', () => { }) it('falls back to a numbered parent label for a projection with no matching member', () => { - vi.mocked(hooks.useAnalytics).mockReturnValue({ - data: { - ...mockAnalyticsData, - parentProjections: [ - { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60 }, - { personalDataUuid: 'pd-unknown', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44 }, - ], - combinedExpiringAtB4: 104, - }, - isLoading: false, - error: null, - refresh: vi.fn(), - } as any) + vi.mocked(hooks.useAnalytics).mockReturnValue( + makeAnalyticsResult({ + data: { + workspaceId: 'ws-1', + childId: 'c-1', + parentProjections: [ + { personalDataUuid: 'pd-1', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 30, plannedDaysBeforeB4: 10, projectedLeftAtB4: 200, savableAfterB4: 96, expiringAtB4: 60 }, + { personalDataUuid: 'pd-unknown', childId: 'c-1', birthDate: '2022-01-01', fourthBirthday: '2026-01-01', totalDays: 240, daysUsed: 20, plannedDaysBeforeB4: 5, projectedLeftAtB4: 210, savableAfterB4: 96, expiringAtB4: 44 }, + ], + combinedDaysUsed: 50, + combinedDaysLeft: 190, + combinedExpiringAtB4: 104, + }, + }) + ) renderWithRouter() expect(screen.getByTestId('metric-expiring-parent-pd-1')).toHaveTextContent('Alex') diff --git a/frontend/src/pages/Plan.test.tsx b/frontend/src/pages/Plan.test.tsx index 3478ad3..5efb47f 100644 --- a/frontend/src/pages/Plan.test.tsx +++ b/frontend/src/pages/Plan.test.tsx @@ -4,6 +4,13 @@ import { Plan } from './Plan' import * as hooks from '@/hooks' import { useWorkspace } from '@/context' import { LanguageProvider } from '@/i18n' +import { + makeWorkspaceContextValue, + makeLeaveWishesResult, + makeWorkspaceMembersResult, + makePlanProposalResult, + makePlanApplyResult, +} from '../../tests/fixtures' vi.mock('@/hooks') vi.mock('@/context') @@ -34,32 +41,13 @@ function renderPlanInEnglish() { ) } -const mockWorkspaceContext = { - workspace: { - id: 'ws-1', - name: 'Test Workspace', - planningGoal: 'balanced' as const, - planningMode: 'couple' as const, - createdAt: '2026-01-01', - }, - isLoading: false, - error: null, - workspaceId: 'ws-1', - setWorkspaceId: vi.fn(), - refetch: vi.fn(), - children: [{ id: 'c-1', name: 'Child 1', birth_date: '2026-03-01' }], - selectedChildId: 'c-1', - selectChild: vi.fn(), - personalDataUuid: 'pd-1', - parentName: 'Alex', - refreshContext: vi.fn(), -} const mockWish = { id: 'lw1', workspaceId: 'ws-1', childId: null, name: 'Sommar', + nameMessageKey: 'presets.sommar', windowStartMonth: 6, windowStartDay: 15, windowEndMonth: 7, @@ -102,6 +90,7 @@ const mockProposal = { { wishId: 'lw1', wishName: 'Sommar', + wishNameMessageKey: 'presets.sommar', year: 2026, startDate: '2026-06-15', endDate: '2026-07-26', @@ -152,54 +141,79 @@ describe('Plan', () => { createdDays: [], }) - vi.mocked(useWorkspace).mockReturnValue(mockWorkspaceContext as any) - vi.mocked(hooks.useLeaveWishes).mockReturnValue({ - data: [mockWish], - isLoading: false, - isRefreshing: false, - error: null, - refresh: vi.fn(), - add: mockAddWish, - update: mockUpdateWish, - remove: mockRemoveWish, - } as any) - vi.mocked(hooks.useWorkspaceMembers).mockReturnValue({ - data: [{ uuid: 'pd-1', workspaceId: 'ws-1', name: 'Alex', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }], - isLoading: false, - error: null, - refresh: vi.fn(), - } as any) - vi.mocked(hooks.usePlanProposal).mockReturnValue({ - data: null, - isLoading: false, - error: null, - propose: mockPropose, - } as any) - vi.mocked(hooks.usePlanApply).mockReturnValue({ - response: null, - isLoading: false, - error: null, - apply: mockApply, - } as any) + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: { + id: 'ws-1', + name: 'Test Workspace', + planningGoal: 'balanced', + planningMode: 'couple', + createdAt: '2026-01-01', + }, + workspaceId: 'ws-1', + children: [{ id: 'c-1', name: 'Child 1', birth_date: '2026-03-01' }], + selectedChildId: 'c-1', + personalDataUuid: 'pd-1', + parentName: 'Alex', + }) + ) + vi.mocked(hooks.useLeaveWishes).mockReturnValue( + makeLeaveWishesResult({ + data: [mockWish], + add: mockAddWish, + update: mockUpdateWish, + remove: mockRemoveWish, + }) + ) + vi.mocked(hooks.useWorkspaceMembers).mockReturnValue( + makeWorkspaceMembersResult({ + data: [{ uuid: 'pd-1', workspaceId: 'ws-1', name: 'Alex', salaryType: 'monthly', employmentStatus: 'employed', createdAt: '2026-01-01' }], + }) + ) + vi.mocked(hooks.usePlanProposal).mockReturnValue( + makePlanProposalResult({ + propose: mockPropose, + }) + ) + vi.mocked(hooks.usePlanApply).mockReturnValue( + makePlanApplyResult({ + apply: mockApply, + }) + ) }) it('renders loading spinner while the workspace loads', () => { - vi.mocked(useWorkspace).mockReturnValue({ ...mockWorkspaceContext, isLoading: true } as any) + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: { + id: 'ws-1', + name: 'Test Workspace', + planningGoal: 'balanced', + planningMode: 'couple', + createdAt: '2026-01-01', + }, + workspaceId: 'ws-1', + children: [{ id: 'c-1', name: 'Child 1', birth_date: '2026-03-01' }], + selectedChildId: 'c-1', + personalDataUuid: 'pd-1', + parentName: 'Alex', + isLoading: true, + }) + ) renderPlan() expect(screen.getByText(/Laddar planering/)).toBeInTheDocument() }) it('renders the loading spinner during the initial wishes load, before any data has arrived', () => { - vi.mocked(hooks.useLeaveWishes).mockReturnValue({ - data: [], - isLoading: true, - isRefreshing: false, - error: null, - refresh: vi.fn(), - add: mockAddWish, - update: mockUpdateWish, - remove: mockRemoveWish, - } as any) + vi.mocked(hooks.useLeaveWishes).mockReturnValue( + makeLeaveWishesResult({ + data: [], + isLoading: true, + add: mockAddWish, + update: mockUpdateWish, + remove: mockRemoveWish, + }) + ) renderPlan() expect(screen.getByText(/Laddar planering/)).toBeInTheDocument() expect(screen.queryByTestId('wish-editor')).not.toBeInTheDocument() @@ -209,16 +223,15 @@ describe('Plan', () => { // useLeaveWishes flips isRefreshing (not isLoading) while it refetches // after add/update/remove. Plan must keep rendering the wish editor // (and whatever expanded/save-status state it holds) through that. - vi.mocked(hooks.useLeaveWishes).mockReturnValue({ - data: [mockWish], - isLoading: false, - isRefreshing: true, - error: null, - refresh: vi.fn(), - add: mockAddWish, - update: mockUpdateWish, - remove: mockRemoveWish, - } as any) + vi.mocked(hooks.useLeaveWishes).mockReturnValue( + makeLeaveWishesResult({ + data: [mockWish], + isRefreshing: true, + add: mockAddWish, + update: mockUpdateWish, + remove: mockRemoveWish, + }) + ) renderPlan() expect(screen.queryByText(/Laddar planering/)).not.toBeInTheDocument() expect(screen.getByTestId('wish-editor')).toBeInTheDocument() @@ -247,26 +260,25 @@ describe('Plan', () => { }) it('disables "Föreslå plan" when there are no wishes yet', () => { - vi.mocked(hooks.useLeaveWishes).mockReturnValue({ - data: [], - isLoading: false, - error: null, - refresh: vi.fn(), - add: mockAddWish, - update: mockUpdateWish, - remove: mockRemoveWish, - } as any) + vi.mocked(hooks.useLeaveWishes).mockReturnValue( + makeLeaveWishesResult({ + data: [], + add: mockAddWish, + update: mockUpdateWish, + remove: mockRemoveWish, + }) + ) renderPlan() expect(screen.getByTestId('propose-plan-button')).toBeDisabled() }) it('renders the proposal, including the age-4 warning, once it is available', () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue({ - data: mockProposal, - isLoading: false, - error: null, - propose: mockPropose, - } as any) + vi.mocked(hooks.usePlanProposal).mockReturnValue( + makePlanProposalResult({ + data: mockProposal, + propose: mockPropose, + }) + ) renderPlan() expect(screen.getByTestId('plan-results')).toBeInTheDocument() @@ -323,24 +335,29 @@ describe('Plan', () => { }) it('shows a calculating label on the propose button while a proposal is in flight', () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue({ - data: null, - isLoading: true, - error: null, - propose: mockPropose, - } as any) + vi.mocked(hooks.usePlanProposal).mockReturnValue( + makePlanProposalResult({ + data: null, + isLoading: true, + propose: mockPropose, + }) + ) renderPlan() expect(screen.getByTestId('propose-plan-button')).toHaveTextContent('Beräknar') }) it('treats a missing workspace as having no workspace id: skips the wish editor and refuses to apply', async () => { - vi.mocked(useWorkspace).mockReturnValue({ ...mockWorkspaceContext, workspace: null } as any) - vi.mocked(hooks.usePlanProposal).mockReturnValue({ - data: mockProposal, - isLoading: false, - error: null, - propose: mockPropose, - } as any) + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: null, + }) + ) + vi.mocked(hooks.usePlanProposal).mockReturnValue( + makePlanProposalResult({ + data: mockProposal, + propose: mockPropose, + }) + ) renderPlan() // workspace?.id ?? null falls back to null, so the wish editor (guarded @@ -357,24 +374,25 @@ describe('Plan', () => { }) it('shows an error banner when the proposal fails', () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue({ - data: null, - isLoading: false, - error: 'Kunde inte beräkna plan', - propose: mockPropose, - } as any) + vi.mocked(hooks.usePlanProposal).mockReturnValue( + makePlanProposalResult({ + data: null, + error: 'Kunde inte beräkna plan', + propose: mockPropose, + }) + ) renderPlan() expect(screen.getByTestId('proposal-error')).toHaveTextContent('Kunde inte beräkna plan') }) it('shows the apply button when a proposal exists', () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue({ - data: mockProposal, - isLoading: false, - error: null, - propose: mockPropose, - } as any) + vi.mocked(hooks.usePlanProposal).mockReturnValue( + makePlanProposalResult({ + data: mockProposal, + propose: mockPropose, + }) + ) renderPlan() expect(screen.getByTestId('plan-apply-button')).toBeInTheDocument() @@ -382,12 +400,12 @@ describe('Plan', () => { }) it('calls apply when the apply button is clicked and shows success message', async () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue({ - data: mockProposal, - isLoading: false, - error: null, - propose: mockPropose, - } as any) + vi.mocked(hooks.usePlanProposal).mockReturnValue( + makePlanProposalResult({ + data: mockProposal, + propose: mockPropose, + }) + ) renderPlan() fireEvent.click(screen.getByTestId('plan-apply-button')) @@ -399,12 +417,12 @@ describe('Plan', () => { }) it('shows collision prompt when apply returns collisions and replace=false', async () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue({ - data: mockProposal, - isLoading: false, - error: null, - propose: mockPropose, - } as any) + vi.mocked(hooks.usePlanProposal).mockReturnValue( + makePlanProposalResult({ + data: mockProposal, + propose: mockPropose, + }) + ) mockApply.mockResolvedValue({ accepted: false, @@ -437,12 +455,12 @@ describe('Plan', () => { }) it('re-sends with replace=true when user confirms overwrite', async () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue({ - data: mockProposal, - isLoading: false, - error: null, - propose: mockPropose, - } as any) + vi.mocked(hooks.usePlanProposal).mockReturnValue( + makePlanProposalResult({ + data: mockProposal, + propose: mockPropose, + }) + ) mockApply .mockResolvedValueOnce({ @@ -492,12 +510,12 @@ describe('Plan', () => { }) it('hides collision prompt when user cancels', async () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue({ - data: mockProposal, - isLoading: false, - error: null, - propose: mockPropose, - } as any) + vi.mocked(hooks.usePlanProposal).mockReturnValue( + makePlanProposalResult({ + data: mockProposal, + propose: mockPropose, + }) + ) mockApply.mockResolvedValue({ accepted: false, @@ -533,12 +551,12 @@ describe('Plan', () => { }) it('shows rejections in the success message', async () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue({ - data: mockProposal, - isLoading: false, - error: null, - propose: mockPropose, - } as any) + vi.mocked(hooks.usePlanProposal).mockReturnValue( + makePlanProposalResult({ + data: mockProposal, + propose: mockPropose, + }) + ) mockApply.mockResolvedValue({ accepted: true, @@ -568,12 +586,12 @@ describe('Plan', () => { }) it('shows rejections in English when the locale is English (§3.5)', async () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue({ - data: mockProposal, - isLoading: false, - error: null, - propose: mockPropose, - } as any) + vi.mocked(hooks.usePlanProposal).mockReturnValue( + makePlanProposalResult({ + data: mockProposal, + propose: mockPropose, + }) + ) mockApply.mockResolvedValue({ accepted: true, @@ -614,19 +632,19 @@ describe('Plan', () => { }) it('shows an error banner when apply fails', async () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue({ - data: mockProposal, - isLoading: false, - error: null, - propose: mockPropose, - } as any) + vi.mocked(hooks.usePlanProposal).mockReturnValue( + makePlanProposalResult({ + data: mockProposal, + propose: mockPropose, + }) + ) - vi.mocked(hooks.usePlanApply).mockReturnValue({ - response: null, - isLoading: false, - error: 'Kunde inte spara plan', - apply: mockApply, - } as any) + vi.mocked(hooks.usePlanApply).mockReturnValue( + makePlanApplyResult({ + error: 'Kunde inte spara plan', + apply: mockApply, + }) + ) renderPlan() @@ -658,12 +676,12 @@ describe('Plan', () => { proposalData = mockProposal2 }) - vi.mocked(hooks.usePlanProposal).mockImplementation(() => ({ - data: proposalData, - isLoading: false, - error: null, - propose: proposeHandler, - } as any)) + vi.mocked(hooks.usePlanProposal).mockImplementation(() => + makePlanProposalResult({ + data: proposalData, + propose: proposeHandler, + }) + ) const { rerender } = renderPlan() @@ -717,12 +735,12 @@ describe('Plan', () => { proposalData = mockProposal2 }) - vi.mocked(hooks.usePlanProposal).mockImplementation(() => ({ - data: proposalData, - isLoading: false, - error: null, - propose: proposeHandler, - } as any)) + vi.mocked(hooks.usePlanProposal).mockImplementation(() => + makePlanProposalResult({ + data: proposalData, + propose: proposeHandler, + }) + ) // Mock apply to return a collision on first call mockApply.mockResolvedValue({ @@ -772,12 +790,12 @@ describe('Plan', () => { }) it('success message is present immediately after apply succeeds', async () => { - vi.mocked(hooks.usePlanProposal).mockReturnValue({ - data: mockProposal, - isLoading: false, - error: null, - propose: mockPropose, - } as any) + vi.mocked(hooks.usePlanProposal).mockReturnValue( + makePlanProposalResult({ + data: mockProposal, + propose: mockPropose, + }) + ) mockApply.mockResolvedValue({ accepted: true, diff --git a/frontend/src/pages/Settings.test.tsx b/frontend/src/pages/Settings.test.tsx index 8a94f64..b395320 100644 --- a/frontend/src/pages/Settings.test.tsx +++ b/frontend/src/pages/Settings.test.tsx @@ -6,6 +6,15 @@ import { useWorkspace } from '@/context' import * as api from '@/services/api' import { ApiRequestError } from '@/services/errors' import { LanguageProvider } from '@/i18n' +import { + makeWorkspaceContextValue, + makeWorkspaceMembersResult, + makeVacationDaysResult, + makeShowWeekNumbersTuple, + makeChildSettingsResult, + makeDayOverridesResult, + makeCalendarDaysResult, +} from '../../tests/fixtures' vi.mock('@/hooks') vi.mock('@/context') @@ -39,26 +48,6 @@ const CURRENT_YEAR = new Date().getFullYear() const CHILD_BIRTH_DATE = `${CURRENT_YEAR - 1}-01-01` describe('Settings', () => { - const mockWorkspaceContext = { - workspace: { - id: 'ws-1', - name: 'Test Workspace', - planningGoal: 'balanced' as const, - planningMode: 'solo' as const, - createdAt: '2026-01-01', - }, - isLoading: false, - error: null, - workspaceId: 'ws-1', - setWorkspaceId: vi.fn(), - refetch: vi.fn(), - children: [{ id: 'c-1', name: 'Child 1', birth_date: CHILD_BIRTH_DATE }], - selectedChildId: 'c-1', - selectChild: vi.fn(), - personalDataUuid: 'pd-1', - parentName: 'Test Parent', - refreshContext: vi.fn(), - } const mockVacationDays = [ { @@ -123,49 +112,96 @@ describe('Settings', () => { mockRemoveDayOverride = vi.fn().mockResolvedValue(undefined) mockRefreshMembers = vi.fn().mockResolvedValue(undefined) - vi.mocked(useWorkspace).mockReturnValue(mockWorkspaceContext as any) + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: { + id: 'ws-1', + name: 'Test Workspace', + planningGoal: 'balanced', + planningMode: 'solo', + createdAt: '2026-01-01', + }, + workspaceId: 'ws-1', + children: [{ id: 'c-1', name: 'Child 1', birth_date: CHILD_BIRTH_DATE }], + selectedChildId: 'c-1', + personalDataUuid: 'pd-1', + parentName: 'Test Parent', + }) + ) // Default: solo workspace (only the logged-in user) — the add-partner // form should be visible. Tests that need a linked partner override this. - vi.mocked(hooks.useWorkspaceMembers).mockReturnValue({ - data: [selfMember], - isLoading: false, - error: null, - refresh: mockRefreshMembers, - } as any) - vi.mocked(hooks.useVacationDays).mockReturnValue({ - data: mockVacationDays, - isLoading: false, - error: null, - upsert: mockUpsertVacationDays, - } as any) - vi.mocked(hooks.useShowWeekNumbers).mockReturnValue([false, vi.fn()] as any) - vi.mocked(hooks.useChildSettings).mockReturnValue({ - data: mockChildDetails, - isLoading: false, - error: null, - update: mockUpdateChildDetails, - } as any) - vi.mocked(hooks.useDayOverrides).mockReturnValue({ - data: [], - isLoading: false, - error: null, - upsert: mockUpsertDayOverride, - remove: mockRemoveDayOverride, - } as any) - vi.mocked(hooks.useCalendarDays).mockReturnValue({ - data: mockCalendarWindow, - isLoading: false, - error: null, - } as any) - vi.mocked(api.updateWorkspace).mockResolvedValue({ ...mockWorkspaceContext.workspace } as any) - vi.mocked(api.updateParentSettings).mockResolvedValue({} as any) - vi.mocked(api.addPartner).mockResolvedValue({} as any) - vi.mocked(api.renameWorkspaceParent).mockResolvedValue({ ...partnerMember } as any) + vi.mocked(hooks.useWorkspaceMembers).mockReturnValue( + makeWorkspaceMembersResult({ + data: [selfMember], + refresh: mockRefreshMembers, + }) + ) + vi.mocked(hooks.useVacationDays).mockReturnValue( + makeVacationDaysResult({ + data: mockVacationDays, + upsert: mockUpsertVacationDays, + }) + ) + vi.mocked(hooks.useShowWeekNumbers).mockReturnValue(makeShowWeekNumbersTuple(false, vi.fn())) + vi.mocked(hooks.useChildSettings).mockReturnValue( + makeChildSettingsResult({ + data: mockChildDetails, + update: mockUpdateChildDetails, + }) + ) + vi.mocked(hooks.useDayOverrides).mockReturnValue( + makeDayOverridesResult({ + data: [], + upsert: mockUpsertDayOverride, + remove: mockRemoveDayOverride, + }) + ) + vi.mocked(hooks.useCalendarDays).mockReturnValue( + makeCalendarDaysResult({ + data: mockCalendarWindow, + }) + ) + vi.mocked(api.updateWorkspace).mockResolvedValue({ + id: 'ws-1', + name: 'Test Workspace', + planningGoal: 'balanced', + planningMode: 'solo', + createdAt: '2026-01-01', + }) + vi.mocked(api.updateParentSettings).mockResolvedValue({ + uuid: 'pd-1', + name: 'Test Parent', + salary_type: 'monthly', + employment_status: 'employed', + }) + vi.mocked(api.addPartner).mockResolvedValue({ + uuid: 'pd-2', + name: 'Sam', + salary_type: 'monthly', + employment_status: 'employed', + }) + vi.mocked(api.renameWorkspaceParent).mockResolvedValue(partnerMember) vi.mocked(api.removeWorkspaceParent).mockResolvedValue(undefined) }) it('renders loading spinner while workspace loads', () => { - vi.mocked(useWorkspace).mockReturnValue({ ...mockWorkspaceContext, isLoading: true } as any) + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: { + id: 'ws-1', + name: 'Test Workspace', + planningGoal: 'balanced', + planningMode: 'solo', + createdAt: '2026-01-01', + }, + workspaceId: 'ws-1', + children: [{ id: 'c-1', name: 'Child 1', birth_date: CHILD_BIRTH_DATE }], + selectedChildId: 'c-1', + personalDataUuid: 'pd-1', + parentName: 'Test Parent', + isLoading: true, + }) + ) renderSettings() expect(screen.getByText(/Laddar inställningar/)).toBeInTheDocument() }) @@ -326,12 +362,12 @@ describe('Settings', () => { describe('workspace members', () => { beforeEach(() => { - vi.mocked(hooks.useWorkspaceMembers).mockReturnValue({ - data: [selfMember, partnerMember], - isLoading: false, - error: null, - refresh: mockRefreshMembers, - } as any) + vi.mocked(hooks.useWorkspaceMembers).mockReturnValue( + makeWorkspaceMembersResult({ + data: [selfMember, partnerMember], + refresh: mockRefreshMembers, + }) + ) }) it('shows the linked partner and hides the add-partner form', () => { @@ -469,7 +505,7 @@ describe('Settings', () => { it('toggles week numbers', () => { const setShowWeekNumbers = vi.fn() - vi.mocked(hooks.useShowWeekNumbers).mockReturnValue([false, setShowWeekNumbers] as any) + vi.mocked(hooks.useShowWeekNumbers).mockReturnValue(makeShowWeekNumbersTuple(false, setShowWeekNumbers)) renderSettings() fireEvent.click(screen.getByTestId('show-week-numbers-checkbox')) expect(setShowWeekNumbers).toHaveBeenCalledWith(true) @@ -553,26 +589,26 @@ describe('Settings', () => { describe('red days list (§3.8)', () => { it('passes the fetched calendar window through to the list, four years at a time', () => { - vi.mocked(hooks.useCalendarDays).mockReturnValue({ - data: { - startYear: CURRENT_YEAR, - endYear: CURRENT_YEAR + 3, - minYear: 2026, - maxYear: 2035, - days: [ - { - date: `${CURRENT_YEAR}-01-01`, - name: 'Nyårsdagen', - dayType: 'red_day', - isOverride: false, - overrideIsWorkingDay: null, - isFree: true, - }, - ], - }, - isLoading: false, - error: null, - } as any) + vi.mocked(hooks.useCalendarDays).mockReturnValue( + makeCalendarDaysResult({ + data: { + startYear: CURRENT_YEAR, + endYear: CURRENT_YEAR + 3, + minYear: 2026, + maxYear: 2035, + days: [ + { + date: `${CURRENT_YEAR}-01-01`, + name: 'Nyårsdagen', + dayType: 'red_day', + isOverride: false, + overrideIsWorkingDay: null, + isFree: true, + }, + ], + }, + }) + ) renderSettings() @@ -590,11 +626,11 @@ describe('Settings', () => { it('requests the previous 4-year window when paginating backward', () => { // minYear well below the current window so the "previous" button isn't disabled. - vi.mocked(hooks.useCalendarDays).mockReturnValue({ - data: { ...mockCalendarWindow, minYear: 2000 }, - isLoading: false, - error: null, - } as any) + vi.mocked(hooks.useCalendarDays).mockReturnValue( + makeCalendarDaysResult({ + data: { ...mockCalendarWindow, minYear: 2000 }, + }) + ) renderSettings() diff --git a/frontend/src/pages/Tips.test.tsx b/frontend/src/pages/Tips.test.tsx index 798a415..9643bb9 100644 --- a/frontend/src/pages/Tips.test.tsx +++ b/frontend/src/pages/Tips.test.tsx @@ -3,6 +3,7 @@ import { render, screen, fireEvent } from '@testing-library/react' import { Tips } from './Tips' import { useWorkspace } from '@/context' import { useOptimizationTips } from '@/hooks' +import { makeWorkspaceContextValue, makeOptimizationTipsResult } from '../../tests/fixtures' vi.mock('@/context', () => ({ useWorkspace: vi.fn(), @@ -21,73 +22,78 @@ describe('Tips', () => { beforeEach(() => { vi.clearAllMocks() - vi.mocked(useWorkspace).mockReturnValue({ workspace: { id: 'ws-1' }, isLoading: false } as any) + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: { id: 'ws-1', name: 'Test Workspace', planningGoal: 'balanced', planningMode: 'solo', createdAt: '2026-01-01' }, + }) + ) }) it('passes null to useOptimizationTips when there is no workspace yet', () => { - vi.mocked(useWorkspace).mockReturnValue({ workspace: null, isLoading: false } as any) - vi.mocked(useOptimizationTips).mockReturnValue({ - data: [], - isLoading: false, - error: null, - refresh: vi.fn(), - dismiss: mockDismiss, - } as any) + vi.mocked(useWorkspace).mockReturnValue( + makeWorkspaceContextValue({ + workspace: null, + }) + ) + vi.mocked(useOptimizationTips).mockReturnValue( + makeOptimizationTipsResult({ + data: [], + dismiss: mockDismiss, + }) + ) render() expect(useOptimizationTips).toHaveBeenCalledWith(null) }) it('shows the loading spinner while the workspace or tips are loading', () => { - vi.mocked(useOptimizationTips).mockReturnValue({ - data: null, - isLoading: true, - error: null, - refresh: vi.fn(), - dismiss: mockDismiss, - } as any) + vi.mocked(useOptimizationTips).mockReturnValue( + makeOptimizationTipsResult({ + data: null, + isLoading: true, + dismiss: mockDismiss, + }) + ) render() expect(screen.getByText(/Laddar tips/)).toBeInTheDocument() }) it('shows the empty state when there are no tips', () => { - vi.mocked(useOptimizationTips).mockReturnValue({ - data: [], - isLoading: false, - error: null, - refresh: vi.fn(), - dismiss: mockDismiss, - } as any) + vi.mocked(useOptimizationTips).mockReturnValue( + makeOptimizationTipsResult({ + data: [], + dismiss: mockDismiss, + }) + ) render() expect(screen.getByTestId('empty-state')).toHaveTextContent('Bra jobbat') }) it('shows an error banner when tips failed to load', () => { - vi.mocked(useOptimizationTips).mockReturnValue({ - data: null, - isLoading: false, - error: 'Kunde inte hämta tips', - refresh: vi.fn(), - dismiss: mockDismiss, - } as any) + vi.mocked(useOptimizationTips).mockReturnValue( + makeOptimizationTipsResult({ + data: null, + error: 'Kunde inte hämta tips', + dismiss: mockDismiss, + }) + ) render() expect(screen.getByTestId('error-banner')).toHaveTextContent('Kunde inte hämta tips') }) it('renders a tip card per tip and dismisses it on click', () => { - vi.mocked(useOptimizationTips).mockReturnValue({ - data: [ - { - id: 'tip-1', - severity: 'info', - messageKey: 'tips.balance_between_parents', - data: { leading_parent: 'Alex', lagging_parent: 'Sam', gap_days: 40 }, - }, - ], - isLoading: false, - error: null, - refresh: vi.fn(), - dismiss: mockDismiss, - } as any) + vi.mocked(useOptimizationTips).mockReturnValue( + makeOptimizationTipsResult({ + data: [ + { + id: 'tip-1', + severity: 'info', + messageKey: 'tips.balance_between_parents', + data: { leading_parent: 'Alex', lagging_parent: 'Sam', gap_days: 40 }, + }, + ], + dismiss: mockDismiss, + }) + ) render() const tipCard = screen.getByTestId('tip-card') diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts index fd58914..3320ffd 100644 --- a/frontend/src/services/api.test.ts +++ b/frontend/src/services/api.test.ts @@ -8,7 +8,7 @@ describe('API response mappers (snake_case → camelCase)', () => { beforeEach(() => { fetchMock = vi.fn() - globalThis.fetch = fetchMock as any + globalThis.fetch = fetchMock as typeof fetch }) afterEach(() => { @@ -288,7 +288,7 @@ describe('API response mappers (snake_case → camelCase)', () => { firstYear: 2026, }) - const calls = (fetchMock.mock?.calls ?? []) as any[][] + const calls = vi.mocked(fetchMock).mock.calls if (calls.length === 0) throw new Error('No fetch calls made') const [, options] = calls[0]! const bodyStr = options.body @@ -346,7 +346,7 @@ describe('API response mappers (snake_case → camelCase)', () => { firstYear: 2026, }) - const calls = (fetchMock.mock?.calls ?? []) as any[][] + const calls = vi.mocked(fetchMock).mock.calls if (calls.length === 0) throw new Error('No fetch calls made') const [, options] = calls[0]! const body = JSON.parse(options.body) @@ -386,7 +386,7 @@ describe('apiFetch (shared request/response handling)', () => { beforeEach(() => { fetchMock = vi.fn() - globalThis.fetch = fetchMock as any + globalThis.fetch = fetchMock as typeof fetch }) afterEach(() => { @@ -534,7 +534,7 @@ describe('endpoint functions (representative coverage)', () => { beforeEach(() => { fetchMock = vi.fn() - globalThis.fetch = fetchMock as any + globalThis.fetch = fetchMock as typeof fetch }) afterEach(() => { @@ -543,15 +543,17 @@ describe('endpoint functions (representative coverage)', () => { }) function lastRequestBody(): Record { - const calls = fetchMock.mock.calls as any[][] - const options = calls[calls.length - 1]![1] as RequestInit - return JSON.parse(options.body as string) as Record + const calls = vi.mocked(fetchMock).mock.calls + const options = calls[calls.length - 1]![1] as RequestInit | undefined + return JSON.parse((options?.body ?? '') as string) as Record } function lastRequest(): { url: string; method?: string | undefined } { - const calls = fetchMock.mock.calls as any[][] - const [url, options] = calls[calls.length - 1]! - return { url, method: (options as RequestInit | undefined)?.method } + const calls = vi.mocked(fetchMock).mock.calls + const call = calls[calls.length - 1]! + const url = (call[0] ?? '') as string + const options = call[1] as RequestInit | undefined + return { url, method: options?.method } } it('register sends snake_case payload and returns the token response', async () => { @@ -1517,7 +1519,7 @@ describe('apiFetch — structured {message_key, data} errors', () => { beforeEach(() => { fetchMock = vi.fn() - globalThis.fetch = fetchMock as any + globalThis.fetch = fetchMock as typeof fetch }) afterEach(() => { @@ -1583,7 +1585,7 @@ describe('workspace parent management (rename/remove)', () => { beforeEach(() => { fetchMock = vi.fn() - globalThis.fetch = fetchMock as any + globalThis.fetch = fetchMock as typeof fetch }) afterEach(() => { @@ -1592,15 +1594,17 @@ describe('workspace parent management (rename/remove)', () => { }) function lastRequestBody(): Record { - const calls = fetchMock.mock.calls as any[][] - const options = calls[calls.length - 1]![1] as RequestInit - return JSON.parse(options.body as string) as Record + const calls = vi.mocked(fetchMock).mock.calls + const options = calls[calls.length - 1]![1] as RequestInit | undefined + return JSON.parse((options?.body ?? '') as string) as Record } function lastRequest(): { url: string; method?: string | undefined } { - const calls = fetchMock.mock.calls as any[][] - const [url, options] = calls[calls.length - 1]! - return { url, method: (options as RequestInit | undefined)?.method } + const calls = vi.mocked(fetchMock).mock.calls + const call = calls[calls.length - 1]! + const url = (call[0] ?? '') as string + const options = call[1] as RequestInit | undefined + return { url, method: options?.method } } it('renameWorkspaceParent PATCHes the new display name and maps the response to camelCase', async () => { diff --git a/frontend/tests/fixtures/analytics.ts b/frontend/tests/fixtures/analytics.ts new file mode 100644 index 0000000..6ccb52a --- /dev/null +++ b/frontend/tests/fixtures/analytics.ts @@ -0,0 +1,31 @@ +/** Shared test fixtures — Dashboard analytics (`GET /api/analytics/overview`). */ + +import type { Age4Projection, WorkspaceOverview } from '@/types' + +export function makeAge4Projection(overrides: Partial = {}): Age4Projection { + return { + personalDataUuid: 'pd-a', + childId: 'child-1', + birthDate: '2025-06-01', + fourthBirthday: '2029-06-01', + totalDays: 240, + daysUsed: 30, + plannedDaysBeforeB4: 10, + projectedLeftAtB4: 200, + savableAfterB4: 96, + expiringAtB4: 60, + ...overrides, + } +} + +export function makeWorkspaceOverview(overrides: Partial = {}): WorkspaceOverview { + return { + workspaceId: 'ws-1', + childId: 'child-1', + parentProjections: [], + combinedDaysUsed: 30, + combinedDaysLeft: 270, + combinedExpiringAtB4: 100, + ...overrides, + } +} diff --git a/frontend/tests/fixtures/calendar.ts b/frontend/tests/fixtures/calendar.ts new file mode 100644 index 0000000..1690086 --- /dev/null +++ b/frontend/tests/fixtures/calendar.ts @@ -0,0 +1,26 @@ +/** Shared test fixtures — calendar metadata (`GET /api/calendar/days`). */ + +import type { CalendarDayEntry, CalendarWindow } from '@/types' + +export function makeCalendarDayEntry(overrides: Partial = {}): CalendarDayEntry { + return { + date: '2026-01-01', + name: null, + dayType: 'working_day', + isOverride: false, + overrideIsWorkingDay: null, + isFree: false, + ...overrides, + } +} + +export function makeCalendarWindow(overrides: Partial = {}): CalendarWindow { + return { + startYear: 2026, + endYear: 2026, + minYear: 2026, + maxYear: 2035, + days: [], + ...overrides, + } +} diff --git a/frontend/tests/fixtures/hooks.ts b/frontend/tests/fixtures/hooks.ts new file mode 100644 index 0000000..3e3feb4 --- /dev/null +++ b/frontend/tests/fixtures/hooks.ts @@ -0,0 +1,254 @@ +/** + * Shared test fixtures — return values of `src/context/*` and `src/hooks/*`. + * + * None of these hooks export their result interface (e.g. `useAnalytics`'s + * `UseAnalyticsResult` is private to useAnalytics.ts), and this file + * deliberately does not add exports to make that possible — `ReturnType` gets the same fully-typed result without touching source files. + * `import type` erases at compile time, so this doesn't pull the hooks' + * runtime code (or React) into the test bundle. + */ + +import { vi } from 'vitest' +import type { useWorkspace } from '@/context/WorkspaceContext' +import type { useAuth } from '@/context/AuthContext' +import type { useAnalytics } from '@/hooks/useAnalytics' +import type { useParentalLeave } from '@/hooks/useParentalLeave' +import type { usePlannedDays } from '@/hooks/usePlannedDays' +import type { useScraperFetch } from '@/hooks/useScraperFetch' +import type { useWorkspaceMembers } from '@/hooks/useWorkspaceMembers' +import type { useOptimizationTips } from '@/hooks/useOptimizationTips' +import type { useDayOverrides } from '@/hooks/useDayOverrides' +import type { useLeaveWishes } from '@/hooks/useLeaveWishes' +import type { usePlanProposal } from '@/hooks/usePlanProposal' +import type { usePlanApply } from '@/hooks/usePlanApply' +import type { useVacationDays } from '@/hooks/useVacationDays' +import type { useChildSettings } from '@/hooks/useChildSettings' +import type { useCalendarDays } from '@/hooks/useCalendarDays' +import type { useShowWeekNumbers } from '@/hooks/useCalendarPreferences' +import { MOCK_CHILD, MOCK_PARENT_A, MOCK_VACATION_DAYS, MOCK_WORKSPACE, makeParentalLeaveFetch } from './mockData' +import { makeWorkspaceOverview } from './analytics' +import { makeCalendarWindow } from './calendar' + +// --------------------------------------------------------------------------- +// Contexts +// --------------------------------------------------------------------------- + +export function makeWorkspaceContextValue( + overrides: Partial> = {}, +): ReturnType { + return { + workspace: MOCK_WORKSPACE, + isLoading: false, + error: null, + workspaceId: MOCK_WORKSPACE.id, + setWorkspaceId: vi.fn(), + refetch: vi.fn(), + children: [{ id: MOCK_CHILD.id, name: MOCK_CHILD.name, birth_date: MOCK_CHILD.birthDate }], + selectedChildId: MOCK_CHILD.id, + selectChild: vi.fn(), + personalDataUuid: MOCK_PARENT_A.uuid, + parentName: MOCK_PARENT_A.name, + refreshContext: vi.fn(), + ...overrides, + } +} + +export function makeAuthContextValue( + overrides: Partial> = {}, +): ReturnType { + return { + accessToken: 'token-123', + isAuthenticated: true, + isLoading: false, + login: vi.fn(), + register: vi.fn(), + logout: vi.fn(), + ...overrides, + } +} + +// --------------------------------------------------------------------------- +// Data-fetching hooks +// --------------------------------------------------------------------------- + +export function makeAnalyticsResult( + overrides: Partial> = {}, +): ReturnType { + return { + data: makeWorkspaceOverview(), + isLoading: false, + error: null, + refresh: vi.fn(), + ...overrides, + } +} + +export function makeParentalLeaveResult( + overrides: Partial> = {}, +): ReturnType { + return { + data: makeParentalLeaveFetch(), + isLoading: false, + error: null, + refresh: vi.fn(), + ...overrides, + } +} + +export function makePlannedDaysResult( + overrides: Partial> = {}, +): ReturnType { + return { + data: [], + isLoading: false, + error: null, + refresh: vi.fn(), + add: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + ...overrides, + } +} + +export function makeScraperFetchResult( + overrides: Partial> = {}, +): ReturnType { + return { + sessionId: null, + pollData: null, + resolvedIds: null, + error: null, + isUnauthorized: false, + start: vi.fn(), + cancel: vi.fn(), + isRunning: false, + isTerminal: false, + ...overrides, + } +} + +export function makeWorkspaceMembersResult( + overrides: Partial> = {}, +): ReturnType { + return { + data: [MOCK_PARENT_A], + isLoading: false, + error: null, + refresh: vi.fn(), + ...overrides, + } +} + +export function makeOptimizationTipsResult( + overrides: Partial> = {}, +): ReturnType { + return { + data: [], + isLoading: false, + error: null, + refresh: vi.fn(), + dismiss: vi.fn(), + ...overrides, + } +} + +export function makeDayOverridesResult( + overrides: Partial> = {}, +): ReturnType { + return { + data: [], + isLoading: false, + error: null, + upsert: vi.fn(), + remove: vi.fn(), + ...overrides, + } +} + +export function makeLeaveWishesResult( + overrides: Partial> = {}, +): ReturnType { + return { + data: [], + isLoading: false, + isRefreshing: false, + error: null, + refresh: vi.fn(), + add: vi.fn(), + update: vi.fn(), + remove: vi.fn(), + ...overrides, + } +} + +export function makePlanProposalResult( + overrides: Partial> = {}, +): ReturnType { + return { + data: null, + isLoading: false, + error: null, + propose: vi.fn(), + ...overrides, + } +} + +export function makePlanApplyResult( + overrides: Partial> = {}, +): ReturnType { + return { + response: null, + isLoading: false, + error: null, + apply: vi.fn(), + ...overrides, + } +} + +export function makeVacationDaysResult( + overrides: Partial> = {}, +): ReturnType { + return { + data: [MOCK_VACATION_DAYS], + isLoading: false, + error: null, + upsert: vi.fn(), + ...overrides, + } +} + +export function makeChildSettingsResult( + overrides: Partial> = {}, +): ReturnType { + return { + data: MOCK_CHILD, + isLoading: false, + error: null, + update: vi.fn(), + ...overrides, + } +} + +export function makeCalendarDaysResult( + overrides: Partial> = {}, +): ReturnType { + return { + data: makeCalendarWindow(), + isLoading: false, + error: null, + refetch: vi.fn(), + ...overrides, + } +} + +// --------------------------------------------------------------------------- +// Local-storage-backed preference hook (a tuple, not an object) +// --------------------------------------------------------------------------- + +export function makeShowWeekNumbersTuple( + show = false, + setShow: (show: boolean) => void = vi.fn(), +): ReturnType { + return [show, setShow] +} diff --git a/frontend/tests/fixtures/index.ts b/frontend/tests/fixtures/index.ts new file mode 100644 index 0000000..427c835 --- /dev/null +++ b/frontend/tests/fixtures/index.ts @@ -0,0 +1,26 @@ +/** + * Barrel for all typed test fixtures. Import from here: + * + * import { makeWorkspaceOverview, makePlanProposal, MOCK_WORKSPACE } from '../../tests/fixtures' + * + * See the sibling files for what each group covers: + * - mockData.ts domain entities (Workspace, PersonalData, Child, VacationDays, + * ParentalLeaveFetch/TierData, LeaveWish, PlannedLeaveDay, + * DayOverride, Tip) + * - plan.ts the planning engine (PlanProposal and everything nested in it, + * plus the plan-apply request/response shapes) + * - analytics.ts Dashboard analytics (Age4Projection, WorkspaceOverview) + * - calendar.ts calendar metadata (CalendarDayEntry, CalendarWindow) + * - scraper.ts the FK scraper session lifecycle + * - workspace.ts small inline API response shapes (addPartner, etc.) + * - hooks.ts return values of src/context/* and src/hooks/* — these are + * the ones most test files reach for + */ + +export * from './mockData' +export * from './plan' +export * from './analytics' +export * from './calendar' +export * from './scraper' +export * from './workspace' +export * from './hooks' diff --git a/frontend/tests/fixtures/mockData.ts b/frontend/tests/fixtures/mockData.ts index 3d3dcc7..7d67aea 100644 --- a/frontend/tests/fixtures/mockData.ts +++ b/frontend/tests/fixtures/mockData.ts @@ -1,78 +1,217 @@ /** - * Shared test fixtures. Import from here rather than duplicating inline. + * Shared test fixtures — domain entities. + * + * Prefer the `make*` factories over the `MOCK_*` constants in new tests: a + * factory returns a complete, correctly-typed object, so overriding one field + * can't silently leave the rest `undefined`/wrong the way a hand-rolled + * partial object cast with `as any` can. See `frontend/tests/fixtures/index.ts` + * for the full barrel of factories (plan, analytics, calendar, scraper, hooks). */ -import type { Child, ParentalLeaveFetch, PersonalData, VacationDays, Workspace } from '@/types' +import type { + Child, + DayOverride, + LeaveWish, + ParentalLeaveFetch, + PersonalData, + PlannedLeaveDay, + TierData, + Tip, + VacationDays, + Workspace, +} from '@/types' -export const MOCK_WORKSPACE: Workspace = { - id: 'ws-1', - name: 'Familjen Testsson', - planningGoal: 'balanced', - planningMode: 'solo', - createdAt: '2026-01-01T00:00:00', +// --------------------------------------------------------------------------- +// Workspace +// --------------------------------------------------------------------------- + +export function makeWorkspace(overrides: Partial = {}): Workspace { + return { + id: 'ws-1', + name: 'Familjen Testsson', + planningGoal: 'balanced', + planningMode: 'solo', + createdAt: '2026-01-01T00:00:00', + ...overrides, + } } -export const MOCK_PARENT_A: PersonalData = { - uuid: 'pd-a', - workspaceId: 'ws-1', - name: 'Förälder A', - salaryType: 'monthly', - employmentStatus: 'employed', - createdAt: '2026-01-01T00:00:00', +export const MOCK_WORKSPACE: Workspace = makeWorkspace() + +// --------------------------------------------------------------------------- +// Personal data (parents) +// --------------------------------------------------------------------------- + +export function makePersonalData(overrides: Partial = {}): PersonalData { + return { + uuid: 'pd-a', + workspaceId: 'ws-1', + name: 'Förälder A', + salaryType: 'monthly', + employmentStatus: 'employed', + createdAt: '2026-01-01T00:00:00', + ...overrides, + } } -export const MOCK_PARENT_B: PersonalData = { - uuid: 'pd-b', - workspaceId: 'ws-1', - name: 'Förälder B', - salaryType: 'monthly', - employmentStatus: 'employed', - createdAt: '2026-01-01T00:00:00', +export const MOCK_PARENT_A: PersonalData = makePersonalData({ uuid: 'pd-a', name: 'Förälder A' }) +export const MOCK_PARENT_B: PersonalData = makePersonalData({ uuid: 'pd-b', name: 'Förälder B' }) + +// --------------------------------------------------------------------------- +// Child +// --------------------------------------------------------------------------- + +export function makeChild(overrides: Partial = {}): Child { + return { + id: 'child-1', + workspaceId: 'ws-1', + name: 'Barnet', + birthDate: '2025-06-01', + preschoolStartDate: null, + preschoolPlanningDaysPerYear: 6, + createdAt: '2026-01-01T00:00:00', + ...overrides, + } } -export const MOCK_CHILD: Child = { - id: 'child-1', - workspaceId: 'ws-1', - name: 'Barnet', - birthDate: '2025-06-01', - preschoolStartDate: null, - preschoolPlanningDaysPerYear: 6, - createdAt: '2026-01-01T00:00:00', +export const MOCK_CHILD: Child = makeChild() + +// --------------------------------------------------------------------------- +// Vacation days +// --------------------------------------------------------------------------- + +export function makeVacationDays(overrides: Partial = {}): VacationDays { + return { + id: 'vd-1', + personalDataUuid: 'pd-a', + workspaceId: 'ws-1', + year: 2026, + totalDays: 25, + savedDaysIn: 0, + createdAt: '2026-01-01T00:00:00', + updatedAt: '2026-01-01T00:00:00', + ...overrides, + } } -export const MOCK_VACATION_DAYS: VacationDays = { - id: 'vd-1', - personalDataUuid: 'pd-a', - workspaceId: 'ws-1', - year: 2026, - totalDays: 25, - savedDaysIn: 0, - createdAt: '2026-01-01T00:00:00', - updatedAt: '2026-01-01T00:00:00', -} +export const MOCK_VACATION_DAYS: VacationDays = makeVacationDays() -export const MOCK_PARENTAL_LEAVE: ParentalLeaveFetch = { - id: 'plf-1', - personalDataUuid: 'pd-a', - workspaceId: 'ws-1', - childId: 'child-1', - source: 'manual', - fetchedAt: '2026-05-01T10:00:00', - totalDays: 240, - daysUsed: 80, - daysLeft: 160, - reservedDaysTotal: 90, - reservedDaysLeft: 70, - transferableDaysLeft: 50, - dubbeldagarLeft: 55, - tierData: { +// --------------------------------------------------------------------------- +// Parental leave fetch (+ tier data) +// --------------------------------------------------------------------------- + +export function makeTierData(overrides: Partial = {}): TierData { + return { sjukpenningTotal: 390, sjukpenningUsed: 80, sjukpenningLeft: 310, lagstaNivaTotal: 90, lagstaNivaUsed: 0, lagstaNivaLeft: 90, - }, - createdAt: '2026-05-01T10:00:00', - updatedAt: '2026-05-01T10:00:00', + ...overrides, + } +} + +export function makeParentalLeaveFetch(overrides: Partial = {}): ParentalLeaveFetch { + return { + id: 'plf-1', + personalDataUuid: 'pd-a', + workspaceId: 'ws-1', + childId: 'child-1', + source: 'manual', + fetchedAt: '2026-05-01T10:00:00', + totalDays: 240, + daysUsed: 80, + daysLeft: 160, + reservedDaysTotal: 90, + reservedDaysLeft: 70, + transferableDaysLeft: 50, + dubbeldagarLeft: 55, + tierData: makeTierData(), + createdAt: '2026-05-01T10:00:00', + updatedAt: '2026-05-01T10:00:00', + ...overrides, + } +} + +export const MOCK_PARENTAL_LEAVE: ParentalLeaveFetch = makeParentalLeaveFetch() + +// --------------------------------------------------------------------------- +// Leave wishes +// --------------------------------------------------------------------------- + +export function makeLeaveWish(overrides: Partial = {}): LeaveWish { + return { + id: 'lw-1', + workspaceId: 'ws-1', + childId: null, + name: 'Sommar', + nameMessageKey: null, + windowStartMonth: 6, + windowStartDay: 15, + windowEndMonth: 7, + windowEndDay: 26, + targetWeeks: 6, + recurrence: 'yearly', + firstYear: 2026, + lastYear: null, + priority: 1, + parentMode: 'either', + parentPersonalDataUuid: null, + goalOverride: null, + windowMode: 'fixed', + createdAt: '2026-01-01T00:00:00', + updatedAt: '2026-01-01T00:00:00', + ...overrides, + } +} + +// --------------------------------------------------------------------------- +// Planned leave days +// --------------------------------------------------------------------------- + +export function makePlannedLeaveDay(overrides: Partial = {}): PlannedLeaveDay { + return { + id: 'pld-1', + workspaceId: 'ws-1', + childId: 'child-1', + parentPersonalDataUuid: 'pd-a', + date: '2026-06-15', + dayType: 'vacation', + isDubbeldag: false, + createdAt: '2026-01-01T00:00:00', + updatedAt: '2026-01-01T00:00:00', + ...overrides, + } +} + +// --------------------------------------------------------------------------- +// Day overrides (calendar exceptions) +// --------------------------------------------------------------------------- + +export function makeDayOverride(overrides: Partial = {}): DayOverride { + return { + id: 'do-1', + workspaceId: 'ws-1', + date: '2026-12-24', + isWorkingDay: false, + note: null, + createdAt: '2026-01-01T00:00:00', + updatedAt: '2026-01-01T00:00:00', + ...overrides, + } +} + +// --------------------------------------------------------------------------- +// Tips +// --------------------------------------------------------------------------- + +export function makeTip(overrides: Partial = {}): Tip { + return { + id: 'tip-1', + severity: 'info', + messageKey: 'tips.example', + data: {}, + ...overrides, + } } diff --git a/frontend/tests/fixtures/plan.ts b/frontend/tests/fixtures/plan.ts new file mode 100644 index 0000000..a54323f --- /dev/null +++ b/frontend/tests/fixtures/plan.ts @@ -0,0 +1,182 @@ +/** + * Shared test fixtures — the planning engine (`POST /api/plan/propose`, + * `POST /api/plan/apply`) and its nested shapes. + */ + +import type { + CostBreakdown, + DayCollision, + PlanAge4Projection, + PlanApplyResponse, + PlanProposal, + PlanWarning, + PoolBalance, + ProposedDay, + ProposedDayApplyItem, + Rejection, + ShortfallRemedy, + SurplusAdvice, + VacationBalance, + WindowOutcome, +} from '@/types' + +export function makeCostBreakdown(overrides: Partial = {}): CostBreakdown { + return { free: 0, vacation: 0, sjukpenning: 0, lagsta: 0, ...overrides } +} + +export function makeShortfallRemedy(overrides: Partial = {}): ShortfallRemedy { + return { + kind: 'dubbeldag', + messageKey: 'planRemedies.example', + data: {}, + daysFreed: 0, + ...overrides, + } +} + +export function makeProposedDay(overrides: Partial = {}): ProposedDay { + return { + date: '2026-06-15', + parentPersonalDataUuid: 'pd-a', + childId: 'child-1', + dayType: 'sjukpenning', + isDubbeldag: false, + wishId: 'lw-1', + reason: 'Sommar', + ...overrides, + } +} + +/** The `/api/plan/apply` request-side counterpart of `ProposedDay` — no + * `wishId`/`reason`, and `dayType` additionally allows `'free'`. */ +export function makeProposedDayApplyItem( + overrides: Partial = {}, +): ProposedDayApplyItem { + return { + date: '2026-06-15', + parentPersonalDataUuid: 'pd-a', + childId: 'child-1', + dayType: 'sjukpenning', + ...overrides, + } +} + +export function makeWindowOutcome(overrides: Partial = {}): WindowOutcome { + return { + wishId: 'lw-1', + wishName: 'Sommar', + wishNameMessageKey: null, + year: 2026, + startDate: '2026-06-15', + endDate: '2026-07-26', + weeksWished: 6, + weeksFunded: 6, + shortfallDays: 0, + strategy: 'parent_swap', + messageKey: 'planOutcomes.windowExplanation', + data: {}, + costBreakdown: makeCostBreakdown({ free: 12, vacation: 19, sjukpenning: 11 }), + remedies: [], + ...overrides, + } +} + +export function makePlanWarning(overrides: Partial = {}): PlanWarning { + return { + kind: 'days_lost_at_age_4', + messageKey: 'planWarnings.days_lost_at_age_4', + data: {}, + ...overrides, + } +} + +export function makePoolBalance(overrides: Partial = {}): PoolBalance { + return { + parentPersonalDataUuid: 'pd-a', + childId: 'child-1', + sjukpenningLeft: 310, + lagstaLeft: 90, + dubbeldagarLeft: 55, + ...overrides, + } +} + +export function makeVacationBalance(overrides: Partial = {}): VacationBalance { + return { + parentPersonalDataUuid: 'pd-a', + year: 2026, + totalDays: 25, + savedDaysIn: 0, + alreadyUsed: 0, + isAssumed: false, + ...overrides, + } +} + +export function makeSurplusAdvice(overrides: Partial = {}): SurplusAdvice { + return { + kind: 'wish_more_leave', + messageKey: 'planSurplus.wish_more_leave', + daysInvolved: 0, + data: {}, + ...overrides, + } +} + +export function makePlanAge4Projection(overrides: Partial = {}): PlanAge4Projection { + return { + parentPersonalDataUuid: 'pd-a', + childId: 'child-1', + fourthBirthday: '2029-06-01', + projectedLeftAtB4: 0, + savableAfterB4: 96, + expiringAtB4: 0, + ...overrides, + } +} + +export function makePlanProposal(overrides: Partial = {}): PlanProposal { + return { + days: [makeProposedDay()], + outcomes: [makeWindowOutcome()], + warnings: [], + poolBalances: [], + vacationBalances: [], + surplusAdvice: [], + age4Projections: [], + ...overrides, + } +} + +export function makeDayCollision(overrides: Partial = {}): DayCollision { + return { + date: '2026-06-15', + parentPersonalDataUuid: 'pd-a', + childId: 'child-1', + existingDayType: 'vacation', + proposedDayType: 'sjukpenning', + ...overrides, + } +} + +export function makeRejection(overrides: Partial = {}): Rejection { + return { + messageKey: 'planApply.rejection.insufficientBalance', + data: {}, + ...overrides, + } +} + +export function makePlanApplyResponse(overrides: Partial = {}): PlanApplyResponse { + return { + accepted: true, + createdCount: 0, + skippedIdenticalCount: 0, + skippedFreeCount: 0, + replacedCount: 0, + collisions: [], + rejections: [], + createdDays: [], + ...overrides, + } +} diff --git a/frontend/tests/fixtures/scraper.ts b/frontend/tests/fixtures/scraper.ts new file mode 100644 index 0000000..e50c5de --- /dev/null +++ b/frontend/tests/fixtures/scraper.ts @@ -0,0 +1,37 @@ +/** + * Shared test fixtures — the FK scraper session lifecycle + * (`startScraperSession` / `pollScraperSession`, and the `useScraperFetch` + * hook that polls it). + * + * `pollScraperSession`'s declared return type is `Promise` (the + * hook casts it internally), so there is nothing to derive a poll-response + * type from at the API layer. `PollResponse` is exported from + * `useScraperFetch.ts` for exactly this reason — see the comment there. + */ + +import type { startScraperSession } from '@/services/api' +import type { PollResponse } from '@/hooks/useScraperFetch' + +export type ScraperSessionStartResponse = Awaited> + +export function makeScraperSessionStartResponse( + overrides: Partial = {}, +): ScraperSessionStartResponse { + return { + session_id: 'sess-123', + status: 'launching', + workspace_id: 'ws-1', + personal_data_uuid: 'pd-a', + child_id: 'child-1', + ...overrides, + } +} + +/** One factory taking the `status` discriminant, rather than one factory per + * variant — `PollResponse` isn't a discriminated union in the type system + * (every field besides `status` is optional regardless of status), and every + * real call site only varies `status` plus one or two of the optional + * fields. */ +export function makePollResponse(overrides: Partial = {}): PollResponse { + return { status: 'launching', ...overrides } +} diff --git a/frontend/tests/fixtures/workspace.ts b/frontend/tests/fixtures/workspace.ts new file mode 100644 index 0000000..2cce658 --- /dev/null +++ b/frontend/tests/fixtures/workspace.ts @@ -0,0 +1,25 @@ +/** + * Shared test fixtures — small ad hoc API response shapes that aren't a + * named type in `src/types/` (they're inline return-type object literals in + * `src/services/api.ts`). Derived via `Awaited>` + * instead of redeclaring the shape, so a change to the real signature fails + * the fixture at compile time rather than drifting silently. + */ + +import type { addPartner } from '@/services/api' + +/** Shared by `addPartner` and `updateParentSettings` — both return the same + * `{ uuid, name, salary_type, employment_status }` wire shape. */ +export type PartnerMemberResponse = Awaited> + +export function makePartnerMemberResponse( + overrides: Partial = {}, +): PartnerMemberResponse { + return { + uuid: 'pd-b', + name: 'Partner', + salary_type: 'monthly', + employment_status: 'employed', + ...overrides, + } +}