c3ba580e30
Clicking a planned day raises the calendar toast with a plain-language
reason (sv + en) taken from the live in-memory proposal; the backend
field-parity walk now covers ProposedDay.
Conflicts in Calendar.tsx / Calendar.test.tsx against the day-assertions
restructure and the two earlier merges; both intents kept:
- usePlannedDayExclusions stays replaced by useDayAssertions; the branch's
usePlanProposal watch, reasonProposal state, reasonByDay map and the
handleBandClick reason toast are added alongside main's band logic.
- runSync keeps the security branch's background refresh and also records
the sync's proposal for the reason toast. main's applyWishSyncResult
(every wish save on this page) records result.sync.proposal the same way,
so an edit's fresh reasons reach the toast — the stale-response guards
in usePlanSync/usePlanProposal only gate hook state, never the returned
response the reason rides in on.
- One main test ("no success toast when sync-with-wish throws") now
dismisses the click's own reason toast first; its assertion is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
1413 lines
57 KiB
Python
1413 lines
57 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, ProposedDay, 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] = {}
|
|
|
|
# Same idea again for `ProposedDay` -> `ProposedDayResponse` -- added
|
|
# specifically because `reason` (the machine-readable code the frontend's
|
|
# calendar toast now reads, see `frontend/src/utils/planReasons.ts`) was
|
|
# never walked by this mechanism: nothing would have failed here if
|
|
# `from_domain()` had silently dropped it, the same class of bug this file's
|
|
# `_assert_domain_fields_reach_the_wire` exists to catch for `PlanProposal`/
|
|
# `WindowOutcome`. Every field is a straight same-name passthrough today.
|
|
_PROPOSED_DAY_RENAMES: dict[str, tuple[str, ...]] = {}
|
|
_PROPOSED_DAY_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 TestPlanSyncDisplacementOverTheWire:
|
|
"""`POST /api/plan/sync`'s displacement reporting (`PlanChangeResponse.
|
|
|
|
evicted`) over the actual HTTP boundary -- the dataclass-level contract
|
|
is covered exhaustively by `tests/unit/test_planning_service_sync.py::
|
|
TestSyncDisplacement`; this only needs to prove the wire shape
|
|
round-trips correctly and that the sync always writes (no
|
|
`eviction_choice`/`eviction_preview` exists on the wire any more -- Step
|
|
2 of the intent-vs-derived-output restructure made displacement
|
|
informational, never a refusal).
|
|
|
|
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_writes_and_reports_displacement(
|
|
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 not any(c["evicted"] for c in first_body["changes"])
|
|
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
|
|
|
|
# The sync writes immediately -- no separate preview round trip, no
|
|
# `eviction_choice` to pass. `Existing`'s stale days come back as
|
|
# 'removed' changes flagged `evicted: true`; `New`'s days land.
|
|
resolved = await client.post("/api/plan/sync", json={"workspace_id": str(ws.id)})
|
|
assert resolved.status_code == 200
|
|
resolved_body = resolved.json()
|
|
assert resolved_body["apply_result"]["accepted"] is True, resolved_body["apply_result"]
|
|
|
|
removed = [c for c in resolved_body["changes"] if c["kind"] == "removed"]
|
|
assert removed, "expected the crowded-out 'Existing' days to be reported as removed"
|
|
assert all(c["evicted"] is True for c in removed)
|
|
assert all(c["old_day_type"] == "sjukpenning" for c in removed)
|
|
|
|
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,
|
|
)
|
|
|
|
# `ProposedDay` -> `ProposedDayResponse`: was never walked before
|
|
# (see `_PROPOSED_DAY_RENAMES`'s docstring) -- this is what would have
|
|
# caught `reason` being silently dropped from the wire.
|
|
for day in body["days"]:
|
|
_assert_domain_fields_reach_the_wire(
|
|
ProposedDay,
|
|
_PROPOSED_DAY_RENAMES,
|
|
_PROPOSED_DAY_ALLOWLIST,
|
|
day,
|
|
context="ProposedDay -> ProposedDayResponse",
|
|
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"
|
|
|
|
|
|
class TestRescueDaysDismissRoute:
|
|
"""§vacation-rescue dismissal — `POST /api/plan/rescue-days/dismiss`.
|
|
|
|
A parent with no `vacation_days` row at all still gets a genuine
|
|
forfeiture forecast (see `AnalyticsService._vacation_year_series`: with
|
|
zero carry-in and zero usage so far, the assumed statutory entitlement
|
|
forecasts losing `min(earned, floor)` days — 20 with every default left
|
|
untouched), so every test below can rely on the seed alone to produce a
|
|
real `rescue_days` group, exactly like `TestProposeSurplusAdviceOverTheWire`
|
|
relies on an untouched FP pool for a guaranteed surplus.
|
|
"""
|
|
|
|
async def _seed_workspace_with_rescue_group(
|
|
self, connection: aiosqlite.Connection
|
|
) -> tuple[Workspace, PersonalData, Child]:
|
|
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,
|
|
)
|
|
)
|
|
return ws, parent, child
|
|
|
|
async def test_dismiss_returns_204_and_group_disappears_from_a_later_propose(
|
|
self,
|
|
client: AsyncClient,
|
|
connection: aiosqlite.Connection,
|
|
) -> None:
|
|
ws, parent, _child = await self._seed_workspace_with_rescue_group(connection)
|
|
|
|
propose_response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)})
|
|
assert propose_response.status_code == 200
|
|
rescue_days = propose_response.json()["rescue_days"]
|
|
assert rescue_days, "seed must produce a real rescue-day group to dismiss"
|
|
group = next(r for r in rescue_days if r["parent_personal_data_uuid"] == str(parent.uuid))
|
|
|
|
dismiss_response = await client.post(
|
|
"/api/plan/rescue-days/dismiss",
|
|
json={
|
|
"workspace_id": str(ws.id),
|
|
"parent_personal_data_uuid": str(parent.uuid),
|
|
"year": group["year"],
|
|
},
|
|
)
|
|
assert dismiss_response.status_code == 204
|
|
assert dismiss_response.content == b""
|
|
|
|
after_response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)})
|
|
assert after_response.status_code == 200
|
|
remaining = after_response.json()["rescue_days"]
|
|
assert not any(
|
|
r["parent_personal_data_uuid"] == str(parent.uuid) and r["year"] == group["year"]
|
|
for r in remaining
|
|
), "dismissed group must not reappear on the next propose"
|
|
|
|
async def test_dismiss_a_group_not_currently_suggested_returns_404(
|
|
self,
|
|
client: AsyncClient,
|
|
connection: aiosqlite.Connection,
|
|
) -> None:
|
|
ws, parent, _child = await self._seed_workspace_with_rescue_group(connection)
|
|
# Rescue days only ever exist for `today`'s own calendar year (see
|
|
# `_Ledger.build_rescue_days`) -- next year's group can never be
|
|
# currently suggested.
|
|
not_suggested_year = date.today().year + 1
|
|
|
|
response = await client.post(
|
|
"/api/plan/rescue-days/dismiss",
|
|
json={
|
|
"workspace_id": str(ws.id),
|
|
"parent_personal_data_uuid": str(parent.uuid),
|
|
"year": not_suggested_year,
|
|
},
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
async def test_dismiss_rejects_a_parent_from_another_workspace(
|
|
self,
|
|
client: AsyncClient,
|
|
connection: aiosqlite.Connection,
|
|
) -> None:
|
|
"""The caller's own workspace_id passes `scope.check_workspace`, but
|
|
the named parent belongs to a different tenant entirely -- must 404
|
|
via `scope.check_member`, never silently dismiss (or confirm) a
|
|
foreign tenant's suggestion."""
|
|
ws, _parent, _child = await self._seed_workspace_with_rescue_group(connection)
|
|
|
|
workspace_repo = SQLiteWorkspaceRepository(connection)
|
|
personal_data_repo = SQLitePersonalDataRepository(connection)
|
|
other_ws = await workspace_repo.create(
|
|
Workspace(id=uuid4(), name="Other Familj", created_at=datetime.now())
|
|
)
|
|
other_parent = await personal_data_repo.create(
|
|
PersonalData(
|
|
uuid=uuid4(), workspace_id=other_ws.id, name="Victim", created_at=datetime.now()
|
|
)
|
|
)
|
|
|
|
response = await client.post(
|
|
"/api/plan/rescue-days/dismiss",
|
|
json={
|
|
"workspace_id": str(ws.id),
|
|
"parent_personal_data_uuid": str(other_parent.uuid),
|
|
"year": date.today().year,
|
|
},
|
|
)
|
|
assert response.status_code == 404
|
|
|
|
async def test_dismiss_rejects_a_foreign_workspace(
|
|
self,
|
|
client: AsyncClient,
|
|
connection: aiosqlite.Connection,
|
|
) -> None:
|
|
"""The caller's own JWT resolves to their own workspace, so naming a
|
|
DIFFERENT (still-existing, real) workspace_id must 404 via
|
|
`scope.check_workspace` -- never confirm that the foreign workspace
|
|
even exists."""
|
|
await self._seed_workspace_with_rescue_group(connection)
|
|
|
|
workspace_repo = SQLiteWorkspaceRepository(connection)
|
|
other_ws = await workspace_repo.create(
|
|
Workspace(id=uuid4(), name="Other Familj", created_at=datetime.now())
|
|
)
|
|
|
|
response = await client.post(
|
|
"/api/plan/rescue-days/dismiss",
|
|
json={
|
|
"workspace_id": str(other_ws.id),
|
|
"parent_personal_data_uuid": str(_CURRENT_USER_PD_UUID),
|
|
"year": date.today().year,
|
|
},
|
|
)
|
|
assert response.status_code == 404
|