From fe120ee2222f0f91fdc38cc80cc79ea9d02461d8 Mon Sep 17 00:00:00 2001 From: Jacob Hagstedt Date: Tue, 15 Sep 2026 04:17:22 +0200 Subject: [PATCH] Remove every `as any` from the test suite and enforce it ESLint turned `no-explicit-any` off for test files, and 219 casts had accumulated across 12 of them. Two shapes, very different risks: most were `(useWorkspace as any).mockReturnValue(...)`, mere laziness that `vi.mocked()` does properly; the rest cast partial fixtures onto API types, which erases the contract exactly where a test should enforce it. That second kind matters here specifically. This project's recurring bug is a field silently dropped between backend and frontend -- the hand-written mapping in models/plan.py has done it twice, and apiFetch mishandling structured errors was the same shape. A test that casts an incomplete fixture to `any` keeps passing through precisely that change. A typed fixture layer already existed at tests/fixtures/mockData.ts and was imported by exactly zero tests: the right tool was there and invisible, so everyone reached for a cast. It is now a set of `make*(overrides: Partial) => T` factories returning complete, valid objects, split by domain with a barrel. When a type gains a field, one factory fails to compile instead of thirty tests silently passing. With the count at zero the ESLint exemption is deleted, so this cannot regrow. Removing it immediately caught two `}: any` prop stubs that a grep for "as any" had missed; those mocks now use the real components' exported prop types. `as unknown as T` survives in five places, each a test deliberately feeding invalid input (an undefined breakdown, missing tierData, a bogus leave type). CLAUDE.md now documents that as the only accepted escape, and points at the factories. interface PollResponse in useScraperFetch.ts is exported so its shape can be derived rather than duplicated -- pollScraperSession declares Promise, so there was no other honest way to type it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL --- CLAUDE.md | 2 +- frontend/eslint.config.js | 6 - .../components/common/CalendarWeek.test.tsx | 6 +- frontend/src/hooks/useAnalytics.test.ts | 19 +- frontend/src/hooks/useDayOverrides.test.ts | 3 +- .../src/hooks/useOptimizationTips.test.ts | 14 +- frontend/src/hooks/useParentalLeave.test.ts | 29 +- frontend/src/hooks/usePlannedDays.test.ts | 87 ++- frontend/src/hooks/useScraperFetch.test.ts | 183 +++--- frontend/src/hooks/useScraperFetch.ts | 4 +- frontend/src/pages/Calendar.test.tsx | 543 ++++++++-------- frontend/src/pages/Dashboard.test.tsx | 590 +++++++++--------- frontend/src/pages/Plan.test.tsx | 370 +++++------ frontend/src/pages/Settings.test.tsx | 214 ++++--- frontend/src/pages/Tips.test.tsx | 94 +-- frontend/src/services/api.test.ts | 42 +- frontend/tests/fixtures/analytics.ts | 31 + frontend/tests/fixtures/calendar.ts | 26 + frontend/tests/fixtures/hooks.ts | 254 ++++++++ frontend/tests/fixtures/index.ts | 26 + frontend/tests/fixtures/mockData.ts | 255 ++++++-- frontend/tests/fixtures/plan.ts | 182 ++++++ frontend/tests/fixtures/scraper.ts | 37 ++ frontend/tests/fixtures/workspace.ts | 25 + 24 files changed, 1905 insertions(+), 1137 deletions(-) create mode 100644 frontend/tests/fixtures/analytics.ts create mode 100644 frontend/tests/fixtures/calendar.ts create mode 100644 frontend/tests/fixtures/hooks.ts create mode 100644 frontend/tests/fixtures/index.ts create mode 100644 frontend/tests/fixtures/plan.ts create mode 100644 frontend/tests/fixtures/scraper.ts create mode 100644 frontend/tests/fixtures/workspace.ts 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, + } +}