"""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}