Files
F-r-ldraledigplannerare/backend/tests/unit/test_plan_router.py
T
jacobh2 76b1e712b9 Complete NextSteps roadmap: planning engine UI, calendar cluster, flexible wishes
Works through every numbered item in NextSteps260913.md section 3, plus the
three known verification issues in section 4.1.

Surface what the backend already computed:
- 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies
- 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible
- 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing
  the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt)

Calendar cluster (one pass over the holiday data):
- new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides,
  paginated four years at a time
- 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal
- 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact
- 3.7 click month/year to advance
- 3.8 per-year red-days list in Settings, statutory days plus overrides

i18n:
- 3.5 five backend-generated Swedish surfaces converted to message_key + data,
  following the tips_service pattern; old string fields replaced, not kept

Flexible wishes (3.9):
- PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function,
  deterministic tie-break, two-pass ordering, test matrix rows 15-24
- backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator
- frontend: season-preset toggle in WishEditor, unplaced outcome rendering

Fixes (4.1):
- pin typecheck to the local tsc rather than npx resolving a newer TypeScript
- ignore generated coverage/ and dist/ in the ESLint flat config
- fix the flaky usePlanApply test properly, with waitFor rather than a retry

Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines.

Not yet verified in a browser.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
2026-09-14 02:33:03 +02:00

643 lines
25 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.
"""
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
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)
# 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 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