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

355 lines
14 KiB
Python

"""Proves a `DayAssertion` is honoured by `sync_plan` exactly as the legacy
signals it replaces — restructure step 3
(`~/.claude/plans/please-make-a-plan-cheerful-beacon.md.restructure`):
- a pinned day (`PlannedLeaveDay.is_pinned=True`) -> §`TestAssertionActsLikeAPin`.
- a `PlannedDayExclusion` row -> §`TestAssertionActsLikeAnExclusion`.
Both tests deliberately avoid the LEGACY signal they're replacing (no
`is_pinned=True` on the row in the pin test, no `planned_day_exclusion` row in
the exclusion test) so the assertion is proven to work on its own, not merely
alongside a fallback that would have produced the same result anyway. Reuses
the exact scenarios from `test_planning_service_sync.py::
TestSyncNeverOverwritesAPinnedDay` and `test_planned_day_exclusion.py::
TestExclusionSurvivesSync` so the equivalence is provable rather than
freshly invented.
"""
from datetime import date, datetime
from uuid import uuid4
from app.entities.day_assertion import DayAssertion
from app.entities.leave_wish import LeaveWish
from app.entities.planned_leave import PlannedLeaveDay
from app.entities.vacation_days import VacationDays
from app.repositories_impl.sqlite.day_assertion_repository import SQLiteDayAssertionRepository
from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository
from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository
from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository
from app.services.day_assertion_service import DayAssertionService
from app.services.leave_wish_service import LeaveWishService
from app.services.planning_service import PlanningService
from tests.conftest import SeededIds
_DAY = date(2027, 2, 3) # a Wednesday inside the wish window below
def _make_wish(seeded: SeededIds) -> LeaveWish:
"""A fixed, one-week February 2027 window — same shape as the reference
scenarios in test_planning_service_sync.py / test_planned_day_exclusion.py.
"""
return LeaveWish(
id=uuid4(),
workspace_id=seeded.workspace_id,
child_id=seeded.child_id,
name="Sportlov",
window_start_month=2,
window_start_day=1,
window_end_month=2,
window_end_day=7,
target_weeks=1,
first_year=2027,
last_year=None,
priority=1,
parent_mode="either",
parent_personal_data_uuid=None,
window_mode="fixed",
created_at=datetime.now(),
updated_at=datetime.now(),
)
class TestAssertionActsLikeAPin:
async def test_a_concrete_assertion_alone_freezes_the_day_like_is_pinned_would(
self,
planning_service: PlanningService,
planned_leave_repo: SQLitePlannedLeaveRepository,
day_assertion_repo: SQLiteDayAssertionRepository,
vacation_repo: SQLiteVacationDaysRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
# No vacation budget this year -> tier-3 falls through to FP,
# sjukpenning preferred -- so a freshly-funded interior day in this
# window would be 'sjukpenning', never 'lagsta' (see
# TestSyncNeverOverwritesAPinnedDay, the scenario this mirrors).
await vacation_repo.create(
VacationDays(
id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0
)
)
wish = await leave_wish_repo.create(_make_wish(seeded_ids))
# The row is deliberately NOT `is_pinned=True` and NOT ad-hoc
# (`wish_id` points at the real wish) -- under pre-step-3 rules this
# row is exactly the kind `only_pinned_existing` would hide and
# `sync_plan` would freely retype. Only the DayAssertion below should
# be what keeps it frozen.
row = await planned_leave_repo.create(
PlannedLeaveDay(
id=uuid4(),
workspace_id=ws,
child_id=child,
parent_personal_data_uuid=parent,
date=_DAY,
day_type="lagsta", # deliberately NOT what a fresh plan would pick
wish_id=wish.id,
created_at=datetime.now(),
updated_at=datetime.now(),
)
)
await day_assertion_repo.create(
DayAssertion(
id=uuid4(),
workspace_id=ws,
child_id=child,
parent_personal_data_uuid=parent,
date=_DAY,
day_type="lagsta",
wish_id=wish.id,
created_at=datetime.now(),
updated_at=datetime.now(),
)
)
result = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert result.apply_result.accepted is True
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
frozen = next(r for r in rows if r.date == _DAY)
assert frozen.id == row.id
assert frozen.day_type == "lagsta"
# Every OTHER day in the window was free to be funded normally.
other_days = [r for r in rows if r.date != _DAY]
assert other_days, "expected the rest of the window to be funded"
assert all(r.day_type == "sjukpenning" for r in other_days)
# The frozen date never shows up as a changed slot.
assert all(c.date != _DAY for c in result.changes)
class TestAssertionActsLikeAnExclusion:
async def test_a_none_assertion_alone_keeps_the_day_out_with_no_exclusion_row(
self,
planning_service: PlanningService,
planned_leave_repo: SQLitePlannedLeaveRepository,
day_assertion_repo: SQLiteDayAssertionRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
await leave_wish_repo.create(_make_wish(seeded_ids))
first = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert first.apply_result.accepted is True
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
assert _DAY in {r.date for r in rows}, "the planner should place the day to begin with"
# No `PlannedDayExclusion` row anywhere -- only a raw DayAssertion.
await day_assertion_repo.create(
DayAssertion(
id=uuid4(),
workspace_id=ws,
child_id=child,
parent_personal_data_uuid=parent,
date=_DAY,
day_type="none",
created_at=datetime.now(),
updated_at=datetime.now(),
)
)
second = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert second.apply_result.accepted is True, second.apply_result.rejections
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
assert _DAY not in {r.date for r in rows}
# The rest of the week is still planned — only the one day went.
assert {date(2027, 2, 1), date(2027, 2, 2), date(2027, 2, 4), date(2027, 2, 5)} <= {
r.date for r in rows
}
# Not a reported eviction: this is a deliberate removal, not a lost
# priority race — see `_Ledger._excluded_window_dates`'s docstring.
assert not any(c.evicted for c in second.changes)
async def test_clearing_the_exclusion_puts_the_day_back(
self,
planning_service: PlanningService,
planned_leave_repo: SQLitePlannedLeaveRepository,
day_assertion_repo: SQLiteDayAssertionRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""There must be a way back — this is what the undo toast calls.
Ported from the deleted `test_planned_day_exclusion.py::
TestExclusionSurvivesSync.test_un_excluding_puts_the_day_back`, now
exercised through `DayAssertionService.clear` instead of the deleted
`PlannedDayExclusionService.delete`.
"""
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
await leave_wish_repo.create(_make_wish(seeded_ids))
await planning_service.sync_plan(ws, today=date(2027, 1, 1))
service = DayAssertionService(day_assertion_repo, planned_leave_repo=planned_leave_repo)
await service.assert_day(ws, child, parent, _DAY, day_type="none")
await planning_service.sync_plan(ws, today=date(2027, 1, 1))
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
assert _DAY not in {r.date for r in rows}
await service.clear(ws, child, parent, _DAY)
await planning_service.sync_plan(ws, today=date(2027, 1, 1))
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
assert _DAY in {r.date for r in rows}
class TestWishDeleteClearsAssertions:
"""Restructure step 3b — deleting a wish must not strand a permanently
locked slot behind. `LeaveWishService.delete()` clears the `DayAssertion`
at each deleted row's key (see that method's docstring for why this is
unconditional for a concrete assertion): otherwise the assertion
survives with no `planned_leave_day` row behind it (invisible to the
frontend, which only ever lists assertions via the rows/exclusions it
already knows about) while still silently pinning whatever a later sync
or hand-pin tries to place at that same (workspace, child, parent, date).
"""
async def test_delete_clears_the_assertion_and_the_date_is_placeable_again(
self,
planning_service: PlanningService,
leave_wish_service: LeaveWishService,
planned_leave_repo: SQLitePlannedLeaveRepository,
day_assertion_repo: SQLiteDayAssertionRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
wish = await leave_wish_repo.create(_make_wish(seeded_ids))
# A locked/promoted day: a `planned_leave_day` row plus the
# `DayAssertion` that pins it, both attributed to `wish` — exactly
# what `PlanningService._assert_pin` produces for an 'attach'/'pin'
# day-lock action.
row = await planned_leave_repo.create(
PlannedLeaveDay(
id=uuid4(),
workspace_id=ws,
child_id=child,
parent_personal_data_uuid=parent,
date=_DAY,
day_type="vacation",
wish_id=wish.id,
created_at=datetime.now(),
updated_at=datetime.now(),
)
)
await day_assertion_repo.create(
DayAssertion(
id=uuid4(),
workspace_id=ws,
child_id=child,
parent_personal_data_uuid=parent,
date=_DAY,
day_type="vacation",
wish_id=wish.id,
created_at=datetime.now(),
updated_at=datetime.now(),
)
)
await leave_wish_service.delete(wish.id)
assert await day_assertion_repo.get_by_natural_key(ws, child, parent, _DAY) is None
assert await planned_leave_repo.get_by_id(row.id) is None
# A fresh wish covering the same date must be able to place a day
# there — nothing left invisibly pinning the slot.
new_wish = await leave_wish_repo.create(_make_wish(seeded_ids))
result = await planning_service.sync_plan(ws, today=date(2027, 1, 1))
assert result.apply_result.accepted is True, result.apply_result.rejections
rows = await planned_leave_repo.list_by_workspace_child(ws, child)
placed = next((r for r in rows if r.date == _DAY), None)
assert placed is not None, "the freed date must be placeable again by a later sync"
assert placed.wish_id == new_wish.id
async def test_delete_leaves_a_none_exclusion_untouched(
self,
leave_wish_service: LeaveWishService,
planned_leave_repo: SQLitePlannedLeaveRepository,
day_assertion_repo: SQLiteDayAssertionRepository,
leave_wish_repo: SQLiteLeaveWishRepository,
seeded_ids: SeededIds,
) -> None:
"""An exclusion (`day_type='none'`) is a standing "never place leave
here" instruction about the DATE, not about one period — it must
outlive the wish it happens to share a key with (matching the
restructure notes' already-accepted "exclusions outlive the wish
they were made against" behaviour). No current write path actually
produces a `'none'` assertion with a `wish_id` set (a
`planned_leave_day` row's `day_type` CHECK constraint never allows
`'none'`, so 'attach'/'pin' can never assert one) — this proves the
defensive exception in `LeaveWishService._clear_assertions_for`
anyway, in case a future caller does.
"""
ws, child, parent = (
seeded_ids.workspace_id,
seeded_ids.child_id,
seeded_ids.personal_data_uuid,
)
wish = await leave_wish_repo.create(_make_wish(seeded_ids))
row = await planned_leave_repo.create(
PlannedLeaveDay(
id=uuid4(),
workspace_id=ws,
child_id=child,
parent_personal_data_uuid=parent,
date=_DAY,
day_type="vacation",
wish_id=wish.id,
created_at=datetime.now(),
updated_at=datetime.now(),
)
)
await day_assertion_repo.create(
DayAssertion(
id=uuid4(),
workspace_id=ws,
child_id=child,
parent_personal_data_uuid=parent,
date=_DAY,
day_type="none",
wish_id=wish.id,
created_at=datetime.now(),
updated_at=datetime.now(),
)
)
await leave_wish_service.delete(wish.id)
assert await planned_leave_repo.get_by_id(row.id) is None
survivor = await day_assertion_repo.get_by_natural_key(ws, child, parent, _DAY)
assert survivor is not None
assert survivor.day_type == "none"