Close the residual gaps the 2026-09-18 audit left open
Re-checked every item of the audit's own outcome section against the tree.
Most held, but five items marked fixed had residual gaps — one of them a
live IDOR — and the deferred list was untouched.
Security:
- Analytics IDOR: /api/analytics/{ws}/overview and /projection took
child_id as an unchecked query param, and get_overview called
get_days_left_at_age_4 without workspace_id, so the ownership guard
never ran. Any member could read another workspace's child projection.
Fixed at both layers.
- Scraper: session ids no longer logged at INFO; a persist-callback
failure's raw exception text and the FK maintenance banner's scraped
text no longer reach the poll client.
- Email is now case-normalised at the API boundary, with migration 009
lowercasing existing rows (aborts naming colliding rows rather than
failing opaquely on the UNIQUE constraint).
Frontend correctness:
- usePlanProposal/usePlanSync gained the stale-response guard the other
hooks already had.
- PlanSyncProvider no longer fires POST /plan/sync before auth resolves
off a blindly-read localStorage workspaceId.
- Busy flags are cleared unconditionally across all 16 useLatestRequest
consumers. setBusy(true) was unconditional while the clear was guarded
by isCurrent, so a superseded call stranded its flag forever — under
StrictMode this hung every in-app navigation behind the PageSkeleton.
isCurrent decides which response's data wins, never whether a call
releases the flag it raised.
Performance:
- Calendar no longer blanks on a mutation, and a multi-day save issues N
writes plus one background refetch instead of 2N requests.
- take_pl_on_red_days moved onto GET /auth/context, dropping a redundant
GET /workspace/:id from every cold load.
CI and repo hygiene: least-privilege permissions block, actions pinned to
commit SHAs, npm ci, uv sync --locked, failure-only artifact upload,
dependabot; dead frontend/out.js removed; CSP added to the nginx template
(repeated into the location blocks that set their own add_header, since
nginx does not merge them across levels).
Left deferred by decision: refresh-token revocation, the phase-2 contract
change, M4, M5, L2, and H12's cross-route cache. Details and the smaller
follow-ups browser verification turned up are in the audit report.
Backend 901 passed, coverage 92%. Frontend 1322 tests, coverage
98.17/94.79/92.14/98.17.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PngVF38MLpdkgXcByKfdW6
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'
|
||||
@@ -80,18 +81,43 @@ describe('usePlannedDays', () => {
|
||||
expect(api.createPlannedDay).not.toHaveBeenCalled() // won't be called without user action
|
||||
})
|
||||
|
||||
it('removes planned day and refreshes', async () => {
|
||||
it('removes planned day, and the resulting planVersion bump triggers a background refetch on the next render', async () => {
|
||||
vi.mocked(api.deletePlannedDay).mockResolvedValue(undefined)
|
||||
vi.mocked(api.getPlannedDays).mockResolvedValue([])
|
||||
// `remove` itself never fetches directly (see usePlannedDays.ts's doc
|
||||
// comment on the `planVersion` effect) — only the bump it calls does,
|
||||
// once this instance re-renders with the new value, exactly as the real
|
||||
// `WorkspaceProvider`'s state update would. A bare `vi.fn()` for
|
||||
// `bumpPlanVersion` (this file's default) never causes that re-render on
|
||||
// its own, so this test wires up a real counter + explicit `rerender()`.
|
||||
let planVersion = 0
|
||||
vi.mocked(useWorkspace).mockImplementation(() =>
|
||||
makeWorkspaceContextValue({
|
||||
planVersion,
|
||||
bumpPlanVersion: () => {
|
||||
planVersion += 1
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => usePlannedDays('child1'))
|
||||
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 () => {
|
||||
await result.current.remove('pd1')
|
||||
})
|
||||
|
||||
expect(api.deletePlannedDay).toHaveBeenCalledWith('pd1')
|
||||
expect(api.getPlannedDays).toHaveBeenCalled()
|
||||
// Nothing has refetched yet — the bump only takes effect next render.
|
||||
expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBe(callCountAfterMount)
|
||||
|
||||
rerender()
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBeGreaterThan(callCountAfterMount)
|
||||
})
|
||||
|
||||
it('handles fetch error and sets error state', async () => {
|
||||
@@ -122,7 +148,7 @@ describe('usePlannedDays', () => {
|
||||
expect(result.current.data).toBeNull()
|
||||
})
|
||||
|
||||
it('updates planned day and refreshes', async () => {
|
||||
it('updates planned day, and the resulting planVersion bump triggers a background refetch on the next render', async () => {
|
||||
const mockUpdated = makePlannedLeaveDay({
|
||||
id: '1',
|
||||
workspaceId: 'ws1',
|
||||
@@ -135,15 +161,33 @@ describe('usePlannedDays', () => {
|
||||
|
||||
vi.mocked(api.updatePlannedDay).mockResolvedValue(mockUpdated)
|
||||
vi.mocked(api.getPlannedDays).mockResolvedValue([mockUpdated])
|
||||
let planVersion = 0
|
||||
vi.mocked(useWorkspace).mockImplementation(() =>
|
||||
makeWorkspaceContextValue({
|
||||
planVersion,
|
||||
bumpPlanVersion: () => {
|
||||
planVersion += 1
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => usePlannedDays('child1'))
|
||||
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 () => {
|
||||
await result.current.update('1', { day_type: 'lagsta' })
|
||||
})
|
||||
|
||||
expect(api.updatePlannedDay).toHaveBeenCalledWith('1', { day_type: 'lagsta' })
|
||||
expect(api.getPlannedDays).toHaveBeenCalled()
|
||||
expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBe(callCountAfterMount)
|
||||
|
||||
rerender()
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBeGreaterThan(callCountAfterMount)
|
||||
})
|
||||
|
||||
it('updates data after successful add', async () => {
|
||||
@@ -172,8 +216,21 @@ describe('usePlannedDays', () => {
|
||||
vi.mocked(api.getPlannedDays).mockResolvedValueOnce(initialData)
|
||||
vi.mocked(api.createPlannedDay).mockResolvedValue(newData)
|
||||
vi.mocked(api.getPlannedDays).mockResolvedValueOnce([...initialData, newData])
|
||||
// `add` bumps `planVersion` but never fetches itself — the background
|
||||
// refetch that lands the fresh data only happens once this instance
|
||||
// re-renders with the new value, exactly as the real `WorkspaceProvider`
|
||||
// update would (see usePlannedDays.ts's doc comment).
|
||||
let planVersion = 0
|
||||
vi.mocked(useWorkspace).mockImplementation(() =>
|
||||
makeWorkspaceContextValue({
|
||||
planVersion,
|
||||
bumpPlanVersion: () => {
|
||||
planVersion += 1
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => usePlannedDays('child1'))
|
||||
const { result, rerender } = renderHook(() => usePlannedDays('child1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
@@ -184,6 +241,13 @@ describe('usePlannedDays', () => {
|
||||
await act(async () => {
|
||||
await result.current.add({ workspace_id: 'ws1', child_id: 'child1', date: '2026-06-16', day_type: 'vacation', parent_personal_data_uuid: 'parent1' })
|
||||
})
|
||||
// Nothing has refetched yet.
|
||||
expect(result.current.data).toEqual(initialData)
|
||||
|
||||
rerender()
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(result.current.data).toEqual([...initialData, newData])
|
||||
})
|
||||
@@ -213,8 +277,17 @@ describe('usePlannedDays', () => {
|
||||
vi.mocked(api.getPlannedDays).mockResolvedValueOnce(initialData)
|
||||
vi.mocked(api.deletePlannedDay).mockResolvedValue(undefined)
|
||||
vi.mocked(api.getPlannedDays).mockResolvedValueOnce([initialData[0]!])
|
||||
let planVersion = 0
|
||||
vi.mocked(useWorkspace).mockImplementation(() =>
|
||||
makeWorkspaceContextValue({
|
||||
planVersion,
|
||||
bumpPlanVersion: () => {
|
||||
planVersion += 1
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const { result } = renderHook(() => usePlannedDays('child1'))
|
||||
const { result, rerender } = renderHook(() => usePlannedDays('child1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
@@ -225,6 +298,13 @@ describe('usePlannedDays', () => {
|
||||
await act(async () => {
|
||||
await result.current.remove('2')
|
||||
})
|
||||
// Nothing has refetched yet — `remove` only bumps `planVersion`.
|
||||
expect(result.current.data).toEqual(initialData)
|
||||
|
||||
rerender()
|
||||
await act(async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
expect(result.current.data).toEqual([initialData[0]])
|
||||
})
|
||||
@@ -260,7 +340,256 @@ 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 `add`/`update`/`remove` 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. `add`/`update`/`remove` 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([])
|
||||
vi.mocked(api.createPlannedDay).mockResolvedValue(
|
||||
makePlannedLeaveDay({ id: '1', date: '2026-06-16' }),
|
||||
)
|
||||
// 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 (via
|
||||
// `add`) and then `rerender()`ing genuinely reproduces what the real
|
||||
// `WorkspaceProvider`'s state update would do.
|
||||
let planVersion = 0
|
||||
vi.mocked(useWorkspace).mockImplementation(() =>
|
||||
makeWorkspaceContextValue({
|
||||
planVersion,
|
||||
bumpPlanVersion: () => {
|
||||
planVersion += 1
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
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 () => {
|
||||
await result.current.add({
|
||||
workspace_id: 'ws1',
|
||||
child_id: 'child1',
|
||||
date: '2026-06-16',
|
||||
day_type: 'vacation',
|
||||
parent_personal_data_uuid: 'parent1',
|
||||
})
|
||||
})
|
||||
// `add` itself never fetched — 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 — suppressing the per-call bumpPlanVersion (skipRefresh)', () => {
|
||||
it('add: skipRefresh skips this call\'s own bumpPlanVersion', async () => {
|
||||
vi.mocked(api.getPlannedDays).mockResolvedValue([])
|
||||
vi.mocked(api.createPlannedDay).mockResolvedValue(
|
||||
makePlannedLeaveDay({ id: '1', date: '2026-06-16' }),
|
||||
)
|
||||
const { result } = renderHook(() => usePlannedDays('child1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.add(
|
||||
{
|
||||
workspace_id: 'ws1',
|
||||
child_id: 'child1',
|
||||
date: '2026-06-16',
|
||||
day_type: 'vacation',
|
||||
parent_personal_data_uuid: 'parent1',
|
||||
},
|
||||
{ skipRefresh: true },
|
||||
)
|
||||
})
|
||||
|
||||
expect(api.createPlannedDay).toHaveBeenCalled()
|
||||
expect(bumpPlanVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('update: skipRefresh skips this call\'s own bumpPlanVersion', async () => {
|
||||
vi.mocked(api.getPlannedDays).mockResolvedValue([])
|
||||
vi.mocked(api.updatePlannedDay).mockResolvedValue(makePlannedLeaveDay({ id: '1' }))
|
||||
const { result } = renderHook(() => usePlannedDays('child1'))
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
await result.current.update('1', { day_type: 'lagsta' }, { skipRefresh: true })
|
||||
})
|
||||
|
||||
expect(api.updatePlannedDay).toHaveBeenCalledWith('1', { day_type: 'lagsta' })
|
||||
expect(bumpPlanVersion).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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([])
|
||||
vi.mocked(api.createPlannedDay).mockResolvedValue(
|
||||
makePlannedLeaveDay({ id: '1', date: '2026-06-16' }),
|
||||
)
|
||||
// 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, every one
|
||||
// suppressing its own bump...
|
||||
await result.current.add(
|
||||
{ workspace_id: 'ws1', child_id: 'child1', date: '2026-06-16', day_type: 'vacation', parent_personal_data_uuid: 'parent1' },
|
||||
{ skipRefresh: true },
|
||||
)
|
||||
await result.current.add(
|
||||
{ workspace_id: 'ws1', child_id: 'child1', date: '2026-06-17', day_type: 'vacation', parent_personal_data_uuid: 'parent1' },
|
||||
{ skipRefresh: true },
|
||||
)
|
||||
// ...then exactly ONE trailing bump for the whole batch (mirrors
|
||||
// `handleSavePinnedDay`'s `finally`).
|
||||
bump()
|
||||
})
|
||||
|
||||
expect(api.createPlannedDay).toHaveBeenCalledTimes(2)
|
||||
// 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
|
||||
@@ -270,7 +599,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!([])
|
||||
@@ -550,8 +882,9 @@ describe('usePlannedDays', () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
})
|
||||
|
||||
// Own direct fetch() after add, plus one effect-triggered refetch from
|
||||
// its own bump — not an unbounded/growing number of calls.
|
||||
// `add` itself never fetches — only this one effect-triggered refetch
|
||||
// from its own bump landing on the rerender above. Not an
|
||||
// unbounded/growing number of calls on further settling.
|
||||
const callCountAfterFirstSettle = vi.mocked(api.getPlannedDays).mock.calls.length
|
||||
rerender()
|
||||
await act(async () => {
|
||||
|
||||
Reference in New Issue
Block a user