663d5fcd92
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
877 lines
32 KiB
TypeScript
877 lines
32 KiB
TypeScript
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,
|
||
})
|
||
})
|
||
})
|
||
})
|
||
})
|