Files
F-r-ldraledigplannerare/backend/tests/conftest.py
T
jacobh2 782415122d Separate user intent from planner output via day assertions
The calendar mixed two kinds of truth in one table. `planned_leave_day` was
simultaneously the allocator's output cache, the user's override store
(`is_pinned`, plus ad-hoc rows with no `wish_id`), and the resource ledger that
analytics and the age-4 maths read. The planner therefore had to ask "am I
allowed to touch this row?" on every sync, and got it wrong at the edges.

Every user assertion is now one `day_assertion` row — a concrete `day_type`
replaces a pinned day, a row with no `wish_id` replaces an ad-hoc day, and
`day_type='none'` replaces `planned_day_exclusion`. `planned_leave_day` becomes
purely derived with a single writer, and `PlanningService.pinned_keys()` is the
single answer to "is this day pinned", used by the allocator's loader, by
`sync_plan` and by the read router alike.

On top of that, every calendar band is now a real event: an ad-hoc band opens
the same full editor as a wish-backed one and saving it promotes it to a
`LeaveWish`, so it can be named, repeated yearly, assigned to either or both
parents, and handed to the planner. "Planering" is a `moveable` toggle plus a
from/to month bound plus a separate lock/unlock action on the days themselves,
replacing a three-rung ladder whose "planner decides" rung set a search window
equal to the drawn dates and so could never move anything.

Bugs found and fixed along the way:

- The plan erased its own history. Fixed windows clip to today, so an elapsed
  auto-placed day fell into `stale_keys` and was deleted on the next sync —
  and a background sync runs on every workspace load. Past rows are now frozen
  and counted as spent.
- Migration 010's downgrade silently destroyed every pinned day and exclusion
  (`is_pinned` returned as DEFAULT 0, the exclusion table came back empty,
  exit 0, no warning). It now reconstructs both from `day_assertion`, and 009
  itemises what it cannot recover before dropping the table.
- Deleting a period left its assertions behind, locking those dates forever
  with no UI path to clear them. Wish deletion now clears them by `wish_id`.
- `DayAssertionService` and `LeaveWishService` committed multi-statement writes
  without a transaction; a crash mid-write diverged the tables or half-deleted
  a series. Both now run in a unit of work.
- A sibling year's shortfall was never surfaced: the toast only inspected the
  edited occurrence's outcome, so a fully unfunded later year rendered as an
  empty band with no warning.
- A flexible window whose bound had entirely elapsed was dropped from the
  proposal with no outcome at all. It now reports as `unplaced`.
- Unlocking a period deleted its days instead of returning them to the planner.
- The e2e global setup posted to a route deleted in this change, which would
  have failed the whole suite before its first test.

Gates: backend 938 tests / 91% coverage, frontend 1351 / 97.4 | 92.2 | 94.37 |
97.4. Migrations 009 and 010 audited against a hand-built realistic database:
the upgrade reconciles exactly and a constraint violation fails safe, leaving
the schema at 008.

Not complete. Step 4's toggle, month picker and lock/unlock action are verified
by browser walk and unit tests, but the partial-lock readout was not exercised.
`ideas.md` §7 adds a full plan for the vacation-forfeiture defect (the planner
detects expiring days and then does nothing about them) — not implemented here.
Remaining smaller items are listed in the session's restructure notes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T4wjgbVL4HAC5Rdaw75VbV
2026-09-21 07:20:14 +02:00

531 lines
18 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_assertion_repository import SQLiteDayAssertionRepository
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_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_assertion_service import DayAssertionService
from app.services.day_override_service import DayOverrideService
from app.services.leave_wish_service import LeaveWishService
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,
day_assertion_repo: SQLiteDayAssertionRepository,
) -> LeaveWishService:
# `child_repo` lets an open-ended ("until the child turns 4") series
# materialise against the child's real birth date; see LeaveWishService.
# `day_assertion_repo` lets `delete()` clear a pinned/promoted row's
# assertion alongside the row itself (restructure step 3b).
return LeaveWishService(leave_wish_repo, planned_leave_repo, child_repo, day_assertion_repo)
@pytest_asyncio.fixture
async def leave_wish_service_with_uow(
leave_wish_repo: SQLiteLeaveWishRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
child_repo: SQLiteChildRepository,
day_assertion_repo: SQLiteDayAssertionRepository,
sqlite_uow: SQLiteUnitOfWork,
) -> LeaveWishService:
"""A `LeaveWishService` wired with a real `SQLiteUnitOfWork` — for tests
exercising `create`/`update`/`delete`'s multi-row atomicity (this
service previously had none at all). See `planning_service_with_uow`
above for the same pattern.
"""
return LeaveWishService(
leave_wish_repo, planned_leave_repo, child_repo, day_assertion_repo, uow=sqlite_uow
)
# ---------------------------------------------------------------------------
# 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)
# ---------------------------------------------------------------------------
# DayAssertion (restructure step 3 — see app/entities/day_assertion.py)
# ---------------------------------------------------------------------------
@pytest_asyncio.fixture
async def day_assertion_repo(
connection: aiosqlite.Connection,
) -> SQLiteDayAssertionRepository:
return SQLiteDayAssertionRepository(connection)
@pytest_asyncio.fixture
async def day_assertion_service(
day_assertion_repo: SQLiteDayAssertionRepository,
) -> DayAssertionService:
return DayAssertionService(day_assertion_repo)
@pytest_asyncio.fixture
async def day_assertion_service_with_uow(
day_assertion_repo: SQLiteDayAssertionRepository,
planned_leave_repo: SQLitePlannedLeaveRepository,
sqlite_uow: SQLiteUnitOfWork,
) -> DayAssertionService:
"""A `DayAssertionService` wired with mirroring AND a real `SQLiteUnitOfWork`
— for tests that specifically exercise the assert-then-mirror atomicity
fix (a failure mid-mirror must leave no assertion committed). See
`planning_service_with_uow` above for the same pattern.
"""
return DayAssertionService(
day_assertion_repo,
planned_leave_repo=planned_leave_repo,
uow=sqlite_uow,
)
@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,
day_assertion_repo: SQLiteDayAssertionRepository,
) -> 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,
day_assertion_repo=day_assertion_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_service: LeaveWishService,
day_assertion_repo: SQLiteDayAssertionRepository,
) -> PlanningService:
return PlanningService(
planned_leave_repo,
analytics_service,
parental_leave_repo,
vacation_repo,
planning_engine_service,
leave_wish_service=leave_wish_service,
day_assertion_repo=day_assertion_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_service: LeaveWishService,
sqlite_uow: SQLiteUnitOfWork,
day_assertion_repo: SQLiteDayAssertionRepository,
) -> PlanningService:
return PlanningService(
planned_leave_repo,
analytics_service,
parental_leave_repo,
vacation_repo,
planning_engine_service,
uow=sqlite_uow,
leave_wish_service=leave_wish_service,
day_assertion_repo=day_assertion_repo,
)
# ---------------------------------------------------------------------------
# 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,
)