Raise frontend branch coverage from 86.84% to 93.78%
Branches were the weakest metric and had the least headroom above the 85% floor. These are tests for decision paths that were genuinely unexercised, not padding. The largest gaps were systemic rather than per-file: the `err instanceof Error ? ... : fallback` branch was untested across eight data hooks, useVacationDays had no dedicated test file at all, and Calendar.tsx's missing-workspace/child/personalData fallbacks were never hit. Left deliberately uncovered, with reasons: `?? fallback` guards whose fallback cannot occur given how the value is constructed upstream, and guards behind buttons that are disabled whenever the guard would matter. Reaching those means calling handlers directly, which tests the test rather than the app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
This commit is contained in:
@@ -36,6 +36,14 @@ const baseDays: CalendarDayEntry[] = [
|
||||
overrideIsWorkingDay: null,
|
||||
isFree: true,
|
||||
},
|
||||
{
|
||||
date: '2026-05-04',
|
||||
name: null,
|
||||
dayType: 'working_day',
|
||||
isOverride: false,
|
||||
overrideIsWorkingDay: null,
|
||||
isFree: true,
|
||||
},
|
||||
]
|
||||
|
||||
describe('RedDaysList', () => {
|
||||
@@ -80,6 +88,47 @@ describe('RedDaysList', () => {
|
||||
expect(year2028).toHaveTextContent(/Inga röda dagar eller undantag/)
|
||||
})
|
||||
|
||||
it('skips a plain working day that is neither a red day nor an override', () => {
|
||||
render(<RedDaysList {...defaultProps} />)
|
||||
// 2026-05-04 is a plain working_day, non-override entry in baseDays — it
|
||||
// must not show up as a list item under its year.
|
||||
expect(screen.queryByTestId('red-days-item-2026-05-04')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('labels a working-day override distinctly from a free-day override', () => {
|
||||
const workingOverrideDays = [
|
||||
{
|
||||
date: '2026-03-02',
|
||||
name: null,
|
||||
dayType: 'working_day' as const,
|
||||
isOverride: true,
|
||||
overrideIsWorkingDay: true,
|
||||
isFree: false,
|
||||
},
|
||||
]
|
||||
render(<RedDaysList {...defaultProps} days={workingOverrideDays} />)
|
||||
const badge = screen.getByTestId('red-days-override-badge-2026-03-02')
|
||||
expect(badge).toHaveClass('red-days-list__override-badge--working')
|
||||
expect(badge).toHaveTextContent('Undantag: arbetsdag')
|
||||
})
|
||||
|
||||
it('shows the effective-working label for a day that is not free', () => {
|
||||
const workingDays = [
|
||||
{
|
||||
date: '2026-03-02',
|
||||
name: null,
|
||||
dayType: 'working_day' as const,
|
||||
isOverride: true,
|
||||
overrideIsWorkingDay: true,
|
||||
isFree: false,
|
||||
},
|
||||
]
|
||||
render(<RedDaysList {...defaultProps} days={workingDays} />)
|
||||
const item = screen.getByTestId('red-days-item-2026-03-02')
|
||||
expect(item.querySelector('.red-days-list__effective--working')).toBeInTheDocument()
|
||||
expect(item.querySelector('.red-days-list__effective--free')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('disables the previous button at minYear', () => {
|
||||
render(<RedDaysList {...defaultProps} startYear={2026} minYear={2026} />)
|
||||
expect(screen.getByTestId('red-days-prev')).toBeDisabled()
|
||||
|
||||
@@ -64,6 +64,18 @@ describe('CalendarDay', () => {
|
||||
expect(onClick).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('calls onActivate instead of onClick on Enter when onActivate is provided', () => {
|
||||
const onClick = vi.fn()
|
||||
const onActivate = vi.fn()
|
||||
const { getByTestId } = render(
|
||||
<CalendarDay date="2026-06-15" onClick={onClick} onActivate={onActivate} />
|
||||
)
|
||||
const day = getByTestId('calendar-day')
|
||||
fireEvent.keyDown(day, { key: 'Enter' })
|
||||
expect(onActivate).toHaveBeenCalledTimes(1)
|
||||
expect(onClick).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('calls onClick on Space key', () => {
|
||||
const onClick = vi.fn()
|
||||
const { getByTestId } = render(
|
||||
|
||||
@@ -65,6 +65,22 @@ describe('CalendarEvent', () => {
|
||||
expect(stopPropagation).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prefixes the label with the parent label when provided', () => {
|
||||
const { getByTestId } = render(
|
||||
<CalendarEvent leaveType="vacation" color="#FF5733" parentLabel="Alice" />
|
||||
)
|
||||
const event = getByTestId('calendar-event')
|
||||
expect(event.textContent).toBe('Alice · vacation')
|
||||
})
|
||||
|
||||
it('shows the double-day badge when isDubbeldag is true', () => {
|
||||
const { getByTestId } = render(
|
||||
<CalendarEvent leaveType="vacation" color="#FF5733" isDubbeldag={true} />
|
||||
)
|
||||
const event = getByTestId('calendar-event')
|
||||
expect(event.textContent).toContain('2×')
|
||||
})
|
||||
|
||||
it('provides accessibility label', () => {
|
||||
const { getByTestId } = render(
|
||||
<CalendarEvent leaveType="vacation" color="#FF5733" onDismiss={() => {}} />
|
||||
|
||||
@@ -412,6 +412,39 @@ describe('CalendarWeek', () => {
|
||||
expect(events.length).toBeGreaterThanOrEqual(2)
|
||||
})
|
||||
|
||||
it('closes an open bar that runs through the last day of the week', () => {
|
||||
// A run of planned days ending on the week's final date (2026-01-11)
|
||||
// leaves the bar "open" when the loop over weekDates finishes, which
|
||||
// exercises the post-loop flush rather than the mid-loop gap-close path.
|
||||
const plannedDays: PlannedLeaveDay[] = [
|
||||
{
|
||||
id: '1',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-10',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'parent-1',
|
||||
date: '2026-01-11',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
]
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={plannedDays} />)
|
||||
const events = screen.getAllByTestId('calendar-event')
|
||||
expect(events.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('handles empty planned days', () => {
|
||||
render(<CalendarWeek {...defaultProps} plannedDays={[]} />)
|
||||
expect(screen.queryByTestId('calendar-event')).not.toBeInTheDocument()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { render } from '@testing-library/react'
|
||||
import { DaysBreakdown } from './DaysBreakdown'
|
||||
import { DaysBreakdown, type DaysBreakdownProps } from './DaysBreakdown'
|
||||
|
||||
describe('DaysBreakdown', () => {
|
||||
it('renders empty state when no breakdown provided', () => {
|
||||
@@ -8,6 +8,13 @@ describe('DaysBreakdown', () => {
|
||||
expect(getByTestId('days-breakdown').textContent).toContain('Inga dagar registrerade än')
|
||||
})
|
||||
|
||||
it('renders empty state when breakdown itself is missing (defensive null/undefined guard)', () => {
|
||||
const { getByTestId } = render(
|
||||
<DaysBreakdown breakdown={undefined as unknown as DaysBreakdownProps['breakdown']} />
|
||||
)
|
||||
expect(getByTestId('days-breakdown').textContent).toContain('Inga dagar registrerade än')
|
||||
})
|
||||
|
||||
it('renders table with breakdown data', () => {
|
||||
const breakdown = [
|
||||
{ type: 'vacation', count: 10 },
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import { ParentalLeaveBreakdown } from './ParentalLeaveBreakdown'
|
||||
import { LEAVE_RULES } from '@/utils/constants'
|
||||
import { LanguageProvider } from '@/i18n'
|
||||
import type { ParentalLeaveFetch } from '@/types'
|
||||
|
||||
describe('ParentalLeaveBreakdown', () => {
|
||||
@@ -36,6 +37,15 @@ describe('ParentalLeaveBreakdown', () => {
|
||||
expect(screen.getByText(/Ingen data hämtad ännu/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits the tier rows when tierData is absent', () => {
|
||||
const dataWithoutTiers = { ...mockData, tierData: undefined } as unknown as ParentalLeaveFetch
|
||||
render(<ParentalLeaveBreakdown data={dataWithoutTiers} />)
|
||||
expect(screen.queryByTestId('breakdown-sjukpenning')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('breakdown-lagsta-niva')).not.toBeInTheDocument()
|
||||
// The rows that don't depend on tierData still render.
|
||||
expect(screen.getByTestId('breakdown-transferable')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders breakdown table with data', () => {
|
||||
render(<ParentalLeaveBreakdown data={mockData} />)
|
||||
expect(screen.getByTestId('parental-leave-breakdown')).toBeInTheDocument()
|
||||
@@ -92,4 +102,26 @@ describe('ParentalLeaveBreakdown', () => {
|
||||
render(<ParentalLeaveBreakdown data={mockData} />)
|
||||
expect(screen.getByText(/2026-06-13/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('formats the provenance date using the en-US locale when the language is English', () => {
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
})
|
||||
|
||||
try {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<ParentalLeaveBreakdown data={mockData} />
|
||||
</LanguageProvider>,
|
||||
)
|
||||
// en-US formats as M/D/YYYY, distinct from the sv-SE "2026-06-13" form.
|
||||
expect(screen.getByText(/6\/13\/2026/)).toBeInTheDocument()
|
||||
} finally {
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'sv-SE', configurable: true })
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -203,6 +203,25 @@ describe('DayOverridesInput', () => {
|
||||
const submitButton = screen.getByTestId('add-override-button') as HTMLButtonElement
|
||||
expect(submitButton.disabled).toBe(true)
|
||||
})
|
||||
|
||||
it('does not call onAdd when the form is submitted with no date entered', async () => {
|
||||
render(
|
||||
<DayOverridesInput
|
||||
overrides={[]}
|
||||
onAdd={mockOnAdd}
|
||||
onRemove={mockOnRemove}
|
||||
workspaceId="ws-1"
|
||||
/>
|
||||
)
|
||||
|
||||
// Submit the form directly (bypassing the disabled submit button) to
|
||||
// exercise the handler's own empty-date guard.
|
||||
fireEvent.submit(screen.getByTestId('add-override-form'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('removing override', () => {
|
||||
@@ -222,6 +241,26 @@ describe('DayOverridesInput', () => {
|
||||
expect(mockOnRemove).toHaveBeenCalledWith('do-1')
|
||||
})
|
||||
})
|
||||
|
||||
it('swallows an onRemove failure instead of throwing (handled by the parent)', async () => {
|
||||
mockOnRemove.mockRejectedValueOnce(new Error('delete failed'))
|
||||
render(
|
||||
<DayOverridesInput
|
||||
overrides={mockOverrides}
|
||||
onAdd={mockOnAdd}
|
||||
onRemove={mockOnRemove}
|
||||
workspaceId="ws-1"
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByTestId('remove-override-do-1'))
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockOnRemove).toHaveBeenCalledWith('do-1')
|
||||
})
|
||||
// The item is still rendered — the component didn't crash on the rejection.
|
||||
expect(screen.getByTestId('override-item-do-1')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('loading state', () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import { BrowserRouter, MemoryRouter } from 'react-router-dom'
|
||||
import { Header } from './Header'
|
||||
|
||||
const renderWithRouter = (element: React.ReactElement) => {
|
||||
@@ -12,6 +12,14 @@ const renderWithRouter = (element: React.ReactElement) => {
|
||||
)
|
||||
}
|
||||
|
||||
const renderAtPath = (element: React.ReactElement, path: string) => {
|
||||
return render(
|
||||
<MemoryRouter initialEntries={[path]}>
|
||||
{element}
|
||||
</MemoryRouter>,
|
||||
)
|
||||
}
|
||||
|
||||
describe('Header', () => {
|
||||
it('renders header with default title', () => {
|
||||
renderWithRouter(<Header />)
|
||||
@@ -75,4 +83,39 @@ describe('Header', () => {
|
||||
renderWithRouter(<Header />)
|
||||
expect(screen.queryByTestId('logout-button')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('does not crash and renders the select when selectedChildId is not provided (falls back to "")', () => {
|
||||
const childrenList = [
|
||||
{ id: '1', name: 'Emma' },
|
||||
{ id: '2', name: 'Oscar' },
|
||||
]
|
||||
renderWithRouter(<Header childrenList={childrenList} />)
|
||||
const select = screen.getByTestId('child-select') as HTMLSelectElement
|
||||
// selectedChildId ?? '' binds the select to a value ('') that matches no
|
||||
// <option>, so the browser falls back to the first option — it must not
|
||||
// be coerced to something else (e.g. "undefined" or a stale selection).
|
||||
expect(select.value).toBe('1')
|
||||
})
|
||||
|
||||
it('marks the calendar link active on the calendar route', () => {
|
||||
renderAtPath(<Header />, '/calendar')
|
||||
expect(screen.getByText('Kalender')).toHaveClass('header__nav-link--active')
|
||||
expect(screen.getByText('Översikt')).not.toHaveClass('header__nav-link--active')
|
||||
})
|
||||
|
||||
it('marks the plan link active on the plan route', () => {
|
||||
renderAtPath(<Header />, '/plan')
|
||||
expect(screen.getByText('Planering')).toHaveClass('header__nav-link--active')
|
||||
})
|
||||
|
||||
it('marks the tips link active on the tips route', () => {
|
||||
renderAtPath(<Header />, '/tips')
|
||||
expect(screen.getByText('Tips')).toHaveClass('header__nav-link--active')
|
||||
})
|
||||
|
||||
it('marks the settings link active on the settings route, and the overview link inactive', () => {
|
||||
renderAtPath(<Header />, '/settings')
|
||||
expect(screen.getByText('Inställningar')).toHaveClass('header__nav-link--active')
|
||||
expect(screen.getByText('Översikt')).not.toHaveClass('header__nav-link--active')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -463,6 +463,38 @@ describe('PlannedDayModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('displays a generic error message for non-Error thrown in delete', async () => {
|
||||
const existingDay: PlannedLeaveDay = {
|
||||
id: 'day-1',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'c-1',
|
||||
parentPersonalDataUuid: 'p1',
|
||||
date: '2026-06-15',
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01T00:00:00Z',
|
||||
updatedAt: '2026-01-01T00:00:00Z',
|
||||
}
|
||||
mockOnDelete.mockRejectedValue('Some error')
|
||||
vi.spyOn(window, 'confirm').mockReturnValue(true)
|
||||
|
||||
render(
|
||||
<PlannedDayModal
|
||||
{...defaultProps}
|
||||
isOpen={true}
|
||||
existingDay={existingDay}
|
||||
onDelete={mockOnDelete}
|
||||
/>
|
||||
)
|
||||
|
||||
const deleteButton = screen.getByTestId('delete-button')
|
||||
await userEvent.click(deleteButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toHaveTextContent('Det gick inte att ta bort den planerade dagen')
|
||||
})
|
||||
})
|
||||
|
||||
it('clears local error when save starts after a failed attempt', async () => {
|
||||
// First save fails
|
||||
mockOnSave.mockRejectedValueOnce(new Error('First save failed'))
|
||||
|
||||
@@ -49,6 +49,19 @@ describe('useAnalytics', () => {
|
||||
expect(result.current.error).toContain('API error')
|
||||
})
|
||||
|
||||
it('falls back to a generic message when a non-Error value is thrown', async () => {
|
||||
vi.mocked(api.getWorkspaceOverview).mockRejectedValue('some string failure')
|
||||
|
||||
const { result } = renderHook(() => useAnalytics('ws1', 'child1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Failed to fetch analytics')
|
||||
expect(result.current.data).toBeNull()
|
||||
})
|
||||
|
||||
it('does not fetch when workspaceId is null', () => {
|
||||
vi.mocked(api.getWorkspaceOverview).mockResolvedValue({} as any)
|
||||
|
||||
|
||||
@@ -154,4 +154,10 @@ describe('getLastLeaveType / setLastLeaveType', () => {
|
||||
expect(getLastLeaveType()).toBe(type)
|
||||
}
|
||||
})
|
||||
|
||||
it('ignores an invalid leave type and leaves storage untouched', () => {
|
||||
setLastLeaveType('not-a-real-type' as unknown as Parameters<typeof setLastLeaveType>[0])
|
||||
expect(sessionStorage.getItem(LAST_LEAVE_TYPE_KEY)).toBeNull()
|
||||
expect(getLastLeaveType()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -93,4 +93,13 @@ describe('useDayOverrides', () => {
|
||||
expect(result.current.error).toBe('Network error')
|
||||
expect(result.current.data).toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to a generic message when a non-Error value is thrown', async () => {
|
||||
vi.mocked(api.listDayOverridesByWorkspace).mockRejectedValueOnce('boom')
|
||||
|
||||
const { result } = renderHook(() => useDayOverrides('ws-1'))
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
expect(result.current.error).toBe('Failed to load day overrides')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,6 +49,18 @@ describe('useOptimizationTips', () => {
|
||||
expect(result.current.error).toContain('API error')
|
||||
})
|
||||
|
||||
it('falls back to a generic message when a non-Error value is thrown', async () => {
|
||||
vi.mocked(api.getTips).mockRejectedValue('boom')
|
||||
|
||||
const { result } = renderHook(() => useOptimizationTips('ws1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Failed to fetch tips')
|
||||
})
|
||||
|
||||
it('dismisses tips locally', async () => {
|
||||
const mockTips = [
|
||||
{ id: 'tip1', severity: 'info', messageKey: 'msg1', data: {} },
|
||||
|
||||
@@ -59,6 +59,18 @@ describe('useParentalLeave', () => {
|
||||
expect(result.current.error).toContain('Fetch failed')
|
||||
})
|
||||
|
||||
it('falls back to a generic message when a non-Error value is thrown', async () => {
|
||||
vi.mocked(api.getParentalLeave).mockRejectedValue('boom')
|
||||
|
||||
const { result } = renderHook(() => useParentalLeave('pd1', 'child1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Failed to fetch parental leave data')
|
||||
})
|
||||
|
||||
it('does not fetch when personalDataUuid or childId is null', () => {
|
||||
vi.mocked(api.getParentalLeave).mockResolvedValue({} as any)
|
||||
|
||||
|
||||
@@ -81,6 +81,33 @@ describe('usePlanApply', () => {
|
||||
expect(result.current.response).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to a generic message when a non-Error value is thrown', async () => {
|
||||
vi.mocked(api.applyPlan).mockRejectedValue('boom')
|
||||
|
||||
const { result } = renderHook(() => usePlanApply())
|
||||
const days = [
|
||||
{
|
||||
date: '2026-06-15',
|
||||
parentPersonalDataUuid: 'pd-1',
|
||||
childId: 'c-1',
|
||||
dayType: 'sjukpenning' as const,
|
||||
},
|
||||
]
|
||||
|
||||
try {
|
||||
await result.current.apply('ws-1', days, false)
|
||||
} catch {
|
||||
// Expected to throw
|
||||
}
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Kunde inte använda plan')
|
||||
expect(result.current.response).toBeNull()
|
||||
})
|
||||
|
||||
it('handles replace parameter correctly', async () => {
|
||||
const mockResponse = {
|
||||
accepted: true,
|
||||
|
||||
@@ -63,4 +63,17 @@ describe('usePlanProposal', () => {
|
||||
expect(result.current.error).toContain('kaputt')
|
||||
expect(result.current.data).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to a generic message when a non-Error value is thrown', async () => {
|
||||
vi.mocked(api.proposePlan).mockRejectedValue('boom')
|
||||
|
||||
const { result } = renderHook(() => usePlanProposal('ws1'))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.propose()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Failed to compute plan proposal')
|
||||
expect(result.current.data).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -147,6 +147,171 @@ describe('useScraperFetch', () => {
|
||||
expect(result.current.error).toBe('Not authenticated')
|
||||
})
|
||||
|
||||
it('falls back to a generic message when polling rejects with a non-Error value', async () => {
|
||||
vi.mocked(api.startScraperSession).mockResolvedValue({
|
||||
session_id: 'sess-123',
|
||||
workspace_id: 'ws-1',
|
||||
personal_data_uuid: 'pd-1',
|
||||
child_id: 'c-1',
|
||||
} as any)
|
||||
|
||||
vi.mocked(api.pollScraperSession).mockRejectedValue('not an Error object')
|
||||
|
||||
const { result } = renderHook(() => useScraperFetch())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Poll error')
|
||||
})
|
||||
|
||||
it('falls back to a generic message when start rejects with a non-Error value', async () => {
|
||||
vi.mocked(api.startScraperSession).mockRejectedValue('not an Error object')
|
||||
vi.mocked(errors.isUnauthorized).mockReturnValue(false)
|
||||
|
||||
const { result } = renderHook(() => useScraperFetch())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start()
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Start error')
|
||||
expect(result.current.isUnauthorized).toBe(false)
|
||||
})
|
||||
|
||||
it('cancels a previous non-terminal session before starting a new one', async () => {
|
||||
vi.mocked(api.startScraperSession)
|
||||
.mockResolvedValueOnce({
|
||||
session_id: 'sess-old',
|
||||
workspace_id: 'ws-1',
|
||||
personal_data_uuid: 'pd-1',
|
||||
child_id: 'c-1',
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
session_id: 'sess-new',
|
||||
workspace_id: 'ws-1',
|
||||
personal_data_uuid: 'pd-1',
|
||||
child_id: 'c-1',
|
||||
} as any)
|
||||
vi.mocked(api.cancelScraperSession).mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() => useScraperFetch())
|
||||
|
||||
// First session starts and remains in a non-terminal state.
|
||||
await act(async () => {
|
||||
await result.current.start()
|
||||
})
|
||||
expect(result.current.sessionId).toBe('sess-old')
|
||||
|
||||
// Starting again while the old session is still running (not done/error/
|
||||
// cancelled) must cancel it first before launching the new one.
|
||||
await act(async () => {
|
||||
await result.current.start()
|
||||
})
|
||||
|
||||
expect(api.cancelScraperSession).toHaveBeenCalledWith('sess-old')
|
||||
expect(result.current.sessionId).toBe('sess-new')
|
||||
})
|
||||
|
||||
it('ignores a failure to cancel the previous session before starting a new one', async () => {
|
||||
vi.mocked(api.startScraperSession)
|
||||
.mockResolvedValueOnce({
|
||||
session_id: 'sess-old',
|
||||
workspace_id: 'ws-1',
|
||||
personal_data_uuid: 'pd-1',
|
||||
child_id: 'c-1',
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
session_id: 'sess-new',
|
||||
workspace_id: 'ws-1',
|
||||
personal_data_uuid: 'pd-1',
|
||||
child_id: 'c-1',
|
||||
} as any)
|
||||
vi.mocked(api.cancelScraperSession).mockRejectedValue(new Error('cancel failed'))
|
||||
|
||||
const { result } = renderHook(() => useScraperFetch())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start()
|
||||
})
|
||||
expect(result.current.sessionId).toBe('sess-old')
|
||||
|
||||
// The old session is still non-terminal; cancelling it fails, but the
|
||||
// new session must still be started rather than the failure propagating.
|
||||
await act(async () => {
|
||||
await result.current.start()
|
||||
})
|
||||
|
||||
expect(api.cancelScraperSession).toHaveBeenCalledWith('sess-old')
|
||||
expect(result.current.sessionId).toBe('sess-new')
|
||||
})
|
||||
|
||||
it('does not try to cancel a previous session that already finished', async () => {
|
||||
vi.mocked(api.startScraperSession)
|
||||
.mockResolvedValueOnce({
|
||||
session_id: 'sess-old',
|
||||
workspace_id: 'ws-1',
|
||||
personal_data_uuid: 'pd-1',
|
||||
child_id: 'c-1',
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
session_id: 'sess-new',
|
||||
workspace_id: 'ws-1',
|
||||
personal_data_uuid: 'pd-1',
|
||||
child_id: 'c-1',
|
||||
} as any)
|
||||
vi.mocked(api.pollScraperSession).mockResolvedValue({ status: 'done', result: {} } as any)
|
||||
vi.mocked(api.cancelScraperSession).mockResolvedValue(undefined)
|
||||
|
||||
const { result } = renderHook(() => useScraperFetch())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start()
|
||||
})
|
||||
|
||||
// Let it reach the terminal 'done' status.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1000)
|
||||
})
|
||||
expect(result.current.isTerminal).toBe(true)
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start()
|
||||
})
|
||||
|
||||
expect(api.cancelScraperSession).not.toHaveBeenCalled()
|
||||
expect(result.current.sessionId).toBe('sess-new')
|
||||
})
|
||||
|
||||
it('ignores a failure to cancel the running session', async () => {
|
||||
vi.mocked(api.startScraperSession).mockResolvedValue({
|
||||
session_id: 'sess-123',
|
||||
workspace_id: 'ws-1',
|
||||
personal_data_uuid: 'pd-1',
|
||||
child_id: 'c-1',
|
||||
} as any)
|
||||
vi.mocked(api.pollScraperSession).mockResolvedValue({ status: 'awaiting_scan' } as any)
|
||||
vi.mocked(api.cancelScraperSession).mockRejectedValue(new Error('cancel failed'))
|
||||
|
||||
const { result } = renderHook(() => useScraperFetch())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.start()
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.cancel()
|
||||
})
|
||||
|
||||
expect(result.current.pollData?.status).toBe('cancelled')
|
||||
expect(result.current.sessionId).toBeNull()
|
||||
})
|
||||
|
||||
it('handles poll error', async () => {
|
||||
vi.mocked(api.startScraperSession).mockResolvedValue({
|
||||
session_id: 'sess-123',
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import { useVacationDays } from './useVacationDays'
|
||||
import * as api from '@/services/api'
|
||||
|
||||
vi.mock('@/services/api')
|
||||
|
||||
const mockRow = {
|
||||
id: 'vd-1',
|
||||
personalDataUuid: 'pd-1',
|
||||
workspaceId: 'ws-1',
|
||||
year: 2026,
|
||||
totalDays: 25,
|
||||
savedDaysIn: 0,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
}
|
||||
|
||||
describe('useVacationDays', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.mocked(api.getVacationDaysByWorkspace).mockResolvedValue([mockRow])
|
||||
vi.mocked(api.upsertVacationDays).mockResolvedValue(mockRow)
|
||||
})
|
||||
|
||||
it('fetches vacation days on mount', async () => {
|
||||
const { result } = renderHook(() => useVacationDays('ws-1'))
|
||||
expect(result.current.isLoading).toBe(true)
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
expect(result.current.data).toEqual([mockRow])
|
||||
expect(api.getVacationDaysByWorkspace).toHaveBeenCalledWith('ws-1')
|
||||
})
|
||||
|
||||
it('does not fetch when workspaceId is empty', async () => {
|
||||
renderHook(() => useVacationDays(''))
|
||||
await waitFor(() => {
|
||||
expect(api.getVacationDaysByWorkspace).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
it('sets error when fetch fails with an Error', async () => {
|
||||
vi.mocked(api.getVacationDaysByWorkspace).mockRejectedValueOnce(new Error('Network down'))
|
||||
|
||||
const { result } = renderHook(() => useVacationDays('ws-1'))
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
expect(result.current.error).toBe('Network down')
|
||||
expect(result.current.data).toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to a generic message when a non-Error value is thrown', async () => {
|
||||
vi.mocked(api.getVacationDaysByWorkspace).mockRejectedValueOnce('boom')
|
||||
|
||||
const { result } = renderHook(() => useVacationDays('ws-1'))
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
expect(result.current.error).toBe('Failed to load vacation days')
|
||||
})
|
||||
|
||||
it('upserts and refetches', async () => {
|
||||
const { result } = renderHook(() => useVacationDays('ws-1'))
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
|
||||
await act(async () => {
|
||||
await result.current.upsert({
|
||||
personalDataUuid: 'pd-1',
|
||||
workspaceId: 'ws-1',
|
||||
year: 2027,
|
||||
totalDays: 25,
|
||||
savedDaysIn: 0,
|
||||
})
|
||||
})
|
||||
|
||||
expect(api.upsertVacationDays).toHaveBeenCalledWith({
|
||||
personalDataUuid: 'pd-1',
|
||||
workspaceId: 'ws-1',
|
||||
year: 2027,
|
||||
totalDays: 25,
|
||||
savedDaysIn: 0,
|
||||
})
|
||||
expect(api.getVacationDaysByWorkspace).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -48,4 +48,16 @@ describe('useWorkspaceMembers', () => {
|
||||
|
||||
expect(result.current.error).toContain('boom')
|
||||
})
|
||||
|
||||
it('falls back to a generic message when a non-Error value is thrown', async () => {
|
||||
vi.mocked(api.listWorkspaceMembers).mockRejectedValue('not an error object')
|
||||
|
||||
const { result } = renderHook(() => useWorkspaceMembers('ws1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(result.current.error).toBe('Failed to load workspace members')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -2,7 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
||||
import { Calendar } from './Calendar'
|
||||
import { useWorkspace } from '@/context'
|
||||
import { usePlannedDays, useCalendarDays, useDayOverrides } from '@/hooks'
|
||||
import { usePlannedDays, useCalendarDays, useDayOverrides, useShowWeekNumbers, getLastLeaveType } from '@/hooks'
|
||||
import { LanguageProvider } from '@/i18n'
|
||||
|
||||
vi.mock('@/context', () => ({
|
||||
useWorkspace: vi.fn(),
|
||||
@@ -26,7 +27,15 @@ vi.mock('@/components/common/LoadingSpinner', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/components/modals/PlannedDayModal', () => ({
|
||||
PlannedDayModal: () => <div data-testid="modal" />,
|
||||
// Exposes the props this test suite cares about as data attributes so
|
||||
// tests can assert on them without rendering the real modal.
|
||||
PlannedDayModal: (props: { defaultDayType?: string; existingDay?: unknown }) => (
|
||||
<div
|
||||
data-testid="modal"
|
||||
data-default-day-type={props.defaultDayType ?? ''}
|
||||
data-existing-day={props.existingDay ? 'yes' : 'no'}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('Calendar', () => {
|
||||
@@ -500,4 +509,342 @@ describe('Calendar', () => {
|
||||
await waitFor(() => expect(refetchCalendarWindow).toHaveBeenCalledTimes(1))
|
||||
})
|
||||
})
|
||||
|
||||
describe('defensive fallbacks and additional branches', () => {
|
||||
it('falls back to safe defaults when workspace/child/personalData are unavailable', () => {
|
||||
const noWorkspace = { isLoading: false, selectedChildId: null, workspace: null, personalDataUuid: null }
|
||||
;(useWorkspace as any).mockReturnValue(noWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
const upsert = vi.fn()
|
||||
const removeOverride = vi.fn()
|
||||
;(useDayOverrides as any).mockReturnValue({ data: [], isLoading: false, error: null, upsert, remove: removeOverride })
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
// selectedChildId ?? undefined
|
||||
expect(usePlannedDays).toHaveBeenCalledWith(undefined)
|
||||
// workspace?.id ?? '' (used for both useCalendarDays and useDayOverrides)
|
||||
expect(useCalendarDays).toHaveBeenCalledWith('', expect.any(Number), expect.any(Number))
|
||||
expect(useDayOverrides).toHaveBeenCalledWith('')
|
||||
|
||||
// Attempting an override toggle without a workspace id must be a no-op
|
||||
// (the `if (!workspace?.id) return` guard fires before any mutation).
|
||||
fireEvent.click(screen.getByTestId('calendar-override-toggle'))
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
|
||||
fireEvent.click(currentMonthDay!)
|
||||
|
||||
expect(upsert).not.toHaveBeenCalled()
|
||||
expect(removeOverride).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats a missing calendar window as having no days yet', () => {
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
;(useCalendarDays as any).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
// calendarWindow?.days ?? [] — must render normally with no shaded days.
|
||||
expect(document.querySelector('.calendar-day--red-day')).not.toBeInTheDocument()
|
||||
expect(screen.getByTestId('calendar')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('treats missing planned days as an empty list for lookups and bar rendering', () => {
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue({ ...mockPlannedDays, data: undefined })
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
|
||||
fireEvent.click(currentMonthDay!)
|
||||
|
||||
// getExistingDay's (plannedDays ?? []).find(...) must not throw, and
|
||||
// there is (correctly) no existing day to edit.
|
||||
expect(screen.getByTestId('modal')).toHaveAttribute('data-existing-day', 'no')
|
||||
})
|
||||
|
||||
it('renders the month/year header in English when the locale is English', () => {
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'en-US', configurable: true })
|
||||
vi.stubGlobal('localStorage', {
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
})
|
||||
|
||||
try {
|
||||
render(
|
||||
<LanguageProvider>
|
||||
<Calendar />
|
||||
</LanguageProvider>,
|
||||
)
|
||||
const title = screen.getByRole('heading', { level: 2 })
|
||||
const monthButton = title.querySelector('.calendar__month-button') as HTMLButtonElement
|
||||
// English month names differ from the Swedish ones (e.g. "January" vs "januari").
|
||||
expect(monthButton.getAttribute('aria-label')).toContain('click to advance month')
|
||||
} finally {
|
||||
Object.defineProperty(window.navigator, 'language', { value: 'sv-SE', configurable: true })
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('shows the week-number column when the preference is enabled', () => {
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
;(useShowWeekNumbers as any).mockReturnValueOnce([true])
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
expect(document.querySelector('.calendar__grid--with-week-numbers')).toBeInTheDocument()
|
||||
expect(document.querySelector('.calendar__week-number-header')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('defaults the new-day modal to "vacation" when there is no last-used leave type', () => {
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
;(getLastLeaveType as any).mockReturnValueOnce(null)
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
|
||||
fireEvent.click(currentMonthDay!)
|
||||
|
||||
expect(screen.getByTestId('modal')).toHaveAttribute('data-default-day-type', 'vacation')
|
||||
})
|
||||
|
||||
it('ignores pointer-drag gestures entirely while override mode is active', () => {
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
fireEvent.click(screen.getByTestId('calendar-override-toggle'))
|
||||
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
|
||||
const startDay = currentMonthDays[0] as HTMLElement
|
||||
const endDay = currentMonthDays[3] as HTMLElement
|
||||
|
||||
fireEvent.pointerDown(startDay)
|
||||
fireEvent.pointerEnter(endDay)
|
||||
|
||||
// Both handlers' `if (isOverrideMode) return` guards must fire, so no
|
||||
// drag-selection highlighting should appear.
|
||||
const selected = Array.from(calendarDays).filter((el) => el.classList.contains('calendar-day--selected'))
|
||||
expect(selected).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('treats a missing override-list fetch as empty when clearing an override', async () => {
|
||||
const today = todayDateString()
|
||||
const removeOverride = vi.fn()
|
||||
const refetchCalendarWindow = vi.fn().mockResolvedValue(undefined)
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
;(useCalendarDays as any).mockReturnValue({
|
||||
data: {
|
||||
startYear: 2026,
|
||||
endYear: 2026,
|
||||
minYear: 2026,
|
||||
maxYear: 2035,
|
||||
days: [
|
||||
{
|
||||
date: today,
|
||||
name: null,
|
||||
dayType: 'working_day',
|
||||
isOverride: true,
|
||||
overrideIsWorkingDay: false,
|
||||
isFree: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: refetchCalendarWindow,
|
||||
})
|
||||
// data is undefined here (e.g. before the overrides fetch resolves) —
|
||||
// the (dayOverrides ?? []).find(...) fallback must kick in rather than
|
||||
// throwing, and since nothing is found there is nothing to remove.
|
||||
;(useDayOverrides as any).mockReturnValue({
|
||||
data: undefined,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
upsert: vi.fn(),
|
||||
remove: removeOverride,
|
||||
})
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getByTestId('calendar-override-toggle'))
|
||||
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
|
||||
expect(todayCell).toBeDefined()
|
||||
fireEvent.click(todayCell!)
|
||||
|
||||
await waitFor(() => expect(refetchCalendarWindow).toHaveBeenCalledTimes(1))
|
||||
expect(removeOverride).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows the Error message directly when the override mutation rejects with an Error', async () => {
|
||||
const today = todayDateString()
|
||||
const upsert = vi.fn().mockRejectedValue(new Error('quota exceeded'))
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
;(useCalendarDays as any).mockReturnValue({
|
||||
data: {
|
||||
startYear: 2026,
|
||||
endYear: 2026,
|
||||
minYear: 2026,
|
||||
maxYear: 2035,
|
||||
days: [
|
||||
{
|
||||
date: today,
|
||||
name: null,
|
||||
dayType: 'weekend',
|
||||
isOverride: false,
|
||||
overrideIsWorkingDay: null,
|
||||
isFree: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
;(useDayOverrides as any).mockReturnValue({ data: [], isLoading: false, error: null, upsert, remove: vi.fn() })
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getByTestId('calendar-override-toggle'))
|
||||
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
|
||||
fireEvent.click(todayCell!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('calendar-override-error')).toHaveTextContent('quota exceeded')
|
||||
})
|
||||
})
|
||||
|
||||
it('passes an empty personalDataUuid through to the planning modal when it is unavailable', () => {
|
||||
const noPersonalData = { ...mockWorkspace, personalDataUuid: null }
|
||||
;(useWorkspace as any).mockReturnValue(noPersonalData)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
// Normal (non-override) click opens the planning modal, which must not
|
||||
// crash even though personalDataUuid is missing.
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
|
||||
fireEvent.click(currentMonthDay!)
|
||||
|
||||
expect(screen.getByTestId('modal')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lets the modal offer deletion when the clicked date already has a planned day', () => {
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
render(<Calendar />)
|
||||
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const currentMonthDay = calendarDays.find((el) => !el.classList.contains('calendar-day--disabled'))
|
||||
const clickedDate = currentMonthDay!.getAttribute('aria-label')!
|
||||
|
||||
;(usePlannedDays as any).mockReturnValue({
|
||||
...mockPlannedDays,
|
||||
data: [
|
||||
{
|
||||
id: 'pd-1',
|
||||
workspaceId: 'ws-1',
|
||||
childId: 'child-1',
|
||||
parentPersonalDataUuid: 'person-1',
|
||||
date: clickedDate,
|
||||
dayType: 'vacation',
|
||||
isDubbeldag: false,
|
||||
createdAt: '2026-01-01',
|
||||
updatedAt: '2026-01-01',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const { rerender } = render(<Calendar />)
|
||||
rerender(<Calendar />)
|
||||
|
||||
const daysAfterRerender = screen.getAllByTestId('calendar-day')
|
||||
const sameDay = daysAfterRerender.find((el) => el.getAttribute('aria-label') === clickedDate)
|
||||
fireEvent.click(sameDay!)
|
||||
|
||||
expect(screen.getByTestId('modal')).toHaveAttribute('data-existing-day', 'yes')
|
||||
})
|
||||
|
||||
it('forces existingDay to null while a multi-day range is being planned', () => {
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
|
||||
render(<Calendar />)
|
||||
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const currentMonthDays = calendarDays.filter((el) => !el.classList.contains('calendar-day--disabled'))
|
||||
const startDay = currentMonthDays[0] as HTMLElement
|
||||
const endDay = currentMonthDays[3] as HTMLElement
|
||||
|
||||
fireEvent.pointerDown(startDay)
|
||||
fireEvent.pointerEnter(endDay)
|
||||
fireEvent.pointerUp(window)
|
||||
|
||||
// isRangeMode ? null : ... — a completed multi-day drag must force
|
||||
// existingDay to null even if one of the dates happened to have a plan.
|
||||
expect(screen.getByTestId('modal')).toHaveAttribute('data-existing-day', 'no')
|
||||
})
|
||||
|
||||
it('shows a generic override error when the mutation rejects with a non-Error value', async () => {
|
||||
const today = todayDateString()
|
||||
const upsert = vi.fn().mockRejectedValue('network exploded')
|
||||
;(useWorkspace as any).mockReturnValue(mockWorkspace)
|
||||
;(usePlannedDays as any).mockReturnValue(mockPlannedDays)
|
||||
;(useCalendarDays as any).mockReturnValue({
|
||||
data: {
|
||||
startYear: 2026,
|
||||
endYear: 2026,
|
||||
minYear: 2026,
|
||||
maxYear: 2035,
|
||||
days: [
|
||||
{
|
||||
date: today,
|
||||
name: null,
|
||||
dayType: 'weekend',
|
||||
isOverride: false,
|
||||
overrideIsWorkingDay: null,
|
||||
isFree: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: vi.fn(),
|
||||
})
|
||||
;(useDayOverrides as any).mockReturnValue({ data: [], isLoading: false, error: null, upsert, remove: vi.fn() })
|
||||
|
||||
render(<Calendar />)
|
||||
fireEvent.click(screen.getByTestId('calendar-override-toggle'))
|
||||
|
||||
const calendarDays = screen.getAllByTestId('calendar-day')
|
||||
const todayCell = calendarDays.find((el) => el.getAttribute('aria-label')?.startsWith(today))
|
||||
fireEvent.click(todayCell!)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('calendar-override-error')).toBeInTheDocument()
|
||||
})
|
||||
// The fallback message is used, not the raw non-Error rejection value.
|
||||
expect(screen.getByTestId('calendar-override-error')).not.toHaveTextContent('network exploded')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -436,6 +436,23 @@ describe('DevFetch', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('colors the status red when the scraper session errored', async () => {
|
||||
vi.mocked(useScraperFetch).mockReturnValue({
|
||||
...mockScraperFetchResult,
|
||||
pollData: {
|
||||
status: 'error',
|
||||
message: 'Something broke',
|
||||
},
|
||||
})
|
||||
|
||||
render(<DevFetch />)
|
||||
|
||||
await waitFor(() => {
|
||||
const statusValue = screen.getByText('error')
|
||||
expect(statusValue).toHaveStyle({ color: 'rgb(255, 0, 0)' })
|
||||
})
|
||||
})
|
||||
|
||||
it('displays fetching message during data fetch', async () => {
|
||||
vi.mocked(useScraperFetch).mockReturnValue({
|
||||
...mockScraperFetchResult,
|
||||
|
||||
@@ -24,6 +24,19 @@ describe('Tips', () => {
|
||||
vi.mocked(useWorkspace).mockReturnValue({ workspace: { id: 'ws-1' }, isLoading: false } as any)
|
||||
})
|
||||
|
||||
it('passes null to useOptimizationTips when there is no workspace yet', () => {
|
||||
vi.mocked(useWorkspace).mockReturnValue({ workspace: null, isLoading: false } as any)
|
||||
vi.mocked(useOptimizationTips).mockReturnValue({
|
||||
data: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
dismiss: mockDismiss,
|
||||
} as any)
|
||||
render(<Tips />)
|
||||
expect(useOptimizationTips).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('shows the loading spinner while the workspace or tips are loading', () => {
|
||||
vi.mocked(useOptimizationTips).mockReturnValue({
|
||||
data: null,
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
isValidDate,
|
||||
getDayName,
|
||||
getMonthName,
|
||||
getISOWeekNumber,
|
||||
formatHolidayName,
|
||||
datesInRange,
|
||||
} from '@/utils/dates'
|
||||
|
||||
describe('dates.ts utilities', () => {
|
||||
@@ -140,4 +143,66 @@ describe('dates.ts utilities', () => {
|
||||
expect(result).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('getISOWeekNumber', () => {
|
||||
it('returns 0 for an invalid date', () => {
|
||||
expect(getISOWeekNumber(new Date('invalid'))).toBe(0)
|
||||
})
|
||||
|
||||
it('computes the ISO week number for a mid-week date', () => {
|
||||
// 2026-06-15 is a Monday.
|
||||
expect(getISOWeekNumber(new Date('2026-06-15T00:00:00Z'))).toBe(25)
|
||||
})
|
||||
|
||||
it('handles a Sunday correctly (getUTCDay() === 0 falls back to ISO day 7)', () => {
|
||||
// 2026-01-11 is a Sunday, closing out ISO week 2 of 2026.
|
||||
expect(getISOWeekNumber(new Date('2026-01-11T00:00:00Z'))).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatHolidayName', () => {
|
||||
it('returns the name unchanged when it has no ";" segments', () => {
|
||||
expect(formatHolidayName('Midsommardagen')).toBe('Midsommardagen')
|
||||
})
|
||||
|
||||
it('strips a trailing weekday segment', () => {
|
||||
expect(formatHolidayName('Påskdagen; Söndag')).toBe('Påskdagen')
|
||||
})
|
||||
|
||||
it('falls back to the first segment when every segment is a weekday name', () => {
|
||||
// Contrived input where nothing survives the weekday filter — the
|
||||
// function must not return an empty string in that case.
|
||||
expect(formatHolidayName('Söndag; Måndag')).toBe('Söndag')
|
||||
})
|
||||
})
|
||||
|
||||
describe('datesInRange', () => {
|
||||
it('returns an empty array when the start date is invalid', () => {
|
||||
expect(datesInRange('invalid', '2026-06-15')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns an empty array when the end date is invalid', () => {
|
||||
expect(datesInRange('2026-06-15', 'invalid')).toEqual([])
|
||||
})
|
||||
|
||||
it('returns the inclusive range in ascending order for start before end', () => {
|
||||
expect(datesInRange('2026-06-01', '2026-06-03')).toEqual([
|
||||
'2026-06-01',
|
||||
'2026-06-02',
|
||||
'2026-06-03',
|
||||
])
|
||||
})
|
||||
|
||||
it('returns the same range regardless of argument order (reversed input)', () => {
|
||||
expect(datesInRange('2026-06-03', '2026-06-01')).toEqual([
|
||||
'2026-06-01',
|
||||
'2026-06-02',
|
||||
'2026-06-03',
|
||||
])
|
||||
})
|
||||
|
||||
it('returns a single date when start equals end', () => {
|
||||
expect(datesInRange('2026-06-01', '2026-06-01')).toEqual(['2026-06-01'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user