76b1e712b9
Works through every numbered item in NextSteps260913.md section 3, plus the three known verification issues in section 4.1. Surface what the backend already computed: - 3.1 render surplus_advice in PlanResults, mirroring the shortfall remedies - 3.2 surface VacationBalance.is_assumed so assumed statutory years are visible - 3.3 age-4 expiry given the proposed plan, via a new Age4Projection reusing the existing _age4_projections arithmetic (Dashboard stat verified, not rebuilt) Calendar cluster (one pass over the holiday data): - new GET /api/calendar/days exposing roda dagar, klamdagar and day overrides, paginated four years at a time - 3.4 shade roda dagar/klamdagar, with R/K badges so colour is not the only signal - 3.6 click-to-override behind an explicit mode toggle, leaving drag multi-select intact - 3.7 click month/year to advance - 3.8 per-year red-days list in Settings, statutory days plus overrides i18n: - 3.5 five backend-generated Swedish surfaces converted to message_key + data, following the tips_service pattern; old string fields replaced, not kept Flexible wishes (3.9): - PLANNING_ENGINE_SPEC section 10: window_mode, candidate search, cost function, deterministic tie-break, two-pass ordering, test matrix rows 15-24 - backend: entity, migration 006, _FULL_SCHEMA sync, repository, allocator - frontend: season-preset toggle in WishEditor, unplaced outcome rendering Fixes (4.1): - pin typecheck to the local tsc rather than npx resolving a newer TypeScript - ignore generated coverage/ and dist/ in the ESLint flat config - fix the flaky usePlanApply test properly, with waitFor rather than a retry Tests: backend 361 -> 404, frontend 540 -> 704. Coverage 89.47% -> 96.52% lines. Not yet verified in a browser. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RXMhc9nT6sNz1SL4j8dznL
643 lines
25 KiB
Python
643 lines
25 KiB
Python
"""API-level test for `POST /api/plan/propose`.
|
|
|
|
This exists specifically to catch the class of bug where the pure
|
|
`Allocator` computes a field on `PlanProposal` that the wire model
|
|
(`app.models.plan.PlanProposalResponse`) then drops — the dataclass tests in
|
|
`tests/unit/test_services/test_allocation_service_branches.py` cannot catch
|
|
this, because they call `Allocator().propose()` directly and never go
|
|
through `PlanProposalResponse.from_domain`. `surplus_advice` (§9.2) was
|
|
exactly such a case: computed, tested against the dataclass, but absent from
|
|
the HTTP response until this test/fix landed.
|
|
|
|
Uses the same in-memory sqlite `connection` fixture as the rest of the suite
|
|
(via `tests/conftest.py`), wired into the real FastAPI app by overriding the
|
|
`get_connection` dependency — this exercises the actual router + dependency
|
|
graph without touching the real `foraldraledig.db` file or running Alembic.
|
|
"""
|
|
|
|
from datetime import date, datetime
|
|
from uuid import uuid4
|
|
|
|
import aiosqlite
|
|
import pytest_asyncio
|
|
from dateutil.relativedelta import relativedelta
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app.dependencies import get_connection
|
|
from app.entities.child import Child
|
|
from app.entities.parental_leave import ParentalLeaveFetch, TierData
|
|
from app.entities.personal_data import PersonalData
|
|
from app.entities.vacation_days import VacationDays
|
|
from app.entities.workspace import Workspace
|
|
from app.main import app
|
|
from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository
|
|
from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository
|
|
from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository
|
|
from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository
|
|
from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository
|
|
|
|
|
|
def _make_fetch(
|
|
personal_data_uuid,
|
|
workspace_id,
|
|
child_id,
|
|
sjukpenning_left: int = 390,
|
|
lagsta_left: int = 90,
|
|
) -> ParentalLeaveFetch:
|
|
now = datetime.now()
|
|
tier = TierData(
|
|
sjukpenning_total=390,
|
|
sjukpenning_used=390 - sjukpenning_left,
|
|
sjukpenning_left=sjukpenning_left,
|
|
lagsta_niva_total=90,
|
|
lagsta_niva_used=90 - lagsta_left,
|
|
lagsta_niva_left=lagsta_left,
|
|
)
|
|
return ParentalLeaveFetch(
|
|
id=uuid4(),
|
|
personal_data_uuid=personal_data_uuid,
|
|
workspace_id=workspace_id,
|
|
child_id=child_id,
|
|
source="manual",
|
|
fetched_at=now,
|
|
total_days=480,
|
|
days_used=480 - sjukpenning_left - lagsta_left,
|
|
days_left=sjukpenning_left + lagsta_left,
|
|
reserved_days_total=90,
|
|
reserved_days_left=90,
|
|
transferable_days_left=sjukpenning_left + lagsta_left,
|
|
dubbeldagar_left=60,
|
|
tier_data=tier,
|
|
created_at=now,
|
|
updated_at=now,
|
|
)
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
async def client(connection: aiosqlite.Connection) -> AsyncClient:
|
|
"""An httpx client against the real app, with `get_connection` overridden
|
|
to hand back the test's in-memory connection instead of opening the real
|
|
on-disk database via the (never-run) lifespan.
|
|
"""
|
|
app.dependency_overrides[get_connection] = lambda: connection
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
|
|
yield ac
|
|
app.dependency_overrides.pop(get_connection, None)
|
|
|
|
|
|
class TestProposeSurplusAdviceOverTheWire:
|
|
async def test_surplus_advice_appears_in_the_http_response(
|
|
self,
|
|
client: AsyncClient,
|
|
connection: aiosqlite.Connection,
|
|
) -> None:
|
|
"""A workspace with a small child, a full/untouched FP pool and no
|
|
leave wishes at all is a guaranteed surplus at the age-4 boundary
|
|
(nothing is planned before it, so the whole 480-day pool minus the
|
|
96-day ceiling is forecast to be forfeited) — the router must return
|
|
that as `surplus_advice`, not just the `days_lost_at_age_4` warning.
|
|
"""
|
|
now = datetime.now()
|
|
workspace_repo = SQLiteWorkspaceRepository(connection)
|
|
personal_data_repo = SQLitePersonalDataRepository(connection)
|
|
child_repo = SQLiteChildRepository(connection)
|
|
|
|
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
|
|
parent = await personal_data_repo.create(
|
|
PersonalData(
|
|
uuid=uuid4(),
|
|
workspace_id=ws.id,
|
|
name="Alex",
|
|
salary_type="monthly",
|
|
created_at=now,
|
|
)
|
|
)
|
|
child = await child_repo.create(
|
|
Child(
|
|
id=uuid4(),
|
|
workspace_id=ws.id,
|
|
name="Barn",
|
|
# Under 2 years old "today" -> still small enough for
|
|
# front_load_early_years too, though this test only asserts
|
|
# on wish_more_leave (the always-present one here).
|
|
birth_date=date.today() - relativedelta(months=6),
|
|
created_at=now,
|
|
)
|
|
)
|
|
|
|
response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)})
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
|
|
assert "surplus_advice" in body, (
|
|
"PlanProposalResponse must expose surplus_advice — it was silently "
|
|
"dropped at the API boundary even though the pure Allocator computed it"
|
|
)
|
|
assert body["surplus_advice"], "expected at least one surplus suggestion"
|
|
|
|
by_kind = {a["kind"]: a for a in body["surplus_advice"]}
|
|
assert "wish_more_leave" in by_kind
|
|
|
|
advice = by_kind["wish_more_leave"]
|
|
assert advice["days_involved"] > 0
|
|
# §3.5 — i18n-ready: message_key + data, not a pre-rendered Swedish
|
|
# string (see PlanResults.tsx / frontend/src/i18n/{sv,en}.ts).
|
|
assert advice["message_key"] == "planSurplusAdvice.wish_more_leave"
|
|
assert advice["data"]["parent_personal_data_uuid"] == str(parent.uuid)
|
|
assert advice["data"]["child_id"] == str(child.id)
|
|
assert isinstance(advice["data"]["weeks"], int) and advice["data"]["weeks"] > 0
|
|
|
|
# The warning it mirrors must also still be present, and — surface #3
|
|
# — expose message_key + data rather than a pre-rendered string.
|
|
age4_warning = next(w for w in body["warnings"] if w["kind"] == "days_lost_at_age_4")
|
|
assert age4_warning["message_key"] == "planWarnings.days_lost_at_age_4"
|
|
assert age4_warning["data"]["parent_personal_data_uuid"] == str(parent.uuid)
|
|
assert "fourth_birthday" in age4_warning["data"]
|
|
|
|
|
|
class TestProposeAge4ProjectionsOverTheWire:
|
|
async def test_age4_projections_appear_in_the_http_response(
|
|
self,
|
|
client: AsyncClient,
|
|
connection: aiosqlite.Connection,
|
|
) -> None:
|
|
"""§3.3 part 2 — the propose response must expose a plan-aware age-4
|
|
projection per (parent, child), the same class of "computed on the
|
|
pure Allocator but silently dropped at the API boundary" bug that
|
|
`surplus_advice` and `is_assumed` hit before their mapping landed in
|
|
`app.models.plan.PlanProposalResponse`.
|
|
"""
|
|
now = datetime.now()
|
|
workspace_repo = SQLiteWorkspaceRepository(connection)
|
|
personal_data_repo = SQLitePersonalDataRepository(connection)
|
|
child_repo = SQLiteChildRepository(connection)
|
|
|
|
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
|
|
parent = await personal_data_repo.create(
|
|
PersonalData(
|
|
uuid=uuid4(),
|
|
workspace_id=ws.id,
|
|
name="Alex",
|
|
salary_type="monthly",
|
|
created_at=now,
|
|
)
|
|
)
|
|
child = await child_repo.create(
|
|
Child(
|
|
id=uuid4(),
|
|
workspace_id=ws.id,
|
|
name="Barn",
|
|
birth_date=date.today() - relativedelta(months=6),
|
|
created_at=now,
|
|
)
|
|
)
|
|
|
|
response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)})
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
|
|
assert "age4_projections" in body, (
|
|
"PlanProposalResponse must expose age4_projections — computed by "
|
|
"the pure Allocator (Age4Projection / build_age4_projections) but "
|
|
"invisible to the UI until mapped onto the wire response"
|
|
)
|
|
assert body["age4_projections"], "expected one projection for the (parent, child) pair"
|
|
|
|
projection = next(
|
|
p
|
|
for p in body["age4_projections"]
|
|
if p["parent_personal_data_uuid"] == str(parent.uuid) and p["child_id"] == str(child.id)
|
|
)
|
|
assert (
|
|
projection["fourth_birthday"] == (child.birth_date + relativedelta(years=4)).isoformat()
|
|
)
|
|
assert isinstance(projection["projected_left_at_b4"], int)
|
|
assert isinstance(projection["savable_after_b4"], int)
|
|
assert isinstance(projection["expiring_at_b4"], int)
|
|
# No leave planned at all before B4 in this scenario -> everything
|
|
# above the savable ceiling is forecast to expire, matching the
|
|
# surplus-advice test above.
|
|
assert projection["expiring_at_b4"] > 0
|
|
|
|
|
|
class TestProposeMessageKeysOverTheWire:
|
|
"""§3.5 — outcomes/remedies must expose `message_key` + `data` (not a
|
|
|
|
pre-rendered Swedish string) all the way through `PlanProposalResponse`,
|
|
the same class of "computed by the pure Allocator but dropped/wrong-
|
|
shaped at the API boundary" bug the surplus_advice/age4_projections
|
|
tests above guard against. The warning surface is covered by
|
|
`TestProposeSurplusAdviceOverTheWire` above (it already sets up a
|
|
scenario with a guaranteed `days_lost_at_age_4` warning).
|
|
"""
|
|
|
|
async def test_outcome_remedy_and_warning_expose_message_key_and_data(
|
|
self,
|
|
client: AsyncClient,
|
|
connection: aiosqlite.Connection,
|
|
) -> None:
|
|
now = datetime.now()
|
|
workspace_repo = SQLiteWorkspaceRepository(connection)
|
|
personal_data_repo = SQLitePersonalDataRepository(connection)
|
|
child_repo = SQLiteChildRepository(connection)
|
|
vacation_repo = SQLiteVacationDaysRepository(connection)
|
|
parental_leave_repo = SQLiteParentalLeaveRepository(connection)
|
|
|
|
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
|
|
parent = await personal_data_repo.create(
|
|
PersonalData(
|
|
uuid=uuid4(),
|
|
workspace_id=ws.id,
|
|
name="Alex",
|
|
salary_type="monthly",
|
|
created_at=now,
|
|
)
|
|
)
|
|
child = await child_repo.create(
|
|
Child(
|
|
id=uuid4(),
|
|
workspace_id=ws.id,
|
|
name="Barn",
|
|
birth_date=date.today() - relativedelta(months=6),
|
|
created_at=now,
|
|
)
|
|
)
|
|
|
|
# Exhaust the FP pool entirely so the wish's window can't be funded
|
|
# from it -> guaranteed shortfall.
|
|
await parental_leave_repo.create(
|
|
_make_fetch(parent.uuid, ws.id, child.id, sjukpenning_left=0, lagsta_left=0)
|
|
)
|
|
|
|
# No vacation next year (the wish's funding year) either -> the
|
|
# window is entirely unfunded (shortfall_days > 0), which is what
|
|
# `_build_remedies` requires to run at all.
|
|
next_year = date.today().year + 1
|
|
await vacation_repo.create(
|
|
VacationDays(
|
|
id=uuid4(),
|
|
personal_data_uuid=parent.uuid,
|
|
workspace_id=ws.id,
|
|
year=next_year,
|
|
total_days=0,
|
|
saved_days_in=0,
|
|
)
|
|
)
|
|
# Spare vacation *this* year (before the wish's window) -> the
|
|
# carry_vacation_forward remedy has something to point at:
|
|
# available (25) above the semesterlagen minimum (20) = 5 spare days.
|
|
await vacation_repo.create(
|
|
VacationDays(
|
|
id=uuid4(),
|
|
personal_data_uuid=parent.uuid,
|
|
workspace_id=ws.id,
|
|
year=date.today().year,
|
|
total_days=25,
|
|
saved_days_in=0,
|
|
)
|
|
)
|
|
|
|
create_wish = await client.post(
|
|
"/api/leave-wishes",
|
|
json={
|
|
"workspace_id": str(ws.id),
|
|
"child_id": None,
|
|
"name": "Vinter",
|
|
"window_start_month": 12,
|
|
"window_start_day": 1,
|
|
"window_end_month": 12,
|
|
"window_end_day": 7,
|
|
"target_weeks": 1,
|
|
"recurrence": "once",
|
|
"first_year": next_year,
|
|
},
|
|
)
|
|
assert create_wish.status_code == 201
|
|
|
|
response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)})
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
|
|
outcomes = [o for o in body["outcomes"] if o["shortfall_days"] > 0]
|
|
assert outcomes, "expected the wish's window to be underfunded"
|
|
outcome = outcomes[0]
|
|
|
|
# Surface #1 — outcome.explanation -> message_key + data.
|
|
assert outcome["message_key"] == "planOutcomes.windowExplanation"
|
|
assert outcome["data"]["wish_name"] == "Vinter"
|
|
assert outcome["data"]["shortfall_days"] == outcome["shortfall_days"]
|
|
|
|
# Surface #2 — remedy.description -> message_key + data.
|
|
assert outcome["remedies"], "expected at least the carry_vacation_forward remedy"
|
|
remedy = next(r for r in outcome["remedies"] if r["kind"] == "carry_vacation_forward")
|
|
assert remedy["message_key"] == "planRemedies.carry_vacation_forward"
|
|
assert remedy["data"]["spare_total"] == remedy["days_freed"] == 5
|
|
|
|
|
|
class TestApplyRejectionsOverTheWireMessageKeys:
|
|
"""§3.5 — `PlanApplyResponse.rejections` must expose `message_key` + `data`
|
|
|
|
(surface #4), not a pre-rendered string, all the way through the HTTP
|
|
response.
|
|
"""
|
|
|
|
async def test_insufficient_balance_rejection_carries_message_key_and_data(
|
|
self,
|
|
client: AsyncClient,
|
|
connection: aiosqlite.Connection,
|
|
) -> None:
|
|
now = datetime.now()
|
|
workspace_repo = SQLiteWorkspaceRepository(connection)
|
|
personal_data_repo = SQLitePersonalDataRepository(connection)
|
|
child_repo = SQLiteChildRepository(connection)
|
|
parental_leave_repo = SQLiteParentalLeaveRepository(connection)
|
|
|
|
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
|
|
parent = await personal_data_repo.create(
|
|
PersonalData(
|
|
uuid=uuid4(),
|
|
workspace_id=ws.id,
|
|
name="Alex",
|
|
salary_type="monthly",
|
|
created_at=now,
|
|
)
|
|
)
|
|
child = await child_repo.create(
|
|
Child(
|
|
id=uuid4(),
|
|
workspace_id=ws.id,
|
|
name="Barn",
|
|
birth_date=date.today() - relativedelta(months=6),
|
|
created_at=now,
|
|
)
|
|
)
|
|
await parental_leave_repo.create(
|
|
_make_fetch(parent.uuid, ws.id, child.id, sjukpenning_left=1, lagsta_left=90)
|
|
)
|
|
|
|
response = await client.post(
|
|
"/api/plan/apply",
|
|
json={
|
|
"workspace_id": str(ws.id),
|
|
"days": [
|
|
{
|
|
"date": "2026-06-15",
|
|
"parent_personal_data_uuid": str(parent.uuid),
|
|
"child_id": str(child.id),
|
|
"day_type": "sjukpenning",
|
|
},
|
|
{
|
|
"date": "2026-06-16",
|
|
"parent_personal_data_uuid": str(parent.uuid),
|
|
"child_id": str(child.id),
|
|
"day_type": "sjukpenning",
|
|
},
|
|
],
|
|
"replace": False,
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
assert body["accepted"] is False
|
|
assert body["rejections"], "expected the balance violation to be reported"
|
|
rejection = body["rejections"][0]
|
|
assert rejection["message_key"] == "planApply.rejection.insufficientBalance"
|
|
assert rejection["data"]["tier"] == "sjukpenning"
|
|
assert rejection["data"]["needed"] == 2
|
|
assert rejection["data"]["remaining"] == 1
|
|
|
|
|
|
class TestLeaveWishWindowModeOverTheWire:
|
|
"""PLANNING_ENGINE_SPEC.md §10 — `window_mode` must round-trip through
|
|
|
|
the `/api/leave-wishes` create endpoint (§10.5's model mapping), and a
|
|
flexible wish's engine-picked placement/unplaced outcome must survive
|
|
the `/api/plan/propose` wire mapping (`app.models.plan`), the same class
|
|
of "computed by the pure Allocator but dropped/wrong-shaped at the API
|
|
boundary" bug the other classes in this file guard against.
|
|
"""
|
|
|
|
async def test_create_leave_wish_round_trips_window_mode(
|
|
self,
|
|
client: AsyncClient,
|
|
connection: aiosqlite.Connection,
|
|
) -> None:
|
|
now = datetime.now()
|
|
workspace_repo = SQLiteWorkspaceRepository(connection)
|
|
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
|
|
|
|
response = await client.post(
|
|
"/api/leave-wishes",
|
|
json={
|
|
"workspace_id": str(ws.id),
|
|
"child_id": None,
|
|
"name": "Vårlov",
|
|
"window_start_month": 3,
|
|
"window_start_day": 1,
|
|
"window_end_month": 5,
|
|
"window_end_day": 31,
|
|
"target_weeks": 1,
|
|
"recurrence": "once",
|
|
"first_year": date.today().year + 1,
|
|
"window_mode": "flexible",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
body = response.json()
|
|
assert body["window_mode"] == "flexible"
|
|
|
|
listed = await client.get(f"/api/leave-wishes/{ws.id}")
|
|
assert listed.status_code == 200
|
|
assert listed.json()[0]["window_mode"] == "flexible"
|
|
|
|
async def test_create_leave_wish_rejects_bound_shorter_than_target_weeks(
|
|
self,
|
|
client: AsyncClient,
|
|
connection: aiosqlite.Connection,
|
|
) -> None:
|
|
now = datetime.now()
|
|
workspace_repo = SQLiteWorkspaceRepository(connection)
|
|
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
|
|
|
|
response = await client.post(
|
|
"/api/leave-wishes",
|
|
json={
|
|
"workspace_id": str(ws.id),
|
|
"child_id": None,
|
|
"name": "Vårlov",
|
|
"window_start_month": 3,
|
|
"window_start_day": 1,
|
|
"window_end_month": 3,
|
|
"window_end_day": 10, # 10-day bound
|
|
"target_weeks": 2, # needs 14 days
|
|
"recurrence": "once",
|
|
"first_year": date.today().year + 1,
|
|
"window_mode": "flexible",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 422
|
|
|
|
async def test_flexible_wish_placement_appears_in_propose_response(
|
|
self,
|
|
client: AsyncClient,
|
|
connection: aiosqlite.Connection,
|
|
) -> None:
|
|
now = datetime.now()
|
|
workspace_repo = SQLiteWorkspaceRepository(connection)
|
|
personal_data_repo = SQLitePersonalDataRepository(connection)
|
|
child_repo = SQLiteChildRepository(connection)
|
|
vacation_repo = SQLiteVacationDaysRepository(connection)
|
|
parental_leave_repo = SQLiteParentalLeaveRepository(connection)
|
|
|
|
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
|
|
parent = await personal_data_repo.create(
|
|
PersonalData(
|
|
uuid=uuid4(), workspace_id=ws.id, name="Alex", salary_type="monthly", created_at=now
|
|
)
|
|
)
|
|
child = await child_repo.create(
|
|
Child(
|
|
id=uuid4(),
|
|
workspace_id=ws.id,
|
|
name="Barn",
|
|
birth_date=date.today() - relativedelta(months=6),
|
|
created_at=now,
|
|
)
|
|
)
|
|
next_year = date.today().year + 1
|
|
await parental_leave_repo.create(_make_fetch(parent.uuid, ws.id, child.id))
|
|
await vacation_repo.create(
|
|
VacationDays(
|
|
id=uuid4(),
|
|
personal_data_uuid=parent.uuid,
|
|
workspace_id=ws.id,
|
|
year=next_year,
|
|
total_days=25,
|
|
saved_days_in=0,
|
|
)
|
|
)
|
|
|
|
create_wish = await client.post(
|
|
"/api/leave-wishes",
|
|
json={
|
|
"workspace_id": str(ws.id),
|
|
"child_id": str(child.id),
|
|
"name": "Vårlov",
|
|
"window_start_month": 1,
|
|
"window_start_day": 1,
|
|
"window_end_month": 3,
|
|
"window_end_day": 31,
|
|
"target_weeks": 1,
|
|
"recurrence": "once",
|
|
"first_year": next_year,
|
|
"window_mode": "flexible",
|
|
},
|
|
)
|
|
assert create_wish.status_code == 201
|
|
wish_id = create_wish.json()["id"]
|
|
|
|
response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)})
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
|
|
outcome = next(o for o in body["outcomes"] if o["wish_id"] == wish_id)
|
|
assert outcome["strategy"] != "unplaced"
|
|
assert outcome["shortfall_days"] == 0
|
|
start = date.fromisoformat(outcome["start_date"])
|
|
end = date.fromisoformat(outcome["end_date"])
|
|
assert date(next_year, 1, 1) <= start <= date(next_year, 3, 31)
|
|
assert date(next_year, 1, 1) <= end <= date(next_year, 3, 31)
|
|
assert (end - start).days == 6 # a full 1-week (7-day) placement
|
|
|
|
async def test_flexible_wish_unplaced_when_fixed_wish_claims_the_bound(
|
|
self,
|
|
client: AsyncClient,
|
|
connection: aiosqlite.Connection,
|
|
) -> None:
|
|
now = datetime.now()
|
|
workspace_repo = SQLiteWorkspaceRepository(connection)
|
|
personal_data_repo = SQLitePersonalDataRepository(connection)
|
|
child_repo = SQLiteChildRepository(connection)
|
|
vacation_repo = SQLiteVacationDaysRepository(connection)
|
|
parental_leave_repo = SQLiteParentalLeaveRepository(connection)
|
|
|
|
ws = await workspace_repo.create(Workspace(id=uuid4(), name="Familj", created_at=now))
|
|
parent = await personal_data_repo.create(
|
|
PersonalData(
|
|
uuid=uuid4(), workspace_id=ws.id, name="Alex", salary_type="monthly", created_at=now
|
|
)
|
|
)
|
|
child = await child_repo.create(
|
|
Child(
|
|
id=uuid4(),
|
|
workspace_id=ws.id,
|
|
name="Barn",
|
|
birth_date=date.today() - relativedelta(months=6),
|
|
created_at=now,
|
|
)
|
|
)
|
|
next_year = date.today().year + 1
|
|
await parental_leave_repo.create(_make_fetch(parent.uuid, ws.id, child.id))
|
|
await vacation_repo.create(
|
|
VacationDays(
|
|
id=uuid4(),
|
|
personal_data_uuid=parent.uuid,
|
|
workspace_id=ws.id,
|
|
year=next_year,
|
|
total_days=25,
|
|
saved_days_in=0,
|
|
)
|
|
)
|
|
|
|
# Fixed wish claims exactly the same 7-day range the flexible wish's
|
|
# bound offers as its only full-length candidate.
|
|
fixed = await client.post(
|
|
"/api/leave-wishes",
|
|
json={
|
|
"workspace_id": str(ws.id),
|
|
"child_id": str(child.id),
|
|
"name": "Fixed",
|
|
"window_start_month": 1,
|
|
"window_start_day": 1,
|
|
"window_end_month": 1,
|
|
"window_end_day": 7,
|
|
"target_weeks": 1,
|
|
"recurrence": "once",
|
|
"first_year": next_year,
|
|
},
|
|
)
|
|
assert fixed.status_code == 201
|
|
|
|
flexible = await client.post(
|
|
"/api/leave-wishes",
|
|
json={
|
|
"workspace_id": str(ws.id),
|
|
"child_id": str(child.id),
|
|
"name": "Flex",
|
|
"window_start_month": 1,
|
|
"window_start_day": 1,
|
|
"window_end_month": 1,
|
|
"window_end_day": 7,
|
|
"target_weeks": 1,
|
|
"recurrence": "once",
|
|
"first_year": next_year,
|
|
"window_mode": "flexible",
|
|
},
|
|
)
|
|
assert flexible.status_code == 201
|
|
flexible_id = flexible.json()["id"]
|
|
|
|
response = await client.post("/api/plan/propose", json={"workspace_id": str(ws.id)})
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
|
|
outcome = next(o for o in body["outcomes"] if o["wish_id"] == flexible_id)
|
|
assert outcome["strategy"] == "unplaced"
|
|
assert outcome["weeks_funded"] == 0.0
|
|
assert outcome["shortfall_days"] == 7
|