Merge branch 'worktree-check-security-audit'

Security and frontend-correctness hardening: analytics IDOR fix, scraper
log scrubbing, lowercase email normalisation (migration 009), stale-response
guards in the plan hooks, busy flags cleared unconditionally across all
useLatestRequest consumers, Calendar no longer blanks on mutation, plus
dependabot/CI config and removal of the stray frontend/out.js.

Conflicts came from main's later "day assertions" restructure, which made
usePlannedDays read-only and replaced the calendar's per-day add/update with
useDayAssertions.assertDay. Both intents kept:

- usePlannedDays: branch's fetch machinery (isRefreshing, in-flight dedup,
  unguarded busy clear); mutators dropped as on main.
- usePlanSync: branch's useLatestRequest guard also applied to main's new
  syncWithWish.
- useDayAssertions / useLatestBalance (new on main): busy flag now cleared
  unconditionally, as the branch did for every other consumer.
- useDayAssertions.assertDay gains the branch's `skipRefresh` option and a
  `refresh`, so Calendar's multi-day save keeps the batch shape (N writes,
  one trailing bumpPlanVersion + one assertions refresh) on main's
  assertDay path. Branch's M7 tests ported from the removed add/update API
  to assertDay.
- PlanSyncProvider: branch's isAuthenticated gate kept; eviction-preview
  state stays removed as on main.
- usePlannedDayExclusions.ts stays deleted (superseded by useDayAssertions).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
This commit is contained in:
2026-09-21 07:40:09 +02:00
47 changed files with 1271 additions and 190 deletions
+16
View File
@@ -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
View File
@@ -5,6 +5,9 @@ on:
branches: [main, develop] branches: [main, develop]
pull_request: pull_request:
permissions:
contents: read
jobs: jobs:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Backend: ruff lint + pytest with coverage gate (rises as junior tests land) # Backend: ruff lint + pytest with coverage gate (rises as junior tests land)
@@ -17,14 +20,14 @@ jobs:
working-directory: backend working-directory: backend
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: astral-sh/setup-uv@v5 - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
with: with:
version: "latest" version: "latest"
- name: Install dependencies - name: Install dependencies
run: uv sync run: uv sync --locked
- name: Lint (ruff) - name: Lint (ruff)
run: uv run ruff check . run: uv run ruff check .
@@ -47,9 +50,9 @@ jobs:
working-directory: frontend working-directory: frontend
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: "20" node-version: "20"
cache: "npm" cache: "npm"
@@ -81,23 +84,23 @@ jobs:
continue-on-error: true continue-on-error: true
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
- uses: astral-sh/setup-uv@v5 - uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
with: with:
version: "latest" version: "latest"
- uses: actions/setup-node@v4 - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with: with:
node-version: "20" node-version: "20"
- name: Install backend dependencies - name: Install backend dependencies
working-directory: backend working-directory: backend
run: uv sync run: uv sync --locked
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: frontend working-directory: frontend
run: npm install run: npm ci
- name: Install Playwright browsers - name: Install Playwright browsers
working-directory: frontend working-directory: frontend
@@ -108,8 +111,8 @@ jobs:
run: npm run e2e run: npm run e2e
- name: Upload Playwright report - name: Upload Playwright report
if: always() if: failure()
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
with: with:
name: playwright-report name: playwright-report
path: frontend/playwright-report/ path: frontend/playwright-report/
+3
View File
@@ -235,6 +235,9 @@ dist
/package-lock.json /package-lock.json
yarn.lock yarn.lock
# Dead hand-committed minified bundle (see SECURITY_PERF_AUDIT_2026-09-18.md L5)
frontend/out.js
# Playwright (frontend/e2e) # Playwright (frontend/e2e)
frontend/e2e/.auth/ frontend/e2e/.auth/
frontend/playwright-report/ frontend/playwright-report/
+13 -1
View File
@@ -175,6 +175,7 @@ Binding for all work here, not just verification.
* **Post-edit verification:** check immediately for duplicated lines and intact imports. * **Post-edit verification:** check immediately for duplicated lines and intact imports.
* **Stability check:** after any change, run that side's lint + typecheck + tests. For cross-cutting changes (e.g. API contract edits in `app/models` and `src/types`), run **both** suites — they must agree on the wire format. * **Stability check:** after any change, run that side's lint + typecheck + tests. For cross-cutting changes (e.g. API contract edits in `app/models` and `src/types`), run **both** suites — they must agree on the wire format.
* **Keep the graph current:** `graphify update .` after code changes. * **Keep the graph current:** `graphify update .` after code changes.
* **Always commit:** commit finished work without being asked — this overrides any default to wait for permission. Commit once the gates are green, never mid-change or over a failing suite. **Push and PRs still require an explicit ask.** Never commit on the default branch; branch first.
--- ---
@@ -202,4 +203,15 @@ Unsure about a rule? Ask before encoding a number — Swedish rules change, and
## OUTPUTS ## OUTPUTS
Be concise: no preamble, caveats, or restating the request. Sentence fragments and short bullets where clarity holds. Explain reasoning only when it aids debugging or isn't obvious from the code/diff. **Default reply: 1-5 lines.** Bullets or a small table. No preamble, no recap, no restating the request.
Hard rules:
* Lead with the answer. A yes/no question gets yes or no on line one.
* One line per finding. Name it, say it's fixed, move on.
* Never re-narrate a subagent report or a diff — extract the decision, drop the rest.
* Never explain reasoning that wasn't asked for, and never justify a choice in more than one clause.
* No "want me to X?" when X is obvious.
Length is **not** licensed by a finding being important, security-related, or surprising. An important finding gets a short, blunt line — that is what makes it get read. The only things that may run long: raw failure output, and an open product trade-off that needs a decision.
Explain reasoning only when it aids debugging or isn't obvious from the code/diff.
+41
View File
@@ -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. 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. 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. 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.
+15 -1
View File
@@ -1,6 +1,6 @@
"""Pydantic schemas for auth endpoints.""" """Pydantic schemas for auth endpoints."""
from pydantic import BaseModel, EmailStr, Field from pydantic import BaseModel, EmailStr, Field, field_validator
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
@@ -13,11 +13,25 @@ class RegisterRequest(BaseModel):
# if one exists) instead of creating a new one. # if one exists) instead of creating a new one.
invite_token: str | None = None 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): class LoginRequest(BaseModel):
email: EmailStr email: EmailStr
password: str 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): class TokenResponse(BaseModel):
access_token: str access_token: str
+4
View File
@@ -44,7 +44,9 @@ async def get_overview(
pd_repo: PersonalDataRepositoryDep, pd_repo: PersonalDataRepositoryDep,
workspace_repo: WorkspaceRepositoryDep, workspace_repo: WorkspaceRepositoryDep,
vacation_repo: VacationDaysRepositoryDep, vacation_repo: VacationDaysRepositoryDep,
scope: WorkspaceScopeDep,
) -> WorkspaceOverviewResponse: ) -> WorkspaceOverviewResponse:
await scope.check_child(child_id)
service = _make_service( service = _make_service(
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
) )
@@ -65,7 +67,9 @@ async def get_projection(
pd_repo: PersonalDataRepositoryDep, pd_repo: PersonalDataRepositoryDep,
workspace_repo: WorkspaceRepositoryDep, workspace_repo: WorkspaceRepositoryDep,
vacation_repo: VacationDaysRepositoryDep, vacation_repo: VacationDaysRepositoryDep,
scope: WorkspaceScopeDep,
) -> WorkspaceOverviewResponse: ) -> WorkspaceOverviewResponse:
await scope.check_child(child_id)
service = _make_service( service = _make_service(
leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo leave_repo, planned_repo, child_repo, pd_repo, workspace_repo, vacation_repo
) )
+11
View File
@@ -10,6 +10,7 @@ from app.dependencies import (
ChildRepositoryDep, ChildRepositoryDep,
CurrentUserDep, CurrentUserDep,
PersonalDataRepositoryDep, PersonalDataRepositoryDep,
WorkspaceRepositoryDep,
) )
from app.rate_limit import limiter from app.rate_limit import limiter
from app.models.auth import LoginRequest, RegisterRequest, TokenResponse, UserMeResponse from app.models.auth import LoginRequest, RegisterRequest, TokenResponse, UserMeResponse
@@ -105,6 +106,11 @@ class _UserContext(BaseModel):
workspace_id: str workspace_id: str
parent_name: str parent_name: str
children: list[_ChildContext] 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) @router.get("/context", response_model=_UserContext)
@@ -112,12 +118,16 @@ async def get_context(
current_user: CurrentUserDep, current_user: CurrentUserDep,
pd_repo: PersonalDataRepositoryDep, pd_repo: PersonalDataRepositoryDep,
child_repo: ChildRepositoryDep, child_repo: ChildRepositoryDep,
ws_repo: WorkspaceRepositoryDep,
) -> _UserContext: ) -> _UserContext:
"""Return workspace + children for the current user. Used by the dev harness.""" """Return workspace + children for the current user. Used by the dev harness."""
pd = await pd_repo.get_by_id(current_user.personal_data_uuid) pd = await pd_repo.get_by_id(current_user.personal_data_uuid)
if pd is None: if pd is None:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "No parent profile found") raise HTTPException(status.HTTP_422_UNPROCESSABLE_CONTENT, "No parent profile found")
children = await child_repo.list_by_workspace(pd.workspace_id) 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( return _UserContext(
personal_data_uuid=str(pd.uuid), personal_data_uuid=str(pd.uuid),
workspace_id=str(pd.workspace_id), workspace_id=str(pd.workspace_id),
@@ -125,6 +135,7 @@ async def get_context(
children=[ children=[
_ChildContext(id=str(c.id), name=c.name, birth_date=str(c.birth_date)) for c in 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,
) )
+35 -27
View File
@@ -306,7 +306,7 @@ class FKScraperService(FetchDataService):
# Schedule TTL watchdog # Schedule TTL watchdog
asyncio.create_task(_ttl_watchdog(session_id)) 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 return session_id
async def poll_session(self, session_id: str, owner_user_id: UUID) -> dict: async def poll_session(self, session_id: str, owner_user_id: UUID) -> dict:
@@ -345,7 +345,7 @@ class FKScraperService(FetchDataService):
state.qr_data = None state.qr_data = None
if state._task and not state._task.done(): if state._task and not state._task.done():
state._task.cancel() 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 state.qr_data = None
if state._task and not state._task.done(): if state._task and not state._task.done():
state._task.cancel() 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"): elif state.status in ("done", "error", "cancelled"):
_sessions.pop(session_id, None) _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") screenshot_path = prefix.with_suffix(".png")
await page.screenshot(path=str(screenshot_path), full_page=True) await page.screenshot(path=str(screenshot_path), full_page=True)
logger.info( logger.debug(
"scraper debug artifacts saved [id=%s] dir=%s", "scraper debug artifacts saved [id=%s] dir=%s",
session_id, session_id,
out.resolve(), out.resolve(),
) )
except Exception as artifact_exc: 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: 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 # Check for maintenance message before proceeding
maintenance_msg = await _check_maintenance(page) maintenance_msg = await _check_maintenance(page)
if maintenance_msg: if maintenance_msg:
error_msg = f"FK is under maintenance: {maintenance_msg}" _set_status(state, "error", "FK is currently under maintenance. Please try again later.")
_set_status(state, "error", error_msg) logger.debug("[%s] FK maintenance banner text: %s", state.session_id, maintenance_msg)
logger.info("[%s] FK maintenance detected; aborting session", state.session_id) logger.info("FK maintenance detected; aborting session")
return return
# Accept cookie consent banner if present (only appears once per browser profile) # 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: if is_disabled:
error_msg = "FK login temporarily unavailable (BankID button disabled). Please try again later." error_msg = "FK login temporarily unavailable (BankID button disabled). Please try again later."
_set_status(state, "error", error_msg) _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 return
except Exception: except Exception:
pass # proceed anyway; may not be disabled pass # proceed anyway; may not be disabled
@@ -627,7 +627,9 @@ async def _do_extract(page, state: _SessionState) -> None:
if family is not None: if family is not None:
state.partner_deduction = _deduce_partner(data, family) state.partner_deduction = _deduce_partner(data, family)
except Exception as exc: 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.family = None
state.partner_deduction = 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 state.partner_deduction, transferable_days_left=transferable.partner_total
) )
except Exception as exc: except Exception as exc:
logger.info( logger.debug(
"[%s] transferable-days parse skipped: %s", state.session_id, type(exc).__name__ "[%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( _set_status(
state, state,
"error", "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 return
_set_status(state, "done") _set_status(state, "done")
state.result = data 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: 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 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 `_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) None — never raised. The caller's own `data` (this parent's snapshot)
must succeed regardless of what happens here. must succeed regardless of what happens here.
""" """
try: try:
family_panel = page.locator(_SEL.FAMILY) family_panel = page.locator(_SEL.FAMILY)
if await family_panel.count() == 0: 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 return None
dagar_kvar_loc = page.locator(_SEL.FAMILY_DAGAR_KVAR) dagar_kvar_loc = page.locator(_SEL.FAMILY_DAGAR_KVAR)
if await dagar_kvar_loc.count() == 0: if await dagar_kvar_loc.count() == 0:
logger.info( logger.debug(
"[%s] family panel dagar-kvar element not found; skipping deduction", "[%s] family panel dagar-kvar element not found; skipping deduction",
state.session_id, state.session_id,
) )
@@ -768,7 +776,7 @@ async def _extract_family_summary(page, state: _SessionState) -> FamilySummary |
discrete_loc = page.locator(_SEL.FAMILY_DISCRETE) discrete_loc = page.locator(_SEL.FAMILY_DISCRETE)
if await discrete_loc.count() == 0: if await discrete_loc.count() == 0:
logger.info( logger.debug(
"[%s] family panel discrete element not found; skipping deduction", "[%s] family panel discrete element not found; skipping deduction",
state.session_id, 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) discrete_text = await discrete_loc.first.inner_text(timeout=2_000)
total_match = re.search(r"av\s+(\d+)\s+dagar", discrete_text) total_match = re.search(r"av\s+(\d+)\s+dagar", discrete_text)
if not total_match: if not total_match:
logger.info( logger.debug(
"[%s] could not parse 'av N dagar' from family panel text; skipping deduction", "[%s] could not parse 'av N dagar' from family panel text; skipping deduction",
state.session_id, state.session_id,
) )
@@ -806,7 +814,7 @@ async def _extract_family_summary(page, state: _SessionState) -> FamilySummary |
lagsta_niva_left=lagsta_niva_left, lagsta_niva_left=lagsta_niva_left,
) )
except Exception as exc: except Exception as exc:
logger.info( logger.debug(
"[%s] family panel parsing failed (%s); skipping deduction", "[%s] family panel parsing failed (%s); skipping deduction",
state.session_id, state.session_id,
type(exc).__name__, type(exc).__name__,
@@ -894,7 +902,7 @@ async def _extract_transferable_days(
Fully defensive by design, mirroring `_extract_family_summary`: any Fully defensive by design, mirroring `_extract_family_summary`: any
missing element, unmatched child, or unexpected text is "no transferable 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. The caller's own `data` (already validated) must be unaffected either way.
""" """
try: try:
@@ -924,7 +932,7 @@ async def _extract_transferable_days(
sections = page.locator(_SEL.MIN_FP_BARN_SEKTION) sections = page.locator(_SEL.MIN_FP_BARN_SEKTION)
section_count = await sections.count() section_count = await sections.count()
if section_count == 0: if section_count == 0:
logger.info( logger.debug(
"[%s] no barn-sektion found on min-foraldrapenning page; skipping " "[%s] no barn-sektion found on min-foraldrapenning page; skipping "
"transferable-days parse", "transferable-days parse",
state.session_id, state.session_id,
@@ -935,7 +943,7 @@ async def _extract_transferable_days(
chosen = sections.first chosen = sections.first
else: else:
if not child_name or not child_name.strip(): if not child_name or not child_name.strip():
logger.info( logger.debug(
"[%s] multiple children on min-foraldrapenning page and no " "[%s] multiple children on min-foraldrapenning page and no "
"child_name to match; skipping transferable-days parse", "child_name to match; skipping transferable-days parse",
state.session_id, state.session_id,
@@ -953,7 +961,7 @@ async def _extract_transferable_days(
chosen = section chosen = section
break break
if chosen is None: if chosen is None:
logger.info( logger.debug(
"[%s] no barn-sektion matched child_name; skipping transferable-days parse", "[%s] no barn-sektion matched child_name; skipping transferable-days parse",
state.session_id, state.session_id,
) )
@@ -961,7 +969,7 @@ async def _extract_transferable_days(
panel = chosen.locator(_SEL.PANEL_FORA_OVER_DAGAR) panel = chosen.locator(_SEL.PANEL_FORA_OVER_DAGAR)
if await panel.count() == 0: if await panel.count() == 0:
logger.info( logger.debug(
"[%s] chosen barn-sektion has no transfer panel; skipping transferable-days parse", "[%s] chosen barn-sektion has no transfer panel; skipping transferable-days parse",
state.session_id, state.session_id,
) )
@@ -1014,7 +1022,7 @@ async def _extract_transferable_days(
partner_sjuk, partner_lagsta = sjuk, lagsta partner_sjuk, partner_lagsta = sjuk, lagsta
if mine_sjuk is None or mine_lagsta is None: 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 " "[%s] 'Du kan föra över' group missing or incomplete; skipping "
"transferable-days parse", "transferable-days parse",
state.session_id, state.session_id,
@@ -1028,7 +1036,7 @@ async def _extract_transferable_days(
partner_lagsta_niva=partner_lagsta, partner_lagsta_niva=partner_lagsta,
) )
except Exception as exc: except Exception as exc:
logger.info( logger.debug(
"[%s] transferable-days panel parsing failed (%s); skipping", "[%s] transferable-days panel parsing failed (%s); skipping",
state.session_id, state.session_id,
type(exc).__name__, type(exc).__name__,
+1 -1
View File
@@ -552,7 +552,7 @@ class AnalyticsService:
projections = [] projections = []
for pd in members: 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) projections.append(proj)
combined_used = sum(p.days_used + p.planned_days_before_b4 for p in projections) 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."""
+132
View File
@@ -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
+23
View File
@@ -285,6 +285,29 @@ class TestForeignWorkspaceIdIs404:
) )
assert resp.status_code == 404 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( async def test_analytics_age4_foreign_parent(
self, client: AsyncClient, two_workspaces: _TwoWorkspaces self, client: AsyncClient, two_workspaces: _TwoWorkspaces
) -> None: ) -> None:
+42
View File
@@ -31,6 +31,7 @@ from app.scrapers.fk_scraper import (
_apply_transferable, _apply_transferable,
_deduce_partner, _deduce_partner,
_do_extract, _do_extract,
_do_login,
_extract_family_summary, _extract_family_summary,
_extract_transferable_days, _extract_transferable_days,
_SEL, _SEL,
@@ -1218,6 +1219,45 @@ class TestDoExtractTransferableIntegration:
assert state.result.transferable_days_left == 49 # unchanged from the original estimate 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 # Session state machine
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1706,6 +1746,8 @@ class TestSessionStateMachine:
assert state.status == "error" assert state.status == "error"
assert "persist" in (state.message or "").lower() 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: async def test_do_extract_waits_for_hydration_before_extracting(self) -> None:
"""_do_extract calls wait_for_function to ensure data hydrates before extraction.""" """_do_extract calls wait_for_function to ensure data hydrates before extraction."""
+31
View File
@@ -22,10 +22,23 @@ server {
server_tokens off; 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-Content-Type-Options nosniff always;
add_header X-Frame-Options DENY always; add_header X-Frame-Options DENY always;
add_header Referrer-Policy no-referrer 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 # Container-level liveness. Served from memory, so a broken bundle or a
# read-only mount cannot make this lie. # read-only mount cannot make this lie.
location = /healthz { location = /healthz {
@@ -58,6 +71,12 @@ server {
# `expires 1y` would emit its own Cache-Control alongside this one; # `expires 1y` would emit its own Cache-Control alongside this one;
# one explicit header is clearer than two contradicting-looking ones. # one explicit header is clearer than two contradicting-looking ones.
add_header Cache-Control "public, max-age=31536000, immutable" always; 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; try_files $uri =404;
} }
@@ -66,6 +85,11 @@ server {
location /fonts/ { location /fonts/ {
access_log off; access_log off;
add_header Cache-Control "public, max-age=31536000, immutable" always; 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; try_files $uri =404;
} }
@@ -73,6 +97,13 @@ server {
# leaves clients pinned to a stale asset graph. # leaves clients pinned to a stale asset graph.
location = /index.html { location = /index.html {
add_header Cache-Control "no-cache" always; 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 # SPA fallback. react-router-dom uses BrowserRouter, so a hard refresh or a
-20
View File
@@ -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 type { ReactNode } from 'react'
import { PlanSyncProvider, usePlanSyncContext } from './PlanSyncProvider' import { PlanSyncProvider, usePlanSyncContext } from './PlanSyncProvider'
import { useWorkspace } from './WorkspaceContext' import { useWorkspace } from './WorkspaceContext'
import { useAuth } from './AuthContext'
import * as api from '@/services/api' import * as api from '@/services/api'
import { ApiRequestError } from '@/services/errors' import { ApiRequestError } from '@/services/errors'
import { import {
makeWorkspaceContextValue, makeWorkspaceContextValue,
makeAuthContextValue,
makePlanSyncResponse, makePlanSyncResponse,
makePlanProposal, makePlanProposal,
makePlanWarning, makePlanWarning,
@@ -16,6 +18,9 @@ vi.mock('@/services/api')
vi.mock('./WorkspaceContext', () => ({ vi.mock('./WorkspaceContext', () => ({
useWorkspace: vi.fn(), useWorkspace: vi.fn(),
})) }))
vi.mock('./AuthContext', () => ({
useAuth: vi.fn(),
}))
function wrapper({ children }: { children: ReactNode }) { function wrapper({ children }: { children: ReactNode }) {
return <PlanSyncProvider>{children}</PlanSyncProvider> return <PlanSyncProvider>{children}</PlanSyncProvider>
@@ -25,6 +30,10 @@ function mockWorkspace(overrides: Partial<ReturnType<typeof useWorkspace>> = {})
vi.mocked(useWorkspace).mockReturnValue(makeWorkspaceContextValue(overrides)) vi.mocked(useWorkspace).mockReturnValue(makeWorkspaceContextValue(overrides))
} }
function mockAuth(overrides: Partial<ReturnType<typeof useAuth>> = {}) {
vi.mocked(useAuth).mockReturnValue(makeAuthContextValue(overrides))
}
describe('PlanSyncProvider', () => { describe('PlanSyncProvider', () => {
let bumpPlanVersion: ReturnType<typeof vi.fn> let bumpPlanVersion: ReturnType<typeof vi.fn>
@@ -32,6 +41,11 @@ describe('PlanSyncProvider', () => {
vi.clearAllMocks() vi.clearAllMocks()
bumpPlanVersion = vi.fn() bumpPlanVersion = vi.fn()
mockWorkspace({ workspaceId: 'ws-1', planVersion: 0, bumpPlanVersion }) 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()) vi.mocked(api.syncPlan).mockResolvedValue(makePlanSyncResponse())
}) })
@@ -42,6 +56,41 @@ describe('PlanSyncProvider', () => {
expect(api.syncPlan).not.toHaveBeenCalled() 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 () => { it('syncs silently once the workspace is known', async () => {
renderHook(() => usePlanSyncContext(), { wrapper }) renderHook(() => usePlanSyncContext(), { wrapper })
+23 -1
View File
@@ -1,6 +1,7 @@
import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react' import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'
import * as api from '@/services/api' import * as api from '@/services/api'
import { useWorkspace } from './WorkspaceContext' import { useWorkspace } from './WorkspaceContext'
import { useAuth } from './AuthContext'
import { isApiError } from '@/services/errors' import { isApiError } from '@/services/errors'
import type { PlanProposal } from '@/types' import type { PlanProposal } from '@/types'
@@ -39,6 +40,17 @@ const PlanSyncContext = createContext<PlanSyncContextValue | null>(null)
* instance every plan-input hook already bumps, and runs for the lifetime * instance every plan-input hook already bumps, and runs for the lifetime
* of the app, not just while Calendar happens to be mounted. * 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`. * **Cycle safety.** This effect must NEVER call `bumpPlanVersion`.
* `planVersion` is the shared invalidation signal every plan-input hook's * `planVersion` is the shared invalidation signal every plan-input hook's
* own fetch effect watches (see `usePlannedDays.ts:84-93`); a sync path * own fetch effect watches (see `usePlannedDays.ts:84-93`); a sync path
@@ -48,6 +60,7 @@ const PlanSyncContext = createContext<PlanSyncContextValue | null>(null)
*/ */
export function PlanSyncProvider({ children }: { children: React.ReactNode }) { export function PlanSyncProvider({ children }: { children: React.ReactNode }) {
const { workspaceId, planVersion } = useWorkspace() const { workspaceId, planVersion } = useWorkspace()
const { isAuthenticated } = useAuth()
const [latestProposal, setLatestProposal] = useState<PlanProposal | null>(null) const [latestProposal, setLatestProposal] = useState<PlanProposal | null>(null)
const [latestProposalVersion, setLatestProposalVersion] = useState(0) const [latestProposalVersion, setLatestProposalVersion] = useState(0)
@@ -106,6 +119,15 @@ export function PlanSyncProvider({ children }: { children: React.ReactNode }) {
}, []) }, [])
useEffect(() => { 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 (!workspaceId) return
if (inFlightRef.current) { if (inFlightRef.current) {
// A sync is already running — record that another bump landed and // A sync is already running — record that another bump landed and
@@ -116,7 +138,7 @@ export function PlanSyncProvider({ children }: { children: React.ReactNode }) {
return return
} }
void runSyncOnce(workspaceId) void runSyncOnce(workspaceId)
}, [workspaceId, planVersion, runSyncOnce]) }, [isAuthenticated, workspaceId, planVersion, runSyncOnce])
return ( return (
<PlanSyncContext.Provider <PlanSyncContext.Provider
+11 -37
View File
@@ -70,6 +70,7 @@ const userContext: api.UserContext = {
{ id: 'c1', name: 'Child One', birth_date: '2024-01-01' }, { id: 'c1', name: 'Child One', birth_date: '2024-01-01' },
{ id: 'c2', name: 'Child Two', birth_date: '2025-01-01' }, { id: 'c2', name: 'Child Two', birth_date: '2025-01-01' },
], ],
take_pl_on_red_days: false,
} }
describe('WorkspaceContext', () => { describe('WorkspaceContext', () => {
@@ -92,23 +93,26 @@ describe('WorkspaceContext', () => {
expect(result.current.isLoading).toBe(false) 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(useAuth).mockReturnValue(authenticated)
vi.mocked(api.getUserContext).mockResolvedValue(userContext) vi.mocked(api.getUserContext).mockResolvedValue(userContext)
vi.mocked(api.getWorkspace).mockResolvedValue(workspace)
const { result } = renderHook(() => useWorkspace(), { wrapper }) const { result } = renderHook(() => useWorkspace(), { wrapper })
await waitFor(() => expect(result.current.workspace).not.toBeNull()) await waitFor(() => expect(result.current.workspace).not.toBeNull())
expect(api.getUserContext).toHaveBeenCalled() expect(api.getUserContext).toHaveBeenCalled()
expect(api.getWorkspace).toHaveBeenCalledWith('ws1') expect(api.getWorkspace).not.toHaveBeenCalled()
expect(result.current.workspaceId).toBe('ws1') expect(result.current.workspaceId).toBe('ws1')
expect(result.current.personalDataUuid).toBe('pd1') expect(result.current.personalDataUuid).toBe('pd1')
expect(result.current.parentName).toBe('Anna') expect(result.current.parentName).toBe('Anna')
expect(result.current.children).toHaveLength(2) expect(result.current.children).toHaveLength(2)
expect(result.current.selectedChildId).toBe('c1') 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(localStorage.getItem('workspaceId')).toBe('ws1')
expect(result.current.isLoading).toBe(false) expect(result.current.isLoading).toBe(false)
}) })
@@ -136,31 +140,15 @@ describe('WorkspaceContext', () => {
expect(result.current.error).toBe('Failed to load user context') 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 () => { it('defaults to an empty children list when the context response omits children', async () => {
vi.mocked(useAuth).mockReturnValue(authenticated) vi.mocked(useAuth).mockReturnValue(authenticated)
const contextWithoutChildren = { const contextWithoutChildren = {
personal_data_uuid: userContext.personal_data_uuid, personal_data_uuid: userContext.personal_data_uuid,
workspace_id: userContext.workspace_id, workspace_id: userContext.workspace_id,
parent_name: userContext.parent_name, parent_name: userContext.parent_name,
take_pl_on_red_days: false,
} as unknown as api.UserContext } as unknown as api.UserContext
vi.mocked(api.getUserContext).mockResolvedValue(contextWithoutChildren) vi.mocked(api.getUserContext).mockResolvedValue(contextWithoutChildren)
vi.mocked(api.getWorkspace).mockResolvedValue(workspace)
const { result } = renderHook(() => useWorkspace(), { wrapper }) const { result } = renderHook(() => useWorkspace(), { wrapper })
@@ -170,19 +158,6 @@ describe('WorkspaceContext', () => {
expect(result.current.selectedChildId).toBeNull() 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 () => { it('refetch sets a non-Error rejection to a generic message', async () => {
vi.mocked(useAuth).mockReturnValue(authenticated) vi.mocked(useAuth).mockReturnValue(authenticated)
vi.mocked(api.getUserContext).mockResolvedValue(userContext) vi.mocked(api.getUserContext).mockResolvedValue(userContext)
@@ -311,15 +286,14 @@ describe('WorkspaceContext', () => {
rerender() rerender()
await waitFor(() => expect(result.current.workspace).toBeNull()) await waitFor(() => expect(result.current.workspace).toBeNull())
const otherWorkspace: Workspace = { ...workspace, id: 'ws2', name: 'Other workspace' }
const otherUserContext: api.UserContext = { const otherUserContext: api.UserContext = {
personal_data_uuid: 'pd2', personal_data_uuid: 'pd2',
workspace_id: 'ws2', workspace_id: 'ws2',
parent_name: 'Alex', parent_name: 'Alex',
children: [{ id: 'c3', name: 'Child Three', birth_date: '2026-01-01' }], 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.getUserContext).mockResolvedValue(otherUserContext)
vi.mocked(api.getWorkspace).mockResolvedValue(otherWorkspace)
vi.mocked(api.getUserContext).mockClear() vi.mocked(api.getUserContext).mockClear()
vi.mocked(useAuth).mockReturnValue(authenticated) vi.mocked(useAuth).mockReturnValue(authenticated)
@@ -330,7 +304,7 @@ describe('WorkspaceContext', () => {
expect(result.current.workspaceId).toBe('ws2') expect(result.current.workspaceId).toBe('ws2')
expect(result.current.personalDataUuid).toBe('pd2') expect(result.current.personalDataUuid).toBe('pd2')
expect(result.current.selectedChildId).toBe('c3') 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 () => { it('discards a refreshContext response that resolves after logout (S4)', async () => {
+6 -9
View File
@@ -98,15 +98,12 @@ export function WorkspaceProvider({ children: childrenProp }: { children: React.
if (childrenList.length > 0 && childrenList[0]) { if (childrenList.length > 0 && childrenList[0]) {
setSelectedChildId(childrenList[0].id) setSelectedChildId(childrenList[0].id)
} }
try { // H12 step 1: `GET /auth/context` now carries `take_pl_on_red_days`,
const ws = await api.getWorkspace(context.workspace_id) // the only field of `Workspace` any consumer actually reads off the
if (generation !== generationRef.current) return // cold-load `workspace` (see `Workspace`'s doc comment in
setWorkspace(ws) // src/types/entities.ts) — so building it here replaces a follow-up
} catch (err: unknown) { // `GET /workspace/:id` that was otherwise redundant on every cold load.
if (generation !== generationRef.current) return setWorkspace({ id: context.workspace_id, takePlOnRedDays: context.take_pl_on_red_days })
setError(err instanceof Error ? err.message : 'Failed to load workspace')
setWorkspace(null)
}
} catch (err: unknown) { } catch (err: unknown) {
if (generation !== generationRef.current) return if (generation !== generationRef.current) return
setError(err instanceof Error ? err.message : 'Failed to load user context') setError(err instanceof Error ? err.message : 'Failed to load user context')
+6 -3
View File
@@ -39,9 +39,12 @@ export function useAnalytics(workspaceId: string | null, childId?: string): UseA
setError(err instanceof Error ? err.message : 'Failed to fetch analytics') setError(err instanceof Error ? err.message : 'Failed to fetch analytics')
setData(null) setData(null)
} finally { } finally {
if (isCurrent(requestId)) { // Unguarded: `isCurrent` decides which response's DATA wins, not
setIsLoading(false) // 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]) }, [workspaceId, childId, start, isCurrent])
+6 -1
View File
@@ -101,7 +101,12 @@ export function useCalendarDays(
if (!isCurrent(requestId)) return if (!isCurrent(requestId)) return
setError(err instanceof Error ? err.message : 'Failed to load calendar days') setError(err instanceof Error ? err.message : 'Failed to load calendar days')
} finally { } 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], [start, isCurrent],
+6 -1
View File
@@ -55,7 +55,12 @@ export function useChildSettings(childId: string | null): UseChildSettingsResult
if (!isCurrent(requestId)) return if (!isCurrent(requestId)) return
setError(err instanceof Error ? err.message : 'Failed to load child') setError(err instanceof Error ? err.message : 'Failed to load child')
} finally { } 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], [childId, start, isCurrent],
@@ -87,6 +87,44 @@ describe('useDayAssertions', () => {
expect(bumpPlanVersion).toHaveBeenCalledTimes(1) expect(bumpPlanVersion).toHaveBeenCalledTimes(1)
}) })
// Ported from `usePlannedDays.test.ts`'s "M7 part 2" block when its own
// mutators moved here — the per-write `skipRefresh` contract behind
// `Calendar.tsx`'s multi-day save (N writes, one trailing bump).
describe('M7 part 2 — suppressing the per-call refetch/bump (skipRefresh)', () => {
it('assertDay: skipRefresh skips this call\'s own refetch and bumpPlanVersion', async () => {
const { result } = renderHook(() => useDayAssertions('ws-1'))
await waitFor(() => expect(result.current.isLoading).toBe(false))
const callCountBefore = vi.mocked(api.listDayAssertionsByWorkspace).mock.calls.length
const created = await result.current.assertDay(
{
workspaceId: 'ws-1',
childId: 'child-1',
parentPersonalDataUuid: 'parent-1',
date: '2027-02-03',
dayType: 'vacation',
},
{ skipRefresh: true },
)
expect(api.assertDay).toHaveBeenCalledTimes(1)
expect(created.id).toBe('da-1')
expect(vi.mocked(api.listDayAssertionsByWorkspace).mock.calls.length).toBe(callCountBefore)
expect(bumpPlanVersion).not.toHaveBeenCalled()
})
it('refresh: refetches the list once, for a caller catching up after a batch', async () => {
const { result } = renderHook(() => useDayAssertions('ws-1'))
await waitFor(() => expect(result.current.isLoading).toBe(false))
const callCountBefore = vi.mocked(api.listDayAssertionsByWorkspace).mock.calls.length
await result.current.refresh()
expect(vi.mocked(api.listDayAssertionsByWorkspace).mock.calls.length).toBe(callCountBefore + 1)
expect(bumpPlanVersion).not.toHaveBeenCalled()
})
})
it('sets an error when the fetch fails', async () => { it('sets an error when the fetch fails', async () => {
vi.mocked(api.listDayAssertionsByWorkspace).mockRejectedValueOnce(new Error('Network error')) vi.mocked(api.listDayAssertionsByWorkspace).mockRejectedValueOnce(new Error('Network error'))
+29 -7
View File
@@ -8,10 +8,25 @@ interface UseDayAssertionsResult {
data: DayAssertion[] data: DayAssertion[]
isLoading: boolean isLoading: boolean
error: string | null error: string | null
/** Refetches the workspace's assertions — for a caller that batched
* several `skipRefresh` writes and now wants the list caught up once. */
refresh: () => Promise<void>
/** Pin a concrete day type, register an ad-hoc day, or record an /** Pin a concrete day type, register an ad-hoc day, or record an
* exclusion (`dayType: 'none'`) — mirrors immediately into * exclusion (`dayType: 'none'`) — mirrors immediately into
* `planned_leave_day`, see `api.assertDay`. */ * `planned_leave_day`, see `api.assertDay`.
assertDay: (req: DayAssertionCreateRequest) => Promise<DayAssertion> *
* `options.skipRefresh` skips this call's own list refetch AND its
* `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` (and `refresh` above) itself once after the batch,
* including on a failure partway through it — see `Calendar.tsx`'s
* `handleSavePinnedDay`. Without it, every write's own bump re-runs
* `usePlannedDays`' fetch effect — N refetches for an N-day save. */
assertDay: (
req: DayAssertionCreateRequest,
options?: { skipRefresh?: boolean },
) => Promise<DayAssertion>
/** Retract the assertion at a (workspace, child, parent, date) key, if /** Retract the assertion at a (workspace, child, parent, date) key, if
* any — idempotent. */ * any — idempotent. */
clear: (key: DayAssertionKey) => Promise<void> clear: (key: DayAssertionKey) => Promise<void>
@@ -47,7 +62,12 @@ export function useDayAssertions(workspaceId: string | null): UseDayAssertionsRe
if (!isCurrent(requestId)) return if (!isCurrent(requestId)) return
setError(err instanceof Error ? err.message : 'Failed to load day assertions') setError(err instanceof Error ? err.message : 'Failed to load day assertions')
} finally { } 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]) }, [workspaceId, start, isCurrent])
@@ -56,10 +76,12 @@ export function useDayAssertions(workspaceId: string | null): UseDayAssertionsRe
}, [fetch]) }, [fetch])
const assertDay = useCallback( const assertDay = useCallback(
async (req: DayAssertionCreateRequest) => { async (req: DayAssertionCreateRequest, options?: { skipRefresh?: boolean }) => {
const created = await api.assertDay(req) const created = await api.assertDay(req)
await fetch() if (!options?.skipRefresh) {
bumpPlanVersion() await fetch()
bumpPlanVersion()
}
return created return created
}, },
[fetch, bumpPlanVersion], [fetch, bumpPlanVersion],
@@ -74,5 +96,5 @@ export function useDayAssertions(workspaceId: string | null): UseDayAssertionsRe
[fetch, bumpPlanVersion], [fetch, bumpPlanVersion],
) )
return { data, isLoading, error, assertDay, clear } return { data, isLoading, error, refresh: fetch, assertDay, clear }
} }
+6 -1
View File
@@ -42,7 +42,12 @@ export function useDayOverrides(workspaceId: string): UseDayOverridesResult {
if (!isCurrent(requestId)) return if (!isCurrent(requestId)) return
setError(err instanceof Error ? err.message : 'Failed to load day overrides') setError(err instanceof Error ? err.message : 'Failed to load day overrides')
} finally { } 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], [workspaceId, start, isCurrent],
+6 -1
View File
@@ -77,7 +77,12 @@ export function useLatestBalance(
setInitial(undefined) setInitial(undefined)
setRaw(null) setRaw(null)
} finally { } 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]) }, [personalDataUuid, childId, start, isCurrent])
+6 -1
View File
@@ -61,7 +61,12 @@ export function useLeaveWishes(workspaceId: string | null): UseLeaveWishesResult
if (!isCurrent(requestId)) return if (!isCurrent(requestId)) return
setError(err instanceof Error ? err.message : 'Failed to load leave wishes') setError(err instanceof Error ? err.message : 'Failed to load leave wishes')
} finally { } 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], [workspaceId, start, isCurrent],
+6 -1
View File
@@ -32,7 +32,12 @@ export function useOptimizationTips(workspaceId: string | null): UseOptimization
setError(err instanceof Error ? err.message : 'Failed to fetch tips') setError(err instanceof Error ? err.message : 'Failed to fetch tips')
setData(null) setData(null)
} finally { } 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]) }, [workspaceId, start, isCurrent])
+6 -1
View File
@@ -40,7 +40,12 @@ export function useParentalLeave(
setError(err instanceof Error ? err.message : 'Failed to fetch parental leave data') setError(err instanceof Error ? err.message : 'Failed to fetch parental leave data')
setData(null) setData(null)
} finally { } 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]) }, [personalDataUuid, childId, start, isCurrent])
+6 -1
View File
@@ -61,7 +61,12 @@ export function useParentalLeaves(memberUuids: string[], childId: string | null)
setError(err instanceof Error ? err.message : 'Failed to fetch parental leave data') setError(err instanceof Error ? err.message : 'Failed to fetch parental leave data')
setData({}) setData({})
} finally { } 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]) }, [memberKey, childId, start, isCurrent])
@@ -65,6 +65,45 @@ describe('usePlanProposal', () => {
expect(result.current.data).toBeNull() 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 () => { it('falls back to a generic message when a non-Error value is thrown', async () => {
vi.mocked(api.proposePlan).mockRejectedValue('boom') vi.mocked(api.proposePlan).mockRejectedValue('boom')
+16 -1
View File
@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import * as api from '@/services/api' import * as api from '@/services/api'
import { useLatestRequest } from './useLatestRequest'
import { usePlanSyncContextOptional } from '@/context' import { usePlanSyncContextOptional } from '@/context'
import type { PlanProposal } from '@/types' import type { PlanProposal } from '@/types'
@@ -35,27 +36,41 @@ interface UsePlanProposalResult {
* this hook keeps working when rendered outside a `<PlanSyncProvider>` — * this hook keeps working when rendered outside a `<PlanSyncProvider>` —
* e.g. this file's own unit tests, or a page test that auto-mocks * e.g. this file's own unit tests, or a page test that auto-mocks
* `@/context` wholesale — falling back to `propose()`-only behaviour. * `@/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 { export function usePlanProposal(workspaceId: string | null): UsePlanProposalResult {
const [data, setData] = useState<PlanProposal | null>(null) const [data, setData] = useState<PlanProposal | null>(null)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const syncContext = usePlanSyncContextOptional() const syncContext = usePlanSyncContextOptional()
const { start, isCurrent } = useLatestRequest()
const propose = useCallback(async () => { const propose = useCallback(async () => {
if (!workspaceId) return if (!workspaceId) return
const requestId = start()
setIsLoading(true) setIsLoading(true)
setError(null) setError(null)
try { try {
const result = await api.proposePlan(workspaceId) const result = await api.proposePlan(workspaceId)
if (!isCurrent(requestId)) return
setData(result) setData(result)
} catch (err: unknown) { } catch (err: unknown) {
if (!isCurrent(requestId)) return
setError(err instanceof Error ? err.message : 'Failed to compute plan proposal') setError(err instanceof Error ? err.message : 'Failed to compute plan proposal')
setData(null) setData(null)
} finally { } 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) setIsLoading(false)
} }
}, [workspaceId]) }, [workspaceId, start, isCurrent])
const hasSynced = (syncContext?.latestProposalVersion ?? 0) > 0 const hasSynced = (syncContext?.latestProposalVersion ?? 0) > 0
const freshest = hasSynced ? (syncContext?.latestProposal ?? null) : data const freshest = hasSynced ? (syncContext?.latestProposal ?? null) : data
+39
View File
@@ -90,6 +90,45 @@ describe('usePlanSync', () => {
resolveSecond(makePlanSyncResponse()) 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 () => { 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')) vi.mocked(api.syncPlan).mockRejectedValue(new Error('workspace not found'))
const { result } = renderHook(() => usePlanSync()) const { result } = renderHook(() => usePlanSync())
+28 -6
View File
@@ -1,5 +1,6 @@
import { useCallback, useState } from 'react' import { useCallback, useState } from 'react'
import * as api from '@/services/api' import * as api from '@/services/api'
import { useLatestRequest } from './useLatestRequest'
import type { PlanSyncResponse, PlanSyncWithWishResponse, SyncPlanWithWishRequest } from '@/types' import type { PlanSyncResponse, PlanSyncWithWishResponse, SyncPlanWithWishRequest } from '@/types'
interface UsePlanSyncResult { interface UsePlanSyncResult {
@@ -23,45 +24,66 @@ interface UsePlanSyncResult {
* never retyping/overwriting a pinned day, and reporting exactly what * never retyping/overwriting a pinned day, and reporting exactly what
* changed. Callers (Calendar.tsx) drive their own toast/undo UI off the * changed. Callers (Calendar.tsx) drive their own toast/undo UI off the
* returned `changes`; this hook only owns the request's loading/error state. * 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 { export function usePlanSync(): UsePlanSyncResult {
const [isSyncing, setIsSyncing] = useState(false) const [isSyncing, setIsSyncing] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [lastChanges, setLastChanges] = useState<PlanSyncResponse['changes'] | null>(null) const [lastChanges, setLastChanges] = useState<PlanSyncResponse['changes'] | null>(null)
const { start, isCurrent } = useLatestRequest()
const sync = useCallback(async (workspaceId: string): Promise<PlanSyncResponse> => { const sync = useCallback(async (workspaceId: string): Promise<PlanSyncResponse> => {
const requestId = start()
setIsSyncing(true) setIsSyncing(true)
setError(null) setError(null)
setLastChanges(null) setLastChanges(null)
try { try {
const result = await api.syncPlan(workspaceId) const result = await api.syncPlan(workspaceId)
setLastChanges(result.changes) if (isCurrent(requestId)) setLastChanges(result.changes)
return result return result
} catch (err) { } 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 throw err
} finally { } 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) setIsSyncing(false)
} }
}, []) }, [start, isCurrent])
const syncWithWish = useCallback( const syncWithWish = useCallback(
async (request: SyncPlanWithWishRequest): Promise<PlanSyncWithWishResponse> => { async (request: SyncPlanWithWishRequest): Promise<PlanSyncWithWishResponse> => {
const requestId = start()
setIsSyncing(true) setIsSyncing(true)
setError(null) setError(null)
setLastChanges(null) setLastChanges(null)
try { try {
const result = await api.syncPlanWithWish(request) const result = await api.syncPlanWithWish(request)
setLastChanges(result.sync.changes) if (isCurrent(requestId)) setLastChanges(result.sync.changes)
return result return result
} catch (err) { } 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 throw err
} finally { } finally {
// Unguarded — same reasoning as `sync` above: a superseded call must
// still release the busy flag it raised.
setIsSyncing(false) setIsSyncing(false)
} }
}, },
[], [start, isCurrent],
) )
return { isSyncing, error, lastChanges, sync, syncWithWish } return { isSyncing, error, lastChanges, sync, syncWithWish }
+191 -1
View File
@@ -1,5 +1,6 @@
import { describe, it, expect, beforeEach, vi } from 'vitest' import { describe, it, expect, beforeEach, vi } from 'vitest'
import { renderHook, act } from '@testing-library/react' import { renderHook, act } from '@testing-library/react'
import { createElement, StrictMode, type ReactNode } from 'react'
import { usePlannedDays } from './usePlannedDays' import { usePlannedDays } from './usePlannedDays'
import * as api from '@/services/api' import * as api from '@/services/api'
import { useWorkspace } from '@/context' import { useWorkspace } from '@/context'
@@ -116,7 +117,193 @@ describe('usePlannedDays', () => {
expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBeGreaterThan(callCount) expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBeGreaterThan(callCount)
}) })
it('sets loading state during fetch', async () => { it('M7 part 1 — a mutation results in exactly one background refetch, never isLoading (only isRefreshing flips)', async () => {
// This is the exact regression a browser verification pass caught: an
// earlier version had this hook's own mutators call `fetch({ background:
// true })` themselves AND rely on their own resulting `planVersion` bump
// to re-run this hook's effect a second time — and that second,
// effect-triggered fetch used the *blocking* default, so a save still
// blanked the page via a second GET. The writers (`useDayAssertions`,
// `usePlanSync`) now only ever call `bumpPlanVersion()`; the effect
// below is the ONLY place that fetches, so there is exactly one fetch,
// and it decides for itself whether that's the (blocking) first load or
// a (background) mutation refetch — see usePlannedDays.ts's doc comment.
vi.mocked(api.getPlannedDays).mockResolvedValue([])
// A bare `vi.fn()` `bumpPlanVersion` (this file's default) never causes
// a re-render on its own — wire up a real counter so calling it (as a
// writer hook would) and then `rerender()`ing genuinely reproduces what
// the real `WorkspaceProvider`'s state update would do.
let planVersion = 0
const bump = () => {
planVersion += 1
}
vi.mocked(useWorkspace).mockImplementation(() =>
makeWorkspaceContextValue({ planVersion, bumpPlanVersion: bump }),
)
const { result, rerender } = renderHook(() => usePlannedDays('child1'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(result.current.isLoading).toBe(false)
const callCountAfterMount = vi.mocked(api.getPlannedDays).mock.calls.length
// The refetch that follows the mutation's bump should hang so the
// in-flight state is observable before it resolves.
let resolveRefetch: (value: ReturnType<typeof makePlannedLeaveDay>[]) => void = () => {}
vi.mocked(api.getPlannedDays).mockReturnValueOnce(
new Promise((resolve) => {
resolveRefetch = resolve
}),
)
await act(async () => {
// Stands in for a writer hook's post-mutation `bumpPlanVersion()`.
bump()
})
// The bump only takes effect on this instance's next render — nothing
// has refetched yet.
expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBe(callCountAfterMount)
rerender()
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
// Exactly one refetch so far, and it's non-blocking.
expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBe(callCountAfterMount + 1)
expect(result.current.isRefreshing).toBe(true)
expect(result.current.isLoading).toBe(false)
await act(async () => {
resolveRefetch([])
})
expect(result.current.isRefreshing).toBe(false)
expect(result.current.isLoading).toBe(false)
// Settling further (no further bump) raises no additional GET.
rerender()
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBe(callCountAfterMount + 1)
})
describe('M7 follow-up — the StrictMode double-mount hang', () => {
const strictModeWrapper = ({ children }: { children: ReactNode }) =>
createElement(StrictMode, null, children)
it('reproduces the reported hang: navigating to a page that mounts with childId already known must not strand isLoading true', async () => {
// React 18 StrictMode double-invokes a fresh mount's effect,
// back-to-back, before either invocation's fetch has resolved. On a
// real SPA navigation `childId` is already known at mount (unlike a
// hard reload, where it's still `undefined` on the very first
// render) — this is exactly what the bug report's "works on hard
// reload, hangs on in-app navigation" symptom traced back to.
vi.mocked(api.getPlannedDays).mockResolvedValue([])
const { result } = renderHook(() => usePlannedDays('child1'), {
wrapper: strictModeWrapper,
})
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
expect(result.current.isLoading).toBe(false)
expect(result.current.isRefreshing).toBe(false)
expect(result.current.data).toEqual([])
// The dedup fix means the double-invoked mount effect still issues
// exactly one real HTTP request, not two.
expect(api.getPlannedDays).toHaveBeenCalledTimes(1)
})
it('two overlapping calls for the same childId — one foreground, one background, started before either resolves — both end up cleared: isLoading false, isRefreshing false, exactly one request', async () => {
// The same hazard, exercised directly (per the coordinator's
// alternative to a StrictMode wrapper): two calls for the identical
// childId, racing, with DIFFERENT background flags — the exact shape
// that stranded `isLoading` before this fix (the stale call raised
// one flag, the current call raised the other, and only the current
// call's own — guarded — clear ever ran).
let resolveFetch: (value: ReturnType<typeof makePlannedLeaveDay>[]) => void = () => {}
vi.mocked(api.getPlannedDays).mockReturnValue(
new Promise((resolve) => {
resolveFetch = resolve
}),
)
const { result } = renderHook(() => usePlannedDays('child1'))
// The mount effect already started one (foreground) call — fire a
// second, differently-classified call for the SAME childId before it
// resolves.
let secondCall: Promise<unknown> = Promise.resolve()
act(() => {
secondCall = result.current.refresh({ background: true })
})
expect(result.current.isLoading).toBe(true)
await act(async () => {
resolveFetch([])
await secondCall
})
expect(result.current.isLoading).toBe(false)
expect(result.current.isRefreshing).toBe(false)
// Deduped into a single request.
expect(api.getPlannedDays).toHaveBeenCalledTimes(1)
})
})
describe('M7 part 2 — one trailing bump for a whole batch of writes', () => {
// The per-write `skipRefresh` option itself lives on the writer hook
// now (`useDayAssertions.assertDay` — see its tests); this covers the
// reader's side of that contract.
it('a batch of skipRefresh writes followed by one bumpPlanVersion() call (Calendar.tsx\'s multi-day save pattern) produces exactly one trailing background GET', async () => {
vi.mocked(api.getPlannedDays).mockResolvedValue([])
// A real counter (not this file's default inert `vi.fn()`) so calling
// `bump()` — standing in for `Calendar.tsx handleSavePinnedDay`'s
// trailing `bumpPlanVersion()` — then `rerender()`ing genuinely
// reproduces the real `WorkspaceProvider` state update that follows.
let planVersion = 0
const bump = () => {
planVersion += 1
}
vi.mocked(useWorkspace).mockImplementation(() =>
makeWorkspaceContextValue({ planVersion, bumpPlanVersion: bump }),
)
const { result, rerender } = renderHook(() => usePlannedDays('child1'))
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
const callCountAfterMount = vi.mocked(api.getPlannedDays).mock.calls.length
await act(async () => {
// Simulates Calendar.tsx's multi-day save loop: N writes through
// `useDayAssertions.assertDay(..., { skipRefresh: true })`, none of
// which touch this hook, then exactly ONE trailing bump for the
// whole batch (mirrors `handleSavePinnedDay`'s `finally`).
bump()
})
// The bump only takes effect on this instance's next render — nothing
// has refetched yet.
expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBe(callCountAfterMount)
rerender()
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0))
})
// Exactly one trailing GET for the whole batch, and it never blocked.
expect(vi.mocked(api.getPlannedDays).mock.calls.length).toBe(callCountAfterMount + 1)
expect(result.current.isLoading).toBe(false)
expect(result.current.isRefreshing).toBe(false)
})
})
it('sets loading state during fetch (M7 part 1 — the initial mount load DOES block, unlike a post-mutation refetch)', async () => {
let resolveGetPlannedDays: (value: Awaited<ReturnType<typeof api.getPlannedDays>>) => void let resolveGetPlannedDays: (value: Awaited<ReturnType<typeof api.getPlannedDays>>) => void
const getPlannedDaysPromise: ReturnType<typeof api.getPlannedDays> = new Promise(resolve => { const getPlannedDaysPromise: ReturnType<typeof api.getPlannedDays> = new Promise(resolve => {
resolveGetPlannedDays = resolve resolveGetPlannedDays = resolve
@@ -126,7 +313,10 @@ describe('usePlannedDays', () => {
const { result } = renderHook(() => usePlannedDays('child1')) 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.isLoading).toBe(true)
expect(result.current.isRefreshing).toBe(false)
await act(async () => { await act(async () => {
resolveGetPlannedDays!([]) resolveGetPlannedDays!([])
+151 -34
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useRef } from 'react'
import * as api from '@/services/api' import * as api from '@/services/api'
import { useLatestRequest } from './useLatestRequest' import { useLatestRequest } from './useLatestRequest'
import { useWorkspace } from '@/context' import { useWorkspace } from '@/context'
@@ -6,61 +6,178 @@ import type { PlannedLeaveDay } from '@/types'
interface UsePlannedDaysResult { interface UsePlannedDaysResult {
data: PlannedLeaveDay[] | null 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 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 error: string | null
/** Refetches and returns the fresh list — for a caller that needs the /** Refetches and returns the fresh list — for a caller that needs the
* just-fetched data synchronously rather than `data`, which is whatever * just-fetched data synchronously rather than `data`, which is whatever
* this hook's `data` was at the closure's own render, frozen for that * this hook's `data` was at the closure's own render, frozen for that
* closure's lifetime, not a live view. */ * closure's lifetime, not a live view. `options.background` routes the
refresh: () => Promise<PlannedLeaveDay[] | null> * 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>
} }
/** `planned_leave_day` is purely derived output (restructure step 3b) — this /** `planned_leave_day` is purely derived output (restructure step 3b) — this
* hook is READ ONLY. Every write goes through `usePlanSync` (`POST * hook is READ ONLY. Every write goes through `usePlanSync` (`POST
* /api/plan/sync(-with-wish)`) or `useDayAssertions` (`POST`/`DELETE * /api/plan/sync(-with-wish)`) or `useDayAssertions` (`POST`/`DELETE`
* /api/day-assertions`), both of which bump the shared `planVersion` this * /api/day-assertions`), both of which bump the shared `planVersion` this
* hook watches to refetch — see the `useEffect` below. * hook watches to refetch — see the `useEffect` below.
*/ */
export function usePlannedDays(childId?: string): UsePlannedDaysResult { export function usePlannedDays(childId?: string): UsePlannedDaysResult {
const [data, setData] = useState<PlannedLeaveDay[] | null>(null) const [data, setData] = useState<PlannedLeaveDay[] | null>(null)
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [isRefreshing, setIsRefreshing] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const { start, isCurrent } = useLatestRequest() const { start, isCurrent } = useLatestRequest()
const { planVersion } = useWorkspace() const { planVersion } = 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 fetch = useCallback(
const requestId = start() async (options?: { background?: boolean }): Promise<PlannedLeaveDay[] | null> => {
if (!childId) { if (!childId) {
// The child this data belongs to is gone (e.g. a logout cleared the // The child this data belongs to is gone (e.g. a logout cleared the
// selection) — clear rather than leave the previous account's // selection) — clear rather than leave the previous account's
// planned days rendered until a new childId arrives. // 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')
setData(null) setData(null)
setError(null)
return null
} }
return null
} finally {
if (isCurrent(requestId)) setIsLoading(false)
}
}, [childId, start, isCurrent])
// `planVersion` changing (from `usePlanSync`, `useDayAssertions`, or any // Piggyback on an already-in-flight request for this exact childId
// other instance of this same hook elsewhere) is this hook's only signal // instead of firing a second one. Without this, React 18 StrictMode's
// to refetch — it never mutates on its own any more. // 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],
)
// The ONLY place this hook ever calls `fetch()` on a mutation's behalf —
// the writers (`usePlanSync`, `useDayAssertions`) never fetch this hook's
// data 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(() => { useEffect(() => {
fetch() const isNewChild = loadedForChildIdRef.current !== childId
}, [fetch, planVersion]) void fetch({ background: !isNewChild })
}, [fetch, planVersion, childId])
return { data, isLoading, error, refresh: fetch } return { data, isLoading, isRefreshing, error, refresh: fetch }
} }
@@ -68,7 +68,12 @@ export function usePreschoolPlanningDays(
if (!isCurrent(requestId)) return if (!isCurrent(requestId)) return
setError(err instanceof Error ? err.message : 'Failed to load preschool planning days') setError(err instanceof Error ? err.message : 'Failed to load preschool planning days')
} finally { } 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], [childId, year, start, isCurrent],
@@ -64,7 +64,12 @@ export function usePreschoolPlanningDaysList(
if (!isCurrent(requestId)) return if (!isCurrent(requestId)) return
setError(err instanceof Error ? err.message : 'Failed to load preschool planning days') setError(err instanceof Error ? err.message : 'Failed to load preschool planning days')
} finally { } 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], [childId, start, isCurrent],
+6 -1
View File
@@ -54,7 +54,12 @@ export function useVacationDays(workspaceId: string): UseVacationDaysResult {
if (!isCurrent(requestId)) return if (!isCurrent(requestId)) return
setError(err instanceof Error ? err.message : 'Failed to load vacation days') setError(err instanceof Error ? err.message : 'Failed to load vacation days')
} finally { } 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], [workspaceId, start, isCurrent],
+6 -1
View File
@@ -30,7 +30,12 @@ export function useWorkspaceMembers(workspaceId: string | null): UseWorkspaceMem
if (!isCurrent(requestId)) return if (!isCurrent(requestId)) return
setError(err instanceof Error ? err.message : 'Failed to load workspace members') setError(err instanceof Error ? err.message : 'Failed to load workspace members')
} finally { } 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]) }, [workspaceId, start, isCurrent])
+49 -3
View File
@@ -689,7 +689,9 @@ describe('Calendar', () => {
fireEvent.click(screen.getByTestId('modal-save-pinned')) fireEvent.click(screen.getByTestId('modal-save-pinned'))
await waitFor(() => await waitFor(() =>
expect(assertDay).toHaveBeenCalledWith(expect.objectContaining({ dayType: 'vacation' })), expect(assertDay).toHaveBeenCalledWith(expect.objectContaining({ dayType: 'vacation' }), {
skipRefresh: true,
}),
) )
}) })
@@ -712,6 +714,7 @@ describe('Calendar', () => {
await waitFor(() => await waitFor(() =>
expect(assertDay).toHaveBeenCalledWith( expect(assertDay).toHaveBeenCalledWith(
expect.objectContaining({ dayType: 'vacation', parentPersonalDataUuid: 'person-1', date: today }), expect.objectContaining({ dayType: 'vacation', parentPersonalDataUuid: 'person-1', date: today }),
{ skipRefresh: true },
), ),
) )
}) })
@@ -2984,9 +2987,49 @@ describe('Calendar', () => {
// All 3 dragged dates are asserted — never a LeaveWish, never a sync. // All 3 dragged dates are asserted — never a LeaveWish, never a sync.
await waitFor(() => expect(assertDay).toHaveBeenCalledTimes(3)) await waitFor(() => expect(assertDay).toHaveBeenCalledTimes(3))
expect(assertDay).toHaveBeenCalledWith(expect.objectContaining({ dayType: 'vacation' })) expect(assertDay).toHaveBeenCalledWith(expect.objectContaining({ dayType: 'vacation' }), {
skipRefresh: true,
})
await waitFor(() => expect(screen.queryByTestId('modal')).not.toBeInTheDocument()) await waitFor(() => expect(screen.queryByTestId('modal')).not.toBeInTheDocument())
}) })
it('§M7 part 2 — issues N writes, every one skipping its own refetch/bump, plus exactly one trailing plan-version bump and one assertions refresh', async () => {
const assertDay = vi.fn().mockResolvedValue(makeDayAssertion())
const refresh = vi.fn().mockResolvedValue(undefined)
vi.mocked(useDayAssertions).mockReturnValue(makeDayAssertionsResult({ assertDay, refresh }))
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 -> 4 assertions (assertDay is an upsert, so a
// pre-existing row needs no separate code path).
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 refetch/bump...
expect(assertDay).toHaveBeenCalledTimes(4)
expect(assertDay).toHaveBeenCalledWith(expect.objectContaining({ dayType: 'vacation' }), {
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) — and one assertions-list refresh, whose
// data is not driven by `planVersion`.
expect(mockWorkspace.bumpPlanVersion).toHaveBeenCalledTimes(1)
expect(refresh).toHaveBeenCalledTimes(1)
expect(mockPlannedDays.refresh).not.toHaveBeenCalled()
})
}) })
describe('hand-pin path never lets a free day become vacation (§ free days never consume vacation)', () => { describe('hand-pin path never lets a free day become vacation (§ free days never consume vacation)', () => {
@@ -3220,7 +3263,10 @@ describe('Calendar', () => {
// A's row must never be touched — assertDay is only ever called once, // A's row must never be touched — assertDay is only ever called once,
// for B. // for B.
await waitFor(() => await waitFor(() =>
expect(assertDay).toHaveBeenCalledWith(expect.objectContaining({ parentPersonalDataUuid: 'pd-b', dayType: 'vacation' })), expect(assertDay).toHaveBeenCalledWith(
expect.objectContaining({ parentPersonalDataUuid: 'pd-b', dayType: 'vacation' }),
{ skipRefresh: true },
),
) )
expect(assertDay).toHaveBeenCalledTimes(1) expect(assertDay).toHaveBeenCalledTimes(1)
}) })
+44 -12
View File
@@ -570,7 +570,16 @@ export function Calendar(): ReactNode {
): Promise<PlanSyncResponse | undefined> => { ): Promise<PlanSyncResponse | undefined> => {
try { try {
const result = await syncHook.sync(workspaceId) 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) { if (!options?.silent) {
const message = buildSyncToastMessage( const message = buildSyncToastMessage(
result, result,
@@ -840,17 +849,40 @@ export function Calendar(): ReactNode {
setLastLeaveType(payload.dayType) setLastLeaveType(payload.dayType)
for (const date of eligibleDates) { // §M7 part 2 — a multi-day save otherwise issued 3 requests per date
// §Task7 — a hand-edit through the day popover asserts the day so a // (write + the hook's own assertions refetch + a planned-days refetch
// later `POST /api/plan/sync` never silently retypes it. // from its bump) sequentially, and each of those planned-days refetches
await assertionsHook.assertDay({ // blocked the page. Each write below skips its own refetch/bump; the
workspaceId: workspace.id, // `finally` bumps exactly once for the whole batch, on both the
childId: selectedChildId, // success AND error path — a failure partway through must still
parentPersonalDataUuid: payload.parentId, // invalidate so `data` doesn't go stale for the dates that DID save.
date, // That one bump is also the ONLY planned-days refetch: it reaches
dayType: payload.dayType, // `usePlannedDays`' own `planVersion` effect (never a direct
isDubbeldag: payload.isDubbeldag, // `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).
// `assertionsHook.refresh()` is not redundant with it: that hook's
// list isn't driven by `planVersion`. Writes stay sequential (never
// `Promise.all`) — the backend runs a single SQLite connection.
try {
for (const date of eligibleDates) {
// §Task7 — a hand-edit through the day popover asserts the day so a
// later `POST /api/plan/sync` never silently retypes it.
await assertionsHook.assertDay(
{
workspaceId: workspace.id,
childId: selectedChildId,
parentPersonalDataUuid: payload.parentId,
date,
dayType: payload.dayType,
isDubbeldag: payload.isDubbeldag,
},
{ skipRefresh: true },
)
}
} finally {
bumpPlanVersion()
await assertionsHook.refresh()
} }
setIsModalOpen(false) setIsModalOpen(false)
+2
View File
@@ -23,6 +23,7 @@ describe('DevFetch', () => {
birth_date: '2022-01-15', birth_date: '2022-01-15',
}, },
], ],
take_pl_on_red_days: false,
} }
const emptyUserContext: api.UserContext = { const emptyUserContext: api.UserContext = {
@@ -30,6 +31,7 @@ describe('DevFetch', () => {
personal_data_uuid: 'pd-test-456', personal_data_uuid: 'pd-test-456',
parent_name: 'Test Parent', parent_name: 'Test Parent',
children: [], children: [],
take_pl_on_red_days: false,
} }
const mockScraperFetchResult = { const mockScraperFetchResult = {
+4
View File
@@ -808,6 +808,10 @@ export interface UserContext {
workspace_id: string workspace_id: string
parent_name: string parent_name: string
children: Array<{ id: string; name: string; birth_date: 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> { export async function getUserContext(): Promise<UserContext> {
+12 -2
View File
@@ -3,8 +3,18 @@
export interface Workspace { export interface Workspace {
id: string id: string
name: string // `name`/`createdAt` are optional: since H12 step 1, the cold-load path
createdAt: string // ISO-8601 // (`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 // Optional (rather than required) so pre-existing test literals across the
// codebase that construct a `Workspace` by hand don't all need updating — // codebase that construct a `Workspace` by hand don't all need updating —
// a real API response always includes it (see `toWorkspace` in // a real API response always includes it (see `toWorkspace` in
+2
View File
@@ -122,6 +122,7 @@ export function makePlannedDaysResult(
return { return {
data: [], data: [],
isLoading: false, isLoading: false,
isRefreshing: false,
error: null, error: null,
refresh: vi.fn(), refresh: vi.fn(),
...overrides, ...overrides,
@@ -192,6 +193,7 @@ export function makeDayAssertionsResult(
data: [], data: [],
isLoading: false, isLoading: false,
error: null, error: null,
refresh: vi.fn(),
assertDay: vi.fn(), assertDay: vi.fn(),
clear: vi.fn(), clear: vi.fn(),
...overrides, ...overrides,