"""Tests for `PlanningService.apply_proposed_days` (POST /api/plan/apply). Covers the contract described in PLANNING_ENGINE_SPEC.md §4: idempotency per (workspace, child, parent, date), collision handling (refuse vs `replace`), validate-the-whole-batch-before-writing, and the balance/eligibility guards. The "nothing written on refusal" assertions check actual repository state (`planned_leave_repo.list(...)`), not just the returned report — that is the property this test file cares about most. """ from datetime import date, datetime from uuid import uuid4 from app.entities.parental_leave import ParentalLeaveFetch, TierData from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository from app.services.planning_service import PlanningService, ProposedDayInput from tests.conftest import SeededIds def _make_fetch( personal_data_uuid, workspace_id, child_id, sjukpenning_left: int = 390, lagsta_left: int = 90, ) -> ParentalLeaveFetch: now = datetime.now() tier = TierData( sjukpenning_total=390, sjukpenning_used=390 - sjukpenning_left, sjukpenning_left=sjukpenning_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=now, total_days=480, days_used=480 - sjukpenning_left - lagsta_left, days_left=sjukpenning_left + lagsta_left, reserved_days_total=90, reserved_days_left=90, transferable_days_left=sjukpenning_left + lagsta_left, dubbeldagar_left=60, tier_data=tier, created_at=now, updated_at=now, ) class TestApplyProposedDaysHappyPath: async def test_apply_fresh_plan_creates_rows( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, seeded_ids: SeededIds, ) -> None: ws, child, parent = ( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, ) days = [ ProposedDayInput(date(2026, 6, 15), parent, child, "vacation"), ProposedDayInput(date(2026, 6, 16), parent, child, "lagsta"), ProposedDayInput(date(2026, 6, 17), parent, child, "sjukpenning"), ] result = await planning_service.apply_proposed_days(ws, days) assert result.accepted is True assert result.created_count == 3 assert result.skipped_identical_count == 0 assert 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 {r.date for r in rows} == {date(2026, 6, 15), date(2026, 6, 16), date(2026, 6, 17)} async def test_apply_skips_free_days( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, seeded_ids: SeededIds, ) -> None: ws, child, parent = ( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, ) days = [ ProposedDayInput(date(2026, 6, 15), parent, child, "vacation"), ProposedDayInput(date(2026, 6, 20), parent, child, "free"), # weekend, not persisted ] result = await planning_service.apply_proposed_days(ws, days) assert result.accepted is True assert result.created_count == 1 assert result.skipped_free_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 len(rows) == 1 assert rows[0].date == date(2026, 6, 15) async def test_apply_is_idempotent( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, seeded_ids: SeededIds, ) -> None: ws, child, parent = ( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, ) days = [ ProposedDayInput(date(2026, 6, 15), parent, child, "vacation"), ProposedDayInput(date(2026, 6, 16), parent, child, "lagsta"), ] first = await planning_service.apply_proposed_days(ws, days) assert first.accepted is True assert first.created_count == 2 second = await planning_service.apply_proposed_days(ws, days) assert second.accepted is True assert second.created_count == 0 assert second.skipped_identical_count == 2 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 len(rows) == 2 # no duplicates class TestApplyProposedDaysCollisions: async def test_collision_without_replace_refused_and_writes_nothing( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, seeded_ids: SeededIds, ) -> None: ws, child, parent = ( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, ) existing = await planning_service.add_planned_day( workspace_id=ws, child_id=child, parent_personal_data_uuid=parent, date=date(2026, 6, 15), day_type="lagsta", ) days = [ ProposedDayInput(date(2026, 6, 15), parent, child, "vacation"), # collides ProposedDayInput(date(2026, 6, 16), parent, child, "vacation"), # would be new ] result = await planning_service.apply_proposed_days(ws, days, replace=False) assert result.accepted is False assert len(result.collisions) == 1 assert result.collisions[0].date == date(2026, 6, 15) assert result.collisions[0].existing_day_type == "lagsta" assert result.collisions[0].proposed_day_type == "vacation" assert result.rejections # named clearly # Nothing written at all — not even the non-colliding 6/16 day. 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 len(rows) == 1 assert rows[0].id == existing.id assert rows[0].day_type == "lagsta" async def test_collision_with_replace_true_overwrites( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, seeded_ids: SeededIds, ) -> None: ws, child, parent = ( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, ) existing = await planning_service.add_planned_day( workspace_id=ws, child_id=child, parent_personal_data_uuid=parent, date=date(2026, 6, 15), day_type="lagsta", ) days = [ ProposedDayInput(date(2026, 6, 15), parent, child, "vacation"), ProposedDayInput(date(2026, 6, 16), parent, child, "vacation"), ] result = await planning_service.apply_proposed_days(ws, days, replace=True) assert result.accepted is True assert result.replaced_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 len(rows) == 2 by_date = {r.date: r for r in rows} assert by_date[date(2026, 6, 15)].day_type == "vacation" assert by_date[date(2026, 6, 15)].id != existing.id # old row gone, new one in its place assert by_date[date(2026, 6, 16)].day_type == "vacation" class TestApplyProposedDaysValidation: async def test_invalid_day_type_rejected_before_any_write( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, seeded_ids: SeededIds, ) -> None: ws, child, parent = ( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, ) days = [ ProposedDayInput(date(2026, 6, 15), parent, child, "vacation"), # would be valid alone ProposedDayInput(date(2026, 6, 16), parent, child, "annual_leave"), # invalid ] result = await planning_service.apply_proposed_days(ws, days) assert result.accepted is False assert any(r.message_key == "planApply.rejection.invalidDayType" 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 == [] async def test_batch_exceeding_remaining_balance_rejected_whole( self, planning_service: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, parental_leave_repo: SQLiteParentalLeaveRepository, seeded_ids: SeededIds, ) -> None: ws, child, parent = ( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, ) await parental_leave_repo.create( _make_fetch(parent, ws, child, sjukpenning_left=1, lagsta_left=90) ) days = [ ProposedDayInput(date(2026, 6, 15), parent, child, "sjukpenning"), ProposedDayInput( date(2026, 6, 16), parent, child, "sjukpenning" ), # only 1 left, asks 2 ] result = await planning_service.apply_proposed_days(ws, days) assert result.accepted is False assert any( r.message_key == "planApply.rejection.insufficientBalance" and r.data["tier"] == "sjukpenning" 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 == []