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:
@@ -0,0 +1,16 @@
|
|||||||
|
version: 2
|
||||||
|
updates:
|
||||||
|
- package-ecosystem: "github-actions"
|
||||||
|
directory: "/"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|
||||||
|
- package-ecosystem: "npm"
|
||||||
|
directory: "/frontend"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
|
|
||||||
|
- package-ecosystem: "uv"
|
||||||
|
directory: "/backend"
|
||||||
|
schedule:
|
||||||
|
interval: "weekly"
|
||||||
+15
-12
@@ -5,6 +5,9 @@ on:
|
|||||||
branches: [main, develop]
|
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/
|
||||||
|
|||||||
@@ -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/
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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__,
|
||||||
|
|||||||
@@ -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."""
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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'))
|
||||||
|
|
||||||
|
|||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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!([])
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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])
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Vendored
+2
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user