Files
F-r-ldraledigplannerare/backend/tests/conftest.py
T
jacobh2 4b802ba0be Give the analytics overview a per-parent, per-year balance series
Each parent projection now carries one entry per calendar year from
birth to the fourth birthday: vacation earned, carried in, planned,
left, carried out and expiring under Semesterlagen's 20-day floor and
5-year carry-over, plus parental-leave days used and left at year end
with the age-4 clamp applied in the birthday year. The overview sums
them into per-year totals. Frontend types, mapper and fixtures follow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XBRLomdLK9jF7wU7a8AA6H
2026-09-19 19:02:02 +02:00

470 lines
16 KiB
Python

"""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.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.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,
) -> AuthService:
return AuthService(user_repo, personal_data_repo, workspace_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 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,
)