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)
})
})
})