"""Tests for the unit-of-work mechanism (`app.db.connection`) and the batched `PlannedLeaveRepository` methods it exists to support. See `scratchpad/audit/backend-perf.md` §3: the app runs on one shared connection, so a multi-statement write needs (a) mutual exclusion for the whole unit of work and (b) the per-row `commit()` calls suppressed while one is in progress. These tests exercise that mechanism directly against an in-memory connection, plus the `PlanningService.apply_proposed_days` atomicity it is meant to guarantee end-to-end. """ import asyncio from datetime import date, datetime from uuid import uuid4 import aiosqlite import pytest from app.db.connection import commit, ensure_schema, unit_of_work from app.entities.leave_wish import LeaveWish from app.entities.planned_leave import PlannedLeaveDay from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository from app.services.planning_service import PlanningService, ProposedDayInput from tests.conftest import SeededIds @pytest.fixture async def uow_connection() -> aiosqlite.Connection: conn = await aiosqlite.connect(":memory:") conn.row_factory = aiosqlite.Row await conn.execute("PRAGMA foreign_keys = ON;") await ensure_schema(conn) try: yield conn finally: await conn.close() async def _workspace_count(conn: aiosqlite.Connection) -> int: cursor = await conn.execute("SELECT COUNT(*) FROM workspace") row = await cursor.fetchone() return int(row[0]) async def _insert_workspace(conn: aiosqlite.Connection, workspace_id: str) -> None: await conn.execute( "INSERT INTO workspace (id, name, created_at) VALUES (?, ?, ?)", (workspace_id, "Test", datetime.now().isoformat()), ) class TestUnitOfWork: async def test_commits_on_success(self, uow_connection: aiosqlite.Connection) -> None: async with unit_of_work(uow_connection): await _insert_workspace(uow_connection, "ws-commit") assert await _workspace_count(uow_connection) == 1 async def test_rolls_back_on_exception(self, uow_connection: aiosqlite.Connection) -> None: with pytest.raises(ValueError): async with unit_of_work(uow_connection): await _insert_workspace(uow_connection, "ws-rollback") raise ValueError("simulated failure") assert await _workspace_count(uow_connection) == 0 async def test_commit_helper_is_noop_inside_uow( self, uow_connection: aiosqlite.Connection ) -> None: """`commit()` must not durably commit inside a unit of work — otherwise a later rollback in the same block could only undo writes made after that no-op `commit()` call, not before it (see backend-perf audit §3, "Is BEGIN/COMMIT ... safe on the shared connection? No, not alone."). """ with pytest.raises(RuntimeError): async with unit_of_work(uow_connection): await _insert_workspace(uow_connection, "ws-noop-commit") await commit(uow_connection) # must be a no-op here raise RuntimeError("still rolls back everything, including the row above") assert await _workspace_count(uow_connection) == 0 async def test_concurrent_calls_serialize(self, uow_connection: aiosqlite.Connection) -> None: """Two concurrent `unit_of_work()` blocks on the same connection never interleave — the second blocks at entry until the first fully exits. """ events: list[str] = [] async def worker(name: str, hold: float) -> None: async with unit_of_work(uow_connection): events.append(f"{name}-start") await asyncio.sleep(hold) events.append(f"{name}-end") await asyncio.gather(worker("a", 0.05), worker("b", 0)) assert events in ( ["a-start", "a-end", "b-start", "b-end"], ["b-start", "b-end", "a-start", "a-end"], ) class TestPlannedLeaveRepositoryBatchMethods: async def test_create_many_then_delete_many( self, planned_leave_repo: SQLitePlannedLeaveRepository, seeded_ids: SeededIds, ) -> None: now = datetime.now() entities = [ 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=date(2026, 3, day), day_type="sjukpenning", created_at=now, updated_at=now, ) for day in (1, 2, 3) ] await planned_leave_repo.create_many(entities) rows = await planned_leave_repo.list(workspace_id=seeded_ids.workspace_id) assert len(rows) == 3 deleted = await planned_leave_repo.delete_many([entities[0].id, entities[1].id]) assert deleted == 2 remaining = await planned_leave_repo.list(workspace_id=seeded_ids.workspace_id) assert [r.id for r in remaining] == [entities[2].id] async def test_create_many_empty_list_is_noop( self, planned_leave_repo: SQLitePlannedLeaveRepository, seeded_ids: SeededIds, ) -> None: await planned_leave_repo.create_many([]) assert await planned_leave_repo.list(workspace_id=seeded_ids.workspace_id) == [] async def test_delete_many_is_idempotent( self, planned_leave_repo: SQLitePlannedLeaveRepository ) -> None: """Unlike `delete()`, a missing id is silently ignored rather than raising.""" deleted = await planned_leave_repo.delete_many([uuid4(), uuid4()]) assert deleted == 0 async def test_update_metadata_many( self, planned_leave_repo: SQLitePlannedLeaveRepository, leave_wish_repo: SQLiteLeaveWishRepository, seeded_ids: SeededIds, ) -> None: now = datetime.now() entity = 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=date(2026, 4, 1), day_type="vacation", created_at=now, updated_at=now, ) await planned_leave_repo.create(entity) wish = await leave_wish_repo.create( LeaveWish( id=uuid4(), workspace_id=seeded_ids.workspace_id, child_id=seeded_ids.child_id, name="Test wish", window_start_month=4, window_start_day=1, window_end_month=4, window_end_day=30, target_weeks=1, first_year=2026, last_year=None, priority=1, parent_mode="either", parent_personal_data_uuid=None, window_mode="fixed", created_at=now, updated_at=now, ) ) await planned_leave_repo.update_metadata_many([(entity.id, wish.id)]) refreshed = await planned_leave_repo.get_by_id(entity.id) assert refreshed is not None assert refreshed.wish_id == wish.id # day_type/is_dubbeldag are untouched by the metadata-only update. assert refreshed.day_type == "vacation" class TestApplyProposedDaysAtomicity: async def test_mid_batch_failure_leaves_nothing_persisted( self, planning_service_with_uow: PlanningService, planned_leave_repo: SQLitePlannedLeaveRepository, seeded_ids: SeededIds, monkeypatch: pytest.MonkeyPatch, ) -> None: """A batch that replaces an existing row (delete + create, both inside stage 4) must not leave the delete applied if the create half fails — the whole `async with self._uow():` block must roll back together. Uses `planning_service_with_uow` (a real `SQLiteUnitOfWork`), not the default `planning_service` fixture, which wires a no-op unit of work and would (correctly, given a no-op) leave the delete committed. """ existing = await planning_service_with_uow.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, 5, 1), day_type="sjukpenning", ) async def boom(_items: list[PlannedLeaveDay]) -> None: raise RuntimeError("simulated mid-batch failure") monkeypatch.setattr(planned_leave_repo, "create_many", boom) days = [ ProposedDayInput( date=date(2026, 5, 1), parent_personal_data_uuid=seeded_ids.personal_data_uuid, child_id=seeded_ids.child_id, day_type="lagsta", # differs from the existing row -> collision -> to_replace ) ] with pytest.raises(RuntimeError): await planning_service_with_uow.apply_proposed_days( workspace_id=seeded_ids.workspace_id, days=days, replace=True ) remaining = await planned_leave_repo.list(workspace_id=seeded_ids.workspace_id) assert len(remaining) == 1 assert remaining[0].id == existing.id assert remaining[0].day_type == "sjukpenning"