mirror of
https://github.com/zvx-echo6/central.git
synced 2026-05-22 18:44:40 +02:00
Compare commits
12 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
76c5e94b39 |
|||
|
|
578c9bc0fe | ||
|
d0375225b2 |
|||
|
|
ff3d9bb3c3 | ||
|
e33a896592 |
|||
|
|
f666014821 | ||
|
69dddd0240 |
|||
|
|
765635e720 | ||
|
496dd1626f |
|||
|
|
5d4320bc73 | ||
|
339c980c9a |
|||
|
|
49d85021e8 |
35 changed files with 969 additions and 95 deletions
|
|
@ -1,10 +1,11 @@
|
|||
"""Route handlers for Central GUI."""
|
||||
|
||||
import base64
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger("central.gui.routes")
|
||||
|
|
@ -2727,6 +2728,24 @@ def _parse_events_params(params) -> tuple[dict | None, str | None]:
|
|||
}, None
|
||||
|
||||
|
||||
def _derive_subject(event: dict) -> str | None:
|
||||
"""Derive an event's plain-text subject for the JSON API.
|
||||
|
||||
Renders the same per-adapter ``_event_summaries/{adapter}.html`` partial
|
||||
the /events table uses (falling back to ``_default.html``), so the JSON
|
||||
subject carries the same human text as the GUI's Subject cell with no
|
||||
duplicated derivation logic. The partials are HTML-autoescaped for the
|
||||
table (e.g. ``>`` -> ``>``); we ``html.unescape`` so JSON consumers get
|
||||
plain text. Returns ``None`` when the partial yields no text -- an unknown
|
||||
adapter, or an event whose source fields don't support a subject (e.g. a
|
||||
wfigs row with neither county nor state).
|
||||
"""
|
||||
template = _get_templates().env.select_template(
|
||||
[f"_event_summaries/{event.get('adapter')}.html", "_event_summaries/_default.html"]
|
||||
)
|
||||
return html.unescape(template.render(event=event)).strip() or None
|
||||
|
||||
|
||||
async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
|
||||
"""
|
||||
Fetch events from database using parsed parameters.
|
||||
|
|
@ -2794,7 +2813,6 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
|
|||
received,
|
||||
adapter,
|
||||
category,
|
||||
payload->>'subject' as subject,
|
||||
ST_AsGeoJSON(geom) as geometry,
|
||||
payload as data,
|
||||
regions
|
||||
|
|
@ -2824,17 +2842,23 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
|
|||
if row["geometry"]:
|
||||
geometry = json.loads(row["geometry"])
|
||||
|
||||
events.append({
|
||||
event = {
|
||||
"id": row["id"],
|
||||
"time": row["time"].isoformat(),
|
||||
"received": row["received"].isoformat(),
|
||||
"adapter": row["adapter"],
|
||||
"category": row["category"],
|
||||
"subject": row["subject"],
|
||||
"geometry": geometry,
|
||||
"data": dict(row["data"]) if row["data"] else {},
|
||||
"regions": list(row["regions"]) if row["regions"] else [],
|
||||
})
|
||||
}
|
||||
# Subject is derived from the inner adapter payload by rendering the
|
||||
# same _event_summaries partial the /events table uses, so the JSON
|
||||
# `subject` matches the GUI's Subject cell. (The CloudEvents envelope
|
||||
# has no top-level `subject`; the old `payload->>'subject'` was always
|
||||
# null for every consumer.)
|
||||
event["subject"] = _derive_subject(event)
|
||||
events.append(event)
|
||||
|
||||
# Build next_cursor if there are more results
|
||||
next_cursor = None
|
||||
|
|
@ -2870,6 +2894,31 @@ def _geometry_summary(geometry: dict | None) -> str:
|
|||
return geom_type
|
||||
|
||||
|
||||
def _format_event_time(iso: str | None) -> str:
|
||||
"""Format an ISO-8601 timestamp as 'MM-DD-YYYY HH:MM UTC' (24h, no seconds)."""
|
||||
if not iso:
|
||||
return ""
|
||||
try:
|
||||
dt = datetime.fromisoformat(iso).astimezone(timezone.utc)
|
||||
except (ValueError, TypeError):
|
||||
return iso
|
||||
return dt.strftime("%m-%d-%Y %H:%M") + " UTC"
|
||||
|
||||
|
||||
def _decorate_table_events(events: list[dict]) -> None:
|
||||
"""Add display-only fields used by the HTML events table (in place).
|
||||
|
||||
These are for the table chrome only and are deliberately NOT added in
|
||||
_fetch_events, so the /events.json payload is unchanged. adapter_display
|
||||
is sourced from the registry (display_name), with the bare name as fallback.
|
||||
"""
|
||||
display = {cls.name: cls.display_name for cls in discover_adapters().values()}
|
||||
for event in events:
|
||||
event["geometry_summary"] = _geometry_summary(event.get("geometry"))
|
||||
event["time_human"] = _format_event_time(event.get("time"))
|
||||
event["adapter_display"] = display.get(event.get("adapter"), event.get("adapter"))
|
||||
|
||||
|
||||
|
||||
@router.get("/events.json")
|
||||
async def events_json(request: Request):
|
||||
|
|
@ -2958,9 +3007,15 @@ async def events_list(request: Request) -> HTMLResponse:
|
|||
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"))
|
||||
# Add table-only display fields (time_human, adapter_display, geometry_summary)
|
||||
_decorate_table_events(events)
|
||||
|
||||
# Registry-derived adapter list for the filter <select> and map legend.
|
||||
# Sorted by name for stable ordering; index drives the legend color palette.
|
||||
adapters = [
|
||||
{"name": cls.name, "display_name": cls.display_name}
|
||||
for cls in sorted(discover_adapters().values(), key=lambda c: c.name)
|
||||
]
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
|
|
@ -2974,6 +3029,7 @@ async def events_list(request: Request) -> HTMLResponse:
|
|||
"filter_error": error,
|
||||
"tile_url": tile_url,
|
||||
"tile_attribution": tile_attribution,
|
||||
"adapters": adapters,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -3014,9 +3070,8 @@ async def events_rows(request: Request) -> HTMLResponse:
|
|||
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"))
|
||||
# Add table-only display fields (time_human, adapter_display, geometry_summary)
|
||||
_decorate_table_events(events)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request=request,
|
||||
|
|
|
|||
2
src/central/gui/templates/_event_rows/_default.html
Normal file
2
src/central/gui/templates/_event_rows/_default.html
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{# Fallback for adapters without a bespoke partial. No curated fields — the
|
||||
full payload is shown in the "Data" block rendered by _events_rows.html. #}
|
||||
6
src/central/gui/templates/_event_rows/eonet.html
Normal file
6
src/central/gui/templates/_event_rows/eonet.html
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{# EONET natural-event tracker. Fields from payload->data->data. #}
|
||||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{% if d.get('title') is not none %}<dt>Title</dt><dd>{{ d.title }}</dd>{% endif %}
|
||||
{% if d.get('category_id') is not none %}<dt>Category</dt><dd>{{ d.category_id }}</dd>{% endif %}
|
||||
{% if d.get('magnitudeValue') is not none %}<dt>Magnitude</dt><dd>{{ d.magnitudeValue }} {{ d.get('magnitudeUnit', '') }}</dd>{% endif %}
|
||||
{% if d.get('url') is not none %}<dt>Source</dt><dd><a href="{{ d.url }}" target="_blank" rel="noopener">{{ d.url }}</a></dd>{% endif %}
|
||||
6
src/central/gui/templates/_event_rows/firms.html
Normal file
6
src/central/gui/templates/_event_rows/firms.html
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{# NASA FIRMS fire hotspots. Fields from payload->data->data. #}
|
||||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{% if d.get('confidence') is not none %}<dt>Confidence</dt><dd>{{ d.confidence }}</dd>{% endif %}
|
||||
{% if d.get('frp') is not none %}<dt>FRP (MW)</dt><dd>{{ d.frp }}</dd>{% endif %}
|
||||
{% if d.get('bright_ti4') is not none %}<dt>Brightness (TI4)</dt><dd>{{ d.bright_ti4 }}</dd>{% endif %}
|
||||
{% if d.get('satellite') is not none %}<dt>Satellite</dt><dd>{{ d.satellite }}{% if d.get('daynight') is not none %} ({{ d.daynight }}){% endif %}</dd>{% endif %}
|
||||
6
src/central/gui/templates/_event_rows/gdacs.html
Normal file
6
src/central/gui/templates/_event_rows/gdacs.html
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{# GDACS global disaster alerts. Fields from payload->data->data. #}
|
||||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{% if d.get('title') is not none %}<dt>Title</dt><dd>{{ d.title }}</dd>{% endif %}
|
||||
{% if d.get('eventtype') is not none %}<dt>Event type</dt><dd>{{ d.eventtype }}</dd>{% endif %}
|
||||
{% if d.get('alertlevel') is not none %}<dt>Alert level</dt><dd>{{ d.alertlevel }}{% if d.get('alertscore') is not none %} ({{ d.alertscore }}){% endif %}</dd>{% endif %}
|
||||
{% if d.get('country') is not none %}<dt>Country</dt><dd>{{ d.country }}</dd>{% endif %}
|
||||
5
src/central/gui/templates/_event_rows/inciweb.html
Normal file
5
src/central/gui/templates/_event_rows/inciweb.html
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{# NIFC InciWeb wildfire narrative. Fields from payload->data->data. #}
|
||||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{% if d.get('title') is not none %}<dt>Title</dt><dd>{{ d.title }}</dd>{% endif %}
|
||||
{% if d.get('description') is not none %}<dt>Description</dt><dd>{{ d.description | truncate(200) }}</dd>{% endif %}
|
||||
{% if d.get('url') is not none %}<dt>Source</dt><dd><a href="{{ d.url }}" target="_blank" rel="noopener">{{ d.url }}</a></dd>{% endif %}
|
||||
6
src/central/gui/templates/_event_rows/nwis.html
Normal file
6
src/central/gui/templates/_event_rows/nwis.html
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{# USGS NWIS water observations. Fields from payload->data->data. #}
|
||||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{% if d.get('parameter_code') is not none %}<dt>Parameter</dt><dd>{{ d.parameter_code }}</dd>{% endif %}
|
||||
{% if d.get('value') is not none %}<dt>Value</dt><dd>{{ d.value }} {{ d.get('unit_of_measure', '') }}</dd>{% endif %}
|
||||
{% if d.get('monitoring_location_id') is not none %}<dt>Site</dt><dd><code>{{ d.monitoring_location_id }}</code></dd>{% endif %}
|
||||
{% if d.get('statistic_id') is not none %}<dt>Statistic</dt><dd>{{ d.statistic_id }}</dd>{% endif %}
|
||||
6
src/central/gui/templates/_event_rows/nws.html
Normal file
6
src/central/gui/templates/_event_rows/nws.html
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{# NWS weather alerts. Fields from payload->data->data. #}
|
||||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{% if d.get('event') is not none %}<dt>Event</dt><dd>{{ d.event }}</dd>{% endif %}
|
||||
{% if d.get('severity') is not none %}<dt>Severity</dt><dd>{{ d.severity }}{% if d.get('certainty') is not none %} / {{ d.certainty }}{% endif %}</dd>{% endif %}
|
||||
{% if d.get('headline') is not none %}<dt>Headline</dt><dd>{{ d.headline }}</dd>{% endif %}
|
||||
{% if d.get('areaDesc') is not none %}<dt>Area</dt><dd>{{ d.areaDesc | truncate(200) }}</dd>{% endif %}
|
||||
5
src/central/gui/templates/_event_rows/swpc_alerts.html
Normal file
5
src/central/gui/templates/_event_rows/swpc_alerts.html
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
{# NOAA SWPC space-weather alerts. Fields from payload->data->data. #}
|
||||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{% if d.get('product_id') is not none %}<dt>Product</dt><dd><code>{{ d.product_id }}</code></dd>{% endif %}
|
||||
{% if d.get('issue_datetime') is not none %}<dt>Issued</dt><dd>{{ d.issue_datetime }}</dd>{% endif %}
|
||||
{% if d.get('message') is not none %}<dt>Message</dt><dd>{{ d.message | truncate(240) }}</dd>{% endif %}
|
||||
6
src/central/gui/templates/_event_rows/swpc_kindex.html
Normal file
6
src/central/gui/templates/_event_rows/swpc_kindex.html
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{# NOAA SWPC planetary K-index. Fields from payload->data->data. #}
|
||||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{% if d.get('Kp') is not none %}<dt>Kp</dt><dd>{{ d.Kp }}</dd>{% endif %}
|
||||
{% if d.get('a_running') is not none %}<dt>A-running</dt><dd>{{ d.a_running }}</dd>{% endif %}
|
||||
{% if d.get('station_count') is not none %}<dt>Stations</dt><dd>{{ d.station_count }}</dd>{% endif %}
|
||||
{% if d.get('time_tag') is not none %}<dt>Time tag</dt><dd>{{ d.time_tag }}</dd>{% endif %}
|
||||
6
src/central/gui/templates/_event_rows/swpc_protons.html
Normal file
6
src/central/gui/templates/_event_rows/swpc_protons.html
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{# NOAA SWPC GOES proton flux. Fields from payload->data->data. #}
|
||||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{% if d.get('flux') is not none %}<dt>Flux</dt><dd>{{ d.flux }}</dd>{% endif %}
|
||||
{% if d.get('energy') is not none %}<dt>Energy</dt><dd>{{ d.energy }}</dd>{% endif %}
|
||||
{% if d.get('satellite') is not none %}<dt>Satellite</dt><dd>{{ d.satellite }}</dd>{% endif %}
|
||||
{% if d.get('time_tag') is not none %}<dt>Time tag</dt><dd>{{ d.time_tag }}</dd>{% endif %}
|
||||
6
src/central/gui/templates/_event_rows/usgs_quake.html
Normal file
6
src/central/gui/templates/_event_rows/usgs_quake.html
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{# USGS earthquakes. Fields from payload->data->data. #}
|
||||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{% if d.get('magnitude') is not none %}<dt>Magnitude</dt><dd>{{ d.magnitude }}{% if d.get('magType') is not none %} {{ d.magType }}{% endif %}</dd>{% endif %}
|
||||
{% if d.get('place') is not none %}<dt>Place</dt><dd>{{ d.place }}</dd>{% endif %}
|
||||
{% if d.get('depth') is not none %}<dt>Depth (km)</dt><dd>{{ d.depth }}</dd>{% endif %}
|
||||
{% if d.get('url') is not none %}<dt>Source</dt><dd><a href="{{ d.url }}" target="_blank" rel="noopener">{{ d.url }}</a></dd>{% endif %}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{# NIFC WFIGS wildfire incidents. Fields from payload->data->data. #}
|
||||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{% if d.get('state') is not none %}<dt>State</dt><dd>{{ d.state }}</dd>{% endif %}
|
||||
{% if d.get('county') is not none %}<dt>County</dt><dd>{{ d.county }}</dd>{% endif %}
|
||||
{% if d.get('reason') is not none %}<dt>Reason</dt><dd>{{ d.reason }}</dd>{% endif %}
|
||||
{% if d.get('irwin_id') is not none %}<dt>IRWIN ID</dt><dd><code>{{ d.irwin_id }}</code></dd>{% endif %}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{# NIFC WFIGS wildfire perimeters. Fields from payload->data->data. #}
|
||||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{% if d.get('state') is not none %}<dt>State</dt><dd>{{ d.state }}</dd>{% endif %}
|
||||
{% if d.get('county') is not none %}<dt>County</dt><dd>{{ d.county }}</dd>{% endif %}
|
||||
{% if d.get('reason') is not none %}<dt>Reason</dt><dd>{{ d.reason }}</dd>{% endif %}
|
||||
{% if d.get('irwin_id') is not none %}<dt>IRWIN ID</dt><dd><code>{{ d.irwin_id }}</code></dd>{% endif %}
|
||||
2
src/central/gui/templates/_event_summaries/_default.html
Normal file
2
src/central/gui/templates/_event_summaries/_default.html
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{# No per-adapter summary; the Subject cell falls back to "—" and the map
|
||||
popup omits the subject line. #}
|
||||
2
src/central/gui/templates/_event_summaries/eonet.html
Normal file
2
src/central/gui/templates/_event_summaries/eonet.html
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{%- if d.get('title') %}{{ d.title }}{% endif -%}
|
||||
2
src/central/gui/templates/_event_summaries/firms.html
Normal file
2
src/central/gui/templates/_event_summaries/firms.html
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{%- if d.get('frp') is not none or d.get('confidence') %}Fire detected{% if d.get('frp') is not none %} — {{ d.frp }} MW radiative power{% endif %}{% if d.get('confidence') == 'high' %} (high confidence){% endif %}{% endif -%}
|
||||
2
src/central/gui/templates/_event_summaries/gdacs.html
Normal file
2
src/central/gui/templates/_event_summaries/gdacs.html
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{%- if d.get('title') %}{{ d.title }}{% endif %}{% if d.get('alertlevel') %} — {{ d.alertlevel }} alert{% endif -%}
|
||||
2
src/central/gui/templates/_event_summaries/inciweb.html
Normal file
2
src/central/gui/templates/_event_summaries/inciweb.html
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{%- if d.get('title') %}{{ d.title }}{% endif -%}
|
||||
2
src/central/gui/templates/_event_summaries/nwis.html
Normal file
2
src/central/gui/templates/_event_summaries/nwis.html
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{%- if d.get('value') is not none %}Water reading: {{ d.value }}{% if d.get('unit_of_measure') %} {{ d.unit_of_measure }}{% endif %}{% endif -%}
|
||||
2
src/central/gui/templates/_event_summaries/nws.html
Normal file
2
src/central/gui/templates/_event_summaries/nws.html
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{%- if d.get('event') %}{{ d.event }}{% endif %}{% if d.get('severity') %} — {{ d.severity }}{% endif -%}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{%- if d.get('product_id') or d.get('message') %}Space weather alert{% if d.get('product_id') %} {{ d.product_id }}{% endif %}{% if d.get('message') %}: {{ d.message | replace('\r', ' ') | replace('\n', ' ') | truncate(80) }}{% endif %}{% endif -%}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{%- if d.get('Kp') is not none %}Geomagnetic activity (Kp index): {{ d.Kp }}{% endif -%}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{%- if d.get('flux') is not none %}Solar proton flux: {{ d.flux | round(2) }} pfu{% if d.get('energy') %} at {{ d.energy }}{% endif %}{% endif -%}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{%- if d.get('magnitude') is not none %}Magnitude {{ d.magnitude | round(1) }}{% if d.get('place') %} — {{ d.place }}{% endif %}{% endif -%}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{%- if d.get('county') or d.get('state') %}Wildfire incident — {% if d.get('county') %}{{ d.county }}{% if d.get('state') %}, {% endif %}{% endif %}{% if d.get('state') %}{{ d.state }}{% endif %}{% endif -%}
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{%- if d.get('county') or d.get('state') %}Wildfire perimeter — {% if d.get('county') %}{{ d.county }}{% if d.get('state') %}, {% endif %}{% endif %}{% if d.get('state') %}{{ d.state }}{% endif %}{% endif -%}
|
||||
|
|
@ -10,30 +10,40 @@
|
|||
<tr>
|
||||
<th style="width: 2rem;"></th>
|
||||
<th>Time</th>
|
||||
<th>Adapter</th>
|
||||
<th>Category</th>
|
||||
<th>Geometry</th>
|
||||
<th>Location</th>
|
||||
<th>Subject</th>
|
||||
<th>Adapter</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for event in events %}
|
||||
{# Per-adapter one-line summary, dispatched by adapter name with a generic
|
||||
fallback (no hardcoded list). Captured once so it serves both the
|
||||
Subject cell and the map popup (via data-subject). #}
|
||||
{% set subject_summary %}{% include ["_event_summaries/" ~ event.adapter ~ ".html", "_event_summaries/_default.html"] %}{% endset %}
|
||||
{# Location: generic _enriched.geocoder reader, then top-level named
|
||||
fields, then coordinates. No adapter-specific logic. #}
|
||||
{% set d = (event.data.get('data') or {}).get('data') or {} %}
|
||||
{% set gc = (d.get('_enriched') or {}).get('geocoder') or {} %}
|
||||
{% set loc_local = gc.get('city') or d.get('city') or gc.get('county') or d.get('county') %}
|
||||
{% set loc_state = gc.get('state') or d.get('state') %}
|
||||
{% set loc_country = gc.get('country') or d.get('country') %}
|
||||
{% set loc_parts = [loc_local, loc_state, loc_country] | select | list %}
|
||||
<tr class="event-row" data-row-idx="{{ loop.index0 }}"
|
||||
data-event-id="{{ event.id }}"
|
||||
data-adapter="{{ event.adapter }}"
|
||||
data-category="{{ event.category }}"
|
||||
data-time="{{ event.time }}"
|
||||
data-subject="{{ event.subject or '' }}"
|
||||
data-subject="{{ subject_summary | trim }}"
|
||||
{% if event.geometry %}data-geometry='{{ event.geometry | tojson }}'{% endif %}>
|
||||
<td><button type="button" class="expand-row" aria-label="Expand">▸</button></td>
|
||||
<td>{{ event.time }}</td>
|
||||
<td>{{ event.adapter }}</td>
|
||||
<td>{{ event.category }}</td>
|
||||
<td>{{ event.geometry_summary }}</td>
|
||||
<td>{{ event.subject or '—' }}</td>
|
||||
<td title="{{ event.time }}">{{ event.time_human }}</td>
|
||||
<td>{% if loc_parts %}{{ loc_parts | join(', ') }}{% elif gc.get('landclass') %}{{ gc.landclass }}{% else %}—{% endif %}</td>
|
||||
<td>{{ subject_summary | trim or '—' }}</td>
|
||||
<td>{{ event.adapter_display }}</td>
|
||||
</tr>
|
||||
<tr class="event-detail" hidden>
|
||||
<td colspan="6">
|
||||
<td colspan="5">
|
||||
<dl class="event-detail-list">
|
||||
<dt>Event ID</dt>
|
||||
<dd><code>{{ event.id }}</code></dd>
|
||||
|
|
@ -43,6 +53,11 @@
|
|||
<dt>Regions</dt>
|
||||
<dd>{{ event.regions | join(", ") }}</dd>
|
||||
{% endif %}
|
||||
{# Per-adapter curated fields. Dispatch by adapter name with a
|
||||
generic fallback; the chosen template is whichever exists
|
||||
first, so new adapters fall through to _default.html with
|
||||
no hardcoded adapter list here. #}
|
||||
{% include ["_event_rows/" ~ event.adapter ~ ".html", "_event_rows/_default.html"] %}
|
||||
<dt>Data</dt>
|
||||
<dd><pre class="event-data-pre">{{ event.data | tojson(indent=2) }}</pre></dd>
|
||||
</dl>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,8 @@
|
|||
}
|
||||
.map-legend {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem 1rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.map-legend-item {
|
||||
|
|
@ -105,6 +106,12 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{# 12-slot legend/marker palette. Adapters map to colors by their sorted index
|
||||
(loop.index0 % palette length) — no per-adapter color is hardcoded. #}
|
||||
{% set palette = [
|
||||
"#f59e0b", "#dc2626", "#7c3aed", "#2563eb", "#059669", "#db2777",
|
||||
"#0891b2", "#65a30d", "#ea580c", "#4f46e5", "#9333ea", "#0d9488"
|
||||
] %}
|
||||
<h1>Events</h1>
|
||||
|
||||
{% if filter_error %}
|
||||
|
|
@ -123,9 +130,9 @@
|
|||
<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>
|
||||
{% for a in adapters %}
|
||||
<option value="{{ a.name }}" {% if filter_values.adapter == a.name %}selected{% endif %}>{{ a.display_name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
|
|
@ -163,18 +170,12 @@
|
|||
<div id="events-map"></div>
|
||||
<div class="map-controls">
|
||||
<div class="map-legend">
|
||||
{% for a in adapters %}
|
||||
<div class="map-legend-item">
|
||||
<div class="map-legend-swatch" style="background-color: #f59e0b;"></div>
|
||||
<span>NWS (Weather)</span>
|
||||
</div>
|
||||
<div class="map-legend-item">
|
||||
<div class="map-legend-swatch" style="background-color: #dc2626;"></div>
|
||||
<span>FIRMS (Fire)</span>
|
||||
</div>
|
||||
<div class="map-legend-item">
|
||||
<div class="map-legend-swatch" style="background-color: #7c3aed;"></div>
|
||||
<span>USGS (Quake)</span>
|
||||
<div class="map-legend-swatch" style="background-color: {{ palette[loop.index0 % palette|length] }};"></div>
|
||||
<span>{{ a.display_name }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<button type="button" id="fit-to-results" class="outline secondary">Fit map to results</button>
|
||||
</div>
|
||||
|
|
@ -189,17 +190,57 @@
|
|||
var tileUrl = {{ tile_url | tojson }};
|
||||
var tileAttr = {{ tile_attribution | tojson }};
|
||||
|
||||
// Adapter color mapping
|
||||
// Adapter color mapping — built from the registry-derived adapter list and
|
||||
// the same palette the legend uses, keyed by sorted index. No adapter name
|
||||
// or color is hardcoded here.
|
||||
var PALETTE = {{ palette | tojson }};
|
||||
var ADAPTER_COLORS = {
|
||||
"nws": "#f59e0b",
|
||||
"firms": "#dc2626",
|
||||
"usgs_quake": "#7c3aed"
|
||||
{% for a in adapters %}{{ a.name | tojson }}: PALETTE[{{ loop.index0 }} % PALETTE.length]{{ "," if not loop.last }}
|
||||
{% endfor %}
|
||||
};
|
||||
|
||||
function getAdapterColor(adapter) {
|
||||
return ADAPTER_COLORS[adapter] || "#3388ff";
|
||||
}
|
||||
|
||||
// Flatten arbitrarily-nested GeoJSON coordinates into a flat [lng, lat] list.
|
||||
function flattenCoords(coords, out) {
|
||||
if (coords.length && typeof coords[0] === "number") {
|
||||
out.push(coords);
|
||||
return;
|
||||
}
|
||||
for (var i = 0; i < coords.length; i++) {
|
||||
flattenCoords(coords[i], out);
|
||||
}
|
||||
}
|
||||
|
||||
// A geometry whose vertices all collapse to a single point (zero extent in
|
||||
// both dimensions). Enrichment stores some point-like sources as degenerate
|
||||
// polygons; Leaflet would draw these invisibly, so we plot a marker instead.
|
||||
function isDegenerate(geom) {
|
||||
if (!geom || !geom.coordinates) return false;
|
||||
var pts = [];
|
||||
flattenCoords(geom.coordinates, pts);
|
||||
if (pts.length === 0) return false;
|
||||
var minX = pts[0][0], maxX = pts[0][0], minY = pts[0][1], maxY = pts[0][1];
|
||||
for (var i = 1; i < pts.length; i++) {
|
||||
if (pts[i][0] < minX) minX = pts[i][0];
|
||||
if (pts[i][0] > maxX) maxX = pts[i][0];
|
||||
if (pts[i][1] < minY) minY = pts[i][1];
|
||||
if (pts[i][1] > maxY) maxY = pts[i][1];
|
||||
}
|
||||
return (maxX - minX) < 1e-9 && (maxY - minY) < 1e-9;
|
||||
}
|
||||
|
||||
// Mean of all vertices, returned as Leaflet [lat, lng].
|
||||
function centroidLatLng(geom) {
|
||||
var pts = [];
|
||||
flattenCoords(geom.coordinates, pts);
|
||||
var sx = 0, sy = 0;
|
||||
for (var i = 0; i < pts.length; i++) { sx += pts[i][0]; sy += pts[i][1]; }
|
||||
return [sy / pts.length, sx / pts.length];
|
||||
}
|
||||
|
||||
// Initialize map
|
||||
var map = L.map("events-map").setView([39, -98], 4);
|
||||
|
||||
|
|
@ -275,23 +316,33 @@
|
|||
var adapter = row.dataset.adapter || "";
|
||||
var color = getAdapterColor(adapter);
|
||||
|
||||
var layer = L.geoJSON(geom, {
|
||||
style: {
|
||||
color: color,
|
||||
weight: 2,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.25
|
||||
},
|
||||
pointToLayer: function(feature, latlng) {
|
||||
return L.circleMarker(latlng, {
|
||||
radius: 8,
|
||||
var markerStyle = {
|
||||
radius: 8,
|
||||
color: color,
|
||||
weight: 2,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.25
|
||||
};
|
||||
|
||||
// Real polygons/lines render as geometry; zero-extent geometries
|
||||
// (degenerate polygons from enrichment) render as a point marker
|
||||
// so every non-null geometry is actually visible on the map.
|
||||
var layer;
|
||||
if (isDegenerate(geom)) {
|
||||
layer = L.circleMarker(centroidLatLng(geom), markerStyle);
|
||||
} else {
|
||||
layer = L.geoJSON(geom, {
|
||||
style: {
|
||||
color: color,
|
||||
weight: 2,
|
||||
fillColor: color,
|
||||
fillOpacity: 0.25
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
pointToLayer: function(feature, latlng) {
|
||||
return L.circleMarker(latlng, markerStyle);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
layer.bindPopup(buildPopup(row));
|
||||
layer.on("click", function() {
|
||||
|
|
@ -414,17 +465,80 @@
|
|||
// Fit to results button
|
||||
document.getElementById("fit-to-results").addEventListener("click", fitToAllLayers);
|
||||
|
||||
// Client-side sort of the displayed rows; state persists across HTMX swaps.
|
||||
var sortState = { col: null, dir: 1 }; // dir: 1 asc, -1 desc
|
||||
|
||||
function sortKey(row, col) {
|
||||
// Time sorts on the ISO timestamp for true chronological order.
|
||||
if (col === 1) return row.dataset.time || "";
|
||||
var cell = row.children[col];
|
||||
return cell ? cell.textContent.trim().toLowerCase() : "";
|
||||
}
|
||||
|
||||
function applySort() {
|
||||
if (sortState.col === null) return;
|
||||
var tbody = document.querySelector("#events-rows table.events-table tbody");
|
||||
if (!tbody) return;
|
||||
var pairs = [];
|
||||
tbody.querySelectorAll("tr.event-row").forEach(function(r) {
|
||||
pairs.push([r, r.nextElementSibling]); // main row + its detail row
|
||||
});
|
||||
// Array.prototype.sort is stable (ES2019+), so equal keys keep order.
|
||||
pairs.sort(function(a, b) {
|
||||
var ka = sortKey(a[0], sortState.col), kb = sortKey(b[0], sortState.col);
|
||||
if (ka < kb) return -sortState.dir;
|
||||
if (ka > kb) return sortState.dir;
|
||||
return 0;
|
||||
});
|
||||
pairs.forEach(function(p) {
|
||||
tbody.appendChild(p[0]);
|
||||
if (p[1]) tbody.appendChild(p[1]);
|
||||
});
|
||||
}
|
||||
|
||||
function updateSortIndicators(ths) {
|
||||
ths.forEach(function(th, idx) {
|
||||
th.querySelectorAll(".sort-ind").forEach(function(s) { s.remove(); });
|
||||
if (idx === sortState.col) {
|
||||
var s = document.createElement("span");
|
||||
s.className = "sort-ind";
|
||||
s.textContent = sortState.dir === 1 ? " ▲" : " ▼";
|
||||
th.appendChild(s);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function bindSortHandlers() {
|
||||
var ths = document.querySelectorAll("#events-rows table.events-table thead th");
|
||||
ths.forEach(function(th, idx) {
|
||||
if (idx === 0) return; // expand column is not sortable
|
||||
th.style.cursor = "pointer";
|
||||
th.onclick = function() {
|
||||
if (sortState.col === idx) { sortState.dir *= -1; }
|
||||
else { sortState.col = idx; sortState.dir = 1; }
|
||||
updateSortIndicators(ths);
|
||||
applySort();
|
||||
};
|
||||
});
|
||||
updateSortIndicators(ths);
|
||||
}
|
||||
|
||||
// Initial load - bind layers and fit bounds
|
||||
rebindEventLayers(); // Initial load only
|
||||
bindSortHandlers();
|
||||
if (false) { // DISABLED: map never auto-fits
|
||||
fitToAllLayers();
|
||||
isInitialLoad = false;
|
||||
}
|
||||
|
||||
// Re-bind layers after HTMX swap (but do NOT fit bounds)
|
||||
// Re-bind layers after HTMX swap so the map tracks the current (filtered /
|
||||
// paginated) result set. Viewport is preserved — we never auto-fit here.
|
||||
document.body.addEventListener("htmx:afterSwap", function(evt) {
|
||||
if (evt.detail.target.id === "events-rows") {
|
||||
// rebindEventLayers(); // DISABLED: map shows all events, only table filters
|
||||
rebindEventLayers();
|
||||
// Re-bind sort handlers to the new rows and re-apply the active sort.
|
||||
bindSortHandlers();
|
||||
applySort();
|
||||
// Do NOT call fitToAllLayers - preserve user viewport
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,6 +13,25 @@ from unittest.mock import AsyncMock, MagicMock, patch
|
|||
from central.bootstrap_config import Settings
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_enrichment_cache(tmp_path, monkeypatch):
|
||||
"""Redirect the supervisor's enrichment cache off the production path.
|
||||
|
||||
`central.supervisor.ENRICHMENT_CACHE_DB_PATH` defaults to
|
||||
/var/lib/central/enrichment_cache.db. Constructing a Supervisor opens it,
|
||||
so without this fixture the suite writes to (or, for any user without write
|
||||
access to /var/lib/central, fails on) the live cache. Point it at a
|
||||
per-test temp dir so no test ever touches the production path.
|
||||
"""
|
||||
import central.supervisor as supervisor_mod
|
||||
|
||||
monkeypatch.setattr(
|
||||
supervisor_mod,
|
||||
"ENRICHMENT_CACHE_DB_PATH",
|
||||
tmp_path / "enrichment_cache.db",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""Create an event loop for the test session."""
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ from central.config_source import (
|
|||
ConfigSource,
|
||||
DbConfigSource,
|
||||
)
|
||||
from central.bootstrap_config import get_settings
|
||||
from central.crypto import KEY_SIZE, clear_key_cache
|
||||
|
||||
# Test database DSN
|
||||
|
|
@ -31,11 +32,20 @@ def master_key_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Configure master key path for all tests."""
|
||||
clear_key_cache()
|
||||
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Configure master key path for all tests.
|
||||
|
||||
Clear get_settings (and the crypto key cache) AFTER setting the env so
|
||||
crypto rebuilds from the test key regardless of suite order, and again on
|
||||
teardown so the test key never leaks into a later test. See PR M-b.
|
||||
"""
|
||||
monkeypatch.setenv("CENTRAL_DB_DSN", TEST_DB_DSN)
|
||||
monkeypatch.setenv("CENTRAL_MASTER_KEY_PATH", str(master_key_path))
|
||||
clear_key_cache()
|
||||
get_settings.cache_clear()
|
||||
yield
|
||||
clear_key_cache()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import asyncpg
|
|||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from central.bootstrap_config import get_settings
|
||||
from central.config_store import ConfigStore
|
||||
from central.crypto import KEY_SIZE, clear_key_cache
|
||||
|
||||
|
|
@ -34,12 +35,24 @@ def master_key_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Configure master key path for all tests."""
|
||||
clear_key_cache()
|
||||
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Configure master key path for all tests.
|
||||
|
||||
CENTRAL_MASTER_KEY_PATH feeds Settings, which get_settings() lru-caches. An
|
||||
earlier test can warm that cache with the default /etc/central/master.key
|
||||
before this fixture runs, so the env change alone is not enough — clear
|
||||
get_settings (and the crypto key cache) AFTER setting the env so crypto
|
||||
rebuilds from the test key regardless of suite order, and again on teardown
|
||||
so the test key never leaks into a later test.
|
||||
"""
|
||||
monkeypatch.setenv("CENTRAL_DB_DSN", TEST_DB_DSN)
|
||||
monkeypatch.setenv("CENTRAL_MASTER_KEY_PATH", str(master_key_path))
|
||||
monkeypatch.setenv("CENTRAL_CSRF_SECRET", "test-csrf-secret-for-testing-only-32chars")
|
||||
clear_key_cache()
|
||||
get_settings.cache_clear()
|
||||
yield
|
||||
clear_key_cache()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
|
|
@ -338,3 +351,13 @@ class TestListenerReconnect:
|
|||
pytest.fail("Listener did not stop after cancellation")
|
||||
|
||||
assert listen_task.cancelled() or listen_task.done()
|
||||
|
||||
|
||||
def test_master_key_path_is_isolated(master_key_path: Path) -> None:
|
||||
"""Contract: after setup_master_key runs, get_settings() resolves the master
|
||||
key to the per-session test key — never the production /etc/central path —
|
||||
regardless of suite order. Fails on the pre-fix code in a full-suite run
|
||||
where get_settings was warmed with the default path by an earlier test.
|
||||
"""
|
||||
assert get_settings().master_key_path == master_key_path
|
||||
assert get_settings().master_key_path != Path("/etc/central/master.key")
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
"""Tests for events feed frontend routes."""
|
||||
|
||||
import html
|
||||
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
|
||||
from central.adapter_discovery import discover_adapters
|
||||
from central.gui import templates as _gui_templates
|
||||
from central.gui.routes import events_list, events_rows, events_json, _derive_subject
|
||||
|
||||
|
||||
class TestEventsFeedFrontendAuthenticated:
|
||||
|
|
@ -683,4 +686,512 @@ class TestEventRowDataAttributes:
|
|||
assert len(context["events"]) == 1
|
||||
assert context["events"][0]["adapter"] == "usgs_quake"
|
||||
assert context["events"][0]["category"] == "quake.event"
|
||||
assert context["events"][0]["subject"] == "M4.2 Earthquake"
|
||||
# `subject` is now derived from the inner payload (rendered partial),
|
||||
# not a DB pass-through, so the mock's input value is no longer echoed;
|
||||
# just confirm the field is present. See TestEventsJsonSubject for the
|
||||
# derivation contract.
|
||||
assert "subject" in context["events"][0]
|
||||
|
||||
|
||||
# --- PR L-b: operator /events tab polish ---------------------------------
|
||||
|
||||
|
||||
def _events_context(events):
|
||||
"""Minimal context for rendering _events_rows.html as a standalone fragment."""
|
||||
return {
|
||||
"events": events,
|
||||
"next_cursor": None,
|
||||
"filter_error": None,
|
||||
"filter_values": {
|
||||
"adapter": "", "category": "", "since": "", "until": "",
|
||||
"region_north": "", "region_south": "", "region_east": "",
|
||||
"region_west": "", "limit": "50",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _event(adapter, inner=None, geometry=None):
|
||||
"""Build an event dict matching _fetch_events output shape.
|
||||
|
||||
`inner` populates payload->data->data (the adapter-specific payload) at
|
||||
event["data"]["data"]["data"], which the per-adapter partials read.
|
||||
"""
|
||||
return {
|
||||
"id": "evt-" + adapter,
|
||||
"time": "2026-05-17T12:00:00+00:00",
|
||||
"received": "2026-05-17T12:00:00+00:00",
|
||||
"adapter": adapter,
|
||||
"category": adapter + ".test",
|
||||
"subject": "subject",
|
||||
"geometry": geometry,
|
||||
"geometry_summary": "",
|
||||
"data": {"data": {"data": inner or {}}},
|
||||
"regions": [],
|
||||
}
|
||||
|
||||
|
||||
def _render_rows(events):
|
||||
"""Render _events_rows.html through the real Jinja environment."""
|
||||
from central.gui import templates as gui_templates
|
||||
return gui_templates.env.get_template("_events_rows.html").render(
|
||||
**_events_context(events)
|
||||
)
|
||||
|
||||
|
||||
class TestRegistryDrivenAdapterFilter:
|
||||
"""(A) Adapter filter <select> is driven by discover_adapters(), no hardcoded list."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_options_cover_every_discovered_adapter(self):
|
||||
from central.adapter_discovery import discover_adapters
|
||||
registry = discover_adapters()
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.state.operator = MagicMock(id=1, username="admin")
|
||||
mock_request.state.csrf_token = "test_csrf"
|
||||
mock_request.query_params = {}
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.fetch.return_value = []
|
||||
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):
|
||||
await events_list(mock_request)
|
||||
|
||||
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
||||
|
||||
# The list is exactly the registry, sorted by name (stable), no extras.
|
||||
assert [a["name"] for a in context["adapters"]] == sorted(registry.keys())
|
||||
# Each entry carries name + display_name straight from the adapter class.
|
||||
for cls in registry.values():
|
||||
assert {"name": cls.name, "display_name": cls.display_name} in context["adapters"]
|
||||
|
||||
|
||||
class TestPerAdapterRowPartials:
|
||||
"""(C) Per-adapter row partials with registry-derived dispatch + _default fallback."""
|
||||
|
||||
def test_every_discovered_adapter_has_a_partial(self):
|
||||
from central.adapter_discovery import discover_adapters
|
||||
from central.gui import templates as gui_templates
|
||||
# get_template raises TemplateNotFound if a per-adapter file is missing.
|
||||
for name in discover_adapters():
|
||||
gui_templates.env.get_template("_event_rows/%s.html" % name)
|
||||
|
||||
def test_default_fallback_partial_exists(self):
|
||||
from central.gui import templates as gui_templates
|
||||
gui_templates.env.get_template("_event_rows/_default.html")
|
||||
|
||||
def test_every_discovered_adapter_renders_without_error(self):
|
||||
from central.adapter_discovery import discover_adapters
|
||||
for name in discover_adapters():
|
||||
html = _render_rows([_event(name)])
|
||||
assert 'data-adapter="%s"' % name in html
|
||||
|
||||
def test_unknown_adapter_falls_back_to_default(self):
|
||||
# No bespoke partial -> dispatch resolves to _default.html (no crash),
|
||||
# and the raw payload block is still rendered.
|
||||
html = _render_rows([_event("not_a_real_adapter", inner={"foo": "bar"})])
|
||||
assert 'data-adapter="not_a_real_adapter"' in html
|
||||
assert "event-data-pre" in html
|
||||
|
||||
def test_usgs_quake_partial_surfaces_curated_fields(self):
|
||||
html = _render_rows([_event(
|
||||
"usgs_quake",
|
||||
inner={"magnitude": 4.2, "magType": "mb", "place": "10km N of Town", "depth": 5.0},
|
||||
)])
|
||||
assert "Magnitude" in html
|
||||
assert "4.2" in html
|
||||
assert "10km N of Town" in html
|
||||
|
||||
def test_nws_partial_surfaces_curated_fields(self):
|
||||
html = _render_rows([_event(
|
||||
"nws",
|
||||
inner={"event": "Tornado Warning", "headline": "TORNADO", "severity": "Extreme"},
|
||||
)])
|
||||
assert "Tornado Warning" in html
|
||||
assert "Extreme" in html
|
||||
|
||||
|
||||
class TestMapAllAdapterGeometry:
|
||||
"""(B) Every non-null geometry reaches the map; rebind-on-swap is enabled."""
|
||||
|
||||
def test_polygon_geometry_emitted_as_data_geometry(self):
|
||||
poly = {
|
||||
"type": "Polygon",
|
||||
"coordinates": [[[-104.18, 37.14], [-103.66, 37.14],
|
||||
[-103.66, 37.43], [-104.18, 37.43], [-104.18, 37.14]]],
|
||||
}
|
||||
html = _render_rows([_event("nws", inner={"event": "x"}, geometry=poly)])
|
||||
assert "data-geometry=" in html
|
||||
assert "Polygon" in html
|
||||
|
||||
def test_event_without_geometry_omits_data_geometry(self):
|
||||
html = _render_rows([_event("swpc_protons", inner={"flux": 1.0})])
|
||||
assert "data-geometry=" not in html
|
||||
|
||||
def test_map_rebinds_on_swap_and_handles_degenerate_geometry(self):
|
||||
# Regression guard for the NWIS-only map: rebind must fire on HTMX swap
|
||||
# and the degenerate-geometry fallback must exist.
|
||||
import pathlib
|
||||
from central.gui import templates as gui_templates
|
||||
src = pathlib.Path(
|
||||
gui_templates.env.loader.searchpath[0], "events_list.html"
|
||||
).read_text()
|
||||
assert "// rebindEventLayers(); // DISABLED" not in src
|
||||
assert "isDegenerate" in src
|
||||
|
||||
|
||||
# --- PR L-c: readable Time / Location / Subject / Adapter columns ---------
|
||||
|
||||
|
||||
def _first_row_cells(html):
|
||||
"""Visible <td> text of the first event-row (before its detail row).
|
||||
|
||||
Splits on the detail row's class attribute (not the bare string
|
||||
'event-detail', which also appears in the page's <style> block).
|
||||
"""
|
||||
import re
|
||||
body = html.split('class="event-detail"')[0]
|
||||
return [
|
||||
re.sub(r"<[^>]+>", "", c).strip()
|
||||
for c in re.findall(r"<td[^>]*>(.*?)</td>", body, re.S)
|
||||
]
|
||||
|
||||
|
||||
class TestEventTimeFormat:
|
||||
"""(A) Server-side 'MM-DD-YYYY HH:MM UTC' formatting (24h, no seconds)."""
|
||||
|
||||
def test_format_basic_utc(self):
|
||||
from central.gui.routes import _format_event_time
|
||||
assert _format_event_time("2026-05-21T06:00:00+00:00") == "05-21-2026 06:00 UTC"
|
||||
|
||||
def test_format_converts_offset_to_utc(self):
|
||||
from central.gui.routes import _format_event_time
|
||||
# 19:30 at -06:00 is 01:30 UTC the next day.
|
||||
assert _format_event_time("2026-05-20T19:30:00-06:00") == "05-21-2026 01:30 UTC"
|
||||
|
||||
def test_format_empty_and_none(self):
|
||||
from central.gui.routes import _format_event_time
|
||||
assert _format_event_time("") == ""
|
||||
assert _format_event_time(None) == ""
|
||||
|
||||
def test_format_no_seconds_no_offset_suffix(self):
|
||||
from central.gui.routes import _format_event_time
|
||||
out = _format_event_time("2026-01-02T03:04:59+00:00")
|
||||
assert out == "01-02-2026 03:04 UTC"
|
||||
assert ":59" not in out and "+00" not in out
|
||||
|
||||
|
||||
class TestTableDisplayDecoration:
|
||||
"""(A)/(D) _decorate_table_events adds display fields; /events.json unaffected."""
|
||||
|
||||
def test_adapter_display_matches_registry(self):
|
||||
from central.adapter_discovery import discover_adapters
|
||||
from central.gui.routes import _decorate_table_events, _format_event_time
|
||||
registry = discover_adapters()
|
||||
events = [_event(name) for name in registry]
|
||||
_decorate_table_events(events)
|
||||
for ev in events:
|
||||
cls = registry[ev["adapter"]]
|
||||
assert ev["adapter_display"] == cls.display_name
|
||||
assert ev["time_human"] == _format_event_time(ev["time"])
|
||||
|
||||
def test_unknown_adapter_display_falls_back_to_name(self):
|
||||
from central.gui.routes import _decorate_table_events
|
||||
events = [_event("not_a_real_adapter")]
|
||||
_decorate_table_events(events)
|
||||
assert events[0]["adapter_display"] == "not_a_real_adapter"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_events_json_has_no_table_only_fields(self):
|
||||
# No /events.json schema change: display fields must not leak into JSON.
|
||||
mock_request = MagicMock()
|
||||
mock_request.state.operator = MagicMock(id=1, username="admin")
|
||||
mock_request.query_params = {}
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_conn.fetch.return_value = [{
|
||||
"id": "e1",
|
||||
"time": datetime(2026, 5, 21, 6, 0, tzinfo=timezone.utc),
|
||||
"received": datetime(2026, 5, 21, 6, 0, tzinfo=timezone.utc),
|
||||
"adapter": "nwis",
|
||||
"category": "hydro",
|
||||
"subject": None,
|
||||
"geometry": None,
|
||||
"data": {},
|
||||
"regions": [],
|
||||
}]
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.acquire.return_value.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||
mock_pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
with patch("central.gui.routes.get_pool", return_value=mock_pool):
|
||||
resp = await events_json(mock_request)
|
||||
|
||||
ev = json.loads(resp.body)["events"][0]
|
||||
assert "time_human" not in ev
|
||||
assert "adapter_display" not in ev
|
||||
|
||||
|
||||
class TestLocationColumn:
|
||||
"""(B) Generic _enriched/top-level location reader — no adapter logic."""
|
||||
|
||||
def _location(self, inner):
|
||||
return _first_row_cells(_render_rows([_event("usgs_quake", inner=inner)]))[2]
|
||||
|
||||
def test_city_state_country(self):
|
||||
loc = self._location({"_enriched": {"geocoder": {
|
||||
"city": "Trinidad", "state": "Colorado", "country": "United States"}}})
|
||||
assert loc == "Trinidad, Colorado, United States"
|
||||
|
||||
def test_state_country_when_city_null(self):
|
||||
loc = self._location({"_enriched": {"geocoder": {
|
||||
"city": None, "state": "Missouri", "country": "United States"}}})
|
||||
assert loc == "Missouri, United States"
|
||||
|
||||
def test_top_level_country_fallback(self):
|
||||
# gdacs-style: no geocoder, country at top level.
|
||||
assert self._location({"country": "Austria"}) == "Austria"
|
||||
|
||||
def test_top_level_state_only_fallback(self):
|
||||
# wfigs-style: state code at top level, no country.
|
||||
assert self._location({"state": "CO"}) == "CO"
|
||||
|
||||
def test_landclass_fallback(self):
|
||||
loc = self._location({"_enriched": {"geocoder": {
|
||||
"city": None, "state": None, "country": None,
|
||||
"landclass": "Ridgecrest Field Office"}}})
|
||||
assert loc == "Ridgecrest Field Office"
|
||||
|
||||
def test_coordinates_alone_are_not_a_location(self):
|
||||
# Bare lat/lon is a position, not a place name -> "—".
|
||||
assert self._location({"latitude": 35.3603324, "longitude": -117.7854995}) == "—"
|
||||
|
||||
def test_none_when_nothing_available(self):
|
||||
assert self._location({}) == "—"
|
||||
|
||||
|
||||
class TestSubjectColumn:
|
||||
"""(C) Per-adapter one-line summaries, registry-derived dispatch + fallback."""
|
||||
|
||||
def test_every_adapter_has_a_summary_partial(self):
|
||||
from central.adapter_discovery import discover_adapters
|
||||
from central.gui import templates as gui_templates
|
||||
for name in discover_adapters():
|
||||
gui_templates.env.get_template("_event_summaries/%s.html" % name)
|
||||
|
||||
def test_default_summary_partial_exists(self):
|
||||
from central.gui import templates as gui_templates
|
||||
gui_templates.env.get_template("_event_summaries/_default.html")
|
||||
|
||||
def test_every_adapter_summary_renders_without_error(self):
|
||||
from central.adapter_discovery import discover_adapters
|
||||
for name in discover_adapters():
|
||||
_render_rows([_event(name)]) # must not raise
|
||||
|
||||
def test_usgs_quake_summary_is_plain_language(self):
|
||||
cells = _first_row_cells(_render_rows([_event(
|
||||
"usgs_quake", inner={"magnitude": 1.347, "magType": "ml",
|
||||
"place": "14 km W of Johannesburg, CA"})]))
|
||||
assert cells[3] == "Magnitude 1.3 — 14 km W of Johannesburg, CA"
|
||||
assert "ml" not in cells[3] # scale code dropped
|
||||
|
||||
def test_nwis_summary_drops_parameter_code(self):
|
||||
cells = _first_row_cells(_render_rows([_event(
|
||||
"nwis", inner={"parameter_code": "00060", "value": 111.0,
|
||||
"unit_of_measure": "ft^3/s"})]))
|
||||
assert cells[3] == "Water reading: 111.0 ft^3/s"
|
||||
assert "00060" not in cells[3] # opaque pcode dropped
|
||||
|
||||
def test_unknown_adapter_summary_is_dash(self):
|
||||
cells = _first_row_cells(_render_rows([_event("not_a_real_adapter", inner={"x": 1})]))
|
||||
assert cells[3] == "—"
|
||||
|
||||
def test_summary_populates_data_subject_for_popup(self):
|
||||
html = _render_rows([_event("usgs_quake", inner={"magnitude": 1.347,
|
||||
"place": "near Town"})])
|
||||
assert 'data-subject="Magnitude 1.3 — near Town"' in html
|
||||
|
||||
|
||||
class TestTableRendersThroughHTTP:
|
||||
"""End-to-end HTTP render: Time and Adapter cells must be populated in the
|
||||
real response, guarding the route->template binding (not the helper alone)."""
|
||||
|
||||
def _mock_pool(self):
|
||||
now = datetime(2026, 5, 21, 6, 0, tzinfo=timezone.utc)
|
||||
|
||||
def _fetchrow(query, *args):
|
||||
q = " ".join(str(query).split())
|
||||
if "config.sessions" in q:
|
||||
return {"id": 1, "username": "admin", "created_at": now,
|
||||
"password_changed_at": now, "csrf_token": "csrf"}
|
||||
if "map_tile_url" in q:
|
||||
return {"map_tile_url": "https://t/{z}/{x}/{y}.png",
|
||||
"map_attribution": "OSM"}
|
||||
return None
|
||||
|
||||
rows = [
|
||||
{"id": "evt-q", "time": now, "received": now, "adapter": "usgs_quake",
|
||||
"category": "quake", "subject": None, "geometry": None,
|
||||
"data": {"data": {"data": {"magnitude": 1.3, "place": "near Town"}}},
|
||||
"regions": []},
|
||||
{"id": "evt-w", "time": now, "received": now, "adapter": "nwis",
|
||||
"category": "hydro", "subject": None, "geometry": None,
|
||||
"data": {"data": {"data": {"value": 5.0, "unit_of_measure": "ft"}}},
|
||||
"regions": []},
|
||||
]
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.fetchrow = AsyncMock(side_effect=_fetchrow)
|
||||
mock_conn.fetch = AsyncMock(return_value=rows)
|
||||
mock_conn.__aenter__ = AsyncMock(return_value=mock_conn)
|
||||
mock_conn.__aexit__ = AsyncMock()
|
||||
mock_pool = MagicMock()
|
||||
mock_pool.acquire = MagicMock(return_value=mock_conn)
|
||||
return mock_pool
|
||||
|
||||
def _client(self):
|
||||
from fastapi import FastAPI
|
||||
from starlette.testclient import TestClient
|
||||
from central.gui.middleware import SessionMiddleware
|
||||
from central.gui.routes import router
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.add_middleware(SessionMiddleware)
|
||||
return TestClient(app, cookies={"central_session": "valid"})
|
||||
|
||||
def _expected_adapter_display(self):
|
||||
from central.adapter_discovery import discover_adapters
|
||||
return discover_adapters()["usgs_quake"].display_name
|
||||
|
||||
def test_events_page_time_and_adapter_cells_populated(self):
|
||||
mock_pool = self._mock_pool()
|
||||
with patch("central.gui.middleware.get_pool", return_value=mock_pool), \
|
||||
patch("central.gui.routes.get_pool", return_value=mock_pool):
|
||||
resp = self._client().get("/events")
|
||||
assert resp.status_code == 200
|
||||
cells = _first_row_cells(resp.text)
|
||||
assert cells[1].endswith("UTC") and "2026" in cells[1] # Time non-empty
|
||||
assert cells[4] == self._expected_adapter_display() # Adapter display_name
|
||||
|
||||
def test_events_rows_fragment_time_and_adapter_cells_populated(self):
|
||||
mock_pool = self._mock_pool()
|
||||
with patch("central.gui.middleware.get_pool", return_value=mock_pool), \
|
||||
patch("central.gui.routes.get_pool", return_value=mock_pool):
|
||||
resp = self._client().get("/events/rows")
|
||||
assert resp.status_code == 200
|
||||
cells = _first_row_cells(resp.text)
|
||||
assert cells[1].endswith("UTC") and "2026" in cells[1] # Time non-empty
|
||||
assert cells[4] == self._expected_adapter_display() # Adapter display_name
|
||||
|
||||
|
||||
# --- feat(events-json-subject): JSON subject derivation ------------------
|
||||
|
||||
# Representative inner adapter payloads (payload->'data'->'data'), captured from
|
||||
# production -- one per registered adapter. Keyed by adapter name so the
|
||||
# coverage test below fails loudly if a new adapter ships without a sample.
|
||||
_SAMPLE_INNER = {
|
||||
"eonet": {"title": "Kress Wildfire, Swisher, Texas"},
|
||||
"firms": {"frp": 0.34, "confidence": "nominal"},
|
||||
"gdacs": {"title": "Green flood alert in Austria", "alertlevel": "Green"},
|
||||
"inciweb": {"title": "MTHLF Jericho Creek"},
|
||||
"nwis": {"value": 93.2, "unit_of_measure": "ft^3/s"},
|
||||
"nws": {"event": "Special Weather Statement", "severity": "Moderate"},
|
||||
"swpc_alerts": {
|
||||
"product_id": "EF3A",
|
||||
"message": (
|
||||
"Space Weather Message Code: ALTEF3\r\nSerial Number: 3691\r\n"
|
||||
"Issue Time: 2026 May 21 0509 UTC\r\n\r\nCONTINUED ALERT: "
|
||||
"Electron 2MeV Integral Flux exceeded 1000pfu"
|
||||
),
|
||||
},
|
||||
"swpc_kindex": {"Kp": 1.0},
|
||||
"swpc_protons": {"flux": 15.06399917602539, "energy": ">=1 MeV"},
|
||||
"usgs_quake": {"magnitude": 1.009682538298, "place": "17 km W of Searles Valley, CA"},
|
||||
"wfigs_incidents": {"county": "Montezuma", "state": "CO"},
|
||||
"wfigs_perimeters": {"county": "Carbon", "state": "MT"},
|
||||
}
|
||||
|
||||
# Exact expected subjects for the deterministic adapters. swpc_alerts is omitted
|
||||
# (its message runs through Jinja's truncate(80)) and is checked separately.
|
||||
# swpc_protons expects unescaped ">=" -- _derive_subject html.unescapes the
|
||||
# autoescaped partial output so JSON consumers get plain text.
|
||||
_EXPECTED_SUBJECT = {
|
||||
"eonet": "Kress Wildfire, Swisher, Texas",
|
||||
"firms": "Fire detected — 0.34 MW radiative power",
|
||||
"gdacs": "Green flood alert in Austria — Green alert",
|
||||
"inciweb": "MTHLF Jericho Creek",
|
||||
"nwis": "Water reading: 93.2 ft^3/s",
|
||||
"nws": "Special Weather Statement — Moderate",
|
||||
"swpc_kindex": "Geomagnetic activity (Kp index): 1.0",
|
||||
"swpc_protons": "Solar proton flux: 15.06 pfu at >=1 MeV",
|
||||
"usgs_quake": "Magnitude 1.0 — 17 km W of Searles Valley, CA",
|
||||
"wfigs_incidents": "Wildfire incident — Montezuma, CO",
|
||||
"wfigs_perimeters": "Wildfire perimeter — Carbon, MT",
|
||||
}
|
||||
|
||||
|
||||
def _subject_event(adapter: str, inner: dict) -> dict:
|
||||
"""Build a minimal event dict shaped like _fetch_events output."""
|
||||
return {"adapter": adapter, "data": {"data": {"data": inner}}}
|
||||
|
||||
|
||||
class TestEventsJsonSubject:
|
||||
"""/events.json `subject` is derived from the inner payload and carries the
|
||||
same human text as the GUI's per-adapter Subject cell (feat/events-json-subject).
|
||||
|
||||
The old `payload->>'subject'` SQL was always null (the CloudEvents envelope
|
||||
has no top-level subject). Parameterized over discover_adapters() -- no
|
||||
hardcoded adapter list.
|
||||
"""
|
||||
|
||||
def test_sample_covers_every_registered_adapter(self):
|
||||
"""No hardcoded list: samples must track the live registry exactly."""
|
||||
assert set(_SAMPLE_INNER) == set(discover_adapters())
|
||||
|
||||
@pytest.mark.parametrize("adapter", sorted(discover_adapters()))
|
||||
def test_subject_non_null_per_adapter(self, adapter):
|
||||
"""Every registered adapter derives a non-null subject for a real event."""
|
||||
event = _subject_event(adapter, _SAMPLE_INNER[adapter])
|
||||
assert _derive_subject(event) is not None
|
||||
|
||||
@pytest.mark.parametrize("adapter", sorted(discover_adapters()))
|
||||
def test_subject_matches_rendered_partial(self, adapter):
|
||||
"""Derived subject equals the adapter's own partial (unescaped) -- the
|
||||
JSON path and the GUI Subject cell never diverge."""
|
||||
event = _subject_event(adapter, _SAMPLE_INNER[adapter])
|
||||
oracle = html.unescape(
|
||||
_gui_templates.env.get_template(f"_event_summaries/{adapter}.html").render(event=event)
|
||||
).strip()
|
||||
assert _derive_subject(event) == oracle
|
||||
|
||||
@pytest.mark.parametrize("adapter", sorted(_EXPECTED_SUBJECT))
|
||||
def test_subject_exact_human_text(self, adapter):
|
||||
"""Pin the human-readable subject for the deterministic adapters."""
|
||||
event = _subject_event(adapter, _SAMPLE_INNER[adapter])
|
||||
assert _derive_subject(event) == _EXPECTED_SUBJECT[adapter]
|
||||
|
||||
def test_swpc_alerts_prefixes_id_and_truncates_message(self):
|
||||
"""swpc_alerts subject prefixes the product id and truncates the body."""
|
||||
event = _subject_event("swpc_alerts", _SAMPLE_INNER["swpc_alerts"])
|
||||
subject = _derive_subject(event)
|
||||
assert subject is not None
|
||||
assert subject.startswith("Space weather alert EF3A: ")
|
||||
assert subject.endswith("...")
|
||||
|
||||
def test_unknown_adapter_yields_none(self):
|
||||
"""Unknown adapters fall back to _default.html -> no subject."""
|
||||
assert _derive_subject(_subject_event("does_not_exist", {"x": 1})) is None
|
||||
|
||||
def test_missing_source_fields_yields_none(self):
|
||||
"""An event lacking its adapter's source fields derives no subject."""
|
||||
assert _derive_subject(_subject_event("usgs_quake", {})) is None
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import base64
|
|||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import asyncpg
|
||||
import pytest
|
||||
|
|
@ -14,6 +14,7 @@ import pytest_asyncio
|
|||
from central.config_models import AdapterConfig
|
||||
from central.config_source import DbConfigSource
|
||||
from central.config_store import ConfigStore
|
||||
from central.bootstrap_config import get_settings
|
||||
from central.crypto import KEY_SIZE, clear_key_cache
|
||||
|
||||
# Test database DSN
|
||||
|
|
@ -33,11 +34,20 @@ def master_key_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Configure master key path for all tests."""
|
||||
clear_key_cache()
|
||||
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Configure master key path for all tests.
|
||||
|
||||
Clear get_settings (and the crypto key cache) AFTER setting the env so
|
||||
crypto rebuilds from the test key regardless of suite order, and again on
|
||||
teardown so the test key never leaks into a later test. See PR M-b.
|
||||
"""
|
||||
monkeypatch.setenv("CENTRAL_DB_DSN", TEST_DB_DSN)
|
||||
monkeypatch.setenv("CENTRAL_MASTER_KEY_PATH", str(master_key_path))
|
||||
clear_key_cache()
|
||||
get_settings.cache_clear()
|
||||
yield
|
||||
clear_key_cache()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
|
|
@ -196,10 +206,7 @@ class TestRateLimitGuarantee:
|
|||
state.config = new_config
|
||||
state.adapter.cadence_s = 90
|
||||
|
||||
# Calculate expected next poll time
|
||||
expected_next_poll = last_poll + timedelta(seconds=90)
|
||||
now = datetime.now(timezone.utc)
|
||||
expected_wait = max(0, (expected_next_poll - now).total_seconds())
|
||||
|
||||
# The wait time should be based on last_poll + new_cadence
|
||||
# Since last_poll was 30 seconds ago and new cadence is 90,
|
||||
|
|
@ -220,7 +227,6 @@ class TestRateLimitGuarantee:
|
|||
If operator increases cadence to 120s after a gap of 150s,
|
||||
the poll should happen now (not wait another 120s).
|
||||
"""
|
||||
from central.supervisor import AdapterState
|
||||
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.name = "test"
|
||||
|
|
@ -238,13 +244,6 @@ class TestRateLimitGuarantee:
|
|||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
state = AdapterState(
|
||||
name="test",
|
||||
adapter=mock_adapter,
|
||||
config=config,
|
||||
last_completed_poll=last_poll,
|
||||
)
|
||||
|
||||
# Calculate next poll time
|
||||
now = datetime.now(timezone.utc)
|
||||
next_poll_at = last_poll.timestamp() + config.cadence_s
|
||||
|
|
@ -264,7 +263,6 @@ class TestRateLimitGuarantee:
|
|||
poll should be at (last_completed_poll + cadence_s), not immediately
|
||||
(unless that time has already passed).
|
||||
"""
|
||||
from central.supervisor import AdapterState
|
||||
|
||||
mock_adapter = MagicMock()
|
||||
mock_adapter.name = "test"
|
||||
|
|
@ -283,13 +281,6 @@ class TestRateLimitGuarantee:
|
|||
updated_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
state = AdapterState(
|
||||
name="test",
|
||||
adapter=mock_adapter,
|
||||
config=config,
|
||||
last_completed_poll=last_poll,
|
||||
)
|
||||
|
||||
# Calculate next poll time
|
||||
now = datetime.now(timezone.utc)
|
||||
next_poll_at = last_poll.timestamp() + config.cadence_s
|
||||
|
|
|
|||
|
|
@ -9,17 +9,16 @@ IMPORTANT: These tests are designed to:
|
|||
- PASS on fixed code (last_completed_poll is preserved across disable/enable)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
import pytest_asyncio
|
||||
|
||||
from central.config_models import AdapterConfig
|
||||
from central.bootstrap_config import get_settings
|
||||
from central.crypto import KEY_SIZE, clear_key_cache
|
||||
|
||||
|
||||
|
|
@ -56,11 +55,20 @@ def master_key_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
|||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Configure master key path for all tests."""
|
||||
clear_key_cache()
|
||||
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""Configure master key path for all tests.
|
||||
|
||||
Clear get_settings (and the crypto key cache) AFTER setting the env so
|
||||
crypto rebuilds from the test key regardless of suite order, and again on
|
||||
teardown so the test key never leaks into a later test. See PR M-b.
|
||||
"""
|
||||
monkeypatch.setenv("CENTRAL_DB_DSN", TEST_DB_DSN)
|
||||
monkeypatch.setenv("CENTRAL_MASTER_KEY_PATH", str(master_key_path))
|
||||
clear_key_cache()
|
||||
get_settings.cache_clear()
|
||||
yield
|
||||
clear_key_cache()
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
class MockConfigSource:
|
||||
|
|
@ -139,12 +147,18 @@ class MockNWSAdapter:
|
|||
|
||||
@pytest.fixture
|
||||
def mock_nats():
|
||||
"""Mock NATS connection."""
|
||||
"""Mock NATS connection.
|
||||
|
||||
nats-py's `nc.jetstream()` is synchronous, so model it with a sync
|
||||
MagicMock. (As an AsyncMock attribute, `supervisor._js = nc.jetstream()`
|
||||
would assign an unawaited coroutine — the "coroutine ... was never awaited"
|
||||
warning — rather than the JetStream mock.)
|
||||
"""
|
||||
mock_nc = AsyncMock()
|
||||
mock_nc.publish = AsyncMock()
|
||||
mock_js = AsyncMock()
|
||||
mock_js.publish = AsyncMock()
|
||||
mock_nc.jetstream.return_value = mock_js
|
||||
mock_nc.jetstream = MagicMock(return_value=mock_js)
|
||||
return mock_nc
|
||||
|
||||
|
||||
|
|
@ -179,7 +193,7 @@ class TestEnableDisableEnableIntegration:
|
|||
- Assert next poll fires immediately (last+cadence is in past)
|
||||
- Assert exactly ONE poll happens, not multiple catch-up
|
||||
"""
|
||||
from central.supervisor import Supervisor, AdapterState
|
||||
from central.supervisor import Supervisor
|
||||
|
||||
config_source = MockConfigSource()
|
||||
initial_config = AdapterConfig(
|
||||
|
|
@ -290,7 +304,7 @@ class TestEnableDisableEnableIntegration:
|
|||
- Re-enable adapter 20 seconds later (still within cadence window)
|
||||
- Assert next poll fires at last_poll + 60s, NOT immediately
|
||||
"""
|
||||
from central.supervisor import Supervisor, AdapterState
|
||||
from central.supervisor import Supervisor
|
||||
|
||||
config_source = MockConfigSource()
|
||||
initial_config = AdapterConfig(
|
||||
|
|
@ -574,3 +588,27 @@ class TestEnableDisableEnableIntegration:
|
|||
|
||||
# State should be gone
|
||||
assert "nws" not in supervisor._adapter_states
|
||||
|
||||
|
||||
def test_enrichment_cache_path_is_hermetic(mock_config_store, tmp_path: Path) -> None:
|
||||
"""No test may touch the production enrichment cache.
|
||||
|
||||
The autouse `isolate_enrichment_cache` fixture (conftest) must redirect
|
||||
ENRICHMENT_CACHE_DB_PATH off /var/lib/central onto a per-test temp dir, and
|
||||
constructing a Supervisor must open the cache there — not in production.
|
||||
"""
|
||||
import central.supervisor as supervisor_mod
|
||||
|
||||
patched = supervisor_mod.ENRICHMENT_CACHE_DB_PATH
|
||||
assert tmp_path in patched.parents
|
||||
assert "/var/lib/central" not in str(patched)
|
||||
|
||||
supervisor = supervisor_mod.Supervisor(
|
||||
config_source=MockConfigSource(),
|
||||
config_store=mock_config_store,
|
||||
nats_url="nats://localhost:4222",
|
||||
cloudevents_config=None,
|
||||
)
|
||||
# __init__ opened the cache at the temp path, leaving the db file behind.
|
||||
assert patched.exists()
|
||||
assert supervisor._enrichment_cache is not None
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue