Files
F-r-ldraledigplannerare/backend/tests/unit/test_planning_service_sync_with_wish.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

213 lines
8.8 KiB
Python

"""Tests for `PlanningService.sync_with_wish` (POST /api/plan/sync-with-wish).
This endpoint drives all four of the calendar's create/edit-then-sync paths
(see its docstring) but had NO test coverage at all before this file —
"930 backend tests pass" never once exercised it. Bug 4 (day-assertion
subsystem review) was reported as: open a pinned/locked day's Period modal,
set "Varje år" (yearly) + "Alla år" + "Tills barnet fyller 4 år", save —
`sync-with-wish` returns 200, but the FOLLOWING year shows no occurrence at
all, no band, no error.
Investigation (see the review's report) found that a yearly wish — locked
or not — DOES materialise and fund every future year correctly as long as
there is enough balance; what silently swallows a sibling year is a genuine
funding shortfall (an ordinary, expected outcome under `sync_plan`'s
"shortfall, not failure" model) that the frontend's toast never surfaced for
any wish-year OTHER than the one just edited — fixed separately in
`Calendar.tsx`'s `buildSyncToastMessage` (see `WindowOutcome.series_id`).
This file locks in the currently-correct backend behaviour as a regression
guard: a yearly (recurring) wish — including one whose CURRENT year is
locked/pinned — produces a funded occurrence in the following year given
sufficient balance, both generally (Bug 4's "recurrence generally" question)
and specifically for the locked case.
"""
from datetime import date, datetime
from uuid import uuid4
from app.entities.parental_leave import ParentalLeaveFetch, TierData
from app.entities.vacation_days import VacationDays
from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository
from app.services.planning_service import DayLockAction, PlanningService, WishCreateInput
from tests.conftest import SeededIds
_FIRST_YEAR = date.today().year + 1
_SECOND_YEAR = _FIRST_YEAR + 1
def _fetch(
personal_data_uuid, workspace_id, child_id, fetched_at, sjuk_left, lagsta_left
) -> ParentalLeaveFetch:
tier = TierData(
sjukpenning_total=390,
sjukpenning_used=390 - sjuk_left,
sjukpenning_left=sjuk_left,
lagsta_niva_total=90,
lagsta_niva_used=90 - lagsta_left,
lagsta_niva_left=lagsta_left,
)
return ParentalLeaveFetch(
id=uuid4(),
personal_data_uuid=personal_data_uuid,
workspace_id=workspace_id,
child_id=child_id,
source="manual",
fetched_at=fetched_at,
total_days=480,
days_used=480 - sjuk_left - lagsta_left,
days_left=sjuk_left + lagsta_left,
reserved_days_total=90,
reserved_days_left=90,
transferable_days_left=sjuk_left + lagsta_left,
dubbeldagar_left=60,
tier_data=tier,
created_at=fetched_at,
updated_at=fetched_at,
)
class TestSyncWithWishYearlyRecurrence:
async def _seed_balance(
self,
vacation_repo: SQLiteVacationDaysRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
seeded_ids: SeededIds,
) -> None:
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
for year in (_FIRST_YEAR, _SECOND_YEAR):
await vacation_repo.create(
VacationDays(
id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=year, total_days=25
)
)
await parental_leave_repo.create(
_fetch(parent, ws, child, datetime.now(), sjuk_left=300, lagsta_left=90)
)
async def test_yearly_recurrence_funds_the_following_year(
self,
planning_service: PlanningService,
planned_leave_repo: SQLitePlannedLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
seeded_ids: SeededIds,
) -> None:
"""Bug 4's "does recurrence work generally" question — plain yearly
recurrence, no locking at all, must fund BOTH years in one call.
"""
await self._seed_balance(vacation_repo, parental_leave_repo, seeded_ids)
result = await planning_service.sync_with_wish(
seeded_ids.workspace_id,
wish_create=WishCreateInput(
child_id=seeded_ids.child_id,
name="Sommar",
name_message_key=None,
window_start_month=9,
window_start_day=1,
window_end_month=9,
window_end_day=10,
target_weeks=1,
target_days=None,
recurrence="yearly",
first_year=_FIRST_YEAR,
last_year=_SECOND_YEAR,
priority=1,
parent_mode="either",
parent_personal_data_uuid=None,
window_mode="fixed",
series_id=None,
wants_dubbeldag=False,
),
)
assert result.sync.apply_result.accepted, result.sync.apply_result.rejections
rows = await planned_leave_repo.list_by_workspace_child(
seeded_ids.workspace_id, seeded_ids.child_id
)
years_with_rows = {r.date.year for r in rows}
assert _FIRST_YEAR in years_with_rows
assert _SECOND_YEAR in years_with_rows, (
"a plain yearly wish must fund BOTH years given sufficient balance"
)
async def test_yearly_recurrence_with_current_year_locked_still_funds_the_following_year(
self,
planning_service: PlanningService,
planned_leave_repo: SQLitePlannedLeaveRepository,
vacation_repo: SQLiteVacationDaysRepository,
parental_leave_repo: SQLiteParentalLeaveRepository,
seeded_ids: SeededIds,
) -> None:
"""Bug 4's literal claim — a yearly wish whose CURRENT (first) year
is locked/pinned must still produce a funded occurrence in the
following year, exactly as the unlocked case does. `day_actions`
only ever pins the specific `planned_day_id`s of the edited
occurrence (see `Calendar.tsx`'s `handleSavePeriod`) — the sibling
year is never itself locked, and per the review's design call,
should not need to be: it is planner-funded like any ordinary band.
"""
await self._seed_balance(vacation_repo, parental_leave_repo, seeded_ids)
created = await planning_service.sync_with_wish(
seeded_ids.workspace_id,
wish_create=WishCreateInput(
child_id=seeded_ids.child_id,
name="Sommar",
name_message_key=None,
window_start_month=9,
window_start_day=1,
window_end_month=9,
window_end_day=10,
target_weeks=1,
target_days=None,
recurrence="yearly",
first_year=_FIRST_YEAR,
last_year=_SECOND_YEAR,
priority=1,
parent_mode="either",
parent_personal_data_uuid=None,
window_mode="fixed",
series_id=None,
wants_dubbeldag=False,
),
)
assert created.sync.apply_result.accepted, created.sync.apply_result.rejections
rows = await planned_leave_repo.list_by_workspace_child(
seeded_ids.workspace_id, seeded_ids.child_id
)
year1_rows = [r for r in rows if r.date.year == _FIRST_YEAR]
assert year1_rows, "expected the first year to already be funded before locking it"
from app.services.planning_service import WishUpdateInput
locked = await planning_service.sync_with_wish(
seeded_ids.workspace_id,
wish_update=WishUpdateInput(leave_wish_id=created.wish.id, scope="occurrence"),
day_actions=[DayLockAction(planned_day_id=r.id, action="pin") for r in year1_rows],
)
assert locked.sync.apply_result.accepted, locked.sync.apply_result.rejections
rows_after = await planned_leave_repo.list_by_workspace_child(
seeded_ids.workspace_id, seeded_ids.child_id
)
year2_rows_after = [r for r in rows_after if r.date.year == _SECOND_YEAR]
assert year2_rows_after, (
"locking the current year's days must not prevent the following "
"year's occurrence from being (re-)funded by the same sync"
)
# And the locked year's own days are exactly what they were before —
# locking must not have disturbed them either.
year1_rows_after = {r.id for r in rows_after if r.date.year == _FIRST_YEAR}
assert year1_rows_after == {r.id for r in year1_rows}