depscreen / tests /conftest.py
halsabbah's picture
deploy: sync code from GitHub main
36b2bff verified
"""
Pytest configuration + fixtures for DepScreen backend.
Strategy
--------
Tests run against an in-memory SQLite DB so they're fast and isolated.
External services (LLM, RAG, email, avatar storage, inference HTTP) are
patched at the service-boundary layer β€” we exercise real route + schema
+ SQL logic, but never a real paid API call.
The FastAPI lifespan (which initializes ML models, Alembic, and the
APScheduler) is replaced with a no-op so `create_app()` doesn't try to
load a 400 MB DistilBERT during `pytest --collect-only`.
Because the pgvector-backed tables (KnowledgeChunk, PatientRAGChunk)
can't be created on SQLite, we skip them in `create_all()`. Tests that
need RAG behavior mock the service entirely, which matches production
behavior on the endpoint level.
"""
from __future__ import annotations
import os
from collections.abc import Generator
from contextlib import asynccontextmanager
from typing import Any
from uuid import uuid4
# ── Environment setup ────────────────────────────────────────────────────────
# Must happen BEFORE importing anything from `app` β€” the settings singleton
# reads these once via pydantic-settings and caches the result.
os.environ.setdefault("DATABASE_URL", "sqlite:///:memory:")
os.environ.setdefault("JWT_SECRET", "test-secret-do-not-use-in-production-" + "x" * 16)
os.environ.setdefault("ENVIRONMENT", "test")
os.environ.setdefault("LLM_API_KEY", "test-key")
os.environ.setdefault("CORS_ORIGINS", '["http://testserver"]')
# Silence Sentry fully during tests
os.environ.setdefault("SENTRY_DSN", "")
import pytest # noqa: E402
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from sqlalchemy import create_engine # noqa: E402
from sqlalchemy.orm import Session, sessionmaker # noqa: E402
from sqlalchemy.pool import StaticPool # noqa: E402
# ── Import app modules (after env vars are set) ──────────────────────────────
from app.core.config import get_settings # noqa: E402
from app.models.db import Base, User, get_db # noqa: E402
from app.services.auth import ( # noqa: E402
create_access_token,
generate_clinician_code,
hash_password,
)
# ─────────────────────────────────────────────────────────────────────────────
# Engine + session fixtures
# ─────────────────────────────────────────────────────────────────────────────
@pytest.fixture(scope="session")
def test_engine():
"""A single in-memory SQLite engine shared across the test session.
StaticPool + shared connection is the canonical pattern for in-memory
SQLite with SQLAlchemy β€” without it, each new connection gets its own
blank DB and fixtures can't share state with the FastAPI app.
"""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
# Create every table EXCEPT those with pgvector/TSVector columns β€”
# SQLite can't handle them. On real Postgres these hold RAG chunks;
# tests that need RAG mock the service, not the storage, so the
# tables are unreachable and safe to skip. This check is dynamic so
# newly added vector-backed tables are skipped automatically.
def _has_vector_cols(table) -> bool:
return any("vector" in str(col.type).lower() or "tsvector" in str(col.type).lower() for col in table.columns)
tables = [t for t in Base.metadata.tables.values() if not _has_vector_cols(t)]
Base.metadata.create_all(bind=engine, tables=tables)
yield engine
engine.dispose()
@pytest.fixture
def db(test_engine) -> Generator[Session, None, None]:
"""A fresh DB session per test, with truncation between tests.
Truncation (not transactional rollback) because FastAPI's route
handlers commit, and rolling back a committed session is a no-op.
Truncating every table between tests keeps isolation cheap.
"""
TestSession = sessionmaker(bind=test_engine, autocommit=False, autoflush=False)
session = TestSession()
try:
yield session
finally:
session.close()
# Truncate everything for the next test (skip vector-backed tables
# that were never created on SQLite)
with test_engine.begin() as conn:
for table in reversed(Base.metadata.sorted_tables):
has_vector_cols = any(
"vector" in str(col.type).lower() or "tsvector" in str(col.type).lower() for col in table.columns
)
if has_vector_cols:
continue
conn.execute(table.delete())
# ─────────────────────────────────────────────────────────────────────────────
# App + client fixtures
# ─────────────────────────────────────────────────────────────────────────────
@asynccontextmanager
async def _noop_lifespan(app: FastAPI):
"""Replace the real lifespan so tests don't load the ML model or
start the APScheduler."""
yield
@pytest.fixture
def app(test_engine, db, monkeypatch) -> FastAPI:
"""Build a FastAPI app wired to the in-memory test DB.
`create_app()` from `main.py` is imported fresh here (after the env is
set) and its lifespan swapped for a no-op. The `get_db` dependency
is overridden to return our session-bound factory.
"""
# Patch heavyweight startup work before create_app runs
monkeypatch.setattr("app.models.db.init_db", lambda: None, raising=False)
from main import create_app
application = create_app()
# Replace lifespan β€” FastAPI reads this via app.router.lifespan_context
application.router.lifespan_context = _noop_lifespan
# Route every `Depends(get_db)` to a session bound to our test engine.
# We deliberately yield a *fresh* session per request (not the
# test-level `db` fixture) because request handlers commit and then
# expect to re-query β€” sharing a single session across the test and
# the request would leak uncommitted state between them.
TestSession = sessionmaker(bind=test_engine, autocommit=False, autoflush=False)
def override_get_db():
session = TestSession()
try:
yield session
finally:
session.close()
application.dependency_overrides[get_db] = override_get_db
# Disable rate limiting so parallel tests don't bump into the 10/min
# auth throttle. slowapi uses IP as the key and TestClient always
# hits from the same synthetic address.
application.state.limiter.enabled = False
yield application
application.dependency_overrides.clear()
@pytest.fixture
def client(app) -> TestClient:
"""Sync test client. Use for every non-streaming route test."""
return TestClient(app)
# ─────────────────────────────────────────────────────────────────────────────
# User factory fixtures
# ─────────────────────────────────────────────────────────────────────────────
def _make_user(
db: Session,
*,
role: str,
email: str | None = None,
password: str = "test-password-123",
full_name: str = "Test User",
clinician_id: str | None = None,
**extra: Any,
) -> User:
"""Insert a user straight into the DB, bypassing the HTTP layer."""
user = User(
id=str(uuid4()),
email=email or f"{uuid4().hex[:8]}@test.local",
password_hash=hash_password(password),
full_name=full_name,
role=role,
is_active=True,
onboarding_completed=True,
clinician_id=clinician_id,
clinician_code=generate_clinician_code() if role == "clinician" else None,
**extra,
)
db.add(user)
db.commit()
db.refresh(user)
return user
@pytest.fixture
def patient_user(db) -> User:
return _make_user(db, role="patient", email="patient@test.local", full_name="Patient Test")
@pytest.fixture
def clinician_user(db) -> User:
return _make_user(db, role="clinician", email="clinician@test.local", full_name="Dr. Test")
@pytest.fixture
def linked_patient(db, clinician_user) -> User:
"""A patient already linked to the clinician_user fixture."""
return _make_user(
db,
role="patient",
email="linked@test.local",
full_name="Linked Patient",
clinician_id=clinician_user.id,
)
def _auth_headers(user: User) -> dict[str, str]:
token = create_access_token(user.id, user.role, get_settings())
return {"Authorization": f"Bearer {token}"}
@pytest.fixture
def patient_headers(patient_user) -> dict[str, str]:
return _auth_headers(patient_user)
@pytest.fixture
def clinician_headers(clinician_user) -> dict[str, str]:
return _auth_headers(clinician_user)
@pytest.fixture
def linked_patient_headers(linked_patient) -> dict[str, str]:
return _auth_headers(linked_patient)
# ─────────────────────────────────────────────────────────────────────────────
# External service mocks
# ─────────────────────────────────────────────────────────────────────────────
@pytest.fixture(autouse=True)
def _silence_external_services(monkeypatch):
"""Block every real network call by default.
Tests that need a specific mocked return value should patch a
narrower target in their own fixture β€” this one just guarantees no
test accidentally hits OpenRouter / Resend / Supabase.
"""
# Email: send_welcome / send_notification / send_screening_due β€” all no-op
from app.services import email as email_module
class _NoopEmail:
def send_welcome(self, *a, **kw):
return None
def send_notification(self, *a, **kw):
return None
def send_screening_due(self, *a, **kw):
return None
def send_appointment_reminder(self, *a, **kw):
return None
def send_care_plan_updated(self, *a, **kw):
return None
def is_configured(self):
return False
monkeypatch.setattr(email_module, "get_email_service", lambda _settings: _NoopEmail())
# Avatar: block real Supabase Storage calls
try:
from app.services import avatar as avatar_module
async def _fake_upload(*a, **kw):
return "https://test.local/avatar.webp"
monkeypatch.setattr(avatar_module, "upload_avatar", _fake_upload, raising=False)
except ImportError:
pass
yield