feat: add health endpoints, static serving, and base template (Step 7.2)

- Add /healthz endpoint with DB writable check
- Add /metrics endpoint with Prometheus-compatible format (enabled by default)
- Configure static file serving at /static/v1/... with immutable cache headers
- Create base page template with MonsterUI slate theme
- Create industrial farm aesthetic bottom navigation with custom SVG icons
- Add StaticCacheMiddleware for adding cache-control headers

Changes:
- src/animaltrack/web/routes/health.py: Health and metrics endpoints
- src/animaltrack/web/templates/: Base template, nav, and icons
- src/animaltrack/web/app.py: Integrate theme, routes, static serving
- src/animaltrack/config.py: metrics_enabled defaults to True

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-29 20:35:57 +00:00
parent 52bc33891c
commit 6cdf48fc32
12 changed files with 588 additions and 18 deletions

View File

@@ -81,7 +81,7 @@ class TestConfigDefaults:
from animaltrack.config import Settings
settings = Settings()
assert settings.metrics_enabled is False
assert settings.metrics_enabled is True
class TestConfigEnvOverrides:

129
tests/test_web_health.py Normal file
View File

@@ -0,0 +1,129 @@
# ABOUTME: Tests for health and metrics endpoints.
# ABOUTME: Covers /healthz DB check and /metrics Prometheus format.
import os
from unittest.mock import MagicMock
import pytest
def make_test_settings(
csrf_secret: str = "test-secret",
trusted_proxy_ips: str = "127.0.0.1",
metrics_enabled: bool = True,
):
"""Create Settings for testing by setting env vars temporarily."""
from animaltrack.config import Settings
old_env = os.environ.copy()
try:
os.environ["CSRF_SECRET"] = csrf_secret
os.environ["TRUSTED_PROXY_IPS"] = trusted_proxy_ips
os.environ["METRICS_ENABLED"] = str(metrics_enabled).lower()
return Settings()
finally:
os.environ.clear()
os.environ.update(old_env)
class TestHealthzEndpoint:
"""Tests for /healthz endpoint."""
@pytest.fixture
def client(self, seeded_db):
"""Create a test client for the app."""
from starlette.testclient import TestClient
from animaltrack.web.app import create_app
settings = make_test_settings(trusted_proxy_ips="testclient")
app, rt = create_app(settings=settings, db=seeded_db)
return TestClient(app, raise_server_exceptions=False)
def test_healthz_returns_200_when_db_healthy(self, client):
"""GET /healthz returns 200 OK when database is healthy."""
resp = client.get("/healthz")
assert resp.status_code == 200
assert resp.text == "OK"
def test_healthz_returns_503_when_db_fails(self, seeded_db):
"""GET /healthz returns 503 when database query fails."""
from starlette.testclient import TestClient
from animaltrack.web.app import create_app
# Create a mock db that raises on execute
mock_db = MagicMock()
mock_db.execute.side_effect = Exception("Database connection lost")
settings = make_test_settings(trusted_proxy_ips="testclient")
app, rt = create_app(settings=settings, db=mock_db)
client = TestClient(app, raise_server_exceptions=False)
resp = client.get("/healthz")
assert resp.status_code == 503
assert "Database error" in resp.text
class TestMetricsEndpoint:
"""Tests for /metrics Prometheus endpoint."""
@pytest.fixture
def client_metrics_enabled(self, seeded_db):
"""Create a test client with metrics enabled."""
from starlette.testclient import TestClient
from animaltrack.web.app import create_app
settings = make_test_settings(
trusted_proxy_ips="testclient",
metrics_enabled=True,
)
app, rt = create_app(settings=settings, db=seeded_db)
return TestClient(app, raise_server_exceptions=False)
@pytest.fixture
def client_metrics_disabled(self, seeded_db):
"""Create a test client with metrics disabled."""
from starlette.testclient import TestClient
from animaltrack.web.app import create_app
settings = make_test_settings(
trusted_proxy_ips="testclient",
metrics_enabled=False,
)
app, rt = create_app(settings=settings, db=seeded_db)
return TestClient(app, raise_server_exceptions=False)
def test_metrics_returns_200_when_enabled(self, client_metrics_enabled):
"""GET /metrics returns 200 when metrics_enabled=True."""
resp = client_metrics_enabled.get("/metrics")
assert resp.status_code == 200
def test_metrics_returns_404_when_disabled(self, client_metrics_disabled):
"""GET /metrics returns 404 when metrics_enabled=False."""
resp = client_metrics_disabled.get("/metrics")
assert resp.status_code == 404
def test_metrics_returns_prometheus_format(self, client_metrics_enabled):
"""GET /metrics returns valid Prometheus text format."""
resp = client_metrics_enabled.get("/metrics")
# Check Content-Type
content_type = resp.headers.get("content-type", "")
assert "text/plain" in content_type
# Check body contains Prometheus format elements
body = resp.text
assert "# HELP" in body
assert "# TYPE" in body
assert "animaltrack_up" in body
def test_metrics_includes_db_health_metric(self, client_metrics_enabled):
"""GET /metrics includes database health gauge."""
resp = client_metrics_enabled.get("/metrics")
body = resp.text
assert "animaltrack_db_healthy" in body
assert "# TYPE animaltrack_db_healthy gauge" in body

85
tests/test_web_static.py Normal file
View File

@@ -0,0 +1,85 @@
# ABOUTME: Tests for static file serving with cache headers.
# ABOUTME: Verifies /static/v1/ path and immutable cache-control.
import os
import pytest
def make_test_settings(
csrf_secret: str = "test-secret",
trusted_proxy_ips: str = "127.0.0.1",
):
"""Create Settings for testing by setting env vars temporarily."""
from animaltrack.config import Settings
old_env = os.environ.copy()
try:
os.environ["CSRF_SECRET"] = csrf_secret
os.environ["TRUSTED_PROXY_IPS"] = trusted_proxy_ips
return Settings()
finally:
os.environ.clear()
os.environ.update(old_env)
class TestStaticFileServing:
"""Tests for static file serving."""
@pytest.fixture
def static_dir(self, tmp_path):
"""Create a temporary static directory with test files."""
static_v1 = tmp_path / "static" / "v1"
static_v1.mkdir(parents=True)
# Create a test CSS file
test_css = static_v1 / "test.css"
test_css.write_text("body { color: red; }")
# Create a test JS file
test_js = static_v1 / "test.js"
test_js.write_text("console.log('test');")
return tmp_path / "static"
@pytest.fixture
def client(self, seeded_db, static_dir):
"""Create a test client with static file serving configured."""
from starlette.testclient import TestClient
from animaltrack.web.app import create_app
settings = make_test_settings(trusted_proxy_ips="testclient")
app, rt = create_app(
settings=settings,
db=seeded_db,
static_dir=static_dir,
)
return TestClient(app, raise_server_exceptions=False)
def test_static_files_are_served(self, client):
"""Static files under /static/v1/ are accessible."""
resp = client.get("/static/v1/test.css")
assert resp.status_code == 200
assert "body { color: red; }" in resp.text
def test_static_files_have_immutable_cache_headers(self, client):
"""Static files have Cache-Control: immutable header."""
resp = client.get("/static/v1/test.css")
assert resp.status_code == 200
cache_control = resp.headers.get("cache-control", "")
assert "immutable" in cache_control
assert "max-age=31536000" in cache_control
assert "public" in cache_control
def test_static_js_files_served(self, client):
"""JavaScript files are served correctly."""
resp = client.get("/static/v1/test.js")
assert resp.status_code == 200
assert "console.log" in resp.text
def test_static_404_for_missing_files(self, client):
"""Missing static files return 404."""
resp = client.get("/static/v1/nonexistent.css")
assert resp.status_code == 404