"""Tests for the PlannedDayExclusion repository, service and router. The core promise, in the user's words: "removing a single day should stay permanent. Even if it's an auto added period day." `TestExclusionSurvivesSync` is that promise — a day removed from a planner-placed period must still be gone after the next `POST /api/plan/sync`, which is precisely what re-derives it today. Follows the DayOverride reference pattern (arrange an entity, exercise the repo/service, assert on the result). """ from datetime import date, datetime from uuid import uuid4 import pytest from httpx import AsyncClient from app.entities.leave_wish import LeaveWish from app.entities.planned_day_exclusion import PlannedDayExclusion from app.entities.planned_leave import PlannedLeaveDay from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository from app.repositories_impl.sqlite.planned_day_exclusion_repository import ( SQLitePlannedDayExclusionRepository, ) from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository from app.services.planned_day_exclusion_service import PlannedDayExclusionService from app.services.planning_service import PlanningService from app.utils.errors import NotFoundError from tests.conftest import SeededIds _DAY = date(2027, 2, 3) # a Wednesday inside the wish window below def _make(seeded: SeededIds, **overrides) -> PlannedDayExclusion: base = dict( id=uuid4(), workspace_id=seeded.workspace_id, child_id=seeded.child_id, parent_personal_data_uuid=seeded.personal_data_uuid, date=_DAY, created_at=datetime.now(), updated_at=datetime.now(), ) base.update(overrides) return PlannedDayExclusion(**base) def _make_wish(seeded: SeededIds) -> LeaveWish: """A fixed, one-week February 2027 window, funded by the seeded parent.""" return LeaveWish( id=uuid4(), workspace_id=seeded.workspace_id, child_id=seeded.child_id, name="Sportlov", window_start_month=2, window_start_day=1, window_end_month=2, window_end_day=7, target_weeks=1, 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(), ) class TestSQLitePlannedDayExclusionRepository: async def test_create_and_get_by_id( self, planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository, seeded_ids: SeededIds, ) -> None: entity = _make(seeded_ids) await planned_day_exclusion_repo.create(entity) fetched = await planned_day_exclusion_repo.get_by_id(entity.id) assert fetched is not None assert fetched.id == entity.id assert fetched.date == _DAY assert fetched.child_id == seeded_ids.child_id assert fetched.parent_personal_data_uuid == seeded_ids.personal_data_uuid async def test_get_by_natural_key( self, planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository, seeded_ids: SeededIds, ) -> None: entity = _make(seeded_ids) await planned_day_exclusion_repo.create(entity) found = await planned_day_exclusion_repo.get_by_natural_key( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY ) assert found is not None assert found.id == entity.id # The parent IS part of the key — a different parent is a different row. other_parent = await planned_day_exclusion_repo.get_by_natural_key( seeded_ids.workspace_id, seeded_ids.child_id, uuid4(), _DAY ) assert other_parent is None other_day = await planned_day_exclusion_repo.get_by_natural_key( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, date(2027, 2, 4), ) assert other_day is None async def test_update_and_delete( self, planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository, seeded_ids: SeededIds, ) -> None: entity = _make(seeded_ids) await planned_day_exclusion_repo.create(entity) entity.date = date(2027, 2, 5) updated = await planned_day_exclusion_repo.update(entity.id, entity) assert updated.date == date(2027, 2, 5) await planned_day_exclusion_repo.delete(entity.id) assert await planned_day_exclusion_repo.get_by_id(entity.id) is None with pytest.raises(NotFoundError): await planned_day_exclusion_repo.delete(entity.id) with pytest.raises(NotFoundError): await planned_day_exclusion_repo.update(entity.id, entity) async def test_list_by_workspace_is_date_ordered( self, planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository, seeded_ids: SeededIds, ) -> None: await planned_day_exclusion_repo.create(_make(seeded_ids, date=date(2027, 2, 5))) await planned_day_exclusion_repo.create(_make(seeded_ids, date=date(2027, 2, 1))) rows = await planned_day_exclusion_repo.list_by_workspace(seeded_ids.workspace_id) assert [r.date for r in rows] == [date(2027, 2, 1), date(2027, 2, 5)] assert await planned_day_exclusion_repo.list_by_workspace(uuid4()) == [] async def test_unsupported_filter_is_rejected( self, planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository, ) -> None: with pytest.raises(ValueError): await planned_day_exclusion_repo.list(nonsense=1) class TestPlannedDayExclusionService: async def test_exclude_is_idempotent( self, planned_day_exclusion_service: PlannedDayExclusionService, seeded_ids: SeededIds, ) -> None: first = await planned_day_exclusion_service.exclude( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY ) second = await planned_day_exclusion_service.exclude( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY ) assert second.id == first.id rows = await planned_day_exclusion_service.list_for_workspace(seeded_ids.workspace_id) assert len(rows) == 1 async def test_exclude_removes_the_planned_day_immediately( self, planned_day_exclusion_service: PlannedDayExclusionService, planned_leave_repo: SQLitePlannedLeaveRepository, seeded_ids: SeededIds, ) -> None: await planned_leave_repo.create( PlannedLeaveDay( id=uuid4(), workspace_id=seeded_ids.workspace_id, child_id=seeded_ids.child_id, parent_personal_data_uuid=seeded_ids.personal_data_uuid, date=_DAY, day_type="sjukpenning", created_at=datetime.now(), updated_at=datetime.now(), ) ) await planned_day_exclusion_service.exclude( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY ) rows = await planned_leave_repo.list_by_workspace_child( seeded_ids.workspace_id, seeded_ids.child_id ) assert [r for r in rows if r.date == _DAY] == [] async def test_get_and_delete_are_workspace_scoped( self, planned_day_exclusion_service: PlannedDayExclusionService, seeded_ids: SeededIds, ) -> None: created = await planned_day_exclusion_service.exclude( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY ) with pytest.raises(NotFoundError): await planned_day_exclusion_service.get(created.id, workspace_id=uuid4()) with pytest.raises(NotFoundError): await planned_day_exclusion_service.delete(created.id, workspace_id=uuid4()) await planned_day_exclusion_service.delete(created.id, workspace_id=seeded_ids.workspace_id) assert await planned_day_exclusion_service.list_for_workspace(seeded_ids.workspace_id) == [] async def test_excluded_keys_shape_matches_the_allocator_input( self, planned_day_exclusion_service: PlannedDayExclusionService, seeded_ids: SeededIds, ) -> None: await planned_day_exclusion_service.exclude( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY ) keys = await planned_day_exclusion_service.get_excluded_keys(seeded_ids.workspace_id) assert keys == frozenset({(seeded_ids.personal_data_uuid, seeded_ids.child_id, _DAY)}) class TestExclusionSurvivesSync: """The core promise: a removed day stays removed across re-syncs.""" async def test_removed_day_is_not_re_derived_by_the_next_sync( self, planning_service: PlanningService, planned_day_exclusion_service: PlannedDayExclusionService, 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(seeded_ids)) first = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) assert first.apply_result.accepted is True rows = await planned_leave_repo.list_by_workspace_child(ws, child) assert _DAY in {r.date for r in rows}, "the planner should place the day to begin with" await planned_day_exclusion_service.exclude(ws, child, parent, _DAY) second = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) assert second.apply_result.accepted is True, second.apply_result.rejections rows = await planned_leave_repo.list_by_workspace_child(ws, child) assert _DAY not in {r.date for r in rows} # The rest of the week is still planned — only the one day went. assert {date(2027, 2, 1), date(2027, 2, 2), date(2027, 2, 4), date(2027, 2, 5)} <= { r.date for r in rows } # ...and a third sync must not quietly bring it back either. third = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) assert third.apply_result.accepted is True rows = await planned_leave_repo.list_by_workspace_child(ws, child) assert _DAY not in {r.date for r in rows} async def test_removal_does_not_report_a_shortfall( self, planning_service: PlanningService, planned_day_exclusion_service: PlannedDayExclusionService, leave_wish_repo: SQLiteLeaveWishRepository, seeded_ids: SeededIds, ) -> None: """A day the user removed is not a day the plan failed to fund. If it were counted as a gap, `sync_plan`'s eviction preview could refuse the very sync that carries out the removal. """ ws, child, parent = ( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, ) await leave_wish_repo.create(_make_wish(seeded_ids)) await planning_service.sync_plan(ws, today=date(2027, 1, 1)) await planned_day_exclusion_service.exclude(ws, child, parent, _DAY) result = await planning_service.sync_plan(ws, today=date(2027, 1, 1)) assert result.apply_result.accepted is True assert result.eviction_preview is not None assert result.eviction_preview.would_evict is False assert [o.shortfall_days for o in result.proposal.outcomes] == [0] async def test_un_excluding_puts_the_day_back( self, planning_service: PlanningService, planned_day_exclusion_service: PlannedDayExclusionService, planned_leave_repo: SQLitePlannedLeaveRepository, leave_wish_repo: SQLiteLeaveWishRepository, seeded_ids: SeededIds, ) -> None: """There must be a way back — this is what the undo toast calls.""" ws, child, parent = ( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, ) await leave_wish_repo.create(_make_wish(seeded_ids)) await planning_service.sync_plan(ws, today=date(2027, 1, 1)) created = await planned_day_exclusion_service.exclude(ws, child, parent, _DAY) await planning_service.sync_plan(ws, today=date(2027, 1, 1)) await planned_day_exclusion_service.delete(created.id, workspace_id=ws) await planning_service.sync_plan(ws, today=date(2027, 1, 1)) rows = await planned_leave_repo.list_by_workspace_child(ws, child) assert _DAY in {r.date for r in rows} class TestPlannedDayExclusionRouter: async def test_create_list_delete_round_trip( self, authed_client: AsyncClient, seeded_ids: SeededIds ) -> None: payload = { "workspace_id": str(seeded_ids.workspace_id), "child_id": str(seeded_ids.child_id), "parent_personal_data_uuid": str(seeded_ids.personal_data_uuid), "date": _DAY.isoformat(), } created = await authed_client.post("/api/planned-day-exclusions", json=payload) assert created.status_code == 201, created.text body = created.json() assert body["date"] == _DAY.isoformat() listed = await authed_client.get( f"/api/planned-day-exclusions/workspace/{seeded_ids.workspace_id}" ) assert listed.status_code == 200 assert [r["id"] for r in listed.json()] == [body["id"]] deleted = await authed_client.delete(f"/api/planned-day-exclusions/{body['id']}") assert deleted.status_code == 204 missing = await authed_client.delete(f"/api/planned-day-exclusions/{body['id']}") assert missing.status_code == 404 async def test_a_foreign_workspace_is_rejected( self, authed_client: AsyncClient, seeded_ids: SeededIds ) -> None: response = await authed_client.post( "/api/planned-day-exclusions", json={ "workspace_id": str(uuid4()), "child_id": str(seeded_ids.child_id), "parent_personal_data_uuid": str(seeded_ids.personal_data_uuid), "date": _DAY.isoformat(), }, ) assert response.status_code == 404 async def test_a_foreign_list_is_rejected(self, authed_client: AsyncClient) -> None: response = await authed_client.get(f"/api/planned-day-exclusions/workspace/{uuid4()}") assert response.status_code == 404