0fc71101ae
Entering a parent's Försäkringskassan balances had no tests at all, despite being how every number in the planner originates and despite it becoming the first thing a new user does. The scraper endpoints are pinned per test rather than read from the ambient configuration, because a local .env sets the flag opposite to the shipped default and a test that trusts it would pass here and fail in CI. Also covered: the hard expiry at twelve, whose rejection path nothing reached and whose boundary turns out to be inclusive; and the dubbeldagar count cap, which now drives past the limit and asserts the days beyond it fall back to a single parent. Two weak assertions were strengthened, including one that derived its expectation from the same response it was checking and so could not have failed on a shared error.
945 lines
39 KiB
Python
945 lines
39 KiB
Python
"""API-level end-to-end journey tests.
|
|
|
|
Two real bugs were found in one session by driving the *running* app through
|
|
a browser while 1,119 unit/service/router tests sat green. Both were
|
|
whole-journey bugs: every existing test exercised a single layer (a service
|
|
in isolation, or a router with hand-built repository rows), so nothing ever
|
|
walked register -> child -> vacation days -> wishes -> propose -> apply ->
|
|
re-propose the way a real user (and the browser session that found the
|
|
bugs) does.
|
|
|
|
This file closes that gap. Every test here drives the real FastAPI app
|
|
(`app.main.app`) over HTTP with `httpx.ASGITransport`, authenticating with a
|
|
real register -> bearer-token flow (see `_register`), and only the DB
|
|
connection is swapped for the in-memory `connection` fixture from
|
|
`tests/conftest.py` (via the `get_connection` dependency override — the same
|
|
technique `tests/unit/test_plan_router.py` uses).
|
|
|
|
Dates are always derived from `date.today()` (`next_year`,
|
|
`relativedelta(months=...)`, ...) or from `cached_year_bounds()` /
|
|
`dateutil.easter.easter()` — never a hardcoded year — so these tests stay
|
|
meaningful next year and as the Swedish-calendar cache is regenerated with a
|
|
different range.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import date
|
|
from typing import Any
|
|
from uuid import UUID, uuid4
|
|
|
|
import aiosqlite
|
|
import pytest_asyncio
|
|
from dateutil.easter import easter
|
|
from dateutil.relativedelta import relativedelta
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
from app.config import settings
|
|
from app.dependencies import get_connection
|
|
from app.main import app
|
|
from app.utils.holidays import cached_year_bounds
|
|
|
|
_PASSWORD = "correct horse battery staple"
|
|
|
|
|
|
@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. Same technique as
|
|
`tests/unit/test_plan_router.py`.
|
|
"""
|
|
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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Journey helpers — each wraps one HTTP call the real app exposes.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
async def _register(
|
|
client: AsyncClient, email: str, display_name: str
|
|
) -> tuple[dict[str, str], UUID, UUID]:
|
|
"""Register a brand-new user and return (auth headers, workspace_id,
|
|
|
|
personal_data_uuid) — `AuthService.register` creates the workspace and
|
|
the registering parent's `PersonalData` row as a side effect, exactly as
|
|
the real onboarding flow does.
|
|
"""
|
|
resp = await client.post(
|
|
"/api/auth/register",
|
|
json={"email": email, "password": _PASSWORD, "display_name": display_name},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
token = resp.json()["access_token"]
|
|
headers = {"Authorization": f"Bearer {token}"}
|
|
|
|
ctx = await client.get("/api/auth/context", headers=headers)
|
|
assert ctx.status_code == 200, ctx.text
|
|
body = ctx.json()
|
|
return headers, UUID(body["workspace_id"]), UUID(body["personal_data_uuid"])
|
|
|
|
|
|
async def _add_child(
|
|
client: AsyncClient, headers: dict[str, str], workspace_id: UUID, name: str, birth_date: date
|
|
) -> UUID:
|
|
resp = await client.post(
|
|
"/api/children",
|
|
headers=headers,
|
|
json={
|
|
"workspace_id": str(workspace_id),
|
|
"name": name,
|
|
"birth_date": birth_date.isoformat(),
|
|
},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
return UUID(resp.json()["id"])
|
|
|
|
|
|
async def _set_vacation(
|
|
client: AsyncClient,
|
|
headers: dict[str, str],
|
|
personal_data_uuid: UUID,
|
|
workspace_id: UUID,
|
|
year: int,
|
|
total_days: int = 25,
|
|
saved_days_in: int = 0,
|
|
) -> None:
|
|
resp = await client.post(
|
|
"/api/vacation-days",
|
|
headers=headers,
|
|
json={
|
|
"personal_data_uuid": str(personal_data_uuid),
|
|
"workspace_id": str(workspace_id),
|
|
"year": year,
|
|
"total_days": total_days,
|
|
"saved_days_in": saved_days_in,
|
|
},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
|
|
|
|
async def _set_full_parental_leave(
|
|
client: AsyncClient,
|
|
headers: dict[str, str],
|
|
personal_data_uuid: UUID,
|
|
workspace_id: UUID,
|
|
child_id: UUID,
|
|
sjukpenning_used: int = 0,
|
|
lagsta_niva_used: int = 0,
|
|
) -> None:
|
|
"""Record a manual FK snapshot via the real `/api/parental-leave/manual`
|
|
|
|
endpoint (the always-available alternative to the scraper), defaulting
|
|
to a full, untouched 390+90 pool.
|
|
"""
|
|
resp = await client.post(
|
|
"/api/parental-leave/manual",
|
|
headers=headers,
|
|
json={
|
|
"personal_data_uuid": str(personal_data_uuid),
|
|
"workspace_id": str(workspace_id),
|
|
"child_id": str(child_id),
|
|
"total_days": 480,
|
|
"days_used": sjukpenning_used + lagsta_niva_used,
|
|
"reserved_days_total": 90,
|
|
"reserved_days_left": 90,
|
|
"transferable_days_left": 150,
|
|
"dubbeldagar_left": 60,
|
|
"sjukpenning_total": 390,
|
|
"sjukpenning_used": sjukpenning_used,
|
|
"lagsta_niva_total": 90,
|
|
"lagsta_niva_used": lagsta_niva_used,
|
|
},
|
|
)
|
|
assert resp.status_code == 201, resp.text
|
|
|
|
|
|
async def _create_wish(
|
|
client: AsyncClient, headers: dict[str, str], **kwargs: Any
|
|
) -> dict[str, Any]:
|
|
resp = await client.post("/api/leave-wishes", headers=headers, json=kwargs)
|
|
assert resp.status_code == 201, resp.text
|
|
return resp.json()
|
|
|
|
|
|
async def _propose(
|
|
client: AsyncClient, headers: dict[str, str], workspace_id: UUID
|
|
) -> dict[str, Any]:
|
|
resp = await client.post(
|
|
"/api/plan/propose", headers=headers, json={"workspace_id": str(workspace_id)}
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
return resp.json()
|
|
|
|
|
|
async def _apply(
|
|
client: AsyncClient,
|
|
headers: dict[str, str],
|
|
workspace_id: UUID,
|
|
days: list[dict[str, Any]],
|
|
replace: bool = False,
|
|
) -> dict[str, Any]:
|
|
resp = await client.post(
|
|
"/api/plan/apply",
|
|
headers=headers,
|
|
json={"workspace_id": str(workspace_id), "days": days, "replace": replace},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
return resp.json()
|
|
|
|
|
|
def _paid_days_for_wish(proposal: dict[str, Any], wish_id: str) -> list[dict[str, Any]]:
|
|
"""Proposed days belonging to `wish_id` that would actually cost a
|
|
|
|
semesterdag/föräldrapenningdag — excludes the automatic 'free'
|
|
(weekend/red-day) filler entries a window also reports.
|
|
"""
|
|
return [d for d in proposal["days"] if d["wish_id"] == wish_id and d["day_type"] != "free"]
|
|
|
|
|
|
def _to_apply_item(d: dict[str, Any]) -> dict[str, Any]:
|
|
return {
|
|
"date": d["date"],
|
|
"parent_personal_data_uuid": d["parent_personal_data_uuid"],
|
|
"child_id": d["child_id"],
|
|
"day_type": d["day_type"],
|
|
"is_dubbeldag": d["is_dubbeldag"],
|
|
}
|
|
|
|
|
|
def _outcome_for_wish(
|
|
proposal: dict[str, Any], wish_id: str, year: int | None = None
|
|
) -> dict[str, Any]:
|
|
matches = [
|
|
o
|
|
for o in proposal["outcomes"]
|
|
if o["wish_id"] == wish_id and (year is None or o["year"] == year)
|
|
]
|
|
assert matches, f"no outcome for wish {wish_id} (year={year}) in {proposal['outcomes']}"
|
|
assert len(matches) == 1, f"expected exactly one outcome, got {matches}"
|
|
return matches[0]
|
|
|
|
|
|
_SWEDISH_CHARS = set("åäöÅÄÖ")
|
|
|
|
|
|
def _find_swedish_strings(obj: object, path: str = "$") -> list[str]:
|
|
"""Recursively walk a decoded JSON response and collect every string
|
|
|
|
leaf containing å/ä/ö — §3.5 requires every user-facing surface to be a
|
|
`message_key` + `data` pair instead of a pre-rendered Swedish string.
|
|
"""
|
|
found: list[str] = []
|
|
if isinstance(obj, str):
|
|
if any(ch in _SWEDISH_CHARS for ch in obj):
|
|
found.append(f"{path} = {obj!r}")
|
|
elif isinstance(obj, dict):
|
|
for k, v in obj.items():
|
|
found.extend(_find_swedish_strings(v, f"{path}.{k}"))
|
|
elif isinstance(obj, list):
|
|
for i, v in enumerate(obj):
|
|
found.extend(_find_swedish_strings(v, f"{path}[{i}]"))
|
|
return found
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 1) THE DOUBLE-BOOKING REGRESSION (confirmed bug, currently unfixed)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFlexibleWishDoubleBookingOnRepropose:
|
|
"""Guards the double-booking bug found live on 2026-09-14.
|
|
|
|
Observed in the running app: propose -> apply -> propose again for a
|
|
*flexible* wish returns a full second week of brand-new paid days on
|
|
shifted dates (9 paid days become 18 if applied twice), while reporting
|
|
`weeks_funded: 1.0` for the "new" window. Root cause: once a flexible
|
|
window's dates are persisted, `_Ledger.resolve_flexible_window`'s
|
|
`_candidate_occupied` check sees them as occupied and searches for a
|
|
*different* full-length candidate elsewhere in the bounding range,
|
|
instead of recognising the wish-year as already funded.
|
|
|
|
The approved fix (product-owner decision, landing separately): if a
|
|
flexible wish-year already has enough planned days inside its bounding
|
|
range, treat that year as funded and propose nothing new.
|
|
|
|
This test asserts the CORRECT (post-fix) behaviour and is EXPECTED TO
|
|
FAIL until that fix lands — do not skip/xfail/weaken it; a red result
|
|
here is the point.
|
|
"""
|
|
|
|
async def test_repropose_after_apply_adds_no_new_paid_days(
|
|
self, client: AsyncClient, connection: aiosqlite.Connection
|
|
) -> None:
|
|
headers, ws_id, pd_uuid = await _register(client, "double-booking@example.com", "Alex")
|
|
birth_date = date.today() - relativedelta(months=6)
|
|
child_id = await _add_child(client, headers, ws_id, "Barn", birth_date)
|
|
await _set_full_parental_leave(client, headers, pd_uuid, ws_id, child_id)
|
|
|
|
next_year = date.today().year + 1
|
|
await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=25)
|
|
|
|
wish = await _create_wish(
|
|
client,
|
|
headers,
|
|
workspace_id=str(ws_id),
|
|
child_id=str(child_id),
|
|
name="Sommar",
|
|
window_start_month=6,
|
|
window_start_day=1,
|
|
window_end_month=8,
|
|
window_end_day=31,
|
|
target_weeks=1,
|
|
recurrence="once",
|
|
first_year=next_year,
|
|
window_mode="flexible",
|
|
)
|
|
wish_id = wish["id"]
|
|
|
|
proposal_1 = await _propose(client, headers, ws_id)
|
|
outcome_1 = _outcome_for_wish(proposal_1, wish_id)
|
|
assert outcome_1["strategy"] != "unplaced"
|
|
paid_days_1 = _paid_days_for_wish(proposal_1, wish_id)
|
|
assert paid_days_1, "expected the flexible wish to be funded with at least one paid day"
|
|
applied_dates = {d["date"] for d in paid_days_1}
|
|
|
|
all_days_1 = [d for d in proposal_1["days"] if d["wish_id"] == wish_id]
|
|
apply_result = await _apply(client, headers, ws_id, [_to_apply_item(d) for d in all_days_1])
|
|
assert apply_result["accepted"] is True, apply_result
|
|
assert apply_result["created_count"] == len(paid_days_1)
|
|
|
|
proposal_2 = await _propose(client, headers, ws_id)
|
|
paid_days_2 = _paid_days_for_wish(proposal_2, wish_id)
|
|
new_dates = {d["date"] for d in paid_days_2} - applied_dates
|
|
|
|
assert new_dates == set(), (
|
|
"Double-booking regression: re-proposing after applying a flexible "
|
|
"wish's window must add ZERO new paid days for that wish-year (the "
|
|
"already-applied dates should be recognised as funding it), but "
|
|
f"got {sorted(new_dates)} brand-new paid day(s) beyond the "
|
|
f"already-applied {sorted(applied_dates)}. This is the confirmed, "
|
|
"not-yet-fixed bug from the 2026-09-14 handoff."
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 2) FIXED-WISH CONTROL CASE — locks in currently-correct behaviour
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFixedWishReproposeAddsNothingNew:
|
|
"""Control case for the double-booking bug above: the SAME propose ->
|
|
|
|
apply -> propose cycle for a `window_mode='fixed'` wish must return zero
|
|
new paid days on re-propose. `_fund_solo`'s `existing_lookup` check
|
|
already prevents this for fixed windows (unlike the flexible resolver's
|
|
`_candidate_occupied` check, which excludes rather than recognises
|
|
already-planned dates) — this test locks that in so a future change to
|
|
the shared funding path can't regress it.
|
|
"""
|
|
|
|
async def test_repropose_after_apply_adds_no_new_paid_days(
|
|
self, client: AsyncClient, connection: aiosqlite.Connection
|
|
) -> None:
|
|
headers, ws_id, pd_uuid = await _register(client, "fixed-control@example.com", "Alex")
|
|
birth_date = date.today() - relativedelta(months=6)
|
|
child_id = await _add_child(client, headers, ws_id, "Barn", birth_date)
|
|
await _set_full_parental_leave(client, headers, pd_uuid, ws_id, child_id)
|
|
|
|
next_year = date.today().year + 1
|
|
await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=25)
|
|
|
|
wish = await _create_wish(
|
|
client,
|
|
headers,
|
|
workspace_id=str(ws_id),
|
|
child_id=str(child_id),
|
|
name="Fixed Week",
|
|
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,
|
|
window_mode="fixed",
|
|
)
|
|
wish_id = wish["id"]
|
|
|
|
proposal_1 = await _propose(client, headers, ws_id)
|
|
outcome_1 = _outcome_for_wish(proposal_1, wish_id)
|
|
assert outcome_1["strategy"] != "unplaced"
|
|
paid_days_1 = _paid_days_for_wish(proposal_1, wish_id)
|
|
assert paid_days_1
|
|
applied_dates = {d["date"] for d in paid_days_1}
|
|
|
|
all_days_1 = [d for d in proposal_1["days"] if d["wish_id"] == wish_id]
|
|
apply_result = await _apply(client, headers, ws_id, [_to_apply_item(d) for d in all_days_1])
|
|
assert apply_result["accepted"] is True, apply_result
|
|
|
|
proposal_2 = await _propose(client, headers, ws_id)
|
|
paid_days_2 = _paid_days_for_wish(proposal_2, wish_id)
|
|
new_dates = {d["date"] for d in paid_days_2} - applied_dates
|
|
|
|
assert new_dates == set(), (
|
|
f"a fixed wish must never be re-funded with new dates on re-propose, "
|
|
f"got {sorted(new_dates)}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 3) APPLY IDEMPOTENCY
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestApplyIdempotency:
|
|
"""Applying the exact same day set twice must skip, not duplicate."""
|
|
|
|
async def test_reapplying_identical_days_skips_instead_of_duplicating(
|
|
self, client: AsyncClient, connection: aiosqlite.Connection
|
|
) -> None:
|
|
headers, ws_id, pd_uuid = await _register(client, "idempotency@example.com", "Alex")
|
|
birth_date = date.today() - relativedelta(months=6)
|
|
child_id = await _add_child(client, headers, ws_id, "Barn", birth_date)
|
|
await _set_full_parental_leave(client, headers, pd_uuid, ws_id, child_id)
|
|
|
|
next_year = date.today().year + 1
|
|
await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=25)
|
|
|
|
wish = await _create_wish(
|
|
client,
|
|
headers,
|
|
workspace_id=str(ws_id),
|
|
child_id=str(child_id),
|
|
name="Idempotency Week",
|
|
window_start_month=9,
|
|
window_start_day=1,
|
|
window_end_month=9,
|
|
window_end_day=7,
|
|
target_weeks=1,
|
|
recurrence="once",
|
|
first_year=next_year,
|
|
)
|
|
wish_id = wish["id"]
|
|
|
|
proposal = await _propose(client, headers, ws_id)
|
|
paid_days = _paid_days_for_wish(proposal, wish_id)
|
|
assert paid_days, "expected at least one paid day to apply"
|
|
payload = [_to_apply_item(d) for d in paid_days]
|
|
|
|
first = await _apply(client, headers, ws_id, payload)
|
|
assert first["accepted"] is True
|
|
assert first["created_count"] == len(paid_days)
|
|
assert first["skipped_identical_count"] == 0
|
|
|
|
second = await _apply(client, headers, ws_id, payload)
|
|
assert second["accepted"] is True, second
|
|
assert second["created_count"] == 0
|
|
assert second["skipped_identical_count"] == len(paid_days)
|
|
assert second["replaced_count"] == 0
|
|
assert second["collisions"] == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 4) FIXED BEATS FLEXIBLE (§3.11.2 — fixed wishes always win)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFixedWishNeverDisplacedByFlexible:
|
|
"""A fixed wish that consumes a flexible wish's whole bounding range must
|
|
|
|
leave the flexible one `strategy='unplaced'`, `weeks_funded=0.0` — a
|
|
flexible wish can never displace a fixed one, driven end-to-end through
|
|
the real onboarding flow (register/child/leave/vacation/wishes) rather
|
|
than hand-built repository rows.
|
|
"""
|
|
|
|
async def test_flexible_wish_is_unplaced_when_fixed_wish_claims_its_bound(
|
|
self, client: AsyncClient, connection: aiosqlite.Connection
|
|
) -> None:
|
|
headers, ws_id, pd_uuid = await _register(client, "fixed-beats-flex@example.com", "Alex")
|
|
birth_date = date.today() - relativedelta(months=6)
|
|
child_id = await _add_child(client, headers, ws_id, "Barn", birth_date)
|
|
await _set_full_parental_leave(client, headers, pd_uuid, ws_id, child_id)
|
|
|
|
next_year = date.today().year + 1
|
|
await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=25)
|
|
|
|
await _create_wish(
|
|
client,
|
|
headers,
|
|
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,
|
|
window_mode="fixed",
|
|
)
|
|
flexible = await _create_wish(
|
|
client,
|
|
headers,
|
|
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",
|
|
)
|
|
flexible_id = flexible["id"]
|
|
|
|
proposal = await _propose(client, headers, ws_id)
|
|
outcome = _outcome_for_wish(proposal, flexible_id)
|
|
|
|
assert outcome["strategy"] == "unplaced"
|
|
assert outcome["weeks_funded"] == 0.0
|
|
assert outcome["shortfall_days"] == 7
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 5) FLEXIBLE WISHES RE-PICK PER YEAR, TRACKING EASTER
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestFlexibleYearlyWishTracksEasterEachYear:
|
|
"""A yearly flexible spring wish (bounding range 03-01..05-31) must land
|
|
|
|
on a different window each year, tracking Easter (verified live: 2027
|
|
Mar 23-29, 2028 Apr 11-17, 2029 Mar 27-Apr 2 — the calendar-only cost
|
|
function in `resolve_flexible_window` favours whichever full-length
|
|
candidate contains the most red days, and Easter's Fri-Mon cluster is
|
|
the densest one in that range). Easter is derived via `dateutil.easter`
|
|
and the three years come from `cached_year_bounds()`, not hardcoded, so
|
|
this stays meaningful as the calendar cache is regenerated.
|
|
"""
|
|
|
|
async def test_each_years_window_is_distinct_and_easter_adjacent(
|
|
self, client: AsyncClient, connection: aiosqlite.Connection
|
|
) -> None:
|
|
bounds = cached_year_bounds()
|
|
assert bounds is not None, "Swedish calendar cache is missing"
|
|
min_year, max_year = bounds
|
|
base_year = min_year + 1 # skip the partially-elapsed current year
|
|
years = [base_year, base_year + 1, base_year + 2]
|
|
assert years[-1] <= max_year, (
|
|
f"calendar cache ({min_year}-{max_year}) too short to run this test; "
|
|
"it needs 3 full years starting the year after the cache's first year"
|
|
)
|
|
|
|
headers, ws_id, pd_uuid = await _register(client, "easter-tracking@example.com", "Alex")
|
|
birth_date = date.today() - relativedelta(years=1)
|
|
child_id = await _add_child(client, headers, ws_id, "Barn", birth_date)
|
|
await _set_full_parental_leave(client, headers, pd_uuid, ws_id, child_id)
|
|
for year in years:
|
|
await _set_vacation(client, headers, pd_uuid, ws_id, year, total_days=25)
|
|
|
|
wish = await _create_wish(
|
|
client,
|
|
headers,
|
|
workspace_id=str(ws_id),
|
|
child_id=str(child_id),
|
|
name="Varlov",
|
|
window_start_month=3,
|
|
window_start_day=1,
|
|
window_end_month=5,
|
|
window_end_day=31,
|
|
target_weeks=1,
|
|
recurrence="yearly",
|
|
first_year=years[0],
|
|
last_year=years[-1],
|
|
window_mode="flexible",
|
|
)
|
|
wish_id = wish["id"]
|
|
|
|
proposal = await _propose(client, headers, ws_id)
|
|
|
|
windows: dict[int, tuple[date, date]] = {}
|
|
for year in years:
|
|
outcome = _outcome_for_wish(proposal, wish_id, year=year)
|
|
assert outcome["strategy"] != "unplaced", outcome
|
|
assert outcome["shortfall_days"] == 0, outcome
|
|
start = date.fromisoformat(outcome["start_date"])
|
|
end = date.fromisoformat(outcome["end_date"])
|
|
assert (end - start).days == 6 # a full 1-week (7-day) placement
|
|
windows[year] = (start, end)
|
|
|
|
easter_sunday = easter(year)
|
|
assert start <= easter_sunday <= end, (
|
|
f"{year}'s window {start}..{end} is not Easter-adjacent "
|
|
f"(Easter Sunday is {easter_sunday})"
|
|
)
|
|
|
|
start_month_days = {year: (w[0].month, w[0].day) for year, w in windows.items()}
|
|
assert len(set(start_month_days.values())) == len(years), (
|
|
f"expected a distinct window each year, got {start_month_days}"
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 6) NO SWEDISH ON THE WIRE (§3.5)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNoSwedishOnTheWire:
|
|
"""Every user-facing string in the propose/apply responses must be a
|
|
|
|
`message_key` + `data` pair, never a pre-rendered Swedish string. Builds
|
|
a scenario that exercises outcomes, remedies, warnings and an apply
|
|
rejection (the message-bearing surfaces §3.5 converted), then walks the
|
|
full decoded JSON body looking for å/ä/ö in any string leaf. All wish
|
|
names created here are plain ASCII, so no field needs to be excluded
|
|
from the walk.
|
|
"""
|
|
|
|
async def test_propose_and_apply_responses_carry_no_swedish_strings(
|
|
self, client: AsyncClient, connection: aiosqlite.Connection
|
|
) -> None:
|
|
headers, ws_id, pd_uuid = await _register(client, "no-swedish@example.com", "Alex")
|
|
birth_date = date.today() - relativedelta(months=6)
|
|
child_id = await _add_child(client, headers, ws_id, "Barn", birth_date)
|
|
|
|
# Exhaust the FP pool entirely -> guaranteed shortfall + remedies.
|
|
await _set_full_parental_leave(
|
|
client, headers, pd_uuid, ws_id, child_id, sjukpenning_used=390, lagsta_niva_used=90
|
|
)
|
|
|
|
next_year = date.today().year + 1
|
|
# No vacation in the wish's own year -> fully unfunded window.
|
|
await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=0)
|
|
# Spare vacation this year -> carry_vacation_forward remedy fires.
|
|
await _set_vacation(client, headers, pd_uuid, ws_id, date.today().year, total_days=25)
|
|
|
|
await _create_wish(
|
|
client,
|
|
headers,
|
|
workspace_id=str(ws_id),
|
|
child_id=str(child_id),
|
|
name="Winter",
|
|
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,
|
|
)
|
|
|
|
proposal = await _propose(client, headers, ws_id)
|
|
outcomes_with_shortfall = [o for o in proposal["outcomes"] if o["shortfall_days"] > 0]
|
|
assert outcomes_with_shortfall, "expected the wish's window to be underfunded"
|
|
assert outcomes_with_shortfall[0]["remedies"], "expected at least one shortfall remedy"
|
|
# A fully-drained FP pool means nothing is left to forecast as
|
|
# expiring at age-4, so `warnings` is legitimately empty here — the
|
|
# remedies/outcomes/data surfaces above already exercise the
|
|
# message_key + data i18n path; walking `warnings` too (even if
|
|
# empty) costs nothing and still guards it if it ever fires.
|
|
violations = _find_swedish_strings(proposal)
|
|
assert not violations, f"Swedish text leaked into /api/plan/propose response: {violations}"
|
|
|
|
# Trigger a structured apply rejection (insufficient balance) too.
|
|
apply_result = await _apply(
|
|
client,
|
|
headers,
|
|
ws_id,
|
|
[
|
|
{
|
|
"date": date(next_year, 6, 15).isoformat(),
|
|
"parent_personal_data_uuid": str(pd_uuid),
|
|
"child_id": str(child_id),
|
|
"day_type": "sjukpenning",
|
|
"is_dubbeldag": False,
|
|
}
|
|
],
|
|
)
|
|
assert apply_result["accepted"] is False
|
|
assert apply_result["rejections"], "expected the balance violation to be reported"
|
|
|
|
violations = _find_swedish_strings(apply_result)
|
|
assert not violations, f"Swedish text leaked into /api/plan/apply response: {violations}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 7) PLAN-AWARE AGE-4 PROJECTION (§3.3) IS SELF-CONSISTENT
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestAge4ProjectionArithmeticIsSelfConsistent:
|
|
"""The propose response's `age4_projections` must satisfy
|
|
|
|
`expiring_at_b4 == max(projected_left_at_b4 - 96, 0)` — after the
|
|
child's 4th birthday only 96 saved days may carry forward; anything
|
|
above that in the projected balance is what's forecast to expire.
|
|
"""
|
|
|
|
async def test_expiring_equals_projected_minus_ceiling_clamped_at_zero(
|
|
self, client: AsyncClient, connection: aiosqlite.Connection
|
|
) -> None:
|
|
"""Independently-derived expected values, not internal consistency.
|
|
|
|
The original version of this test re-derived its expected
|
|
`expiring_at_b4` from `projection["projected_left_at_b4"]` -- a field
|
|
of the SAME response it was checking -- so it only proved the two
|
|
response fields agree with each other, not that either is correct
|
|
(a shared bug in both would still pass; the test's own docstring
|
|
said as much).
|
|
|
|
This version instead computes the expected numbers from the seeded
|
|
fixture and `settings.rules` directly, independent of the response.
|
|
The `/api/plan/propose` response's `age4_projections` come from
|
|
`allocation_service._Ledger._age4_projections`, not from
|
|
`AnalyticsService.get_days_left_at_age_4` -- that method always
|
|
passes `days_used=0` and derives `total_initial` from the
|
|
(parent, child) pool `AllocationInputLoader.load` built
|
|
(`allocation_service.py:1446-1475`). No wish is created in this
|
|
test, so nothing gets proposed and `planned_fp_before_b4` is 0.
|
|
With no `ParentalLeaveFetch` snapshot for this brand-new
|
|
registration, that pool falls back to `rules.sjukpenning_days // 2 +
|
|
rules.lagsta_niva_days // 2`, which is `rules.days_per_parent`
|
|
exactly (the "Bug A" default the two must agree on -- see
|
|
`test_planning_service_apply.py`'s
|
|
`test_no_snapshot_default_balance_matches_allocation_engine`) --
|
|
making `projected_left_at_b4 == rules.days_per_parent`. The
|
|
workspace is a fresh single-member 'solo' workspace (never set to
|
|
'couple' here), so the per-CHILD cap
|
|
(`rules.max_savable_days_at_age_4`) applies via
|
|
`resolve_age4_savable_cap`, not the per-parent one -- making
|
|
`expiring_at_b4 == max(rules.days_per_parent -
|
|
rules.max_savable_days_at_age_4, 0)`.
|
|
"""
|
|
headers, ws_id, pd_uuid = await _register(client, "age4-projection@example.com", "Alex")
|
|
birth_date = date.today() - relativedelta(months=6)
|
|
child_id = await _add_child(client, headers, ws_id, "Barn", birth_date)
|
|
next_year = date.today().year + 1
|
|
await _set_vacation(client, headers, pd_uuid, ws_id, next_year, total_days=25)
|
|
|
|
proposal = await _propose(client, headers, ws_id)
|
|
|
|
rules = settings.rules
|
|
expected_cap = rules.max_savable_days_at_age_4
|
|
expected_projected_left = rules.days_per_parent
|
|
expected_expiring = max(expected_projected_left - expected_cap, 0)
|
|
expected_savable = min(expected_projected_left, expected_cap)
|
|
|
|
assert proposal["age4_projections"], "expected an age4 projection for the (parent, child)"
|
|
for projection in proposal["age4_projections"]:
|
|
assert projection["child_id"] == str(child_id)
|
|
assert projection["savable_cap_at_b4"] == expected_cap, projection
|
|
assert projection["projected_left_at_b4"] == expected_projected_left, projection
|
|
assert projection["expiring_at_b4"] == expected_expiring, projection
|
|
assert projection["savable_after_b4"] == expected_savable, projection
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 8) WORKSPACE PARENTS: ADD / RENAME / REMOVE A PLACEHOLDER PARTNER
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestWorkspaceParentsRenameAndRemove:
|
|
"""API-level coverage for POST/PATCH/DELETE .../parents — the hand-written
|
|
|
|
dataclass -> response mapping in this codebase has silently dropped
|
|
fields before, so this hits the real routes rather than only the
|
|
service. Deliberately a single test walking every case behind ONE
|
|
`_register()` call: `/api/auth/register` is rate-limited to 10/minute
|
|
(see `app/routers/auth.py`), and this whole file shares that limiter's
|
|
in-memory counters within a single test run — one call per scenario
|
|
here would have pushed the file's total registrations over the limit.
|
|
"""
|
|
|
|
async def test_add_rename_remove_and_guards(
|
|
self, client: AsyncClient, connection: aiosqlite.Connection
|
|
) -> None:
|
|
headers, ws_id, pd_uuid = await _register(client, "wsparents@example.com", "Alex")
|
|
|
|
# --- add: happy path + response shape ---------------------------
|
|
add_resp = await client.post(
|
|
f"/api/workspace/{ws_id}/parents",
|
|
headers=headers,
|
|
json={"display_name": "Sam"},
|
|
)
|
|
assert add_resp.status_code == 201, add_resp.text
|
|
placeholder = add_resp.json()
|
|
assert placeholder["name"] == "Sam"
|
|
assert placeholder["workspace_id"] == str(ws_id)
|
|
placeholder_uuid = placeholder["uuid"]
|
|
|
|
members_resp = await client.get(f"/api/workspace/{ws_id}/members", headers=headers)
|
|
assert members_resp.status_code == 200
|
|
assert placeholder_uuid in {m["uuid"] for m in members_resp.json()}
|
|
|
|
# --- add: blank name is refused ----------------------------------
|
|
blank_resp = await client.post(
|
|
f"/api/workspace/{ws_id}/parents",
|
|
headers=headers,
|
|
json={"display_name": " "},
|
|
)
|
|
assert blank_resp.status_code == 422, blank_resp.text
|
|
assert blank_resp.json()["detail"]["message_key"] == "workspace.parents.invalidName"
|
|
|
|
# --- rename: happy path -------------------------------------------
|
|
rename_resp = await client.patch(
|
|
f"/api/workspace/{ws_id}/parents/{placeholder_uuid}",
|
|
headers=headers,
|
|
json={"display_name": "Samuel"},
|
|
)
|
|
assert rename_resp.status_code == 200, rename_resp.text
|
|
renamed = rename_resp.json()
|
|
assert renamed["uuid"] == placeholder_uuid
|
|
assert renamed["name"] == "Samuel"
|
|
|
|
members_resp = await client.get(f"/api/workspace/{ws_id}/members", headers=headers)
|
|
names_by_uuid = {m["uuid"]: m["name"] for m in members_resp.json()}
|
|
assert names_by_uuid[placeholder_uuid] == "Samuel"
|
|
|
|
# --- rename: a personal_data_uuid that isn't a member 404s -------
|
|
rename_404 = await client.patch(
|
|
f"/api/workspace/{ws_id}/parents/{uuid4()}",
|
|
headers=headers,
|
|
json={"display_name": "Whoever"},
|
|
)
|
|
assert rename_404.status_code == 404, rename_404.text
|
|
assert rename_404.json()["detail"]["message_key"] == "workspace.parents.notFound"
|
|
|
|
# --- remove: a personal_data_uuid that isn't a member 404s -------
|
|
remove_404 = await client.delete(
|
|
f"/api/workspace/{ws_id}/parents/{uuid4()}", headers=headers
|
|
)
|
|
assert remove_404.status_code == 404, remove_404.text
|
|
assert remove_404.json()["detail"]["message_key"] == "workspace.parents.notFound"
|
|
|
|
# --- remove: the caller's own profile is refused -------------------
|
|
remove_self = await client.delete(
|
|
f"/api/workspace/{ws_id}/parents/{pd_uuid}", headers=headers
|
|
)
|
|
assert remove_self.status_code == 422, remove_self.text
|
|
assert remove_self.json()["detail"]["message_key"] == "workspace.parents.remove.self"
|
|
|
|
# --- remove: refused while dependent data exists -------------------
|
|
await _set_vacation(client, headers, UUID(placeholder_uuid), ws_id, date.today().year)
|
|
|
|
remove_blocked = await client.delete(
|
|
f"/api/workspace/{ws_id}/parents/{placeholder_uuid}", headers=headers
|
|
)
|
|
assert remove_blocked.status_code == 422, remove_blocked.text
|
|
blocked_detail = remove_blocked.json()["detail"]
|
|
assert blocked_detail["message_key"] == "workspace.parents.remove.hasDependentData"
|
|
assert "vacation_days" in blocked_detail["data"]["blockers"]
|
|
|
|
# Still a member — never silently removed despite the refusal.
|
|
members_resp = await client.get(f"/api/workspace/{ws_id}/members", headers=headers)
|
|
assert placeholder_uuid in {m["uuid"] for m in members_resp.json()}
|
|
|
|
# --- remove: happy path, on a second (unencumbered) placeholder --
|
|
add_resp2 = await client.post(
|
|
f"/api/workspace/{ws_id}/parents",
|
|
headers=headers,
|
|
json={"display_name": "Kim"},
|
|
)
|
|
assert add_resp2.status_code == 201, add_resp2.text
|
|
other_placeholder_uuid = add_resp2.json()["uuid"]
|
|
|
|
delete_resp = await client.delete(
|
|
f"/api/workspace/{ws_id}/parents/{other_placeholder_uuid}", headers=headers
|
|
)
|
|
assert delete_resp.status_code == 204, delete_resp.text
|
|
assert delete_resp.content == b""
|
|
|
|
members_resp = await client.get(f"/api/workspace/{ws_id}/members", headers=headers)
|
|
member_uuids = {m["uuid"] for m in members_resp.json()}
|
|
assert other_placeholder_uuid not in member_uuids
|
|
# The blocked placeholder and the caller are both still members.
|
|
assert placeholder_uuid in member_uuids
|
|
assert str(pd_uuid) in member_uuids
|
|
|
|
|
|
class TestLeaveWishNameMessageKeyRoundTrip:
|
|
"""`name_message_key` (added to fix the "Jul"/"Höstlov" always-Swedish
|
|
|
|
bug — see `backend/app/entities/leave_wish.py`) must actually appear on
|
|
the wire. The hand-written entity<->response mapping in this codebase
|
|
has silently dropped new fields twice before, so this asserts the
|
|
response *body* directly rather than only the service/repository layer.
|
|
|
|
All four scenarios share a single registered user/workspace (one
|
|
`_register` call) rather than one each: `/api/auth/register` is rate
|
|
limited to 10/minute (see `app/routers/auth.py`), and this file already
|
|
registers a user per test class — four more registrations pushed the
|
|
whole module over that limit when run together.
|
|
"""
|
|
|
|
async def test_name_message_key_round_trip(
|
|
self, client: AsyncClient, connection: aiosqlite.Connection
|
|
) -> None:
|
|
headers, ws_id, _ = await _register(client, "wish-key-roundtrip@example.com", "Alex")
|
|
|
|
# --- create returns the key it was given -----------------------
|
|
wish = await _create_wish(
|
|
client,
|
|
headers,
|
|
workspace_id=str(ws_id),
|
|
name="Jul",
|
|
name_message_key="wishEditor.seasonJul",
|
|
window_start_month=12,
|
|
window_start_day=1,
|
|
window_end_month=1,
|
|
window_end_day=31,
|
|
target_weeks=4,
|
|
recurrence="yearly",
|
|
first_year=date.today().year,
|
|
)
|
|
assert wish["name_message_key"] == "wishEditor.seasonJul"
|
|
assert wish["name"] == "Jul"
|
|
|
|
# --- a wish created without a key round-trips null, not a crash -
|
|
# (pre-existing rows, or a free-typed custom name, have no key.)
|
|
keyless_wish = await _create_wish(
|
|
client,
|
|
headers,
|
|
workspace_id=str(ws_id),
|
|
name="Custom trip",
|
|
window_start_month=6,
|
|
window_start_day=1,
|
|
window_end_month=6,
|
|
window_end_day=7,
|
|
target_weeks=1,
|
|
recurrence="once",
|
|
first_year=date.today().year,
|
|
)
|
|
assert keyless_wish["name_message_key"] is None
|
|
|
|
# --- updating an unrelated field preserves the key --------------
|
|
resp = await client.put(
|
|
f"/api/leave-wishes/{wish['id']}", headers=headers, json={"target_weeks": 5}
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["target_weeks"] == 5
|
|
assert body["name"] == "Jul"
|
|
assert body["name_message_key"] == "wishEditor.seasonJul"
|
|
|
|
# --- renaming clears the key even if the caller never mentions --
|
|
# it: the backend enforces this, not just the frontend. Getting
|
|
# this backwards would mean a user's custom name silently reverts
|
|
# to the old preset label the next time the UI translates it.
|
|
resp = await client.put(
|
|
f"/api/leave-wishes/{wish['id']}", headers=headers, json={"name": "Vinterresa"}
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["name"] == "Vinterresa"
|
|
assert body["name_message_key"] is None
|