diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py
index f86b6e1..3a415c2 100644
--- a/src/central/gui/routes.py
+++ b/src/central/gui/routes.py
@@ -2197,95 +2197,90 @@ async def api_keys_delete(
return RedirectResponse(url="/api-keys", status_code=302)
-@router.get("/events.json")
-async def events_json(request: Request):
+
+
+# --- Events query helper ---
+
+class EventsQueryResult:
+ """Result from events query."""
+ def __init__(self, events: list, next_cursor: str | None, error: str | None = None):
+ self.events = events
+ self.next_cursor = next_cursor
+ self.error = error
+
+
+def _parse_events_params(params) -> tuple[dict | None, str | None]:
"""
- Paginated, filterable JSON endpoint for events.
-
- Query parameters (all optional):
- adapter: filter by adapter name
- category: filter by event category
- since: ISO 8601 datetime - events where time >= since
- until: ISO 8601 datetime - events where time < until
- region_north, region_south, region_east, region_west: bbox filter (all four required if any)
- limit: page size (default 50, max 200)
- cursor: opaque pagination cursor
-
+ Parse and validate events query parameters.
+
Returns:
- {"events": [...], "next_cursor": string or null}
+ (parsed_params, error_message)
+ If error_message is not None, parsed_params is None.
"""
- from fastapi.responses import JSONResponse
-
- params = request.query_params
-
# Parse and validate limit
limit_str = params.get("limit", "50")
try:
limit = int(limit_str)
except ValueError:
- return JSONResponse(
- {"error": f"Invalid limit value: {limit_str}"},
- status_code=400,
- )
-
+ return None, f"Invalid limit value: {limit_str}"
+
if limit < 1 or limit > 200:
- return JSONResponse(
- {"error": "limit must be between 1 and 200"},
- status_code=400,
- )
-
+ return None, "limit must be between 1 and 200"
+
# Parse adapter filter
adapter = params.get("adapter")
-
- # Parse category filter
+ if adapter == "":
+ adapter = None
+
+ # Parse category filter
category = params.get("category")
-
+ if category == "":
+ category = None
+
# Parse since/until filters
since = None
until = None
-
+
since_str = params.get("since")
if since_str:
try:
since = datetime.fromisoformat(since_str.replace("Z", "+00:00"))
except ValueError:
- return JSONResponse(
- {"error": f"Invalid ISO 8601 datetime for since: {since_str}"},
- status_code=400,
- )
-
+ return None, f"Invalid ISO 8601 datetime for since: {since_str}"
+
until_str = params.get("until")
if until_str:
try:
until = datetime.fromisoformat(until_str.replace("Z", "+00:00"))
except ValueError:
- return JSONResponse(
- {"error": f"Invalid ISO 8601 datetime for until: {until_str}"},
- status_code=400,
- )
-
+ return None, f"Invalid ISO 8601 datetime for until: {until_str}"
+
# Validate since <= until
if since and until and since > until:
- return JSONResponse(
- {"error": "since must be before or equal to until"},
- status_code=400,
- )
-
+ return None, "since must be before or equal to until"
+
# Parse region bbox
region_north = params.get("region_north")
region_south = params.get("region_south")
region_east = params.get("region_east")
region_west = params.get("region_west")
-
+
+ # Treat empty strings as None
+ if region_north == "":
+ region_north = None
+ if region_south == "":
+ region_south = None
+ if region_east == "":
+ region_east = None
+ if region_west == "":
+ region_west = None
+
region_params = [region_north, region_south, region_east, region_west]
region_supplied = [p for p in region_params if p is not None]
-
+
if len(region_supplied) > 0 and len(region_supplied) < 4:
- return JSONResponse(
- {"error": "Region filter requires all four parameters: region_north, region_south, region_east, region_west"},
- status_code=400,
- )
-
+ return None, "Region filter requires all four parameters: region_north, region_south, region_east, region_west"
+
bbox = None
if len(region_supplied) == 4:
try:
@@ -2296,16 +2291,13 @@ async def events_json(request: Request):
"west": float(region_west),
}
except ValueError:
- return JSONResponse(
- {"error": "Region parameters must be valid numbers"},
- status_code=400,
- )
-
+ return None, "Region parameters must be valid numbers"
+
# Parse cursor
cursor_time = None
cursor_id = None
cursor_str = params.get("cursor")
-
+
if cursor_str:
try:
decoded = base64.b64decode(cursor_str).decode("utf-8")
@@ -2315,59 +2307,82 @@ async def events_json(request: Request):
cursor_time = datetime.fromisoformat(parts[0])
cursor_id = parts[1]
except Exception:
- return JSONResponse(
- {"error": "Invalid cursor"},
- status_code=400,
- )
-
- # Get database pool after validation
+ return None, "Invalid cursor"
+
+ return {
+ "limit": limit,
+ "adapter": adapter,
+ "category": category,
+ "since": since,
+ "until": until,
+ "bbox": bbox,
+ "cursor_time": cursor_time,
+ "cursor_id": cursor_id,
+ }, None
+
+
+async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
+ """
+ Fetch events from database using parsed parameters.
+
+ Returns EventsQueryResult with events list, next_cursor, and optional error.
+ """
pool = get_pool()
-
+
+ limit = parsed_params["limit"]
+ adapter = parsed_params["adapter"]
+ category = parsed_params["category"]
+ since = parsed_params["since"]
+ until = parsed_params["until"]
+ bbox = parsed_params["bbox"]
+ cursor_time = parsed_params["cursor_time"]
+ cursor_id = parsed_params["cursor_id"]
+
# Build query
conditions = []
query_params = []
param_idx = 1
-
+
if adapter:
conditions.append(f"adapter = ${param_idx}")
query_params.append(adapter)
param_idx += 1
-
+
if category:
conditions.append(f"category = ${param_idx}")
query_params.append(category)
param_idx += 1
-
+
if since:
conditions.append(f"time >= ${param_idx}")
query_params.append(since)
param_idx += 1
-
+
if until:
conditions.append(f"time < ${param_idx}")
query_params.append(until)
param_idx += 1
-
+
if bbox:
conditions.append(
f"ST_Intersects(geom, ST_MakeEnvelope(${param_idx}, ${param_idx+1}, ${param_idx+2}, ${param_idx+3}, 4326))"
)
query_params.extend([bbox["west"], bbox["south"], bbox["east"], bbox["north"]])
param_idx += 4
-
+
if cursor_time and cursor_id:
conditions.append(f"(time, id) < (${param_idx}, ${param_idx+1})")
query_params.append(cursor_time)
query_params.append(cursor_id)
param_idx += 2
-
+
where_clause = ""
if conditions:
where_clause = "WHERE " + " AND ".join(conditions)
-
+
# Fetch limit+1 to check for next page
query = f"""
- SELECT
+ SELECT
id,
time,
received,
@@ -2383,29 +2398,26 @@ async def events_json(request: Request):
LIMIT ${param_idx}
"""
query_params.append(limit + 1)
-
+
try:
async with pool.acquire() as conn:
rows = await conn.fetch(query, *query_params)
except Exception as e:
- logger.error(f"Database error in events_json: {e}")
- return JSONResponse(
- {"error": "Database error"},
- status_code=500,
- )
-
+ logger.error(f"Database error in _fetch_events: {e}")
+ return EventsQueryResult([], None, "Database error")
+
# Check if there is a next page
has_next = len(rows) > limit
if has_next:
rows = rows[:limit]
-
+
# Build response
events = []
for row in rows:
geometry = None
if row["geometry"]:
geometry = json.loads(row["geometry"])
-
+
events.append({
"id": row["id"],
"time": row["time"].isoformat(),
@@ -2417,15 +2429,196 @@ async def events_json(request: Request):
"data": dict(row["data"]) if row["data"] else {},
"regions": list(row["regions"]) if row["regions"] else [],
})
-
+
# Build next_cursor if there are more results
next_cursor = None
if has_next and events:
last_event = rows[-1]
cursor_data = f"{last_event['time'].isoformat()}|{last_event['id']}"
next_cursor = base64.b64encode(cursor_data.encode("utf-8")).decode("utf-8")
-
+
+ return EventsQueryResult(events, next_cursor)
+
+
+def _geometry_summary(geometry: dict | None) -> str:
+ """Generate a human-readable summary of a geometry."""
+ if not geometry:
+ return "None"
+
+ geom_type = geometry.get("type", "Unknown")
+
+ if geom_type == "Point":
+ return "Point"
+ elif geom_type == "LineString":
+ coords = geometry.get("coordinates", [])
+ return f"Line ({len(coords)} pts)"
+ elif geom_type == "Polygon":
+ coords = geometry.get("coordinates", [[]])
+ if coords:
+ return f"Polygon ({len(coords[0])} pts)"
+ return "Polygon"
+ elif geom_type == "MultiPolygon":
+ coords = geometry.get("coordinates", [])
+ return f"MultiPolygon ({len(coords)} parts)"
+ else:
+ return geom_type
+
+
+
+@router.get("/events.json")
+async def events_json(request: Request):
+ """
+ Paginated, filterable JSON endpoint for events.
+
+ Query parameters (all optional):
+ adapter: filter by adapter name
+ category: filter by event category
+ since: ISO 8601 datetime - events where time >= since
+ until: ISO 8601 datetime - events where time < until
+ region_north, region_south, region_east, region_west: bbox filter (all four required if any)
+ limit: page size (default 50, max 200)
+ cursor: opaque pagination cursor
+
+ Returns:
+ {"events": [...], "next_cursor": string or null}
+ """
+ from fastapi.responses import JSONResponse
+
+ params = request.query_params
+
+ # Parse and validate parameters using shared helper
+ parsed, error = _parse_events_params(params)
+ if error:
+ return JSONResponse({"error": error}, status_code=400)
+
+ # Fetch events using shared helper
+ result = await _fetch_events(parsed)
+ if result.error:
+ return JSONResponse({"error": result.error}, status_code=500)
+
return JSONResponse({
- "events": events,
- "next_cursor": next_cursor,
+ "events": result.events,
+ "next_cursor": result.next_cursor,
})
+
+
+# --- Events feed frontend routes ---
+
+@router.get("/events", response_class=HTMLResponse)
+async def events_list(request: Request) -> HTMLResponse:
+ """Events feed page with filter form, table, and map."""
+ templates = _get_templates()
+ operator = getattr(request.state, "operator", None)
+ csrf_token = getattr(request.state, "csrf_token", "")
+
+ params = request.query_params
+
+ # Parse parameters
+ parsed, error = _parse_events_params(params)
+
+ # Get system settings for map tiles
+ pool = get_pool()
+ async with pool.acquire() as conn:
+ system_row = await conn.fetchrow("SELECT map_tile_url, map_attribution FROM config.system")
+
+ tile_url = system_row["map_tile_url"] if system_row else "https://tile.openstreetmap.org/{z}/{x}/{y}.png"
+ tile_attribution = system_row["map_attribution"] if system_row else "OpenStreetMap"
+
+ # Prepare filter values for template
+ filter_values = {
+ "adapter": params.get("adapter", ""),
+ "category": params.get("category", ""),
+ "since": params.get("since", ""),
+ "until": params.get("until", ""),
+ "region_north": params.get("region_north", ""),
+ "region_south": params.get("region_south", ""),
+ "region_east": params.get("region_east", ""),
+ "region_west": params.get("region_west", ""),
+ "limit": params.get("limit", "50"),
+ }
+
+ events = []
+ next_cursor = None
+
+ if error:
+ # Validation error - show error banner but don't fail the page
+ pass
+ else:
+ # Fetch events
+ result = await _fetch_events(parsed)
+ if result.error:
+ error = result.error
+ else:
+ events = result.events
+ next_cursor = result.next_cursor
+
+ # Add geometry summary to each event
+ for event in events:
+ event["geometry_summary"] = _geometry_summary(event.get("geometry"))
+
+ return templates.TemplateResponse(
+ request=request,
+ name="events_list.html",
+ context={
+ "operator": operator,
+ "csrf_token": csrf_token,
+ "events": events,
+ "next_cursor": next_cursor,
+ "filter_values": filter_values,
+ "filter_error": error,
+ "tile_url": tile_url,
+ "tile_attribution": tile_attribution,
+ },
+ )
+
+
+@router.get("/events/rows", response_class=HTMLResponse)
+async def events_rows(request: Request) -> HTMLResponse:
+ """HTMX fragment: events table rows only (no page chrome)."""
+ templates = _get_templates()
+
+ params = request.query_params
+
+ # Parse parameters
+ parsed, error = _parse_events_params(params)
+
+ # Prepare filter values for template
+ filter_values = {
+ "adapter": params.get("adapter", ""),
+ "category": params.get("category", ""),
+ "since": params.get("since", ""),
+ "until": params.get("until", ""),
+ "region_north": params.get("region_north", ""),
+ "region_south": params.get("region_south", ""),
+ "region_east": params.get("region_east", ""),
+ "region_west": params.get("region_west", ""),
+ "limit": params.get("limit", "50"),
+ }
+
+ events = []
+ next_cursor = None
+
+ if error:
+ pass
+ else:
+ result = await _fetch_events(parsed)
+ if result.error:
+ error = result.error
+ else:
+ events = result.events
+ next_cursor = result.next_cursor
+
+ # Add geometry summary to each event
+ for event in events:
+ event["geometry_summary"] = _geometry_summary(event.get("geometry"))
+
+ return templates.TemplateResponse(
+ request=request,
+ name="_events_rows.html",
+ context={
+ "events": events,
+ "next_cursor": next_cursor,
+ "filter_values": filter_values,
+ "filter_error": error,
+ },
+ )
diff --git a/src/central/gui/templates/_events_rows.html b/src/central/gui/templates/_events_rows.html
new file mode 100644
index 0000000..231d7b6
--- /dev/null
+++ b/src/central/gui/templates/_events_rows.html
@@ -0,0 +1,50 @@
+{% if filter_error %}
+
+ Filter Error: {{ filter_error }}
+
+{% endif %}
+
+{% if events %}
+
+
+
+ | Time |
+ Adapter |
+ Category |
+ Geometry |
+ Subject |
+
+
+
+ {% for event in events %}
+
+ | {{ event.time }} |
+ {{ event.adapter }} |
+ {{ event.category }} |
+ {{ event.geometry_summary }} |
+ {{ event.subject or '—' }} |
+
+ {% endfor %}
+
+
+
+
+{% else %}
+
+ No events match the filters.
+
+{% endif %}
diff --git a/src/central/gui/templates/base.html b/src/central/gui/templates/base.html
index 0cd7baa..a7a667d 100644
--- a/src/central/gui/templates/base.html
+++ b/src/central/gui/templates/base.html
@@ -17,6 +17,7 @@
{% if operator %}
Dashboard
Adapters
+ Events
Streams
API Keys
{{ operator.username }}
diff --git a/src/central/gui/templates/events_list.html b/src/central/gui/templates/events_list.html
new file mode 100644
index 0000000..f1fe5be
--- /dev/null
+++ b/src/central/gui/templates/events_list.html
@@ -0,0 +1,378 @@
+{% extends "base.html" %}
+
+{% block title %}Events - Central{% endblock %}
+
+{% block head %}
+
+
+
+{% endblock %}
+
+{% block content %}
+Events
+
+{% if filter_error %}
+
+ Filter Error: {{ filter_error }}
+
+{% endif %}
+
+
+ Filters
+
+
+
+
+
+
+ {% include "_events_rows.html" %}
+
+
+
+
+
+
+{% endblock %}
diff --git a/tests/test_events_feed_frontend.py b/tests/test_events_feed_frontend.py
new file mode 100644
index 0000000..305b660
--- /dev/null
+++ b/tests/test_events_feed_frontend.py
@@ -0,0 +1,633 @@
+"""Tests for events feed frontend routes."""
+
+import json
+from datetime import datetime, timedelta, timezone
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import pytest
+
+from central.gui.routes import events_list, events_rows, events_json
+
+
+class TestEventsFeedFrontendAuthenticated:
+ """Test events feed frontend with authentication."""
+
+ @pytest.mark.asyncio
+ async def test_events_no_filters_returns_html(self):
+ """GET /events authenticated, no filters returns HTML with events."""
+ mock_request = MagicMock()
+ mock_request.state.operator = MagicMock(id=1, username="admin")
+ mock_request.state.csrf_token = "test_csrf_token"
+ mock_request.query_params = {}
+
+ mock_events = [
+ {
+ "id": f"event_{i}",
+ "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
+ "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
+ "adapter": "nws",
+ "category": "Weather Alert",
+ "subject": f"Test Alert {i}",
+ "geometry": '{"type": "Point", "coordinates": [-122.4, 37.8]}' if i % 2 == 0 else None,
+ "data": {},
+ "regions": [],
+ }
+ for i in range(5)
+ ]
+
+ mock_conn = AsyncMock()
+ mock_conn.fetch.return_value = mock_events
+ mock_conn.fetchrow.return_value = {
+ "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
+ "map_attribution": "OpenStreetMap",
+ }
+
+ mock_pool = MagicMock()
+ mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
+ mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
+
+ mock_templates = MagicMock()
+ mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
+
+ with patch("central.gui.routes._get_templates", return_value=mock_templates):
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ result = await events_list(mock_request)
+
+ assert result.status_code == 200
+ call_args = mock_templates.TemplateResponse.call_args
+ context = call_args.kwargs.get("context", call_args[1].get("context"))
+ assert "events" in context
+ assert context["filter_error"] is None
+
+ @pytest.mark.asyncio
+ async def test_events_adapter_filter(self):
+ """GET /events?adapter=nws returns only nws events."""
+ mock_request = MagicMock()
+ mock_request.state.operator = MagicMock(id=1, username="admin")
+ mock_request.state.csrf_token = "test_csrf_token"
+ mock_request.query_params = {"adapter": "nws"}
+
+ mock_events = [
+ {
+ "id": "nws_event_1",
+ "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
+ "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
+ "adapter": "nws",
+ "category": "Alert",
+ "subject": "NWS Alert",
+ "geometry": None,
+ "data": {},
+ "regions": [],
+ },
+ ]
+
+ mock_conn = AsyncMock()
+ mock_conn.fetch.return_value = mock_events
+ mock_conn.fetchrow.return_value = {
+ "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
+ "map_attribution": "OpenStreetMap",
+ }
+
+ mock_pool = MagicMock()
+ mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
+ mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
+
+ mock_templates = MagicMock()
+ mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
+
+ with patch("central.gui.routes._get_templates", return_value=mock_templates):
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ result = await events_list(mock_request)
+
+ assert result.status_code == 200
+ context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
+ assert context["filter_values"]["adapter"] == "nws"
+
+ @pytest.mark.asyncio
+ async def test_events_since_until_filter(self):
+ """GET /events?since=...&until=... filters by time window."""
+ mock_request = MagicMock()
+ mock_request.state.operator = MagicMock(id=1, username="admin")
+ mock_request.state.csrf_token = "test_csrf_token"
+ mock_request.query_params = {
+ "since": "2026-05-17T00:00:00",
+ "until": "2026-05-17T12:00:00",
+ }
+
+ mock_events = [
+ {
+ "id": "in_range",
+ "time": datetime(2026, 5, 17, 6, 0, tzinfo=timezone.utc),
+ "received": datetime(2026, 5, 17, 6, 0, tzinfo=timezone.utc),
+ "adapter": "nws",
+ "category": "Alert",
+ "subject": "In Range",
+ "geometry": None,
+ "data": {},
+ "regions": [],
+ },
+ ]
+
+ mock_conn = AsyncMock()
+ mock_conn.fetch.return_value = mock_events
+ mock_conn.fetchrow.return_value = {
+ "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
+ "map_attribution": "OpenStreetMap",
+ }
+
+ mock_pool = MagicMock()
+ mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
+ mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
+
+ mock_templates = MagicMock()
+ mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
+
+ with patch("central.gui.routes._get_templates", return_value=mock_templates):
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ result = await events_list(mock_request)
+
+ assert result.status_code == 200
+ # Verify filter was actually parsed and passed to template
+ mock_templates.TemplateResponse.assert_called_once()
+ call_kwargs = mock_templates.TemplateResponse.call_args.kwargs
+ context = call_kwargs.get("context", call_kwargs)
+ assert context["filter_values"]["since"] == "2026-05-17T00:00:00"
+ assert context["filter_values"]["until"] == "2026-05-17T12:00:00"
+
+ @pytest.mark.asyncio
+ async def test_events_region_filter(self):
+ """GET /events with full region bbox filters by location."""
+ mock_request = MagicMock()
+ mock_request.state.operator = MagicMock(id=1, username="admin")
+ mock_request.state.csrf_token = "test_csrf_token"
+ mock_request.query_params = {
+ "region_north": "49.5",
+ "region_south": "31",
+ "region_east": "-102",
+ "region_west": "-124.5",
+ }
+
+ mock_events = [
+ {
+ "id": "in_bbox",
+ "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
+ "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
+ "adapter": "nws",
+ "category": "Alert",
+ "subject": "In BBox",
+ "geometry": '{"type": "Point", "coordinates": [-120, 40]}',
+ "data": {},
+ "regions": [],
+ },
+ ]
+
+ mock_conn = AsyncMock()
+ mock_conn.fetch.return_value = mock_events
+ mock_conn.fetchrow.return_value = {
+ "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
+ "map_attribution": "OpenStreetMap",
+ }
+
+ mock_pool = MagicMock()
+ mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
+ mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
+
+ mock_templates = MagicMock()
+ mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
+
+ with patch("central.gui.routes._get_templates", return_value=mock_templates):
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ result = await events_list(mock_request)
+
+ assert result.status_code == 200
+ # Verify region filter was actually parsed and passed to template
+ mock_templates.TemplateResponse.assert_called_once()
+ call_kwargs = mock_templates.TemplateResponse.call_args.kwargs
+ context = call_kwargs.get("context", call_kwargs)
+ assert context["filter_values"]["region_north"] == "49.5"
+ assert context["filter_values"]["region_south"] == "31"
+ assert context["filter_values"]["region_east"] == "-102"
+ assert context["filter_values"]["region_west"] == "-124.5"
+
+ @pytest.mark.asyncio
+ async def test_events_partial_region_shows_error_banner(self):
+ """GET /events with partial region shows error banner, not 400."""
+ mock_request = MagicMock()
+ mock_request.state.operator = MagicMock(id=1, username="admin")
+ mock_request.state.csrf_token = "test_csrf_token"
+ mock_request.query_params = {"region_north": "49"}
+
+ mock_conn = AsyncMock()
+ mock_conn.fetchrow.return_value = {
+ "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
+ "map_attribution": "OpenStreetMap",
+ }
+
+ mock_pool = MagicMock()
+ mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
+ mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
+
+ mock_templates = MagicMock()
+ mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
+
+ with patch("central.gui.routes._get_templates", return_value=mock_templates):
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ result = await events_list(mock_request)
+
+ # Should be 200, not 400
+ assert result.status_code == 200
+ context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
+ assert context["filter_error"] is not None
+ assert "region" in context["filter_error"].lower()
+ # Events should be empty due to validation error
+ assert context["events"] == []
+
+ @pytest.mark.asyncio
+ async def test_events_with_limit_shows_next_button(self):
+ """GET /events?limit=5 shows Next button when more events exist."""
+ mock_request = MagicMock()
+ mock_request.state.operator = MagicMock(id=1, username="admin")
+ mock_request.state.csrf_token = "test_csrf_token"
+ mock_request.query_params = {"limit": "5"}
+
+ # Return 6 events (limit+1) to trigger pagination
+ mock_events = [
+ {
+ "id": f"event_{i}",
+ "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
+ "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
+ "adapter": "nws",
+ "category": "Alert",
+ "subject": f"Event {i}",
+ "geometry": None,
+ "data": {},
+ "regions": [],
+ }
+ for i in range(6)
+ ]
+
+ mock_conn = AsyncMock()
+ mock_conn.fetch.return_value = mock_events
+ mock_conn.fetchrow.return_value = {
+ "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
+ "map_attribution": "OpenStreetMap",
+ }
+
+ mock_pool = MagicMock()
+ mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
+ mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
+
+ mock_templates = MagicMock()
+ mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
+
+ with patch("central.gui.routes._get_templates", return_value=mock_templates):
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ result = await events_list(mock_request)
+
+ assert result.status_code == 200
+ context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
+ assert context["next_cursor"] is not None
+ assert len(context["events"]) == 5 # Should be trimmed to limit
+
+
+class TestEventsRowsFragment:
+ """Test /events/rows HTMX fragment."""
+
+ @pytest.mark.asyncio
+ async def test_events_rows_returns_fragment(self):
+ """GET /events/rows returns table fragment, not full page."""
+ mock_request = MagicMock()
+ mock_request.state.operator = MagicMock(id=1, username="admin")
+ mock_request.query_params = {"limit": "5"}
+
+ mock_events = [
+ {
+ "id": "event_1",
+ "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
+ "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
+ "adapter": "nws",
+ "category": "Alert",
+ "subject": "Event 1",
+ "geometry": None,
+ "data": {},
+ "regions": [],
+ },
+ ]
+
+ mock_conn = AsyncMock()
+ mock_conn.fetch.return_value = mock_events
+
+ mock_pool = MagicMock()
+ mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
+ mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
+
+ mock_templates = MagicMock()
+ mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
+
+ with patch("central.gui.routes._get_templates", return_value=mock_templates):
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ result = await events_rows(mock_request)
+
+ assert result.status_code == 200
+ # Verify it uses the fragment template
+ call_args = mock_templates.TemplateResponse.call_args
+ assert call_args.kwargs.get("name") == "_events_rows.html"
+
+
+class TestGeometrySummary:
+ """Test geometry summary function."""
+
+ def test_geometry_summary_polygon(self):
+ """Polygon geometry shows point count."""
+ from central.gui.routes import _geometry_summary
+
+ geom = {
+ "type": "Polygon",
+ "coordinates": [[[-122, 37], [-122, 38], [-121, 38], [-121, 37], [-122, 37]]]
+ }
+ summary = _geometry_summary(geom)
+ assert "Polygon" in summary
+ assert "5 pts" in summary
+
+ def test_geometry_summary_point(self):
+ """Point geometry shows 'Point'."""
+ from central.gui.routes import _geometry_summary
+
+ geom = {"type": "Point", "coordinates": [-122.4, 37.8]}
+ summary = _geometry_summary(geom)
+ assert summary == "Point"
+
+ def test_geometry_summary_linestring(self):
+ """LineString geometry shows point count."""
+ from central.gui.routes import _geometry_summary
+
+ geom = {
+ "type": "LineString",
+ "coordinates": [[-122, 37], [-121, 38], [-120, 39]]
+ }
+ summary = _geometry_summary(geom)
+ assert "Line" in summary
+ assert "3 pts" in summary
+
+ def test_geometry_summary_none(self):
+ """None geometry shows 'None'."""
+ from central.gui.routes import _geometry_summary
+
+ summary = _geometry_summary(None)
+ assert summary == "None"
+
+
+class TestDataGeometryAttribute:
+ """Test that rows have valid geometry data attributes."""
+
+ @pytest.mark.asyncio
+ async def test_event_with_geometry_has_valid_json(self):
+ """Events with geometry have parseable JSON in data-geometry."""
+ mock_request = MagicMock()
+ mock_request.state.operator = MagicMock(id=1, username="admin")
+ mock_request.query_params = {}
+
+ mock_events = [
+ {
+ "id": "geom_event",
+ "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
+ "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
+ "adapter": "nws",
+ "category": "Alert",
+ "subject": "With Geometry",
+ "geometry": '{"type": "Polygon", "coordinates": [[[-122, 37], [-122, 38], [-121, 38], [-121, 37], [-122, 37]]]}',
+ "data": {},
+ "regions": [],
+ },
+ ]
+
+ mock_conn = AsyncMock()
+ mock_conn.fetch.return_value = mock_events
+
+ mock_pool = MagicMock()
+ mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
+ mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
+
+ mock_templates = MagicMock()
+ mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
+
+ with patch("central.gui.routes._get_templates", return_value=mock_templates):
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ result = await events_rows(mock_request)
+
+ context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
+ event = context["events"][0]
+ # Geometry should be parsed dict, not string
+ assert isinstance(event["geometry"], dict)
+ assert event["geometry"]["type"] == "Polygon"
+
+ @pytest.mark.asyncio
+ async def test_event_without_geometry_has_none(self):
+ """Events without geometry have None for geometry field."""
+ mock_request = MagicMock()
+ mock_request.state.operator = MagicMock(id=1, username="admin")
+ mock_request.query_params = {}
+
+ mock_events = [
+ {
+ "id": "no_geom_event",
+ "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
+ "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
+ "adapter": "nws",
+ "category": "Alert",
+ "subject": "No Geometry",
+ "geometry": None,
+ "data": {},
+ "regions": [],
+ },
+ ]
+
+ mock_conn = AsyncMock()
+ mock_conn.fetch.return_value = mock_events
+
+ mock_pool = MagicMock()
+ mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
+ mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
+
+ mock_templates = MagicMock()
+ mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
+
+ with patch("central.gui.routes._get_templates", return_value=mock_templates):
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ result = await events_rows(mock_request)
+
+ context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
+ event = context["events"][0]
+ assert event["geometry"] is None
+
+
+class TestCrossEndpointParity:
+ """Test that /events.json and /events return the same filtered results."""
+
+ @pytest.mark.asyncio
+ async def test_category_filter_both_endpoints(self):
+ """Category filter works on both /events.json and /events."""
+ mock_events = [
+ {
+ "id": "weather_event",
+ "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
+ "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc),
+ "adapter": "nws",
+ "category": "Weather Alert",
+ "subject": "Weather Event",
+ "geometry": None,
+ "data": {},
+ "regions": [],
+ },
+ ]
+
+ mock_conn = AsyncMock()
+ mock_conn.fetch.return_value = mock_events
+ mock_conn.fetchrow.return_value = {
+ "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
+ "map_attribution": "OpenStreetMap",
+ }
+
+ mock_pool = MagicMock()
+ mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
+ mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
+
+ query_params = {"category": "Weather Alert"}
+
+ # Test /events.json
+ json_request = MagicMock()
+ json_request.state.operator = MagicMock(id=1, username="admin")
+ json_request.query_params = query_params
+
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ json_response = await events_json(json_request)
+
+ json_data = json.loads(json_response.body)
+ assert len(json_data["events"]) == 1
+ assert json_data["events"][0]["category"] == "Weather Alert"
+
+ # Test /events
+ html_request = MagicMock()
+ html_request.state.operator = MagicMock(id=1, username="admin")
+ html_request.state.csrf_token = "test_csrf"
+ html_request.query_params = query_params
+
+ mock_templates = MagicMock()
+ mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
+
+ mock_conn.fetch.return_value = mock_events
+
+ with patch("central.gui.routes._get_templates", return_value=mock_templates):
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ await events_list(html_request)
+
+ html_context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
+ assert len(html_context["events"]) == 1
+ assert html_context["events"][0]["category"] == "Weather Alert"
+
+ @pytest.mark.asyncio
+ async def test_cursor_pagination_both_endpoints(self):
+ """Cursor pagination works identically on both endpoints."""
+ first_page = [
+ {
+ "id": f"event_{i}",
+ "time": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
+ "received": datetime(2026, 5, 17, 12, 0, tzinfo=timezone.utc) - timedelta(hours=i),
+ "adapter": "nws",
+ "category": "Alert",
+ "subject": f"Event {i}",
+ "geometry": None,
+ "data": {},
+ "regions": [],
+ }
+ for i in range(3)
+ ]
+
+ mock_conn = AsyncMock()
+ mock_conn.fetch.return_value = first_page
+ mock_conn.fetchrow.return_value = {
+ "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
+ "map_attribution": "OpenStreetMap",
+ }
+
+ mock_pool = MagicMock()
+ mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
+ mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
+
+ json_request = MagicMock()
+ json_request.state.operator = MagicMock(id=1, username="admin")
+ json_request.query_params = {"limit": "2"}
+
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ json_response = await events_json(json_request)
+
+ json_data = json.loads(json_response.body)
+ json_cursor = json_data["next_cursor"]
+ assert json_cursor is not None
+
+ html_request = MagicMock()
+ html_request.state.operator = MagicMock(id=1, username="admin")
+ html_request.state.csrf_token = "test_csrf"
+ html_request.query_params = {"limit": "2"}
+
+ mock_templates = MagicMock()
+ mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
+
+ mock_conn.fetch.return_value = first_page
+
+ with patch("central.gui.routes._get_templates", return_value=mock_templates):
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ await events_list(html_request)
+
+ html_context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
+ html_cursor = html_context["next_cursor"]
+
+ assert json_cursor == html_cursor
+
+
+class TestErrorSemantics:
+ """Test error handling differences between JSON and HTML endpoints."""
+
+ @pytest.mark.asyncio
+ async def test_json_endpoint_returns_400_on_invalid_limit(self):
+ """/events.json?limit=0 returns 400 JSON error."""
+ mock_request = MagicMock()
+ mock_request.state.operator = MagicMock(id=1, username="admin")
+ mock_request.query_params = {"limit": "0"}
+
+ response = await events_json(mock_request)
+
+ assert response.status_code == 400
+ data = json.loads(response.body)
+ assert "error" in data
+
+ @pytest.mark.asyncio
+ async def test_html_endpoint_returns_200_with_error_banner(self):
+ """/events?limit=0 returns 200 HTML with error banner."""
+ mock_request = MagicMock()
+ mock_request.state.operator = MagicMock(id=1, username="admin")
+ mock_request.state.csrf_token = "test_csrf"
+ mock_request.query_params = {"limit": "0"}
+
+ mock_conn = AsyncMock()
+ mock_conn.fetchrow.return_value = {
+ "map_tile_url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
+ "map_attribution": "OpenStreetMap",
+ }
+
+ mock_pool = MagicMock()
+ mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
+ mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
+
+ mock_templates = MagicMock()
+ mock_templates.TemplateResponse.return_value = MagicMock(status_code=200)
+
+ with patch("central.gui.routes._get_templates", return_value=mock_templates):
+ with patch("central.gui.routes.get_pool", return_value=mock_pool):
+ result = await events_list(mock_request)
+
+ assert result.status_code == 200
+ context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
+ assert context["filter_error"] is not None
+ assert "limit" in context["filter_error"].lower()
+ assert context["events"] == []