Files
F-r-ldraledigplannerare/backend/tests/unit/test_plan_router.py
T
jacobh2 e4719cb9ae Rework balances around per-parent days left and deduce the partner from FK
Manual entry now takes one parent's sjukpenningnivå, lägstanivå and
dubbeldagar left (plus optional reserved days) and derives the rest in
build_snapshot, shared with the scraper. The scraper also reads the
family panel and exposes a partner deduction; with two parents it is
saved as a 'deduced' snapshot (migration 002). Parent count is the single
source of entitlement: one member gets 480 days and 96 savable at age 4,
two get 240 and 48 each. Workspaces are capped at two parents.

Also lands the in-flight squash of migrations 002–011 into the initial
schema and the removal of planning_mode.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-19 12:27:29 +02:00

1261 lines
51 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 TestPlanSyncEvictionPreviewOverTheWire:
"""`POST /api/plan/sync`'s `eviction_choice` field and `eviction_preview`
response over the actual HTTP boundary -- the dataclass-level contract is
covered exhaustively by `tests/unit/test_planning_service_sync.py::
TestSyncEvictionPreview`; this only needs to prove the wire shape
(`PlanSyncRequest.eviction_choice` / `PlanSyncResponse.eviction_preview`)
round-trips correctly and that an invalid choice 422s at the Pydantic
boundary rather than silently reaching the service as a no-op.
Two non-overlapping windows sharing one sjukpenning pool (same recipe as
the service-level tests): "Existing" (Jan 11-15) syncs first and spends
the whole pool; "New" (Feb 1-5), added afterwards with higher priority,
steals it on the next sync.
"""
async def test_sync_previews_then_resolves_an_eviction(
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(2020, 1, 1),
created_at=now,
)
)
await vacation_repo.create(
VacationDays(
id=uuid4(),
personal_data_uuid=parent.uuid,
workspace_id=ws.id,
year=2027,
total_days=0,
)
)
await parental_leave_repo.create(
_make_fetch(parent.uuid, ws.id, child.id, sjukpenning_left=390, lagsta_left=0)
)
create_existing = await client.post(
"/api/leave-wishes",
json={
"workspace_id": str(ws.id),
"child_id": str(child.id),
"name": "Existing",
"window_start_month": 1,
"window_start_day": 11,
"window_end_month": 1,
"window_end_day": 15,
"target_weeks": 1,
"recurrence": "once",
"first_year": 2027,
"priority": 5,
},
)
assert create_existing.status_code == 201
first = await client.post("/api/plan/sync", json={"workspace_id": str(ws.id)})
assert first.status_code == 200
first_body = first.json()
assert first_body["apply_result"]["accepted"] is True
assert first_body["eviction_preview"]["would_evict"] is False
existing_count = first_body["apply_result"]["created_count"]
assert existing_count > 0
# Shrink the shared pool to exactly what `Existing` already spent,
# then a higher-priority wish over a DIFFERENT window steals it.
await parental_leave_repo.create(
_make_fetch(
parent.uuid, ws.id, child.id, sjukpenning_left=existing_count, lagsta_left=0
)
)
create_new = await client.post(
"/api/leave-wishes",
json={
"workspace_id": str(ws.id),
"child_id": str(child.id),
"name": "New",
"window_start_month": 2,
"window_start_day": 1,
"window_end_month": 2,
"window_end_day": 5,
"target_weeks": 1,
"recurrence": "once",
"first_year": 2027,
"priority": 1,
},
)
assert create_new.status_code == 201
# An invalid choice 422s at the Pydantic boundary.
invalid = await client.post(
"/api/plan/sync",
json={"workspace_id": str(ws.id), "eviction_choice": "not_a_real_choice"},
)
assert invalid.status_code == 422
# No choice -> preview only, nothing accepted/mutated.
preview = await client.post("/api/plan/sync", json={"workspace_id": str(ws.id)})
assert preview.status_code == 200
preview_body = preview.json()
assert preview_body["apply_result"]["accepted"] is False
assert (
preview_body["apply_result"]["rejections"][0]["message_key"]
== "planSync.rejection.evictionChoiceRequired"
)
assert preview_body["eviction_preview"]["would_evict"] is True
candidates = preview_body["eviction_preview"]["candidates"]
assert candidates
assert all(c["wish_name"] == "Existing" for c in candidates)
assert all(c["day_type"] == "sjukpenning" for c in candidates)
shortages = preview_body["eviction_preview"]["shortages"]
assert len(shortages) == 1
assert shortages[0]["resource_kind"] == "parental_leave"
assert shortages[0]["tier"] == "sjukpenning"
assert shortages[0]["child_id"] == str(child.id)
assert shortages[0]["parent_personal_data_uuid"] == str(parent.uuid)
# The chosen option resolves it: the edit lands, `Existing`'s stale
# days are gone.
resolved = await client.post(
"/api/plan/sync",
json={"workspace_id": str(ws.id), "eviction_choice": "drop_future_pl"},
)
assert resolved.status_code == 200
resolved_body = resolved.json()
assert resolved_body["apply_result"]["accepted"] is True
assert resolved_body["eviction_preview"]["would_evict"] is True
wish_ids_written = {d["wish_id"] for d in resolved_body["apply_result"]["created_days"]}
assert wish_ids_written == {create_new.json()["id"]}
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"