e4719cb9ae
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>
1261 lines
51 KiB
Python
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"
|