"""Tests for the PlanningService (planned leave day planning). Demonstrates service-layer testing with external service dependencies. Aim for >=90% coverage on new code (ARCHITECTURE.md ยง10). """ from datetime import date, datetime from uuid import uuid4 import pytest from app.entities.child import Child from app.entities.leave_wish import LeaveWish from app.entities.personal_data import PersonalData from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository from app.services.planning_service import PlanningService from app.utils.errors import NotFoundError, ValidationError from tests.conftest import SeededIds def _make_wish(workspace_id, child_id=None, **overrides) -> LeaveWish: base = dict( id=uuid4(), workspace_id=workspace_id, child_id=child_id, name="Test wish", window_start_month=6, window_start_day=1, window_end_month=6, window_end_day=30, target_weeks=1, recurrence="once", first_year=2026, last_year=None, priority=1, parent_mode="either", parent_personal_data_uuid=None, created_at=datetime.now(), updated_at=datetime.now(), ) base.update(overrides) return LeaveWish(**base) class TestPlanningService: """Test PlanningService business logic.""" async def test_add_planned_day_simple( self, planning_service: PlanningService, seeded_ids: SeededIds, ) -> None: """Add a simple planned day (non-dubbeldag).""" result = await planning_service.add_planned_day( workspace_id=seeded_ids.workspace_id, child_id=seeded_ids.child_id, parent_personal_data_uuid=seeded_ids.personal_data_uuid, date=date(2026, 6, 15), day_type="vacation", is_dubbeldag=False, ) assert result.date == date(2026, 6, 15) assert result.day_type == "vacation" assert result.is_dubbeldag is False async def test_add_planned_day_with_wish_id_and_is_pinned( self, planning_service: PlanningService, leave_wish_repo: SQLiteLeaveWishRepository, seeded_ids: SeededIds, ) -> None: """Round-trips `wish_id`/`is_pinned` through the repository (create + re-fetch), not just the in-memory object the create call returns.""" wish = await leave_wish_repo.create( _make_wish(seeded_ids.workspace_id, seeded_ids.child_id) ) result = await planning_service.add_planned_day( workspace_id=seeded_ids.workspace_id, child_id=seeded_ids.child_id, parent_personal_data_uuid=seeded_ids.personal_data_uuid, date=date(2026, 6, 15), day_type="vacation", wish_id=wish.id, is_pinned=True, ) assert result.wish_id == wish.id assert result.is_pinned is True fetched = await planning_service.get(result.id) assert fetched.wish_id == wish.id assert fetched.is_pinned is True async def test_add_planned_day_without_wish_id_defaults_to_none( self, planning_service: PlanningService, seeded_ids: SeededIds, ) -> None: result = await planning_service.add_planned_day( workspace_id=seeded_ids.workspace_id, child_id=seeded_ids.child_id, parent_personal_data_uuid=seeded_ids.personal_data_uuid, date=date(2026, 6, 15), day_type="vacation", ) assert result.wish_id is None assert result.is_pinned is False async def test_add_double_booking_raises( self, planning_service: PlanningService, seeded_ids: SeededIds, ) -> None: """Adding a second planned day on same date raises ValidationError. Note: This test verifies that the same parent cannot plan the same date twice. The error message was updated to be more accurate: "Parent already has a planned day" instead of the previous generic "A planned day already exists" (which incorrectly rejected different parents planning the same date as a dubbeldag). """ child_id = seeded_ids.child_id await planning_service.add_planned_day( workspace_id=seeded_ids.workspace_id, child_id=child_id, parent_personal_data_uuid=seeded_ids.personal_data_uuid, date=date(2026, 6, 15), day_type="vacation", is_dubbeldag=False, ) # Try to add another on same date with pytest.raises(ValidationError, match="Parent already has a planned day"): await planning_service.add_planned_day( workspace_id=seeded_ids.workspace_id, child_id=child_id, parent_personal_data_uuid=seeded_ids.personal_data_uuid, date=date(2026, 6, 15), day_type="lagsta", is_dubbeldag=False, ) async def test_remove_planned_day( self, planning_service: PlanningService, seeded_ids: SeededIds, ) -> None: """Remove a planned day.""" created = await planning_service.add_planned_day( workspace_id=seeded_ids.workspace_id, child_id=seeded_ids.child_id, parent_personal_data_uuid=seeded_ids.personal_data_uuid, date=date(2026, 6, 15), day_type="vacation", ) await planning_service.remove_planned_day(created.id) with pytest.raises(NotFoundError): await planning_service.get(created.id) async def test_remove_missing_raises( self, planning_service: PlanningService, ) -> None: """Removing a missing planned day raises NotFoundError.""" with pytest.raises(NotFoundError): await planning_service.remove_planned_day(uuid4()) async def test_get_planned_day( self, planning_service: PlanningService, seeded_ids: SeededIds, ) -> None: """Get a planned day by ID.""" created = await planning_service.add_planned_day( workspace_id=seeded_ids.workspace_id, child_id=seeded_ids.child_id, parent_personal_data_uuid=seeded_ids.personal_data_uuid, date=date(2026, 6, 15), day_type="vacation", ) fetched = await planning_service.get(created.id) assert fetched.id == created.id assert fetched.date == date(2026, 6, 15) async def test_get_missing_raises( self, planning_service: PlanningService, ) -> None: """Getting a missing planned day raises NotFoundError.""" with pytest.raises(NotFoundError): await planning_service.get(uuid4()) async def test_list_by_workspace( self, planning_service: PlanningService, seeded_ids: SeededIds, ) -> None: """List all planned days in a workspace.""" ws = seeded_ids.workspace_id child_id = seeded_ids.child_id d1 = await planning_service.add_planned_day( workspace_id=ws, child_id=child_id, parent_personal_data_uuid=seeded_ids.personal_data_uuid, date=date(2026, 6, 15), day_type="vacation", ) d2 = await planning_service.add_planned_day( workspace_id=ws, child_id=child_id, parent_personal_data_uuid=seeded_ids.personal_data_uuid, date=date(2026, 6, 16), day_type="vacation", ) result = await planning_service.list_by_workspace(ws) ids = {d.id for d in result} assert d1.id in ids assert d2.id in ids async def test_list_by_date_range( self, planning_service: PlanningService, seeded_ids: SeededIds, ) -> None: """List planned days within a date range.""" ws = seeded_ids.workspace_id child_id = seeded_ids.child_id await planning_service.add_planned_day( workspace_id=ws, child_id=child_id, parent_personal_data_uuid=seeded_ids.personal_data_uuid, date=date(2026, 6, 15), day_type="vacation", ) await planning_service.add_planned_day( workspace_id=ws, child_id=child_id, parent_personal_data_uuid=seeded_ids.personal_data_uuid, date=date(2026, 6, 16), day_type="vacation", ) result = await planning_service.list_by_date_range( workspace_id=ws, child_id=child_id, start_date=date(2026, 6, 15), end_date=date(2026, 6, 16), ) assert len(result) == 2 async def test_list_by_date_range_empty( self, planning_service: PlanningService, seeded_ids: SeededIds, ) -> None: """Listing with no planned days in range returns empty.""" result = await planning_service.list_by_date_range( workspace_id=seeded_ids.workspace_id, child_id=uuid4(), start_date=date(2026, 6, 15), end_date=date(2026, 6, 16), ) assert result == [] async def test_add_planned_day_different_day_types( self, planning_service: PlanningService, seeded_ids: SeededIds, ) -> None: """Add planned days with different day types.""" ws = seeded_ids.workspace_id child_id = seeded_ids.child_id parent_uuid = seeded_ids.personal_data_uuid vacation = await planning_service.add_planned_day( workspace_id=ws, child_id=child_id, parent_personal_data_uuid=parent_uuid, date=date(2026, 6, 15), day_type="vacation", ) lagsta = await planning_service.add_planned_day( workspace_id=ws, child_id=child_id, parent_personal_data_uuid=parent_uuid, date=date(2026, 6, 16), day_type="lagsta", ) sjuk = await planning_service.add_planned_day( workspace_id=ws, child_id=child_id, parent_personal_data_uuid=parent_uuid, date=date(2026, 6, 17), day_type="sjukpenning", ) assert vacation.day_type == "vacation" assert lagsta.day_type == "lagsta" assert sjuk.day_type == "sjukpenning" async def test_list_by_child( self, planning_service: PlanningService, seeded_ids: SeededIds, ) -> None: """List all planned days for a child.""" child_id = seeded_ids.child_id d1 = await planning_service.add_planned_day( workspace_id=seeded_ids.workspace_id, child_id=child_id, parent_personal_data_uuid=seeded_ids.personal_data_uuid, date=date(2026, 6, 15), day_type="vacation", ) d2 = await planning_service.add_planned_day( workspace_id=seeded_ids.workspace_id, child_id=child_id, parent_personal_data_uuid=seeded_ids.personal_data_uuid, date=date(2026, 6, 16), day_type="lagsta", ) result = await planning_service.list_by_child(child_id) ids = {d.id for d in result} assert d1.id in ids assert d2.id in ids async def test_list_by_child_empty( self, planning_service: PlanningService, ) -> None: """Listing a child with no planned days returns empty.""" result = await planning_service.list_by_child(uuid4()) assert result == [] async def test_update_planned_day_day_type( self, planning_service: PlanningService, seeded_ids: SeededIds, ) -> None: """Update the day_type of a planned day.""" created = await planning_service.add_planned_day( workspace_id=seeded_ids.workspace_id, child_id=seeded_ids.child_id, parent_personal_data_uuid=seeded_ids.personal_data_uuid, date=date(2026, 6, 15), day_type="vacation", ) updated = await planning_service.update( created.id, day_type="lagsta", ) assert updated.day_type == "lagsta" assert updated.date == date(2026, 6, 15) async def test_update_planned_day_is_dubbeldag( self, planning_service: PlanningService, seeded_ids: SeededIds, ) -> None: """Update the is_dubbeldag flag of a planned day.""" created = await planning_service.add_planned_day( workspace_id=seeded_ids.workspace_id, child_id=seeded_ids.child_id, parent_personal_data_uuid=seeded_ids.personal_data_uuid, date=date(2026, 6, 15), day_type="vacation", is_dubbeldag=False, ) updated = await planning_service.update( created.id, is_dubbeldag=True, ) assert updated.is_dubbeldag is True assert updated.day_type == "vacation" async def test_update_planned_day_both_fields( self, planning_service: PlanningService, seeded_ids: SeededIds, ) -> None: """Update both day_type and is_dubbeldag.""" created = await planning_service.add_planned_day( workspace_id=seeded_ids.workspace_id, child_id=seeded_ids.child_id, parent_personal_data_uuid=seeded_ids.personal_data_uuid, date=date(2026, 6, 15), day_type="vacation", is_dubbeldag=False, ) updated = await planning_service.update( created.id, day_type="sjukpenning", is_dubbeldag=True, ) assert updated.day_type == "sjukpenning" assert updated.is_dubbeldag is True async def test_update_planned_day_wish_id_and_is_pinned( self, planning_service: PlanningService, leave_wish_repo: SQLiteLeaveWishRepository, seeded_ids: SeededIds, ) -> None: wish = await leave_wish_repo.create( _make_wish(seeded_ids.workspace_id, seeded_ids.child_id) ) created = await planning_service.add_planned_day( workspace_id=seeded_ids.workspace_id, child_id=seeded_ids.child_id, parent_personal_data_uuid=seeded_ids.personal_data_uuid, date=date(2026, 6, 15), day_type="vacation", ) assert created.wish_id is None updated = await planning_service.update( created.id, wish_id=wish.id, is_pinned=True, ) assert updated.wish_id == wish.id assert updated.is_pinned is True refetched = await planning_service.get(created.id) assert refetched.wish_id == wish.id assert refetched.is_pinned is True async def test_update_missing_raises( self, planning_service: PlanningService, ) -> None: """Updating a missing planned day raises NotFoundError.""" with pytest.raises(NotFoundError): await planning_service.update( uuid4(), day_type="lagsta", ) async def test_add_planned_day_different_parents_same_date_allowed( self, planning_service: PlanningService, personal_data_repo: SQLitePersonalDataRepository, seeded_ids: SeededIds, ) -> None: """Two different parents can plan the same date for the same child (dubbeldagar). This is the legitimate dubbeldagar case: parent A has the date planned, parent B can also plan the same date (subject to dubbeldagar validation). Note: The test uses a non-dubbeldag date for parent A and a dubbeldag date for parent B within the 15-month window. """ child_id = seeded_ids.child_id ws = seeded_ids.workspace_id parent_a_uuid = seeded_ids.personal_data_uuid # Create a second parent parent_b = await personal_data_repo.create( PersonalData( uuid=uuid4(), workspace_id=ws, name="Test Parent B", created_at=datetime.now(), ) ) parent_b_uuid = parent_b.uuid # Parent A plans 2024-10-01 (within dubbeldagar window, but not as a dubbeldag) await planning_service.add_planned_day( workspace_id=ws, child_id=child_id, parent_personal_data_uuid=parent_a_uuid, date=date(2024, 10, 1), day_type="sjukpenning", is_dubbeldag=False, ) # Parent B can also plan the same date (this should succeed) # Parent B marks this as a dubbeldag result = await planning_service.add_planned_day( workspace_id=ws, child_id=child_id, parent_personal_data_uuid=parent_b_uuid, date=date(2024, 10, 1), day_type="lagsta", is_dubbeldag=True, ) assert result.parent_personal_data_uuid == parent_b_uuid assert result.date == date(2024, 10, 1) assert result.is_dubbeldag is True async def test_add_planned_day_same_parent_same_date_rejected( self, planning_service: PlanningService, seeded_ids: SeededIds, ) -> None: """Same parent cannot plan the same date twice (genuine double-booking). This verifies that the fix for parent-aware checking still rejects the genuine double-booking case where the same parent tries to plan the same date twice. """ child_id = seeded_ids.child_id ws = seeded_ids.workspace_id parent_uuid = seeded_ids.personal_data_uuid # Parent plans 2024-09-01 await planning_service.add_planned_day( workspace_id=ws, child_id=child_id, parent_personal_data_uuid=parent_uuid, date=date(2024, 9, 1), day_type="sjukpenning", is_dubbeldag=False, ) # Same parent tries to plan the same date again - should be rejected with pytest.raises(ValidationError, match="already has a planned day"): await planning_service.add_planned_day( workspace_id=ws, child_id=child_id, parent_personal_data_uuid=parent_uuid, date=date(2024, 9, 1), day_type="lagsta", is_dubbeldag=False, ) async def test_add_planned_day_same_date_different_child_allowed( self, planning_service: PlanningService, child_repo: SQLiteChildRepository, seeded_ids: SeededIds, ) -> None: """Same parent can plan the same date for different children. The double-booking check is per (parent, child), so the same parent planning the same date for different children is always allowed. """ ws = seeded_ids.workspace_id parent_uuid = seeded_ids.personal_data_uuid child_1 = seeded_ids.child_id # Create a second child child_2 = await child_repo.create( Child( id=uuid4(), workspace_id=ws, name="Test Child 2", birth_date=date(2024, 6, 1), created_at=datetime.now(), ) ) child_2_id = child_2.id # Parent plans 2024-09-01 for child 1 await planning_service.add_planned_day( workspace_id=ws, child_id=child_1, parent_personal_data_uuid=parent_uuid, date=date(2024, 9, 1), day_type="sjukpenning", is_dubbeldag=False, ) # Same parent can plan the same date for child 2 result = await planning_service.add_planned_day( workspace_id=ws, child_id=child_2_id, parent_personal_data_uuid=parent_uuid, date=date(2024, 9, 1), day_type="lagsta", is_dubbeldag=False, ) assert result.child_id == child_2_id assert result.parent_personal_data_uuid == parent_uuid assert result.date == date(2024, 9, 1)