Files
F-r-ldraledigplannerare/backend/tests/unit/test_plan_router.py
T
jacobh2 cc3ced905e Link planned days to their wish, fix the age-4 cap, drop the planning goal
Three changes that together unblock treating a calendar period as one
editable object.

Migration 008 adds `planned_leave_day.wish_id` (nullable, ON DELETE SET NULL)
so a persisted day knows which `leave_wish` produced it, `is_pinned` to mark a
day the user hand-edited, and `leave_wish.target_days` so a period drawn on a
calendar need not be a whole number of weeks. Apply-time identity still keys on
day_type/is_dubbeldag only, so a wish_id-only difference is a metadata update
rather than a collision.

The age-4 savable ceiling was applying the per-child figure of 96 to each
parent's pool, modelling a two-parent family as able to save 192 days past the
child's fourth birthday. `resolve_age4_savable_cap` is now the single place
that decides 48 (couple with two parents) versus 96 (everyone else), and both
AnalyticsService and the allocator read it.

Migration 009 drops `workspace.planning_goal` and `leave_wish.goal_override`.
The allocator's tier-3 policy collapses to one rule; `salary_type` still
branches, since that reflects what a vacation day actually costs a parent
rather than a user preference.
2026-09-15 20:28:06 +02:00

812 lines
32 KiB
Python

"""API-level test for `POST /api/plan/propose`.
This exists specifically to catch the class of bug where the pure
`Allocator` computes a field on `PlanProposal` that the wire model
(`app.models.plan.PlanProposalResponse`) then drops — the dataclass tests in
`tests/unit/test_services/test_allocation_service_branches.py` cannot catch
this, because they call `Allocator().propose()` directly and never go
through `PlanProposalResponse.from_domain`. `surplus_advice` (§9.2) was
exactly such a case: computed, tested against the dataclass, but absent from
the HTTP response until this test/fix landed.
Uses the same in-memory sqlite `connection` fixture as the rest of the suite
(via `tests/conftest.py`), wired into the real FastAPI app by overriding the
`get_connection` dependency — this exercises the actual router + dependency
graph without touching the real `foraldraledig.db` file or running Alembic.
"""
from datetime import date, datetime
from uuid import uuid4
import aiosqlite
import pytest_asyncio
from dateutil.relativedelta import relativedelta
from httpx import ASGITransport, AsyncClient
from app.dependencies import get_connection
from app.entities.child import Child
from app.entities.parental_leave import ParentalLeaveFetch, TierData
from app.entities.personal_data import PersonalData
from app.entities.vacation_days import VacationDays
from app.entities.workspace import Workspace
from app.main import app
from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository
from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository
from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository
def _make_fetch(
personal_data_uuid,
workspace_id,
child_id,
sjukpenning_left: int = 390,
lagsta_left: int = 90,
) -> ParentalLeaveFetch:
now = datetime.now()
tier = TierData(
sjukpenning_total=390,
sjukpenning_used=390 - sjukpenning_left,
sjukpenning_left=sjukpenning_left,
lagsta_niva_total=90,
lagsta_niva_used=90 - lagsta_left,
lagsta_niva_left=lagsta_left,
)
return ParentalLeaveFetch(
id=uuid4(),
personal_data_uuid=personal_data_uuid,
workspace_id=workspace_id,
child_id=child_id,
source="manual",
fetched_at=now,
total_days=480,
days_used=480 - sjukpenning_left - lagsta_left,
days_left=sjukpenning_left + lagsta_left,
reserved_days_total=90,
reserved_days_left=90,
transferable_days_left=sjukpenning_left + lagsta_left,
dubbeldagar_left=60,
tier_data=tier,
created_at=now,
updated_at=now,
)
@pytest_asyncio.fixture
async def client(connection: aiosqlite.Connection) -> AsyncClient:
"""An httpx client against the real app, with `get_connection` overridden
to hand back the test's in-memory connection instead of opening the real
on-disk database via the (never-run) lifespan.
"""
app.dependency_overrides[get_connection] = lambda: connection
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
yield ac
app.dependency_overrides.pop(get_connection, None)
class TestProposeSurplusAdviceOverTheWire:
async def test_surplus_advice_appears_in_the_http_response(
self,
client: AsyncClient,
connection: aiosqlite.Connection,
) -> None:
"""A workspace with a small child, a full/untouched FP pool and no
leave wishes at all is a guaranteed surplus at the age-4 boundary
(nothing is planned before it, so the whole 480-day pool minus the
96-day ceiling is forecast to be forfeited) — the router must return
that as `surplus_advice`, not just the `days_lost_at_age_4` warning.
"""
now = datetime.now()
workspace_repo = SQLiteWorkspaceRepository(connection)
personal_data_repo = SQLitePersonalDataRepository(connection)
child_repo = SQLiteChildRepository(connection)
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
parent = await personal_data_repo.create(
PersonalData(
uuid=uuid4(),
workspace_id=ws.id,
name="Alex",
salary_type="monthly",
created_at=now,
)
)
child = await child_repo.create(
Child(
id=uuid4(),
workspace_id=ws.id,
name="Barn",
# Under 2 years old "today" -> still small enough for
# front_load_early_years too, though this test only asserts
# on wish_more_leave (the always-present one here).
birth_date=date.today() - relativedelta(months=6),
created_at=now,
)
)
response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)})
assert response.status_code == 200
body = response.json()
assert "surplus_advice" in body, (
"PlanProposalResponse must expose surplus_advice — it was silently "
"dropped at the API boundary even though the pure Allocator computed it"
)
assert body["surplus_advice"], "expected at least one surplus suggestion"
by_kind = {a["kind"]: a for a in body["surplus_advice"]}
assert "wish_more_leave" in by_kind
advice = by_kind["wish_more_leave"]
assert advice["days_involved"] > 0
# §3.5 — i18n-ready: message_key + data, not a pre-rendered Swedish
# string (see PlanResults.tsx / frontend/src/i18n/{sv,en}.ts).
assert advice["message_key"] == "planSurplusAdvice.wish_more_leave"
assert advice["data"]["parent_personal_data_uuid"] == str(parent.uuid)
assert advice["data"]["child_id"] == str(child.id)
assert isinstance(advice["data"]["weeks"], int) and advice["data"]["weeks"] > 0
# The warning it mirrors must also still be present, and — surface #3
# — expose message_key + data rather than a pre-rendered string.
age4_warning = next(w for w in body["warnings"] if w["kind"] == "days_lost_at_age_4")
assert age4_warning["message_key"] == "planWarnings.days_lost_at_age_4"
assert age4_warning["data"]["parent_personal_data_uuid"] == str(parent.uuid)
assert "fourth_birthday" in age4_warning["data"]
class TestProposeAge4ProjectionsOverTheWire:
async def test_age4_projections_appear_in_the_http_response(
self,
client: AsyncClient,
connection: aiosqlite.Connection,
) -> None:
"""§3.3 part 2 — the propose response must expose a plan-aware age-4
projection per (parent, child), the same class of "computed on the
pure Allocator but silently dropped at the API boundary" bug that
`surplus_advice` and `is_assumed` hit before their mapping landed in
`app.models.plan.PlanProposalResponse`.
"""
now = datetime.now()
workspace_repo = SQLiteWorkspaceRepository(connection)
personal_data_repo = SQLitePersonalDataRepository(connection)
child_repo = SQLiteChildRepository(connection)
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
parent = await personal_data_repo.create(
PersonalData(
uuid=uuid4(),
workspace_id=ws.id,
name="Alex",
salary_type="monthly",
created_at=now,
)
)
child = await child_repo.create(
Child(
id=uuid4(),
workspace_id=ws.id,
name="Barn",
birth_date=date.today() - relativedelta(months=6),
created_at=now,
)
)
response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)})
assert response.status_code == 200
body = response.json()
assert "age4_projections" in body, (
"PlanProposalResponse must expose age4_projections — computed by "
"the pure Allocator (Age4Projection / build_age4_projections) but "
"invisible to the UI until mapped onto the wire response"
)
assert body["age4_projections"], "expected one projection for the (parent, child) pair"
projection = next(
p
for p in body["age4_projections"]
if p["parent_personal_data_uuid"] == str(parent.uuid) and p["child_id"] == str(child.id)
)
assert (
projection["fourth_birthday"] == (child.birth_date + relativedelta(years=4)).isoformat()
)
assert isinstance(projection["projected_left_at_b4"], int)
assert isinstance(projection["savable_after_b4"], int)
assert isinstance(projection["expiring_at_b4"], int)
# 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=uuid4(),
workspace_id=ws.id,
name="Alex",
salary_type="monthly",
created_at=now,
)
)
child = await child_repo.create(
Child(
id=uuid4(),
workspace_id=ws.id,
name="Barn",
birth_date=date.today() - relativedelta(months=6),
created_at=now,
)
)
# Exhaust the FP pool entirely so the wish's window can't be funded
# from it -> guaranteed shortfall.
await parental_leave_repo.create(
_make_fetch(parent.uuid, ws.id, child.id, sjukpenning_left=0, lagsta_left=0)
)
# No vacation next year (the wish's funding year) either -> the
# window is entirely unfunded (shortfall_days > 0), which is what
# `_build_remedies` requires to run at all.
next_year = date.today().year + 1
await vacation_repo.create(
VacationDays(
id=uuid4(),
personal_data_uuid=parent.uuid,
workspace_id=ws.id,
year=next_year,
total_days=0,
saved_days_in=0,
)
)
# Spare vacation *this* year (before the wish's window) -> the
# carry_vacation_forward remedy has something to point at:
# available (25) above the semesterlagen minimum (20) = 5 spare days.
await vacation_repo.create(
VacationDays(
id=uuid4(),
personal_data_uuid=parent.uuid,
workspace_id=ws.id,
year=date.today().year,
total_days=25,
saved_days_in=0,
)
)
create_wish = await client.post(
"/api/leave-wishes",
json={
"workspace_id": str(ws.id),
"child_id": None,
"name": "Vinter",
"window_start_month": 12,
"window_start_day": 1,
"window_end_month": 12,
"window_end_day": 7,
"target_weeks": 1,
"recurrence": "once",
"first_year": next_year,
},
)
assert create_wish.status_code == 201
response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)})
assert response.status_code == 200
body = response.json()
outcomes = [o for o in body["outcomes"] if o["shortfall_days"] > 0]
assert outcomes, "expected the wish's window to be underfunded"
outcome = outcomes[0]
# Surface #1 — outcome.explanation -> message_key + data.
assert outcome["message_key"] == "planOutcomes.windowExplanation"
assert outcome["data"]["wish_name"] == "Vinter"
assert outcome["data"]["shortfall_days"] == outcome["shortfall_days"]
# Surface #2 — remedy.description -> message_key + data.
assert outcome["remedies"], "expected at least the carry_vacation_forward remedy"
remedy = next(r for r in outcome["remedies"] if r["kind"] == "carry_vacation_forward")
assert remedy["message_key"] == "planRemedies.carry_vacation_forward"
assert remedy["data"]["spare_total"] == remedy["days_freed"] == 5
class 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=uuid4(),
workspace_id=ws.id,
name="Alex",
salary_type="monthly",
created_at=now,
)
)
child = await child_repo.create(
Child(
id=uuid4(),
workspace_id=ws.id,
name="Barn",
birth_date=date.today() - relativedelta(months=6),
created_at=now,
)
)
await parental_leave_repo.create(_make_fetch(parent.uuid, ws.id, child.id))
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=uuid4(),
workspace_id=ws.id,
name="Alex",
salary_type="monthly",
created_at=now,
)
)
child = await child_repo.create(
Child(
id=uuid4(),
workspace_id=ws.id,
name="Barn",
birth_date=date.today() - relativedelta(months=6),
created_at=now,
)
)
await parental_leave_repo.create(_make_fetch(parent.uuid, ws.id, child.id))
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=uuid4(),
workspace_id=ws.id,
name="Alex",
salary_type="monthly",
created_at=now,
)
)
child = await child_repo.create(
Child(
id=uuid4(),
workspace_id=ws.id,
name="Barn",
birth_date=date.today() - relativedelta(months=6),
created_at=now,
)
)
await parental_leave_repo.create(
_make_fetch(parent.uuid, ws.id, child.id, sjukpenning_left=1, lagsta_left=90)
)
response = await client.post(
"/api/plan/apply",
json={
"workspace_id": str(ws.id),
"days": [
{
"date": "2026-06-15",
"parent_personal_data_uuid": str(parent.uuid),
"child_id": str(child.id),
"day_type": "sjukpenning",
},
{
"date": "2026-06-16",
"parent_personal_data_uuid": str(parent.uuid),
"child_id": str(child.id),
"day_type": "sjukpenning",
},
],
"replace": False,
},
)
assert response.status_code == 200
body = response.json()
assert body["accepted"] is False
assert body["rejections"], "expected the balance violation to be reported"
rejection = body["rejections"][0]
assert rejection["message_key"] == "planApply.rejection.insufficientBalance"
assert rejection["data"]["tier"] == "sjukpenning"
assert rejection["data"]["needed"] == 2
assert rejection["data"]["remaining"] == 1
class TestLeaveWishWindowModeOverTheWire:
"""PLANNING_ENGINE_SPEC.md §10 — `window_mode` must round-trip through
the `/api/leave-wishes` create endpoint (§10.5's model mapping), and a
flexible wish's engine-picked placement/unplaced outcome must survive
the `/api/plan/propose` wire mapping (`app.models.plan`), the same class
of "computed by the pure Allocator but dropped/wrong-shaped at the API
boundary" bug the other classes in this file guard against.
"""
async def test_create_leave_wish_round_trips_window_mode(
self,
client: AsyncClient,
connection: aiosqlite.Connection,
) -> None:
now = datetime.now()
workspace_repo = SQLiteWorkspaceRepository(connection)
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
response = await client.post(
"/api/leave-wishes",
json={
"workspace_id": str(ws.id),
"child_id": None,
"name": "Vårlov",
"window_start_month": 3,
"window_start_day": 1,
"window_end_month": 5,
"window_end_day": 31,
"target_weeks": 1,
"recurrence": "once",
"first_year": date.today().year + 1,
"window_mode": "flexible",
},
)
assert response.status_code == 201
body = response.json()
assert body["window_mode"] == "flexible"
listed = await client.get(f"/api/leave-wishes/{ws.id}")
assert listed.status_code == 200
assert listed.json()[0]["window_mode"] == "flexible"
async def test_create_leave_wish_rejects_bound_shorter_than_target_weeks(
self,
client: AsyncClient,
connection: aiosqlite.Connection,
) -> None:
now = datetime.now()
workspace_repo = SQLiteWorkspaceRepository(connection)
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
response = await client.post(
"/api/leave-wishes",
json={
"workspace_id": str(ws.id),
"child_id": None,
"name": "Vårlov",
"window_start_month": 3,
"window_start_day": 1,
"window_end_month": 3,
"window_end_day": 10, # 10-day bound
"target_weeks": 2, # needs 14 days
"recurrence": "once",
"first_year": date.today().year + 1,
"window_mode": "flexible",
},
)
assert response.status_code == 422
async def test_flexible_wish_placement_appears_in_propose_response(
self,
client: AsyncClient,
connection: aiosqlite.Connection,
) -> None:
now = datetime.now()
workspace_repo = SQLiteWorkspaceRepository(connection)
personal_data_repo = SQLitePersonalDataRepository(connection)
child_repo = SQLiteChildRepository(connection)
vacation_repo = SQLiteVacationDaysRepository(connection)
parental_leave_repo = SQLiteParentalLeaveRepository(connection)
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
parent = await personal_data_repo.create(
PersonalData(
uuid=uuid4(), workspace_id=ws.id, name="Alex", salary_type="monthly", created_at=now
)
)
child = await child_repo.create(
Child(
id=uuid4(),
workspace_id=ws.id,
name="Barn",
birth_date=date.today() - relativedelta(months=6),
created_at=now,
)
)
next_year = date.today().year + 1
await parental_leave_repo.create(_make_fetch(parent.uuid, ws.id, child.id))
await vacation_repo.create(
VacationDays(
id=uuid4(),
personal_data_uuid=parent.uuid,
workspace_id=ws.id,
year=next_year,
total_days=25,
saved_days_in=0,
)
)
create_wish = await client.post(
"/api/leave-wishes",
json={
"workspace_id": str(ws.id),
"child_id": str(child.id),
"name": "Vårlov",
"window_start_month": 1,
"window_start_day": 1,
"window_end_month": 3,
"window_end_day": 31,
"target_weeks": 1,
"recurrence": "once",
"first_year": next_year,
"window_mode": "flexible",
},
)
assert create_wish.status_code == 201
wish_id = create_wish.json()["id"]
response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)})
assert response.status_code == 200
body = response.json()
outcome = next(o for o in body["outcomes"] if o["wish_id"] == wish_id)
assert outcome["strategy"] != "unplaced"
assert outcome["shortfall_days"] == 0
start = date.fromisoformat(outcome["start_date"])
end = date.fromisoformat(outcome["end_date"])
assert date(next_year, 1, 1) <= start <= date(next_year, 3, 31)
assert date(next_year, 1, 1) <= end <= date(next_year, 3, 31)
assert (end - start).days == 6 # a full 1-week (7-day) placement
async def test_flexible_wish_unplaced_when_fixed_wish_claims_the_bound(
self,
client: AsyncClient,
connection: aiosqlite.Connection,
) -> None:
now = datetime.now()
workspace_repo = SQLiteWorkspaceRepository(connection)
personal_data_repo = SQLitePersonalDataRepository(connection)
child_repo = SQLiteChildRepository(connection)
vacation_repo = SQLiteVacationDaysRepository(connection)
parental_leave_repo = SQLiteParentalLeaveRepository(connection)
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
parent = await personal_data_repo.create(
PersonalData(
uuid=uuid4(), workspace_id=ws.id, name="Alex", salary_type="monthly", created_at=now
)
)
child = await child_repo.create(
Child(
id=uuid4(),
workspace_id=ws.id,
name="Barn",
birth_date=date.today() - relativedelta(months=6),
created_at=now,
)
)
next_year = date.today().year + 1
await parental_leave_repo.create(_make_fetch(parent.uuid, ws.id, child.id))
await vacation_repo.create(
VacationDays(
id=uuid4(),
personal_data_uuid=parent.uuid,
workspace_id=ws.id,
year=next_year,
total_days=25,
saved_days_in=0,
)
)
# Fixed wish claims exactly the same 7-day range the flexible wish's
# bound offers as its only full-length candidate.
fixed = await client.post(
"/api/leave-wishes",
json={
"workspace_id": str(ws.id),
"child_id": str(child.id),
"name": "Fixed",
"window_start_month": 1,
"window_start_day": 1,
"window_end_month": 1,
"window_end_day": 7,
"target_weeks": 1,
"recurrence": "once",
"first_year": next_year,
},
)
assert fixed.status_code == 201
flexible = await client.post(
"/api/leave-wishes",
json={
"workspace_id": str(ws.id),
"child_id": str(child.id),
"name": "Flex",
"window_start_month": 1,
"window_start_day": 1,
"window_end_month": 1,
"window_end_day": 7,
"target_weeks": 1,
"recurrence": "once",
"first_year": next_year,
"window_mode": "flexible",
},
)
assert flexible.status_code == 201
flexible_id = flexible.json()["id"]
response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)})
assert response.status_code == 200
body = response.json()
outcome = next(o for o in body["outcomes"] if o["wish_id"] == flexible_id)
assert outcome["strategy"] == "unplaced"
assert outcome["weeks_funded"] == 0.0
assert outcome["shortfall_days"] == 7