Merge branch 'worktree-check-security-audit'
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
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
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'
|
||||
@@ -116,7 +117,193 @@ describe('usePlannedDays', () => {
|
||||
expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBeGreaterThan(callCount)
|
||||
})
|
||||
|
||||
it('sets loading state during fetch', async () => {
|
||||
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
|
||||
@@ -126,7 +313,10 @@ describe('usePlannedDays', () => {
|
||||
|
||||
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!([])
|
||||
|
||||
Reference in New Issue
Block a user