Files
F-r-ldraledigplannerare/frontend/src/pages/Settings.test.tsx
T
jacobh2 663d5fcd92 Flip a single holiday to a working day by clicking it
The status on the right of each public holiday is now a button. Clicking
it writes an override for that one date, and clicking it back removes the
override rather than storing a row that agrees with the almanac. Because
an override is keyed on an absolute date, the same holiday in other years
is left alone — which is what was asked for and needed no schema change.

The separate badge saying an override exists stays, so the fact that a
day has been changed is not carried by the button alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ei7gCZzdd751uZtPzb1Pep
2026-09-19 14:38:45 +02:00

877 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
makeParentalLeaveFetch,
makeTierData,
} 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>,
}))
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>
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',
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.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)
// Default: nothing saved yet for whoever's balance form gets opened —
// `useLatestBalance` (a real hook, imported directly by Settings.tsx to
// bypass the `vi.mock('@/hooks')` above) falls back to the form's
// preset. Tests that care about a saved balance override this.
vi.mocked(api.getParentalLeave).mockResolvedValue(null)
})
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.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: 'Partner' })).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()
})
it('renders the partner section where the removed planning-mode selector used to be, right after the profile section, and never renders a planning-mode selector', () => {
renderSettings()
const headings = screen.getAllByRole('heading', { level: 2 }).map((h) => h.textContent)
const profileIndex = headings.indexOf('Din profil')
const partnerIndex = headings.indexOf('Partner')
const vacationIndex = headings.indexOf('Semesterdagar')
expect(profileIndex).toBeGreaterThanOrEqual(0)
expect(partnerIndex).toBe(profileIndex + 1)
expect(vacationIndex).toBe(partnerIndex + 1)
expect(screen.queryByTestId('planning-mode-selector')).not.toBeInTheDocument()
expect(screen.queryByRole('heading', { name: 'Planering' })).not.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('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('parent balance ("Dagar kvar")', () => {
it('opens the balance form for a solo workspace with a 480-day total', async () => {
renderSettings()
fireEvent.click(screen.getByTestId('settings-balance-pd-1'))
expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument()
})
it('prefills the form with the parent\'s saved balance instead of the preset', async () => {
vi.mocked(api.getParentalLeave).mockResolvedValue(
makeParentalLeaveFetch({
personalDataUuid: 'pd-1',
childId: 'c-1',
dubbeldagarLeft: 12,
reservedDaysLeft: 34,
tierData: makeTierData({ sjukpenningLeft: 111, lagstaNivaLeft: 22 }),
}),
)
renderSettings()
fireEvent.click(screen.getByTestId('settings-balance-pd-1'))
expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument()
expect((screen.getByLabelText(/Sjukpenningnivå/) as HTMLInputElement).value).toBe('111')
expect((screen.getByLabelText(/Lägstanivå/) as HTMLInputElement).value).toBe('22')
expect((screen.getByLabelText(/Dubbeldagar/) as HTMLInputElement).value).toBe('12')
})
it('falls back to the preset when the parent has no saved balance yet', async () => {
vi.mocked(api.getParentalLeave).mockResolvedValue(null)
renderSettings()
fireEvent.click(screen.getByTestId('settings-balance-pd-1'))
expect(await screen.findByText('Dina dagar kvar')).toBeInTheDocument()
// Solo workspace -> 480-day preset, split 390:90.
expect((screen.getByLabelText(/Sjukpenningnivå/) as HTMLInputElement).value).toBe('390')
expect((screen.getByLabelText(/Lägstanivå/) as HTMLInputElement).value).toBe('90')
})
it("falls back to the workspace's first child when no child is selected yet", async () => {
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()
fireEvent.click(screen.getByTestId('settings-balance-pd-1'))
await screen.findByText('Dina dagar kvar')
const saveButtons = screen.getAllByRole('button', { name: 'Spara' })
fireEvent.click(saveButtons[saveButtons.length - 1]!)
await waitFor(() =>
expect(api.saveManualFetch).toHaveBeenCalledWith(
expect.objectContaining({ personalDataUuid: 'pd-1', workspaceId: 'ws-1', childId: 'c-9', source: 'manual' })
)
)
})
it('does nothing when there is no child to attach the balance to', async () => {
vi.mocked(useWorkspace).mockReturnValue(
makeWorkspaceContextValue({
workspace: { id: 'ws-1', name: 'Test Workspace', createdAt: '2026-01-01' },
workspaceId: 'ws-1',
children: [],
selectedChildId: null,
personalDataUuid: 'pd-1',
parentName: 'Test Parent',
})
)
renderSettings()
fireEvent.click(screen.getByTestId('settings-balance-pd-1'))
await screen.findByText('Dina dagar kvar')
const saveButtons = screen.getAllByRole('button', { name: 'Spara' })
fireEvent.click(saveButtons[saveButtons.length - 1]!)
expect(api.saveManualFetch).not.toHaveBeenCalled()
})
it('shows an error when saving a balance fails', async () => {
vi.mocked(api.saveManualFetch).mockRejectedValue(new Error('balance save failed'))
renderSettings()
fireEvent.click(screen.getByTestId('settings-balance-pd-1'))
await screen.findByText('Dina dagar kvar')
const saveButtons = screen.getAllByRole('button', { name: 'Spara' })
fireEvent.click(saveButtons[saveButtons.length - 1]!)
expect(await screen.findByText('balance save failed')).toBeInTheDocument()
})
})
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('opens the balance form for the clicked member and saves with that member\'s uuid', async () => {
vi.mocked(api.saveManualFetch).mockResolvedValue({
id: 'plf-1',
personalDataUuid: 'pd-2',
workspaceId: 'ws-1',
childId: 'c-1',
source: 'manual',
fetchedAt: '2026-01-01',
totalDays: 240,
daysUsed: 0,
daysLeft: 240,
reservedDaysTotal: 45,
reservedDaysLeft: 45,
transferableDaysLeft: 0,
dubbeldagarLeft: 60,
tierData: {
sjukpenningTotal: 195,
sjukpenningUsed: 0,
sjukpenningLeft: 195,
lagstaNivaTotal: 45,
lagstaNivaUsed: 0,
lagstaNivaLeft: 45,
},
createdAt: '2026-01-01',
updatedAt: '2026-01-01',
})
renderSettings()
fireEvent.click(screen.getByTestId('settings-balance-pd-2'))
expect(await screen.findByText('Sams dagar kvar')).toBeInTheDocument()
// The vacation-days table above also has per-year "Spara" buttons —
// the balance form's is the last one rendered (it's mounted at the
// very end of the page).
const saveButtons = screen.getAllByRole('button', { name: 'Spara' })
fireEvent.click(saveButtons[saveButtons.length - 1]!)
await waitFor(() =>
expect(api.saveManualFetch).toHaveBeenCalledWith(
expect.objectContaining({ personalDataUuid: 'pd-2', workspaceId: 'ws-1', childId: 'c-1', source: 'manual' })
)
)
})
it('opens the balance form for the logged-in user\'s own row too', async () => {
renderSettings()
fireEvent.click(screen.getByTestId('settings-balance-pd-1'))
expect(await screen.findByText('Dina dagar kvar')).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,
})
)
renderSettings()
fireEvent.click(screen.getByTestId('take-pl-on-red-days-checkbox'))
await waitFor(() =>
expect(api.updateWorkspace).toHaveBeenCalledWith('ws-1', { takePlOnRedDays: true })
)
expect(mockRefreshContext).toHaveBeenCalled()
})
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')
})
})
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('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)
})
it('wires the toggle button through to the shared day-overrides hook', async () => {
vi.mocked(hooks.useCalendarDays).mockReturnValue(
makeCalendarDaysResult({
data: {
...mockCalendarWindow,
days: [
{
date: `${CURRENT_YEAR}-01-01`,
name: 'Nyårsdagen',
dayType: 'red_day',
isOverride: false,
overrideIsWorkingDay: null,
isFree: true,
},
],
},
})
)
renderSettings()
fireEvent.click(screen.getByTestId(`red-days-toggle-${CURRENT_YEAR}-01-01`))
await waitFor(() => {
expect(mockUpsertDayOverride).toHaveBeenCalledWith({
workspaceId: 'ws-1',
date: `${CURRENT_YEAR}-01-01`,
isWorkingDay: true,
note: null,
})
})
})
})
})