import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { render, screen, fireEvent, waitFor } from '@testing-library/react' import { Settings } from './Settings' import * as hooks from '@/hooks' 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, makeParentalLeaveFetch, makeTierData, } from '../../tests/fixtures' vi.mock('@/hooks') vi.mock('@/context') vi.mock('@/services/api') // Layout pulls in useAuth/useNavigate, which need a full AuthProvider/Router tree // to work. Settings.tsx only cares that its content renders inside Layout, so // stub Layout out — same pattern used by Calendar.test.tsx. vi.mock('@/components/layout/Layout', () => ({ Layout: ({ children }: { children: React.ReactNode }) =>
{children}
, })) function renderSettings() { return render() } // The language selector tests exercise real locale switching, which needs an // actual in the tree (the other tests in this file render // Settings unwrapped and get the i18n module's non-reactive default context, // which is fine since they never touch the locale). function renderSettingsWithLanguageProvider() { return render( , ) } const CURRENT_YEAR = new Date().getFullYear() // Turns 4 three years from now -> vacation table should span CURRENT_YEAR..CURRENT_YEAR+3 const CHILD_BIRTH_DATE = `${CURRENT_YEAR - 1}-01-01` describe('Settings', () => { const mockVacationDays = [ { id: 'vd-1', personalDataUuid: 'pd-1', workspaceId: 'ws-1', year: CURRENT_YEAR, totalDays: 10, savedDaysIn: 2, createdAt: '2026-01-01T00:00:00', updatedAt: '2026-01-01T00:00:00', }, ] const mockChildDetails = { id: 'c-1', workspaceId: 'ws-1', name: 'Child 1', birthDate: CHILD_BIRTH_DATE, preschoolStartDate: null, preschoolPlanningDaysPerYear: 6, createdAt: '2026-01-01T00:00:00', } const selfMember = { uuid: 'pd-1', workspaceId: 'ws-1', name: 'Test Parent', salaryType: 'monthly' as const, employmentStatus: 'employed' as const, createdAt: '2026-01-01T00:00:00', } const partnerMember = { uuid: 'pd-2', workspaceId: 'ws-1', name: 'Sam', salaryType: 'monthly' as const, employmentStatus: 'employed' as const, createdAt: '2026-01-01T00:00:00', } let mockUpsertVacationDays: ReturnType let mockUpdateChildDetails: ReturnType let mockUpsertDayOverride: ReturnType let mockRemoveDayOverride: ReturnType let mockRefreshMembers: ReturnType const mockCalendarWindow = { startYear: CURRENT_YEAR, endYear: CURRENT_YEAR + 3, minYear: 2026, maxYear: 2035, days: [], } beforeEach(() => { vi.clearAllMocks() mockUpsertVacationDays = vi.fn().mockResolvedValue(undefined) mockUpdateChildDetails = vi.fn().mockResolvedValue(undefined) mockUpsertDayOverride = vi.fn().mockResolvedValue(undefined) mockRemoveDayOverride = vi.fn().mockResolvedValue(undefined) mockRefreshMembers = vi.fn().mockResolvedValue(undefined) vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ workspace: { id: 'ws-1', name: 'Test Workspace', 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( 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.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) // Default: nothing saved yet for whoever's balance form gets opened — // `useLatestBalance` (a real hook, imported directly by Settings.tsx to // bypass the `vi.mock('@/hooks')` above) falls back to the form's // preset. Tests that care about a saved balance override this. vi.mocked(api.getParentalLeave).mockResolvedValue(null) }) it('renders loading spinner while workspace loads', () => { vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ workspace: { id: 'ws-1', name: 'Test Workspace', 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() }) it('renders consistently Swedish section headings', () => { renderSettings() expect(screen.getByRole('heading', { name: 'Barn' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Din profil' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Partner' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Semesterdagar' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Kalender' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Undantag i kalendern' })).toBeInTheDocument() expect(screen.getByRole('heading', { name: 'Röda dagar' })).toBeInTheDocument() }) it('renders the partner section where the removed planning-mode selector used to be, right after the profile section, and never renders a planning-mode selector', () => { renderSettings() const headings = screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent) const profileIndex = headings.indexOf('Din profil') const partnerIndex = headings.indexOf('Partner') const vacationIndex = headings.indexOf('Semesterdagar') expect(profileIndex).toBeGreaterThanOrEqual(0) expect(partnerIndex).toBe(profileIndex + 1) expect(vacationIndex).toBe(partnerIndex + 1) expect(screen.queryByTestId('planning-mode-selector')).not.toBeInTheDocument() expect(screen.queryByRole('heading', { name: 'Planering' })).not.toBeInTheDocument() }) describe('vacation days table', () => { it('renders one row per year from the current year through the year the child turns 4', () => { renderSettings() for (let year = CURRENT_YEAR; year <= CURRENT_YEAR + 3; year++) { expect(screen.getByTestId(`vacation-year-row-${year}`)).toBeInTheDocument() } expect(screen.queryByTestId(`vacation-year-row-${CURRENT_YEAR + 4}`)).not.toBeInTheDocument() }) it('prefills a year that already has saved data', () => { renderSettings() const totalInput = screen.getByTestId(`vacation-total-days-${CURRENT_YEAR}`) as HTMLInputElement const savedInput = screen.getByTestId(`vacation-saved-days-${CURRENT_YEAR}`) as HTMLInputElement expect(totalInput.value).toBe('10') expect(savedInput.value).toBe('2') }) it('defaults a year with no saved data to 25 vacation days', () => { renderSettings() const totalInput = screen.getByTestId(`vacation-total-days-${CURRENT_YEAR + 1}`) as HTMLInputElement expect(totalInput.value).toBe('25') }) it('saves the edited year with its total days when Spara is clicked', async () => { renderSettings() const year = CURRENT_YEAR + 1 const totalInput = screen.getByTestId(`vacation-total-days-${year}`) fireEvent.change(totalInput, { target: { value: '30' } }) fireEvent.click(screen.getByTestId(`vacation-save-${year}`)) await waitFor(() => expect(mockUpsertVacationDays).toHaveBeenCalledWith({ personalDataUuid: 'pd-1', workspaceId: 'ws-1', year, totalDays: 30, savedDaysIn: 0, }), ) }) it('saves saved-days-in alongside total days', async () => { renderSettings() const savedInput = screen.getByTestId(`vacation-saved-days-${CURRENT_YEAR}`) fireEvent.change(savedInput, { target: { value: '7' } }) fireEvent.click(screen.getByTestId(`vacation-save-${CURRENT_YEAR}`)) await waitFor(() => expect(mockUpsertVacationDays).toHaveBeenCalledWith({ personalDataUuid: 'pd-1', workspaceId: 'ws-1', year: CURRENT_YEAR, totalDays: 10, savedDaysIn: 7, }), ) }) }) describe('child settings', () => { it('prefills birth date and preschool planning days from the child', () => { renderSettings() const birthInput = screen.getByTestId('birth-date-input') as HTMLInputElement const preschoolInput = screen.getByTestId('vacation-days-field') as HTMLInputElement expect(birthInput.value).toBe(CHILD_BIRTH_DATE) expect(preschoolInput.value).toBe('6') }) it('saves birth date and preschool planning days together', async () => { renderSettings() const preschoolInput = screen.getByTestId('vacation-days-field') fireEvent.change(preschoolInput, { target: { value: '10' } }) fireEvent.click(screen.getByTestId('save-child-button')) await waitFor(() => expect(mockUpdateChildDetails).toHaveBeenCalledWith({ birthDate: CHILD_BIRTH_DATE, preschoolPlanningDaysPerYear: 10, }), ) }) }) it('saves parent profile fields', async () => { renderSettings() fireEvent.change(screen.getByTestId('salary-type'), { target: { value: 'monthly' } }) fireEvent.change(screen.getByTestId('employment-status'), { target: { value: 'employed' } }) fireEvent.click(screen.getByTestId('save-parent-button')) await waitFor(() => expect(api.updateParentSettings).toHaveBeenCalledWith('pd-1', { salary_type: 'monthly', employment_status: 'employed', }), ) }) it('adds a partner', async () => { renderSettings() fireEvent.change(screen.getByPlaceholderText('Partnerns namn'), { target: { value: 'Partner Name' } }) fireEvent.click(screen.getByTestId('add-partner-button')) await waitFor(() => expect(api.addPartner).toHaveBeenCalledWith('ws-1', 'Partner Name')) await waitFor(() => expect(mockRefreshMembers).toHaveBeenCalled()) }) it('shows an add-partner error without a raw message_key when the name is rejected', async () => { vi.mocked(api.addPartner).mockRejectedValue( new ApiRequestError(422, { code: 'workspace.parents.invalidName', message: 'workspace.parents.invalidName', details: { messageKey: 'workspace.parents.invalidName', data: {} }, }), ) renderSettings() fireEvent.change(screen.getByPlaceholderText('Partnerns namn'), { target: { value: 'X' } }) fireEvent.click(screen.getByTestId('add-partner-button')) const message = await screen.findByTestId('save-message') expect(message).toHaveTextContent('Partnerns namn är ogiltigt.') }) describe('parent balance ("Dagar kvar")', () => { it('opens the balance form for a solo workspace with a 480-day total', async () => { renderSettings() fireEvent.click(screen.getByTestId('settings-balance-pd-1')) expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument() }) it('prefills the form with the parent\'s saved balance instead of the preset', async () => { vi.mocked(api.getParentalLeave).mockResolvedValue( makeParentalLeaveFetch({ personalDataUuid: 'pd-1', childId: 'c-1', dubbeldagarLeft: 12, reservedDaysLeft: 34, tierData: makeTierData({ sjukpenningLeft: 111, lagstaNivaLeft: 22 }), }), ) renderSettings() fireEvent.click(screen.getByTestId('settings-balance-pd-1')) expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument() expect((screen.getByLabelText(/Sjukpenningnivå/) as HTMLInputElement).value).toBe('111') expect((screen.getByLabelText(/Lägstanivå/) as HTMLInputElement).value).toBe('22') expect((screen.getByLabelText(/Dubbeldagar/) as HTMLInputElement).value).toBe('12') }) it('falls back to the preset when the parent has no saved balance yet', async () => { vi.mocked(api.getParentalLeave).mockResolvedValue(null) renderSettings() fireEvent.click(screen.getByTestId('settings-balance-pd-1')) expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument() // Solo workspace -> 480-day preset, split 390:90. expect((screen.getByLabelText(/Sjukpenningnivå/) as HTMLInputElement).value).toBe('390') expect((screen.getByLabelText(/Lägstanivå/) as HTMLInputElement).value).toBe('90') }) it("falls back to the workspace's first child when no child is selected yet", async () => { vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' }, workspaceId: 'ws-1', children: [{ id: 'c-9', name: 'Child 9', birth_date: CHILD_BIRTH_DATE }], selectedChildId: null, personalDataUuid: 'pd-1', parentName: 'Test Parent', }) ) renderSettings() fireEvent.click(screen.getByTestId('settings-balance-pd-1')) await screen.findByText('Dina dagar kvar') const saveButtons = screen.getAllByRole('button', { name: 'Spara' }) fireEvent.click(saveButtons[saveButtons.length - 1]!) await waitFor(() => expect(api.saveManualFetch).toHaveBeenCalledWith( expect.objectContaining({ personalDataUuid: 'pd-1', workspaceId: 'ws-1', childId: 'c-9', source: 'manual' }) ) ) }) it('does nothing when there is no child to attach the balance to', async () => { vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' }, workspaceId: 'ws-1', children: [], selectedChildId: null, personalDataUuid: 'pd-1', parentName: 'Test Parent', }) ) renderSettings() fireEvent.click(screen.getByTestId('settings-balance-pd-1')) await screen.findByText('Dina dagar kvar') const saveButtons = screen.getAllByRole('button', { name: 'Spara' }) fireEvent.click(saveButtons[saveButtons.length - 1]!) expect(api.saveManualFetch).not.toHaveBeenCalled() }) it('shows an error when saving a balance fails', async () => { vi.mocked(api.saveManualFetch).mockRejectedValue(new Error('balance save failed')) renderSettings() fireEvent.click(screen.getByTestId('settings-balance-pd-1')) await screen.findByText('Dina dagar kvar') const saveButtons = screen.getAllByRole('button', { name: 'Spara' }) fireEvent.click(saveButtons[saveButtons.length - 1]!) expect(await screen.findByText('balance save failed')).toBeInTheDocument() }) }) describe('workspace members', () => { beforeEach(() => { vi.mocked(hooks.useWorkspaceMembers).mockReturnValue( makeWorkspaceMembersResult({ data: [selfMember, partnerMember], refresh: mockRefreshMembers, }) ) }) it('shows the linked partner and hides the add-partner form', () => { renderSettings() expect(screen.getByTestId('member-name-pd-2')).toHaveTextContent('Sam') expect(screen.queryByTestId('add-partner-button')).not.toBeInTheDocument() }) it('shows a "you" badge on the logged-in user\'s own row', () => { renderSettings() expect(screen.getByTestId('member-name-pd-1')).toHaveTextContent('Test Parent') expect(screen.getByTestId('member-name-pd-1')).toHaveTextContent('(Du)') }) it('does not offer a remove button on the logged-in user\'s own row', () => { renderSettings() expect(screen.queryByTestId('remove-button-pd-1')).not.toBeInTheDocument() }) it('renames a member and refreshes the member list', async () => { renderSettings() fireEvent.click(screen.getByTestId('rename-button-pd-2')) fireEvent.change(screen.getByTestId('rename-input-pd-2'), { target: { value: 'Samuel' } }) fireEvent.click(screen.getByTestId('rename-save-pd-2')) await waitFor(() => expect(api.renameWorkspaceParent).toHaveBeenCalledWith('ws-1', 'pd-2', 'Samuel'), ) await waitFor(() => expect(mockRefreshMembers).toHaveBeenCalled()) }) it('allows renaming the logged-in user\'s own profile too', () => { renderSettings() fireEvent.click(screen.getByTestId('rename-button-pd-1')) expect(screen.getByTestId('rename-input-pd-1')).toBeInTheDocument() }) it('cancels a rename without calling the API', () => { renderSettings() fireEvent.click(screen.getByTestId('rename-button-pd-2')) fireEvent.change(screen.getByTestId('rename-input-pd-2'), { target: { value: 'Something else' } }) fireEvent.click(screen.getByTestId('rename-cancel-pd-2')) expect(screen.queryByTestId('rename-input-pd-2')).not.toBeInTheDocument() expect(api.renameWorkspaceParent).not.toHaveBeenCalled() }) it('requires an in-app confirmation before removing a member', async () => { renderSettings() fireEvent.click(screen.getByTestId('remove-button-pd-2')) expect(api.removeWorkspaceParent).not.toHaveBeenCalled() expect(screen.getByTestId('remove-confirm-pd-2')).toBeInTheDocument() fireEvent.click(screen.getByTestId('remove-confirm-pd-2')) await waitFor(() => expect(api.removeWorkspaceParent).toHaveBeenCalledWith('ws-1', 'pd-2'), ) }) it('cancelling the remove confirmation does not call the API', () => { renderSettings() fireEvent.click(screen.getByTestId('remove-button-pd-2')) fireEvent.click(screen.getByTestId('remove-cancel-pd-2')) expect(screen.queryByTestId('remove-confirm-pd-2')).not.toBeInTheDocument() expect(api.removeWorkspaceParent).not.toHaveBeenCalled() }) it('refreshes the member list after a successful removal', async () => { renderSettings() fireEvent.click(screen.getByTestId('remove-button-pd-2')) fireEvent.click(screen.getByTestId('remove-confirm-pd-2')) await waitFor(() => expect(mockRefreshMembers).toHaveBeenCalled()) }) it('shows the has-login refusal without a raw message_key', async () => { vi.mocked(api.removeWorkspaceParent).mockRejectedValue( new ApiRequestError(422, { code: 'workspace.parents.remove.hasLogin', message: 'workspace.parents.remove.hasLogin', details: { messageKey: 'workspace.parents.remove.hasLogin', data: {} }, }), ) renderSettings() fireEvent.click(screen.getByTestId('remove-button-pd-2')) fireEvent.click(screen.getByTestId('remove-confirm-pd-2')) const message = await screen.findByTestId('member-action-error-pd-2') expect(message).toHaveTextContent('den här personen har ett eget konto och egen inloggning') expect(message).not.toHaveTextContent('workspace.parents.remove.hasLogin') }) it('names each blocker on a hasDependentData refusal, not a raw message_key', async () => { vi.mocked(api.removeWorkspaceParent).mockRejectedValue( new ApiRequestError(422, { code: 'workspace.parents.remove.hasDependentData', message: 'workspace.parents.remove.hasDependentData', details: { messageKey: 'workspace.parents.remove.hasDependentData', data: { blockers: ['leave_wishes', 'planned_leave'] }, }, }), ) renderSettings() fireEvent.click(screen.getByTestId('remove-button-pd-2')) fireEvent.click(screen.getByTestId('remove-confirm-pd-2')) const message = await screen.findByTestId('member-action-error-pd-2') expect(message).toHaveTextContent('önskemål om ledighet') expect(message).toHaveTextContent('planerade lediga dagar') expect(message).not.toHaveTextContent('hasDependentData') }) it('shows the remove-self refusal without a raw message_key', async () => { // Defence in depth: the UI never renders a remove button for the // caller's own row, but the handler still routes a self-removal // refusal from the backend through the same safe error mapping. vi.mocked(api.removeWorkspaceParent).mockRejectedValue( new ApiRequestError(422, { code: 'workspace.parents.remove.self', message: 'workspace.parents.remove.self', details: { messageKey: 'workspace.parents.remove.self', data: {} }, }), ) renderSettings() fireEvent.click(screen.getByTestId('remove-button-pd-2')) fireEvent.click(screen.getByTestId('remove-confirm-pd-2')) const message = await screen.findByTestId('member-action-error-pd-2') expect(message).not.toHaveTextContent('workspace.parents.remove.self') }) it('opens the balance form for the clicked member and saves with that member\'s uuid', async () => { vi.mocked(api.saveManualFetch).mockResolvedValue({ id: 'plf-1', personalDataUuid: 'pd-2', workspaceId: 'ws-1', childId: 'c-1', source: 'manual', fetchedAt: '2026-01-01', totalDays: 240, daysUsed: 0, daysLeft: 240, reservedDaysTotal: 45, reservedDaysLeft: 45, transferableDaysLeft: 0, dubbeldagarLeft: 60, tierData: { sjukpenningTotal: 195, sjukpenningUsed: 0, sjukpenningLeft: 195, lagstaNivaTotal: 45, lagstaNivaUsed: 0, lagstaNivaLeft: 45, }, createdAt: '2026-01-01', updatedAt: '2026-01-01', }) renderSettings() fireEvent.click(screen.getByTestId('settings-balance-pd-2')) expect(await screen.findByText('Sams dagar kvar')).toBeInTheDocument() // The vacation-days table above also has per-year "Spara" buttons — // the balance form's is the last one rendered (it's mounted at the // very end of the page). const saveButtons = screen.getAllByRole('button', { name: 'Spara' }) fireEvent.click(saveButtons[saveButtons.length - 1]!) await waitFor(() => expect(api.saveManualFetch).toHaveBeenCalledWith( expect.objectContaining({ personalDataUuid: 'pd-2', workspaceId: 'ws-1', childId: 'c-1', source: 'manual' }) ) ) }) it('opens the balance form for the logged-in user\'s own row too', async () => { renderSettings() fireEvent.click(screen.getByTestId('settings-balance-pd-1')) expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument() }) }) it('toggles week numbers', () => { const setShowWeekNumbers = vi.fn() vi.mocked(hooks.useShowWeekNumbers).mockReturnValue(makeShowWeekNumbersTuple(false, setShowWeekNumbers)) renderSettings() fireEvent.click(screen.getByTestId('show-week-numbers-checkbox')) expect(setShowWeekNumbers).toHaveBeenCalledWith(true) }) describe('take PL on red days', () => { it('renders unchecked by default', () => { renderSettings() expect(screen.getByTestId('take-pl-on-red-days-checkbox')).not.toBeChecked() }) it('renders checked when the workspace has it enabled', () => { vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01', takePlOnRedDays: true, }, workspaceId: 'ws-1', children: [{ id: 'c-1', name: 'Child 1', birth_date: CHILD_BIRTH_DATE }], selectedChildId: 'c-1', personalDataUuid: 'pd-1', parentName: 'Test Parent', }) ) renderSettings() expect(screen.getByTestId('take-pl-on-red-days-checkbox')).toBeChecked() }) it('saves the setting and refreshes the workspace context on toggle', async () => { vi.mocked(api.updateWorkspace).mockResolvedValue({ id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01', takePlOnRedDays: true, }) const mockRefreshContext = vi.fn().mockResolvedValue(undefined) vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ workspace: { id: 'ws-1', name: 'Test Workspace', 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', refreshContext: mockRefreshContext, }) ) renderSettings() fireEvent.click(screen.getByTestId('take-pl-on-red-days-checkbox')) await waitFor(() => expect(api.updateWorkspace).toHaveBeenCalledWith('ws-1', { takePlOnRedDays: true }) ) expect(mockRefreshContext).toHaveBeenCalled() }) it('shows an error message when saving fails', async () => { vi.mocked(api.updateWorkspace).mockRejectedValue(new Error('network down')) renderSettings() fireEvent.click(screen.getByTestId('take-pl-on-red-days-checkbox')) expect(await screen.findByTestId('save-message')).toHaveTextContent('network down') }) }) describe('language selector', () => { // Node's own experimental `localStorage` global shadows jsdom's working // implementation in this environment and throws on every call — stub a // real in-memory Storage, matching the idiom used in // WorkspaceContext.test.tsx and useCalendarPreferences.test.ts. function createStorageMock(): Storage { let store: Record = {} return { getItem: (key: string) => (key in store ? store[key]! : null), setItem: (key: string, value: string) => { store[key] = value }, removeItem: (key: string) => { delete store[key] }, clear: () => { store = {} }, key: (index: number) => Object.keys(store)[index] ?? null, get length() { return Object.keys(store).length }, } } beforeEach(() => { vi.stubGlobal('localStorage', createStorageMock()) }) afterEach(() => { vi.unstubAllGlobals() }) it('is labelled understandably regardless of the active language', () => { renderSettingsWithLanguageProvider() expect(screen.getByText('Språk / Language')).toBeInTheDocument() expect(screen.getByText('Välj språk / Select language')).toBeInTheDocument() }) it('offers automatic, Swedish, and English options', () => { renderSettingsWithLanguageProvider() const select = screen.getByTestId('language-select') as HTMLSelectElement const optionValues = Array.from(select.options).map((o) => o.value) expect(optionValues).toEqual(['auto', 'sv', 'en']) }) it('defaults to "auto" when no explicit preference is stored', () => { renderSettingsWithLanguageProvider() const select = screen.getByTestId('language-select') as HTMLSelectElement expect(select.value).toBe('auto') }) it('switching to English updates the UI immediately, without a reload', () => { renderSettingsWithLanguageProvider() expect(screen.getByRole('heading', { name: 'Inställningar' })).toBeInTheDocument() fireEvent.change(screen.getByTestId('language-select'), { target: { value: 'en' } }) expect(screen.getByRole('heading', { name: 'Settings' })).toBeInTheDocument() expect(screen.queryByRole('heading', { name: 'Inställningar' })).not.toBeInTheDocument() }) it('persists the explicit choice to localStorage', () => { renderSettingsWithLanguageProvider() fireEvent.change(screen.getByTestId('language-select'), { target: { value: 'en' } }) expect(localStorage.getItem('language_preference')).toBe('en') }) it('going back to "Automatic" clears the stored preference', () => { renderSettingsWithLanguageProvider() fireEvent.change(screen.getByTestId('language-select'), { target: { value: 'en' } }) fireEvent.change(screen.getByTestId('language-select'), { target: { value: 'auto' } }) expect(localStorage.getItem('language_preference')).toBeNull() }) }) 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( 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() expect(screen.getByTestId('red-days-range')).toHaveTextContent(`${CURRENT_YEAR}–${CURRENT_YEAR + 3}`) expect(screen.getByTestId(`red-days-item-${CURRENT_YEAR}-01-01`)).toHaveTextContent('Nyårsdagen') }) it('requests the next 4-year window when paginating forward', () => { renderSettings() fireEvent.click(screen.getByTestId('red-days-next')) expect(hooks.useCalendarDays).toHaveBeenLastCalledWith('ws-1', CURRENT_YEAR + 4, 4) }) 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( makeCalendarDaysResult({ data: { ...mockCalendarWindow, minYear: 2000 }, }) ) renderSettings() fireEvent.click(screen.getByTestId('red-days-prev')) expect(hooks.useCalendarDays).toHaveBeenLastCalledWith('ws-1', CURRENT_YEAR - 4, 4) }) it('wires the toggle button through to the shared day-overrides hook', async () => { vi.mocked(hooks.useCalendarDays).mockReturnValue( makeCalendarDaysResult({ data: { ...mockCalendarWindow, days: [ { date: `${CURRENT_YEAR}-01-01`, name: 'Nyårsdagen', dayType: 'red_day', isOverride: false, overrideIsWorkingDay: null, isFree: true, }, ], }, }) ) renderSettings() fireEvent.click(screen.getByTestId(`red-days-toggle-${CURRENT_YEAR}-01-01`)) await waitFor(() => { expect(mockUpsertDayOverride).toHaveBeenCalledWith({ workspaceId: 'ws-1', date: `${CURRENT_YEAR}-01-01`, isWorkingDay: true, note: null, }) }) }) }) })