"""Pytest fixtures. Each test gets a fresh in-memory SQLite connection with the FULL schema applied (all 9 tables), so all entities can be tested in isolation without Alembic. Follow the `vacation_repo` / `vacation_service` pattern to add fixtures for other entities. """ from collections.abc import AsyncIterator from dataclasses import dataclass from datetime import date, datetime from uuid import UUID, uuid4 import aiosqlite import pytest_asyncio from httpx import ASGITransport, AsyncClient from app.db.connection import ensure_schema from app.dependencies import get_connection, get_current_user from app.entities.child import Child from app.entities.personal_data import PersonalData from app.entities.user import User from app.entities.workspace import Workspace from app.main import app from app.repositories_impl.sqlite.child_repository import SQLiteChildRepository from app.repositories_impl.sqlite.day_override_repository import SQLiteDayOverrideRepository from app.repositories_impl.sqlite.leave_wish_repository import SQLiteLeaveWishRepository from app.repositories_impl.sqlite.parental_leave_repository import SQLiteParentalLeaveRepository from app.repositories_impl.sqlite.personal_data_repository import SQLitePersonalDataRepository from app.repositories_impl.sqlite.planned_day_exclusion_repository import ( SQLitePlannedDayExclusionRepository, ) from app.repositories_impl.sqlite.planned_leave_repository import SQLitePlannedLeaveRepository from app.repositories_impl.sqlite.preschool_planning_days_repository import ( SQLitePreschoolPlanningDaysRepository, ) from app.repositories_impl.sqlite.tip_dismissal_repository import SQLiteTipDismissalRepository from app.repositories_impl.sqlite.unit_of_work import SQLiteUnitOfWork from app.repositories_impl.sqlite.user_repository import SQLiteUserRepository from app.repositories_impl.sqlite.vacation_days_repository import ( SQLiteVacationDaysRepository, ) from app.repositories_impl.sqlite.workspace_repository import ( SQLiteWorkspaceInvitationRepository, SQLiteWorkspaceRepository, ) from app.services.allocation_service import AllocationInputLoader, PlanningEngineService from app.services.analytics_service import AnalyticsService from app.services.auth_service import AuthService from app.services.child_service import ChildService from app.services.day_override_service import DayOverrideService from app.services.leave_wish_service import LeaveWishService from app.services.planned_day_exclusion_service import PlannedDayExclusionService from app.services.planning_service import PlanningService from app.services.preschool_planning_days_service import PreschoolPlanningDaysService from app.services.tips_service import TipsService from app.services.vacation_days_service import VacationDaysService from app.services.workspace_service import WorkspaceService @dataclass class SeededIds: workspace_id: UUID personal_data_uuid: UUID child_id: UUID @pytest_asyncio.fixture async def connection() -> AsyncIterator[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() # --------------------------------------------------------------------------- # VacationDays (reference slice — keep unchanged) # --------------------------------------------------------------------------- @pytest_asyncio.fixture async def vacation_repo( connection: aiosqlite.Connection, ) -> SQLiteVacationDaysRepository: return SQLiteVacationDaysRepository(connection) @pytest_asyncio.fixture async def vacation_service( vacation_repo: SQLiteVacationDaysRepository, ) -> VacationDaysService: return VacationDaysService(vacation_repo) # --------------------------------------------------------------------------- # Auth # --------------------------------------------------------------------------- @pytest_asyncio.fixture async def user_repo(connection: aiosqlite.Connection) -> SQLiteUserRepository: return SQLiteUserRepository(connection) @pytest_asyncio.fixture async def personal_data_repo( connection: aiosqlite.Connection, ) -> SQLitePersonalDataRepository: return SQLitePersonalDataRepository(connection) @pytest_asyncio.fixture async def auth_service( user_repo: SQLiteUserRepository, personal_data_repo: SQLitePersonalDataRepository, workspace_repo: SQLiteWorkspaceRepository, invitation_repo: SQLiteWorkspaceInvitationRepository, ) -> AuthService: return AuthService(user_repo, personal_data_repo, workspace_repo, inv_repo=invitation_repo) # --------------------------------------------------------------------------- # Workspace # --------------------------------------------------------------------------- @pytest_asyncio.fixture async def workspace_repo( connection: aiosqlite.Connection, ) -> SQLiteWorkspaceRepository: return SQLiteWorkspaceRepository(connection) @pytest_asyncio.fixture async def invitation_repo( connection: aiosqlite.Connection, ) -> SQLiteWorkspaceInvitationRepository: return SQLiteWorkspaceInvitationRepository(connection) @pytest_asyncio.fixture async def workspace_service( workspace_repo: SQLiteWorkspaceRepository, invitation_repo: SQLiteWorkspaceInvitationRepository, personal_data_repo: SQLitePersonalDataRepository, user_repo: SQLiteUserRepository, leave_wish_repo: SQLiteLeaveWishRepository, planned_leave_repo: SQLitePlannedLeaveRepository, parental_leave_repo: SQLiteParentalLeaveRepository, vacation_repo: SQLiteVacationDaysRepository, ) -> WorkspaceService: return WorkspaceService( workspace_repo, invitation_repo, personal_data_repo, user_repo, leave_wish_repo, planned_leave_repo, parental_leave_repo, vacation_repo, ) # --------------------------------------------------------------------------- # Shared FK seed — used by any test that needs real workspace + personal_data rows # --------------------------------------------------------------------------- @pytest_asyncio.fixture async def seeded_ids( workspace_repo: SQLiteWorkspaceRepository, personal_data_repo: SQLitePersonalDataRepository, child_repo: SQLiteChildRepository, ) -> SeededIds: """Pre-create a workspace + personal_data + child row so FK constraints pass.""" ws = await workspace_repo.create( Workspace(id=uuid4(), name="Test WS", created_at=datetime.now()) ) pd = await personal_data_repo.create( PersonalData( uuid=uuid4(), workspace_id=ws.id, name="Test Parent", created_at=datetime.now() ) ) child = await child_repo.create( Child( id=uuid4(), workspace_id=ws.id, name="Test Child", birth_date=date(2024, 1, 1), created_at=datetime.now(), ) ) return SeededIds(workspace_id=ws.id, personal_data_uuid=pd.uuid, child_id=child.id) # --------------------------------------------------------------------------- # Shared authenticated HTTP client — bound to `seeded_ids`'s workspace. # # T1 authz work: every router now rejects ids outside the caller's workspace, # so a router test's `client` must be authenticated as a real member of # whatever workspace the test operates on. This fixture is that identity for # any router test built around `seeded_ids` (see `tests/unit/test_fetch_data_ # router.py`). Router tests that seed their own bespoke workspace per test # (e.g. `test_plan_router.py`) still define their own local `client`/current- # user fixture bound to THAT workspace instead — a single global identity # can't satisfy both shapes. # --------------------------------------------------------------------------- @pytest_asyncio.fixture async def current_user(seeded_ids: SeededIds) -> User: """A real, logged-in user whose PersonalData is `seeded_ids.personal_data_uuid`.""" return User( id=uuid4(), personal_data_uuid=seeded_ids.personal_data_uuid, email="seeded-user@example.com", hashed_password="not-a-real-hash", created_at=datetime.now(), ) @pytest_asyncio.fixture async def authed_client( connection: aiosqlite.Connection, current_user: User ) -> AsyncIterator[AsyncClient]: """An httpx client against the real app, authenticated as `current_user` and wired to the test's in-memory `connection` — the same technique every router test file uses, consolidated here for tests keyed off `seeded_ids`. """ app.dependency_overrides[get_connection] = lambda: connection app.dependency_overrides[get_current_user] = lambda: current_user try: async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac: yield ac finally: app.dependency_overrides.pop(get_connection, None) app.dependency_overrides.pop(get_current_user, None) # --------------------------------------------------------------------------- # Child & leave # --------------------------------------------------------------------------- @pytest_asyncio.fixture async def child_repo(connection: aiosqlite.Connection) -> SQLiteChildRepository: return SQLiteChildRepository(connection) @pytest_asyncio.fixture async def parental_leave_repo( connection: aiosqlite.Connection, ) -> SQLiteParentalLeaveRepository: return SQLiteParentalLeaveRepository(connection) @pytest_asyncio.fixture async def planned_leave_repo( connection: aiosqlite.Connection, ) -> SQLitePlannedLeaveRepository: return SQLitePlannedLeaveRepository(connection) # --------------------------------------------------------------------------- # LeaveWish # --------------------------------------------------------------------------- @pytest_asyncio.fixture async def leave_wish_repo( connection: aiosqlite.Connection, ) -> SQLiteLeaveWishRepository: return SQLiteLeaveWishRepository(connection) @pytest_asyncio.fixture async def leave_wish_service( leave_wish_repo: SQLiteLeaveWishRepository, planned_leave_repo: SQLitePlannedLeaveRepository, child_repo: SQLiteChildRepository, ) -> LeaveWishService: # `child_repo` lets an open-ended ("until the child turns 4") series # materialise against the child's real birth date; see LeaveWishService. return LeaveWishService(leave_wish_repo, planned_leave_repo, child_repo) # --------------------------------------------------------------------------- # DayOverride # --------------------------------------------------------------------------- @pytest_asyncio.fixture async def day_override_repo( connection: aiosqlite.Connection, ) -> SQLiteDayOverrideRepository: return SQLiteDayOverrideRepository(connection) @pytest_asyncio.fixture async def day_override_service( day_override_repo: SQLiteDayOverrideRepository, ) -> DayOverrideService: return DayOverrideService(day_override_repo) # --------------------------------------------------------------------------- # PlannedDayExclusion (user-removed days — see app/entities/planned_day_exclusion.py) # --------------------------------------------------------------------------- @pytest_asyncio.fixture async def planned_day_exclusion_repo( connection: aiosqlite.Connection, ) -> SQLitePlannedDayExclusionRepository: return SQLitePlannedDayExclusionRepository(connection) @pytest_asyncio.fixture async def planned_day_exclusion_service( planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository, planned_leave_repo: SQLitePlannedLeaveRepository, ) -> PlannedDayExclusionService: return PlannedDayExclusionService(planned_day_exclusion_repo, planned_leave_repo) @pytest_asyncio.fixture async def child_service( child_repo: SQLiteChildRepository, ) -> ChildService: return ChildService(child_repo) @pytest_asyncio.fixture async def analytics_service( parental_leave_repo: SQLiteParentalLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository, child_repo: SQLiteChildRepository, personal_data_repo: SQLitePersonalDataRepository, workspace_repo: SQLiteWorkspaceRepository, vacation_repo: SQLiteVacationDaysRepository, ) -> AnalyticsService: return AnalyticsService( parental_leave_repo, planned_leave_repo, child_repo, personal_data_repo, workspace_repo, vacation_repo, ) @pytest_asyncio.fixture async def allocation_input_loader( workspace_repo: SQLiteWorkspaceRepository, child_repo: SQLiteChildRepository, personal_data_repo: SQLitePersonalDataRepository, parental_leave_repo: SQLiteParentalLeaveRepository, vacation_repo: SQLiteVacationDaysRepository, planned_leave_repo: SQLitePlannedLeaveRepository, leave_wish_repo: SQLiteLeaveWishRepository, day_override_repo: SQLiteDayOverrideRepository, planned_day_exclusion_repo: SQLitePlannedDayExclusionRepository, ) -> AllocationInputLoader: return AllocationInputLoader( workspace_repo=workspace_repo, child_repo=child_repo, personal_data_repo=personal_data_repo, parental_leave_repo=parental_leave_repo, vacation_repo=vacation_repo, planned_leave_repo=planned_leave_repo, leave_wish_repo=leave_wish_repo, day_override_repo=day_override_repo, exclusion_repo=planned_day_exclusion_repo, ) @pytest_asyncio.fixture async def planning_engine_service( allocation_input_loader: AllocationInputLoader, ) -> PlanningEngineService: return PlanningEngineService(allocation_input_loader) @pytest_asyncio.fixture async def planning_service( planned_leave_repo: SQLitePlannedLeaveRepository, analytics_service: AnalyticsService, parental_leave_repo: SQLiteParentalLeaveRepository, vacation_repo: SQLiteVacationDaysRepository, planning_engine_service: PlanningEngineService, leave_wish_repo: SQLiteLeaveWishRepository, ) -> PlanningService: return PlanningService( planned_leave_repo, analytics_service, parental_leave_repo, vacation_repo, planning_engine_service, leave_wish_repo=leave_wish_repo, ) # --------------------------------------------------------------------------- # Unit of work — a real `SQLiteUnitOfWork` bound to the test's shared # `connection`, for tests that specifically exercise atomicity/locking (see # `tests/unit/test_db_unit_of_work.py`). `planning_service` above deliberately # keeps its default no-op unit of work so every other test's per-write-commit # behavior stays exactly as it is; use `planning_service_with_uow` instead # when a test needs the real thing. # --------------------------------------------------------------------------- @pytest_asyncio.fixture async def sqlite_uow(connection: aiosqlite.Connection) -> SQLiteUnitOfWork: return SQLiteUnitOfWork(connection) @pytest_asyncio.fixture async def planning_service_with_uow( planned_leave_repo: SQLitePlannedLeaveRepository, analytics_service: AnalyticsService, parental_leave_repo: SQLiteParentalLeaveRepository, vacation_repo: SQLiteVacationDaysRepository, planning_engine_service: PlanningEngineService, leave_wish_repo: SQLiteLeaveWishRepository, sqlite_uow: SQLiteUnitOfWork, ) -> PlanningService: return PlanningService( planned_leave_repo, analytics_service, parental_leave_repo, vacation_repo, planning_engine_service, leave_wish_repo=leave_wish_repo, uow=sqlite_uow, ) # --------------------------------------------------------------------------- # Tips # --------------------------------------------------------------------------- @pytest_asyncio.fixture async def tip_dismissal_repo( connection: aiosqlite.Connection, ) -> SQLiteTipDismissalRepository: return SQLiteTipDismissalRepository(connection) @pytest_asyncio.fixture async def preschool_planning_days_repo( connection: aiosqlite.Connection, ) -> SQLitePreschoolPlanningDaysRepository: return SQLitePreschoolPlanningDaysRepository(connection) @pytest_asyncio.fixture async def preschool_planning_days_service( preschool_planning_days_repo: SQLitePreschoolPlanningDaysRepository, ) -> PreschoolPlanningDaysService: return PreschoolPlanningDaysService(preschool_planning_days_repo) @pytest_asyncio.fixture async def tips_service( parental_leave_repo: SQLiteParentalLeaveRepository, planned_leave_repo: SQLitePlannedLeaveRepository, child_repo: SQLiteChildRepository, personal_data_repo: SQLitePersonalDataRepository, vacation_repo: SQLiteVacationDaysRepository, leave_wish_repo: SQLiteLeaveWishRepository, tip_dismissal_repo: SQLiteTipDismissalRepository, ) -> TipsService: return TipsService( parental_leave_repo, planned_leave_repo, child_repo, personal_data_repo, vacation_repo, leave_wish_repo, tip_dismissal_repo, )