"""Tests for the DayAssertion repository and service. Restructure step 3 (intent vs. derived output — see `~/.claude/plans/please-make-a-plan-cheerful-beacon.md.restructure`). `TestDayAssertionServiceMirroring`/`TestDayAssertionServiceGuards` below cover what step 3b's `DayAssertionService` newly took on from the deleted `PlannedDayExclusionService`/`PlanningService.add_planned_day`: mirroring a write into `planned_leave_day` and enforcing the free-day guard. """ from datetime import date, datetime from uuid import uuid4 import pytest from app.entities.day_assertion import DAY_TYPE_NONE, DayAssertion from app.repositories_impl.sqlite.day_assertion_repository import SQLiteDayAssertionRepository from app.repositories_impl.sqlite.day_override_repository import SQLiteDayOverrideRepository from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository from app.repositories_impl.sqlite.workspace_repository import SQLiteWorkspaceRepository from app.services.day_assertion_service import DayAssertionService from app.utils.errors import NotFoundError, ValidationError from tests.conftest import SeededIds _DAY = date(2027, 2, 3) def _make(seeded: SeededIds, **overrides) -> DayAssertion: base = dict( id=uuid4(), workspace_id=seeded.workspace_id, child_id=seeded.child_id, parent_personal_data_uuid=seeded.personal_data_uuid, date=_DAY, day_type="sjukpenning", is_dubbeldag=False, wish_id=None, created_at=datetime.now(), updated_at=datetime.now(), ) base.update(overrides) return DayAssertion(**base) class TestSQLiteDayAssertionRepository: async def test_create_and_get_by_id( self, day_assertion_repo: SQLiteDayAssertionRepository, seeded_ids: SeededIds ) -> None: entity = _make(seeded_ids) await day_assertion_repo.create(entity) fetched = await day_assertion_repo.get_by_id(entity.id) assert fetched is not None assert fetched.day_type == "sjukpenning" assert fetched.wish_id is None async def test_get_by_natural_key( self, day_assertion_repo: SQLiteDayAssertionRepository, seeded_ids: SeededIds ) -> None: entity = _make(seeded_ids) await day_assertion_repo.create(entity) found = await day_assertion_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 assert ( await day_assertion_repo.get_by_natural_key( seeded_ids.workspace_id, seeded_ids.child_id, uuid4(), _DAY ) is None ) async def test_update_and_delete( self, day_assertion_repo: SQLiteDayAssertionRepository, seeded_ids: SeededIds ) -> None: entity = _make(seeded_ids) await day_assertion_repo.create(entity) entity.day_type = "vacation" entity.is_dubbeldag = True updated = await day_assertion_repo.update(entity.id, entity) assert updated.day_type == "vacation" assert updated.is_dubbeldag is True await day_assertion_repo.delete(entity.id) assert await day_assertion_repo.get_by_id(entity.id) is None with pytest.raises(NotFoundError): await day_assertion_repo.delete(entity.id) with pytest.raises(NotFoundError): await day_assertion_repo.update(entity.id, entity) async def test_delete_by_natural_key_is_idempotent( self, day_assertion_repo: SQLiteDayAssertionRepository, seeded_ids: SeededIds ) -> None: entity = _make(seeded_ids) await day_assertion_repo.create(entity) await day_assertion_repo.delete_by_natural_key( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY ) assert await day_assertion_repo.get_by_id(entity.id) is None # A no-op the second time — never raises. await day_assertion_repo.delete_by_natural_key( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY ) async def test_upsert_creates_then_overwrites( self, day_assertion_repo: SQLiteDayAssertionRepository, seeded_ids: SeededIds ) -> None: first = await day_assertion_repo.upsert(_make(seeded_ids, day_type="sjukpenning")) second = await day_assertion_repo.upsert( _make(seeded_ids, id=uuid4(), day_type="vacation", is_dubbeldag=True) ) # Same natural key -> same underlying row, not a duplicate. assert second.id == first.id rows = await day_assertion_repo.list_by_workspace(seeded_ids.workspace_id) assert len(rows) == 1 assert rows[0].day_type == "vacation" assert rows[0].is_dubbeldag is True async def test_list_by_workspace_is_date_ordered( self, day_assertion_repo: SQLiteDayAssertionRepository, seeded_ids: SeededIds ) -> None: await day_assertion_repo.create(_make(seeded_ids, date=date(2027, 2, 5))) await day_assertion_repo.create(_make(seeded_ids, date=date(2027, 2, 1))) rows = await day_assertion_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 day_assertion_repo.list_by_workspace(uuid4()) == [] async def test_unsupported_filter_is_rejected( self, day_assertion_repo: SQLiteDayAssertionRepository ) -> None: with pytest.raises(ValueError): await day_assertion_repo.list(nonsense=1) class TestDayAssertionService: async def test_assert_day_is_idempotent_by_natural_key( self, day_assertion_service: DayAssertionService, seeded_ids: SeededIds ) -> None: first = await day_assertion_service.assert_day( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY, day_type="lagsta", ) second = await day_assertion_service.assert_day( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY, day_type="vacation", ) assert second.id == first.id rows = await day_assertion_service.list_for_workspace(seeded_ids.workspace_id) assert len(rows) == 1 assert rows[0].day_type == "vacation" async def test_assert_none_is_the_exclusion_shape( self, day_assertion_service: DayAssertionService, seeded_ids: SeededIds ) -> None: created = await day_assertion_service.assert_day( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY, day_type=DAY_TYPE_NONE, ) assert created.day_type == "none" async def test_invalid_day_type_is_rejected( self, day_assertion_service: DayAssertionService, seeded_ids: SeededIds ) -> None: with pytest.raises(ValidationError): await day_assertion_service.assert_day( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY, day_type="not-a-real-type", ) async def test_clear_is_idempotent( self, day_assertion_service: DayAssertionService, seeded_ids: SeededIds ) -> None: await day_assertion_service.assert_day( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY, day_type="lagsta", ) await day_assertion_service.clear( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY ) assert await day_assertion_service.list_for_workspace(seeded_ids.workspace_id) == [] # A no-op the second time. await day_assertion_service.clear( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY ) async def test_get_is_workspace_scoped( self, day_assertion_service: DayAssertionService, seeded_ids: SeededIds ) -> None: created = await day_assertion_service.assert_day( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY, day_type="lagsta", ) with pytest.raises(NotFoundError): await day_assertion_service.get(created.id, workspace_id=uuid4()) assert ( await day_assertion_service.get(created.id, workspace_id=seeded_ids.workspace_id) ).id == created.id class TestDayAssertionServiceMirroring: """Restructure step 3b: `assert_day`/`clear` mirror into `planned_leave_day` immediately — the same "true the moment the call returns" guarantee `PlannedDayExclusionService.exclude()` used to provide, now generalised to every day_type including a concrete pin. """ async def test_assert_day_mirrors_a_concrete_pin_into_planned_leave_day( self, day_assertion_repo: SQLiteDayAssertionRepository, planned_leave_repo: SQLitePlannedLeaveRepository, seeded_ids: SeededIds, ) -> None: service = DayAssertionService(day_assertion_repo, planned_leave_repo=planned_leave_repo) await service.assert_day( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY, day_type="vacation", ) rows = await planned_leave_repo.list_by_workspace_child( seeded_ids.workspace_id, seeded_ids.child_id ) assert len(rows) == 1 assert rows[0].date == _DAY assert rows[0].day_type == "vacation" async def test_reasserting_a_different_day_type_replaces_the_mirrored_row( self, day_assertion_repo: SQLiteDayAssertionRepository, planned_leave_repo: SQLitePlannedLeaveRepository, seeded_ids: SeededIds, ) -> None: service = DayAssertionService(day_assertion_repo, planned_leave_repo=planned_leave_repo) await service.assert_day( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY, day_type="lagsta", ) await service.assert_day( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY, day_type="vacation", ) rows = await planned_leave_repo.list_by_workspace_child( seeded_ids.workspace_id, seeded_ids.child_id ) assert len(rows) == 1 assert rows[0].day_type == "vacation" async def test_asserting_none_deletes_the_mirrored_row( self, day_assertion_repo: SQLiteDayAssertionRepository, planned_leave_repo: SQLitePlannedLeaveRepository, seeded_ids: SeededIds, ) -> None: service = DayAssertionService(day_assertion_repo, planned_leave_repo=planned_leave_repo) await service.assert_day( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY, day_type="vacation", ) await service.assert_day( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY, day_type=DAY_TYPE_NONE, ) rows = await planned_leave_repo.list_by_workspace_child( seeded_ids.workspace_id, seeded_ids.child_id ) assert rows == [] async def test_clear_deletes_the_mirrored_row_too( self, day_assertion_repo: SQLiteDayAssertionRepository, planned_leave_repo: SQLitePlannedLeaveRepository, seeded_ids: SeededIds, ) -> None: service = DayAssertionService(day_assertion_repo, planned_leave_repo=planned_leave_repo) await service.assert_day( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY, day_type="vacation", ) await service.clear( 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 rows == [] # Idempotent — a second clear with nothing left is a no-op. await service.clear( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY ) class TestDayAssertionServiceAtomicity: """Adversarial-review fix #1: `assert_day`/`clear` each do an assertion write followed by a `planned_leave_day` mirror write — two statements that must succeed or fail together. Before the fix, `self._repo.upsert` (or `delete_by_natural_key`) committed immediately and the mirror step ran as its own separate commit(s), so a crash mid-mirror left the assertion table and `planned_leave_day` permanently diverged. These tests inject a failure INTO the mirror step (not just the happy path) and prove the assertion write itself was rolled back with it. """ async def test_failure_during_mirror_leaves_no_assertion_committed( self, day_assertion_service_with_uow: DayAssertionService, day_assertion_repo: SQLiteDayAssertionRepository, planned_leave_repo: SQLitePlannedLeaveRepository, seeded_ids: SeededIds, monkeypatch: pytest.MonkeyPatch, ) -> None: async def boom(*_args: object, **_kwargs: object) -> None: raise RuntimeError("simulated failure mid-mirror") monkeypatch.setattr(planned_leave_repo, "create", boom) with pytest.raises(RuntimeError): await day_assertion_service_with_uow.assert_day( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY, day_type="vacation", ) # Neither half of the write landed — the assertion table and # `planned_leave_day` never diverged. assert ( await day_assertion_repo.get_by_natural_key( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY, ) is None ) assert ( await planned_leave_repo.list_by_workspace_child( seeded_ids.workspace_id, seeded_ids.child_id ) == [] ) async def test_failure_during_clear_mirror_leaves_the_original_assertion_intact( self, day_assertion_service_with_uow: DayAssertionService, day_assertion_repo: SQLiteDayAssertionRepository, planned_leave_repo: SQLitePlannedLeaveRepository, seeded_ids: SeededIds, monkeypatch: pytest.MonkeyPatch, ) -> None: created = await day_assertion_service_with_uow.assert_day( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY, day_type="vacation", ) async def boom(*_args: object, **_kwargs: object) -> None: raise RuntimeError("simulated failure mid-mirror") monkeypatch.setattr(planned_leave_repo, "delete_many", boom) with pytest.raises(RuntimeError): await day_assertion_service_with_uow.clear( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY ) # The assertion delete was rolled back along with the failed mirror # delete — the original assertion (and its mirrored row) survive # untouched, rather than the assertion vanishing while the row it # was meant to keep in step with stays behind. after = await day_assertion_repo.get_by_natural_key( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, _DAY ) assert after is not None assert after.id == created.id rows = await planned_leave_repo.list_by_workspace_child( seeded_ids.workspace_id, seeded_ids.child_id ) assert len(rows) == 1 class TestDayAssertionServiceGuards: """The free-day guard (`app.services.day_guards.reject_illegal_free_day`) is enforced here too now — the same rule `PlanningService. add_planned_day`/`update` used to be the only enforcer of. """ _SATURDAY = date(2027, 2, 6) async def test_vacation_on_a_free_day_is_rejected( self, day_assertion_repo: SQLiteDayAssertionRepository, workspace_repo: SQLiteWorkspaceRepository, day_override_repo: SQLiteDayOverrideRepository, seeded_ids: SeededIds, ) -> None: service = DayAssertionService( day_assertion_repo, workspace_repo=workspace_repo, day_override_repo=day_override_repo ) with pytest.raises(ValidationError): await service.assert_day( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, self._SATURDAY, day_type="vacation", ) async def test_sjukpenning_on_a_free_day_is_allowed_when_workspace_opts_in( self, day_assertion_repo: SQLiteDayAssertionRepository, workspace_repo: SQLiteWorkspaceRepository, day_override_repo: SQLiteDayOverrideRepository, seeded_ids: SeededIds, ) -> None: workspace = await workspace_repo.get_by_id(seeded_ids.workspace_id) assert workspace is not None workspace.take_pl_on_red_days = True await workspace_repo.update(workspace.id, workspace) service = DayAssertionService( day_assertion_repo, workspace_repo=workspace_repo, day_override_repo=day_override_repo ) created = await service.assert_day( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, self._SATURDAY, day_type="sjukpenning", ) assert created.day_type == "sjukpenning" async def test_exclusion_is_always_allowed_on_a_free_day( self, day_assertion_repo: SQLiteDayAssertionRepository, workspace_repo: SQLiteWorkspaceRepository, day_override_repo: SQLiteDayOverrideRepository, seeded_ids: SeededIds, ) -> None: """`day_type='none'` is never a "plan leave here" instruction, so the free-day guard (which only ever governs sjukpenning/lagsta/vacation) must not block it. """ service = DayAssertionService( day_assertion_repo, workspace_repo=workspace_repo, day_override_repo=day_override_repo ) created = await service.assert_day( seeded_ids.workspace_id, seeded_ids.child_id, seeded_ids.personal_data_uuid, self._SATURDAY, day_type=DAY_TYPE_NONE, ) assert created.day_type == DAY_TYPE_NONE