5e96c352fb
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.
1101 lines
46 KiB
Python
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"
|
|
)
|