156 lines
5.1 KiB
Python
156 lines
5.1 KiB
Python
import asyncio
|
|
from importlib.metadata import version
|
|
from unittest.mock import MagicMock
|
|
|
|
import pytest
|
|
from httpx import ASGITransport, AsyncClient
|
|
from pydantic import ValidationError
|
|
|
|
from lead_scoring import __version__
|
|
from lead_scoring.config import Settings
|
|
from lead_scoring.db.session import check_db_health
|
|
from lead_scoring.main import create_app
|
|
from lead_scoring.schemas.health import HealthResponse
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_endpoint_returns_ok_when_lifespan_healthy():
|
|
settings = Settings(database_url="postgresql+asyncpg://user:pass@localhost:5432/testdb")
|
|
app = create_app(settings=settings)
|
|
|
|
# Use lifespan context and mock engine probe
|
|
async with app.router.lifespan_context(app):
|
|
# Lifespan initializes app.state.engine and session_factory
|
|
assert hasattr(app.state, "engine")
|
|
mock_engine = MagicMock()
|
|
|
|
class HealthyAsyncCM:
|
|
async def __aenter__(self):
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
pass
|
|
|
|
async def execute(self, statement):
|
|
return 1
|
|
|
|
mock_engine.connect.return_value = HealthyAsyncCM()
|
|
app.state.engine = mock_engine
|
|
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
response = await client.get("/health")
|
|
|
|
assert response.status_code == 200
|
|
data = response.json()
|
|
validated = HealthResponse.model_validate(data)
|
|
assert validated.status == "ok"
|
|
assert validated.database == "connected"
|
|
assert validated.version == __version__
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_endpoint_returns_503_when_database_unhealthy():
|
|
settings = Settings(database_url="postgresql+asyncpg://user:pass@localhost:5432/testdb")
|
|
app = create_app(settings=settings)
|
|
|
|
mock_engine = MagicMock()
|
|
|
|
class FailingAsyncCM:
|
|
async def __aenter__(self):
|
|
raise ConnectionRefusedError("Database down")
|
|
|
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
pass
|
|
|
|
mock_engine.connect.return_value = FailingAsyncCM()
|
|
app.state.engine = mock_engine
|
|
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
response = await client.get("/health")
|
|
|
|
assert response.status_code == 503
|
|
data = response.json()
|
|
validated = HealthResponse.model_validate(data)
|
|
assert validated.status == "unhealthy"
|
|
assert validated.database == "unreachable"
|
|
assert validated.version == __version__
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_health_endpoint_returns_503_when_engine_unconfigured():
|
|
settings = Settings(database_url="postgresql+asyncpg://user:pass@localhost:5432/testdb")
|
|
app = create_app(settings=settings)
|
|
|
|
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
|
|
app.state.engine = None
|
|
response = await client.get("/health")
|
|
|
|
assert response.status_code == 503
|
|
data = response.json()
|
|
validated = HealthResponse.model_validate(data)
|
|
assert validated.status == "unhealthy"
|
|
assert validated.database == "unconfigured"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_check_db_health_timeout():
|
|
mock_engine = MagicMock()
|
|
|
|
class HangingAsyncCM:
|
|
async def __aenter__(self):
|
|
await asyncio.sleep(5.0)
|
|
|
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
pass
|
|
|
|
mock_engine.connect.return_value = HangingAsyncCM()
|
|
|
|
is_healthy = await check_db_health(mock_engine, timeout=0.1)
|
|
assert is_healthy is False
|
|
|
|
|
|
def test_settings_url_normalization():
|
|
s1 = Settings(database_url="postgres://user:pass@host:5432/db")
|
|
assert s1.database_url == "postgresql+asyncpg://user:pass@host:5432/db"
|
|
|
|
s2 = Settings(database_url="postgresql://user:pass@host:5432/db")
|
|
assert s2.database_url == "postgresql+asyncpg://user:pass@host:5432/db"
|
|
|
|
s3 = Settings(database_url="postgresql+asyncpg://user:pass@host:5432/db")
|
|
assert s3.database_url == "postgresql+asyncpg://user:pass@host:5432/db"
|
|
|
|
|
|
def test_settings_timeout_must_be_positive():
|
|
with pytest.raises(ValidationError):
|
|
Settings(db_health_timeout_seconds=0)
|
|
|
|
with pytest.raises(ValidationError):
|
|
Settings(db_health_timeout_seconds=-1.0)
|
|
|
|
|
|
def test_version_matches_distribution_metadata():
|
|
installed_version = version("lead-scoring")
|
|
assert __version__ == installed_version
|
|
settings = Settings()
|
|
assert settings.app_version == installed_version
|
|
health = HealthResponse()
|
|
assert health.version == installed_version
|
|
|
|
|
|
def test_openapi_declares_503_health_response():
|
|
app = create_app()
|
|
openapi_schema = app.openapi()
|
|
health_responses = openapi_schema["paths"]["/health"]["get"]["responses"]
|
|
assert "200" in health_responses
|
|
assert "503" in health_responses
|
|
|
|
|
|
def test_engine_factory_enforces_hide_parameters():
|
|
from lead_scoring.db.session import create_engine_from_url
|
|
|
|
engine = create_engine_from_url(
|
|
"postgresql+asyncpg://user:pass@localhost:5432/testdb",
|
|
echo=True,
|
|
)
|
|
assert engine.sync_engine.hide_parameters is True
|