"""Tests for `PlanningService.sync_plan` (POST /api/plan/sync). Covers the Task 4 contract: propose+apply in one step, a pinned `planned_leave_day` is never retyped/overwritten by a re-plan even when the fresh proposal would have chosen differently for that date, the `changes` diff surfaces each of its four kinds, sync is idempotent on an unchanged plan, and `/propose` stays read-only (persists nothing). """ from datetime import date, datetime, timedelta from uuid import uuid4 from app.entities.day_assertion import DayAssertion from app.entities.leave_wish import LeaveWish from app.entities.parental_leave import ParentalLeaveFetch, TierData from app.entities.personal_data import PersonalData 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.parental_leave_repository import SQLiteParentalLeaveRepository from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository from app.repositories_impl.sqlite.vacation_days_repository import SQLiteVacationDaysRepository from app.services.allocation_service import PlanningEngineService from app.services.planning_service import PlanningService from tests.conftest import SeededIds def _make_wish(workspace_id, child_id, **overrides) -> LeaveWish: base = dict( id=uuid4(), workspace_id=workspace_id, child_id=child_id, name="Test wish", 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(), ) base.update(overrides) return LeaveWish(**base) def _make_fetch(personal_data_uuid, workspace_id, child_id, fetched_at, sjuk_left, lagsta_left): 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 TestSyncNeverOverwritesAPinnedDay: async def test_pinned_day_survives_even_though_fresh_plan_would_differ( 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 -> the monthly-salaried parent's # tier-3 policy (vacation first) falls through to FP, sjukpenning # preferred -- so a free interior day in this window would be # funded as 'sjukpenning', never 'lagsta'. await vacation_repo.create( VacationDays( id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0 ) ) await leave_wish_repo.create(_make_wish(ws, child)) pinned_date = date(2027, 2, 3) # a Wednesday inside the wish's window pinned = await planned_leave_repo.create( PlannedLeaveDay( id=uuid4(), workspace_id=ws, child_id=child, parent_personal_data_uuid=parent, date=pinned_date, day_type="lagsta", # deliberately NOT what a fresh plan would pick created_at=datetime.now(), updated_at=datetime.now(), ) ) # Restructure step 3b: "pinned" is a `DayAssertion` fact now. await day_assertion_repo.create( DayAssertion( id=uuid4(), workspace_id=ws, child_id=child, parent_personal_data_uuid=parent, date=pinned_date, day_type="lagsta", 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) pinned_after = next(r for r in rows if r.date == pinned_date) assert pinned_after.id == pinned.id assert pinned_after.day_type == "lagsta" assert await planning_service.is_pinned(pinned_after) is True # Every OTHER day in the window was free to be funded normally. other_days = [r for r in rows if r.date != pinned_date] assert other_days, "expected the rest of the window to be funded" assert all(r.day_type == "sjukpenning" for r in other_days) # The pinned date never shows up as a changed slot. assert all(c.date != pinned_date for c in result.changes) class TestSyncPreservesPromotedLockedWish: async def test_pinned_wish_attributed_days_untouched_and_debited_once( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, day_assertion_repo: SQLiteDayAssertionRepository, vacation_repo: SQLiteVacationDaysRepository, parental_leave_repo: SQLiteParentalLeaveRepository, leave_wish_repo: SQLiteLeaveWishRepository, seeded_ids: SeededIds, ) -> None: """§promote-to-locked-wish — when the frontend "promotes" an ad-hoc period into a `LeaveWish` via the "locked" option, the existing `planned_leave_day` rows are re-pointed at the new wish (`wish_id` set) while a `DayAssertion` locks each date in place (restructure step 3b — see `PlanningService._assert_pin`). `sync_plan` must treat that state exactly like any other pinned row for the write path (never moved, retyped, reassigned, or deleted, per `TestSyncNeverOverwritesAPinnedDay` above) -- but it must ALSO never double-spend its cost: `_Ledger. _reserve_pinned_days` (allocation_service.py) debits a pinned day off the top unconditionally, and `_fund_solo` separately must recognize those same dates as already-occupied (`_existing_lookup`) and skip funding them again, rather than spending a second unit of pool capacity for a window whose days are already fully paid for. Rigged so the two failure modes are each independently visible: - a moved/retyped/reassigned/deleted pinned row fails the row-level assertions directly; - a double-spend of the sjukpenning pool leaves too little of it for the second, freshly-funded wish below, which would then come back short (fewer than 5 persisted weekdays, or a different day_type) -- exactly what a double debit of `_reserve_pinned_days` plus a second spend during window-funding would cause. """ ws, child, parent = ( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, ) # No vacation budget -> tier-3 falls to FP, sjukpenning preferred # (same reasoning as `TestSyncNeverOverwritesAPinnedDay` above). await vacation_repo.create( VacationDays( id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0 ) ) # Wish A's 7-day window (Mon-Sun) is entirely pre-existing/pinned, so # all 7 calendar days -- including the weekend -- were explicitly # pinned to `sjukpenning` (a deliberate override of what would # otherwise be a free/unpersisted weekend; `_reserve_pinned_days` # debits a pinned day by `day_type` alone, never by whether the # calendar date is otherwise free). Wish B's window is fresh: its # own Sat/Sun are genuinely free (`is_free_day`) and never persisted, # leaving 5 spendable weekdays to fund. Exactly enough sjukpenning # for both (7 + 5 = 12), no spare -- a single extra debit anywhere # is immediately visible as a shortfall on wish B. await parental_leave_repo.create( _make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=12, lagsta_left=0) ) # -- wish A: the "promoted, locked" wish. All 7 of its days are # ALREADY persisted, pinned, and wish_id-attributed to it -- exactly # the state the frontend's "locked" promotion produces, rather than # something sync_plan itself would ever create in one call. wish_a = await leave_wish_repo.create(_make_wish(ws, child, name="Promoted (locked)")) pinned_dates = [date(2027, 2, 1) + timedelta(days=i) for i in range(7)] pinned_rows = [ await planned_leave_repo.create( PlannedLeaveDay( id=uuid4(), workspace_id=ws, child_id=child, parent_personal_data_uuid=parent, date=d, day_type="sjukpenning", wish_id=wish_a.id, created_at=datetime.now(), updated_at=datetime.now(), ) ) for d in pinned_dates ] for d in pinned_dates: await day_assertion_repo.create( DayAssertion( id=uuid4(), workspace_id=ws, child_id=child, parent_personal_data_uuid=parent, date=d, day_type="sjukpenning", wish_id=wish_a.id, created_at=datetime.now(), updated_at=datetime.now(), ) ) # -- wish B: an ordinary, not-yet-funded wish competing for the SAME # pool. Only gets fully funded if wish A's pinned days were debited # exactly once. wish_b = await leave_wish_repo.create( _make_wish( ws, child, name="Fresh B", priority=2, window_start_month=3, window_start_day=1, window_end_month=3, window_end_day=7, ) ) 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) rows_by_id = {r.id: r for r in rows} # -- 1. the pinned, wish-attributed rows: untouched ----------------- assert len(pinned_rows) == 7 for original in pinned_rows: after = rows_by_id.get(original.id) assert after is not None, "a pinned row was deleted by sync_plan" assert after.date == original.date, "a pinned row was moved" assert after.day_type == "sjukpenning", "a pinned row was retyped" assert after.parent_personal_data_uuid == parent, "a pinned row changed parent" assert after.wish_id == wish_a.id, "a pinned row was reassigned to a different wish" assert await planning_service.is_pinned(after) is True # Exactly wish A's 7 original dates carry its wish_id -- no # duplicate/extra row was created for its already-funded window. wish_a_rows = [r for r in rows if r.wish_id == wish_a.id] assert len(wish_a_rows) == 7 assert {r.id for r in wish_a_rows} == {r.id for r in pinned_rows} # None of wish A's dates show up in the sync diff. pinned_dates = {r.date for r in pinned_rows} assert not any(c.date in pinned_dates for c in result.changes) # -- 2. cost debited exactly once ------------------------------------ # 12 sjukpenning days existed: wish A's 7 pre-paid pinned days plus # exactly the 5 weekday days wish B needs, nothing spare. If # `_reserve_pinned_days` debited wish A's pinned days AND the # allocator also funded wish A's (already-occupied) window a second # time, the pool would run dry before wish B and it would come back # short. Getting all 5 of its weekdays funded (its Sat/Sun stay free # and unpersisted, like any fresh window's weekend) proves the debit # happened exactly once. wish_b_rows = [r for r in rows if r.wish_id == wish_b.id] assert len(wish_b_rows) == 5, ( f"expected wish B fully funded from the 5 remaining sjukpenning days, " f"got {len(wish_b_rows)} -- looks like wish A's pinned days were debited twice" ) assert all(r.day_type == "sjukpenning" for r in wish_b_rows) assert {r.date for r in wish_b_rows} == { date(2027, 3, 1), date(2027, 3, 2), date(2027, 3, 3), date(2027, 3, 4), date(2027, 3, 5), } class TestSyncChangeKinds: async def test_added_then_removed_when_the_wish_shrinks( self, planning_service: PlanningService, leave_wish_repo: SQLiteLeaveWishRepository, seeded_ids: SeededIds, ) -> None: """A wish that still exists but now claims fewer dates: the dates it drops (still `wish_id`-attributed, just no longer produced by this run's batch) must be reported (and written) as 'removed' -- distinct from a deleted wish, whose rows the DB itself detaches via `ON DELETE SET NULL` (making them ad-hoc, permanently kept) before `sync_plan` ever sees them. """ ws, child, _parent = ( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, ) wish = await leave_wish_repo.create(_make_wish(ws, child, name="A", window_end_day=7)) # -- first sync: pure 'added' (Feb 1-7) --------------------------- result_1 = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) assert result_1.apply_result.accepted is True assert result_1.changes, "expected at least one 'added' change" assert {c.kind for c in result_1.changes} == {"added"} # -- idempotent re-sync: no changes ------------------------------- result_2 = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) assert result_2.apply_result.accepted is True assert result_2.changes == [] # -- shrink the window (Feb 1-4) -> the dropped tail is 'removed' - wish.window_end_day = 4 await leave_wish_repo.update(wish.id, wish) result_3 = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) assert result_3.apply_result.accepted is True assert result_3.changes, "expected 'removed' changes once the window shrinks" assert {c.kind for c in result_3.changes} == {"removed"} assert all(c.date > date(2027, 2, 4) for c in result_3.changes) async def test_retyped_when_the_underlying_pool_changes( self, planning_service: PlanningService, personal_data_repo: SQLitePersonalDataRepository, parental_leave_repo: SQLiteParentalLeaveRepository, leave_wish_repo: SQLiteLeaveWishRepository, seeded_ids: SeededIds, ) -> None: ws, child = seeded_ids.workspace_id, seeded_ids.child_id # An hourly-salaried parent prefers FP (sjukpenning first) over # vacation for tier-3 interior days -- UNLESS §Task 2's # vacation-forfeiture bias overrides that for a year where vacation # would otherwise be lost. With no `vacation_days` row entered, the # loader defaults this parent's 2027 entitlement to the statutory # 25 days and, since nothing has been used yet, `AnalyticsService. # _vacation_year_series` forecasts most of it as forfeited (the # `min_vacation_days_per_year` floor caps how much can be banked) # -- which would make this test's own assertions about sjukpenning # flakily depend on the forfeiture bias instead of the FP-pool # fallback it's meant to isolate. `min_vacation_days_per_year=0` # means the ENTIRE entitlement is bankable, so the simulation # forecasts zero forfeiture and this parent is deterministically # FP-first, exactly like before §Task 2. parent = await personal_data_repo.create( PersonalData( uuid=uuid4(), workspace_id=ws, name="Hourly Parent", salary_type="hourly", min_vacation_days_per_year=0, ) ) # `parent_mode="specific"` -- with a second (seeded) parent now also # in the workspace, an "either" wish would default to whichever # parent sorts first, not necessarily this one. await leave_wish_repo.create( _make_wish(ws, child, parent_mode="specific", parent_personal_data_uuid=parent.uuid) ) await parental_leave_repo.create( _make_fetch( parent.uuid, ws, child, datetime(2027, 1, 1), sjuk_left=100, lagsta_left=100 ) ) result_1 = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) assert result_1.apply_result.accepted is True assert any(c.new_day_type == "sjukpenning" for c in result_1.changes) # sjukpenning now exhausted -> the SAME dates must fall back to lagsta. await parental_leave_repo.create( _make_fetch(parent.uuid, ws, child, datetime(2027, 1, 2), sjuk_left=0, lagsta_left=100) ) result_2 = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) assert result_2.apply_result.accepted is True assert result_2.changes, "expected 'retyped' changes once sjukpenning ran out" assert {c.kind for c in result_2.changes} == {"retyped"} assert all( c.old_day_type == "sjukpenning" and c.new_day_type == "lagsta" for c in result_2.changes ) async def test_reattributed_when_a_day_moves_to_a_different_wish( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, 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, ) await vacation_repo.create( VacationDays( id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0 ) ) # `old_wish`'s own window doesn't cover `target_date` at all, so it # never competes for that key -- it just needs to be a real, still- # existing wish so the persisted row's `wish_id` stays non-null # (unlike deleting it, which `ON DELETE SET NULL` would turn into an # ad-hoc, permanently-kept row before `sync_plan` ever sees it). old_wish = await leave_wish_repo.create( _make_wish( ws, child, name="Old", window_start_month=3, window_start_day=1, window_end_month=3, window_end_day=7, ) ) new_wish = await leave_wish_repo.create( _make_wish( ws, child, name="New", window_start_month=2, window_start_day=3, window_end_month=2, window_end_day=3, target_days=1, ) ) target_date = date(2027, 2, 3) # A row identical to what `new_wish` would compute for this date # (same date/type), but still attributed to `old_wish`. await planned_leave_repo.create( PlannedLeaveDay( id=uuid4(), workspace_id=ws, child_id=child, parent_personal_data_uuid=parent, date=target_date, day_type="sjukpenning", wish_id=old_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 by_date = {c.date: c for c in result.changes} assert target_date in by_date changed = by_date[target_date] assert changed.kind == "reattributed" assert changed.old_wish_id == old_wish.id assert changed.new_wish_id == new_wish.id assert changed.old_day_type == changed.new_day_type == "sjukpenning" class TestSyncIdempotent: async def test_unchanged_plan_produces_no_changes( self, planning_service: PlanningService, 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(ws, child)) first = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) assert first.apply_result.accepted is True second = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) assert second.apply_result.accepted is True assert second.changes == [] class TestSyncCreditsFreedCapacityWithinSameCall: """Regression for the bug this change fixes: `sync_plan` used to delete stale (shrunk-away) rows only AFTER `apply_proposed_days` had already validated and written the fresh batch, so a wish shrinking never credited the capacity it freed to a same-call growing wish -- a family near their sjukpenning cap who shortened one window and lengthened another would have the whole sync rejected with `insufficientBalance` even though the freed days more than covered the new demand. """ async def test_shrinking_one_window_frees_capacity_for_a_new_one_in_the_same_sync( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, vacation_repo: SQLiteVacationDaysRepository, parental_leave_repo: SQLiteParentalLeaveRepository, 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 -> forces sjukpenning/lagsta funding, so this # test only has to reason about the sjukpenning pool. await vacation_repo.create( VacationDays( id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0 ) ) await parental_leave_repo.create( _make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=390, lagsta_left=90) ) summer = await leave_wish_repo.create( _make_wish( ws, child, name="Summer", window_start_month=6, window_start_day=1, window_end_month=6, window_end_day=30, target_weeks=4, ) ) first = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) assert first.apply_result.accepted is True summer_rows = await planned_leave_repo.list_by_workspace_child( ws, child, from_date=date(2027, 6, 1), to_date=date(2027, 6, 30) ) sjuk_used = sum(1 for r in summer_rows if r.day_type == "sjukpenning") assert sjuk_used > 10, "expected the 4-week summer window to fund several sjukpenning days" # Pin the balance to exactly what's already spent -> zero slack left, # simulating a family sitting right at their sjukpenning cap. await parental_leave_repo.create( _make_fetch( parent, ws, child, datetime(2027, 1, 2), sjuk_left=sjuk_used, lagsta_left=90 ) ) # Shrink summer to one week and add a smaller winter wish elsewhere. # The capacity freed by shrinking summer comfortably covers winter's # demand, but only if the freed days are credited within this call. summer.window_end_day = 7 summer.target_weeks = 1 await leave_wish_repo.update(summer.id, summer) await leave_wish_repo.create( _make_wish( ws, child, name="Winter", window_start_month=12, window_start_day=1, window_end_month=12, window_end_day=7, target_weeks=1, ) ) second = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) assert second.apply_result.accepted is True, second.apply_result.rejections assert not any( r.message_key == "planApply.rejection.insufficientBalance" for r in second.apply_result.rejections ) winter_rows = await planned_leave_repo.list_by_workspace_child( ws, child, from_date=date(2027, 12, 1), to_date=date(2027, 12, 7) ) assert any(r.day_type == "sjukpenning" for r in winter_rows) class TestSyncDisplacement: """`sync_plan`'s displacement handling -- Step 2 of the intent-vs- derived-output restructure made this an informational outcome, never a refusal: `sync_plan` always writes, and a genuinely evicted row (its OWN wish still wants that day but lost the priority race for a shared pool to a DIFFERENT wish's edit -- see `PlanningService._genuinely_evicted_ rows`) comes back as a 'removed' `PlanChange` with `evicted=True` instead of blocking the whole batch (see the fix for the data-loss bug this replaces in this file's module docstring / PLANNING_ENGINE_SPEC.md). The scenarios below use two wishes over the EXACT SAME window (Mon 11 - Fri 15 Jan 2027 -- five plain workdays, no weekend, no Swedish red day in range) so a higher-priority wish's fresh placement claims literally the same dates the lower-priority wish already had persisted, guaranteeing full contention regardless of the exact day-count the allocator computes. """ async def test_over_subscribed_sync_writes_and_reports_displacement( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, vacation_repo: SQLiteVacationDaysRepository, parental_leave_repo: SQLiteParentalLeaveRepository, 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 this year, no lagsta -> the only lever is sjukpenning, # so a shortfall there can never be silently backstopped by anything # else (it must show up as a genuine displacement). await vacation_repo.create( VacationDays( id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0 ) ) await parental_leave_repo.create( _make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=390, lagsta_left=0) ) existing = await leave_wish_repo.create( _make_wish( ws, child, name="Existing", window_start_month=1, window_start_day=11, window_end_month=1, window_end_day=15, priority=5, ) ) first = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) assert first.apply_result.accepted is True rows_after_first = await planned_leave_repo.list_by_workspace_child(ws, child) existing_count = len(rows_after_first) assert existing_count > 0 assert all(r.wish_id == existing.id for r in rows_after_first) assert not any(c.evicted for c in first.changes) # Shrink the shared pool to exactly what `existing` already spent (no # slack), then a higher-priority wish over a DIFFERENT window steals # it -- `existing`'s own window is now entirely unfunded. await parental_leave_repo.create( _make_fetch( parent, ws, child, datetime(2027, 1, 2), sjuk_left=existing_count, lagsta_left=0 ) ) new_wish = await leave_wish_repo.create( _make_wish( ws, child, name="New", window_start_month=2, window_start_day=1, window_end_month=2, window_end_day=5, priority=1, ) ) second = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) # The edit lands -- displacement is informational, never a refusal. assert second.apply_result.accepted is True, second.apply_result.rejections assert second.apply_result.rejections == [] removed = [c for c in second.changes if c.kind == "removed"] assert removed, "expected the crowded-out 'Existing' days to be reported as removed" assert all(c.evicted for c in removed), ( "a row removed because a DIFFERENT wish's edit won the shared pool must be " "flagged evicted=True, not folded in as an ordinary self-caused removal" ) assert all(c.old_wish_id == existing.id for c in removed) assert all(c.old_day_type == "sjukpenning" for c in removed) assert all(c.child_id == child and c.parent_personal_data_uuid == parent for c in removed) added = [c for c in second.changes if c.kind == "added"] assert added, "expected the new wish's days to be reported as added" assert all(c.new_wish_id == new_wish.id for c in added) # The write actually happened: `existing`'s rows are gone, `new`'s # are there. rows_after_second = await planned_leave_repo.list_by_workspace_child(ws, child) assert rows_after_second assert all(r.wish_id == new_wish.id for r in rows_after_second) assert not any(r.wish_id == existing.id for r in rows_after_second) async def test_shortfall_days_is_not_masked_when_a_higher_priority_wish_claims_the_same_dates( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, vacation_repo: SQLiteVacationDaysRepository, parental_leave_repo: SQLiteParentalLeaveRepository, leave_wish_repo: SQLiteLeaveWishRepository, seeded_ids: SeededIds, ) -> None: """Regression for a real bug this displacement-handling work uncovered in `_Ledger._fund_solo` (`app/services/allocation_service.py`): when a higher-priority wish's placement lands on the EXACT SAME calendar dates a lower-priority wish already occupied (as opposed to merely exhausting a shared pool on different dates — the scenario the test above covers), `_fund_solo` used to find those dates already in `_existing_lookup` (via `record_days()`, folded in by the earlier- processed wish this same run) and credit them toward the LATER wish's own `cost`/`shortfall_days` as if THAT wish had gotten them too — so a wish entirely crowded out on every one of its dates could still report `shortfall_days == 0`. Fixed by only crediting cost for dates present in `_existing_lookup` BEFORE this run's own `record_days()` calls started (`_pre_run_occupied`); a same-run claim by a different wish now correctly falls through unfunded. Follow-up fix (this session): because the two wishes here land on the identical (child, parent, date) keys, `sync_plan`'s `stale_keys` (which only looks at keys ABSENT from the fresh batch) never fires for them — the row survives and is metadata-updated to the new wish's `wish_id`, i.e. a 'reattributed' `PlanChange`, not a 'removed' one. That used to bypass the `evicted` flag entirely (same-date collisions between two DIFFERENT wishes were not called out as displacement the way stale-key drops are). `sync_plan` now runs the same "does the row's own wish still have unmet shortfall here" check `_genuinely_evicted_rows` applies to deleted stale rows against 'reattributed' changes too — asserted below. """ ws, child, parent = ( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, ) await vacation_repo.create( VacationDays( id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0 ) ) await parental_leave_repo.create( _make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=390, lagsta_left=0) ) existing = await leave_wish_repo.create( _make_wish( ws, child, name="Existing", window_start_month=1, window_start_day=11, window_end_month=1, window_end_day=15, priority=5, ) ) first = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) assert first.apply_result.accepted is True existing_count = len(await planned_leave_repo.list_by_workspace_child(ws, child)) await parental_leave_repo.create( _make_fetch( parent, ws, child, datetime(2027, 1, 2), sjuk_left=existing_count, lagsta_left=0 ) ) # SAME window as `existing` -- the higher-priority wish's placement # lands on the literal same calendar dates, not merely a shared pool. await leave_wish_repo.create( _make_wish( ws, child, name="New", window_start_month=1, window_start_day=11, window_end_month=1, window_end_day=15, priority=1, ) ) second = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) existing_outcome = next(o for o in second.proposal.outcomes if o.wish_id == existing.id) assert existing_outcome.shortfall_days == existing_count, ( "the crowded-out wish's own outcome must report the real shortfall, " "not silently claim the other wish's days as its own funded total" ) assert existing_outcome.weeks_funded == 0.0 reattributed = [c for c in second.changes if c.kind == "reattributed"] assert len(reattributed) == existing_count, ( "every day the higher-priority wish claimed from `existing` " "should surface as a reattribution, not vanish silently" ) assert all(c.old_wish_id == existing.id for c in reattributed) assert all(c.evicted is True for c in reattributed), ( "a same-date collision that displaces a wish which still wants " "that day must be flagged evicted=True, exactly like a " "different-date eviction would be — a displaced day must never " "go unreported to the user just because it happened to land on " "the identical calendar slot" ) async def test_over_subscribed_vacation_sync_writes_and_displaces_vacation_days( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, vacation_repo: SQLiteVacationDaysRepository, parental_leave_repo: SQLiteParentalLeaveRepository, leave_wish_repo: SQLiteLeaveWishRepository, seeded_ids: SeededIds, ) -> None: """Displacement isn't only a parental-leave-pool phenomenon -- vacation is budgeted per (parent, year) the same way, and an over-subscribed vacation sync must write and report displacement exactly like the sjukpenning case above. """ ws, child, parent = ( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, ) # No FP at all -> the ONLY way either wish's window can be funded is # vacation, so a shortfall here can only ever be a vacation shortage. await parental_leave_repo.create( _make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=0, lagsta_left=0) ) vacation_row = await vacation_repo.create( VacationDays( id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=25 ) ) existing = await leave_wish_repo.create( _make_wish( ws, child, name="Existing", window_start_month=1, window_start_day=11, window_end_month=1, window_end_day=15, priority=5, ) ) first = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) assert first.apply_result.accepted is True rows_after_first = await planned_leave_repo.list_by_workspace_child(ws, child) existing_count = len(rows_after_first) assert existing_count > 0 assert all(r.day_type == "vacation" for r in rows_after_first) # Shrink the shared vacation budget to exactly what `existing` spent. vacation_row.total_days = existing_count await vacation_repo.update(vacation_row.id, vacation_row) new_wish = await leave_wish_repo.create( _make_wish( ws, child, name="New", window_start_month=2, window_start_day=1, window_end_month=2, window_end_day=5, priority=1, ) ) second = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) assert second.apply_result.accepted is True, second.apply_result.rejections removed = [c for c in second.changes if c.kind == "removed"] assert removed, "expected the crowded-out 'Existing' vacation days to be reported" assert all(c.evicted for c in removed) assert all(c.old_day_type == "vacation" for c in removed) assert all(c.old_wish_id == existing.id for c in removed) rows_after = await planned_leave_repo.list_by_workspace_child(ws, child) assert rows_after assert all(r.wish_id == new_wish.id for r in rows_after) assert not any(r.wish_id == existing.id for r in rows_after) async def test_pinned_day_never_displaced( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, day_assertion_repo: SQLiteDayAssertionRepository, vacation_repo: SQLiteVacationDaysRepository, parental_leave_repo: SQLiteParentalLeaveRepository, 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 vacation_repo.create( VacationDays( id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0 ) ) await parental_leave_repo.create( _make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=390, lagsta_left=0) ) await leave_wish_repo.create( _make_wish( ws, child, name="Existing", window_start_month=1, window_start_day=11, window_end_month=1, window_end_day=15, priority=5, ) ) 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 rows to_pin = rows[0] # Restructure step 3b: "pinned" is a `DayAssertion` fact, not an # `update()` parameter — see `PlanningService.update`'s docstring. await day_assertion_repo.create( DayAssertion( id=uuid4(), workspace_id=to_pin.workspace_id, child_id=to_pin.child_id, parent_personal_data_uuid=to_pin.parent_personal_data_uuid, date=to_pin.date, day_type=to_pin.day_type, wish_id=to_pin.wish_id, created_at=datetime.now(), updated_at=datetime.now(), ) ) pinned = to_pin assert await planning_service.is_pinned(pinned) is True await parental_leave_repo.create( _make_fetch(parent, ws, child, datetime(2027, 1, 2), sjuk_left=len(rows), lagsta_left=0) ) await leave_wish_repo.create( _make_wish( ws, child, name="New", window_start_month=2, window_start_day=1, window_end_month=2, window_end_day=5, priority=1, ) ) resolved = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) assert resolved.apply_result.accepted is True, resolved.apply_result.rejections assert all(c.date != pinned.date for c in resolved.changes if c.evicted) rows_after = await planned_leave_repo.list_by_workspace_child(ws, child) survivor = next((r for r in rows_after if r.id == pinned.id), None) assert survivor is not None, "the pinned day must never be displaced" assert await planning_service.is_pinned(survivor) is True assert survivor.date == pinned.date assert survivor.day_type == pinned.day_type class TestSyncNoDisplacementWithinBudget: async def test_plain_sync_has_no_evicted_changes( self, planning_service: PlanningService, leave_wish_repo: SQLiteLeaveWishRepository, seeded_ids: SeededIds, ) -> None: """A save that fits within budget applies straight through: no removed change is ever flagged `evicted` when nothing was displaced. """ ws, child, _parent = ( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, ) await leave_wish_repo.create(_make_wish(ws, child)) result = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) assert result.apply_result.accepted is True assert not any(c.evicted for c in result.changes) class TestProposeStaysReadOnly: async def test_propose_never_writes_a_row( self, planning_engine_service: PlanningEngineService, planned_leave_repo: SQLitePlannedLeaveRepository, 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(ws, child)) proposal = await planning_engine_service.propose(ws, today=date(2027, 1, 1)) assert proposal.days, "expected the proposal to compute something" rows = await planned_leave_repo.list_by_workspace_child(ws, child) assert rows == [] class TestSyncPreservesElapsedAutoPlacedDays: """§the-past-is-fact — a stale key is only ever "self-caused" or "genuinely evicted" when its own wish is still capable of re-claiming the date at all. A date that has already elapsed can never come back from the allocator (`_expand_all_windows`/`_expand_flexible_wish_years` clip every window to `>= today`), so treating it as stale the moment it elapses -- as `sync_plan` did before this fix -- silently deletes already-taken leave on the very next load, since `PlanSyncProvider` (frontend) syncs on every workspace load. Reproduces the reported scenario exactly: a fixed wish for Feb 1-7 2027 synced once with `today=2027-01-01` (creating auto-placed rows), then synced again with `today=2027-02-04` -- Feb 1-3 must survive and must not appear in `changes`. """ async def test_past_autoplaced_rows_survive_later_sync( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, 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, ) await vacation_repo.create( VacationDays( id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0 ) ) await leave_wish_repo.create(_make_wish(ws, child)) # Feb 1-7 2027: Feb 6-7 are a Sat/Sun and stay free/unpersisted (same # reasoning as `TestSyncNeverOverwritesAPinnedDay`), so the wish # auto-places exactly the 5 weekdays Feb 1-5. result_1 = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) assert result_1.apply_result.accepted is True rows_1 = await planned_leave_repo.list_by_workspace_child(ws, child) assert {r.date for r in rows_1} >= {date(2027, 2, i) for i in range(1, 6)} # "Today" advances past Feb 1-3: the allocator's own window clipping # now excludes those three dates from the fresh proposal entirely. result_2 = await planning_service.sync_plan(ws, today=date(2027, 2, 4)) assert result_2.apply_result.accepted is True rows_2 = await planned_leave_repo.list_by_workspace_child(ws, child) rows_2_dates = {r.date for r in rows_2} elapsed = {date(2027, 2, 1), date(2027, 2, 2), date(2027, 2, 3)} assert elapsed <= rows_2_dates, ( f"elapsed rows were deleted by sync: missing {elapsed - rows_2_dates}" ) assert not any(c.date in elapsed for c in result_2.changes), ( f"elapsed rows appeared in changes: " f"{[c for c in result_2.changes if c.date in elapsed]}" ) class TestSyncTodayItselfIsStillReplannable: """The boundary choice pinned: `today` itself is NOT frozen -- only a row dated strictly before `today` is "the past". A row dated exactly `today` whose wish no longer wants it must still be droppable as ordinary self-caused staleness, exactly like any other date the wish stopped claiming. """ async def test_todays_date_still_evicted_when_its_own_wish_drops_it( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, 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, ) 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(ws, child, window_start_day=1, window_end_day=5) ) # Feb 1-5 2027 are all weekdays -> all five auto-placed. result_1 = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) assert result_1.apply_result.accepted is True rows_1 = await planned_leave_repo.list_by_workspace_child(ws, child) assert {r.date for r in rows_1} == {date(2027, 2, i) for i in range(1, 6)} # The wish itself shrinks to drop Feb 1 -- its own edit, not a # different wish stealing the day, so this is ordinary self-caused # staleness, never "genuine eviction". wish.window_start_day = 2 await leave_wish_repo.update(wish.id, wish) # `today` is exactly Feb 1, the date being dropped. If the boundary # were implemented as `<=` (today frozen too) this row would # survive and never show up in `changes`, which is the wrong # reading: today must stay fully re-plannable in the same call. result_2 = await planning_service.sync_plan(ws, today=date(2027, 2, 1)) assert result_2.apply_result.accepted is True rows_2 = await planned_leave_repo.list_by_workspace_child(ws, child) rows_2_dates = {r.date for r in rows_2} assert date(2027, 2, 1) not in rows_2_dates, ( "today's own date was frozen even though its wish dropped it" ) assert any(c.kind == "removed" and c.date == date(2027, 2, 1) for c in result_2.changes), ( "expected today's dropped date to be reported as a 'removed' change" ) # The rest of the (still-claimed) window is untouched. assert {date(2027, 2, i) for i in range(2, 6)} <= rows_2_dates class TestSyncDebitsElapsedDaysFromPools: """§the-past-is-fact, second half — `AllocationInputLoader.load`'s `only_pinned_existing` mode hides every unpinned wish-attributed row so the auto-placed portion can be re-derived, but a row dated before `today` is never re-derived by the allocator at all (see `TestSyncPreservesElapsedAutoPlacedDays`). Left out of the pool netting too, it would be spent capacity the plan still reports as available -- a competing wish would wrongly get funded from days that are already gone. Rigged so any deviation is loud, not a silent off-by-one: the parent's sjukpenning pool (per a stale FK snapshot that predates the already- elapsed commitment) has exactly `elapsed + wish B's demand - 1` days left. Correctly netting the 3 elapsed days leaves exactly 4 -- one short of wish B's 5 weekdays -- so wish B's window must show 4 `sjukpenning` rows and exactly 1 `lagsta` fallback row. If the elapsed days are NOT netted, the pool has enough spare capacity to fund all 5 of wish B's days as `sjukpenning`, and this test fails on the day-type split rather than a fragile total-count check. """ async def test_elapsed_rows_reduce_the_pool_a_competing_wish_can_draw_from( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, vacation_repo: SQLiteVacationDaysRepository, parental_leave_repo: SQLiteParentalLeaveRepository, 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 vacation_repo.create( VacationDays( id=uuid4(), personal_data_uuid=parent, workspace_id=ws, year=2027, total_days=0 ) ) # A wish whose window is entirely in the past by the time `sync_plan` # runs below -- `_expand_all_windows` drops it outright, so nothing # about its own funding matters here. What matters is the 3 rows # already persisted against it: unpinned, wish-attributed, # `sjukpenning`, dated before `today`. elapsed_wish = await leave_wish_repo.create( _make_wish( ws, child, name="Elapsed", window_start_month=1, window_start_day=4, window_end_month=1, window_end_day=6, ) ) elapsed_dates = [date(2027, 1, 4), date(2027, 1, 5), date(2027, 1, 6)] for d in elapsed_dates: await planned_leave_repo.create( PlannedLeaveDay( id=uuid4(), workspace_id=ws, child_id=child, parent_personal_data_uuid=parent, date=d, day_type="sjukpenning", wish_id=elapsed_wish.id, created_at=datetime.now(), updated_at=datetime.now(), ) ) # A stale FK snapshot that predates those 3 already-taken days: # 7 sjukpenning left, plenty of lagsta so a shortfall never rejects # the batch outright, it just falls back to a visibly different # day_type once sjukpenning runs out. await parental_leave_repo.create( _make_fetch(parent, ws, child, datetime(2027, 1, 1), sjuk_left=7, lagsta_left=99) ) # Wish B: 5 weekdays (Mon-Fri), competing for the SAME pool. await leave_wish_repo.create( _make_wish( ws, child, name="Competing", priority=2, window_start_month=3, window_start_day=1, window_end_month=3, window_end_day=5, ) ) result = await planning_service.sync_plan(ws, today=date(2027, 2, 1)) assert result.apply_result.accepted is True rows = await planned_leave_repo.list_by_workspace_child(ws, child) # The elapsed rows themselves must survive untouched (covered in # detail by `TestSyncPreservesElapsedAutoPlacedDays`; asserted here # too since a bug that deletes them would otherwise free the pool # back up and mask the netting bug this test targets). assert {r.date for r in rows if r.date in elapsed_dates} == set(elapsed_dates) march_rows = {r.date: r.day_type for r in rows if r.date.year == 2027 and r.date.month == 3} assert march_rows == { date(2027, 3, 1): "sjukpenning", date(2027, 3, 2): "sjukpenning", date(2027, 3, 3): "sjukpenning", date(2027, 3, 4): "sjukpenning", date(2027, 3, 5): "lagsta", }, ( f"expected the elapsed days to have debited the sjukpenning pool down to " f"4, leaving wish B's 5th weekday to fall back to lagsta; got {march_rows} " f"-- looks like the elapsed rows were not netted out of the pool" )