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:
@@ -0,0 +1,16 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/frontend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
|
||||
- package-ecosystem: "uv"
|
||||
directory: "/backend"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
+15
-12
@@ -5,6 +5,9 @@ on:
|
||||
branches: [main, develop]
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backend: ruff lint + pytest with coverage gate (rises as junior tests land)
|
||||
@@ -17,14 +20,14 @@ jobs:
|
||||
working-directory: backend
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync
|
||||
run: uv sync --locked
|
||||
|
||||
- name: Lint (ruff)
|
||||
run: uv run ruff check .
|
||||
@@ -47,9 +50,9 @@ jobs:
|
||||
working-directory: frontend
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: "npm"
|
||||
@@ -81,23 +84,23 @@ jobs:
|
||||
continue-on-error: true
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
|
||||
|
||||
- uses: astral-sh/setup-uv@v5
|
||||
- uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
|
||||
with:
|
||||
version: "latest"
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install backend dependencies
|
||||
working-directory: backend
|
||||
run: uv sync
|
||||
run: uv sync --locked
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: npm install
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: frontend
|
||||
@@ -108,8 +111,8 @@ jobs:
|
||||
run: npm run e2e
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: frontend/playwright-report/
|
||||
|
||||
@@ -233,6 +233,9 @@ dist
|
||||
/package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Dead hand-committed minified bundle (see SECURITY_PERF_AUDIT_2026-09-18.md L5)
|
||||
frontend/out.js
|
||||
|
||||
# Playwright (frontend/e2e)
|
||||
frontend/e2e/.auth/
|
||||
frontend/playwright-report/
|
||||
|
||||
@@ -57,3 +57,44 @@ Fixed in commit "Harden the API and the data layer after a security and performa
|
||||
Final gates: backend 632 passed / ruff clean / alembic head OK; frontend 958 tests, typecheck+lint clean, cov 98.77/94.64/92.04/98.77.
|
||||
Deferred (follow-up list): H12 cold-load waterfall + no cross-route cache; M4 rate limiter proxy trust / shared store; M5 N+1 query collapsing (0.05 ms/query — not a bottleneck); M7 Calendar spinner on every mutation + sequential multi-day save; M9 (moot server-side); L2 drop personnummer_enc; L3 CI hardening (permissions block, SHA pins, npm ci, failure-only artefact upload, uv lock --check, dependabot); L5 out.js removal, CSP meta, dead /calendar?date= link.
|
||||
Phase-2 contract change (not started): drop workspace_id from request bodies and derive it from the caller.
|
||||
|
||||
## Re-check (2026-09-20)
|
||||
Verified every item of 2026-09-18 outcome against the tree. Confirmed intact: C1, C2, C3, H5–H11, M1, M2, M3, M6, M8, L1 (partly), L4. Five residual gaps found inside items marked fixed; deferred list untouched.
|
||||
|
||||
Fixed this pass:
|
||||
- R1 (residual H1) — IDOR on `/api/analytics/{ws}/overview` and `/projection`: `child_id` unchecked query param, analytics_service.get_overview() called get_days_left_at_age_4 without workspace_id (guard at analytics_service.py:240 never ran). Member of workspace A could read workspace B's child projection. Fixed: `scope.check_child(child_id)` in router (matching /age4 route pattern) and `workspace_id=` passed through service.
|
||||
- R2 (residual H2) — session ids logged at INFO throughout fk_scraper.py (172,198). All session-id-carrying logger.info() calls downgraded to DEBUG; "scraper session done" kept at INFO without id; debug-artifact warning stays WARNING without id. One WARNING (tier sum mismatch, fk_scraper.py:723) deliberately keeps id.
|
||||
- R3 (residual H3) — raw str(exc) from persist-callback failure and FK maintenance banner's scraped text reached poll client. Both replaced with fixed strings; full detail stays in ERROR/DEBUG logs.
|
||||
- R4 (residual L1) — case-sensitive email: Foo@x.se and foo@x.se became two accounts. Lowercasing field_validator on RegisterRequest/LoginRequest; migration 009_lowercase_user_email (aborts with named-rows error if two rows would collide, not opaque UNIQUE constraint failure).
|
||||
- R5 (residual H11) — usePlanProposal and usePlanSync had no stale-response guard. Both now use shared useLatestRequest helper (as 15 other hooks do).
|
||||
- M9 — WorkspaceContext seeded workspaceId from localStorage synchronously; PlanSyncProvider's effect did not depend on auth → cold load POST /api/plan/sync before auth resolved, 401'd, raced AuthContext's own refresh. Effect now gated on isAuthenticated.
|
||||
- M7 — every planned-day mutation blanked Calendar behind skeleton; multi-day save issued 2N requests (write+full refetch per date). usePlannedDays gained background/isRefreshing split (like useLeaveWishes) + skipRefresh option; Calendar's multi-day loop now N writes + one trailing background refresh + one bumpPlanVersion in finally (error path refreshes too). runSync's refresh also made background.
|
||||
- H12 (partial) — take_pl_on_red_days added to GET /auth/context response; WorkspaceContext.refreshContext now builds workspace state from context alone, drops redundant GET /workspace/:id from every cold load. Workspace.name/createdAt optional (no read-side consumer in src/).
|
||||
- L3 — CI hardened: top-level permissions: contents: read; all four actions pinned to commit SHAs; e2e npm install → npm ci; both uv sync → uv sync --locked; playwright-report upload if: always() → if: failure(). New .github/dependabot.yml (github-actions, npm, uv).
|
||||
- L5 (partial) — frontend/out.js dead artifact removed + gitignored. CSP added to nginx.conf.template (from production build). Note: nginx does not merge add_header across levels; four security headers repeated in three location blocks (Cache-Control: /assets/, /fonts/, = /index.html) — verified curl against running container all four present on every route. Three pre-existing headers had silently been absent from those routes before.
|
||||
|
||||
Decisions taken (not oversights):
|
||||
- H4 refresh-token revocation (jti/iat) NOT implemented — stateless refresh stands. is_active checked on refresh → disabled account cut off; only per-token revocation missing.
|
||||
- Phase-2 contract change (drop workspace_id from bodies) deferred — every route already reconciles body value against caller's scope, defence-in-depth not a live hole.
|
||||
- H12 step 2 (cross-route cache) re-deferred: of 7 Dashboard fan-out endpoints only 2 safe to cache today; 5 have same-hook or cross-page writers (usePlannedDays, useLeaveWishes, useWorkspaceMembers/Settings, saveManualFetch) not wired to shared invalidation signal. URL-keyed cache would serve pre-mutation data to mutation's own refetch. If revisited, go endpoint-by-endpoint.
|
||||
- Still deferred: M4 (rate limiter per-process/IP-keyed/proxy trust), M5 (N+1 fan-outs, 0.05 ms/query), L2 (drop personnummer_enc), L5 bundle splitting.
|
||||
- New follow-up: /auth/context could also carry workspace name/created_at if future feature needs them on cold load.
|
||||
|
||||
Gates after this pass: backend 901 passed / ruff clean / alembic head OK / coverage 92%. Frontend 1320 tests in 75 files / typecheck+lint clean / coverage 98.16 lines, 94.79 functions, 92.15 branches, 98.16 statements.
|
||||
|
||||
### Browser verification (2026-09-20) and two defects it caught
|
||||
Two rounds of real-browser verification against seeded dev instance caught two bugs the unit suites missed; both fixed + re-verified.
|
||||
- M7 incomplete on first landing: `usePlannedDays`' `planVersion` effect called `fetch()` with no options, so mutators' `bumpPlanVersion()` re-ran it as second blocking fetch—2 GETs per save, page blanked on Advanced (direct planned-day) save path. Fixed: effect sole fetcher, mutators only bump, effect backgrounds everything but first load or childId switch.
|
||||
- `isLoading` stranded forever on in-app navigation: `useLatestRequest` pattern latent flaw — `setBusy(true)` ran unconditional but `finally { if (isCurrent(requestId)) setBusy(false) }` cleared it only for still-current request, so superseded calls left flag set. React StrictMode double-invokes mount effect; on SPA navigation (childId already known) both invocations fetched—one foreground, one background—leaving `isLoading` true behind PageSkeleton. Fixed in all 16 hooks: unguarded busy-flag clear (`isCurrent` decides which response's DATA wins, never whether call releases flag it raised); plus in-flight dedup in `usePlannedDays`. Regression tests confirmed to fail against pre-fix code.
|
||||
|
||||
Verified in-browser post-fix: 4 alternating in-app navigations all render without skeleton; 1 GET /api/planned-days per navigation; Advanced single-day save = 1 write + 1 GET, no blanking; 4-day drag-select = 4 writes back-to-back + exactly 1 trailing GET; cold load issues no GET /api/workspace/{id}; red-days flag round-trips through /auth/context and honored by Calendar's free-day filter; SV and EN both clean.
|
||||
|
||||
New follow-ups found (not fixed):
|
||||
- `useCalendarDays` mutates `isFirstPlanVersionRun` ref in effect body; StrictMode double-invoke can fire `background: true` fetch on page with nothing loaded. Unguarded flag clear stops hangs, can still cause transient extra request / brief flash (dev only).
|
||||
- `PlanSyncProvider` handles stale `localStorage` workspaceId poorly: after workspace it names is gone, background sync 404s and logs error instead of clearing stale id.
|
||||
- Cold load issues `GET /api/workspace/{ws}/members` twice.
|
||||
- Cold load issues `POST /api/auth/refresh` twice (consistent with StrictMode double-invoke; dev-only, unconfirmed in prod).
|
||||
- `POST /api/plan/sync` fires before `GET /api/auth/context` resolves (safe: auth resolved and server scope-checks; ordering surprise).
|
||||
- Child-switch behavior could not be browser-tested: demo seed has one child only.
|
||||
|
||||
Frontend gates after final fix: 75 files / 1322 tests / 98.17 lines, 94.79 functions, 92.14 branches, 98.17 statements. Branch coverage 0.14 above gate of 92—thin.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Pydantic schemas for auth endpoints."""
|
||||
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
from pydantic import BaseModel, EmailStr, Field, field_validator
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
@@ -13,11 +13,25 @@ class RegisterRequest(BaseModel):
|
||||
# if one exists) instead of creating a new one.
|
||||
invite_token: str | None = None
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def normalize_email(cls, v: str) -> str:
|
||||
"""Lowercase at the API boundary — storage/lookup is case-sensitive
|
||||
(`WHERE email = ?`), so Foo@x.se and foo@x.se must resolve to one
|
||||
account rather than silently becoming two."""
|
||||
return v.lower()
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
@field_validator("email")
|
||||
@classmethod
|
||||
def normalize_email(cls, v: str) -> str:
|
||||
"""Lowercase at the API boundary — see `RegisterRequest.normalize_email`."""
|
||||
return v.lower()
|
||||
|
||||
|
||||
class TokenResponse(BaseModel):
|
||||
access_token: str
|
||||
|
||||
@@ -44,7 +44,9 @@ async def get_overview(
|
||||
pd_repo: PersonalDataRepositoryDep,
|
||||
workspace_repo: WorkspaceRepositoryDep,
|
||||
vacation_repo: VacationDaysRepositoryDep,
|
||||
scope: WorkspaceScopeDep,
|
||||
) -> WorkspaceOverviewResponse:
|
||||
await scope.check_child(child_id)
|
||||
service = _make_service(
|
||||
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
|
||||
)
|
||||
@@ -65,7 +67,9 @@ async def get_projection(
|
||||
pd_repo: PersonalDataRepositoryDep,
|
||||
workspace_repo: WorkspaceRepositoryDep,
|
||||
vacation_repo: VacationDaysRepositoryDep,
|
||||
scope: WorkspaceScopeDep,
|
||||
) -> WorkspaceOverviewResponse:
|
||||
await scope.check_child(child_id)
|
||||
service = _make_service(
|
||||
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.dependencies import (
|
||||
ChildRepositoryDep,
|
||||
CurrentUserDep,
|
||||
PersonalDataRepositoryDep,
|
||||
WorkspaceRepositoryDep,
|
||||
)
|
||||
from app.rate_limit import limiter
|
||||
from app.models.auth import LoginRequest, RegisterRequest, TokenResponse, UserMeResponse
|
||||
@@ -105,6 +106,11 @@ class _UserContext(BaseModel):
|
||||
workspace_id: str
|
||||
parent_name: str
|
||||
children: list[_ChildContext]
|
||||
# Mirrors `WorkspaceResponse.take_pl_on_red_days` — included here so the
|
||||
# frontend's cold-load `refreshContext()` can build its `workspace`
|
||||
# state from this response alone, without a follow-up `GET
|
||||
# /workspace/:id` (see AGENTS.md H12 step 1).
|
||||
take_pl_on_red_days: bool
|
||||
|
||||
|
||||
@router.get("/context", response_model=_UserContext)
|
||||
@@ -112,12 +118,16 @@ async def get_context(
|
||||
current_user: CurrentUserDep,
|
||||
pd_repo: PersonalDataRepositoryDep,
|
||||
child_repo: ChildRepositoryDep,
|
||||
ws_repo: WorkspaceRepositoryDep,
|
||||
) -> _UserContext:
|
||||
"""Return workspace + children for the current user. Used by the dev harness."""
|
||||
pd = await pd_repo.get_by_id(current_user.personal_data_uuid)
|
||||
if pd is None:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "No parent profile found")
|
||||
children = await child_repo.list_by_workspace(pd.workspace_id)
|
||||
workspace = await ws_repo.get_by_id(pd.workspace_id)
|
||||
if workspace is None:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, "No workspace found")
|
||||
return _UserContext(
|
||||
personal_data_uuid=str(pd.uuid),
|
||||
workspace_id=str(pd.workspace_id),
|
||||
@@ -125,6 +135,7 @@ async def get_context(
|
||||
children=[
|
||||
_ChildContext(id=str(c.id), name=c.name, birth_date=str(c.birth_date)) for c in children
|
||||
],
|
||||
take_pl_on_red_days=workspace.take_pl_on_red_days,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -306,7 +306,7 @@ class FKScraperService(FetchDataService):
|
||||
# Schedule TTL watchdog
|
||||
asyncio.create_task(_ttl_watchdog(session_id))
|
||||
|
||||
logger.info("scraper session started [id=%s]", session_id)
|
||||
logger.debug("scraper session started [id=%s]", session_id)
|
||||
return session_id
|
||||
|
||||
async def poll_session(self, session_id: str, owner_user_id: UUID) -> dict:
|
||||
@@ -345,7 +345,7 @@ class FKScraperService(FetchDataService):
|
||||
state.qr_data = None
|
||||
if state._task and not state._task.done():
|
||||
state._task.cancel()
|
||||
logger.info("scraper session cancelled [id=%s]", session_id)
|
||||
logger.debug("scraper session cancelled [id=%s]", session_id)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -390,7 +390,7 @@ async def _ttl_watchdog(session_id: str) -> None:
|
||||
state.qr_data = None
|
||||
if state._task and not state._task.done():
|
||||
state._task.cancel()
|
||||
logger.info("scraper session timed out [id=%s]", session_id)
|
||||
logger.debug("scraper session timed out [id=%s]", session_id)
|
||||
elif state.status in ("done", "error", "cancelled"):
|
||||
_sessions.pop(session_id, None)
|
||||
|
||||
@@ -451,13 +451,13 @@ async def _save_debug_artifacts(page, session_id: str, console_messages: list[st
|
||||
screenshot_path = prefix.with_suffix(".png")
|
||||
await page.screenshot(path=str(screenshot_path), full_page=True)
|
||||
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"scraper debug artifacts saved [id=%s] dir=%s",
|
||||
session_id,
|
||||
out.resolve(),
|
||||
)
|
||||
except Exception as artifact_exc:
|
||||
logger.warning("failed to save debug artifacts [id=%s]: %s", session_id, artifact_exc)
|
||||
logger.warning("failed to save debug artifacts: %s", artifact_exc)
|
||||
|
||||
|
||||
async def _run_session(state: _SessionState) -> None:
|
||||
@@ -509,9 +509,9 @@ async def _do_login(page, state: _SessionState) -> None:
|
||||
# Check for maintenance message before proceeding
|
||||
maintenance_msg = await _check_maintenance(page)
|
||||
if maintenance_msg:
|
||||
error_msg = f"FK is under maintenance: {maintenance_msg}"
|
||||
_set_status(state, "error", error_msg)
|
||||
logger.info("[%s] FK maintenance detected; aborting session", state.session_id)
|
||||
_set_status(state, "error", "FK is currently under maintenance. Please try again later.")
|
||||
logger.debug("[%s] FK maintenance banner text: %s", state.session_id, maintenance_msg)
|
||||
logger.info("FK maintenance detected; aborting session")
|
||||
return
|
||||
|
||||
# Accept cookie consent banner if present (only appears once per browser profile)
|
||||
@@ -533,7 +533,7 @@ async def _do_login(page, state: _SessionState) -> None:
|
||||
if is_disabled:
|
||||
error_msg = "FK login temporarily unavailable (BankID button disabled). Please try again later."
|
||||
_set_status(state, "error", error_msg)
|
||||
logger.info("[%s] BankID button is disabled; aborting session", state.session_id)
|
||||
logger.info("BankID button is disabled; aborting session")
|
||||
return
|
||||
except Exception:
|
||||
pass # proceed anyway; may not be disabled
|
||||
@@ -627,7 +627,9 @@ async def _do_extract(page, state: _SessionState) -> None:
|
||||
if family is not None:
|
||||
state.partner_deduction = _deduce_partner(data, family)
|
||||
except Exception as exc:
|
||||
logger.info("[%s] family-panel deduction skipped: %s", state.session_id, type(exc).__name__)
|
||||
logger.debug(
|
||||
"[%s] family-panel deduction skipped: %s", state.session_id, type(exc).__name__
|
||||
)
|
||||
state.family = None
|
||||
state.partner_deduction = None
|
||||
|
||||
@@ -645,7 +647,7 @@ async def _do_extract(page, state: _SessionState) -> None:
|
||||
state.partner_deduction, transferable_days_left=transferable.partner_total
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"[%s] transferable-days parse skipped: %s", state.session_id, type(exc).__name__
|
||||
)
|
||||
|
||||
@@ -656,14 +658,20 @@ async def _do_extract(page, state: _SessionState) -> None:
|
||||
_set_status(
|
||||
state,
|
||||
"error",
|
||||
f"Scrape succeeded but failed to persist data: {type(exc).__name__}: {exc}",
|
||||
"Scrape succeeded but failed to persist the data. Please try again.",
|
||||
)
|
||||
logger.error(
|
||||
"persist error [id=%s]: %s: %s",
|
||||
state.session_id,
|
||||
type(exc).__name__,
|
||||
exc,
|
||||
exc_info=False,
|
||||
)
|
||||
logger.error("persist error [id=%s]: %s", state.session_id, exc, exc_info=False)
|
||||
return
|
||||
|
||||
_set_status(state, "done")
|
||||
state.result = data
|
||||
logger.info("scraper session done [id=%s]", state.session_id)
|
||||
logger.info("scraper session done")
|
||||
|
||||
|
||||
async def _extract_parental_leave(page, state: _SessionState) -> ParentalLeaveFetch:
|
||||
@@ -746,19 +754,19 @@ async def _extract_family_summary(page, state: _SessionState) -> FamilySummary |
|
||||
|
||||
Defensive by design: the real FK DOM for this panel is unconfirmed (see
|
||||
`_SEL.FAMILY*`'s comment), so any missing element or unexpected text is
|
||||
treated as "no family data available" — logged at INFO and returned as
|
||||
treated as "no family data available" — logged at DEBUG and returned as
|
||||
None — never raised. The caller's own `data` (this parent's snapshot)
|
||||
must succeed regardless of what happens here.
|
||||
"""
|
||||
try:
|
||||
family_panel = page.locator(_SEL.FAMILY)
|
||||
if await family_panel.count() == 0:
|
||||
logger.info("[%s] family panel not present; skipping deduction", state.session_id)
|
||||
logger.debug("[%s] family panel not present; skipping deduction", state.session_id)
|
||||
return None
|
||||
|
||||
dagar_kvar_loc = page.locator(_SEL.FAMILY_DAGAR_KVAR)
|
||||
if await dagar_kvar_loc.count() == 0:
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"[%s] family panel dagar-kvar element not found; skipping deduction",
|
||||
state.session_id,
|
||||
)
|
||||
@@ -768,7 +776,7 @@ async def _extract_family_summary(page, state: _SessionState) -> FamilySummary |
|
||||
|
||||
discrete_loc = page.locator(_SEL.FAMILY_DISCRETE)
|
||||
if await discrete_loc.count() == 0:
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"[%s] family panel discrete element not found; skipping deduction",
|
||||
state.session_id,
|
||||
)
|
||||
@@ -776,7 +784,7 @@ async def _extract_family_summary(page, state: _SessionState) -> FamilySummary |
|
||||
discrete_text = await discrete_loc.first.inner_text(timeout=2_000)
|
||||
total_match = re.search(r"av\s+(\d+)\s+dagar", discrete_text)
|
||||
if not total_match:
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"[%s] could not parse 'av N dagar' from family panel text; skipping deduction",
|
||||
state.session_id,
|
||||
)
|
||||
@@ -806,7 +814,7 @@ async def _extract_family_summary(page, state: _SessionState) -> FamilySummary |
|
||||
lagsta_niva_left=lagsta_niva_left,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"[%s] family panel parsing failed (%s); skipping deduction",
|
||||
state.session_id,
|
||||
type(exc).__name__,
|
||||
@@ -894,7 +902,7 @@ async def _extract_transferable_days(
|
||||
|
||||
Fully defensive by design, mirroring `_extract_family_summary`: any
|
||||
missing element, unmatched child, or unexpected text is "no transferable
|
||||
data available" — logged at INFO and returned as None — never raised.
|
||||
data available" — logged at DEBUG and returned as None — never raised.
|
||||
The caller's own `data` (already validated) must be unaffected either way.
|
||||
"""
|
||||
try:
|
||||
@@ -924,7 +932,7 @@ async def _extract_transferable_days(
|
||||
sections = page.locator(_SEL.MIN_FP_BARN_SEKTION)
|
||||
section_count = await sections.count()
|
||||
if section_count == 0:
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"[%s] no barn-sektion found on min-foraldrapenning page; skipping "
|
||||
"transferable-days parse",
|
||||
state.session_id,
|
||||
@@ -935,7 +943,7 @@ async def _extract_transferable_days(
|
||||
chosen = sections.first
|
||||
else:
|
||||
if not child_name or not child_name.strip():
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"[%s] multiple children on min-foraldrapenning page and no "
|
||||
"child_name to match; skipping transferable-days parse",
|
||||
state.session_id,
|
||||
@@ -953,7 +961,7 @@ async def _extract_transferable_days(
|
||||
chosen = section
|
||||
break
|
||||
if chosen is None:
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"[%s] no barn-sektion matched child_name; skipping transferable-days parse",
|
||||
state.session_id,
|
||||
)
|
||||
@@ -961,7 +969,7 @@ async def _extract_transferable_days(
|
||||
|
||||
panel = chosen.locator(_SEL.PANEL_FORA_OVER_DAGAR)
|
||||
if await panel.count() == 0:
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"[%s] chosen barn-sektion has no transfer panel; skipping transferable-days parse",
|
||||
state.session_id,
|
||||
)
|
||||
@@ -1014,7 +1022,7 @@ async def _extract_transferable_days(
|
||||
partner_sjuk, partner_lagsta = sjuk, lagsta
|
||||
|
||||
if mine_sjuk is None or mine_lagsta is None:
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"[%s] 'Du kan föra över' group missing or incomplete; skipping "
|
||||
"transferable-days parse",
|
||||
state.session_id,
|
||||
@@ -1028,7 +1036,7 @@ async def _extract_transferable_days(
|
||||
partner_lagsta_niva=partner_lagsta,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.info(
|
||||
logger.debug(
|
||||
"[%s] transferable-days panel parsing failed (%s); skipping",
|
||||
state.session_id,
|
||||
type(exc).__name__,
|
||||
|
||||
@@ -552,7 +552,7 @@ class AnalyticsService:
|
||||
|
||||
projections = []
|
||||
for pd in members:
|
||||
proj = await self.get_days_left_at_age_4(pd.uuid, child_id)
|
||||
proj = await self.get_days_left_at_age_4(pd.uuid, child_id, workspace_id=workspace_id)
|
||||
projections.append(proj)
|
||||
|
||||
combined_used = sum(p.days_used + p.planned_days_before_b4 for p in projections)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Lowercase existing "user".email values.
|
||||
|
||||
Email lookup/storage is case-sensitive (`SQLiteUserRepository.get_by_email`
|
||||
runs a bare `WHERE email = ?`), so `Foo@x.se` and `foo@x.se` used to register
|
||||
as two distinct accounts. The API boundary now lowercases incoming email on
|
||||
every register/login request (`app.models.auth.RegisterRequest`/
|
||||
`LoginRequest`, `normalize_email` field validators), so all NEW rows are
|
||||
already lowercase; this migration normalizes rows written before that fix.
|
||||
|
||||
If two existing rows would collapse to the same lowercased address (a
|
||||
genuine pre-existing duplicate-account situation), blindly lowercasing both
|
||||
would hit the `email` column's UNIQUE constraint and abort with an opaque
|
||||
integrity error on whichever row happens to update second. Instead,
|
||||
`upgrade()` scans for any such collision FIRST and raises a clear,
|
||||
actionable `RuntimeError` naming the colliding rows before writing anything,
|
||||
so an operator can merge or rename one of each colliding pair by hand and
|
||||
re-run the migration, rather than facing a half-applied state.
|
||||
|
||||
`downgrade()` cannot restore the original casing (it was never recorded) —
|
||||
it is a deliberate, documented no-op. Lowercasing is a one-way normalization.
|
||||
|
||||
Revision ID: 009
|
||||
Revises: 008
|
||||
Create Date: 2026-09-20
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision: str = "009"
|
||||
down_revision: str | None = "008"
|
||||
branch_labels: str | None = None
|
||||
depends_on: str | None = None
|
||||
|
||||
|
||||
def _collect_rows(conn: Any) -> list[Any]:
|
||||
from sqlalchemy import text
|
||||
|
||||
return conn.execute(text('SELECT id, email FROM "user"')).fetchall()
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
from sqlalchemy import text
|
||||
|
||||
conn = op.get_bind()
|
||||
rows = _collect_rows(conn)
|
||||
|
||||
by_lower: dict[str, list[tuple[str, str]]] = {}
|
||||
for row in rows:
|
||||
by_lower.setdefault(row.email.lower(), []).append((str(row.id), row.email))
|
||||
|
||||
collisions = {lower: entries for lower, entries in by_lower.items() if len(entries) > 1}
|
||||
if collisions:
|
||||
detail = "; ".join(
|
||||
f"{lower!r} <- {[f'{id_} ({email})' for id_, email in entries]}"
|
||||
for lower, entries in collisions.items()
|
||||
)
|
||||
raise RuntimeError(
|
||||
'Cannot lowercase "user".email: the following addresses would collide '
|
||||
f"under case-insensitive comparison and must be resolved by hand first "
|
||||
f"(merge or rename one account in each pair, then re-run this migration): "
|
||||
f"{detail}"
|
||||
)
|
||||
|
||||
for lower, entries in by_lower.items():
|
||||
(id_, email) = entries[0]
|
||||
if email != lower:
|
||||
conn.execute(
|
||||
text('UPDATE "user" SET email = :email WHERE id = :id'),
|
||||
{"email": lower, "id": id_},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""No-op: the original casing was never recorded, so it cannot be restored."""
|
||||
@@ -0,0 +1,132 @@
|
||||
"""R4: email case-normalisation at the auth API boundary.
|
||||
|
||||
Storage/lookup (`SQLiteUserRepository.get_by_email`) is case-sensitive, so
|
||||
without normalisation `Foo@x.se` and `foo@x.se` would register as two
|
||||
distinct accounts. `RegisterRequest`/`LoginRequest` (`app/models/auth.py`)
|
||||
now lowercase `email` via a field validator — these tests drive the real
|
||||
app over HTTP (same technique as `tests/integration/test_user_journey.py`)
|
||||
to prove that boundary actually normalises, rather than only unit-testing
|
||||
the validator in isolation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import aiosqlite
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from app.dependencies import get_connection
|
||||
from app.main import app
|
||||
from app.rate_limit import limiter
|
||||
|
||||
_PASSWORD = "correct horse battery staple"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_rate_limiter() -> None:
|
||||
"""`/api/auth/register` and `/api/auth/login` are limited to 10/minute,
|
||||
keyed on client IP (`app/rate_limit.py`). In the full suite, every test
|
||||
file that POSTs to either endpoint shares that one in-memory bucket for
|
||||
127.0.0.1 (see `tests/integration/test_user_journey.py`'s comment on the
|
||||
same issue) — by the time this file runs, other files may have already
|
||||
spent it. Reset the limiter's storage before each test here rather than
|
||||
disabling enforcement, so these tests still exercise the real
|
||||
middleware stack; this is exactly `Limiter.reset()`'s documented purpose.
|
||||
"""
|
||||
limiter.reset()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def client(connection: aiosqlite.Connection) -> AsyncClient:
|
||||
"""Real app over HTTP, with `get_connection` swapped for the in-memory
|
||||
test connection — see `tests/integration/test_user_journey.py`'s fixture
|
||||
of the same name."""
|
||||
app.dependency_overrides[get_connection] = lambda: connection
|
||||
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
||||
yield ac
|
||||
app.dependency_overrides.pop(get_connection, None)
|
||||
|
||||
|
||||
async def test_register_lowercases_email(client: AsyncClient) -> None:
|
||||
resp = await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "Mixed.Case@Example.COM", "password": _PASSWORD, "display_name": "A"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
|
||||
|
||||
async def test_login_with_different_case_resolves_to_same_account(client: AsyncClient) -> None:
|
||||
register_resp = await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "Case.Test@Example.com", "password": _PASSWORD, "display_name": "A"},
|
||||
)
|
||||
assert register_resp.status_code == 201, register_resp.text
|
||||
|
||||
login_resp = await client.post(
|
||||
"/api/auth/login",
|
||||
json={"email": "CASE.TEST@EXAMPLE.COM", "password": _PASSWORD},
|
||||
)
|
||||
assert login_resp.status_code == 200, login_resp.text
|
||||
assert "access_token" in login_resp.json()
|
||||
|
||||
|
||||
async def test_registering_same_address_in_different_case_is_rejected_as_duplicate(
|
||||
client: AsyncClient,
|
||||
) -> None:
|
||||
first = await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "dupe@example.com", "password": _PASSWORD, "display_name": "First"},
|
||||
)
|
||||
assert first.status_code == 201, first.text
|
||||
|
||||
second = await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "DUPE@EXAMPLE.COM", "password": _PASSWORD, "display_name": "Second"},
|
||||
)
|
||||
assert second.status_code == 409, second.text
|
||||
|
||||
|
||||
async def test_context_includes_take_pl_on_red_days_default_false(client: AsyncClient) -> None:
|
||||
"""H12 step 1: `/auth/context` must carry the same red-days flag
|
||||
`GET /workspace/:id` does, so the frontend can drop that second cold-load
|
||||
call. A freshly registered workspace defaults to `False`."""
|
||||
register_resp = await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "red-days@example.com", "password": _PASSWORD, "display_name": "A"},
|
||||
)
|
||||
assert register_resp.status_code == 201, register_resp.text
|
||||
headers = {"Authorization": f"Bearer {register_resp.json()['access_token']}"}
|
||||
|
||||
ctx_resp = await client.get("/api/auth/context", headers=headers)
|
||||
assert ctx_resp.status_code == 200, ctx_resp.text
|
||||
assert ctx_resp.json()["take_pl_on_red_days"] is False
|
||||
|
||||
|
||||
async def test_context_reflects_take_pl_on_red_days_after_workspace_update(
|
||||
client: AsyncClient,
|
||||
) -> None:
|
||||
"""Once the workspace's flag is flipped via `PUT /workspace/:id`, the
|
||||
context endpoint must reflect that new value, not a stale default —
|
||||
this is what `Settings.tsx`'s red-days toggle relies on after it saves."""
|
||||
register_resp = await client.post(
|
||||
"/api/auth/register",
|
||||
json={"email": "red-days-2@example.com", "password": _PASSWORD, "display_name": "A"},
|
||||
)
|
||||
assert register_resp.status_code == 201, register_resp.text
|
||||
headers = {"Authorization": f"Bearer {register_resp.json()['access_token']}"}
|
||||
|
||||
ctx_resp = await client.get("/api/auth/context", headers=headers)
|
||||
assert ctx_resp.status_code == 200, ctx_resp.text
|
||||
workspace_id = ctx_resp.json()["workspace_id"]
|
||||
|
||||
update_resp = await client.put(
|
||||
f"/api/workspace/{workspace_id}",
|
||||
headers=headers,
|
||||
json={"take_pl_on_red_days": True},
|
||||
)
|
||||
assert update_resp.status_code == 200, update_resp.text
|
||||
|
||||
ctx_resp_2 = await client.get("/api/auth/context", headers=headers)
|
||||
assert ctx_resp_2.status_code == 200, ctx_resp_2.text
|
||||
assert ctx_resp_2.json()["take_pl_on_red_days"] is True
|
||||
@@ -268,6 +268,29 @@ class TestForeignWorkspaceIdIs404:
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_analytics_overview_foreign_child_in_own_workspace(
|
||||
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
||||
) -> None:
|
||||
"""R1: `workspace_id` is the caller's own (workspace A), but
|
||||
`child_id` belongs to workspace B — the IDOR the router-level
|
||||
`scope.check_child` guard closes (see `analytics.get_overview`).
|
||||
"""
|
||||
resp = await client.get(
|
||||
f"/api/analytics/{two_workspaces.ws_a.id}/overview",
|
||||
params={"child_id": str(two_workspaces.child_b.id)},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_analytics_projection_foreign_child_in_own_workspace(
|
||||
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
||||
) -> None:
|
||||
"""Same IDOR as above, on the sibling `/projection` route."""
|
||||
resp = await client.get(
|
||||
f"/api/analytics/{two_workspaces.ws_a.id}/projection",
|
||||
params={"child_id": str(two_workspaces.child_b.id)},
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
async def test_analytics_age4_foreign_parent(
|
||||
self, client: AsyncClient, two_workspaces: _TwoWorkspaces
|
||||
) -> None:
|
||||
|
||||
@@ -31,6 +31,7 @@ from app.scrapers.fk_scraper import (
|
||||
_apply_transferable,
|
||||
_deduce_partner,
|
||||
_do_extract,
|
||||
_do_login,
|
||||
_extract_family_summary,
|
||||
_extract_transferable_days,
|
||||
_SEL,
|
||||
@@ -1218,6 +1219,45 @@ class TestDoExtractTransferableIntegration:
|
||||
assert state.result.transferable_days_left == 49 # unchanged from the original estimate
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Maintenance detection — client-facing message must not echo scraped text
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMaintenanceDetection:
|
||||
"""R3: `_do_login`'s maintenance branch must return a fixed message —
|
||||
never the scraped FK banner text — to the poll client."""
|
||||
|
||||
async def test_maintenance_banner_text_not_leaked_to_client(self) -> None:
|
||||
sid = str(uuid.uuid4())
|
||||
state = _SessionState(
|
||||
session_id=sid,
|
||||
workspace_id=uuid.uuid4(),
|
||||
personal_data_uuid=uuid.uuid4(),
|
||||
child_id=uuid.uuid4(),
|
||||
owner_user_id=uuid.uuid4(),
|
||||
)
|
||||
|
||||
page = MagicMock()
|
||||
page.goto = AsyncMock()
|
||||
page.url = "https://www.forsakringskassan.se/privatperson/logga-in"
|
||||
|
||||
sensitive_banner = (
|
||||
"Planerat underhall pagar for personer med personnummer som slutar pa "
|
||||
"1-5, tjansten ar otillganglig till kl 23."
|
||||
)
|
||||
with patch(
|
||||
"app.scrapers.fk_scraper._check_maintenance", new_callable=AsyncMock
|
||||
) as mock_check:
|
||||
mock_check.return_value = sensitive_banner
|
||||
await _do_login(page, state)
|
||||
|
||||
assert state.status == "error"
|
||||
assert state.message is not None
|
||||
assert sensitive_banner not in state.message
|
||||
assert "maintenance" in state.message.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session state machine
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1706,6 +1746,8 @@ class TestSessionStateMachine:
|
||||
|
||||
assert state.status == "error"
|
||||
assert "persist" in (state.message or "").lower()
|
||||
# R2/R3: the raw exception text must never reach the poll client.
|
||||
assert "DB unavailable" not in (state.message or "")
|
||||
|
||||
async def test_do_extract_waits_for_hydration_before_extracting(self) -> None:
|
||||
"""_do_extract calls wait_for_function to ensure data hydrates before extraction."""
|
||||
|
||||
@@ -22,10 +22,23 @@ server {
|
||||
|
||||
server_tokens off;
|
||||
|
||||
# nginx does NOT merge add_header across levels: if a location block adds
|
||||
# even one add_header of its own, every add_header set here at server
|
||||
# level is discarded for responses from that location — not appended to.
|
||||
# /assets/, /fonts/ and = /index.html below each set their own
|
||||
# Cache-Control, so the four security headers are repeated verbatim
|
||||
# inside each of those blocks. Add a header here, and it must be added
|
||||
# there too (or it silently never reaches those responses).
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Referrer-Policy no-referrer always;
|
||||
|
||||
# Derived from the actual production build (frontend/dist): no inline
|
||||
# <script>/<style>, all JS/CSS/fonts same-origin, the only data: URIs are
|
||||
# an <img> (QR code) and a CSS background-image (chevron icon) — hence
|
||||
# img-src needs data: but nothing else does. API is same-origin /api/.
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
|
||||
# Container-level liveness. Served from memory, so a broken bundle or a
|
||||
# read-only mount cannot make this lie.
|
||||
location = /healthz {
|
||||
@@ -58,6 +71,12 @@ server {
|
||||
# `expires 1y` would emit its own Cache-Control alongside this one;
|
||||
# one explicit header is clearer than two contradicting-looking ones.
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
# Repeated: see the server-level comment above — an add_header here
|
||||
# discards the server block's add_headers for this location.
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Referrer-Policy no-referrer always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
@@ -66,6 +85,11 @@ server {
|
||||
location /fonts/ {
|
||||
access_log off;
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
# Repeated: see the server-level comment above.
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Referrer-Policy no-referrer always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
@@ -73,6 +97,13 @@ server {
|
||||
# leaves clients pinned to a stale asset graph.
|
||||
location = /index.html {
|
||||
add_header Cache-Control "no-cache" always;
|
||||
# Repeated: see the server-level comment above — this is the response
|
||||
# the CSP/X-Frame-Options/etc. exist to protect, so it must not lose
|
||||
# them just because this block also sets Cache-Control.
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Referrer-Policy no-referrer always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
}
|
||||
|
||||
# SPA fallback. react-router-dom uses BrowserRouter, so a hard refresh or a
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
"use strict";
|
||||
(() => {
|
||||
// src/utils/leaveTypes.ts
|
||||
function getColorForType(type) {
|
||||
const colorMap = {
|
||||
vacation: "#4CAF50",
|
||||
sjukpenning: "#FF9800",
|
||||
lagsta: "#2196F3"
|
||||
};
|
||||
return colorMap[type] ?? "#9E9E9E";
|
||||
}
|
||||
function getLabelForType(type) {
|
||||
const labelMap = {
|
||||
vacation: "Vacation",
|
||||
sjukpenning: "Sick Leave",
|
||||
lagsta: "Parental"
|
||||
};
|
||||
return labelMap[type] ?? "Unknown";
|
||||
}
|
||||
})();
|
||||
@@ -3,10 +3,12 @@ import { renderHook, waitFor, act } from '@testing-library/react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { PlanSyncProvider, usePlanSyncContext } from './PlanSyncProvider'
|
||||
import { useWorkspace } from './WorkspaceContext'
|
||||
import { useAuth } from './AuthContext'
|
||||
import * as api from '@/services/api'
|
||||
import { ApiRequestError } from '@/services/errors'
|
||||
import {
|
||||
makeWorkspaceContextValue,
|
||||
makeAuthContextValue,
|
||||
makePlanSyncResponse,
|
||||
makeEvictionPreview,
|
||||
makeEvictionCandidate,
|
||||
@@ -19,6 +21,9 @@ vi.mock('@/services/api')
|
||||
vi.mock('./WorkspaceContext', () => ({
|
||||
useWorkspace: vi.fn(),
|
||||
}))
|
||||
vi.mock('./AuthContext', () => ({
|
||||
useAuth: vi.fn(),
|
||||
}))
|
||||
|
||||
function wrapper({ children }: { children: ReactNode }) {
|
||||
return <PlanSyncProvider>{children}</PlanSyncProvider>
|
||||
@@ -28,6 +33,10 @@ function mockWorkspace(overrides: Partial<ReturnType<typeof useWorkspace>> = {})
|
||||
vi.mocked(useWorkspace).mockReturnValue(makeWorkspaceContextValue(overrides))
|
||||
}
|
||||
|
||||
function mockAuth(overrides: Partial<ReturnType<typeof useAuth>> = {}) {
|
||||
vi.mocked(useAuth).mockReturnValue(makeAuthContextValue(overrides))
|
||||
}
|
||||
|
||||
describe('PlanSyncProvider', () => {
|
||||
let bumpPlanVersion: ReturnType<typeof vi.fn>
|
||||
|
||||
@@ -35,6 +44,11 @@ describe('PlanSyncProvider', () => {
|
||||
vi.clearAllMocks()
|
||||
bumpPlanVersion = vi.fn()
|
||||
mockWorkspace({ workspaceId: 'ws-1', planVersion: 0, bumpPlanVersion })
|
||||
// Every existing test in this file exercises the already-authenticated
|
||||
// steady state (real app: PlanSyncProvider is mounted inside
|
||||
// AuthProvider — see App.tsx). The unauthenticated-cold-load case (M9)
|
||||
// has its own describe block below with an explicit override.
|
||||
mockAuth({ isAuthenticated: true })
|
||||
vi.mocked(api.syncPlan).mockResolvedValue(makePlanSyncResponse())
|
||||
})
|
||||
|
||||
@@ -45,6 +59,41 @@ describe('PlanSyncProvider', () => {
|
||||
expect(api.syncPlan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
describe('M9 — gated on auth, not just workspaceId', () => {
|
||||
it('does not sync on a cold load before auth has resolved, even with a workspaceId already seeded from localStorage', async () => {
|
||||
// Mirrors WorkspaceContext.tsx's real cold-load shape: workspaceId is
|
||||
// seeded synchronously from localStorage while AuthContext's
|
||||
// mount-time refreshToken() is still in flight (isAuthenticated still
|
||||
// false). Without the auth gate this fires an unauthenticated
|
||||
// POST /api/plan/sync.
|
||||
mockAuth({ isAuthenticated: false })
|
||||
mockWorkspace({ workspaceId: 'ws-1', planVersion: 0, bumpPlanVersion })
|
||||
|
||||
renderHook(() => usePlanSyncContext(), { wrapper })
|
||||
|
||||
// Give any wrongly-fired effect a tick to resolve before asserting.
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(api.syncPlan).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('syncs once auth resolves to authenticated', async () => {
|
||||
mockAuth({ isAuthenticated: false })
|
||||
mockWorkspace({ workspaceId: 'ws-1', planVersion: 0, bumpPlanVersion })
|
||||
|
||||
const { rerender } = renderHook(() => usePlanSyncContext(), { wrapper })
|
||||
expect(api.syncPlan).not.toHaveBeenCalled()
|
||||
|
||||
// The mount-time refreshToken() call in AuthContext resolves.
|
||||
mockAuth({ isAuthenticated: true })
|
||||
rerender()
|
||||
|
||||
await waitFor(() => expect(api.syncPlan).toHaveBeenCalledTimes(1))
|
||||
expect(api.syncPlan).toHaveBeenCalledWith('ws-1')
|
||||
})
|
||||
})
|
||||
|
||||
it('syncs silently once the workspace is known', async () => {
|
||||
renderHook(() => usePlanSyncContext(), { wrapper })
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useWorkspace } from './WorkspaceContext'
|
||||
import { useAuth } from './AuthContext'
|
||||
import { isApiError } from '@/services/errors'
|
||||
import type { EvictionPreview, PlanProposal } from '@/types'
|
||||
|
||||
@@ -49,6 +50,17 @@ const PlanSyncContext = createContext<PlanSyncContextValue | null>(null)
|
||||
* instance every plan-input hook already bumps, and runs for the lifetime
|
||||
* of the app, not just while Calendar happens to be mounted.
|
||||
*
|
||||
* Also gated on `AuthContext`'s `isAuthenticated` — `workspaceId` is seeded
|
||||
* synchronously from `localStorage` (see `WorkspaceContext.tsx`), so on a
|
||||
* cold load it's already truthy before `AuthContext`'s mount-time
|
||||
* `refreshToken()` call resolves. Without this gate the effect below fires
|
||||
* an unauthenticated `POST /api/plan/sync`, which 401s and starts a second,
|
||||
* redundant `/auth/refresh` racing `AuthContext`'s own. Reading `useAuth`
|
||||
* here does not reintroduce the cycle the note below warns about:
|
||||
* `AuthContext` never reads `WorkspaceContext`/`PlanSyncProvider` state, so
|
||||
* this is a one-way dependency, not a loop — and `AuthProvider` sits above
|
||||
* `WorkspaceProvider` in `App.tsx`, so the context is always available.
|
||||
*
|
||||
* **Cycle safety.** This effect must NEVER call `bumpPlanVersion`.
|
||||
* `planVersion` is the shared invalidation signal every plan-input hook's
|
||||
* own fetch effect watches (see `usePlannedDays.ts:84-93`); a sync path
|
||||
@@ -58,6 +70,7 @@ const PlanSyncContext = createContext<PlanSyncContextValue | null>(null)
|
||||
*/
|
||||
export function PlanSyncProvider({ children }: { children: React.ReactNode }) {
|
||||
const { workspaceId, planVersion } = useWorkspace()
|
||||
const { isAuthenticated } = useAuth()
|
||||
const [pendingEvictionPreview, setPendingEvictionPreview] = useState<EvictionPreview | null>(null)
|
||||
const [latestProposal, setLatestProposal] = useState<PlanProposal | null>(null)
|
||||
const [latestProposalVersion, setLatestProposalVersion] = useState(0)
|
||||
@@ -119,6 +132,15 @@ export function PlanSyncProvider({ children }: { children: React.ReactNode }) {
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// `workspaceId` is seeded synchronously from `localStorage` (see
|
||||
// `WorkspaceContext.tsx`), so on a cold load it's already set before
|
||||
// `AuthContext`'s mount-time `refreshToken()` resolves. Without this
|
||||
// gate, that window fires `POST /api/plan/sync` unauthenticated — a
|
||||
// guaranteed 401 that then races `AuthContext`'s own in-flight
|
||||
// `/auth/refresh` against the second one `api.ts`'s 401-retry starts.
|
||||
// `isAuthenticated` flipping true (login, or the mount refresh landing)
|
||||
// re-runs this effect and syncs then, same as a `planVersion` bump would.
|
||||
if (!isAuthenticated) return
|
||||
if (!workspaceId) return
|
||||
if (inFlightRef.current) {
|
||||
// A sync is already running — record that another bump landed and
|
||||
@@ -129,7 +151,7 @@ export function PlanSyncProvider({ children }: { children: React.ReactNode }) {
|
||||
return
|
||||
}
|
||||
void runSyncOnce(workspaceId)
|
||||
}, [workspaceId, planVersion, runSyncOnce])
|
||||
}, [isAuthenticated, workspaceId, planVersion, runSyncOnce])
|
||||
|
||||
const clearPendingEvictionPreview = useCallback(() => {
|
||||
setPendingEvictionPreview(null)
|
||||
|
||||
@@ -70,6 +70,7 @@ const userContext: api.UserContext = {
|
||||
{ id: 'c1', name: 'Child One', birth_date: '2024-01-01' },
|
||||
{ id: 'c2', name: 'Child Two', birth_date: '2025-01-01' },
|
||||
],
|
||||
take_pl_on_red_days: false,
|
||||
}
|
||||
|
||||
describe('WorkspaceContext', () => {
|
||||
@@ -92,23 +93,26 @@ describe('WorkspaceContext', () => {
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('loads user context and workspace on mount when authenticated', async () => {
|
||||
it('loads user context on mount when authenticated, with no follow-up workspace fetch', async () => {
|
||||
// H12 step 1: cold load used to be getUserContext() -> getWorkspace(id).
|
||||
// The workspace state this provider needs (id + takePlOnRedDays) now
|
||||
// comes straight from the context response, so getWorkspace must not
|
||||
// be called at all on this path.
|
||||
vi.mocked(useAuth).mockReturnValue(authenticated)
|
||||
vi.mocked(api.getUserContext).mockResolvedValue(userContext)
|
||||
vi.mocked(api.getWorkspace).mockResolvedValue(workspace)
|
||||
|
||||
const { result } = renderHook(() => useWorkspace(), { wrapper })
|
||||
|
||||
await waitFor(() => expect(result.current.workspace).not.toBeNull())
|
||||
|
||||
expect(api.getUserContext).toHaveBeenCalled()
|
||||
expect(api.getWorkspace).toHaveBeenCalledWith('ws1')
|
||||
expect(api.getWorkspace).not.toHaveBeenCalled()
|
||||
expect(result.current.workspaceId).toBe('ws1')
|
||||
expect(result.current.personalDataUuid).toBe('pd1')
|
||||
expect(result.current.parentName).toBe('Anna')
|
||||
expect(result.current.children).toHaveLength(2)
|
||||
expect(result.current.selectedChildId).toBe('c1')
|
||||
expect(result.current.workspace).toEqual(workspace)
|
||||
expect(result.current.workspace).toEqual({ id: 'ws1', takePlOnRedDays: false })
|
||||
expect(localStorage.getItem('workspaceId')).toBe('ws1')
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
@@ -136,31 +140,15 @@ describe('WorkspaceContext', () => {
|
||||
expect(result.current.error).toBe('Failed to load user context')
|
||||
})
|
||||
|
||||
it('sets an error when the workspace fetch inside refreshContext fails', async () => {
|
||||
vi.mocked(useAuth).mockReturnValue(authenticated)
|
||||
vi.mocked(api.getUserContext).mockResolvedValue(userContext)
|
||||
vi.mocked(api.getWorkspace).mockRejectedValue(new Error('workspace boom'))
|
||||
|
||||
const { result } = renderHook(() => useWorkspace(), { wrapper })
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
|
||||
expect(result.current.error).toBe('workspace boom')
|
||||
expect(result.current.workspace).toBeNull()
|
||||
// But the rest of the context from getUserContext was still applied.
|
||||
expect(result.current.workspaceId).toBe('ws1')
|
||||
expect(result.current.selectedChildId).toBe('c1')
|
||||
})
|
||||
|
||||
it('defaults to an empty children list when the context response omits children', async () => {
|
||||
vi.mocked(useAuth).mockReturnValue(authenticated)
|
||||
const contextWithoutChildren = {
|
||||
personal_data_uuid: userContext.personal_data_uuid,
|
||||
workspace_id: userContext.workspace_id,
|
||||
parent_name: userContext.parent_name,
|
||||
take_pl_on_red_days: false,
|
||||
} as unknown as api.UserContext
|
||||
vi.mocked(api.getUserContext).mockResolvedValue(contextWithoutChildren)
|
||||
vi.mocked(api.getWorkspace).mockResolvedValue(workspace)
|
||||
|
||||
const { result } = renderHook(() => useWorkspace(), { wrapper })
|
||||
|
||||
@@ -170,19 +158,6 @@ describe('WorkspaceContext', () => {
|
||||
expect(result.current.selectedChildId).toBeNull()
|
||||
})
|
||||
|
||||
it('sets a non-Error rejection to a generic message when the workspace fetch inside refreshContext fails', async () => {
|
||||
vi.mocked(useAuth).mockReturnValue(authenticated)
|
||||
vi.mocked(api.getUserContext).mockResolvedValue(userContext)
|
||||
vi.mocked(api.getWorkspace).mockRejectedValue('nope')
|
||||
|
||||
const { result } = renderHook(() => useWorkspace(), { wrapper })
|
||||
|
||||
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
||||
|
||||
expect(result.current.error).toBe('Failed to load workspace')
|
||||
expect(result.current.workspace).toBeNull()
|
||||
})
|
||||
|
||||
it('refetch sets a non-Error rejection to a generic message', async () => {
|
||||
vi.mocked(useAuth).mockReturnValue(authenticated)
|
||||
vi.mocked(api.getUserContext).mockResolvedValue(userContext)
|
||||
@@ -311,15 +286,14 @@ describe('WorkspaceContext', () => {
|
||||
rerender()
|
||||
await waitFor(() => expect(result.current.workspace).toBeNull())
|
||||
|
||||
const otherWorkspace: Workspace = { ...workspace, id: 'ws2', name: 'Other workspace' }
|
||||
const otherUserContext: api.UserContext = {
|
||||
personal_data_uuid: 'pd2',
|
||||
workspace_id: 'ws2',
|
||||
parent_name: 'Alex',
|
||||
children: [{ id: 'c3', name: 'Child Three', birth_date: '2026-01-01' }],
|
||||
take_pl_on_red_days: true,
|
||||
}
|
||||
vi.mocked(api.getUserContext).mockResolvedValue(otherUserContext)
|
||||
vi.mocked(api.getWorkspace).mockResolvedValue(otherWorkspace)
|
||||
vi.mocked(api.getUserContext).mockClear()
|
||||
|
||||
vi.mocked(useAuth).mockReturnValue(authenticated)
|
||||
@@ -330,7 +304,7 @@ describe('WorkspaceContext', () => {
|
||||
expect(result.current.workspaceId).toBe('ws2')
|
||||
expect(result.current.personalDataUuid).toBe('pd2')
|
||||
expect(result.current.selectedChildId).toBe('c3')
|
||||
expect(result.current.workspace).toEqual(otherWorkspace)
|
||||
expect(result.current.workspace).toEqual({ id: 'ws2', takePlOnRedDays: true })
|
||||
})
|
||||
|
||||
it('discards a refreshContext response that resolves after logout (S4)', async () => {
|
||||
|
||||
@@ -98,15 +98,12 @@ export function WorkspaceProvider({ children: childrenProp }: { children: React.
|
||||
if (childrenList.length > 0 && childrenList[0]) {
|
||||
setSelectedChildId(childrenList[0].id)
|
||||
}
|
||||
try {
|
||||
const ws = await api.getWorkspace(context.workspace_id)
|
||||
if (generation !== generationRef.current) return
|
||||
setWorkspace(ws)
|
||||
} catch (err: unknown) {
|
||||
if (generation !== generationRef.current) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load workspace')
|
||||
setWorkspace(null)
|
||||
}
|
||||
// H12 step 1: `GET /auth/context` now carries `take_pl_on_red_days`,
|
||||
// the only field of `Workspace` any consumer actually reads off the
|
||||
// cold-load `workspace` (see `Workspace`'s doc comment in
|
||||
// src/types/entities.ts) — so building it here replaces a follow-up
|
||||
// `GET /workspace/:id` that was otherwise redundant on every cold load.
|
||||
setWorkspace({ id: context.workspace_id, takePlOnRedDays: context.take_pl_on_red_days })
|
||||
} catch (err: unknown) {
|
||||
if (generation !== generationRef.current) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load user context')
|
||||
|
||||
@@ -39,9 +39,12 @@ export function useAnalytics(workspaceId: string | null, childId?: string): UseA
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch analytics')
|
||||
setData(null)
|
||||
} finally {
|
||||
if (isCurrent(requestId)) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
// Unguarded: `isCurrent` decides which response's DATA wins, not
|
||||
// whether a superseded call's own busy flag gets cleared — a stale
|
||||
// call must still release the flag IT raised, or it can strand that
|
||||
// flag true forever (see usePlannedDays.ts for the hang this exact
|
||||
// guard caused there).
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [workspaceId, childId, start, isCurrent])
|
||||
|
||||
|
||||
@@ -101,7 +101,12 @@ export function useCalendarDays(
|
||||
if (!isCurrent(requestId)) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load calendar days')
|
||||
} finally {
|
||||
if (isCurrent(requestId)) setBusy(false)
|
||||
// Unguarded: `isCurrent` decides which response's DATA wins, not
|
||||
// whether a superseded call's own busy flag gets cleared — a stale
|
||||
// call must still release the flag IT raised, or it can strand
|
||||
// that flag true forever (see usePlannedDays.ts for the hang this
|
||||
// exact guard caused there).
|
||||
setBusy(false)
|
||||
}
|
||||
},
|
||||
[start, isCurrent],
|
||||
|
||||
@@ -55,7 +55,12 @@ export function useChildSettings(childId: string | null): UseChildSettingsResult
|
||||
if (!isCurrent(requestId)) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load child')
|
||||
} finally {
|
||||
if (isCurrent(requestId)) setBusy(false)
|
||||
// Unguarded: `isCurrent` decides which response's DATA wins, not
|
||||
// whether a superseded call's own busy flag gets cleared — a stale
|
||||
// call must still release the flag IT raised, or it can strand
|
||||
// that flag true forever (see usePlannedDays.ts for the hang this
|
||||
// exact guard caused there).
|
||||
setBusy(false)
|
||||
}
|
||||
},
|
||||
[childId, start, isCurrent],
|
||||
|
||||
@@ -42,7 +42,12 @@ export function useDayOverrides(workspaceId: string): UseDayOverridesResult {
|
||||
if (!isCurrent(requestId)) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load day overrides')
|
||||
} finally {
|
||||
if (isCurrent(requestId)) setBusy(false)
|
||||
// Unguarded: `isCurrent` decides which response's DATA wins, not
|
||||
// whether a superseded call's own busy flag gets cleared — a stale
|
||||
// call must still release the flag IT raised, or it can strand
|
||||
// that flag true forever (see usePlannedDays.ts for the hang this
|
||||
// exact guard caused there).
|
||||
setBusy(false)
|
||||
}
|
||||
},
|
||||
[workspaceId, start, isCurrent],
|
||||
|
||||
@@ -61,7 +61,12 @@ export function useLeaveWishes(workspaceId: string | null): UseLeaveWishesResult
|
||||
if (!isCurrent(requestId)) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load leave wishes')
|
||||
} finally {
|
||||
if (isCurrent(requestId)) setBusy(false)
|
||||
// Unguarded: `isCurrent` decides which response's DATA wins, not
|
||||
// whether a superseded call's own busy flag gets cleared — a stale
|
||||
// call must still release the flag IT raised, or it can strand
|
||||
// that flag true forever (see usePlannedDays.ts for the hang this
|
||||
// exact guard caused there).
|
||||
setBusy(false)
|
||||
}
|
||||
},
|
||||
[workspaceId, start, isCurrent],
|
||||
|
||||
@@ -32,7 +32,12 @@ export function useOptimizationTips(workspaceId: string | null): UseOptimization
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch tips')
|
||||
setData(null)
|
||||
} finally {
|
||||
if (isCurrent(requestId)) setIsLoading(false)
|
||||
// Unguarded: `isCurrent` decides which response's DATA wins, not
|
||||
// whether a superseded call's own busy flag gets cleared — a stale
|
||||
// call must still release the flag IT raised, or it can strand that
|
||||
// flag true forever (see usePlannedDays.ts for the hang this exact
|
||||
// guard caused there).
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [workspaceId, start, isCurrent])
|
||||
|
||||
|
||||
@@ -40,7 +40,12 @@ export function useParentalLeave(
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch parental leave data')
|
||||
setData(null)
|
||||
} finally {
|
||||
if (isCurrent(requestId)) setIsLoading(false)
|
||||
// Unguarded: `isCurrent` decides which response's DATA wins, not
|
||||
// whether a superseded call's own busy flag gets cleared — a stale
|
||||
// call must still release the flag IT raised, or it can strand that
|
||||
// flag true forever (see usePlannedDays.ts for the hang this exact
|
||||
// guard caused there).
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [personalDataUuid, childId, start, isCurrent])
|
||||
|
||||
|
||||
@@ -61,7 +61,12 @@ export function useParentalLeaves(memberUuids: string[], childId: string | null)
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch parental leave data')
|
||||
setData({})
|
||||
} finally {
|
||||
if (isCurrent(requestId)) setIsLoading(false)
|
||||
// Unguarded: `isCurrent` decides which response's DATA wins, not
|
||||
// whether a superseded call's own busy flag gets cleared — a stale
|
||||
// call must still release the flag IT raised, or it can strand that
|
||||
// flag true forever (see usePlannedDays.ts for the hang this exact
|
||||
// guard caused there).
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [memberKey, childId, start, isCurrent])
|
||||
|
||||
|
||||
@@ -65,6 +65,45 @@ describe('usePlanProposal', () => {
|
||||
expect(result.current.data).toBeNull()
|
||||
})
|
||||
|
||||
it('discards a stale response from an earlier, slower propose() call (P1 stale-response guard)', async () => {
|
||||
let resolveFirst: (value: ReturnType<typeof makePlanProposal>) => void = () => {}
|
||||
let resolveSecond: (value: ReturnType<typeof makePlanProposal>) => void = () => {}
|
||||
const firstPromise = new Promise<ReturnType<typeof makePlanProposal>>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
const secondPromise = new Promise<ReturnType<typeof makePlanProposal>>((resolve) => {
|
||||
resolveSecond = resolve
|
||||
})
|
||||
vi.mocked(api.proposePlan).mockReturnValueOnce(firstPromise).mockReturnValueOnce(secondPromise)
|
||||
|
||||
const { result } = renderHook(() => usePlanProposal('ws1'))
|
||||
|
||||
let firstCall: Promise<void> = Promise.resolve()
|
||||
let secondCall: Promise<void> = Promise.resolve()
|
||||
act(() => {
|
||||
firstCall = result.current.propose()
|
||||
})
|
||||
act(() => {
|
||||
secondCall = result.current.propose()
|
||||
})
|
||||
|
||||
// The newer (second) call resolves first...
|
||||
const fastProposal = makePlanProposal({ warnings: [] })
|
||||
await act(async () => {
|
||||
resolveSecond(fastProposal)
|
||||
await secondCall
|
||||
})
|
||||
expect(result.current.data).toEqual(fastProposal)
|
||||
|
||||
// ...then the stale first response arrives late and must not overwrite it.
|
||||
const slowProposal = makePlanProposal({ surplusAdvice: [makeSurplusAdvice()] })
|
||||
await act(async () => {
|
||||
resolveFirst(slowProposal)
|
||||
await firstCall
|
||||
})
|
||||
expect(result.current.data).toEqual(fastProposal)
|
||||
})
|
||||
|
||||
it('falls back to a generic message when a non-Error value is thrown', async () => {
|
||||
vi.mocked(api.proposePlan).mockRejectedValue('boom')
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useLatestRequest } from './useLatestRequest'
|
||||
import { usePlanSyncContextOptional } from '@/context'
|
||||
import type { PlanProposal } from '@/types'
|
||||
|
||||
@@ -35,27 +36,41 @@ interface UsePlanProposalResult {
|
||||
* this hook keeps working when rendered outside a `<PlanSyncProvider>` —
|
||||
* e.g. this file's own unit tests, or a page test that auto-mocks
|
||||
* `@/context` wholesale — falling back to `propose()`-only behaviour.
|
||||
*
|
||||
* A second `propose()` call before an earlier one resolves (e.g. a quick
|
||||
* re-trigger) must not let the earlier, slower response land last and
|
||||
* overwrite the newer one — `useLatestRequest` guards every `setState` in
|
||||
* `propose()` for exactly that, same pattern as `useLeaveWishes.ts`.
|
||||
*/
|
||||
export function usePlanProposal(workspaceId: string | null): UsePlanProposalResult {
|
||||
const [data, setData] = useState<PlanProposal | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const syncContext = usePlanSyncContextOptional()
|
||||
const { start, isCurrent } = useLatestRequest()
|
||||
|
||||
const propose = useCallback(async () => {
|
||||
if (!workspaceId) return
|
||||
const requestId = start()
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const result = await api.proposePlan(workspaceId)
|
||||
if (!isCurrent(requestId)) return
|
||||
setData(result)
|
||||
} catch (err: unknown) {
|
||||
if (!isCurrent(requestId)) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to compute plan proposal')
|
||||
setData(null)
|
||||
} finally {
|
||||
// Unguarded: `isCurrent` decides which response's DATA wins, not
|
||||
// whether a superseded call's own busy flag gets cleared — a stale
|
||||
// call must still release the flag IT raised, or it can strand that
|
||||
// flag true forever (see usePlannedDays.ts for the hang this exact
|
||||
// guard caused there).
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [workspaceId])
|
||||
}, [workspaceId, start, isCurrent])
|
||||
|
||||
const hasSynced = (syncContext?.latestProposalVersion ?? 0) > 0
|
||||
const freshest = hasSynced ? (syncContext?.latestProposal ?? null) : data
|
||||
|
||||
@@ -89,6 +89,45 @@ describe('usePlanSync', () => {
|
||||
resolveSecond(makePlanSyncResponse())
|
||||
})
|
||||
|
||||
it('discards a stale response from an earlier, slower sync() call (P1 stale-response guard)', async () => {
|
||||
let resolveFirst: (value: ReturnType<typeof makePlanSyncResponse>) => void = () => {}
|
||||
let resolveSecond: (value: ReturnType<typeof makePlanSyncResponse>) => void = () => {}
|
||||
const firstPromise = new Promise<ReturnType<typeof makePlanSyncResponse>>((resolve) => {
|
||||
resolveFirst = resolve
|
||||
})
|
||||
const secondPromise = new Promise<ReturnType<typeof makePlanSyncResponse>>((resolve) => {
|
||||
resolveSecond = resolve
|
||||
})
|
||||
vi.mocked(api.syncPlan).mockReturnValueOnce(firstPromise).mockReturnValueOnce(secondPromise)
|
||||
|
||||
const { result } = renderHook(() => usePlanSync())
|
||||
|
||||
let firstCall: Promise<unknown> = Promise.resolve()
|
||||
let secondCall: Promise<unknown> = Promise.resolve()
|
||||
act(() => {
|
||||
firstCall = result.current.sync('ws-1')
|
||||
})
|
||||
act(() => {
|
||||
secondCall = result.current.sync('ws-1')
|
||||
})
|
||||
|
||||
// The newer (second) call resolves first...
|
||||
const fastResponse = makePlanSyncResponse({ changes: [makePlanChange({ kind: 'added' })] })
|
||||
await act(async () => {
|
||||
resolveSecond(fastResponse)
|
||||
await secondCall
|
||||
})
|
||||
expect(result.current.lastChanges).toEqual(fastResponse.changes)
|
||||
|
||||
// ...then the stale first response arrives late and must not overwrite it.
|
||||
const slowResponse = makePlanSyncResponse({ changes: [makePlanChange({ kind: 'retyped' })] })
|
||||
await act(async () => {
|
||||
resolveFirst(slowResponse)
|
||||
await firstCall
|
||||
})
|
||||
expect(result.current.lastChanges).toEqual(fastResponse.changes)
|
||||
})
|
||||
|
||||
it('sets a readable error and rethrows when the request rejects with an Error', async () => {
|
||||
vi.mocked(api.syncPlan).mockRejectedValue(new Error('workspace not found'))
|
||||
const { result } = renderHook(() => usePlanSync())
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useState } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useLatestRequest } from './useLatestRequest'
|
||||
import type { PlanSyncResponse } from '@/types'
|
||||
|
||||
interface UsePlanSyncResult {
|
||||
@@ -18,27 +19,43 @@ interface UsePlanSyncResult {
|
||||
* never retyping/overwriting a pinned day, and reporting exactly what
|
||||
* changed. Callers (Calendar.tsx) drive their own toast/undo UI off the
|
||||
* returned `changes`; this hook only owns the request's loading/error state.
|
||||
*
|
||||
* Calendar.tsx can call `sync()` again (e.g. a second edit) before an
|
||||
* earlier call's response lands — `useLatestRequest` guards every setState
|
||||
* below so a slow earlier response can never overwrite a faster later one's
|
||||
* `lastChanges`/`error`. The caller-facing `throw` on rejection is
|
||||
* unguarded (and always re-thrown) so each call's own awaiter still sees its
|
||||
* own outcome; only the shared state updates are guarded.
|
||||
*/
|
||||
export function usePlanSync(): UsePlanSyncResult {
|
||||
const [isSyncing, setIsSyncing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastChanges, setLastChanges] = useState<PlanSyncResponse['changes'] | null>(null)
|
||||
const { start, isCurrent } = useLatestRequest()
|
||||
|
||||
const sync = useCallback(async (workspaceId: string): Promise<PlanSyncResponse> => {
|
||||
const requestId = start()
|
||||
setIsSyncing(true)
|
||||
setError(null)
|
||||
setLastChanges(null)
|
||||
try {
|
||||
const result = await api.syncPlan(workspaceId)
|
||||
setLastChanges(result.changes)
|
||||
if (isCurrent(requestId)) setLastChanges(result.changes)
|
||||
return result
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to sync the plan')
|
||||
if (isCurrent(requestId)) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to sync the plan')
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
// Unguarded: `isCurrent` decides which response's DATA wins, not
|
||||
// whether a superseded call's own busy flag gets cleared — a stale
|
||||
// call must still release the flag IT raised, or it can strand that
|
||||
// flag true forever (see usePlannedDays.ts for the hang this exact
|
||||
// guard caused there).
|
||||
setIsSyncing(false)
|
||||
}
|
||||
}, [])
|
||||
}, [start, isCurrent])
|
||||
|
||||
return { isSyncing, error, lastChanges, sync }
|
||||
}
|
||||
|
||||
@@ -44,7 +44,12 @@ export function usePlannedDayExclusions(workspaceId: string | null): UsePlannedD
|
||||
if (!isCurrent(requestId)) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load removed days')
|
||||
} finally {
|
||||
if (isCurrent(requestId)) setIsLoading(false)
|
||||
// Unguarded: `isCurrent` decides which response's DATA wins, not
|
||||
// whether a superseded call's own busy flag gets cleared — a stale
|
||||
// call must still release the flag IT raised, or it can strand that
|
||||
// flag true forever (see usePlannedDays.ts for the hang this exact
|
||||
// guard caused there).
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [workspaceId, start, isCurrent])
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import * as api from '@/services/api'
|
||||
import { useLatestRequest } from './useLatestRequest'
|
||||
import { useWorkspace } from '@/context'
|
||||
@@ -6,91 +6,220 @@ import type { PlannedLeaveDay } from '@/types'
|
||||
|
||||
interface UsePlannedDaysResult {
|
||||
data: PlannedLeaveDay[] | null
|
||||
/** True only while the initial (or childId-switch) load is in flight —
|
||||
* safe to gate a full-page blocking spinner on. */
|
||||
isLoading: boolean
|
||||
/** True while a post-mutation background refresh is in flight. Never true
|
||||
* at the same time as `isLoading`; a consumer that renders a blocking
|
||||
* spinner should NOT key off this — it exists so a mutation's list refetch
|
||||
* doesn't blank whatever is currently showing `data` (see `Calendar.tsx`'s
|
||||
* `isLoading`, which only reads `isLoading`). Same shape as
|
||||
* `useLeaveWishes.ts`'s `isRefreshing` — see its doc comment. */
|
||||
isRefreshing: boolean
|
||||
error: string | null
|
||||
/** Refetches and returns the fresh list — for a caller that needs the
|
||||
* just-fetched data synchronously rather than `data`, which is whatever
|
||||
* this hook's `data` was at the closure's own render, frozen for that
|
||||
* closure's lifetime, not a live view. */
|
||||
refresh: () => Promise<PlannedLeaveDay[] | null>
|
||||
add: (payload: api.CreatePlannedDayPayload) => Promise<PlannedLeaveDay>
|
||||
update: (id: string, payload: api.UpdatePlannedDayPayload) => Promise<PlannedLeaveDay>
|
||||
* closure's lifetime, not a live view. `options.background` routes the
|
||||
* call through `isRefreshing` instead of `isLoading` — pass it whenever
|
||||
* the caller already has content on screen (e.g. a post-sync refresh)
|
||||
* that a blocking spinner would otherwise unmount. */
|
||||
refresh: (options?: { background?: boolean }) => Promise<PlannedLeaveDay[] | null>
|
||||
/** `options.skipRefresh` skips this call's own `bumpPlanVersion()` — for a
|
||||
* caller issuing several writes back-to-back (e.g. a multi-day save) that
|
||||
* wants exactly one resulting refetch instead of one per write. The
|
||||
* caller is then responsible for calling `bumpPlanVersion` itself once
|
||||
* after the batch, including on a failure partway through it — see
|
||||
* `Calendar.tsx`'s `handleSavePinnedDay`. Neither `add` nor `update` ever
|
||||
* fetches directly themselves (see the mount/`planVersion` effect below
|
||||
* for why) — `skipRefresh` only controls whether *this* call's own write
|
||||
* contributes its own `bumpPlanVersion()`. */
|
||||
add: (
|
||||
payload: api.CreatePlannedDayPayload,
|
||||
options?: { skipRefresh?: boolean },
|
||||
) => Promise<PlannedLeaveDay>
|
||||
update: (
|
||||
id: string,
|
||||
payload: api.UpdatePlannedDayPayload,
|
||||
options?: { skipRefresh?: boolean },
|
||||
) => Promise<PlannedLeaveDay>
|
||||
remove: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
export function usePlannedDays(childId?: string): UsePlannedDaysResult {
|
||||
const [data, setData] = useState<PlannedLeaveDay[] | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isRefreshing, setIsRefreshing] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const { start, isCurrent } = useLatestRequest()
|
||||
const { planVersion, bumpPlanVersion } = useWorkspace()
|
||||
// Which childId `data`/`error` currently reflects — set only once a
|
||||
// fetch's response actually lands (see `fetch`'s success/error branches),
|
||||
// NEVER synchronously inside the effect below. That distinction is what
|
||||
// keeps this hook's foreground/background classification consistent
|
||||
// under React 18 StrictMode's double-invoked mount effect — see the
|
||||
// effect's own comment.
|
||||
const loadedForChildIdRef = useRef<string | undefined>(undefined)
|
||||
// Dedupes concurrent calls for the same childId into a single HTTP
|
||||
// request — see `fetch`'s own comment for why. Keyed by `requestId`
|
||||
// (not the promise itself — a `const promise = (async () => { ...
|
||||
// promise ... })()` self-reference is unassigned from TypeScript's
|
||||
// point of view at the point the function body closes over it, even
|
||||
// though it's only ever read long after the assignment completes).
|
||||
const inFlightRef = useRef<{
|
||||
childId: string
|
||||
requestId: number
|
||||
promise: Promise<PlannedLeaveDay[] | null>
|
||||
} | null>(null)
|
||||
|
||||
const fetch = useCallback(async () => {
|
||||
const requestId = start()
|
||||
if (!childId) {
|
||||
// The child this data belongs to is gone (e.g. a logout cleared the
|
||||
// selection) — clear rather than leave the previous account's
|
||||
// planned days rendered until a new childId arrives.
|
||||
setData(null)
|
||||
setError(null)
|
||||
return null
|
||||
}
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const response = await api.getPlannedDays(childId)
|
||||
if (isCurrent(requestId)) setData(response)
|
||||
return response
|
||||
} catch (err) {
|
||||
if (isCurrent(requestId)) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch planned days')
|
||||
const fetch = useCallback(
|
||||
async (options?: { background?: boolean }): Promise<PlannedLeaveDay[] | null> => {
|
||||
if (!childId) {
|
||||
// The child this data belongs to is gone (e.g. a logout cleared the
|
||||
// selection) — clear rather than leave the previous account's
|
||||
// planned days rendered until a new childId arrives.
|
||||
setData(null)
|
||||
setError(null)
|
||||
return null
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
if (isCurrent(requestId)) setIsLoading(false)
|
||||
}
|
||||
}, [childId, start, isCurrent])
|
||||
|
||||
// Piggyback on an already-in-flight request for this exact childId
|
||||
// instead of firing a second one. Without this, React 18 StrictMode's
|
||||
// double-invoked mount effect (see the `planVersion` effect below)
|
||||
// calls this twice, back-to-back, for the same childId before either
|
||||
// has resolved — two real HTTP requests, and worse, the two calls
|
||||
// could each independently (and inconsistently) decide whether to
|
||||
// raise the blocking `isLoading` or the background `isRefreshing`
|
||||
// flag. Deduping here removes that disagreement entirely: only the
|
||||
// FIRST of an overlapping pair actually starts a request and decides
|
||||
// which flag it raises; every other call for the same childId just
|
||||
// awaits that same promise and raises nothing of its own.
|
||||
const inFlight = inFlightRef.current
|
||||
if (inFlight && inFlight.childId === childId) {
|
||||
return inFlight.promise
|
||||
}
|
||||
|
||||
const requestId = start()
|
||||
const background = options?.background ?? false
|
||||
const setBusy = background ? setIsRefreshing : setIsLoading
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
|
||||
const promise = (async (): Promise<PlannedLeaveDay[] | null> => {
|
||||
try {
|
||||
const response = await api.getPlannedDays(childId)
|
||||
if (isCurrent(requestId)) {
|
||||
setData(response)
|
||||
loadedForChildIdRef.current = childId
|
||||
}
|
||||
return response
|
||||
} catch (err) {
|
||||
if (isCurrent(requestId)) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch planned days')
|
||||
setData(null)
|
||||
loadedForChildIdRef.current = childId
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
// Deliberately UNGUARDED — unlike `setData`/`setError` above, the
|
||||
// busy flag this call raised must be cleared by this call
|
||||
// regardless of whether `isCurrent(requestId)` is still true.
|
||||
// `isCurrent` exists to decide which response's DATA wins; it
|
||||
// must never also gate whether a superseded call's own spinner
|
||||
// ever turns off. Guarding this the same way `setData` is guarded
|
||||
// is exactly what stranded `isLoading` true forever the last time
|
||||
// this hook was touched — a stale/losing call still owns its OWN
|
||||
// busy flag and must always release it. (Dedup above already
|
||||
// stops the specific StrictMode double-mount race that surfaced
|
||||
// this; this asymmetry is the fix for the underlying hazard
|
||||
// itself, which any other overlapping-call path could still hit.)
|
||||
setBusy(false)
|
||||
if (inFlightRef.current?.requestId === requestId) inFlightRef.current = null
|
||||
}
|
||||
})()
|
||||
|
||||
inFlightRef.current = { childId, requestId, promise }
|
||||
return promise
|
||||
},
|
||||
[childId, start, isCurrent],
|
||||
)
|
||||
|
||||
const add = useCallback(
|
||||
async (payload: api.CreatePlannedDayPayload) => {
|
||||
async (payload: api.CreatePlannedDayPayload, options?: { skipRefresh?: boolean }) => {
|
||||
const result = await api.createPlannedDay(payload)
|
||||
await fetch()
|
||||
bumpPlanVersion()
|
||||
if (!options?.skipRefresh) bumpPlanVersion()
|
||||
return result
|
||||
},
|
||||
[fetch, bumpPlanVersion],
|
||||
[bumpPlanVersion],
|
||||
)
|
||||
|
||||
const update = useCallback(
|
||||
async (id: string, payload: api.UpdatePlannedDayPayload) => {
|
||||
async (
|
||||
id: string,
|
||||
payload: api.UpdatePlannedDayPayload,
|
||||
options?: { skipRefresh?: boolean },
|
||||
) => {
|
||||
const result = await api.updatePlannedDay(id, payload)
|
||||
await fetch()
|
||||
bumpPlanVersion()
|
||||
if (!options?.skipRefresh) bumpPlanVersion()
|
||||
return result
|
||||
},
|
||||
[fetch, bumpPlanVersion],
|
||||
[bumpPlanVersion],
|
||||
)
|
||||
|
||||
const remove = useCallback(
|
||||
async (id: string) => {
|
||||
await api.deletePlannedDay(id)
|
||||
await fetch()
|
||||
bumpPlanVersion()
|
||||
},
|
||||
[fetch, bumpPlanVersion],
|
||||
[bumpPlanVersion],
|
||||
)
|
||||
|
||||
// `planVersion` is included so an invalidation from *another* instance
|
||||
// (e.g. Calendar's mutation bumping the counter Dashboard's instance also
|
||||
// watches) triggers a refetch here too. The instance that itself mutated
|
||||
// already refetched via the `await fetch()` calls above — its own bump
|
||||
// re-runs this effect and refetches a second time, which is redundant but
|
||||
// finite (bumpPlanVersion is never called as a result of this effect, so
|
||||
// there is no cycle).
|
||||
// The ONLY place this hook ever calls `fetch()` on a mutation's behalf —
|
||||
// `add`/`update`/`remove` above never fetch directly, only bump the
|
||||
// shared `planVersion` counter. That is what M7 part 1 actually needs:
|
||||
// this same effect already runs on mount/childId-change (a genuine first
|
||||
// load, nothing to render yet — must block via `isLoading`) AND on every
|
||||
// `planVersion` bump, from ANY instance (this one's own mutation, or a
|
||||
// different `usePlannedDays` instance elsewhere, e.g. Dashboard's — must
|
||||
// NOT block, `data` is already on screen). A prior version had the
|
||||
// mutators call `fetch({ background: true })` themselves AND rely on this
|
||||
// effect to re-fetch again on their own resulting bump — two fetches per
|
||||
// mutation, and the second one (from this effect, with no options) used
|
||||
// the *blocking* default, silently reintroducing the exact "blanks on
|
||||
// every mutation" bug this hook exists to fix. Routing every refetch
|
||||
// through this one effect makes "exactly one fetch per mutation" and
|
||||
// "which one blocks" the same decision instead of two that can disagree.
|
||||
//
|
||||
// `loadedForChildIdRef` is how a single run tells those two cases apart:
|
||||
// it's `undefined` until a fetch for this childId actually COMPLETES (set
|
||||
// inside `fetch` itself, never here), and thereafter holds whichever
|
||||
// `childId` that completed fetch was for. If the `childId` this render
|
||||
// closed over doesn't match it, this run is a genuine new load (mount, or
|
||||
// a real childId switch) — block via `isLoading`. If it matches,
|
||||
// `childId` hasn't changed and data for it has already loaded at least
|
||||
// once, so this run can only have been triggered by `planVersion`
|
||||
// changing — background via `isRefreshing`, regardless of whose mutation
|
||||
// (this instance's own, or another instance's) caused the bump.
|
||||
//
|
||||
// This effect's own body must NEVER be what decides "have we loaded this
|
||||
// child" by writing to a ref synchronously here (an earlier version did,
|
||||
// via a ref set at the top of this effect) — React 18 StrictMode
|
||||
// double-invokes a fresh mount's effect, back-to-back, before either
|
||||
// invocation's `fetch` has resolved. A ref written synchronously in the
|
||||
// effect body would already hold the new `childId` by the time the SECOND
|
||||
// invocation reads it, so invocation 1 would correctly classify itself as
|
||||
// the first (foreground) load while invocation 2 — reading a ref
|
||||
// invocation 1 had *already* overwritten a moment earlier — would
|
||||
// misclassify itself as a background refresh of a page that had loaded
|
||||
// nothing yet, permanently stranding `isLoading` true once invocation 1's
|
||||
// now-superseded response landed (see `fetch`'s dedup comment for the
|
||||
// rest of that story). Deriving `isNewChild` from a ref that only a
|
||||
// completed *response* can move sidesteps this: neither StrictMode
|
||||
// invocation has a response yet, so both compute the identical answer.
|
||||
useEffect(() => {
|
||||
fetch()
|
||||
}, [fetch, planVersion])
|
||||
const isNewChild = loadedForChildIdRef.current !== childId
|
||||
void fetch({ background: !isNewChild })
|
||||
}, [fetch, planVersion, childId])
|
||||
|
||||
return { data, isLoading, error, refresh: fetch, add, update, remove }
|
||||
return { data, isLoading, isRefreshing, error, refresh: fetch, add, update, remove }
|
||||
}
|
||||
|
||||
@@ -68,7 +68,12 @@ export function usePreschoolPlanningDays(
|
||||
if (!isCurrent(requestId)) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load preschool planning days')
|
||||
} finally {
|
||||
if (isCurrent(requestId)) setBusy(false)
|
||||
// Unguarded: `isCurrent` decides which response's DATA wins, not
|
||||
// whether a superseded call's own busy flag gets cleared — a stale
|
||||
// call must still release the flag IT raised, or it can strand
|
||||
// that flag true forever (see usePlannedDays.ts for the hang this
|
||||
// exact guard caused there).
|
||||
setBusy(false)
|
||||
}
|
||||
},
|
||||
[childId, year, start, isCurrent],
|
||||
|
||||
@@ -64,7 +64,12 @@ export function usePreschoolPlanningDaysList(
|
||||
if (!isCurrent(requestId)) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load preschool planning days')
|
||||
} finally {
|
||||
if (isCurrent(requestId)) setBusy(false)
|
||||
// Unguarded: `isCurrent` decides which response's DATA wins, not
|
||||
// whether a superseded call's own busy flag gets cleared — a stale
|
||||
// call must still release the flag IT raised, or it can strand
|
||||
// that flag true forever (see usePlannedDays.ts for the hang this
|
||||
// exact guard caused there).
|
||||
setBusy(false)
|
||||
}
|
||||
},
|
||||
[childId, start, isCurrent],
|
||||
|
||||
@@ -54,7 +54,12 @@ export function useVacationDays(workspaceId: string): UseVacationDaysResult {
|
||||
if (!isCurrent(requestId)) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load vacation days')
|
||||
} finally {
|
||||
if (isCurrent(requestId)) setBusy(false)
|
||||
// Unguarded: `isCurrent` decides which response's DATA wins, not
|
||||
// whether a superseded call's own busy flag gets cleared — a stale
|
||||
// call must still release the flag IT raised, or it can strand
|
||||
// that flag true forever (see usePlannedDays.ts for the hang this
|
||||
// exact guard caused there).
|
||||
setBusy(false)
|
||||
}
|
||||
},
|
||||
[workspaceId, start, isCurrent],
|
||||
|
||||
@@ -30,7 +30,12 @@ export function useWorkspaceMembers(workspaceId: string | null): UseWorkspaceMem
|
||||
if (!isCurrent(requestId)) return
|
||||
setError(err instanceof Error ? err.message : 'Failed to load workspace members')
|
||||
} finally {
|
||||
if (isCurrent(requestId)) setIsLoading(false)
|
||||
// Unguarded: `isCurrent` decides which response's DATA wins, not
|
||||
// whether a superseded call's own busy flag gets cleared — a stale
|
||||
// call must still release the flag IT raised, or it can strand that
|
||||
// flag true forever (see usePlannedDays.ts for the hang this exact
|
||||
// guard caused there).
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [workspaceId, start, isCurrent])
|
||||
|
||||
|
||||
@@ -545,7 +545,9 @@ describe('Calendar', () => {
|
||||
fireEvent.click(currentMonthDay!)
|
||||
fireEvent.click(screen.getByTestId('modal-save-pinned'))
|
||||
|
||||
await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ is_pinned: true })))
|
||||
await waitFor(() =>
|
||||
expect(add).toHaveBeenCalledWith(expect.objectContaining({ is_pinned: true }), { skipRefresh: true }),
|
||||
)
|
||||
})
|
||||
|
||||
it('pins an existing day edited through the day popover', async () => {
|
||||
@@ -565,7 +567,11 @@ describe('Calendar', () => {
|
||||
fireEvent.click(screen.getByTestId('modal-save-pinned'))
|
||||
|
||||
await waitFor(() =>
|
||||
expect(update).toHaveBeenCalledWith('pd-1', expect.objectContaining({ is_pinned: true })),
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
'pd-1',
|
||||
expect.objectContaining({ is_pinned: true }),
|
||||
{ skipRefresh: true },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1831,11 +1837,64 @@ describe('Calendar', () => {
|
||||
|
||||
// The pre-existing day is updated (pinned), the other two are added
|
||||
// (also pinned) — never a LeaveWish, never a sync.
|
||||
await waitFor(() => expect(update).toHaveBeenCalledWith('pd-existing', expect.objectContaining({ is_pinned: true })))
|
||||
await waitFor(() =>
|
||||
expect(update).toHaveBeenCalledWith(
|
||||
'pd-existing',
|
||||
expect.objectContaining({ is_pinned: true }),
|
||||
{ skipRefresh: true },
|
||||
),
|
||||
)
|
||||
await waitFor(() => expect(add).toHaveBeenCalledTimes(2))
|
||||
expect(add).toHaveBeenCalledWith(expect.objectContaining({ is_pinned: true }))
|
||||
expect(add).toHaveBeenCalledWith(expect.objectContaining({ is_pinned: true }), { skipRefresh: true })
|
||||
await waitFor(() => expect(screen.queryByTestId('modal')).not.toBeInTheDocument())
|
||||
})
|
||||
|
||||
it('§M7 part 2 — issues N writes, every one skipping its own bump, plus exactly one trailing plan-version bump', async () => {
|
||||
const { unmount } = render(<Calendar />)
|
||||
const probeDays = screen.getAllByTestId('calendar-day').filter((el) => !el.classList.contains('calendar-day--disabled'))
|
||||
const firstDate = probeDays[0]!.getAttribute('aria-label')!.slice(0, 10)
|
||||
unmount()
|
||||
|
||||
const add = vi.fn().mockResolvedValue(makePlannedLeaveDay())
|
||||
const update = vi.fn().mockResolvedValue(makePlannedLeaveDay())
|
||||
vi.mocked(usePlannedDays).mockReturnValue(
|
||||
makePlannedDaysResult({
|
||||
data: [makePlannedLeaveDay({ id: 'pd-existing', date: firstDate, parentPersonalDataUuid: 'person-1' })],
|
||||
add,
|
||||
update,
|
||||
}),
|
||||
)
|
||||
|
||||
render(<Calendar />)
|
||||
const currentMonthDays = screen.getAllByTestId('calendar-day').filter((el) => !el.classList.contains('calendar-day--disabled'))
|
||||
const startDay = currentMonthDays[0] as HTMLElement
|
||||
// 4 dragged dates: 1 update (the pre-existing day) + 3 adds.
|
||||
const endDay = currentMonthDays[3] as HTMLElement
|
||||
|
||||
fireEvent.pointerDown(startDay)
|
||||
fireEvent.pointerEnter(endDay)
|
||||
fireEvent.pointerUp(window)
|
||||
|
||||
const modal = await screen.findByTestId('modal')
|
||||
expect(modal).toHaveAttribute('data-day-count', '4')
|
||||
fireEvent.click(screen.getByTestId('modal-save-pinned'))
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('modal')).not.toBeInTheDocument())
|
||||
|
||||
// N writes, every single one asking the hook to skip its own bump...
|
||||
expect(update).toHaveBeenCalledTimes(1)
|
||||
expect(update).toHaveBeenCalledWith('pd-existing', expect.objectContaining({ is_pinned: true }), {
|
||||
skipRefresh: true,
|
||||
})
|
||||
expect(add).toHaveBeenCalledTimes(3)
|
||||
expect(add).toHaveBeenCalledWith(expect.objectContaining({ is_pinned: true }), { skipRefresh: true })
|
||||
// ...but exactly one trailing plan-version bump for the whole batch —
|
||||
// never a direct `plannedDaysHook.refresh()` call here (that would be
|
||||
// a second, redundant GET on top of the one `usePlannedDays`' own
|
||||
// `planVersion` effect already issues in response to this bump; see
|
||||
// that hook's doc comment).
|
||||
expect(mockWorkspace.bumpPlanVersion).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('hand-pin path never lets a free day become vacation (§ free days never consume vacation)', () => {
|
||||
@@ -2069,7 +2128,12 @@ describe('Calendar', () => {
|
||||
fireEvent.click(screen.getByTestId('modal-save-pinned'))
|
||||
|
||||
// A's row must never be touched — the old bug called `update` on it.
|
||||
await waitFor(() => expect(add).toHaveBeenCalledWith(expect.objectContaining({ parent_personal_data_uuid: 'pd-b', day_type: 'vacation' })))
|
||||
await waitFor(() =>
|
||||
expect(add).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ parent_personal_data_uuid: 'pd-b', day_type: 'vacation' }),
|
||||
{ skipRefresh: true },
|
||||
),
|
||||
)
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
|
||||
@@ -279,7 +279,14 @@ function buildSyncToastMessage(
|
||||
|
||||
export function Calendar(): ReactNode {
|
||||
const { t, locale } = useTranslation()
|
||||
const { isLoading: wsLoading, selectedChildId, workspace, personalDataUuid, parentName } = useWorkspace()
|
||||
const {
|
||||
isLoading: wsLoading,
|
||||
selectedChildId,
|
||||
workspace,
|
||||
personalDataUuid,
|
||||
parentName,
|
||||
bumpPlanVersion,
|
||||
} = useWorkspace()
|
||||
const plannedDaysHook = usePlannedDays(selectedChildId ?? undefined)
|
||||
const [showWeekNumbers] = useShowWeekNumbers()
|
||||
const [searchParams] = useSearchParams()
|
||||
@@ -400,7 +407,16 @@ export function Calendar(): ReactNode {
|
||||
): Promise<PlanSyncResponse | undefined> => {
|
||||
try {
|
||||
const result = await syncHook.sync(workspaceId)
|
||||
await Promise.all([plannedDaysHook.refresh(), calendarHook.refetch()])
|
||||
// §M7 part 1 — a background refresh here so a sync triggered by a
|
||||
// wish add/edit/delete elsewhere in this file doesn't blank the
|
||||
// page either; `calendarHook.refetch()` has no such flag and keeps
|
||||
// its pre-existing blocking behaviour (see its own doc comment).
|
||||
// Unlike `handleSavePinnedDay`'s batch, this explicit call is NOT
|
||||
// redundant with `usePlannedDays`' own `planVersion` effect:
|
||||
// `syncPlan` deliberately never bumps `planVersion` (cycle safety —
|
||||
// see `PlanSyncProvider.tsx`), so this is the only refetch a sync's
|
||||
// day changes will ever get.
|
||||
await Promise.all([plannedDaysHook.refresh({ background: true }), calendarHook.refetch()])
|
||||
if (!options?.silent) {
|
||||
const message = buildSyncToastMessage(
|
||||
result,
|
||||
@@ -618,28 +634,51 @@ export function Calendar(): ReactNode {
|
||||
|
||||
setLastLeaveType(payload.dayType)
|
||||
|
||||
for (const date of eligibleDates) {
|
||||
const existingDay = getExistingDay(date, payload.parentId)
|
||||
// §M7 part 2 — a multi-day save otherwise issued 2 requests per date
|
||||
// (write + the hook's own full refetch) sequentially. Each write below
|
||||
// skips its own `bumpPlanVersion`; the `finally` bumps exactly once for
|
||||
// the whole batch, on both the success AND error path — a failure
|
||||
// partway through must still invalidate so `data` doesn't go stale for
|
||||
// the dates that DID save. That one bump is also the ONLY refetch: it
|
||||
// reaches `usePlannedDays`' own `planVersion` effect (never a direct
|
||||
// `plannedDaysHook.refresh()` call here) — see that hook's doc comment
|
||||
// for why calling both was the M7-part-1 regression a browser
|
||||
// verification pass caught (two GETs per save, the second blocking).
|
||||
// Writes stay sequential (never `Promise.all`) — the backend runs a
|
||||
// single SQLite connection.
|
||||
try {
|
||||
for (const date of eligibleDates) {
|
||||
const existingDay = getExistingDay(date, payload.parentId)
|
||||
|
||||
if (existingDay) {
|
||||
// §Task7 — a hand-edit through the day popover pins the day so a
|
||||
// later `POST /api/plan/sync` never silently retypes it.
|
||||
await plannedDaysHook.update(existingDay.id, {
|
||||
day_type: payload.dayType,
|
||||
is_dubbeldag: payload.isDubbeldag,
|
||||
is_pinned: true,
|
||||
})
|
||||
} else {
|
||||
await plannedDaysHook.add({
|
||||
workspace_id: workspace.id,
|
||||
child_id: selectedChildId,
|
||||
parent_personal_data_uuid: payload.parentId,
|
||||
date,
|
||||
day_type: payload.dayType,
|
||||
is_dubbeldag: payload.isDubbeldag,
|
||||
is_pinned: true,
|
||||
})
|
||||
if (existingDay) {
|
||||
// §Task7 — a hand-edit through the day popover pins the day so a
|
||||
// later `POST /api/plan/sync` never silently retypes it.
|
||||
await plannedDaysHook.update(
|
||||
existingDay.id,
|
||||
{
|
||||
day_type: payload.dayType,
|
||||
is_dubbeldag: payload.isDubbeldag,
|
||||
is_pinned: true,
|
||||
},
|
||||
{ skipRefresh: true },
|
||||
)
|
||||
} else {
|
||||
await plannedDaysHook.add(
|
||||
{
|
||||
workspace_id: workspace.id,
|
||||
child_id: selectedChildId,
|
||||
parent_personal_data_uuid: payload.parentId,
|
||||
date,
|
||||
day_type: payload.dayType,
|
||||
is_dubbeldag: payload.isDubbeldag,
|
||||
is_pinned: true,
|
||||
},
|
||||
{ skipRefresh: true },
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
bumpPlanVersion()
|
||||
}
|
||||
|
||||
setIsModalOpen(false)
|
||||
|
||||
@@ -23,6 +23,7 @@ describe('DevFetch', () => {
|
||||
birth_date: '2022-01-15',
|
||||
},
|
||||
],
|
||||
take_pl_on_red_days: false,
|
||||
}
|
||||
|
||||
const emptyUserContext: api.UserContext = {
|
||||
@@ -30,6 +31,7 @@ describe('DevFetch', () => {
|
||||
personal_data_uuid: 'pd-test-456',
|
||||
parent_name: 'Test Parent',
|
||||
children: [],
|
||||
take_pl_on_red_days: false,
|
||||
}
|
||||
|
||||
const mockScraperFetchResult = {
|
||||
|
||||
@@ -800,6 +800,10 @@ export interface UserContext {
|
||||
workspace_id: string
|
||||
parent_name: string
|
||||
children: Array<{ id: string; name: string; birth_date: string }>
|
||||
// Mirrors `RawWorkspace.take_pl_on_red_days` — added so `WorkspaceContext`
|
||||
// can build its `workspace` state from this response alone on cold load,
|
||||
// instead of following up with `GET /workspace/:id` (H12 step 1).
|
||||
take_pl_on_red_days: boolean
|
||||
}
|
||||
|
||||
export async function getUserContext(): Promise<UserContext> {
|
||||
|
||||
@@ -3,8 +3,18 @@
|
||||
|
||||
export interface Workspace {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: string // ISO-8601
|
||||
// `name`/`createdAt` are optional: since H12 step 1, the cold-load path
|
||||
// (`WorkspaceContext.refreshContext`) builds `workspace` straight from
|
||||
// `GET /auth/context`, which carries only `workspace_id` +
|
||||
// `take_pl_on_red_days` — not the workspace's name or creation date. Both
|
||||
// are still populated whenever a full `Workspace` is fetched directly
|
||||
// (`getWorkspace`, `createWorkspace`, `updateWorkspace` — see `toWorkspace`
|
||||
// in src/services/api.ts); nothing in `src/` reads either field off the
|
||||
// cold-load `workspace` today, so leaving them unset there is safe. Kept
|
||||
// on the type (rather than removed) so those direct-fetch call sites don't
|
||||
// need touching.
|
||||
name?: string
|
||||
createdAt?: string // ISO-8601
|
||||
// Optional (rather than required) so pre-existing test literals across the
|
||||
// codebase that construct a `Workspace` by hand don't all need updating —
|
||||
// a real API response always includes it (see `toWorkspace` in
|
||||
|
||||
Vendored
+1
@@ -124,6 +124,7 @@ export function makePlannedDaysResult(
|
||||
return {
|
||||
data: [],
|
||||
isLoading: false,
|
||||
isRefreshing: false,
|
||||
error: null,
|
||||
refresh: vi.fn(),
|
||||
add: vi.fn(),
|
||||
|
||||
Reference in New Issue
Block a user