| """ |
| 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 |
|
|
| |
| |
| |
| 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"]') |
| |
| os.environ.setdefault("SENTRY_DSN", "") |
|
|
| import pytest |
| from fastapi import FastAPI |
| from fastapi.testclient import TestClient |
| from sqlalchemy import create_engine |
| from sqlalchemy.orm import Session, sessionmaker |
| from sqlalchemy.pool import StaticPool |
|
|
| |
| from app.core.config import get_settings |
| from app.models.db import Base, User, get_db |
| from app.services.auth import ( |
| create_access_token, |
| generate_clinician_code, |
| hash_password, |
| ) |
|
|
| |
| |
| |
|
|
|
|
| @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, |
| ) |
|
|
| |
| |
| |
| |
| |
| 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() |
| |
| |
| 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()) |
|
|
|
|
| |
| |
| |
|
|
|
|
| @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. |
| """ |
| |
| monkeypatch.setattr("app.models.db.init_db", lambda: None, raising=False) |
|
|
| from main import create_app |
|
|
| application = create_app() |
|
|
| |
| application.router.lifespan_context = _noop_lifespan |
|
|
| |
| |
| |
| |
| |
| 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 |
|
|
| |
| |
| |
| 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) |
|
|
|
|
| |
| |
| |
|
|
|
|
| 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) |
|
|
|
|
| |
| |
| |
|
|
|
|
| @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. |
| """ |
| |
| 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()) |
|
|
| |
| 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 |
|
|