Files
F-r-ldraledigplannerare/backend/tests/integration/test_user_journey.py
T
jacobh2 5e96c352fb Persist the wish and pinned flag a planned day is created with
The create and update handlers for a planned day accepted both fields in
their request models and passed neither to the service, so a day hand-edited
in the calendar was never marked as pinned and never kept its link back to
the period that produced it. The allocator would then feel free to retype it
on the next sync, which is precisely what pinning exists to prevent.

Two investigations alongside this found no defect. A period that occurs once
rather than yearly does get placed, and a window that wraps past New Year
keeps the half that falls after today; both are now covered by tests, since
neither case had any. Where those looked broken, the wish had simply run out
of budget for that year, which the interface does not yet explain.
2026-09-17 21:04:44 +02:00

1101 lines
46 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()
async def _sync(client: AsyncClient, headers: dict[str, str], workspace_id: UUID) -> dict[str, Any]:
resp = await client.post(
"/api/plan/sync", headers=headers, json={"workspace_id": str(workspace_id)}
)
assert resp.status_code == 200, resp.text
return resp.json()
async def _list_planned_days(
client: AsyncClient, headers: dict[str, str], child_id: UUID
) -> list[dict[str, Any]]:
resp = await client.get(
"/api/planned-days", headers=headers, params={"child_id": str(child_id)}
)
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
# ---------------------------------------------------------------------------
# 9) is_pinned / wish_id ROUND-TRIP, AND SURVIVAL THROUGH /api/plan/sync
# (routers/planned_days.py bug — both fields were accepted by the request
# schema and understood by PlanningService, but silently dropped by the
# router before ever reaching the service, so "pin this day" never
# persisted). One shared registration for both scenarios — see
# `TestLeaveWishNameMessageKeyRoundTrip` above for why: `/api/auth/register`
# is rate-limited to 10/minute and this module already registers a user per
# test class.
# ---------------------------------------------------------------------------
class TestPlannedDayIsPinnedRoundTripAndSyncSurvival:
"""`PlannedLeaveDayCreate`/`Update` both carry `is_pinned`/`wish_id`, and
`PlanningService.add_planned_day`/`.update` both accept them — but the
router never forwarded either field, so a client setting `is_pinned:
true` (the "pin this day so the allocator won't retype it" gesture) had
it silently discarded. Both scenarios (the raw POST/PUT round trip, and
the end-to-end "survives a re-sync" behaviour it exists for) share a
single registration, same reasoning as `TestLeaveWishNameMessageKeyRoundTrip`
above.
"""
async def test_is_pinned_round_trips_and_survives_a_subsequent_sync(
self, client: AsyncClient, connection: aiosqlite.Connection
) -> None:
headers, ws_id, pd_uuid = await _register(client, "pin-roundtrip@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="Pin 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"]
# --- part 1: raw POST/PUT round trip -----------------------------
create_resp = await client.post(
"/api/planned-days",
headers=headers,
json={
"workspace_id": str(ws_id),
"child_id": str(child_id),
"parent_personal_data_uuid": str(pd_uuid),
"date": date.today().isoformat(),
"day_type": "vacation",
"is_dubbeldag": False,
"wish_id": wish_id,
"is_pinned": True,
},
)
assert create_resp.status_code == 201, create_resp.text
created = create_resp.json()
assert created["is_pinned"] is True, (
"is_pinned must round-trip through POST /api/planned-days, not be silently "
"dropped by the router"
)
assert created["wish_id"] == wish_id
day_id = created["id"]
# Flip both fields via PUT — a partial update — and confirm the new
# values, not the create-time ones, come back.
update_resp = await client.put(
f"/api/planned-days/{day_id}",
headers=headers,
json={"is_pinned": False},
)
assert update_resp.status_code == 200, update_resp.text
updated = update_resp.json()
assert updated["is_pinned"] is False, (
"is_pinned must round-trip through PUT /api/planned-days/{id} too"
)
# wish_id was not mentioned in this PUT — must be left untouched.
assert updated["wish_id"] == wish_id
# Delete the ad-hoc probe row before part 2 so it can't be mistaken
# for one of the wish's own placed days below.
del_resp = await client.delete(f"/api/planned-days/{day_id}", headers=headers)
assert del_resp.status_code == 204, del_resp.text
# --- part 2: a pinned day survives a subsequent /api/plan/sync --
# the end-to-end behaviour users actually rely on `is_pinned` for:
# "changing a day's funding type pins it so the allocator won't
# retype it".
# First sync places the wish's days as ordinary (unpinned,
# wish-attributed) rows.
await _sync(client, headers, ws_id)
planned = await _list_planned_days(client, headers, child_id)
wish_days = [d for d in planned if d["wish_id"] == wish_id]
assert wish_days, "expected the wish to have been placed by the first sync"
pinned_day = wish_days[0]
original_day_type = pinned_day["day_type"]
# Retype it to whatever it currently is NOT, mirroring "the user
# hand-edited this day's funding type" — then pin it so a re-sync
# must leave it alone.
new_day_type = "sjukpenning" if original_day_type != "sjukpenning" else "vacation"
pin_resp = await client.put(
f"/api/planned-days/{pinned_day['id']}",
headers=headers,
json={"day_type": new_day_type, "is_pinned": True},
)
assert pin_resp.status_code == 200, pin_resp.text
assert pin_resp.json()["is_pinned"] is True
assert pin_resp.json()["day_type"] == new_day_type
# Re-sync: a fresh propose would want this date back at
# `original_day_type` (the wish's natural funding choice) — the
# pinned row must survive untouched instead.
await _sync(client, headers, ws_id)
after = await _list_planned_days(client, headers, child_id)
after_row = next(d for d in after if d["id"] == pinned_day["id"])
assert after_row["is_pinned"] is True
assert after_row["day_type"] == new_day_type, (
"a pinned day must survive a subsequent /api/plan/sync without being "
"retyped back by the allocator"
)