import { describe, it, expect, beforeEach, vi } from 'vitest' import { renderHook, act } from '@testing-library/react' import { createElement, StrictMode, type ReactNode } from 'react' import { usePlannedDays } from './usePlannedDays' import * as api from '@/services/api' import { useWorkspace } from '@/context' import { makePlannedLeaveDay, makeWorkspaceContextValue } from '../../tests/fixtures' vi.mock('@/services/api') vi.mock('@/context') describe('usePlannedDays', () => { let bumpPlanVersion: ReturnType beforeEach(() => { vi.clearAllMocks() bumpPlanVersion = vi.fn() vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ planVersion: 0, bumpPlanVersion }), ) }) it('initializes with null data', () => { const { result } = renderHook(() => usePlannedDays()) expect(result.current.data).toBeNull() expect(result.current.isLoading).toBe(false) }) it('fetches planned days when childId provided', async () => { const mockData = [ makePlannedLeaveDay({ id: '1', workspaceId: 'ws1', childId: 'child1', parentPersonalDataUuid: 'parent1', date: '2026-06-15', dayType: 'vacation', isDubbeldag: false, }), ] vi.mocked(api.getPlannedDays).mockResolvedValue(mockData) renderHook(() => usePlannedDays('child1')) await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) }) expect(api.getPlannedDays).toHaveBeenCalledWith('child1') }) it('does not fetch without childId', () => { vi.mocked(api.getPlannedDays).mockResolvedValue([]) renderHook(() => usePlannedDays()) expect(api.getPlannedDays).not.toHaveBeenCalled() }) it('handles fetch error and sets error state', async () => { const errorMessage = 'Network error' vi.mocked(api.getPlannedDays).mockRejectedValue(new Error(errorMessage)) const { result } = renderHook(() => usePlannedDays('child1')) await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) }) expect(result.current.error).toBe(errorMessage) expect(result.current.data).toBeNull() expect(result.current.isLoading).toBe(false) }) it('handles fetch error with non-Error object', async () => { vi.mocked(api.getPlannedDays).mockRejectedValue('Unknown error') const { result } = renderHook(() => usePlannedDays('child1')) await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) }) expect(result.current.error).toBe('Failed to fetch planned days') expect(result.current.data).toBeNull() }) it('refreshes data explicitly', async () => { const mockData = [ makePlannedLeaveDay({ id: '1', workspaceId: 'ws1', childId: 'child1', parentPersonalDataUuid: 'parent1', date: '2026-06-15', dayType: 'vacation', isDubbeldag: false, }), ] vi.mocked(api.getPlannedDays).mockResolvedValue(mockData) const { result } = renderHook(() => usePlannedDays('child1')) await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) }) expect(api.getPlannedDays).toHaveBeenCalledWith('child1') const callCount = vi.mocked(api.getPlannedDays).mock.calls.length await act(async () => { await result.current.refresh() }) expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBeGreaterThan(callCount) }) it('M7 part 1 — a mutation results in exactly one background refetch, never isLoading (only isRefreshing flips)', async () => { // This is the exact regression a browser verification pass caught: an // earlier version had this hook's own mutators call `fetch({ background: // true })` themselves AND rely on their own resulting `planVersion` bump // to re-run this hook's effect a second time — and that second, // effect-triggered fetch used the *blocking* default, so a save still // blanked the page via a second GET. The writers (`useDayAssertions`, // `usePlanSync`) now only ever call `bumpPlanVersion()`; the effect // below is the ONLY place that fetches, so there is exactly one fetch, // and it decides for itself whether that's the (blocking) first load or // a (background) mutation refetch — see usePlannedDays.ts's doc comment. vi.mocked(api.getPlannedDays).mockResolvedValue([]) // A bare `vi.fn()` `bumpPlanVersion` (this file's default) never causes // a re-render on its own — wire up a real counter so calling it (as a // writer hook would) and then `rerender()`ing genuinely reproduces what // the real `WorkspaceProvider`'s state update would do. let planVersion = 0 const bump = () => { planVersion += 1 } vi.mocked(useWorkspace).mockImplementation(() => makeWorkspaceContextValue({ planVersion, bumpPlanVersion: bump }), ) const { result, rerender } = renderHook(() => usePlannedDays('child1')) await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) }) expect(result.current.isLoading).toBe(false) const callCountAfterMount = vi.mocked(api.getPlannedDays).mock.calls.length // The refetch that follows the mutation's bump should hang so the // in-flight state is observable before it resolves. let resolveRefetch: (value: ReturnType[]) => void = () => {} vi.mocked(api.getPlannedDays).mockReturnValueOnce( new Promise((resolve) => { resolveRefetch = resolve }), ) await act(async () => { // Stands in for a writer hook's post-mutation `bumpPlanVersion()`. bump() }) // The bump only takes effect on this instance's next render — nothing // has refetched yet. expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBe(callCountAfterMount) rerender() await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) }) // Exactly one refetch so far, and it's non-blocking. expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBe(callCountAfterMount + 1) expect(result.current.isRefreshing).toBe(true) expect(result.current.isLoading).toBe(false) await act(async () => { resolveRefetch([]) }) expect(result.current.isRefreshing).toBe(false) expect(result.current.isLoading).toBe(false) // Settling further (no further bump) raises no additional GET. rerender() await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) }) expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBe(callCountAfterMount + 1) }) describe('M7 follow-up — the StrictMode double-mount hang', () => { const strictModeWrapper = ({ children }: { children: ReactNode }) => createElement(StrictMode, null, children) it('reproduces the reported hang: navigating to a page that mounts with childId already known must not strand isLoading true', async () => { // React 18 StrictMode double-invokes a fresh mount's effect, // back-to-back, before either invocation's fetch has resolved. On a // real SPA navigation `childId` is already known at mount (unlike a // hard reload, where it's still `undefined` on the very first // render) — this is exactly what the bug report's "works on hard // reload, hangs on in-app navigation" symptom traced back to. vi.mocked(api.getPlannedDays).mockResolvedValue([]) const { result } = renderHook(() => usePlannedDays('child1'), { wrapper: strictModeWrapper, }) await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) }) expect(result.current.isLoading).toBe(false) expect(result.current.isRefreshing).toBe(false) expect(result.current.data).toEqual([]) // The dedup fix means the double-invoked mount effect still issues // exactly one real HTTP request, not two. expect(api.getPlannedDays).toHaveBeenCalledTimes(1) }) it('two overlapping calls for the same childId — one foreground, one background, started before either resolves — both end up cleared: isLoading false, isRefreshing false, exactly one request', async () => { // The same hazard, exercised directly (per the coordinator's // alternative to a StrictMode wrapper): two calls for the identical // childId, racing, with DIFFERENT background flags — the exact shape // that stranded `isLoading` before this fix (the stale call raised // one flag, the current call raised the other, and only the current // call's own — guarded — clear ever ran). let resolveFetch: (value: ReturnType[]) => void = () => {} vi.mocked(api.getPlannedDays).mockReturnValue( new Promise((resolve) => { resolveFetch = resolve }), ) const { result } = renderHook(() => usePlannedDays('child1')) // The mount effect already started one (foreground) call — fire a // second, differently-classified call for the SAME childId before it // resolves. let secondCall: Promise = Promise.resolve() act(() => { secondCall = result.current.refresh({ background: true }) }) expect(result.current.isLoading).toBe(true) await act(async () => { resolveFetch([]) await secondCall }) expect(result.current.isLoading).toBe(false) expect(result.current.isRefreshing).toBe(false) // Deduped into a single request. expect(api.getPlannedDays).toHaveBeenCalledTimes(1) }) }) describe('M7 part 2 — one trailing bump for a whole batch of writes', () => { // The per-write `skipRefresh` option itself lives on the writer hook // now (`useDayAssertions.assertDay` — see its tests); this covers the // reader's side of that contract. it('a batch of skipRefresh writes followed by one bumpPlanVersion() call (Calendar.tsx\'s multi-day save pattern) produces exactly one trailing background GET', async () => { vi.mocked(api.getPlannedDays).mockResolvedValue([]) // A real counter (not this file's default inert `vi.fn()`) so calling // `bump()` — standing in for `Calendar.tsx handleSavePinnedDay`'s // trailing `bumpPlanVersion()` — then `rerender()`ing genuinely // reproduces the real `WorkspaceProvider` state update that follows. let planVersion = 0 const bump = () => { planVersion += 1 } vi.mocked(useWorkspace).mockImplementation(() => makeWorkspaceContextValue({ planVersion, bumpPlanVersion: bump }), ) const { result, rerender } = renderHook(() => usePlannedDays('child1')) await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) }) const callCountAfterMount = vi.mocked(api.getPlannedDays).mock.calls.length await act(async () => { // Simulates Calendar.tsx's multi-day save loop: N writes through // `useDayAssertions.assertDay(..., { skipRefresh: true })`, none of // which touch this hook, then exactly ONE trailing bump for the // whole batch (mirrors `handleSavePinnedDay`'s `finally`). bump() }) // The bump only takes effect on this instance's next render — nothing // has refetched yet. expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBe(callCountAfterMount) rerender() await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) }) // Exactly one trailing GET for the whole batch, and it never blocked. expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBe(callCountAfterMount + 1) expect(result.current.isLoading).toBe(false) expect(result.current.isRefreshing).toBe(false) }) }) it('sets loading state during fetch (M7 part 1 — the initial mount load DOES block, unlike a post-mutation refetch)', async () => { let resolveGetPlannedDays: (value: Awaited>) => void const getPlannedDaysPromise: ReturnType = new Promise(resolve => { resolveGetPlannedDays = resolve }) vi.mocked(api.getPlannedDays).mockReturnValue(getPlannedDaysPromise) const { result } = renderHook(() => usePlannedDays('child1')) // The very first fetch for a childId has nothing to render yet — it // must block, unlike every subsequent mutation-triggered refetch. expect(result.current.isLoading).toBe(true) expect(result.current.isRefreshing).toBe(false) await act(async () => { resolveGetPlannedDays!([]) await getPlannedDaysPromise }) expect(result.current.isLoading).toBe(false) }) it('clears error on successful fetch after error', async () => { vi.mocked(api.getPlannedDays).mockRejectedValueOnce(new Error('Network error')) vi.mocked(api.getPlannedDays).mockResolvedValueOnce([]) const { result, rerender } = renderHook(({ childId }: { childId: string }) => usePlannedDays(childId), { initialProps: { childId: 'child1' }, }) await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) }) expect(result.current.error).toBe('Network error') rerender({ childId: 'child2' }) await act(async () => { await new Promise(resolve => setTimeout(resolve, 0)) }) expect(result.current.error).toBeNull() }) it('discards a stale response for a previous childId (P1 stale-response guard)', async () => { let resolveFirst: (value: ReturnType[]) => void = () => {} let resolveSecond: (value: ReturnType[]) => void = () => {} const firstPromise = new Promise[]>((resolve) => { resolveFirst = resolve }) const secondPromise = new Promise[]>((resolve) => { resolveSecond = resolve }) vi.mocked(api.getPlannedDays).mockReturnValueOnce(firstPromise).mockReturnValueOnce(secondPromise) const { result, rerender } = renderHook( ({ childId }: { childId: string }) => usePlannedDays(childId), { initialProps: { childId: 'child1' } }, ) // Switching childId while the first (slower) request is still in // flight fires a second request for the new child. rerender({ childId: 'child2' }) const child1Data = [ makePlannedLeaveDay({ id: 'p1', workspaceId: 'ws1', childId: 'child1', parentPersonalDataUuid: 'parent1', date: '2026-06-01', dayType: 'vacation', isDubbeldag: false, }), ] const child2Data = [ makePlannedLeaveDay({ id: 'p2', workspaceId: 'ws1', childId: 'child2', parentPersonalDataUuid: 'parent1', date: '2026-07-01', dayType: 'vacation', isDubbeldag: false, }), ] // The newer (child2) request resolves first... await act(async () => { resolveSecond(child2Data) await secondPromise }) expect(result.current.data).toEqual(child2Data) // ...then the stale child1 response arrives late and must not overwrite it. await act(async () => { resolveFirst(child1Data) await firstPromise }) expect(result.current.data).toEqual(child2Data) }) describe('plan-version invalidation', () => { it('refetches when planVersion changes, even without a local mutation (invalidation from another hook, e.g. useDayAssertions or usePlanSync)', async () => { vi.mocked(api.getPlannedDays).mockResolvedValue([]) const { rerender } = renderHook(() => usePlannedDays('child1')) await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) }) const callCountBefore = vi.mocked(api.getPlannedDays).mock.calls.length // Simulate another hook (e.g. useDayAssertions) bumping the shared counter. vi.mocked(useWorkspace).mockReturnValue( makeWorkspaceContextValue({ planVersion: 1, bumpPlanVersion }), ) rerender() await act(async () => { await new Promise((resolve) => setTimeout(resolve, 0)) }) expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBeGreaterThan(callCountBefore) }) }) })