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, } 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', 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( 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( 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() }) 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: 'Planering' })).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() expect(screen.getByRole('heading', { name: 'Partner' })).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('planning mode', () => { it('reflects the workspace planning mode', () => { renderSettings() expect(screen.getByTestId('planning-mode-solo')).toBeChecked() }) it('saves the selected planning mode together with the planning goal', async () => { renderSettings() fireEvent.click(screen.getByTestId('planning-mode-couple')) fireEvent.click(screen.getByTestId('save-planning-goal-button')) await waitFor(() => expect(api.updateWorkspace).toHaveBeenCalledWith('ws-1', { planningGoal: 'balanced', planningMode: 'couple', }), ) }) }) 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('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('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('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) }) }) })