"""Tests for `PlanningService`'s vacation-day balance validation. Closes the gap where `_validate_remaining_balance` checked `sjukpenning`/ `lagsta` but never `vacation`, letting a caller persist unlimited vacation days. Vacation is budgeted per (parent, calendar year) -- not per (parent, child) like the FP tiers -- and mirrors `AllocationInputLoader.load`'s `VacationBudget` derivation in `allocation_service.py`: available = total_days + saved_days_in - already_used with a year that has no `VacationDays` row defaulting `total_days` to `rules.statutory_vacation_days_per_year` rather than zero ("not entered" must never be read as "no entitlement"). """ from datetime import date, datetime, timedelta from uuid import uuid4 from app.config import settings from app.entities.vacation_days import VacationDays 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 PlanningService, ProposedDayInput from tests.conftest import SeededIds def _make_vacation( personal_data_uuid, workspace_id, year: int, total_days: int, saved_days_in: int = 0, ) -> VacationDays: now = datetime.now() return VacationDays( id=uuid4(), personal_data_uuid=personal_data_uuid, workspace_id=workspace_id, year=year, total_days=total_days, saved_days_in=saved_days_in, created_at=now, updated_at=now, ) class TestVacationBalanceWithEnteredEntitlement: async def test_vacation_within_entitlement_accepted( self, planning_service: PlanningService, vacation_repo: SQLiteVacationDaysRepository, seeded_ids: SeededIds, ) -> None: ws, child, parent = ( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, ) await vacation_repo.create(_make_vacation(parent, ws, 2026, total_days=5)) days = [ProposedDayInput(date(2026, 6, i), parent, child, "vacation") for i in range(1, 6)] result = await planning_service.apply_proposed_days(ws, days) assert result.accepted is True, result.rejections assert result.created_count == 5 async def test_vacation_beyond_entitlement_rejected_whole( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, vacation_repo: SQLiteVacationDaysRepository, seeded_ids: SeededIds, ) -> None: ws, child, parent = ( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, ) await vacation_repo.create(_make_vacation(parent, ws, 2026, total_days=5)) days = [ ProposedDayInput(date(2026, 6, i), parent, child, "vacation") for i in range(1, 7) ] # 6 days, only 5 entitled result = await planning_service.apply_proposed_days(ws, days) assert result.accepted is False assert any( r.message_key == "planApply.rejection.insufficientVacationBalance" and r.data["year"] == 2026 and r.data["parent_personal_data_uuid"] == str(parent) and r.data["needed"] == 6 and r.data["remaining"] == 5 for r in result.rejections ) rows = await planned_leave_repo.list_by_workspace_child( workspace_id=ws, child_id=child, from_date=date(2026, 6, 1), to_date=date(2026, 6, 30) ) assert rows == [], "a rejected batch must write nothing" class TestVacationBalanceWithNoEnteredYear: async def test_missing_year_defaults_to_statutory_entitlement_not_zero( self, planning_service: PlanningService, seeded_ids: SeededIds, ) -> None: """No `VacationDays` row for 2026 at all -- must fall back to `rules.statutory_vacation_days_per_year`, never to zero. """ ws, child, parent = ( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, ) statutory = settings.rules.statutory_vacation_days_per_year exactly_statutory = [ ProposedDayInput(date(2026, 1, 1) + timedelta(days=i), parent, child, "vacation") for i in range(statutory) ] result = await planning_service.apply_proposed_days(ws, exactly_statutory) assert result.accepted is True, result.rejections async def test_missing_year_one_day_over_statutory_default_rejected( self, planning_service: PlanningService, seeded_ids: SeededIds, ) -> None: ws, child, parent = ( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, ) statutory = settings.rules.statutory_vacation_days_per_year one_too_many = [ ProposedDayInput(date(2026, 1, 1) + timedelta(days=i), parent, child, "vacation") for i in range(statutory + 1) ] result = await planning_service.apply_proposed_days(ws, one_too_many) assert result.accepted is False assert any( r.message_key == "planApply.rejection.insufficientVacationBalance" and r.data["year"] == 2026 for r in result.rejections ) class TestVacationBalanceCountsSavedDaysIn: """5 this year's entitlement + 3 carried forward from prior years = 8 available. Each test starts from a fresh (unspent) budget -- unlike `TestVacationBalanceWithEnteredEntitlement` above, these two are kept independent (not one accept-then-reject sequence in a single test) specifically so the second assertion's `remaining` reflects the *original* 8-day budget, not 8 minus whatever a prior call in the same test already persisted. """ async def test_exactly_available_including_saved_days_in_accepted( self, planning_service: PlanningService, vacation_repo: SQLiteVacationDaysRepository, seeded_ids: SeededIds, ) -> None: ws, child, parent = ( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, ) await vacation_repo.create(_make_vacation(parent, ws, 2026, total_days=5, saved_days_in=3)) exactly_available = [ ProposedDayInput(date(2026, 6, i), parent, child, "vacation") for i in range(1, 9) ] result = await planning_service.apply_proposed_days(ws, exactly_available) assert result.accepted is True, result.rejections assert result.created_count == 8 async def test_one_more_than_saved_days_in_allows_rejected( self, planning_service: PlanningService, vacation_repo: SQLiteVacationDaysRepository, seeded_ids: SeededIds, ) -> None: ws, child, parent = ( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, ) await vacation_repo.create(_make_vacation(parent, ws, 2026, total_days=5, saved_days_in=3)) one_more = [ ProposedDayInput(date(2026, 6, i), parent, child, "vacation") for i in range(1, 10) ] result_over = await planning_service.apply_proposed_days(ws, one_more) assert result_over.accepted is False assert any( r.message_key == "planApply.rejection.insufficientVacationBalance" and r.data["needed"] == 9 and r.data["remaining"] == 8 for r in result_over.rejections ) class TestVacationBalanceDeleteKeysCredit: async def test_delete_and_readd_same_count_same_year_accepted( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, vacation_repo: SQLiteVacationDaysRepository, seeded_ids: SeededIds, ) -> None: """A tight 1-day budget already fully spent -- adding one more vacation day would be rejected outright, but replacing (delete + re-add, same count, same year) must be accepted because the deletion is credited back before the new demand is checked. """ ws, child, parent = ( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, ) await vacation_repo.create(_make_vacation(parent, ws, 2026, total_days=1)) old = await planning_service.add_planned_day( workspace_id=ws, child_id=child, parent_personal_data_uuid=parent, date=date(2026, 6, 1), day_type="vacation", ) # Sanity: with the budget already fully spent, a bare addition (no # delete) of one more vacation day in the same year is rejected. bare_addition = [ProposedDayInput(date(2026, 6, 2), parent, child, "vacation")] blocked = await planning_service.apply_proposed_days(ws, bare_addition) assert blocked.accepted is False assert any( r.message_key == "planApply.rejection.insufficientVacationBalance" for r in blocked.rejections ) new_days = [ProposedDayInput(date(2026, 6, 2), parent, child, "vacation")] delete_keys = {(child, parent, date(2026, 6, 1))} result = await planning_service.apply_proposed_days(ws, new_days, delete_keys=delete_keys) assert result.accepted is True, result.rejections assert result.deleted_count == 1 assert result.created_count == 1 rows = await planned_leave_repo.list_by_workspace_child( workspace_id=ws, child_id=child, from_date=date(2026, 6, 1), to_date=date(2026, 6, 30) ) assert {r.date for r in rows} == {date(2026, 6, 2)} assert old.id not in {r.id for r in rows} class TestVacationBalanceNewYearWrap: async def test_wrapping_period_charges_each_year_independently( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, vacation_repo: SQLiteVacationDaysRepository, seeded_ids: SeededIds, ) -> None: """A Jullov-shaped period (20 Dec - 16 Jan) spans two vacation-year budgets. 2026 has ample room (12 days needed, 15 entitled); 2027 is deliberately too tight (16 days needed, only 10 entitled) -- the whole batch must be rejected, and the rejection must name 2027, not 2026 or a summed total across both years. """ ws, child, parent = ( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, ) await vacation_repo.create(_make_vacation(parent, ws, 2026, total_days=15)) await vacation_repo.create(_make_vacation(parent, ws, 2027, total_days=10)) dec_days = [ ProposedDayInput(date(2026, 12, d), parent, child, "vacation") for d in range(20, 32) ] # 12 days jan_days = [ ProposedDayInput(date(2027, 1, d), parent, child, "vacation") for d in range(1, 17) ] # 16 days batch = dec_days + jan_days result = await planning_service.apply_proposed_days(ws, batch) assert result.accepted is False rejections_by_year = { r.data["year"]: r for r in result.rejections if r.message_key == "planApply.rejection.insufficientVacationBalance" } assert 2027 in rejections_by_year assert rejections_by_year[2027].data["needed"] == 16 assert rejections_by_year[2027].data["remaining"] == 10 assert 2026 not in rejections_by_year, "2026 has room and must not be rejected" rows = await planned_leave_repo.list_by_workspace_child( workspace_id=ws, child_id=child, from_date=date(2026, 12, 1), to_date=date(2027, 1, 31) ) assert rows == [], "a rejected batch must write nothing, including the in-budget year" async def test_wrapping_period_accepted_when_both_years_have_room( self, planning_service: PlanningService, vacation_repo: SQLiteVacationDaysRepository, seeded_ids: SeededIds, ) -> None: ws, child, parent = ( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, ) await vacation_repo.create(_make_vacation(parent, ws, 2026, total_days=15)) await vacation_repo.create(_make_vacation(parent, ws, 2027, total_days=20)) dec_days = [ ProposedDayInput(date(2026, 12, d), parent, child, "vacation") for d in range(20, 32) ] jan_days = [ ProposedDayInput(date(2027, 1, d), parent, child, "vacation") for d in range(1, 17) ] batch = dec_days + jan_days result = await planning_service.apply_proposed_days(ws, batch) assert result.accepted is True, result.rejections assert result.created_count == 28