"""API-level end-to-end journey tests. Two real bugs were found in one session by driving the *running* app through a browser while 1,119 unit/service/router tests sat green. Both were whole-journey bugs: every existing test exercised a single layer (a service in isolation, or a router with hand-built repository rows), so nothing ever walked register -> child -> vacation days -> wishes -> propose -> apply -> re-propose the way a real user (and the browser session that found the bugs) does. This file closes that gap. Every test here drives the real FastAPI app (`app.main.app`) over HTTP with `httpx.ASGITransport`, authenticating with a real register -> bearer-token flow (see `_register`), and only the DB connection is swapped for the in-memory `connection` fixture from `tests/conftest.py` (via the `get_connection` dependency override — the same technique `tests/unit/test_plan_router.py` uses). Dates are always derived from `date.today()` (`next_year`, `relativedelta(months=...)`, ...) or from `cached_year_bounds()` / `dateutil.easter.easter()` — never a hardcoded year — so these tests stay meaningful next year and as the Swedish-calendar cache is regenerated with a different range. """ from __future__ import annotations from datetime import date from typing import Any from uuid import UUID, uuid4 import aiosqlite import pytest_asyncio from dateutil.easter import easter from dateutil.relativedelta import relativedelta from httpx import ASGITransport, AsyncClient from app.config import settings from app.dependencies import get_connection from app.main import app from app.utils.holidays import cached_year_bounds _PASSWORD = "correct horse battery staple" @pytest_asyncio.fixture async def client(connection: aiosqlite.Connection) -> AsyncClient: """An httpx client against the real app, with `get_connection` overridden to hand back the test's in-memory connection instead of opening the real on-disk database via the (never-run) lifespan. Same technique as `tests/unit/test_plan_router.py`. """ 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) # --------------------------------------------------------------------------- # Journey helpers — each wraps one HTTP call the real app exposes. # --------------------------------------------------------------------------- async def _register( client: AsyncClient, email: str, display_name: str ) -> tuple[dict[str, str], UUID, UUID]: """Register a brand-new user and return (auth headers, workspace_id, personal_data_uuid) — `AuthService.register` creates the workspace and the registering parent's `PersonalData` row as a side effect, exactly as the real onboarding flow does. """ resp = await client.post( "/api/auth/register", json={"email": email, "password": _PASSWORD, "display_name": display_name}, ) assert resp.status_code == 201, resp.text token = resp.json()["access_token"] headers = {"Authorization": f"Bearer {token}"} ctx = await client.get("/api/auth/context", headers=headers) assert ctx.status_code == 200, ctx.text body = ctx.json() return headers, UUID(body["workspace_id"]), UUID(body["personal_data_uuid"]) async def _add_child( client: AsyncClient, headers: dict[str, str], workspace_id: UUID, name: str, birth_date: date ) -> UUID: resp = await client.post( "/api/children", headers=headers, json={ "workspace_id": str(workspace_id), "name": name, "birth_date": birth_date.isoformat(), }, ) assert resp.status_code == 201, resp.text return UUID(resp.json()["id"]) async def _set_vacation( client: AsyncClient, headers: dict[str, str], personal_data_uuid: UUID, workspace_id: UUID, year: int, total_days: int = 25, saved_days_in: int = 0, ) -> None: resp = await client.post( "/api/vacation-days", headers=headers, json={ "personal_data_uuid": str(personal_data_uuid), "workspace_id": str(workspace_id), "year": year, "total_days": total_days, "saved_days_in": saved_days_in, }, ) assert resp.status_code == 201, resp.text async def _set_full_parental_leave( client: AsyncClient, headers: dict[str, str], personal_data_uuid: UUID, workspace_id: UUID, child_id: UUID, sjukpenning_used: int = 0, lagsta_niva_used: int = 0, ) -> None: """Record a manual FK snapshot via the real `/api/parental-leave/manual` endpoint (the always-available alternative to the scraper), defaulting to a full, untouched 390+90 pool. """ resp = await client.post( "/api/parental-leave/manual", headers=headers, json={ "personal_data_uuid": str(personal_data_uuid), "workspace_id": str(workspace_id), "child_id": str(child_id), "total_days": 480, "days_used": sjukpenning_used + lagsta_niva_used, "reserved_days_total": 90, "reserved_days_left": 90, "transferable_days_left": 150, "dubbeldagar_left": 60, "sjukpenning_total": 390, "sjukpenning_used": sjukpenning_used, "lagsta_niva_total": 90, "lagsta_niva_used": lagsta_niva_used, }, ) assert resp.status_code == 201, resp.text async def _create_wish( client: AsyncClient, headers: dict[str, str], **kwargs: Any ) -> dict[str, Any]: resp = await client.post("/api/leave-wishes", headers=headers, json=kwargs) assert resp.status_code == 201, resp.text return resp.json() async def _propose( client: AsyncClient, headers: dict[str, str], workspace_id: UUID ) -> dict[str, Any]: resp = await client.post( "/api/plan/propose", headers=headers, json={"workspace_id": str(workspace_id)} ) assert resp.status_code == 200, resp.text return resp.json() async def _apply( client: AsyncClient, headers: dict[str, str], workspace_id: UUID, days: list[dict[str, Any]], replace: bool = False, ) -> dict[str, Any]: resp = await client.post( "/api/plan/apply", headers=headers, json={"workspace_id": str(workspace_id), "days": days, "replace": replace}, ) assert resp.status_code == 200, resp.text return resp.json() async def _sync(client: AsyncClient, headers: dict[str, str], workspace_id: UUID) -> dict[str, Any]: resp = await client.post( "/api/plan/sync", headers=headers, json={"workspace_id": str(workspace_id)} ) assert resp.status_code == 200, resp.text return resp.json() async def _list_planned_days( client: AsyncClient, headers: dict[str, str], child_id: UUID ) -> list[dict[str, Any]]: resp = await client.get( "/api/planned-days", headers=headers, params={"child_id": str(child_id)} ) assert resp.status_code == 200, resp.text return resp.json() def _paid_days_for_wish(proposal: dict[str, Any], wish_id: str) -> list[dict[str, Any]]: """Proposed days belonging to `wish_id` that would actually cost a semesterdag/föräldrapenningdag — excludes the automatic 'free' (weekend/red-day) filler entries a window also reports. """ return [d for d in proposal["days"] if d["wish_id"] == wish_id and d["day_type"] != "free"] def _to_apply_item(d: dict[str, Any]) -> dict[str, Any]: return { "date": d["date"], "parent_personal_data_uuid": d["parent_personal_data_uuid"], "child_id": d["child_id"], "day_type": d["day_type"], "is_dubbeldag": d["is_dubbeldag"], } def _outcome_for_wish( proposal: dict[str, Any], wish_id: str, year: int | None = None ) -> dict[str, Any]: matches = [ o for o in proposal["outcomes"] if o["wish_id"] == wish_id and (year is None or o["year"] == year) ] assert matches, f"no outcome for wish {wish_id} (year={year}) in {proposal['outcomes']}" assert len(matches) == 1, f"expected exactly one outcome, got {matches}" return matches[0] _SWEDISH_CHARS = set("åäöÅÄÖ") def _find_swedish_strings(obj: object, path: str = "$") -> list[str]: """Recursively walk a decoded JSON response and collect every string leaf containing å/ä/ö — §3.5 requires every user-facing surface to be a `message_key` + `data` pair instead of a pre-rendered Swedish string. """ found: list[str] = [] if isinstance(obj, str): if any(ch in _SWEDISH_CHARS for ch in obj): found.append(f"{path} = {obj!r}") elif isinstance(obj, dict): for k, v in obj.items(): found.extend(_find_swedish_strings(v, f"{path}.{k}")) elif isinstance(obj, list): for i, v in enumerate(obj): found.extend(_find_swedish_strings(v, f"{path}[{i}]")) return found # --------------------------------------------------------------------------- # 1) THE DOUBLE-BOOKING REGRESSION (confirmed bug, currently unfixed) # --------------------------------------------------------------------------- class TestFlexibleWishDoubleBookingOnRepropose: """Guards the double-booking bug found live on 2026-09-14. Observed in the running app: propose -> apply -> propose again for a *flexible* wish returns a full second week of brand-new paid days on shifted dates (9 paid days become 18 if applied twice), while reporting `weeks_funded: 1.0` for the "new" window. Root cause: once a flexible window's dates are persisted, `_Ledger.resolve_flexible_window`'s `_candidate_occupied` check sees them as occupied and searches for a *different* full-length candidate elsewhere in the bounding range, instead of recognising the wish-year as already funded. The approved fix (product-owner decision, landing separately): if a flexible wish-year already has enough planned days inside its bounding range, treat that year as funded and propose nothing new. This test asserts the CORRECT (post-fix) behaviour and is EXPECTED TO FAIL until that fix lands — do not skip/xfail/weaken it; a red result here is the point. """ async def test_repropose_after_apply_adds_no_new_paid_days( self, client: AsyncClient, connection: aiosqlite.Connection ) -> None: headers, ws_id, pd_uuid = await _register(client, "double-booking@example.com", "Alex") birth_date = date.today() - relativedelta(months=6) child_id = await _add_child(client, headers, ws_id, "Barn", birth_date) await _set_full_parental_leave(client, headers, pd_uuid, ws_id, child_id) next_year = date.today().year + 1 await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=25) wish = await _create_wish( client, headers, workspace_id=str(ws_id), child_id=str(child_id), name="Sommar", window_start_month=6, window_start_day=1, window_end_month=8, window_end_day=31, target_weeks=1, recurrence="once", first_year=next_year, window_mode="flexible", ) wish_id = wish["id"] proposal_1 = await _propose(client, headers, ws_id) outcome_1 = _outcome_for_wish(proposal_1, wish_id) assert outcome_1["strategy"] != "unplaced" paid_days_1 = _paid_days_for_wish(proposal_1, wish_id) assert paid_days_1, "expected the flexible wish to be funded with at least one paid day" applied_dates = {d["date"] for d in paid_days_1} all_days_1 = [d for d in proposal_1["days"] if d["wish_id"] == wish_id] apply_result = await _apply(client, headers, ws_id, [_to_apply_item(d) for d in all_days_1]) assert apply_result["accepted"] is True, apply_result assert apply_result["created_count"] == len(paid_days_1) proposal_2 = await _propose(client, headers, ws_id) paid_days_2 = _paid_days_for_wish(proposal_2, wish_id) new_dates = {d["date"] for d in paid_days_2} - applied_dates assert new_dates == set(), ( "Double-booking regression: re-proposing after applying a flexible " "wish's window must add ZERO new paid days for that wish-year (the " "already-applied dates should be recognised as funding it), but " f"got {sorted(new_dates)} brand-new paid day(s) beyond the " f"already-applied {sorted(applied_dates)}. This is the confirmed, " "not-yet-fixed bug from the 2026-09-14 handoff." ) # --------------------------------------------------------------------------- # 2) FIXED-WISH CONTROL CASE — locks in currently-correct behaviour # --------------------------------------------------------------------------- class TestFixedWishReproposeAddsNothingNew: """Control case for the double-booking bug above: the SAME propose -> apply -> propose cycle for a `window_mode='fixed'` wish must return zero new paid days on re-propose. `_fund_solo`'s `existing_lookup` check already prevents this for fixed windows (unlike the flexible resolver's `_candidate_occupied` check, which excludes rather than recognises already-planned dates) — this test locks that in so a future change to the shared funding path can't regress it. """ async def test_repropose_after_apply_adds_no_new_paid_days( self, client: AsyncClient, connection: aiosqlite.Connection ) -> None: headers, ws_id, pd_uuid = await _register(client, "fixed-control@example.com", "Alex") birth_date = date.today() - relativedelta(months=6) child_id = await _add_child(client, headers, ws_id, "Barn", birth_date) await _set_full_parental_leave(client, headers, pd_uuid, ws_id, child_id) next_year = date.today().year + 1 await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=25) wish = await _create_wish( client, headers, workspace_id=str(ws_id), child_id=str(child_id), name="Fixed Week", window_start_month=6, window_start_day=1, window_end_month=6, window_end_day=7, target_weeks=1, recurrence="once", first_year=next_year, window_mode="fixed", ) wish_id = wish["id"] proposal_1 = await _propose(client, headers, ws_id) outcome_1 = _outcome_for_wish(proposal_1, wish_id) assert outcome_1["strategy"] != "unplaced" paid_days_1 = _paid_days_for_wish(proposal_1, wish_id) assert paid_days_1 applied_dates = {d["date"] for d in paid_days_1} all_days_1 = [d for d in proposal_1["days"] if d["wish_id"] == wish_id] apply_result = await _apply(client, headers, ws_id, [_to_apply_item(d) for d in all_days_1]) assert apply_result["accepted"] is True, apply_result proposal_2 = await _propose(client, headers, ws_id) paid_days_2 = _paid_days_for_wish(proposal_2, wish_id) new_dates = {d["date"] for d in paid_days_2} - applied_dates assert new_dates == set(), ( f"a fixed wish must never be re-funded with new dates on re-propose, " f"got {sorted(new_dates)}" ) # --------------------------------------------------------------------------- # 3) APPLY IDEMPOTENCY # --------------------------------------------------------------------------- class TestApplyIdempotency: """Applying the exact same day set twice must skip, not duplicate.""" async def test_reapplying_identical_days_skips_instead_of_duplicating( self, client: AsyncClient, connection: aiosqlite.Connection ) -> None: headers, ws_id, pd_uuid = await _register(client, "idempotency@example.com", "Alex") birth_date = date.today() - relativedelta(months=6) child_id = await _add_child(client, headers, ws_id, "Barn", birth_date) await _set_full_parental_leave(client, headers, pd_uuid, ws_id, child_id) next_year = date.today().year + 1 await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=25) wish = await _create_wish( client, headers, workspace_id=str(ws_id), child_id=str(child_id), name="Idempotency Week", window_start_month=9, window_start_day=1, window_end_month=9, window_end_day=7, target_weeks=1, recurrence="once", first_year=next_year, ) wish_id = wish["id"] proposal = await _propose(client, headers, ws_id) paid_days = _paid_days_for_wish(proposal, wish_id) assert paid_days, "expected at least one paid day to apply" payload = [_to_apply_item(d) for d in paid_days] first = await _apply(client, headers, ws_id, payload) assert first["accepted"] is True assert first["created_count"] == len(paid_days) assert first["skipped_identical_count"] == 0 second = await _apply(client, headers, ws_id, payload) assert second["accepted"] is True, second assert second["created_count"] == 0 assert second["skipped_identical_count"] == len(paid_days) assert second["replaced_count"] == 0 assert second["collisions"] == [] # --------------------------------------------------------------------------- # 4) FIXED BEATS FLEXIBLE (§3.11.2 — fixed wishes always win) # --------------------------------------------------------------------------- class TestFixedWishNeverDisplacedByFlexible: """A fixed wish that consumes a flexible wish's whole bounding range must leave the flexible one `strategy='unplaced'`, `weeks_funded=0.0` — a flexible wish can never displace a fixed one, driven end-to-end through the real onboarding flow (register/child/leave/vacation/wishes) rather than hand-built repository rows. """ async def test_flexible_wish_is_unplaced_when_fixed_wish_claims_its_bound( self, client: AsyncClient, connection: aiosqlite.Connection ) -> None: headers, ws_id, pd_uuid = await _register(client, "fixed-beats-flex@example.com", "Alex") birth_date = date.today() - relativedelta(months=6) child_id = await _add_child(client, headers, ws_id, "Barn", birth_date) await _set_full_parental_leave(client, headers, pd_uuid, ws_id, child_id) next_year = date.today().year + 1 await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=25) await _create_wish( client, headers, workspace_id=str(ws_id), child_id=str(child_id), name="Fixed", window_start_month=1, window_start_day=1, window_end_month=1, window_end_day=7, target_weeks=1, recurrence="once", first_year=next_year, window_mode="fixed", ) flexible = await _create_wish( client, headers, workspace_id=str(ws_id), child_id=str(child_id), name="Flex", window_start_month=1, window_start_day=1, window_end_month=1, window_end_day=7, target_weeks=1, recurrence="once", first_year=next_year, window_mode="flexible", ) flexible_id = flexible["id"] proposal = await _propose(client, headers, ws_id) outcome = _outcome_for_wish(proposal, flexible_id) assert outcome["strategy"] == "unplaced" assert outcome["weeks_funded"] == 0.0 assert outcome["shortfall_days"] == 7 # --------------------------------------------------------------------------- # 5) FLEXIBLE WISHES RE-PICK PER YEAR, TRACKING EASTER # --------------------------------------------------------------------------- class TestFlexibleYearlyWishTracksEasterEachYear: """A yearly flexible spring wish (bounding range 03-01..05-31) must land on a different window each year, tracking Easter (verified live: 2027 Mar 23-29, 2028 Apr 11-17, 2029 Mar 27-Apr 2 — the calendar-only cost function in `resolve_flexible_window` favours whichever full-length candidate contains the most red days, and Easter's Fri-Mon cluster is the densest one in that range). Easter is derived via `dateutil.easter` and the three years come from `cached_year_bounds()`, not hardcoded, so this stays meaningful as the calendar cache is regenerated. """ async def test_each_years_window_is_distinct_and_easter_adjacent( self, client: AsyncClient, connection: aiosqlite.Connection ) -> None: bounds = cached_year_bounds() assert bounds is not None, "Swedish calendar cache is missing" min_year, max_year = bounds base_year = min_year + 1 # skip the partially-elapsed current year years = [base_year, base_year + 1, base_year + 2] assert years[-1] <= max_year, ( f"calendar cache ({min_year}-{max_year}) too short to run this test; " "it needs 3 full years starting the year after the cache's first year" ) headers, ws_id, pd_uuid = await _register(client, "easter-tracking@example.com", "Alex") birth_date = date.today() - relativedelta(years=1) child_id = await _add_child(client, headers, ws_id, "Barn", birth_date) await _set_full_parental_leave(client, headers, pd_uuid, ws_id, child_id) for year in years: await _set_vacation(client, headers, pd_uuid, ws_id, year, total_days=25) wish = await _create_wish( client, headers, workspace_id=str(ws_id), child_id=str(child_id), name="Varlov", window_start_month=3, window_start_day=1, window_end_month=5, window_end_day=31, target_weeks=1, recurrence="yearly", first_year=years[0], last_year=years[-1], window_mode="flexible", ) wish_id = wish["id"] proposal = await _propose(client, headers, ws_id) windows: dict[int, tuple[date, date]] = {} for year in years: outcome = _outcome_for_wish(proposal, wish_id, year=year) assert outcome["strategy"] != "unplaced", outcome assert outcome["shortfall_days"] == 0, outcome start = date.fromisoformat(outcome["start_date"]) end = date.fromisoformat(outcome["end_date"]) assert (end - start).days == 6 # a full 1-week (7-day) placement windows[year] = (start, end) easter_sunday = easter(year) assert start <= easter_sunday <= end, ( f"{year}'s window {start}..{end} is not Easter-adjacent " f"(Easter Sunday is {easter_sunday})" ) start_month_days = {year: (w[0].month, w[0].day) for year, w in windows.items()} assert len(set(start_month_days.values())) == len(years), ( f"expected a distinct window each year, got {start_month_days}" ) # --------------------------------------------------------------------------- # 6) NO SWEDISH ON THE WIRE (§3.5) # --------------------------------------------------------------------------- class TestNoSwedishOnTheWire: """Every user-facing string in the propose/apply responses must be a `message_key` + `data` pair, never a pre-rendered Swedish string. Builds a scenario that exercises outcomes, remedies, warnings and an apply rejection (the message-bearing surfaces §3.5 converted), then walks the full decoded JSON body looking for å/ä/ö in any string leaf. All wish names created here are plain ASCII, so no field needs to be excluded from the walk. """ async def test_propose_and_apply_responses_carry_no_swedish_strings( self, client: AsyncClient, connection: aiosqlite.Connection ) -> None: headers, ws_id, pd_uuid = await _register(client, "no-swedish@example.com", "Alex") birth_date = date.today() - relativedelta(months=6) child_id = await _add_child(client, headers, ws_id, "Barn", birth_date) # Exhaust the FP pool entirely -> guaranteed shortfall + remedies. await _set_full_parental_leave( client, headers, pd_uuid, ws_id, child_id, sjukpenning_used=390, lagsta_niva_used=90 ) next_year = date.today().year + 1 # No vacation in the wish's own year -> fully unfunded window. await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=0) # Spare vacation this year -> carry_vacation_forward remedy fires. await _set_vacation(client, headers, pd_uuid, ws_id, date.today().year, total_days=25) await _create_wish( client, headers, workspace_id=str(ws_id), child_id=str(child_id), name="Winter", window_start_month=12, window_start_day=1, window_end_month=12, window_end_day=7, target_weeks=1, recurrence="once", first_year=next_year, ) proposal = await _propose(client, headers, ws_id) outcomes_with_shortfall = [o for o in proposal["outcomes"] if o["shortfall_days"] > 0] assert outcomes_with_shortfall, "expected the wish's window to be underfunded" assert outcomes_with_shortfall[0]["remedies"], "expected at least one shortfall remedy" # A fully-drained FP pool means nothing is left to forecast as # expiring at age-4, so `warnings` is legitimately empty here — the # remedies/outcomes/data surfaces above already exercise the # message_key + data i18n path; walking `warnings` too (even if # empty) costs nothing and still guards it if it ever fires. violations = _find_swedish_strings(proposal) assert not violations, f"Swedish text leaked into /api/plan/propose response: {violations}" # Trigger a structured apply rejection (insufficient balance) too. apply_result = await _apply( client, headers, ws_id, [ { "date": date(next_year, 6, 15).isoformat(), "parent_personal_data_uuid": str(pd_uuid), "child_id": str(child_id), "day_type": "sjukpenning", "is_dubbeldag": False, } ], ) assert apply_result["accepted"] is False assert apply_result["rejections"], "expected the balance violation to be reported" violations = _find_swedish_strings(apply_result) assert not violations, f"Swedish text leaked into /api/plan/apply response: {violations}" # --------------------------------------------------------------------------- # 7) PLAN-AWARE AGE-4 PROJECTION (§3.3) IS SELF-CONSISTENT # --------------------------------------------------------------------------- class TestAge4ProjectionArithmeticIsSelfConsistent: """The propose response's `age4_projections` must satisfy `expiring_at_b4 == max(projected_left_at_b4 - 96, 0)` — after the child's 4th birthday only 96 saved days may carry forward; anything above that in the projected balance is what's forecast to expire. """ async def test_expiring_equals_projected_minus_ceiling_clamped_at_zero( self, client: AsyncClient, connection: aiosqlite.Connection ) -> None: """Independently-derived expected values, not internal consistency. The original version of this test re-derived its expected `expiring_at_b4` from `projection["projected_left_at_b4"]` -- a field of the SAME response it was checking -- so it only proved the two response fields agree with each other, not that either is correct (a shared bug in both would still pass; the test's own docstring said as much). This version instead computes the expected numbers from the seeded fixture and `settings.rules` directly, independent of the response. The `/api/plan/propose` response's `age4_projections` come from `allocation_service._Ledger._age4_projections`, not from `AnalyticsService.get_days_left_at_age_4` -- that method always passes `days_used=0` and derives `total_initial` from the (parent, child) pool `AllocationInputLoader.load` built (`allocation_service.py:1446-1475`). No wish is created in this test, so nothing gets proposed and `planned_fp_before_b4` is 0. With no `ParentalLeaveFetch` snapshot for this brand-new registration, that pool falls back to `rules.sjukpenning_days // 2 + rules.lagsta_niva_days // 2`, which is `rules.days_per_parent` exactly (the "Bug A" default the two must agree on -- see `test_planning_service_apply.py`'s `test_no_snapshot_default_balance_matches_allocation_engine`) -- making `projected_left_at_b4 == rules.days_per_parent`. The workspace is a fresh single-member 'solo' workspace (never set to 'couple' here), so the per-CHILD cap (`rules.max_savable_days_at_age_4`) applies via `resolve_age4_savable_cap`, not the per-parent one -- making `expiring_at_b4 == max(rules.days_per_parent - rules.max_savable_days_at_age_4, 0)`. """ headers, ws_id, pd_uuid = await _register(client, "age4-projection@example.com", "Alex") birth_date = date.today() - relativedelta(months=6) child_id = await _add_child(client, headers, ws_id, "Barn", birth_date) next_year = date.today().year + 1 await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=25) proposal = await _propose(client, headers, ws_id) rules = settings.rules expected_cap = rules.max_savable_days_at_age_4 expected_projected_left = rules.days_per_parent expected_expiring = max(expected_projected_left - expected_cap, 0) expected_savable = min(expected_projected_left, expected_cap) assert proposal["age4_projections"], "expected an age4 projection for the (parent, child)" for projection in proposal["age4_projections"]: assert projection["child_id"] == str(child_id) assert projection["savable_cap_at_b4"] == expected_cap, projection assert projection["projected_left_at_b4"] == expected_projected_left, projection assert projection["expiring_at_b4"] == expected_expiring, projection assert projection["savable_after_b4"] == expected_savable, projection # --------------------------------------------------------------------------- # 8) WORKSPACE PARENTS: ADD / RENAME / REMOVE A PLACEHOLDER PARTNER # --------------------------------------------------------------------------- class TestWorkspaceParentsRenameAndRemove: """API-level coverage for POST/PATCH/DELETE .../parents — the hand-written dataclass -> response mapping in this codebase has silently dropped fields before, so this hits the real routes rather than only the service. Deliberately a single test walking every case behind ONE `_register()` call: `/api/auth/register` is rate-limited to 10/minute (see `app/routers/auth.py`), and this whole file shares that limiter's in-memory counters within a single test run — one call per scenario here would have pushed the file's total registrations over the limit. """ async def test_add_rename_remove_and_guards( self, client: AsyncClient, connection: aiosqlite.Connection ) -> None: headers, ws_id, pd_uuid = await _register(client, "wsparents@example.com", "Alex") # --- add: happy path + response shape --------------------------- add_resp = await client.post( f"/api/workspace/{ws_id}/parents", headers=headers, json={"display_name": "Sam"}, ) assert add_resp.status_code == 201, add_resp.text placeholder = add_resp.json() assert placeholder["name"] == "Sam" assert placeholder["workspace_id"] == str(ws_id) placeholder_uuid = placeholder["uuid"] members_resp = await client.get(f"/api/workspace/{ws_id}/members", headers=headers) assert members_resp.status_code == 200 assert placeholder_uuid in {m["uuid"] for m in members_resp.json()} # --- add: blank name is refused ---------------------------------- blank_resp = await client.post( f"/api/workspace/{ws_id}/parents", headers=headers, json={"display_name": " "}, ) assert blank_resp.status_code == 422, blank_resp.text assert blank_resp.json()["detail"]["message_key"] == "workspace.parents.invalidName" # --- rename: happy path ------------------------------------------- rename_resp = await client.patch( f"/api/workspace/{ws_id}/parents/{placeholder_uuid}", headers=headers, json={"display_name": "Samuel"}, ) assert rename_resp.status_code == 200, rename_resp.text renamed = rename_resp.json() assert renamed["uuid"] == placeholder_uuid assert renamed["name"] == "Samuel" members_resp = await client.get(f"/api/workspace/{ws_id}/members", headers=headers) names_by_uuid = {m["uuid"]: m["name"] for m in members_resp.json()} assert names_by_uuid[placeholder_uuid] == "Samuel" # --- rename: a personal_data_uuid that isn't a member 404s ------- rename_404 = await client.patch( f"/api/workspace/{ws_id}/parents/{uuid4()}", headers=headers, json={"display_name": "Whoever"}, ) assert rename_404.status_code == 404, rename_404.text assert rename_404.json()["detail"]["message_key"] == "workspace.parents.notFound" # --- remove: a personal_data_uuid that isn't a member 404s ------- remove_404 = await client.delete( f"/api/workspace/{ws_id}/parents/{uuid4()}", headers=headers ) assert remove_404.status_code == 404, remove_404.text assert remove_404.json()["detail"]["message_key"] == "workspace.parents.notFound" # --- remove: the caller's own profile is refused ------------------- remove_self = await client.delete( f"/api/workspace/{ws_id}/parents/{pd_uuid}", headers=headers ) assert remove_self.status_code == 422, remove_self.text assert remove_self.json()["detail"]["message_key"] == "workspace.parents.remove.self" # --- remove: refused while dependent data exists ------------------- await _set_vacation(client, headers, UUID(placeholder_uuid), ws_id, date.today().year) remove_blocked = await client.delete( f"/api/workspace/{ws_id}/parents/{placeholder_uuid}", headers=headers ) assert remove_blocked.status_code == 422, remove_blocked.text blocked_detail = remove_blocked.json()["detail"] assert blocked_detail["message_key"] == "workspace.parents.remove.hasDependentData" assert "vacation_days" in blocked_detail["data"]["blockers"] # Still a member — never silently removed despite the refusal. members_resp = await client.get(f"/api/workspace/{ws_id}/members", headers=headers) assert placeholder_uuid in {m["uuid"] for m in members_resp.json()} # --- remove: happy path, on a second (unencumbered) placeholder -- add_resp2 = await client.post( f"/api/workspace/{ws_id}/parents", headers=headers, json={"display_name": "Kim"}, ) assert add_resp2.status_code == 201, add_resp2.text other_placeholder_uuid = add_resp2.json()["uuid"] delete_resp = await client.delete( f"/api/workspace/{ws_id}/parents/{other_placeholder_uuid}", headers=headers ) assert delete_resp.status_code == 204, delete_resp.text assert delete_resp.content == b"" members_resp = await client.get(f"/api/workspace/{ws_id}/members", headers=headers) member_uuids = {m["uuid"] for m in members_resp.json()} assert other_placeholder_uuid not in member_uuids # The blocked placeholder and the caller are both still members. assert placeholder_uuid in member_uuids assert str(pd_uuid) in member_uuids class TestLeaveWishNameMessageKeyRoundTrip: """`name_message_key` (added to fix the "Jul"/"Höstlov" always-Swedish bug — see `backend/app/entities/leave_wish.py`) must actually appear on the wire. The hand-written entity<->response mapping in this codebase has silently dropped new fields twice before, so this asserts the response *body* directly rather than only the service/repository layer. All four scenarios share a single registered user/workspace (one `_register` call) rather than one each: `/api/auth/register` is rate limited to 10/minute (see `app/routers/auth.py`), and this file already registers a user per test class — four more registrations pushed the whole module over that limit when run together. """ async def test_name_message_key_round_trip( self, client: AsyncClient, connection: aiosqlite.Connection ) -> None: headers, ws_id, _ = await _register(client, "wish-key-roundtrip@example.com", "Alex") # --- create returns the key it was given ----------------------- wish = await _create_wish( client, headers, workspace_id=str(ws_id), name="Jul", name_message_key="wishEditor.seasonJul", window_start_month=12, window_start_day=1, window_end_month=1, window_end_day=31, target_weeks=4, recurrence="yearly", first_year=date.today().year, ) assert wish["name_message_key"] == "wishEditor.seasonJul" assert wish["name"] == "Jul" # --- a wish created without a key round-trips null, not a crash - # (pre-existing rows, or a free-typed custom name, have no key.) keyless_wish = await _create_wish( client, headers, workspace_id=str(ws_id), name="Custom trip", window_start_month=6, window_start_day=1, window_end_month=6, window_end_day=7, target_weeks=1, recurrence="once", first_year=date.today().year, ) assert keyless_wish["name_message_key"] is None # --- updating an unrelated field preserves the key -------------- resp = await client.put( f"/api/leave-wishes/{wish['id']}", headers=headers, json={"target_weeks": 5} ) assert resp.status_code == 200, resp.text body = resp.json() assert body["target_weeks"] == 5 assert body["name"] == "Jul" assert body["name_message_key"] == "wishEditor.seasonJul" # --- renaming clears the key even if the caller never mentions -- # it: the backend enforces this, not just the frontend. Getting # this backwards would mean a user's custom name silently reverts # to the old preset label the next time the UI translates it. resp = await client.put( f"/api/leave-wishes/{wish['id']}", headers=headers, json={"name": "Vinterresa"} ) assert resp.status_code == 200, resp.text body = resp.json() assert body["name"] == "Vinterresa" assert body["name_message_key"] is None # --------------------------------------------------------------------------- # 9) is_pinned / wish_id ROUND-TRIP, AND SURVIVAL THROUGH /api/plan/sync # (routers/planned_days.py bug — both fields were accepted by the request # schema and understood by PlanningService, but silently dropped by the # router before ever reaching the service, so "pin this day" never # persisted). One shared registration for both scenarios — see # `TestLeaveWishNameMessageKeyRoundTrip` above for why: `/api/auth/register` # is rate-limited to 10/minute and this module already registers a user per # test class. # --------------------------------------------------------------------------- class TestPlannedDayIsPinnedRoundTripAndSyncSurvival: """`PlannedLeaveDayCreate`/`Update` both carry `is_pinned`/`wish_id`, and `PlanningService.add_planned_day`/`.update` both accept them — but the router never forwarded either field, so a client setting `is_pinned: true` (the "pin this day so the allocator won't retype it" gesture) had it silently discarded. Both scenarios (the raw POST/PUT round trip, and the end-to-end "survives a re-sync" behaviour it exists for) share a single registration, same reasoning as `TestLeaveWishNameMessageKeyRoundTrip` above. """ async def test_is_pinned_round_trips_and_survives_a_subsequent_sync( self, client: AsyncClient, connection: aiosqlite.Connection ) -> None: headers, ws_id, pd_uuid = await _register(client, "pin-roundtrip@example.com", "Alex") birth_date = date.today() - relativedelta(months=6) child_id = await _add_child(client, headers, ws_id, "Barn", birth_date) await _set_full_parental_leave(client, headers, pd_uuid, ws_id, child_id) next_year = date.today().year + 1 await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=25) wish = await _create_wish( client, headers, workspace_id=str(ws_id), child_id=str(child_id), name="Pin Week", window_start_month=9, window_start_day=1, window_end_month=9, window_end_day=7, target_weeks=1, recurrence="once", first_year=next_year, ) wish_id = wish["id"] # --- part 1: raw POST/PUT round trip ----------------------------- create_resp = await client.post( "/api/planned-days", headers=headers, json={ "workspace_id": str(ws_id), "child_id": str(child_id), "parent_personal_data_uuid": str(pd_uuid), "date": date.today().isoformat(), "day_type": "vacation", "is_dubbeldag": False, "wish_id": wish_id, "is_pinned": True, }, ) assert create_resp.status_code == 201, create_resp.text created = create_resp.json() assert created["is_pinned"] is True, ( "is_pinned must round-trip through POST /api/planned-days, not be silently " "dropped by the router" ) assert created["wish_id"] == wish_id day_id = created["id"] # Flip both fields via PUT — a partial update — and confirm the new # values, not the create-time ones, come back. update_resp = await client.put( f"/api/planned-days/{day_id}", headers=headers, json={"is_pinned": False}, ) assert update_resp.status_code == 200, update_resp.text updated = update_resp.json() assert updated["is_pinned"] is False, ( "is_pinned must round-trip through PUT /api/planned-days/{id} too" ) # wish_id was not mentioned in this PUT — must be left untouched. assert updated["wish_id"] == wish_id # Delete the ad-hoc probe row before part 2 so it can't be mistaken # for one of the wish's own placed days below. del_resp = await client.delete(f"/api/planned-days/{day_id}", headers=headers) assert del_resp.status_code == 204, del_resp.text # --- part 2: a pinned day survives a subsequent /api/plan/sync -- # the end-to-end behaviour users actually rely on `is_pinned` for: # "changing a day's funding type pins it so the allocator won't # retype it". # First sync places the wish's days as ordinary (unpinned, # wish-attributed) rows. await _sync(client, headers, ws_id) planned = await _list_planned_days(client, headers, child_id) wish_days = [d for d in planned if d["wish_id"] == wish_id] assert wish_days, "expected the wish to have been placed by the first sync" pinned_day = wish_days[0] original_day_type = pinned_day["day_type"] # Retype it to whatever it currently is NOT, mirroring "the user # hand-edited this day's funding type" — then pin it so a re-sync # must leave it alone. new_day_type = "sjukpenning" if original_day_type != "sjukpenning" else "vacation" pin_resp = await client.put( f"/api/planned-days/{pinned_day['id']}", headers=headers, json={"day_type": new_day_type, "is_pinned": True}, ) assert pin_resp.status_code == 200, pin_resp.text assert pin_resp.json()["is_pinned"] is True assert pin_resp.json()["day_type"] == new_day_type # Re-sync: a fresh propose would want this date back at # `original_day_type` (the wish's natural funding choice) — the # pinned row must survive untouched instead. await _sync(client, headers, ws_id) after = await _list_planned_days(client, headers, child_id) after_row = next(d for d in after if d["id"] == pinned_day["id"]) assert after_row["is_pinned"] is True assert after_row["day_type"] == new_day_type, ( "a pinned day must survive a subsequent /api/plan/sync without being " "retyped back by the allocator" )