"""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 from uuid import uuid4 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.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, recurrence="once", 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, 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 is_pinned=True, 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 pinned_after.is_pinned 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 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, so this is deterministic # regardless of any assumed vacation default. parent = await personal_data_repo.create( PersonalData(uuid=uuid4(), workspace_id=ws, name="Hourly Parent", salary_type="hourly") ) # `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, is_pinned=False, 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 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 == []