feat(backend): add async sqlalchemy session wiring and db health check

This commit is contained in:
copilotcoder
2026-06-18 13:52:34 -04:00
parent d02039a22e
commit 6a36ea7e96
7 changed files with 909 additions and 424 deletions

View File

@@ -34,6 +34,8 @@ classifiers = [
]
dependencies = [
"fastapi==0.120.2",
"sqlalchemy==2.0.44",
"psycopg[binary]==3.2.12",
"uvicorn==0.38.0"
]
description = "Backend service for Plex playlist management"

View File

@@ -0,0 +1,112 @@
"""Database engine and session dependency wiring."""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from fastapi import HTTPException, status
from sqlalchemy import text
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import (
AsyncEngine,
async_sessionmaker,
create_async_engine,
)
if TYPE_CHECKING:
from collections.abc import AsyncIterator
from sqlalchemy.ext.asyncio import AsyncSession
_ENGINE: AsyncEngine | None = None
_SESSIONMAKER: async_sessionmaker[AsyncSession] | None = None
class DatabaseConfigurationError(RuntimeError):
"""Raised when database configuration is missing or invalid."""
def get_database_url() -> str:
"""Return configured async database URL.
Returns:
Configured database URL normalized for SQLAlchemy async psycopg.
Raises:
DatabaseConfigurationError: If DATABASE_URL is not configured.
"""
database_url = os.getenv("DATABASE_URL", "").strip()
if not database_url:
raise DatabaseConfigurationError("DATABASE_URL is not configured")
if database_url.startswith("postgresql://"):
return database_url.replace("postgresql://", "postgresql+psycopg://", 1)
return database_url
def get_engine() -> AsyncEngine:
"""Return a singleton async SQLAlchemy engine."""
global _ENGINE
if _ENGINE is None:
_ENGINE = create_async_engine(
get_database_url(),
pool_pre_ping=True,
)
return _ENGINE
def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
"""Return a singleton async sessionmaker bound to the async engine."""
global _SESSIONMAKER
if _SESSIONMAKER is None:
_SESSIONMAKER = async_sessionmaker(
bind=get_engine(),
expire_on_commit=False,
)
return _SESSIONMAKER
async def get_session() -> AsyncIterator[AsyncSession]:
"""Yield one scoped async session for each request.
Yields:
Async session scoped to the current request lifecycle.
Raises:
HTTPException: If database configuration is missing.
"""
try:
session_factory = get_sessionmaker()
except DatabaseConfigurationError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={"status": "unhealthy", "database": "not_configured"},
) from exc
async with session_factory() as session:
yield session
async def probe_database(session: AsyncSession) -> bool:
"""Return True if a lightweight database probe succeeds."""
try:
await session.execute(text("SELECT 1"))
except SQLAlchemyError:
return False
return True
async def dispose_engine() -> None:
"""Dispose of global engine resources during shutdown."""
global _ENGINE, _SESSIONMAKER
if _ENGINE is not None:
await _ENGINE.dispose()
_ENGINE = None
_SESSIONMAKER = None

View File

@@ -2,15 +2,23 @@
Plex Playlist Backend API.
"""
from __future__ import annotations
import os
import sys
from contextlib import asynccontextmanager
from importlib import metadata
from typing import Any
from fastapi import FastAPI
from fastapi import Depends, FastAPI, status
from fastapi.responses import JSONResponse
from backend.database import dispose_engine, get_session, probe_database
REQUIRED_PACKAGE_PINS: dict[str, str] = {
"fastapi": "0.120.2",
"psycopg": "3.2.12",
"sqlalchemy": "2.0.44",
"uvicorn": "0.38.0",
}
@@ -84,6 +92,7 @@ async def lifespan(_: FastAPI):
"""Validate runtime policy during startup lifecycle."""
validate_runtime_policy()
yield
await dispose_engine()
# Create FastAPI application instance
@@ -102,9 +111,20 @@ def read_root() -> dict[str, str]:
@app.get("/health")
def health_check() -> dict[str, str]:
"""Health check endpoint."""
return {"status": "healthy"}
async def health_check(
session: Any = Depends(get_session), # pyright: ignore[reportCallInDefaultInitializer]
) -> JSONResponse:
"""Health check endpoint with database connectivity validation."""
if await probe_database(session):
return JSONResponse(
status_code=status.HTTP_200_OK,
content={"status": "healthy", "database": "connected"},
)
return JSONResponse(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
content={"status": "unhealthy", "database": "disconnected"},
)
@app.get("/compatibility")

View File

@@ -1,10 +1,15 @@
"""Integration tests for API endpoints."""
from importlib import metadata
from typing import cast
from unittest.mock import AsyncMock
import pytest
from fastapi.testclient import TestClient
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncSession
from backend.database import get_session
from backend.main import app, compatibility_status
client = TestClient(app)
@@ -16,9 +21,46 @@ class TestAPIIntegration:
def test_health_check(self) -> None:
"""Test API health check endpoint."""
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "healthy"}
healthy_session = cast("AsyncSession", AsyncMock(spec=AsyncSession))
healthy_session.execute = AsyncMock(return_value=1)
async def override_get_session():
"""Provide a healthy session dependency override for tests."""
yield healthy_session
app.dependency_overrides[get_session] = override_get_session
try:
with TestClient(app) as local_client:
response = local_client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "healthy", "database": "connected"}
finally:
app.dependency_overrides.clear()
def test_health_check_db_unavailable(self) -> None:
"""Health endpoint should return unavailable when DB probe fails."""
unhealthy_session = cast("AsyncSession", AsyncMock(spec=AsyncSession))
unhealthy_session.execute = AsyncMock(
side_effect=SQLAlchemyError("database unavailable")
)
async def override_get_session():
"""Provide an unhealthy session dependency override for tests."""
yield unhealthy_session
app.dependency_overrides[get_session] = override_get_session
try:
with TestClient(app) as local_client:
response = local_client.get("/health")
assert response.status_code == 503
assert response.json() == {
"status": "unhealthy",
"database": "disconnected",
}
finally:
app.dependency_overrides.clear()
def test_root_endpoint(self) -> None:
"""Test root endpoint."""

View File

@@ -1,6 +1,13 @@
"""Basic tests for the backend application."""
from backend.main import app, health_check, read_root
from typing import cast
from unittest.mock import AsyncMock
from fastapi.testclient import TestClient
from sqlalchemy.ext.asyncio import AsyncSession
from backend.database import get_session
from backend.main import app, read_root
def test_app_creation():
@@ -18,8 +25,23 @@ def test_read_root():
def test_health_check():
"""Test the health check endpoint function."""
result = health_check()
assert result == {"status": "healthy"}
healthy_session = cast("AsyncSession", AsyncMock(spec=AsyncSession))
healthy_session.execute = AsyncMock(return_value=1)
async def override_get_session():
"""Provide a healthy session dependency override for tests."""
yield healthy_session
app.dependency_overrides[get_session] = override_get_session
try:
with TestClient(app) as client:
response = client.get("/health")
assert response.status_code == 200
assert response.json() == {"status": "healthy", "database": "connected"}
finally:
app.dependency_overrides.clear()
def test_typeguard_validation():

View File

@@ -0,0 +1,181 @@
"""Unit tests for database wiring helpers."""
from typing import Any, cast
from unittest.mock import AsyncMock
import pytest
from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession
import backend.database as database
@pytest.fixture(autouse=True)
def reset_database_singletons(monkeypatch: pytest.MonkeyPatch) -> None:
"""Reset database singletons before each test."""
monkeypatch.setattr(database, "_ENGINE", None)
monkeypatch.setattr(database, "_SESSIONMAKER", None)
def test_get_database_url_requires_configuration(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Database URL helper should fail when DATABASE_URL is not set."""
monkeypatch.delenv("DATABASE_URL", raising=False)
with pytest.raises(database.DatabaseConfigurationError):
database.get_database_url()
def test_get_database_url_rewrites_postgresql_scheme(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Database URL helper should normalize URLs for SQLAlchemy async psycopg."""
monkeypatch.setenv("DATABASE_URL", "postgresql://user:pass@db:5432/app")
assert database.get_database_url() == "postgresql+psycopg://user:pass@db:5432/app"
def test_get_database_url_keeps_existing_scheme(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Database URL helper should keep already-normalized URLs unchanged."""
monkeypatch.setenv("DATABASE_URL", "postgresql+psycopg://user:pass@db:5432/app")
assert database.get_database_url() == "postgresql+psycopg://user:pass@db:5432/app"
def test_get_engine_is_singleton(monkeypatch: pytest.MonkeyPatch) -> None:
"""Engine helper should create engine once and reuse it."""
created: list[tuple[str, bool]] = []
engine = cast("AsyncEngine", AsyncMock(spec=AsyncEngine))
def fake_create_async_engine(url: str, *, pool_pre_ping: bool) -> AsyncEngine:
created.append((url, pool_pre_ping))
return engine
monkeypatch.setenv("DATABASE_URL", "postgresql://user:pass@db:5432/app")
monkeypatch.setattr(database, "create_async_engine", fake_create_async_engine)
first = database.get_engine()
second = database.get_engine()
assert first is engine
assert second is engine
assert created == [("postgresql+psycopg://user:pass@db:5432/app", True)]
def test_get_sessionmaker_is_singleton(monkeypatch: pytest.MonkeyPatch) -> None:
"""Sessionmaker helper should create one factory and reuse it."""
engine = cast("AsyncEngine", AsyncMock(spec=AsyncEngine))
monkeypatch.setattr(database, "get_engine", lambda: engine)
first = database.get_sessionmaker()
second = database.get_sessionmaker()
assert first is second
assert first.kw["bind"] is engine
assert first.kw["expire_on_commit"] is False
@pytest.mark.asyncio
async def test_get_session_raises_503_when_db_not_configured(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Session dependency should translate config errors to HTTP 503."""
def raise_config_error() -> Any:
raise database.DatabaseConfigurationError("missing")
monkeypatch.setattr(database, "get_sessionmaker", raise_config_error)
generator = database.get_session()
with pytest.raises(HTTPException) as exc:
await anext(generator)
assert exc.value.status_code == 503
assert exc.value.detail == {"status": "unhealthy", "database": "not_configured"}
@pytest.mark.asyncio
async def test_get_session_yields_scoped_session(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Session dependency should yield one session and close context afterwards."""
fake_session = cast("AsyncSession", AsyncMock(spec=AsyncSession))
class SessionContextManager:
"""Minimal async context manager used by the fake session factory."""
exited = False
async def __aenter__(self) -> AsyncSession:
return fake_session
async def __aexit__(self, *args: object) -> None:
self.exited = True
context_manager = SessionContextManager()
class SessionFactory:
"""Callable session factory returning an async context manager."""
def __call__(self) -> SessionContextManager:
return context_manager
monkeypatch.setattr(database, "get_sessionmaker", lambda: SessionFactory())
generator = database.get_session()
yielded = await anext(generator)
assert yielded is fake_session
with pytest.raises(StopAsyncIteration):
await anext(generator)
assert context_manager.exited is True
@pytest.mark.asyncio
async def test_probe_database_success() -> None:
"""Database probe should return True when SELECT 1 succeeds."""
session = cast("AsyncSession", AsyncMock(spec=AsyncSession))
session.execute = AsyncMock(return_value=1)
assert await database.probe_database(session) is True
@pytest.mark.asyncio
async def test_probe_database_failure() -> None:
"""Database probe should return False when SQLAlchemy raises an error."""
session = cast("AsyncSession", AsyncMock(spec=AsyncSession))
session.execute = AsyncMock(side_effect=SQLAlchemyError("db unavailable"))
assert await database.probe_database(session) is False
@pytest.mark.asyncio
async def test_dispose_engine_resets_global_state(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Dispose helper should call engine dispose and reset cached globals."""
class FakeEngine:
"""Fake async engine exposing a dispose coroutine."""
def __init__(self) -> None:
self.disposed = False
async def dispose(self) -> None:
self.disposed = True
engine = FakeEngine()
monkeypatch.setattr(database, "_ENGINE", engine)
monkeypatch.setattr(database, "_SESSIONMAKER", "sessionmaker")
await database.dispose_engine()
assert engine.disposed is True
assert database._ENGINE is None
assert database._SESSIONMAKER is None

934
backend/uv.lock generated

File diff suppressed because it is too large Load Diff