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
|
# Application behavior
|
||||||
seed_on_start: bool = False
|
seed_on_start: bool = False
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
metrics_enabled: bool = False
|
metrics_enabled: bool = True
|
||||||
|
|
||||||
@cached_property
|
@cached_property
|
||||||
def trusted_proxy_ips(self) -> list[str]:
|
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 __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.requests import Request
|
||||||
from starlette.responses import PlainTextResponse
|
|
||||||
|
|
||||||
from animaltrack.config import Settings
|
from animaltrack.config import Settings
|
||||||
from animaltrack.db import get_db
|
from animaltrack.db import get_db
|
||||||
@@ -14,17 +17,48 @@ from animaltrack.web.middleware import (
|
|||||||
csrf_before,
|
csrf_before,
|
||||||
request_id_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(
|
def create_app(
|
||||||
settings: Settings | None = None,
|
settings: Settings | None = None,
|
||||||
db=None,
|
db=None,
|
||||||
|
static_dir: Path | None = None,
|
||||||
):
|
):
|
||||||
"""Create and configure the FastHTML application.
|
"""Create and configure the FastHTML application.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
settings: Application settings. Loads from env if None.
|
settings: Application settings. Loads from env if None.
|
||||||
db: Database connection. Creates from settings if None.
|
db: Database connection. Creates from settings if None.
|
||||||
|
static_dir: Directory for static files. Uses default if None.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (app, rt) - the FastHTML app and route decorator.
|
Tuple of (app, rt) - the FastHTML app and route decorator.
|
||||||
@@ -64,34 +98,46 @@ def create_app(
|
|||||||
r".*\.css",
|
r".*\.css",
|
||||||
r".*\.js",
|
r".*\.js",
|
||||||
r"/healthz",
|
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(
|
app, rt = fast_app(
|
||||||
before=beforeware,
|
before=beforeware,
|
||||||
|
hdrs=Theme.slate.headers(), # Dark industrial theme
|
||||||
exts=["head-support", "preload"],
|
exts=["head-support", "preload"],
|
||||||
|
static_path=static_path_for_fasthtml,
|
||||||
|
middleware=[Middleware(StaticCacheMiddleware)],
|
||||||
)
|
)
|
||||||
|
|
||||||
# Store settings and db on app state for access in routes
|
# Store settings and db on app state for access in routes
|
||||||
app.state.settings = settings
|
app.state.settings = settings
|
||||||
app.state.db = db
|
app.state.db = db
|
||||||
|
|
||||||
# Register healthz route (excluded from beforeware)
|
# Register health routes (healthz, metrics)
|
||||||
@rt("/healthz")
|
register_health_routes(rt, app)
|
||||||
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)
|
|
||||||
|
|
||||||
# Placeholder index route (will be replaced with real UI later)
|
# Placeholder index route (will be replaced with Egg Quick Capture later)
|
||||||
@rt("/")
|
@rt("/")
|
||||||
def index():
|
def index():
|
||||||
"""Placeholder index route."""
|
"""Placeholder index route - shows egg capture page."""
|
||||||
return PlainTextResponse("AnimalTrack", status_code=200)
|
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
|
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
|
from animaltrack.config import Settings
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
assert settings.metrics_enabled is False
|
assert settings.metrics_enabled is True
|
||||||
|
|
||||||
|
|
||||||
class TestConfigEnvOverrides:
|
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