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