"""API-level test for `POST /api/plan/propose`. This exists specifically to catch the class of bug where the pure `Allocator` computes a field on `PlanProposal` that the wire model (`app.models.plan.PlanProposalResponse`) then drops — the dataclass tests in `tests/unit/test_services/test_allocation_service_branches.py` cannot catch this, because they call `Allocator().propose()` directly and never go through `PlanProposalResponse.from_domain`. `surplus_advice` (§9.2) was exactly such a case: computed, tested against the dataclass, but absent from the HTTP response until this test/fix landed. Uses the same in-memory sqlite `connection` fixture as the rest of the suite (via `tests/conftest.py`), wired into the real FastAPI app by overriding the `get_connection` dependency — this exercises the actual router + dependency graph without touching the real `foraldraledig.db` file or running Alembic. """ import dataclasses from datetime import date, datetime from uuid import uuid4 import aiosqlite import pytest_asyncio from dateutil.relativedelta import relativedelta from httpx import ASGITransport, AsyncClient from app.dependencies import get_connection from app.entities.child import Child from app.entities.parental_leave import ParentalLeaveFetch, TierData from app.entities.personal_data import PersonalData from app.entities.vacation_days import VacationDays from app.entities.workspace import Workspace from app.main import app from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository from app.services.allocation_service import PlanProposal, WindowOutcome def _make_fetch( personal_data_uuid, workspace_id, child_id, sjukpenning_left: int = 390, lagsta_left: int = 90, ) -> ParentalLeaveFetch: now = datetime.now() tier = TierData( sjukpenning_total=390, sjukpenning_used=390 - sjukpenning_left, sjukpenning_left=sjukpenning_left, lagsta_niva_total=90, lagsta_niva_used=90 - lagsta_left, lagsta_niva_left=lagsta_left, ) return ParentalLeaveFetch( id=uuid4(), personal_data_uuid=personal_data_uuid, workspace_id=workspace_id, child_id=child_id, source="manual", fetched_at=now, total_days=480, days_used=480 - sjukpenning_left - lagsta_left, days_left=sjukpenning_left + lagsta_left, reserved_days_total=90, reserved_days_left=90, transferable_days_left=sjukpenning_left + lagsta_left, dubbeldagar_left=60, tier_data=tier, created_at=now, updated_at=now, ) @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. """ 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) class TestProposeSurplusAdviceOverTheWire: async def test_surplus_advice_appears_in_the_http_response( self, client: AsyncClient, connection: aiosqlite.Connection, ) -> None: """A workspace with a small child, a full/untouched FP pool and no leave wishes at all is a guaranteed surplus at the age-4 boundary (nothing is planned before it, so the whole 480-day pool minus the 96-day ceiling is forecast to be forfeited) — the router must return that as `surplus_advice`, not just the `days_lost_at_age_4` warning. """ now = datetime.now() workspace_repo = SQLiteWorkspaceRepository(connection) personal_data_repo = SQLitePersonalDataRepository(connection) child_repo = SQLiteChildRepository(connection) ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) parent = await personal_data_repo.create( PersonalData( uuid=uuid4(), workspace_id=ws.id, name="Alex", salary_type="monthly", created_at=now, ) ) child = await child_repo.create( Child( id=uuid4(), workspace_id=ws.id, name="Barn", # Under 2 years old "today" -> still small enough for # front_load_early_years too, though this test only asserts # on wish_more_leave (the always-present one here). birth_date=date.today() - relativedelta(months=6), created_at=now, ) ) response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)}) assert response.status_code == 200 body = response.json() assert "surplus_advice" in body, ( "PlanProposalResponse must expose surplus_advice — it was silently " "dropped at the API boundary even though the pure Allocator computed it" ) assert body["surplus_advice"], "expected at least one surplus suggestion" by_kind = {a["kind"]: a for a in body["surplus_advice"]} assert "wish_more_leave" in by_kind advice = by_kind["wish_more_leave"] assert advice["days_involved"] > 0 # §3.5 — i18n-ready: message_key + data, not a pre-rendered Swedish # string (see PlanResults.tsx / frontend/src/i18n/{sv,en}.ts). assert advice["message_key"] == "planSurplusAdvice.wish_more_leave" assert advice["data"]["parent_personal_data_uuid"] == str(parent.uuid) assert advice["data"]["child_id"] == str(child.id) assert isinstance(advice["data"]["weeks"], int) and advice["data"]["weeks"] > 0 # The warning it mirrors must also still be present, and — surface #3 # — expose message_key + data rather than a pre-rendered string. age4_warning = next(w for w in body["warnings"] if w["kind"] == "days_lost_at_age_4") assert age4_warning["message_key"] == "planWarnings.days_lost_at_age_4" assert age4_warning["data"]["parent_personal_data_uuid"] == str(parent.uuid) assert "fourth_birthday" in age4_warning["data"] class TestProposeAge4ProjectionsOverTheWire: async def test_age4_projections_appear_in_the_http_response( self, client: AsyncClient, connection: aiosqlite.Connection, ) -> None: """§3.3 part 2 — the propose response must expose a plan-aware age-4 projection per (parent, child), the same class of "computed on the pure Allocator but silently dropped at the API boundary" bug that `surplus_advice` and `is_assumed` hit before their mapping landed in `app.models.plan.PlanProposalResponse`. """ now = datetime.now() workspace_repo = SQLiteWorkspaceRepository(connection) personal_data_repo = SQLitePersonalDataRepository(connection) child_repo = SQLiteChildRepository(connection) ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) parent = await personal_data_repo.create( PersonalData( uuid=uuid4(), workspace_id=ws.id, name="Alex", salary_type="monthly", created_at=now, ) ) child = await child_repo.create( Child( id=uuid4(), workspace_id=ws.id, name="Barn", birth_date=date.today() - relativedelta(months=6), created_at=now, ) ) response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)}) assert response.status_code == 200 body = response.json() assert "age4_projections" in body, ( "PlanProposalResponse must expose age4_projections — computed by " "the pure Allocator (Age4Projection / build_age4_projections) but " "invisible to the UI until mapped onto the wire response" ) assert body["age4_projections"], "expected one projection for the (parent, child) pair" projection = next( p for p in body["age4_projections"] if p["parent_personal_data_uuid"] == str(parent.uuid) and p["child_id"] == str(child.id) ) assert ( projection["fourth_birthday"] == (child.birth_date + relativedelta(years=4)).isoformat() ) assert isinstance(projection["projected_left_at_b4"], int) assert isinstance(projection["savable_after_b4"], int) assert isinstance(projection["expiring_at_b4"], int) # savable_cap_at_b4 (the resolved 96-per-child vs. 48-per-parent # ceiling) is exactly the kind of field this test class exists to # catch going missing at the API boundary. assert projection["savable_cap_at_b4"] == 96 # solo workspace, one parent # No leave planned at all before B4 in this scenario -> everything # above the savable ceiling is forecast to expire, matching the # surplus-advice test above. assert projection["expiring_at_b4"] > 0 class TestProposeMessageKeysOverTheWire: """§3.5 — outcomes/remedies must expose `message_key` + `data` (not a pre-rendered Swedish string) all the way through `PlanProposalResponse`, the same class of "computed by the pure Allocator but dropped/wrong- shaped at the API boundary" bug the surplus_advice/age4_projections tests above guard against. The warning surface is covered by `TestProposeSurplusAdviceOverTheWire` above (it already sets up a scenario with a guaranteed `days_lost_at_age_4` warning). """ async def test_outcome_remedy_and_warning_expose_message_key_and_data( self, client: AsyncClient, connection: aiosqlite.Connection, ) -> None: now = datetime.now() workspace_repo = SQLiteWorkspaceRepository(connection) personal_data_repo = SQLitePersonalDataRepository(connection) child_repo = SQLiteChildRepository(connection) vacation_repo = SQLiteVacationDaysRepository(connection) parental_leave_repo = SQLiteParentalLeaveRepository(connection) ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) parent = await personal_data_repo.create( PersonalData( uuid=uuid4(), workspace_id=ws.id, name="Alex", salary_type="monthly", created_at=now, ) ) child = await child_repo.create( Child( id=uuid4(), workspace_id=ws.id, name="Barn", birth_date=date.today() - relativedelta(months=6), created_at=now, ) ) # Exhaust the FP pool entirely so the wish's window can't be funded # from it -> guaranteed shortfall. await parental_leave_repo.create( _make_fetch(parent.uuid, ws.id, child.id, sjukpenning_left=0, lagsta_left=0) ) # No vacation next year (the wish's funding year) either -> the # window is entirely unfunded (shortfall_days > 0), which is what # `_build_remedies` requires to run at all. next_year = date.today().year + 1 await vacation_repo.create( VacationDays( id=uuid4(), personal_data_uuid=parent.uuid, workspace_id=ws.id, year=next_year, total_days=0, saved_days_in=0, ) ) # Spare vacation *this* year (before the wish's window) -> the # carry_vacation_forward remedy has something to point at: # available (25) above the semesterlagen minimum (20) = 5 spare days. await vacation_repo.create( VacationDays( id=uuid4(), personal_data_uuid=parent.uuid, workspace_id=ws.id, year=date.today().year, total_days=25, saved_days_in=0, ) ) create_wish = await client.post( "/api/leave-wishes", json={ "workspace_id": str(ws.id), "child_id": None, "name": "Vinter", "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, }, ) assert create_wish.status_code == 201 response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)}) assert response.status_code == 200 body = response.json() outcomes = [o for o in body["outcomes"] if o["shortfall_days"] > 0] assert outcomes, "expected the wish's window to be underfunded" outcome = outcomes[0] # Surface #1 — outcome.explanation -> message_key + data. assert outcome["message_key"] == "planOutcomes.windowExplanation" assert outcome["data"]["wish_name"] == "Vinter" assert outcome["data"]["shortfall_days"] == outcome["shortfall_days"] # Surface #2 — remedy.description -> message_key + data. assert outcome["remedies"], "expected at least the carry_vacation_forward remedy" remedy = next(r for r in outcome["remedies"] if r["kind"] == "carry_vacation_forward") assert remedy["message_key"] == "planRemedies.carry_vacation_forward" assert remedy["data"]["spare_total"] == remedy["days_freed"] == 5 class TestProposeWishNameMessageKeyOverTheWire: """A preset-created wish (`LeaveWish.name_message_key` set, see `app/entities/leave_wish.py` and `frontend/src/utils/wishName.ts`) must carry that key through `POST /api/plan/propose`, not just `POST /api/leave-wishes` -- `WindowOutcomeResponse.wish_name_message_key` is a field the hand-written `PlanProposalResponse.from_domain` mapping in `app/models/plan.py` could silently drop, the same class of bug `TestProposeSurplusAdviceOverTheWire`/`TestProposeAge4ProjectionsOverTheWire` above guard against for other fields. """ async def test_wish_name_message_key_reaches_the_proposal_response( self, client: AsyncClient, connection: aiosqlite.Connection, ) -> None: now = datetime.now() workspace_repo = SQLiteWorkspaceRepository(connection) personal_data_repo = SQLitePersonalDataRepository(connection) child_repo = SQLiteChildRepository(connection) parental_leave_repo = SQLiteParentalLeaveRepository(connection) vacation_repo = SQLiteVacationDaysRepository(connection) ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) parent = await personal_data_repo.create( PersonalData( uuid=uuid4(), workspace_id=ws.id, name="Alex", salary_type="monthly", created_at=now, ) ) child = await child_repo.create( Child( id=uuid4(), workspace_id=ws.id, name="Barn", birth_date=date.today() - relativedelta(months=6), created_at=now, ) ) await parental_leave_repo.create(_make_fetch(parent.uuid, ws.id, child.id)) next_year = date.today().year + 1 await vacation_repo.create( VacationDays( id=uuid4(), personal_data_uuid=parent.uuid, workspace_id=ws.id, year=next_year, total_days=25, saved_days_in=0, ) ) # Preset-created: name is the (server-agnostic) translated literal, # name_message_key records which preset produced it -- exactly what # `WishEditor.tsx`'s `addPreset` sends. create_wish = await client.post( "/api/leave-wishes", json={ "workspace_id": str(ws.id), "child_id": str(child.id), "name": "Höstlov", "name_message_key": "wishEditor.presetHostlov", "window_start_month": 10, "window_start_day": 25, "window_end_month": 11, "window_end_day": 1, "target_weeks": 1, "recurrence": "once", "first_year": next_year, }, ) assert create_wish.status_code == 201 wish_id = create_wish.json()["id"] response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)}) assert response.status_code == 200 body = response.json() outcome = next(o for o in body["outcomes"] if o["wish_id"] == wish_id) assert outcome["wish_name"] == "Höstlov" assert outcome["wish_name_message_key"] == "wishEditor.presetHostlov" # Surface #1 (outcome.explanation) mirrors the key in `data` too, so # `buildWindowExplanation` in PlanResults.tsx can re-translate it. assert outcome["data"]["wish_name_message_key"] == "wishEditor.presetHostlov" async def test_wish_name_message_key_is_null_for_a_free_typed_wish( self, client: AsyncClient, connection: aiosqlite.Connection, ) -> None: """A row predating this field, or a free-typed/user-renamed wish, has no key -- the response must expose `null`, never omit the field or leak a stale/empty-string placeholder. """ now = datetime.now() workspace_repo = SQLiteWorkspaceRepository(connection) personal_data_repo = SQLitePersonalDataRepository(connection) child_repo = SQLiteChildRepository(connection) parental_leave_repo = SQLiteParentalLeaveRepository(connection) vacation_repo = SQLiteVacationDaysRepository(connection) ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) parent = await personal_data_repo.create( PersonalData( uuid=uuid4(), workspace_id=ws.id, name="Alex", salary_type="monthly", created_at=now, ) ) child = await child_repo.create( Child( id=uuid4(), workspace_id=ws.id, name="Barn", birth_date=date.today() - relativedelta(months=6), created_at=now, ) ) await parental_leave_repo.create(_make_fetch(parent.uuid, ws.id, child.id)) next_year = date.today().year + 1 await vacation_repo.create( VacationDays( id=uuid4(), personal_data_uuid=parent.uuid, workspace_id=ws.id, year=next_year, total_days=25, saved_days_in=0, ) ) create_wish = await client.post( "/api/leave-wishes", json={ "workspace_id": str(ws.id), "child_id": str(child.id), "name": "Resa till mormor", "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, }, ) assert create_wish.status_code == 201 wish_id = create_wish.json()["id"] response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)}) assert response.status_code == 200 body = response.json() outcome = next(o for o in body["outcomes"] if o["wish_id"] == wish_id) assert outcome["wish_name"] == "Resa till mormor" assert outcome["wish_name_message_key"] is None class TestApplyRejectionsOverTheWireMessageKeys: """§3.5 — `PlanApplyResponse.rejections` must expose `message_key` + `data` (surface #4), not a pre-rendered string, all the way through the HTTP response. """ async def test_insufficient_balance_rejection_carries_message_key_and_data( self, client: AsyncClient, connection: aiosqlite.Connection, ) -> None: now = datetime.now() workspace_repo = SQLiteWorkspaceRepository(connection) personal_data_repo = SQLitePersonalDataRepository(connection) child_repo = SQLiteChildRepository(connection) parental_leave_repo = SQLiteParentalLeaveRepository(connection) ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) parent = await personal_data_repo.create( PersonalData( uuid=uuid4(), workspace_id=ws.id, name="Alex", salary_type="monthly", created_at=now, ) ) child = await child_repo.create( Child( id=uuid4(), workspace_id=ws.id, name="Barn", birth_date=date.today() - relativedelta(months=6), created_at=now, ) ) await parental_leave_repo.create( _make_fetch(parent.uuid, ws.id, child.id, sjukpenning_left=1, lagsta_left=90) ) response = await client.post( "/api/plan/apply", json={ "workspace_id": str(ws.id), "days": [ { "date": "2026-06-15", "parent_personal_data_uuid": str(parent.uuid), "child_id": str(child.id), "day_type": "sjukpenning", }, { "date": "2026-06-16", "parent_personal_data_uuid": str(parent.uuid), "child_id": str(child.id), "day_type": "sjukpenning", }, ], "replace": False, }, ) assert response.status_code == 200 body = response.json() assert body["accepted"] is False assert body["rejections"], "expected the balance violation to be reported" rejection = body["rejections"][0] assert rejection["message_key"] == "planApply.rejection.insufficientBalance" assert rejection["data"]["tier"] == "sjukpenning" assert rejection["data"]["needed"] == 2 assert rejection["data"]["remaining"] == 1 class TestLeaveWishWindowModeOverTheWire: """PLANNING_ENGINE_SPEC.md §10 — `window_mode` must round-trip through the `/api/leave-wishes` create endpoint (§10.5's model mapping), and a flexible wish's engine-picked placement/unplaced outcome must survive the `/api/plan/propose` wire mapping (`app.models.plan`), the same class of "computed by the pure Allocator but dropped/wrong-shaped at the API boundary" bug the other classes in this file guard against. """ async def test_create_leave_wish_round_trips_window_mode( self, client: AsyncClient, connection: aiosqlite.Connection, ) -> None: now = datetime.now() workspace_repo = SQLiteWorkspaceRepository(connection) ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) response = await client.post( "/api/leave-wishes", json={ "workspace_id": str(ws.id), "child_id": None, "name": "Vårlov", "window_start_month": 3, "window_start_day": 1, "window_end_month": 5, "window_end_day": 31, "target_weeks": 1, "recurrence": "once", "first_year": date.today().year + 1, "window_mode": "flexible", }, ) assert response.status_code == 201 body = response.json() assert body["window_mode"] == "flexible" listed = await client.get(f"/api/leave-wishes/{ws.id}") assert listed.status_code == 200 assert listed.json()[0]["window_mode"] == "flexible" async def test_create_leave_wish_rejects_bound_shorter_than_target_weeks( self, client: AsyncClient, connection: aiosqlite.Connection, ) -> None: now = datetime.now() workspace_repo = SQLiteWorkspaceRepository(connection) ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) response = await client.post( "/api/leave-wishes", json={ "workspace_id": str(ws.id), "child_id": None, "name": "Vårlov", "window_start_month": 3, "window_start_day": 1, "window_end_month": 3, "window_end_day": 10, # 10-day bound "target_weeks": 2, # needs 14 days "recurrence": "once", "first_year": date.today().year + 1, "window_mode": "flexible", }, ) assert response.status_code == 422 async def test_flexible_wish_placement_appears_in_propose_response( self, client: AsyncClient, connection: aiosqlite.Connection, ) -> None: now = datetime.now() workspace_repo = SQLiteWorkspaceRepository(connection) personal_data_repo = SQLitePersonalDataRepository(connection) child_repo = SQLiteChildRepository(connection) vacation_repo = SQLiteVacationDaysRepository(connection) parental_leave_repo = SQLiteParentalLeaveRepository(connection) ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) parent = await personal_data_repo.create( PersonalData( uuid=uuid4(), workspace_id=ws.id, name="Alex", salary_type="monthly", created_at=now ) ) child = await child_repo.create( Child( id=uuid4(), workspace_id=ws.id, name="Barn", birth_date=date.today() - relativedelta(months=6), created_at=now, ) ) next_year = date.today().year + 1 await parental_leave_repo.create(_make_fetch(parent.uuid, ws.id, child.id)) await vacation_repo.create( VacationDays( id=uuid4(), personal_data_uuid=parent.uuid, workspace_id=ws.id, year=next_year, total_days=25, saved_days_in=0, ) ) create_wish = await client.post( "/api/leave-wishes", json={ "workspace_id": str(ws.id), "child_id": str(child.id), "name": "Vårlov", "window_start_month": 1, "window_start_day": 1, "window_end_month": 3, "window_end_day": 31, "target_weeks": 1, "recurrence": "once", "first_year": next_year, "window_mode": "flexible", }, ) assert create_wish.status_code == 201 wish_id = create_wish.json()["id"] response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)}) assert response.status_code == 200 body = response.json() outcome = next(o for o in body["outcomes"] if o["wish_id"] == wish_id) assert outcome["strategy"] != "unplaced" assert outcome["shortfall_days"] == 0 start = date.fromisoformat(outcome["start_date"]) end = date.fromisoformat(outcome["end_date"]) assert date(next_year, 1, 1) <= start <= date(next_year, 3, 31) assert date(next_year, 1, 1) <= end <= date(next_year, 3, 31) assert (end - start).days == 6 # a full 1-week (7-day) placement async def test_flexible_wish_unplaced_when_fixed_wish_claims_the_bound( self, client: AsyncClient, connection: aiosqlite.Connection, ) -> None: now = datetime.now() workspace_repo = SQLiteWorkspaceRepository(connection) personal_data_repo = SQLitePersonalDataRepository(connection) child_repo = SQLiteChildRepository(connection) vacation_repo = SQLiteVacationDaysRepository(connection) parental_leave_repo = SQLiteParentalLeaveRepository(connection) ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) parent = await personal_data_repo.create( PersonalData( uuid=uuid4(), workspace_id=ws.id, name="Alex", salary_type="monthly", created_at=now ) ) child = await child_repo.create( Child( id=uuid4(), workspace_id=ws.id, name="Barn", birth_date=date.today() - relativedelta(months=6), created_at=now, ) ) next_year = date.today().year + 1 await parental_leave_repo.create(_make_fetch(parent.uuid, ws.id, child.id)) await vacation_repo.create( VacationDays( id=uuid4(), personal_data_uuid=parent.uuid, workspace_id=ws.id, year=next_year, total_days=25, saved_days_in=0, ) ) # Fixed wish claims exactly the same 7-day range the flexible wish's # bound offers as its only full-length candidate. fixed = await client.post( "/api/leave-wishes", json={ "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, }, ) assert fixed.status_code == 201 flexible = await client.post( "/api/leave-wishes", json={ "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", }, ) assert flexible.status_code == 201 flexible_id = flexible.json()["id"] response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)}) assert response.status_code == 200 body = response.json() outcome = next(o for o in body["outcomes"] if o["wish_id"] == flexible_id) assert outcome["strategy"] == "unplaced" assert outcome["weeks_funded"] == 0.0 assert outcome["shortfall_days"] == 7 # --------------------------------------------------------------------------- # Systematic wire-contract guard for the plan proposal (mechanism test) # --------------------------------------------------------------------------- # # The classes above each guard exactly one field that was already silently # dropped from `app/models/plan.py`'s hand-written `from_domain()` mapping # once (surplus_advice, age4_projections, wish_name_message_key). That is # whack-a-mole: it only ever protects a field *after* losing it has already # been painful enough to write a regression test for. # # This class instead walks `dataclasses.fields()` of the domain `PlanProposal` # (and its nested `WindowOutcome`) and checks each one has a surviving # counterpart in the actual HTTP JSON. Critically, a domain field with no # entry in the rename map or allow-list below is checked under its OWN name # by default -- so a brand-new field added to `PlanProposal` (or # `WindowOutcome`) and forgotten in `from_domain()` fails HERE, without this # test file needing to be touched first. Only intentional renames/fan-outs # and intentional exclusions require a (documented) entry. # Domain field name -> wire field name(s) on `PlanProposalResponse`. Only # fields whose wire shape differs from a straight "same name" passthrough # need an entry. `balances_after` is the one case today: a single nested # `PlanBalances` dataclass that `from_domain()` fans out into two separate # top-level wire lists. _PLAN_PROPOSAL_RENAMES: dict[str, tuple[str, ...]] = { "balances_after": ("pool_balances", "vacation_balances"), } # Domain fields on `PlanProposal` deliberately absent from the wire, with why. # Empty today: every `PlanProposal` field currently reaches the HTTP response. _PLAN_PROPOSAL_ALLOWLIST: dict[str, str] = {} # Same idea for `WindowOutcome` -> `WindowOutcomeResponse`: every field there # is a straight same-name passthrough today, so both maps are empty, but they # stay explicit (not just "no map at all") so the intent -- and the place to # add the next rename/exclusion -- is obvious. _WINDOW_OUTCOME_RENAMES: dict[str, tuple[str, ...]] = {} _WINDOW_OUTCOME_ALLOWLIST: dict[str, str] = {} def _wire_keys_for(domain_field: str, renames: dict[str, tuple[str, ...]]) -> tuple[str, ...]: """The wire key(s) `domain_field` must appear as, absent an explicit rename.""" return renames.get(domain_field, (domain_field,)) def _assert_domain_fields_reach_the_wire( dataclass_type: type, renames: dict[str, tuple[str, ...]], allowlist: dict[str, str], wire_obj: dict[str, object], *, context: str, check_nonempty: bool = True, ) -> None: """Fail loudly, naming the exact field, if any `dataclass_type` field has no live counterpart in `wire_obj` (a decoded JSON object from the HTTP response) -- unless it is explicitly excused via `allowlist`. `check_nonempty` additionally requires list-typed wire fields to be non-empty. Leave it on for a single, richly-seeded top-level object (e.g. the `PlanProposalResponse` body, where every list should have content); turn it off when walking a list of *instances* of a nested dataclass (e.g. each individual `WindowOutcomeResponse`) — a specific outcome's own list field (like `remedies`) can be legitimately empty (a fully-funded window has no remedies) without that being a dropped-field bug. """ for f in dataclasses.fields(dataclass_type): if f.name in allowlist: continue for wire_key in _wire_keys_for(f.name, renames): assert wire_key in wire_obj, ( f"{context}: domain field '{f.name}' has no counterpart " f"'{wire_key}' in the wire response. Either from_domain() in " f"app/models/plan.py silently dropped it, or (if '{f.name}' is " f"a brand-new PlanProposal/WindowOutcome field) it needs a " f"wire mapping AND a rename/allow-list entry in this test." ) if not check_nonempty: continue value = wire_obj[wire_key] if isinstance(value, list): assert value, ( f"{context}: wire field '{wire_key}' (from domain field " f"'{f.name}') is present but empty. Either the seeded " f"workspace in this test needs to be richer, or " f"from_domain() is silently producing an empty list where " f"the domain data is not actually empty." ) class TestPlanProposalFieldParityWithDomain: """Mechanism test: every `PlanProposal`/`WindowOutcome` field must survive the hand-written `PlanProposalResponse.from_domain()` mapping, not just the three fields the classes above already know to check for by name. """ async def test_every_plan_proposal_and_window_outcome_field_reaches_the_wire( self, client: AsyncClient, connection: aiosqlite.Connection, ) -> None: now = datetime.now() workspace_repo = SQLiteWorkspaceRepository(connection) personal_data_repo = SQLitePersonalDataRepository(connection) child_repo = SQLiteChildRepository(connection) parental_leave_repo = SQLiteParentalLeaveRepository(connection) vacation_repo = SQLiteVacationDaysRepository(connection) ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now)) parent = await personal_data_repo.create( PersonalData( uuid=uuid4(), workspace_id=ws.id, name="Alex", salary_type="monthly", created_at=now, ) ) # Child A: pool fully exhausted + no vacation in the wish's funding # year -> a guaranteed shortfall (populates outcomes[].remedies, # outcome.data, outcome.cost_breakdown with a genuinely non-trivial # story) — same recipe as TestProposeMessageKeysOverTheWire above. child_shortfall = await child_repo.create( Child( id=uuid4(), workspace_id=ws.id, name="Barn Ett", birth_date=date.today() - relativedelta(months=6), created_at=now, ) ) await parental_leave_repo.create( _make_fetch(parent.uuid, ws.id, child_shortfall.id, sjukpenning_left=0, lagsta_left=0) ) next_year = date.today().year + 1 await vacation_repo.create( VacationDays( id=uuid4(), personal_data_uuid=parent.uuid, workspace_id=ws.id, year=next_year, total_days=0, saved_days_in=0, ) ) # Spare vacation *this* year gives `carry_vacation_forward` something # to point at: available (25) above the semesterlagen minimum (20). await vacation_repo.create( VacationDays( id=uuid4(), personal_data_uuid=parent.uuid, workspace_id=ws.id, year=date.today().year, total_days=25, saved_days_in=0, ) ) preset_wish = await client.post( "/api/leave-wishes", json={ "workspace_id": str(ws.id), "child_id": str(child_shortfall.id), "name": "Vinter", "name_message_key": "wishEditor.presetVinter", "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, }, ) assert preset_wish.status_code == 201 # Child B: no `parental_leave_fetch` row at all -> the full default FP # pool, untouched by the small wish below, still swamps the age-4 # ceiling -> guaranteed `surplus_advice` + `days_lost_at_age_4` # warning + a real `age4_projections` story — same recipe as # TestProposeSurplusAdviceOverTheWire above. The small wish itself # (well inside the huge default pool) also guarantees `days` and a # second, fully-funded `outcomes` entry are non-trivially populated. child_surplus = await child_repo.create( Child( id=uuid4(), workspace_id=ws.id, name="Barn Två", birth_date=date.today() - relativedelta(months=6), created_at=now, ) ) funded_wish = await client.post( "/api/leave-wishes", json={ "workspace_id": str(ws.id), "child_id": str(child_surplus.id), "name": "Sportlov", "window_start_month": 2, "window_start_day": 1, "window_end_month": 2, "window_end_day": 7, "target_weeks": 1, "recurrence": "once", "first_year": next_year, }, ) assert funded_wish.status_code == 201 response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)}) assert response.status_code == 200 body = response.json() # Sanity-check the seed actually produced a non-trivial proposal # before trusting the generic field walk below to mean anything. assert body["days"], "seed should have produced at least one funded day" assert body["outcomes"], "seed should have produced at least one outcome" assert body["warnings"], "seed should have produced at least one warning" assert body["surplus_advice"], "seed should have produced surplus advice" assert body["age4_projections"], "seed should have produced age4 projections" assert body["pool_balances"], "seed should have produced pool balances" assert body["vacation_balances"], "seed should have produced vacation balances" _assert_domain_fields_reach_the_wire( PlanProposal, _PLAN_PROPOSAL_RENAMES, _PLAN_PROPOSAL_ALLOWLIST, body, context="PlanProposal -> PlanProposalResponse", ) for outcome in body["outcomes"]: _assert_domain_fields_reach_the_wire( WindowOutcome, _WINDOW_OUTCOME_RENAMES, _WINDOW_OUTCOME_ALLOWLIST, outcome, context="WindowOutcome -> WindowOutcomeResponse", # A specific outcome's own list field (e.g. `remedies`) can be # legitimately empty (a fully-funded window has none) -- # presence is still checked, only the blanket emptiness # assertion is skipped here. See docstring above. check_nonempty=False, ) # `remedies` is a list-of-dataclass field inside WindowOutcome too — # confirm the shortfall outcome specifically has a non-empty one # (the generic walk above only guarantees each outcome's own # `remedies` key exists and is non-empty where non-empty is expected; # this pins down that the shortfall scenario, not the funded one, is # the one carrying it). shortfall_outcome = next(o for o in body["outcomes"] if o["shortfall_days"] > 0) assert shortfall_outcome["remedies"], "expected the carry_vacation_forward remedy"