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:
@@ -36,7 +36,7 @@ class Settings(BaseSettings):
|
||||
# Application behavior
|
||||
seed_on_start: bool = False
|
||||
log_level: str = "INFO"
|
||||
metrics_enabled: bool = False
|
||||
metrics_enabled: bool = True
|
||||
|
||||
@cached_property
|
||||
def trusted_proxy_ips(self) -> list[str]:
|
||||
|
||||
0
src/animaltrack/static/v1/.gitkeep
Normal file
0
src/animaltrack/static/v1/.gitkeep
Normal file
@@ -3,9 +3,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fasthtml.common import Beforeware, fast_app
|
||||
from pathlib import Path
|
||||
|
||||
from fasthtml.common import H1, Beforeware, P, fast_app
|
||||
from monsterui.all import Theme
|
||||
from starlette.middleware import Middleware
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import PlainTextResponse
|
||||
|
||||
from animaltrack.config import Settings
|
||||
from animaltrack.db import get_db
|
||||
@@ -14,17 +17,48 @@ from animaltrack.web.middleware import (
|
||||
csrf_before,
|
||||
request_id_before,
|
||||
)
|
||||
from animaltrack.web.routes import register_health_routes
|
||||
from animaltrack.web.templates import page
|
||||
|
||||
# Default static directory relative to this module
|
||||
DEFAULT_STATIC_DIR = Path(__file__).parent.parent / "static"
|
||||
|
||||
|
||||
class StaticCacheMiddleware:
|
||||
"""Middleware to add immutable cache headers to static file responses."""
|
||||
|
||||
def __init__(self, app):
|
||||
self.app = app
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
if scope["type"] != "http":
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
path = scope.get("path", "")
|
||||
|
||||
async def send_with_headers(message):
|
||||
if message["type"] == "http.response.start" and path.startswith("/static/"):
|
||||
headers = list(message.get("headers", []))
|
||||
# Add immutable cache headers for static files
|
||||
headers.append((b"cache-control", b"public, max-age=31536000, immutable"))
|
||||
message = {**message, "headers": headers}
|
||||
await send(message)
|
||||
|
||||
await self.app(scope, receive, send_with_headers)
|
||||
|
||||
|
||||
def create_app(
|
||||
settings: Settings | None = None,
|
||||
db=None,
|
||||
static_dir: Path | None = None,
|
||||
):
|
||||
"""Create and configure the FastHTML application.
|
||||
|
||||
Args:
|
||||
settings: Application settings. Loads from env if None.
|
||||
db: Database connection. Creates from settings if None.
|
||||
static_dir: Directory for static files. Uses default if None.
|
||||
|
||||
Returns:
|
||||
Tuple of (app, rt) - the FastHTML app and route decorator.
|
||||
@@ -64,34 +98,46 @@ def create_app(
|
||||
r".*\.css",
|
||||
r".*\.js",
|
||||
r"/healthz",
|
||||
r"/metrics",
|
||||
],
|
||||
)
|
||||
|
||||
# Create FastHTML app with HTMX extensions
|
||||
# Determine static files directory
|
||||
# FastHTML's static_path should point to parent dir since URL path mirrors file path
|
||||
# e.g., for URL /static/v1/app.css, file should be at static_path/static/v1/app.css
|
||||
static_base = static_dir if static_dir is not None else DEFAULT_STATIC_DIR
|
||||
# static_base is typically .../static, parent would be .../
|
||||
# but for URL /static/v1/... we want files at static_base/v1/...
|
||||
# So static_path should be the parent of static_base
|
||||
static_path_for_fasthtml = str(static_base.parent) if static_base.exists() else "."
|
||||
|
||||
# Create FastHTML app with HTMX extensions, MonsterUI theme, and static path
|
||||
app, rt = fast_app(
|
||||
before=beforeware,
|
||||
hdrs=Theme.slate.headers(), # Dark industrial theme
|
||||
exts=["head-support", "preload"],
|
||||
static_path=static_path_for_fasthtml,
|
||||
middleware=[Middleware(StaticCacheMiddleware)],
|
||||
)
|
||||
|
||||
# Store settings and db on app state for access in routes
|
||||
app.state.settings = settings
|
||||
app.state.db = db
|
||||
|
||||
# Register healthz route (excluded from beforeware)
|
||||
@rt("/healthz")
|
||||
def healthz():
|
||||
"""Health check endpoint."""
|
||||
# Verify database is writable
|
||||
try:
|
||||
db.execute("SELECT 1")
|
||||
return PlainTextResponse("OK", status_code=200)
|
||||
except Exception as e:
|
||||
return PlainTextResponse(f"Database error: {e}", status_code=503)
|
||||
# Register health routes (healthz, metrics)
|
||||
register_health_routes(rt, app)
|
||||
|
||||
# Placeholder index route (will be replaced with real UI later)
|
||||
# Placeholder index route (will be replaced with Egg Quick Capture later)
|
||||
@rt("/")
|
||||
def index():
|
||||
"""Placeholder index route."""
|
||||
return PlainTextResponse("AnimalTrack", status_code=200)
|
||||
"""Placeholder index route - shows egg capture page."""
|
||||
return page(
|
||||
(
|
||||
H1("Egg Quick Capture", cls="text-2xl font-bold mb-4"),
|
||||
P("Coming soon...", cls="text-stone-400"),
|
||||
),
|
||||
title="Egg - AnimalTrack",
|
||||
active_nav="egg",
|
||||
)
|
||||
|
||||
return app, rt
|
||||
|
||||
6
src/animaltrack/web/routes/__init__.py
Normal file
6
src/animaltrack/web/routes/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# ABOUTME: Routes package for AnimalTrack web application.
|
||||
# ABOUTME: Contains modular route handlers for different features.
|
||||
|
||||
from animaltrack.web.routes.health import register_health_routes
|
||||
|
||||
__all__ = ["register_health_routes"]
|
||||
56
src/animaltrack/web/routes/health.py
Normal file
56
src/animaltrack/web/routes/health.py
Normal file
@@ -0,0 +1,56 @@
|
||||
# ABOUTME: Health and metrics endpoints for AnimalTrack.
|
||||
# ABOUTME: Provides /healthz for liveness and /metrics for Prometheus.
|
||||
|
||||
from starlette.responses import PlainTextResponse
|
||||
|
||||
|
||||
def register_health_routes(rt, app):
|
||||
"""Register health and metrics routes.
|
||||
|
||||
Args:
|
||||
rt: FastHTML route decorator
|
||||
app: FastHTML application instance
|
||||
"""
|
||||
|
||||
@rt("/healthz")
|
||||
def healthz():
|
||||
"""Health check endpoint - verifies database is writable."""
|
||||
try:
|
||||
app.state.db.execute("SELECT 1")
|
||||
return PlainTextResponse("OK", status_code=200)
|
||||
except Exception as e:
|
||||
return PlainTextResponse(f"Database error: {e}", status_code=503)
|
||||
|
||||
@rt("/metrics")
|
||||
def metrics():
|
||||
"""Prometheus metrics endpoint.
|
||||
|
||||
Returns metrics in Prometheus text format.
|
||||
Gated by settings.metrics_enabled (default: True).
|
||||
"""
|
||||
if not app.state.settings.metrics_enabled:
|
||||
return PlainTextResponse("Not Found", status_code=404)
|
||||
|
||||
# Check database health for metric
|
||||
try:
|
||||
app.state.db.execute("SELECT 1")
|
||||
db_healthy = 1
|
||||
except Exception:
|
||||
db_healthy = 0
|
||||
|
||||
# Build Prometheus text format response
|
||||
lines = [
|
||||
"# HELP animaltrack_up Whether the service is up",
|
||||
"# TYPE animaltrack_up gauge",
|
||||
"animaltrack_up 1",
|
||||
"",
|
||||
"# HELP animaltrack_db_healthy Whether database is healthy",
|
||||
"# TYPE animaltrack_db_healthy gauge",
|
||||
f"animaltrack_db_healthy {db_healthy}",
|
||||
"",
|
||||
]
|
||||
|
||||
return PlainTextResponse(
|
||||
"\n".join(lines),
|
||||
media_type="text/plain; version=0.0.4; charset=utf-8",
|
||||
)
|
||||
7
src/animaltrack/web/templates/__init__.py
Normal file
7
src/animaltrack/web/templates/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
# ABOUTME: Templates package for AnimalTrack web UI.
|
||||
# ABOUTME: Contains reusable UI components built with FastHTML and MonsterUI.
|
||||
|
||||
from animaltrack.web.templates.base import page
|
||||
from animaltrack.web.templates.nav import BottomNav
|
||||
|
||||
__all__ = ["page", "BottomNav"]
|
||||
36
src/animaltrack/web/templates/base.py
Normal file
36
src/animaltrack/web/templates/base.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# ABOUTME: Base HTML template for AnimalTrack pages.
|
||||
# ABOUTME: Provides consistent layout with MonsterUI theme and bottom nav.
|
||||
|
||||
from fasthtml.common import Container, Div, Title
|
||||
|
||||
from animaltrack.web.templates.nav import BottomNav, BottomNavStyles
|
||||
|
||||
|
||||
def page(content, title: str = "AnimalTrack", active_nav: str = "egg"):
|
||||
"""
|
||||
Base page template wrapper with navigation.
|
||||
|
||||
Wraps content with consistent HTML structure including:
|
||||
- Page title
|
||||
- Bottom navigation styling
|
||||
- Content container with nav padding
|
||||
- Fixed bottom navigation bar
|
||||
|
||||
Args:
|
||||
content: Page content (FT components)
|
||||
title: Page title for browser tab
|
||||
active_nav: Active nav item ID ('egg', 'feed', 'move', 'registry')
|
||||
|
||||
Returns:
|
||||
Tuple of FT components for the complete page
|
||||
"""
|
||||
return (
|
||||
Title(title),
|
||||
BottomNavStyles(),
|
||||
# Main content with bottom padding for fixed nav
|
||||
Div(
|
||||
Container(content),
|
||||
cls="pb-20 min-h-screen bg-[#0f0f0e] text-stone-100",
|
||||
),
|
||||
BottomNav(active_id=active_nav),
|
||||
)
|
||||
93
src/animaltrack/web/templates/icons.py
Normal file
93
src/animaltrack/web/templates/icons.py
Normal file
@@ -0,0 +1,93 @@
|
||||
# ABOUTME: Custom SVG icons for AnimalTrack navigation.
|
||||
# ABOUTME: Chunky, recognizable icons designed for outdoor visibility.
|
||||
|
||||
from fasthtml.svg import Circle, Path, Svg
|
||||
|
||||
|
||||
def EggIcon(active: bool = False): # noqa: N802
|
||||
"""Egg collection icon - simple oval form."""
|
||||
fill = "#b8860b" if active else "#6b6b63"
|
||||
return Svg(
|
||||
Path(
|
||||
d="M12 2C8 2 5 7 5 12C5 17 8 22 12 22C16 22 19 17 19 12C19 7 16 2 12 2Z",
|
||||
fill=fill,
|
||||
stroke=fill,
|
||||
stroke_width="1.5",
|
||||
),
|
||||
viewBox="0 0 24 24",
|
||||
width="28",
|
||||
height="28",
|
||||
)
|
||||
|
||||
|
||||
def FeedIcon(active: bool = False): # noqa: N802
|
||||
"""Feed bucket icon - utilitarian container."""
|
||||
fill = "#b8860b" if active else "#6b6b63"
|
||||
return Svg(
|
||||
Path(
|
||||
d="M4 6H20L18 20H6L4 6Z",
|
||||
fill="none",
|
||||
stroke=fill,
|
||||
stroke_width="2",
|
||||
stroke_linejoin="round",
|
||||
),
|
||||
Path(d="M2 6H22", stroke=fill, stroke_width="2", stroke_linecap="round"),
|
||||
Path(
|
||||
d="M9 10V16M12 10V16M15 10V16",
|
||||
stroke=fill,
|
||||
stroke_width="1.5",
|
||||
stroke_linecap="round",
|
||||
),
|
||||
viewBox="0 0 24 24",
|
||||
width="28",
|
||||
height="28",
|
||||
)
|
||||
|
||||
|
||||
def MoveIcon(active: bool = False): # noqa: N802
|
||||
"""Move/relocate icon - directional arrows."""
|
||||
fill = "#b8860b" if active else "#6b6b63"
|
||||
return Svg(
|
||||
Path(
|
||||
d="M12 4L12 20M12 4L8 8M12 4L16 8",
|
||||
stroke=fill,
|
||||
stroke_width="2",
|
||||
stroke_linecap="round",
|
||||
stroke_linejoin="round",
|
||||
),
|
||||
Path(
|
||||
d="M4 12L20 12M4 12L8 8M4 12L8 16",
|
||||
stroke=fill,
|
||||
stroke_width="2",
|
||||
stroke_linecap="round",
|
||||
stroke_linejoin="round",
|
||||
),
|
||||
viewBox="0 0 24 24",
|
||||
width="28",
|
||||
height="28",
|
||||
)
|
||||
|
||||
|
||||
def RegistryIcon(active: bool = False): # noqa: N802
|
||||
"""Registry/list icon - stacked rows like ledger."""
|
||||
fill = "#b8860b" if active else "#6b6b63"
|
||||
return Svg(
|
||||
Path(d="M4 6H8M12 6H20", stroke=fill, stroke_width="2", stroke_linecap="round"),
|
||||
Path(d="M4 12H8M12 12H20", stroke=fill, stroke_width="2", stroke_linecap="round"),
|
||||
Path(d="M4 18H8M12 18H20", stroke=fill, stroke_width="2", stroke_linecap="round"),
|
||||
Circle(cx="6", cy="6", r="2", fill=fill),
|
||||
Circle(cx="6", cy="12", r="2", fill=fill),
|
||||
Circle(cx="6", cy="18", r="2", fill=fill),
|
||||
viewBox="0 0 24 24",
|
||||
width="28",
|
||||
height="28",
|
||||
)
|
||||
|
||||
|
||||
# Icon mapping by nav item ID
|
||||
NAV_ICONS = {
|
||||
"egg": EggIcon,
|
||||
"feed": FeedIcon,
|
||||
"move": MoveIcon,
|
||||
"registry": RegistryIcon,
|
||||
}
|
||||
112
src/animaltrack/web/templates/nav.py
Normal file
112
src/animaltrack/web/templates/nav.py
Normal file
@@ -0,0 +1,112 @@
|
||||
# ABOUTME: Bottom navigation component for AnimalTrack mobile UI.
|
||||
# ABOUTME: Industrial farm aesthetic with large touch targets and high contrast.
|
||||
|
||||
from fasthtml.common import A, Div, Span, Style
|
||||
|
||||
from animaltrack.web.templates.icons import NAV_ICONS
|
||||
|
||||
# Navigation items configuration
|
||||
NAV_ITEMS = [
|
||||
{"id": "egg", "label": "Egg", "href": "/"},
|
||||
{"id": "feed", "label": "Feed", "href": "/feed"},
|
||||
{"id": "move", "label": "Move", "href": "/move"},
|
||||
{"id": "registry", "label": "Registry", "href": "/registry"},
|
||||
]
|
||||
|
||||
|
||||
def BottomNavStyles(): # noqa: N802
|
||||
"""CSS styles for bottom navigation - include in page head."""
|
||||
return Style("""
|
||||
/* Bottom nav industrial styling */
|
||||
#bottom-nav {
|
||||
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
/* Safe area for iOS notch devices */
|
||||
.safe-area-pb {
|
||||
padding-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
|
||||
/* Active item subtle glow effect */
|
||||
.nav-item-active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 40%;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, #b8860b, transparent);
|
||||
}
|
||||
|
||||
/* Hover state for non-touch devices */
|
||||
@media (hover: hover) {
|
||||
#bottom-nav a:hover {
|
||||
background-color: rgba(184, 134, 11, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure consistent icon rendering */
|
||||
#bottom-nav svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Typography for labels */
|
||||
#bottom-nav span {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
""")
|
||||
|
||||
|
||||
def BottomNav(active_id: str = "egg"): # noqa: N802
|
||||
"""
|
||||
Fixed bottom navigation bar for AnimalTrack.
|
||||
|
||||
Args:
|
||||
active_id: Currently active nav item ('egg', 'feed', 'move', 'registry')
|
||||
|
||||
Returns:
|
||||
FT component for the bottom nav
|
||||
"""
|
||||
|
||||
def nav_item(item):
|
||||
is_active = item["id"] == active_id
|
||||
icon_fn = NAV_ICONS[item["id"]]
|
||||
|
||||
# Active: golden highlight, inactive: muted stone gray
|
||||
label_cls = "text-xs font-semibold tracking-wide uppercase mt-1 "
|
||||
label_cls += "text-amber-600" if is_active else "text-stone-500"
|
||||
|
||||
item_cls = "flex flex-col items-center justify-center py-2 px-4 "
|
||||
if is_active:
|
||||
item_cls += "bg-stone-900/50 rounded-lg"
|
||||
|
||||
link_cls = (
|
||||
"relative flex-1 flex items-center justify-center min-h-[64px] "
|
||||
"transition-all duration-150 active:scale-95 "
|
||||
)
|
||||
if is_active:
|
||||
link_cls += "nav-item-active"
|
||||
|
||||
return A(
|
||||
Div(
|
||||
icon_fn(active=is_active),
|
||||
Span(item["label"], cls=label_cls),
|
||||
cls=item_cls,
|
||||
),
|
||||
href=item["href"],
|
||||
cls=link_cls,
|
||||
)
|
||||
|
||||
return Div(
|
||||
# Top border with subtle texture effect
|
||||
Div(cls="h-px bg-gradient-to-r from-transparent via-stone-700 to-transparent"),
|
||||
# Nav container
|
||||
Div(
|
||||
*[nav_item(item) for item in NAV_ITEMS],
|
||||
cls="flex items-stretch bg-[#1a1a18] safe-area-pb",
|
||||
),
|
||||
cls="fixed bottom-0 left-0 right-0 z-50",
|
||||
id="bottom-nav",
|
||||
)
|
||||
@@ -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
129
tests/test_web_health.py
Normal 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
85
tests/test_web_static.py
Normal 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
|
||||
Reference in New Issue
Block a user