Files
F-r-ldraledigplannerare/backend/tests/integration/test_user_journey.py
T
jacobh2 782415122d Separate user intent from planner output via day assertions
The calendar mixed two kinds of truth in one table. `planned_leave_day` was
simultaneously the allocator's output cache, the user's override store
(`is_pinned`, plus ad-hoc rows with no `wish_id`), and the resource ledger that
analytics and the age-4 maths read. The planner therefore had to ask "am I
allowed to touch this row?" on every sync, and got it wrong at the edges.

Every user assertion is now one `day_assertion` row — a concrete `day_type`
replaces a pinned day, a row with no `wish_id` replaces an ad-hoc day, and
`day_type='none'` replaces `planned_day_exclusion`. `planned_leave_day` becomes
purely derived with a single writer, and `PlanningService.pinned_keys()` is the
single answer to "is this day pinned", used by the allocator's loader, by
`sync_plan` and by the read router alike.

On top of that, every calendar band is now a real event: an ad-hoc band opens
the same full editor as a wish-backed one and saving it promotes it to a
`LeaveWish`, so it can be named, repeated yearly, assigned to either or both
parents, and handed to the planner. "Planering" is a `moveable` toggle plus a
from/to month bound plus a separate lock/unlock action on the days themselves,
replacing a three-rung ladder whose "planner decides" rung set a search window
equal to the drawn dates and so could never move anything.

Bugs found and fixed along the way:

- The plan erased its own history. Fixed windows clip to today, so an elapsed
  auto-placed day fell into `stale_keys` and was deleted on the next sync —
  and a background sync runs on every workspace load. Past rows are now frozen
  and counted as spent.
- Migration 010's downgrade silently destroyed every pinned day and exclusion
  (`is_pinned` returned as DEFAULT 0, the exclusion table came back empty,
  exit 0, no warning). It now reconstructs both from `day_assertion`, and 009
  itemises what it cannot recover before dropping the table.
- Deleting a period left its assertions behind, locking those dates forever
  with no UI path to clear them. Wish deletion now clears them by `wish_id`.
- `DayAssertionService` and `LeaveWishService` committed multi-statement writes
  without a transaction; a crash mid-write diverged the tables or half-deleted
  a series. Both now run in a unit of work.
- A sibling year's shortfall was never surfaced: the toast only inspected the
  edited occurrence's outcome, so a fully unfunded later year rendered as an
  empty band with no warning.
- A flexible window whose bound had entirely elapsed was dropped from the
  proposal with no outcome at all. It now reports as `unplaced`.
- Unlocking a period deleted its days instead of returning them to the planner.
- The e2e global setup posted to a route deleted in this change, which would
  have failed the whole suite before its first test.

Gates: backend 938 tests / 91% coverage, frontend 1351 / 97.4 | 92.2 | 94.37 |
97.4. Migrations 009 and 010 audited against a hand-built realistic database:
the upgrade reconciles exactly and a constraint violation fails safe, leaving
the schema at 008.

Not complete. Step 4's toggle, month picker and lock/unlock action are verified
by browser walk and unit tests, but the partial-lock readout was not exercised.
`ideas.md` §7 adds a full plan for the vacation-forfeiture defect (the planner
detects expiring days and then does nothing about them) — not implemented here.
Remaining smaller items are listed in the session's restructure notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
2026-09-21 07:20:14 +02:00

1176 lines
49 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, timedelta
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.services.allocation_service import is_free_day
from app.utils.holidays import cached_year_bounds, get_swedish_holidays
_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. `reserved_days_left` is left for
`build_snapshot` to derive (transferable-spent-first) rather than
hardcoded, since a hardcoded 90 would be inconsistent with a
(sjukpenning_used, lagsta_niva_used) combination that drains past the
150 transferable days into the reserved ones.
"""
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,
"sjukpenning_left": 390 - sjukpenning_used,
"lagsta_niva_left": 90 - lagsta_niva_used,
"dubbeldagar_left": 60,
},
)
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 _next_workday(start: date) -> date:
"""The next date at/after `start` that isn't free (weekend/red day).
Used for probe rows whose exact date doesn't matter except that it must
be legal to plan as `vacation` — see
`PlanningService._reject_illegal_free_day`. `start` (typically
`date.today()`) is otherwise arbitrary and, per this file's own
docstring, deliberately never hardcoded.
"""
d = start
year = d.year
holidays_map = get_swedish_holidays(year)
while is_free_day(d, holidays_map):
d += timedelta(days=1)
if d.year != year:
year = d.year
holidays_map = get_swedish_holidays(year)
return d
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"],
# §fix (parent-reassignment moves dates, decision B) — a faithful
# caller round-trips `wish_id` from the propose response exactly
# like every other field here; omitting it used to be harmless
# (the old reuse match was parent-based) but `_persisted_by_wish`
# now needs it to recognise a flexible wish's own prior placement
# on the next propose (see allocation_service.py's `_Ledger
# .__init__`). Real callers already send it -- `ProposedDayResponse`
# (and hence this `d`) always carries a non-null `wish_id`.
"wish_id": d["wish_id"],
}
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",
)
# A repeating wish is materialised into one row per year sharing a
# `series_id`; the create response is the first year's row, so the
# later years' outcomes carry their OWN row ids.
assert wish["series_id"] is not None
listed = await client.get(f"/api/leave-wishes/{ws_id}", headers=headers)
assert listed.status_code == 200, listed.text
wish_id_by_year = {
row["first_year"]: row["id"]
for row in listed.json()
if row["series_id"] == wish["series_id"]
}
assert set(wish_id_by_year) == set(years), wish_id_by_year
proposal = await _propose(client, headers, ws_id)
windows: dict[int, tuple[date, date]] = {}
for year in years:
outcome = _outcome_for_wish(proposal, wish_id_by_year[year], 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 `default_tier_pool_share`
(`app.utils.tip_predicates`), which gives a SOLE parent (this
workspace has exactly one member) the full
`rules.sjukpenning_days`/`rules.lagsta_niva_days` pool rather than
half of it -- summing to `rules.total_days_per_child` exactly (the
"Bug A" default the allocation engine and `PlanningService.
_tier_pool_left` must agree on -- see
`test_planning_service_apply.py`'s
`test_no_snapshot_default_balance_matches_allocation_engine`) --
making `projected_left_at_b4 == rules.total_days_per_child`. 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.total_days_per_child -
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.total_days_per_child
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: happy path, on an unencumbered placeholder ------------
# Done here (before any dependent data exists on Samuel) rather than
# via a third placeholder, since the workspace is capped at 2
# parents — see the cap test below.
delete_resp = await client.delete(
f"/api/workspace/{ws_id}/parents/{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 placeholder_uuid not in member_uuids
assert member_uuids == {str(pd_uuid)}
# --- add: a second placeholder, to re-fill the workspace -----------
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"]
# --- add: capped at 2 parents per workspace ------------------------
# Alex + Kim already fill the workspace's 2-parent cap, so a third
# add is refused (and never persisted) before any dependent-data
# concern is even relevant.
cap_resp = await client.post(
f"/api/workspace/{ws_id}/parents",
headers=headers,
json={"display_name": "Robin"},
)
assert cap_resp.status_code == 422, cap_resp.text
assert cap_resp.json()["detail"]["message_key"] == "workspace.parents.add.maxReached"
members_resp = await client.get(f"/api/workspace/{ws_id}/members", headers=headers)
member_uuids = {m["uuid"] for m in members_resp.json()}
assert member_uuids == {other_placeholder_uuid, str(pd_uuid)}
# --- remove: refused while dependent data exists -------------------
await _set_vacation(client, headers, UUID(other_placeholder_uuid), ws_id, date.today().year)
remove_blocked = await client.delete(
f"/api/workspace/{ws_id}/parents/{other_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 other_placeholder_uuid in {m["uuid"] for m in members_resp.json()}
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) DayAssertion round-trip AND survival through /api/plan/sync
# (restructure step 3b — "pinned" moved off `planned_leave_day.is_pinned`,
# now dropped, onto `POST /api/day-assertions`; there is no
# `PUT /api/planned-days/{id}` any more, only reads). 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 TestDayAssertionRoundTripAndSyncSurvival:
"""`POST /api/day-assertions` is the sole way to pin a day now (there is
no `PUT /api/planned-days/{id}` any more). Both scenarios (the raw
create/clear round trip, and the end-to-end "survives a re-sync"
behaviour pinning exists for) share a single registration, same
reasoning as `TestLeaveWishNameMessageKeyRoundTrip` above.
"""
async def test_day_assertion_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/DELETE round trip --------------------------
probe_date = _next_workday(date.today())
create_resp = await client.post(
"/api/day-assertions",
headers=headers,
json={
"workspace_id": str(ws_id),
"child_id": str(child_id),
"parent_personal_data_uuid": str(pd_uuid),
# An ad-hoc probe assertion — the exact date is irrelevant
# except that it must be legal to plan as `vacation` (never
# a free day; see `app.services.day_guards.
# reject_illegal_free_day`).
"date": probe_date.isoformat(),
"day_type": "vacation",
"is_dubbeldag": False,
"wish_id": wish_id,
},
)
assert create_resp.status_code == 201, create_resp.text
created = create_resp.json()
assert created["day_type"] == "vacation"
assert created["wish_id"] == wish_id
# The assertion mirrors immediately into `planned_leave_day` — no
# sync required to see it.
planned = await _list_planned_days(client, headers, child_id)
mirrored = next(d for d in planned if d["date"] == probe_date.isoformat())
assert mirrored["is_pinned"] is True, (
"a concrete DayAssertion must mirror into planned_leave_day immediately, "
"and the row must report is_pinned=true for it"
)
# Clear it (by natural key — DELETE /api/day-assertions takes no id)
# before part 2 so it can't be mistaken for one of the wish's own
# placed days below.
clear_resp = await client.request(
"DELETE",
"/api/day-assertions",
headers=headers,
params={
"workspace_id": str(ws_id),
"child_id": str(child_id),
"parent_personal_data_uuid": str(pd_uuid),
"date": probe_date.isoformat(),
},
)
assert clear_resp.status_code == 204, clear_resp.text
after_clear = await _list_planned_days(client, headers, child_id)
assert not any(d["date"] == probe_date.isoformat() for d in after_clear), (
"clearing the assertion must also remove the mirrored planned_leave_day row"
)
# --- part 2: a pinned day survives a subsequent /api/plan/sync --
# the end-to-end behaviour users actually rely on pinning 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 assert it so a
# re-sync must leave it alone.
new_day_type = "sjukpenning" if original_day_type != "sjukpenning" else "vacation"
pin_resp = await client.post(
"/api/day-assertions",
headers=headers,
json={
"workspace_id": str(ws_id),
"child_id": str(child_id),
"parent_personal_data_uuid": str(pd_uuid),
"date": pinned_day["date"],
"day_type": new_day_type,
"wish_id": wish_id,
},
)
assert pin_resp.status_code == 201, pin_resp.text
pinned_after_assert = await _list_planned_days(client, headers, child_id)
pinned_row = next(d for d in pinned_after_assert if d["date"] == pinned_day["date"])
assert pinned_row["is_pinned"] is True
assert pinned_row["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["date"] == pinned_day["date"])
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"
)