4fa79bad77
Security and frontend-correctness hardening: analytics IDOR fix, scraper log scrubbing, lowercase email normalisation (migration 009), stale-response guards in the plan hooks, busy flags cleared unconditionally across all useLatestRequest consumers, Calendar no longer blanks on mutation, plus dependabot/CI config and removal of the stray frontend/out.js. Conflicts came from main's later "day assertions" restructure, which made usePlannedDays read-only and replaced the calendar's per-day add/update with useDayAssertions.assertDay. Both intents kept: - usePlannedDays: branch's fetch machinery (isRefreshing, in-flight dedup, unguarded busy clear); mutators dropped as on main. - usePlanSync: branch's useLatestRequest guard also applied to main's new syncWithWish. - useDayAssertions / useLatestBalance (new on main): busy flag now cleared unconditionally, as the branch did for every other consumer. - useDayAssertions.assertDay gains the branch's `skipRefresh` option and a `refresh`, so Calendar's multi-day save keeps the batch shape (N writes, one trailing bumpPlanVersion + one assertions refresh) on main's assertDay path. Branch's M7 tests ported from the removed add/update API to assertDay. - PlanSyncProvider: branch's isAuthenticated gate kept; eviction-preview state stays removed as on main. - usePlannedDayExclusions.ts stays deleted (superseded by useDayAssertions). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
435 lines
16 KiB
TypeScript
435 lines
16 KiB
TypeScript
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<typeof vi.fn>
|
|
|
|
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<typeof makePlannedLeaveDay>[]) => 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<typeof makePlannedLeaveDay>[]) => 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<unknown> = 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<ReturnType<typeof api.getPlannedDays>>) => void
|
|
const getPlannedDaysPromise: ReturnType<typeof api.getPlannedDays> = 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<typeof makePlannedLeaveDay>[]) => void = () => {}
|
|
let resolveSecond: (value: ReturnType<typeof makePlannedLeaveDay>[]) => void = () => {}
|
|
const firstPromise = new Promise<ReturnType<typeof makePlannedLeaveDay>[]>((resolve) => {
|
|
resolveFirst = resolve
|
|
})
|
|
const secondPromise = new Promise<ReturnType<typeof makePlannedLeaveDay>[]>((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)
|
|
})
|
|
})
|
|
})
|