Files
F-r-ldraledigplannerare/backend/tests/unit/test_plan_router.py
T
jacobh2 02db5430eb Harden the API and the data layer after a security and performance audit
Security
- Every route now requires a login and refuses ids outside the caller's
  workspace with 404: plan, leave-wishes, day-overrides and vacation-days
  were fully anonymous; every other router trusted client-supplied
  workspace/child/parent ids. Scraper sessions are bound to their owner.
- Invitations can no longer be created for a foreign workspace, and a
  redeemer who is already a member (or a full workspace) is refused.
- Production refuses the default JWT secret; API docs are hidden in
  production; a failed migration aborts startup instead of serving.
- Login and register no longer reveal whether an email exists; inactive
  accounts are rejected; /auth/refresh is rate limited.
- The FK scraper writes debug artefacts only when explicitly enabled,
  logs no scraped values or page text, clears the BankID QR as soon as it
  is scanned and purges finished sessions after five minutes.
- Real FK page captures are removed from the tree (history rewrite
  follows); the parser tests use a synthetic fixture.

Performance and correctness
- Plan apply/sync run inside one locked transaction with batched writes
  instead of one commit per row, so a failure mid-batch leaves nothing
  half-applied and concurrent syncs no longer corrupt each other.
- SQLite runs in WAL mode with a busy timeout; 14 indexes cover the
  filters that were full scans; argon2 hashing runs off the event loop;
  responses are gzipped.
- Frontend: a 401 now refreshes the token once and retries, so sessions
  no longer die silently after 15 minutes; every data hook discards
  stale responses; logout clears state even when the server call fails.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CsXCCcDfmV4Sps4ZQSvNEY
2026-09-18 07:54:21 +02:00

1108 lines
44 KiB
Python

"""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, get_current_user
from app.entities.child import Child
from app.entities.parental_leave import ParentalLeaveFetch, TierData
from app.entities.personal_data import PersonalData
from app.entities.user import User
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
# Fixed identity for the authenticated caller in every test in this file.
# Each test creates its own workspace + "parent" PersonalData row — that row
# is created with THIS uuid (instead of a random one) so the caller's
# `get_current_personal_data` -> `workspace_id` resolves to the workspace the
# test just seeded. See CLAUDE.md T1 authz work / audit §4 blast radius.
_CURRENT_USER_PD_UUID = uuid4()
_CURRENT_USER_ID = uuid4()
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
app.dependency_overrides[get_current_user] = lambda: User(
id=_CURRENT_USER_ID,
personal_data_uuid=_CURRENT_USER_PD_UUID,
email="alex@example.com",
hashed_password="x",
created_at=datetime.now(),
)
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac
app.dependency_overrides.pop(get_connection, None)
app.dependency_overrides.pop(get_current_user, 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=_CURRENT_USER_PD_UUID,
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=_CURRENT_USER_PD_UUID,
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=_CURRENT_USER_PD_UUID,
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=_CURRENT_USER_PD_UUID,
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=_CURRENT_USER_PD_UUID,
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=_CURRENT_USER_PD_UUID,
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)
personal_data_repo = SQLitePersonalDataRepository(connection)
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
await personal_data_repo.create(
PersonalData(
uuid=_CURRENT_USER_PD_UUID, workspace_id=ws.id, name="Alex", 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)
personal_data_repo = SQLitePersonalDataRepository(connection)
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
await personal_data_repo.create(
PersonalData(
uuid=_CURRENT_USER_PD_UUID, workspace_id=ws.id, name="Alex", 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=_CURRENT_USER_PD_UUID,
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=_CURRENT_USER_PD_UUID,
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=_CURRENT_USER_PD_UUID,
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"