import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { render, screen, fireEvent, waitFor, act, within } from '@testing-library/react' import { Settings } from './Settings' import * as hooks from '@/hooks' import { useWorkspace, usePlanSyncContextOptional } from '@/context' import * as api from '@/services/api' import { ApiRequestError } from '@/services/errors' import { LanguageProvider } from '@/i18n' import type { BalanceFlowProps } from '@/components/balance/BalanceFlow' import type { CalendarExceptionsProps } from '@/components/calendar/CalendarExceptions' import type { PartnerInvitePanelProps } from '@/components/settings/PartnerInvitePanel' import { makeWorkspaceContextValue, makeWorkspaceMembersResult, makeVacationDaysResult, makeShowWeekNumbersTuple, makeChildSettingsResult, makeDayOverridesResult, makeCalendarDaysResult, makePreschoolPlanningDaysResult, makeAnalyticsResult, makeAge4Projection, makeParentYearBalance, makeVacationYearBalance, makePlanSyncContextValue, makeEvictionPreview, } 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}
, })) // `BalanceFlow` and `CalendarExceptions` each own an extensive test suite of // their own (BalanceFlow.test.tsx, CalendarExceptions.test.tsx) — Settings // only needs to prove it wires the right props through to each, not // re-exercise their internals. let lastBalanceFlowProps: BalanceFlowProps | null = null vi.mock('@/components/balance/BalanceFlow', () => ({ BalanceFlow: (props: BalanceFlowProps) => { lastBalanceFlowProps = props return
}, })) let lastCalendarExceptionsProps: CalendarExceptionsProps | null = null vi.mock('@/components/calendar/CalendarExceptions', () => ({ CalendarExceptions: (props: CalendarExceptionsProps) => { lastCalendarExceptionsProps = props return
}, })) // `PartnerInvitePanel` owns its own test suite (PartnerInvitePanel.test.tsx) // — Settings only needs to prove it's mounted with the right workspace id. let lastPartnerInvitePanelProps: PartnerInvitePanelProps | null = null vi.mock('@/components/settings/PartnerInvitePanel', () => ({ PartnerInvitePanel: (props: PartnerInvitePanelProps) => { lastPartnerInvitePanelProps = props return
}, })) 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 let mockBumpPlanVersion: ReturnType const mockCalendarWindow = { startYear: CURRENT_YEAR, endYear: CURRENT_YEAR + 3, minYear: 2026, maxYear: 2035, days: [], } beforeEach(() => { vi.clearAllMocks() lastBalanceFlowProps = null lastCalendarExceptionsProps = null lastPartnerInvitePanelProps = null 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) mockBumpPlanVersion = vi.fn() 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', bumpPlanVersion: mockBumpPlanVersion, }) ) // Default: no sync in flight / completed yet — the recompute button's // own tests override this to simulate `PlanSyncProvider`'s background // sync landing. vi.mocked(usePlanSyncContextOptional).mockReturnValue(makePlanSyncContextValue()) // 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, }) ) // Default: no analytics data (matches a fresh/no-child workspace) — the // vacation table's "at risk" column then shows 0, not blocking on it. // Tests exercising the at-risk column override this. vi.mocked(hooks.useAnalytics).mockReturnValue(makeAnalyticsResult({ data: null })) 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(hooks.usePreschoolPlanningDays).mockReturnValue(makePreschoolPlanningDaysResult()) // `useDebouncedCallback` is a real hook exercised end-to-end by its own // test suite and by `VacationDaysTable.test.tsx` — here it's mocked to // fire immediately (no timers to juggle) since these tests only care // that Settings wires the right save handler through, not about the // debounce timing itself. vi.mocked(hooks.useDebouncedCallback).mockImplementation( ((callback: (...args: unknown[]) => void) => callback) as typeof hooks.useDebouncedCallback, ) 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', 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.getByTestId('page-skeleton')).toBeInTheDocument() }) it('renders the four panels as h2 headings, in order, with the family panel\'s h3 subsections', () => { renderSettings() const h2s = screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent) expect(h2s).toEqual(['Barn & familj', 'Saldon', 'Kalender & språk', 'Undantag & röda dagar']) const h3s = screen.getAllByRole('heading', { level: 3 }).map((h) => h.textContent) expect(h3s).toEqual(['Din profil', 'Partner']) }) it('keeps the settings-page root testid', () => { renderSettings() expect(screen.getByTestId('settings-page')).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('surfaces the analytics-computed at-risk figure for a year, joined by member and year', () => { vi.mocked(hooks.useAnalytics).mockReturnValue( makeAnalyticsResult({ data: { workspaceId: 'ws-1', childId: 'c-1', parentProjections: [ makeAge4Projection({ personalDataUuid: 'pd-1', childId: 'c-1', yearBalances: [ makeParentYearBalance({ personalDataUuid: 'pd-1', childId: 'c-1', year: CURRENT_YEAR, vacation: makeVacationYearBalance({ year: CURRENT_YEAR, expiring: 6, expiringAge: 6, expiringOverCap: 0 }), }), ], }), ], combinedDaysUsed: 0, combinedDaysLeft: 0, combinedExpiringAtB4: 0, yearTotals: [], }, }) ) renderSettings() expect(screen.getByTestId(`vacation-at-risk-${CURRENT_YEAR}`)).toHaveTextContent('6') // A year with no matching yearBalance falls back to 0, not crashing. expect(screen.getByTestId(`vacation-at-risk-${CURRENT_YEAR + 1}`)).toHaveTextContent('0') }) it('autosaves an edited year with its total days, with no Save button', async () => { renderSettings() const year = CURRENT_YEAR + 1 expect(screen.queryByTestId(`vacation-save-${year}`)).not.toBeInTheDocument() const totalInput = screen.getByTestId(`vacation-total-days-${year}`) fireEvent.change(totalInput, { target: { value: '30' } }) await waitFor(() => expect(mockUpsertVacationDays).toHaveBeenCalledWith({ personalDataUuid: 'pd-1', workspaceId: 'ws-1', year, totalDays: 30, savedDaysIn: 0, }), ) }) it('does not disable the vacation inputs while the family panel is saving', async () => { renderSettings() fireEvent.click(screen.getByTestId('save-family-button')) // The vacation table has no shared saving flag with the family panel // any more — each row autosaves and reports its own status. expect(screen.getByTestId(`vacation-total-days-${CURRENT_YEAR}`)).not.toBeDisabled() await waitFor(() => expect(screen.getByTestId('save-family-button')).not.toBeDisabled()) }) it('renders one table per workspace member, self first, keyed on (personalDataUuid, year)', async () => { vi.mocked(hooks.useWorkspaceMembers).mockReturnValue( makeWorkspaceMembersResult({ data: [selfMember, partnerMember], refresh: mockRefreshMembers, }), ) vi.mocked(hooks.useVacationDays).mockReturnValue( makeVacationDaysResult({ data: [ ...mockVacationDays, { id: 'vd-2', personalDataUuid: 'pd-2', workspaceId: 'ws-1', year: CURRENT_YEAR, totalDays: 20, savedDaysIn: 3, createdAt: '2026-01-01T00:00:00', updatedAt: '2026-01-01T00:00:00', }, ], upsert: mockUpsertVacationDays, }), ) renderSettings() const selfTable = screen.getByTestId('vacation-days-table-pd-1') const partnerTable = screen.getByTestId('vacation-days-table-pd-2') expect( (within(selfTable).getByTestId(`vacation-total-days-${CURRENT_YEAR}`) as HTMLInputElement).value, ).toBe('10') expect( (within(partnerTable).getByTestId(`vacation-total-days-${CURRENT_YEAR}`) as HTMLInputElement).value, ).toBe('20') // Self's table must come first in document order. const tables = screen.getAllByTestId(/^vacation-days-table-/) expect(tables[0]).toBe(selfTable) expect(tables[1]).toBe(partnerTable) }) it("saves a partner's edit under the partner's own personalDataUuid, not the logged-in user's", async () => { vi.mocked(hooks.useWorkspaceMembers).mockReturnValue( makeWorkspaceMembersResult({ data: [selfMember, partnerMember], refresh: mockRefreshMembers, }), ) renderSettings() const partnerTable = screen.getByTestId('vacation-days-table-pd-2') fireEvent.change(within(partnerTable).getByTestId(`vacation-total-days-${CURRENT_YEAR}`), { target: { value: '18' }, }) await waitFor(() => expect(mockUpsertVacationDays).toHaveBeenCalledWith( expect.objectContaining({ personalDataUuid: 'pd-2', year: CURRENT_YEAR, totalDays: 18 }), ), ) }) }) describe('family panel — child + profile (one combined Save)', () => { 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 child details and the parent profile together with one Save button', async () => { renderSettings() const preschoolInput = screen.getByTestId('vacation-days-field') fireEvent.change(preschoolInput, { target: { value: '10' } }) fireEvent.change(screen.getByTestId('salary-type'), { target: { value: 'monthly' } }) fireEvent.change(screen.getByTestId('employment-status'), { target: { value: 'employed' } }) fireEvent.click(screen.getByTestId('save-family-button')) await waitFor(() => expect(mockUpdateChildDetails).toHaveBeenCalledWith({ birthDate: CHILD_BIRTH_DATE, preschoolPlanningDaysPerYear: 10, }), ) await waitFor(() => expect(api.updateParentSettings).toHaveBeenCalledWith('pd-1', { salary_type: 'monthly', employment_status: 'employed', }), ) // Salary type feeds the allocator — the parent-settings save must // re-plan, not just refetch. (Child-details bumps separately via // useChildSettings.update, exercised by useChildSettings.test.ts.) await waitFor(() => expect(mockBumpPlanVersion).toHaveBeenCalledTimes(1)) }) it('shows an error when the combined family save fails', async () => { vi.mocked(api.updateParentSettings).mockRejectedValue(new Error('save failed')) renderSettings() fireEvent.click(screen.getByTestId('save-family-button')) expect(await screen.findByTestId('save-message')).toHaveTextContent('save failed') expect(mockBumpPlanVersion).not.toHaveBeenCalled() }) }) describe('preschool planning days — per-year editing', () => { it('renders one row per vacation year, wired to usePreschoolPlanningDays', () => { renderSettings() for (let year = CURRENT_YEAR; year <= CURRENT_YEAR + 3; year++) { expect(screen.getByTestId(`preschool-year-row-${year}`)).toBeInTheDocument() } expect(hooks.usePreschoolPlanningDays).toHaveBeenCalledWith('c-1', CURRENT_YEAR) }) it('autosaves an edited planned-days value via setPlanned', async () => { const mockSetPlanned = vi.fn().mockResolvedValue(undefined) vi.mocked(hooks.usePreschoolPlanningDays).mockReturnValue( makePreschoolPlanningDaysResult({ setPlanned: mockSetPlanned }), ) renderSettings() fireEvent.change(screen.getByTestId(`preschool-planned-${CURRENT_YEAR}`), { target: { value: '7' } }) await waitFor(() => expect(mockSetPlanned).toHaveBeenCalledWith(7)) }) it('autosaves an edited taken-days value via recordTaken', async () => { const mockRecordTaken = vi.fn().mockResolvedValue(undefined) vi.mocked(hooks.usePreschoolPlanningDays).mockReturnValue( makePreschoolPlanningDaysResult({ recordTaken: mockRecordTaken }), ) renderSettings() fireEvent.change(screen.getByTestId(`preschool-taken-${CURRENT_YEAR}`), { target: { value: '4' } }) await waitFor(() => expect(mockRecordTaken).toHaveBeenCalledWith(4)) }) it('leaves takenDays blank (not 0) when it has never been recorded', () => { vi.mocked(hooks.usePreschoolPlanningDays).mockReturnValue( makePreschoolPlanningDaysResult({ data: { id: 'ppd-1', childId: 'c-1', year: CURRENT_YEAR, plannedDays: 6, takenDays: null, isDefault: false, createdAt: null, updatedAt: null }, }), ) renderSettings() expect((screen.getByTestId(`preschool-taken-${CURRENT_YEAR}`) as HTMLInputElement).value).toBe('') }) }) it('mounts PartnerInvitePanel in the family section with the workspace id', () => { renderSettings() expect(screen.getByTestId('partner-invite-panel-stub')).toBeInTheDocument() expect(lastPartnerInvitePanelProps).toEqual({ workspaceId: 'ws-1' }) }) 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('bumps planVersion after adding a partner, triggering a sync (a new partner changes who the allocator can assign days to)', async () => { renderSettings() fireEvent.change(screen.getByPlaceholderText('Partnerns namn'), { target: { value: 'Partner Name' } }) fireEvent.click(screen.getByTestId('add-partner-button')) await waitFor(() => expect(mockBumpPlanVersion).toHaveBeenCalledTimes(1)) }) it('does not bump planVersion when adding a partner fails', async () => { vi.mocked(api.addPartner).mockRejectedValue(new Error('boom')) renderSettings() fireEvent.change(screen.getByPlaceholderText('Partnerns namn'), { target: { value: 'Partner Name' } }) fireEvent.click(screen.getByTestId('add-partner-button')) await screen.findByTestId('save-message') expect(mockBumpPlanVersion).not.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('describes a linked member as an unclaimed placeholder, not an already-linked account', () => { renderSettings() // The old copy ("Din partner är kopplad...") falsely implies an // account link that doesn't exist for a placeholder member added via // "Lägg till partner" — replaced by the `invite` namespace's copy, // which correctly describes an invite as the way to connect one. expect(screen.getByText(/Den här platsen är förberedd åt din partner/)).toBeInTheDocument() expect(screen.queryByText('Din partner är kopplad till detta arbetsutrymme.')).not.toBeInTheDocument() }) 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('bumps planVersion after removing a partner, triggering a sync (fewer parents changes who the allocator can assign days to)', async () => { renderSettings() fireEvent.click(screen.getByTestId('remove-button-pd-2')) fireEvent.click(screen.getByTestId('remove-confirm-pd-2')) await waitFor(() => expect(mockBumpPlanVersion).toHaveBeenCalledTimes(1)) }) it('does not bump planVersion when removing a partner fails', async () => { vi.mocked(api.removeWorkspaceParent).mockRejectedValue(new Error('boom')) renderSettings() fireEvent.click(screen.getByTestId('remove-button-pd-2')) fireEvent.click(screen.getByTestId('remove-confirm-pd-2')) await screen.findByTestId('member-action-error-pd-2') expect(mockBumpPlanVersion).not.toHaveBeenCalled() }) it('does not bump planVersion for a rename — cosmetic, nothing for a re-plan to fix', 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(mockRefreshMembers).toHaveBeenCalled()) expect(mockBumpPlanVersion).not.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') }) }) describe('Saldon panel — BalanceFlow wiring', () => { it('renders BalanceFlow in panel mode with the workspace members and selected child', () => { renderSettings() expect(screen.getByTestId('balance-flow-stub')).toBeInTheDocument() expect(lastBalanceFlowProps).toMatchObject({ mode: 'panel', personalDataUuid: 'pd-1', workspaceId: 'ws-1', selectedChildId: 'c-1', }) expect(lastBalanceFlowProps?.members).toHaveLength(1) }) it("falls back to the workspace's first child when no child is selected yet", () => { 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() expect(lastBalanceFlowProps?.selectedChildId).toBe('c-9') }) it('refreshes members when BalanceFlow reports a save', () => { renderSettings() lastBalanceFlowProps?.onSaved?.() expect(mockRefreshMembers).toHaveBeenCalled() }) it('shows a loading message instead of BalanceFlow while members are loading', () => { vi.mocked(hooks.useWorkspaceMembers).mockReturnValue( makeWorkspaceMembersResult({ data: [], isLoading: true, refresh: mockRefreshMembers }) ) renderSettings() // Both the partner list (family panel) and the Saldon panel gate on // the same `membersLoading` flag, so the message appears twice. expect(screen.getAllByText('Laddar medlemmar…')).toHaveLength(2) expect(screen.queryByTestId('balance-flow-stub')).not.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, bumpPlanVersion: mockBumpPlanVersion, }) ) renderSettings() fireEvent.click(screen.getByTestId('take-pl-on-red-days-checkbox')) await waitFor(() => expect(api.updateWorkspace).toHaveBeenCalledWith('ws-1', { takePlOnRedDays: true }) ) expect(mockRefreshContext).toHaveBeenCalled() // Whether PL may be taken on red days changes what the allocator can // place leave on — the toggle must re-plan, not just refresh context. expect(mockBumpPlanVersion).toHaveBeenCalledTimes(1) }) 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') expect(mockBumpPlanVersion).not.toHaveBeenCalled() }) }) 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('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('manual recompute button', () => { it('bumps planVersion (never calling api.syncPlan directly) and shows a spinner label while waiting', () => { renderSettings() fireEvent.click(screen.getByTestId('recompute-plan-button')) expect(mockBumpPlanVersion).toHaveBeenCalledTimes(1) expect(api.syncPlan).not.toHaveBeenCalled() expect(screen.getByTestId('recompute-plan-button')).toHaveTextContent('Räknar om…') expect(screen.getByTestId('recompute-plan-button')).toBeDisabled() }) it('reports success once PlanSyncProvider\'s triggered sync lands with a newer proposal', async () => { const { rerender } = renderSettings() fireEvent.click(screen.getByTestId('recompute-plan-button')) // Simulates `PlanSyncProvider`'s background sync (triggered by the // `bumpPlanVersion()` call above) completing: its context value now // carries a newer `latestProposalVersion` and no eviction. vi.mocked(usePlanSyncContextOptional).mockReturnValue( makePlanSyncContextValue({ latestProposalVersion: 1, pendingEvictionPreview: null }), ) rerender() const message = await screen.findByTestId('save-message') expect(message).toHaveTextContent('Planen räknades om.') expect(screen.getByTestId('recompute-plan-button')).not.toBeDisabled() expect(screen.getByTestId('recompute-plan-button')).toHaveTextContent('Räkna om planen') }) it('reports that nothing was saved when the triggered sync would evict', async () => { const { rerender } = renderSettings() fireEvent.click(screen.getByTestId('recompute-plan-button')) vi.mocked(usePlanSyncContextOptional).mockReturnValue( makePlanSyncContextValue({ latestProposalVersion: 1, pendingEvictionPreview: makeEvictionPreview({ wouldEvict: true }), }), ) rerender() const message = await screen.findByTestId('save-message') expect(message).toHaveTextContent('Omräkningen skulle ta bort redan inplanerade dagar') }) it('ignores a version bump/eviction from an unrelated sync while idle', () => { const { rerender } = renderSettings() // No recompute click happened — `latestProposalVersion` moving on its // own (e.g. a vacation-day autosave elsewhere bumped `planVersion`) // must not surface a message this button never triggered. vi.mocked(usePlanSyncContextOptional).mockReturnValue( makePlanSyncContextValue({ latestProposalVersion: 1, pendingEvictionPreview: null }), ) rerender() expect(screen.queryByTestId('save-message')).not.toBeInTheDocument() }) }) describe('Undantag & röda dagar panel — CalendarExceptions wiring', () => { it('passes the fetched calendar window and override handlers through', () => { renderSettings() expect(screen.getByTestId('calendar-exceptions-stub')).toBeInTheDocument() expect(lastCalendarExceptionsProps).toMatchObject({ startYear: CURRENT_YEAR, years: 4, workspaceId: 'ws-1', }) expect(lastCalendarExceptionsProps?.onUpsertOverride).toBe(mockUpsertDayOverride) expect(lastCalendarExceptionsProps?.onRemoveOverride).toBe(mockRemoveDayOverride) }) it('requests the next 4-year window when paginating forward', () => { renderSettings() act(() => lastCalendarExceptionsProps?.onNextWindow()) expect(hooks.useCalendarDays).toHaveBeenLastCalledWith('ws-1', CURRENT_YEAR + 4, 4) }) it('requests the previous 4-year window when paginating backward', () => { renderSettings() act(() => lastCalendarExceptionsProps?.onPrevWindow()) expect(hooks.useCalendarDays).toHaveBeenLastCalledWith('ws-1', CURRENT_YEAR - 4, 4) }) }) })