mirror of
https://github.com/zvx-echo6/central.git
synced 2026-05-21 18:14:44 +02:00
feat(gui): add events feed frontend with map and filters (1b-9b) (#26)
* feat(gui): add events feed frontend with map and filters GET /events: Full page with filter form, table, and Leaflet map GET /events/rows: HTMX fragment for table updates Features: - Filterable by adapter, category, time range, region bbox - Cursor-based pagination with Next button - Leaflet map showing event geometries - Click/hover row highlights geometry on map - Draw rectangle on map to filter by region - Validation errors shown as banner, not 400 - Events link added to nav between Adapters and Streams Refactored events query into shared helper for JSON and HTML routes. Tests: 14 new tests covering filters, fragments, geometry handling. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(events): use shared helpers for /events.json, fix tests - Refactor /events.json to use _parse_events_params and _fetch_events helpers, removing ~200 lines of duplicate query logic - Delete smoke test (test_events_unauthenticated_redirects) that had no assertions - Add TestCrossEndpointParity: verify /events.json and /events return identical results with same params, test category filter and cursor pagination on both endpoints - Add TestErrorSemantics: verify /events.json returns 400 on bad params while /events returns 200 with error banner (intentional API vs HTML divergence) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * test: add real assertions to since/until and region filter tests Replace trivial status_code==200 assertions with checks that verify the filter values were actually parsed and passed to the template. These tests now fail if the handler ignores the filter parameters. * fix: remove double-escaping from data-geometry attribute tojson already produces HTML-attribute-safe JSON. The extra |e filter was double-escaping, causing JSON.parse to fail in the browser JS. Switch to single-quoted attribute to avoid conflicts with JSON double quotes. --------- Co-authored-by: Matt Johnson <mj@k7zvx.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
78b6fcf150
commit
55e68d038f
5 changed files with 1344 additions and 89 deletions
|
|
@ -2197,48 +2197,45 @@ async def api_keys_delete(
|
||||||
return RedirectResponse(url="/api-keys", status_code=302)
|
return RedirectResponse(url="/api-keys", status_code=302)
|
||||||
|
|
||||||
|
|
||||||
@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
|
# --- Events query helper ---
|
||||||
category: filter by event category
|
|
||||||
since: ISO 8601 datetime - events where time >= since
|
class EventsQueryResult:
|
||||||
until: ISO 8601 datetime - events where time < until
|
"""Result from events query."""
|
||||||
region_north, region_south, region_east, region_west: bbox filter (all four required if any)
|
def __init__(self, events: list, next_cursor: str | None, error: str | None = None):
|
||||||
limit: page size (default 50, max 200)
|
self.events = events
|
||||||
cursor: opaque pagination cursor
|
self.next_cursor = next_cursor
|
||||||
|
self.error = error
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_events_params(params) -> tuple[dict | None, str | None]:
|
||||||
|
"""
|
||||||
|
Parse and validate events query parameters.
|
||||||
|
|
||||||
Returns:
|
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
|
# Parse and validate limit
|
||||||
limit_str = params.get("limit", "50")
|
limit_str = params.get("limit", "50")
|
||||||
try:
|
try:
|
||||||
limit = int(limit_str)
|
limit = int(limit_str)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return JSONResponse(
|
return None, f"Invalid limit value: {limit_str}"
|
||||||
{"error": f"Invalid limit value: {limit_str}"},
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
if limit < 1 or limit > 200:
|
if limit < 1 or limit > 200:
|
||||||
return JSONResponse(
|
return None, "limit must be between 1 and 200"
|
||||||
{"error": "limit must be between 1 and 200"},
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse adapter filter
|
# Parse adapter filter
|
||||||
adapter = params.get("adapter")
|
adapter = params.get("adapter")
|
||||||
|
if adapter == "":
|
||||||
|
adapter = None
|
||||||
|
|
||||||
# Parse category filter
|
# Parse category filter
|
||||||
category = params.get("category")
|
category = params.get("category")
|
||||||
|
if category == "":
|
||||||
|
category = None
|
||||||
|
|
||||||
# Parse since/until filters
|
# Parse since/until filters
|
||||||
since = None
|
since = None
|
||||||
|
|
@ -2249,27 +2246,18 @@ async def events_json(request: Request):
|
||||||
try:
|
try:
|
||||||
since = datetime.fromisoformat(since_str.replace("Z", "+00:00"))
|
since = datetime.fromisoformat(since_str.replace("Z", "+00:00"))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return JSONResponse(
|
return None, f"Invalid ISO 8601 datetime for since: {since_str}"
|
||||||
{"error": f"Invalid ISO 8601 datetime for since: {since_str}"},
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
until_str = params.get("until")
|
until_str = params.get("until")
|
||||||
if until_str:
|
if until_str:
|
||||||
try:
|
try:
|
||||||
until = datetime.fromisoformat(until_str.replace("Z", "+00:00"))
|
until = datetime.fromisoformat(until_str.replace("Z", "+00:00"))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return JSONResponse(
|
return None, f"Invalid ISO 8601 datetime for until: {until_str}"
|
||||||
{"error": f"Invalid ISO 8601 datetime for until: {until_str}"},
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Validate since <= until
|
# Validate since <= until
|
||||||
if since and until and since > until:
|
if since and until and since > until:
|
||||||
return JSONResponse(
|
return None, "since must be before or equal to until"
|
||||||
{"error": "since must be before or equal to until"},
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse region bbox
|
# Parse region bbox
|
||||||
region_north = params.get("region_north")
|
region_north = params.get("region_north")
|
||||||
|
|
@ -2277,14 +2265,21 @@ async def events_json(request: Request):
|
||||||
region_east = params.get("region_east")
|
region_east = params.get("region_east")
|
||||||
region_west = params.get("region_west")
|
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_params = [region_north, region_south, region_east, region_west]
|
||||||
region_supplied = [p for p in region_params if p is not None]
|
region_supplied = [p for p in region_params if p is not None]
|
||||||
|
|
||||||
if len(region_supplied) > 0 and len(region_supplied) < 4:
|
if len(region_supplied) > 0 and len(region_supplied) < 4:
|
||||||
return JSONResponse(
|
return None, "Region filter requires all four parameters: region_north, region_south, region_east, region_west"
|
||||||
{"error": "Region filter requires all four parameters: region_north, region_south, region_east, region_west"},
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
bbox = None
|
bbox = None
|
||||||
if len(region_supplied) == 4:
|
if len(region_supplied) == 4:
|
||||||
|
|
@ -2296,10 +2291,7 @@ async def events_json(request: Request):
|
||||||
"west": float(region_west),
|
"west": float(region_west),
|
||||||
}
|
}
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return JSONResponse(
|
return None, "Region parameters must be valid numbers"
|
||||||
{"error": "Region parameters must be valid numbers"},
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse cursor
|
# Parse cursor
|
||||||
cursor_time = None
|
cursor_time = None
|
||||||
|
|
@ -2315,14 +2307,37 @@ async def events_json(request: Request):
|
||||||
cursor_time = datetime.fromisoformat(parts[0])
|
cursor_time = datetime.fromisoformat(parts[0])
|
||||||
cursor_id = parts[1]
|
cursor_id = parts[1]
|
||||||
except Exception:
|
except Exception:
|
||||||
return JSONResponse(
|
return None, "Invalid cursor"
|
||||||
{"error": "Invalid cursor"},
|
|
||||||
status_code=400,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get database pool after validation
|
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()
|
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
|
# Build query
|
||||||
conditions = []
|
conditions = []
|
||||||
query_params = []
|
query_params = []
|
||||||
|
|
@ -2388,11 +2403,8 @@ async def events_json(request: Request):
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
rows = await conn.fetch(query, *query_params)
|
rows = await conn.fetch(query, *query_params)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Database error in events_json: {e}")
|
logger.error(f"Database error in _fetch_events: {e}")
|
||||||
return JSONResponse(
|
return EventsQueryResult([], None, "Database error")
|
||||||
{"error": "Database error"},
|
|
||||||
status_code=500,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Check if there is a next page
|
# Check if there is a next page
|
||||||
has_next = len(rows) > limit
|
has_next = len(rows) > limit
|
||||||
|
|
@ -2425,7 +2437,188 @@ async def events_json(request: Request):
|
||||||
cursor_data = f"{last_event['time'].isoformat()}|{last_event['id']}"
|
cursor_data = f"{last_event['time'].isoformat()}|{last_event['id']}"
|
||||||
next_cursor = base64.b64encode(cursor_data.encode("utf-8")).decode("utf-8")
|
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({
|
return JSONResponse({
|
||||||
|
"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,
|
"events": events,
|
||||||
"next_cursor": next_cursor,
|
"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,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
|
||||||
50
src/central/gui/templates/_events_rows.html
Normal file
50
src/central/gui/templates/_events_rows.html
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
{% if filter_error %}
|
||||||
|
<article aria-label="Filter Error" style="background-color: var(--pico-del-color); padding: 1rem; margin-bottom: 1rem;">
|
||||||
|
<strong>Filter Error:</strong> {{ filter_error }}
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if events %}
|
||||||
|
<table class="events-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Time</th>
|
||||||
|
<th>Adapter</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Geometry</th>
|
||||||
|
<th>Subject</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for event in events %}
|
||||||
|
<tr data-row-idx="{{ loop.index0 }}"
|
||||||
|
{% if event.geometry %}data-geometry='{{ event.geometry | tojson }}'{% endif %}>
|
||||||
|
<td>{{ event.time }}</td>
|
||||||
|
<td>{{ event.adapter }}</td>
|
||||||
|
<td>{{ event.category }}</td>
|
||||||
|
<td>{{ event.geometry_summary }}</td>
|
||||||
|
<td>{{ event.subject or '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<div class="pagination-info">
|
||||||
|
<span>Showing {{ events | length }} event{{ 's' if events | length != 1 else '' }}.</span>
|
||||||
|
{% if next_cursor %}
|
||||||
|
<a href="/events?cursor={{ next_cursor }}{% if filter_values.adapter %}&adapter={{ filter_values.adapter }}{% endif %}{% if filter_values.category %}&category={{ filter_values.category | urlencode }}{% endif %}{% if filter_values.since %}&since={{ filter_values.since }}{% endif %}{% if filter_values.until %}&until={{ filter_values.until }}{% endif %}{% if filter_values.region_north %}&region_north={{ filter_values.region_north }}&region_south={{ filter_values.region_south }}&region_east={{ filter_values.region_east }}&region_west={{ filter_values.region_west }}{% endif %}&limit={{ filter_values.limit }}"
|
||||||
|
role="button"
|
||||||
|
hx-get="/events/rows?cursor={{ next_cursor }}{% if filter_values.adapter %}&adapter={{ filter_values.adapter }}{% endif %}{% if filter_values.category %}&category={{ filter_values.category | urlencode }}{% endif %}{% if filter_values.since %}&since={{ filter_values.since }}{% endif %}{% if filter_values.until %}&until={{ filter_values.until }}{% endif %}{% if filter_values.region_north %}&region_north={{ filter_values.region_north }}&region_south={{ filter_values.region_south }}&region_east={{ filter_values.region_east }}&region_west={{ filter_values.region_west }}{% endif %}&limit={{ filter_values.limit }}"
|
||||||
|
hx-target="#events-rows"
|
||||||
|
hx-push-url="true">
|
||||||
|
Next →
|
||||||
|
</a>
|
||||||
|
{% else %}
|
||||||
|
<span><em>End of results</em></span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<article>
|
||||||
|
<p>No events match the filters.</p>
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
{% if operator %}
|
{% if operator %}
|
||||||
<li><a href="/">Dashboard</a></li>
|
<li><a href="/">Dashboard</a></li>
|
||||||
<li><a href="/adapters">Adapters</a></li>
|
<li><a href="/adapters">Adapters</a></li>
|
||||||
|
<li><a href="/events">Events</a></li>
|
||||||
<li><a href="/streams">Streams</a></li>
|
<li><a href="/streams">Streams</a></li>
|
||||||
<li><a href="/api-keys">API Keys</a></li>
|
<li><a href="/api-keys">API Keys</a></li>
|
||||||
<li>{{ operator.username }}</li>
|
<li>{{ operator.username }}</li>
|
||||||
|
|
|
||||||
378
src/central/gui/templates/events_list.html
Normal file
378
src/central/gui/templates/events_list.html
Normal file
|
|
@ -0,0 +1,378 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Events - Central{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css" />
|
||||||
|
<style>
|
||||||
|
#events-map {
|
||||||
|
height: 400px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
border-radius: var(--pico-border-radius);
|
||||||
|
}
|
||||||
|
.events-table {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.events-table td {
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
.events-table tr:hover {
|
||||||
|
background-color: var(--pico-primary-focus);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.events-table tr.highlighted {
|
||||||
|
background-color: var(--pico-primary-background);
|
||||||
|
}
|
||||||
|
.filter-form .grid {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.filter-form label {
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
.filter-form input, .filter-form select {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.region-inputs {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
.region-inputs input {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.region-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.pagination-info {
|
||||||
|
margin-top: 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<h1>Events</h1>
|
||||||
|
|
||||||
|
{% if filter_error %}
|
||||||
|
<article aria-label="Filter Error" style="background-color: var(--pico-del-color); padding: 1rem; margin-bottom: 1rem;">
|
||||||
|
<strong>Filter Error:</strong> {{ filter_error }}
|
||||||
|
</article>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<details open>
|
||||||
|
<summary>Filters</summary>
|
||||||
|
<form class="filter-form" action="/events" method="get"
|
||||||
|
hx-get="/events/rows" hx-target="#events-rows" hx-push-url="true">
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div>
|
||||||
|
<label for="adapter">Adapter</label>
|
||||||
|
<select id="adapter" name="adapter">
|
||||||
|
<option value="">All</option>
|
||||||
|
<option value="nws" {% if filter_values.adapter == 'nws' %}selected{% endif %}>nws</option>
|
||||||
|
<option value="firms" {% if filter_values.adapter == 'firms' %}selected{% endif %}>firms</option>
|
||||||
|
<option value="usgs_quake" {% if filter_values.adapter == 'usgs_quake' %}selected{% endif %}>usgs_quake</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="category">Category</label>
|
||||||
|
<input type="text" id="category" name="category" placeholder="Exact match"
|
||||||
|
value="{{ filter_values.category }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="since">From</label>
|
||||||
|
<input type="datetime-local" id="since" name="since"
|
||||||
|
value="{{ filter_values.since }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="until">To</label>
|
||||||
|
<input type="datetime-local" id="until" name="until"
|
||||||
|
value="{{ filter_values.until }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label>Region (draw on map or enter coordinates)</label>
|
||||||
|
<div class="region-controls">
|
||||||
|
<button type="button" id="clear-region-btn" class="outline secondary" style="width: auto; padding: 0.25rem 0.75rem;">
|
||||||
|
Clear Region
|
||||||
|
</button>
|
||||||
|
<small>Draw a rectangle on the map to filter by region</small>
|
||||||
|
</div>
|
||||||
|
<div class="region-inputs">
|
||||||
|
<div>
|
||||||
|
<label for="region_north">N</label>
|
||||||
|
<input type="number" id="region_north" name="region_north" step="0.0001" min="-90" max="90" readonly
|
||||||
|
value="{{ filter_values.region_north }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="region_south">S</label>
|
||||||
|
<input type="number" id="region_south" name="region_south" step="0.0001" min="-90" max="90" readonly
|
||||||
|
value="{{ filter_values.region_south }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="region_east">E</label>
|
||||||
|
<input type="number" id="region_east" name="region_east" step="0.0001" min="-180" max="180" readonly
|
||||||
|
value="{{ filter_values.region_east }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="region_west">W</label>
|
||||||
|
<input type="number" id="region_west" name="region_west" step="0.0001" min="-180" max="180" readonly
|
||||||
|
value="{{ filter_values.region_west }}">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" name="limit" value="{{ filter_values.limit }}">
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 0.5rem; margin-top: 0.5rem;">
|
||||||
|
<button type="submit">Apply</button>
|
||||||
|
<a href="/events" role="button" class="outline">Clear Filters</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div id="events-map"></div>
|
||||||
|
|
||||||
|
<div id="events-rows">
|
||||||
|
{% include "_events_rows.html" %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.js"></script>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
const tileUrl = {{ tile_url | tojson }};
|
||||||
|
const tileAttr = {{ tile_attribution | tojson }};
|
||||||
|
|
||||||
|
// Initialize map
|
||||||
|
const map = L.map('events-map').setView([39, -98], 4);
|
||||||
|
|
||||||
|
L.tileLayer(tileUrl, {
|
||||||
|
attribution: tileAttr,
|
||||||
|
maxZoom: 18
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
// Layer groups for event geometries
|
||||||
|
const eventLayers = {};
|
||||||
|
let highlightedRow = null;
|
||||||
|
let highlightedLayer = null;
|
||||||
|
|
||||||
|
// Styles
|
||||||
|
const defaultStyle = {
|
||||||
|
color: '#3388ff',
|
||||||
|
weight: 2,
|
||||||
|
fillOpacity: 0.2
|
||||||
|
};
|
||||||
|
const highlightStyle = {
|
||||||
|
color: '#ff3333',
|
||||||
|
weight: 4,
|
||||||
|
fillOpacity: 0.4
|
||||||
|
};
|
||||||
|
|
||||||
|
// Region filter rectangle
|
||||||
|
let filterRect = null;
|
||||||
|
const drawnItems = new L.FeatureGroup();
|
||||||
|
map.addLayer(drawnItems);
|
||||||
|
|
||||||
|
// Draw control for region filter
|
||||||
|
const drawControl = new L.Control.Draw({
|
||||||
|
draw: {
|
||||||
|
rectangle: { shapeOptions: { color: '#ff7800', weight: 2, fillOpacity: 0.1 } },
|
||||||
|
polyline: false,
|
||||||
|
polygon: false,
|
||||||
|
circle: false,
|
||||||
|
marker: false,
|
||||||
|
circlemarker: false
|
||||||
|
},
|
||||||
|
edit: {
|
||||||
|
featureGroup: drawnItems,
|
||||||
|
edit: false,
|
||||||
|
remove: false
|
||||||
|
}
|
||||||
|
});
|
||||||
|
map.addControl(drawControl);
|
||||||
|
|
||||||
|
// Region input elements
|
||||||
|
const northInput = document.getElementById('region_north');
|
||||||
|
const southInput = document.getElementById('region_south');
|
||||||
|
const eastInput = document.getElementById('region_east');
|
||||||
|
const westInput = document.getElementById('region_west');
|
||||||
|
|
||||||
|
// Update inputs from rectangle
|
||||||
|
function updateRegionInputs(bounds) {
|
||||||
|
northInput.value = bounds.getNorth().toFixed(4);
|
||||||
|
southInput.value = bounds.getSouth().toFixed(4);
|
||||||
|
eastInput.value = bounds.getEast().toFixed(4);
|
||||||
|
westInput.value = bounds.getWest().toFixed(4);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle new rectangle drawn
|
||||||
|
map.on(L.Draw.Event.CREATED, function(e) {
|
||||||
|
drawnItems.clearLayers();
|
||||||
|
filterRect = e.layer;
|
||||||
|
filterRect.setStyle({ color: '#ff7800', weight: 2, fillOpacity: 0.1 });
|
||||||
|
drawnItems.addLayer(filterRect);
|
||||||
|
updateRegionInputs(filterRect.getBounds());
|
||||||
|
// Auto-submit the form via HTMX
|
||||||
|
document.querySelector('.filter-form').dispatchEvent(new Event('submit', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear region button
|
||||||
|
document.getElementById('clear-region-btn').addEventListener('click', function() {
|
||||||
|
drawnItems.clearLayers();
|
||||||
|
filterRect = null;
|
||||||
|
northInput.value = '';
|
||||||
|
southInput.value = '';
|
||||||
|
eastInput.value = '';
|
||||||
|
westInput.value = '';
|
||||||
|
// Auto-submit to refresh
|
||||||
|
document.querySelector('.filter-form').dispatchEvent(new Event('submit', { bubbles: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// If region values exist, show the filter rectangle
|
||||||
|
if (northInput.value && southInput.value && eastInput.value && westInput.value) {
|
||||||
|
const bounds = L.latLngBounds(
|
||||||
|
L.latLng(parseFloat(southInput.value), parseFloat(westInput.value)),
|
||||||
|
L.latLng(parseFloat(northInput.value), parseFloat(eastInput.value))
|
||||||
|
);
|
||||||
|
filterRect = L.rectangle(bounds, { color: '#ff7800', weight: 2, fillOpacity: 0.1 });
|
||||||
|
drawnItems.addLayer(filterRect);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to add event geometries to map
|
||||||
|
function loadEventGeometries() {
|
||||||
|
// Clear existing event layers
|
||||||
|
Object.values(eventLayers).forEach(layer => map.removeLayer(layer));
|
||||||
|
Object.keys(eventLayers).forEach(key => delete eventLayers[key]);
|
||||||
|
|
||||||
|
const rows = document.querySelectorAll('#events-rows tr[data-geometry]');
|
||||||
|
const bounds = L.latLngBounds();
|
||||||
|
let hasGeometries = false;
|
||||||
|
|
||||||
|
rows.forEach((row, idx) => {
|
||||||
|
const geomStr = row.dataset.geometry;
|
||||||
|
if (!geomStr || geomStr === '') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const geom = JSON.parse(geomStr);
|
||||||
|
if (!geom) return;
|
||||||
|
|
||||||
|
const layer = L.geoJSON(geom, {
|
||||||
|
style: defaultStyle,
|
||||||
|
pointToLayer: function(feature, latlng) {
|
||||||
|
return L.circleMarker(latlng, {
|
||||||
|
radius: 8,
|
||||||
|
...defaultStyle
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
layer.addTo(map);
|
||||||
|
eventLayers[idx] = layer;
|
||||||
|
|
||||||
|
// Extend bounds
|
||||||
|
try {
|
||||||
|
bounds.extend(layer.getBounds());
|
||||||
|
hasGeometries = true;
|
||||||
|
} catch (e) {
|
||||||
|
// Point geometries might not have getBounds
|
||||||
|
if (geom.type === 'Point' && geom.coordinates) {
|
||||||
|
bounds.extend(L.latLng(geom.coordinates[1], geom.coordinates[0]));
|
||||||
|
hasGeometries = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click on geometry highlights row
|
||||||
|
layer.on('click', function() {
|
||||||
|
highlightRow(row, layer);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error parsing geometry:', e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fit map to all geometries
|
||||||
|
if (hasGeometries) {
|
||||||
|
map.fitBounds(bounds.pad(0.1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highlight a row and its geometry
|
||||||
|
function highlightRow(row, layer) {
|
||||||
|
// Reset previous highlight
|
||||||
|
if (highlightedRow) {
|
||||||
|
highlightedRow.classList.remove('highlighted');
|
||||||
|
}
|
||||||
|
if (highlightedLayer) {
|
||||||
|
highlightedLayer.setStyle(defaultStyle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set new highlight
|
||||||
|
row.classList.add('highlighted');
|
||||||
|
highlightedRow = row;
|
||||||
|
|
||||||
|
if (layer) {
|
||||||
|
layer.setStyle(highlightStyle);
|
||||||
|
highlightedLayer = layer;
|
||||||
|
// Pan to geometry
|
||||||
|
try {
|
||||||
|
map.fitBounds(layer.getBounds().pad(0.2));
|
||||||
|
} catch (e) {
|
||||||
|
// For points
|
||||||
|
const geom = JSON.parse(row.dataset.geometry);
|
||||||
|
if (geom && geom.type === 'Point' && geom.coordinates) {
|
||||||
|
map.setView([geom.coordinates[1], geom.coordinates[0]], 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Row hover/click handlers
|
||||||
|
function attachRowHandlers() {
|
||||||
|
const rows = document.querySelectorAll('#events-rows tr[data-row-idx]');
|
||||||
|
rows.forEach(row => {
|
||||||
|
const idx = parseInt(row.dataset.rowIdx);
|
||||||
|
row.addEventListener('click', function() {
|
||||||
|
const layer = eventLayers[idx];
|
||||||
|
highlightRow(row, layer);
|
||||||
|
});
|
||||||
|
row.addEventListener('mouseenter', function() {
|
||||||
|
const layer = eventLayers[idx];
|
||||||
|
if (layer && layer !== highlightedLayer) {
|
||||||
|
layer.setStyle({ ...defaultStyle, weight: 3 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
row.addEventListener('mouseleave', function() {
|
||||||
|
const layer = eventLayers[idx];
|
||||||
|
if (layer && layer !== highlightedLayer) {
|
||||||
|
layer.setStyle(defaultStyle);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
loadEventGeometries();
|
||||||
|
attachRowHandlers();
|
||||||
|
|
||||||
|
// Re-attach handlers after HTMX swap
|
||||||
|
document.body.addEventListener('htmx:afterSwap', function(evt) {
|
||||||
|
if (evt.detail.target.id === 'events-rows') {
|
||||||
|
loadEventGeometries();
|
||||||
|
attachRowHandlers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fix map rendering after container shows
|
||||||
|
setTimeout(function() { map.invalidateSize(); }, 100);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
633
tests/test_events_feed_frontend.py
Normal file
633
tests/test_events_feed_frontend.py
Normal file
|
|
@ -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"] == []
|
||||||
Loading…
Add table
Add a link
Reference in a new issue