Files
F-r-ldraledigplannerare/frontend/src/pages/Settings.test.tsx
T
jacobh2 b76fb7417e Keep plan warnings in step with the plan they describe
Warnings such as "vacation days at risk of being lost" kept showing after
the planner had already resolved them. The planner itself was firing
correctly -- PlanSyncProvider syncs on every plan-input change -- so the
fault was in the warnings, in two independent places.

The vacation-expiry warning was computed before allocation. It read
`vacation_forfeiting`, which the loader derives from already-committed
usage, while `_assign_tier3` deliberately spends at-risk vacation days
first precisely to avoid that forfeiture. The figure therefore counted
days the plan was in the middle of saving. `_vacation_expiry_warnings`
now subtracts the vacation days this run actually placed for that parent
and year, clamped at zero -- sound because the bank is FIFO, so a day
spent in year Y retires one expiring day one-for-one. The full
carry-forward simulation is still not duplicated inside the ledger.

The Dashboard then never refreshed them. Its proposal came from a
one-shot effect guarded by `proposal === null`, with no planVersion
dependency, so the card kept rendering the pre-replan warning until
remount -- while `syncPlan` was already returning a fresh proposal that
both call sites discarded. PlanSyncProvider now exposes that proposal
behind a monotonic counter, and usePlanProposal prefers it once any sync
has completed, keeping `propose()` for the first load.

Also adds a manual recompute button to Settings, which only bumps
planVersion and reports what the resulting sync did, and fixes partner
add/remove never bumping planVersion at all -- that gap meant gaining or
losing a parent never re-ran the allocator, though it changes who days
can be assigned to. Rename stays cosmetic.

Verified in the browser: a 2031 warning of 15 days dropped to 12 after
three vacation days were planned, without a reload.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014jPFUcCJ5AKXmPVC1anhXe
2026-09-20 14:26:50 +02:00

1027 lines
41 KiB
TypeScript

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 }) => <div>{children}</div>,
}))
// `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 <div data-testid="balance-flow-stub" />
},
}))
let lastCalendarExceptionsProps: CalendarExceptionsProps | null = null
vi.mock('@/components/calendar/CalendarExceptions', () => ({
CalendarExceptions: (props: CalendarExceptionsProps) => {
lastCalendarExceptionsProps = props
return <div data-testid="calendar-exceptions-stub" />
},
}))
// `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 <div data-testid="partner-invite-panel-stub" />
},
}))
function renderSettings() {
return render(<Settings />)
}
// The language selector tests exercise real locale switching, which needs an
// actual <LanguageProvider> 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(
<LanguageProvider>
<Settings />
</LanguageProvider>,
)
}
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<typeof vi.fn>
let mockUpdateChildDetails: ReturnType<typeof vi.fn>
let mockUpsertDayOverride: ReturnType<typeof vi.fn>
let mockRemoveDayOverride: ReturnType<typeof vi.fn>
let mockRefreshMembers: ReturnType<typeof vi.fn>
let mockBumpPlanVersion: ReturnType<typeof vi.fn>
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<string, string> = {}
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(<Settings />)
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(<Settings />)
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(<Settings />)
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)
})
})
})