import { describe, it, expect, beforeEach, vi } from 'vitest'
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { BrowserRouter } from 'react-router-dom'
import { Dashboard } from './Dashboard'
import * as hooks from '@/hooks'
import { useWorkspace, useAuth } from '@/context'
vi.mock('@/hooks')
vi.mock('@/context')
vi.mock('@/services/api')
// Layout pulls in useAuth/useNavigate, which need a full AuthProvider/Router tree
// to work. Dashboard.tsx only cares that its content renders inside Layout, so
// stub Layout out — same pattern used by Calendar.test.tsx / Settings.test.tsx.
vi.mock('@/components/layout/Layout', () => ({
Layout: ({ children }: { children: React.ReactNode }) =>
{children}
,
}))
const renderWithRouter = (component: React.ReactElement) => {
return render({component})
}
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)
})
it('renders loading spinner initially', () => {
vi.mocked(useWorkspace).mockReturnValue({
...mockWorkspaceContext,
isLoading: true,
} as any)
renderWithRouter()
expect(screen.getByText(/Laddar/)).toBeInTheDocument()
})
it('shows add child form when no children', () => {
vi.mocked(useWorkspace).mockReturnValue({
...mockWorkspaceContext,
children: [],
selectedChildId: null,
} as any)
renderWithRouter()
expect(screen.getByText(/Du har inga barn registrerade ännu/)).toBeInTheDocument()
expect(screen.getByLabelText(/Barnets namn/)).toBeInTheDocument()
})
it('shows overview metrics when children exist', () => {
renderWithRouter()
expect(screen.getByTestId('overview-metrics')).toBeInTheDocument()
expect(screen.getByText(/Använda dagar/)).toBeInTheDocument()
})
it('shows parental leave breakdown when data exists', () => {
renderWithRouter()
expect(screen.getByTestId('parental-leave-breakdown')).toBeInTheDocument()
// ParentalLeaveBreakdown shows an explanatory note about reserved days
// rather than a "Reserverade dagar" heading/row — see
// ParentalLeaveBreakdown.test.tsx ("displays a reserved-days explanatory
// note instead of a countdown").
expect(screen.getByTestId('reserved-days-note')).toHaveTextContent(/reserverade/i)
})
it('renders FK fetch button', () => {
renderWithRouter()
expect(screen.getByRole('button', { name: /Hämta från Försäkringskassan/ })).toBeInTheDocument()
})
it('disables FK fetch button when no child selected', () => {
vi.mocked(useWorkspace).mockReturnValue({
...mockWorkspaceContext,
selectedChildId: null,
} as any)
renderWithRouter()
const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ })
expect(button).toBeDisabled()
})
it('enables FK fetch button when child selected', () => {
renderWithRouter()
const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ })
expect(button).not.toBeDisabled()
})
it('shows planned days breakdown', () => {
renderWithRouter()
expect(screen.getByTestId('days-breakdown')).toBeInTheDocument()
})
it('shows progress chart with real data', () => {
renderWithRouter()
expect(screen.getByTestId('progress-chart')).toBeInTheDocument()
})
it('shows Swedish section headings', () => {
renderWithRouter()
expect(screen.getByTestId('metrics-title')).toHaveTextContent(/Översikt/)
expect(screen.getAllByText(/Planerade dagar/)[0]).toBeInTheDocument()
expect(screen.getByText(/Återstående till 4 års ålder/)).toBeInTheDocument()
})
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)
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)
renderWithRouter()
expect(screen.getByText(/Ingen data hämtad ännu/)).toBeInTheDocument()
})
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)
renderWithRouter()
const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ })
await user.click(button)
expect(mockStart).toHaveBeenCalledWith('c-1')
})
it('shows FK button is not disabled by default with child selected', () => {
renderWithRouter()
const button = screen.getByRole('button', { name: /Hämta från Försäkringskassan/ })
expect(button).toBeInTheDocument()
expect(button).not.toBeDisabled()
})
it('submits add child form with valid data', async () => {
const user = userEvent.setup()
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)
const mockApi = await import('@/services/api')
vi.mocked(mockApi.createChild).mockImplementation(mockCreateChild)
renderWithRouter()
const nameInput = screen.getByLabelText(/Barnets namn/)
const birthDateInput = screen.getByTestId('birth-date-input')
const submitButton = screen.getByRole('button', { name: /Lägg till barn/ })
await user.type(nameInput, 'Test Child')
await user.type(birthDateInput, '2024-01-15')
await user.click(submitButton)
expect(mockCreateChild).toHaveBeenCalledWith({
workspace_id: 'ws-1',
name: 'Test Child',
birth_date: '2024-01-15',
})
expect(mockRefreshContext).toHaveBeenCalled()
})
it('shows error when add child form submission fails', async () => {
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)
const mockApi = await import('@/services/api')
vi.mocked(mockApi.createChild).mockImplementation(mockCreateChild)
renderWithRouter()
const nameInput = screen.getByLabelText(/Barnets namn/)
const birthDateInput = screen.getByTestId('birth-date-input')
const submitButton = screen.getByRole('button', { name: /Lägg till barn/ })
await user.type(nameInput, 'Test Child')
await user.type(birthDateInput, '2024-01-15')
await user.click(submitButton)
expect(screen.getByText(/API error/)).toBeInTheDocument()
})
it('disables submit button during form submission', async () => {
const user = userEvent.setup()
const mockCreateChild = vi.fn().mockImplementation(
() => new Promise(resolve => setTimeout(() => resolve({ id: 'c-2' }), 100))
)
vi.mocked(useWorkspace).mockReturnValue({
...mockWorkspaceContext,
children: [],
selectedChildId: null,
refreshContext: vi.fn().mockResolvedValue(undefined),
} as any)
const mockApi = await import('@/services/api')
vi.mocked(mockApi.createChild).mockImplementation(mockCreateChild)
renderWithRouter()
const nameInput = screen.getByLabelText(/Barnets namn/)
const birthDateInput = screen.getByTestId('birth-date-input')
const submitButton = screen.getByRole('button', { name: /Lägg till barn/ })
await user.type(nameInput, 'Test Child')
await user.type(birthDateInput, '2024-01-15')
await user.click(submitButton)
expect(submitButton).toBeDisabled()
})
it('validates add child form with workspace check', async () => {
vi.mocked(useWorkspace).mockReturnValue({
...mockWorkspaceContext,
children: [],
selectedChildId: null,
workspace: null,
refreshContext: vi.fn(),
} as any)
renderWithRouter()
const nameInput = screen.getByLabelText(/Barnets namn/)
const birthDateInput = screen.getByTestId('birth-date-input')
const user = userEvent.setup()
await user.type(nameInput, 'Test Child')
await user.type(birthDateInput, '2024-01-15')
const submitButton = screen.getByRole('button', { name: /Lägg till barn/ })
await user.click(submitButton)
expect(screen.getByText(/Fyll i alla fält/)).toBeInTheDocument()
})
it('has manual fetch button enabled when child selected', () => {
renderWithRouter()
const manualButton = screen.getByRole('button', { name: /Ange uppgifter manuellt/ })
expect(manualButton).toBeInTheDocument()
expect(manualButton).not.toBeDisabled()
})
it('disables manual fetch button when no child selected', () => {
vi.mocked(useWorkspace).mockReturnValue({
...mockWorkspaceContext,
selectedChildId: null,
} as any)
renderWithRouter()
const manualButton = screen.getByRole('button', { name: /Ange uppgifter manuellt/ })
expect(manualButton).toBeDisabled()
})
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)
renderWithRouter()
// QRCodeModal is rendered inside Dashboard
// Verify it was constructed with proper props
expect(screen.getByText(/Hämta från Försäkringskassan/)).toBeInTheDocument()
})
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)
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)
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)
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)
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)
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)
renderWithRouter()
expect(screen.getByTestId('metric-expiring-combined')).toBeInTheDocument()
expect(screen.queryByTestId('metric-expiring-parent-pd-1')).not.toBeInTheDocument()
})
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)
renderWithRouter()
const alexMetric = screen.getByTestId('metric-expiring-parent-pd-1')
const samMetric = screen.getByTestId('metric-expiring-parent-pd-2')
const combinedMetric = screen.getByTestId('metric-expiring-combined')
expect(alexMetric).toHaveTextContent('Alex')
expect(alexMetric).toHaveTextContent('60')
expect(samMetric).toHaveTextContent('Sam')
expect(samMetric).toHaveTextContent('44')
expect(combinedMetric).toHaveTextContent('104')
})
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)
renderWithRouter()
expect(screen.getByTestId('metric-expiring-parent-pd-1')).toHaveTextContent('Förälder 1')
expect(screen.getByTestId('metric-expiring-parent-pd-2')).toHaveTextContent('Förälder 2')
})
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)
renderWithRouter()
expect(screen.getByTestId('metric-expiring-parent-pd-1')).toHaveTextContent('Alex')
expect(screen.getByTestId('metric-expiring-parent-pd-unknown')).toHaveTextContent('Förälder 2')
})
})