Compare commits

...

14 commits

Author SHA1 Message Date
76c5e94b39
Merge pull request #52 from zvx-echo6/feat/events-json-subject
feat(events-subject): fix /events.json always-null subject column
2026-05-21 13:07:59 -06:00
Matt Johnson
578c9bc0fe feat(events-subject): derive /events.json subject from inner payload
The events_json SELECT read payload->>'subject', but the CloudEvents
envelope has no top-level subject, so every JSON consumer saw
subject: null. The /events GUI already derives readable subjects via
per-adapter templates/_event_summaries/{adapter}.html (PR L-c).

This makes the JSON path produce the same plain-text subjects with no
duplicated logic: _derive_subject(event) renders the same partial the
table uses (falling back to _default.html) and html.unescapes the
autoescaped output so JSON consumers get plain text (e.g. ">=1 MeV"
rather than the escaped ">=1 MeV"). _fetch_events now sets subject
from it and drops the always-null SQL expression. The GUI Subject cell
is unchanged.

Adds TestEventsJsonSubject (parameterized over discover_adapters(), no
hardcoded list): non-null subject per adapter, equality with the rendered
partial, pinned human text for the deterministic adapters, swpc_alerts
truncation, and null fallbacks. Updates one TestEventRowDataAttributes
assertion that pinned the old SQL pass-through contract.

One route change plus tests; central-gui restart required.
Full suite: 629 passed, 1 skipped (central and unprivileged zvx).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 19:07:19 +00:00
d0375225b2
Merge pull request #51 from zvx-echo6/chore/lint-cleanup-test-imports
chore(lint-cleanup): remove 10 pre-existing ruff issues in 4 test files
2026-05-21 12:21:05 -06:00
Matt Johnson
ff3d9bb3c3 chore(lint-cleanup): remove 10 pre-existing ruff issues in 4 test files
Cleans up unused imports and dead locals flagged by ruff in the test
files PR #50 (M-b) touched. Tests-only; no production code, no service
restart.

- test_supervisor_hotreload.py: drop unused AsyncMock/patch imports,
  dead expected_wait/expected_next_poll locals, and two dead
  state = AdapterState(...) blocks plus their now-orphaned local imports
- test_supervisor_integration.py: drop unused asyncio/patch/pytest_asyncio
  imports and AdapterState from two function-local imports

ruff tests/ 92 -> 82 (the 4 named files now 0; all other files unchanged).
Full suite: 590 passed, 1 skipped (central and unprivileged zvx).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 18:20:18 +00:00
e33a896592
Merge pull request #50 from zvx-echo6/chore/config-store-test-isolation
chore(M-b): clear get_settings lru_cache in test fixtures (fixes order-dependent crypto failures + 3 latent siblings)
2026-05-21 09:52:29 -06:00
zvx
f666014821 chore(M-b): clear get_settings lru_cache in test fixtures (fixes order-dependent crypto failures + 3 latent siblings)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 15:51:51 +00:00
69dddd0240
Merge pull request #49 from zvx-echo6/chore/hermetic-enrichment-cache
chore(M): make enrichment-cache path test-hermetic via conftest autouse fixture
2026-05-21 08:24:10 -06:00
zvx
765635e720 chore(M): make enrichment-cache path test-hermetic via conftest autouse fixture
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 14:23:31 +00:00
496dd1626f
Merge pull request #48 from zvx-echo6/feat/l-c-events-table-readable
feat(L-c): operator /events table polish — readable Time, Location, Subject, Adapter columns; sortable; plain-language summaries
2026-05-21 01:06:00 -06:00
zvx
5d4320bc73 feat(L-c): operator /events table polish — readable Time, Location, Subject, Adapter columns; sortable; plain-language summaries
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 07:05:20 +00:00
339c980c9a
Merge pull request #47 from zvx-echo6/feat/l-b-events-tab-polish
feat(L-b): operator /events tab polish — registry-derived filter, all-adapter map, per-adapter row partials
2026-05-21 00:08:01 -06:00
zvx
49d85021e8 feat(L-b): operator /events tab polish — registry-derived filter, all-adapter map, per-adapter row partials
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 05:45:15 +00:00
1cf1eabb1c
Merge pull request #46 from zvx-echo6/feature/3-mb-apply-enrichment-coordless
fix(3-M.b): apply_enrichment always attaches _enriched (coordless events)
2026-05-20 22:09:33 -06:00
Matt Johnson
f0c044505f fix(3-M.b): apply_enrichment always attaches _enriched for declared adapters
Coordless events such as removal tombstones with null lat/lon, from adapters that declare enrichment_locations, previously fell off the loop without writing _enriched and carried no geocoder bundle at all, violating the every-event-carries-_enriched design rule. Add a post-loop fallback that resolves the null location to an all-null bundle per enricher. Adapters with no enrichment_locations remain skipped.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 04:04:25 +00:00
37 changed files with 1062 additions and 98 deletions

View file

@ -1,10 +1,11 @@
"""Route handlers for Central GUI.""" """Route handlers for Central GUI."""
import base64 import base64
import html
import json import json
import logging import logging
import re import re
from datetime import datetime from datetime import datetime, timezone
from typing import Any from typing import Any
logger = logging.getLogger("central.gui.routes") logger = logging.getLogger("central.gui.routes")
@ -2727,6 +2728,24 @@ def _parse_events_params(params) -> tuple[dict | None, str | None]:
}, 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. ``>`` -> ``&gt;``); 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: async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
""" """
Fetch events from database using parsed parameters. Fetch events from database using parsed parameters.
@ -2794,7 +2813,6 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
received, received,
adapter, adapter,
category, category,
payload->>'subject' as subject,
ST_AsGeoJSON(geom) as geometry, ST_AsGeoJSON(geom) as geometry,
payload as data, payload as data,
regions regions
@ -2824,17 +2842,23 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
if row["geometry"]: if row["geometry"]:
geometry = json.loads(row["geometry"]) geometry = json.loads(row["geometry"])
events.append({ event = {
"id": row["id"], "id": row["id"],
"time": row["time"].isoformat(), "time": row["time"].isoformat(),
"received": row["received"].isoformat(), "received": row["received"].isoformat(),
"adapter": row["adapter"], "adapter": row["adapter"],
"category": row["category"], "category": row["category"],
"subject": row["subject"],
"geometry": geometry, "geometry": geometry,
"data": dict(row["data"]) if row["data"] else {}, "data": dict(row["data"]) if row["data"] else {},
"regions": list(row["regions"]) if row["regions"] 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 # Build next_cursor if there are more results
next_cursor = None next_cursor = None
@ -2870,6 +2894,31 @@ def _geometry_summary(geometry: dict | None) -> str:
return geom_type 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") @router.get("/events.json")
async def events_json(request: Request): async def events_json(request: Request):
@ -2958,9 +3007,15 @@ async def events_list(request: Request) -> HTMLResponse:
events = result.events events = result.events
next_cursor = result.next_cursor next_cursor = result.next_cursor
# Add geometry summary to each event # Add table-only display fields (time_human, adapter_display, geometry_summary)
for event in events: _decorate_table_events(events)
event["geometry_summary"] = _geometry_summary(event.get("geometry"))
# 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( return templates.TemplateResponse(
request=request, request=request,
@ -2974,6 +3029,7 @@ async def events_list(request: Request) -> HTMLResponse:
"filter_error": error, "filter_error": error,
"tile_url": tile_url, "tile_url": tile_url,
"tile_attribution": tile_attribution, "tile_attribution": tile_attribution,
"adapters": adapters,
}, },
) )
@ -3014,9 +3070,8 @@ async def events_rows(request: Request) -> HTMLResponse:
events = result.events events = result.events
next_cursor = result.next_cursor next_cursor = result.next_cursor
# Add geometry summary to each event # Add table-only display fields (time_human, adapter_display, geometry_summary)
for event in events: _decorate_table_events(events)
event["geometry_summary"] = _geometry_summary(event.get("geometry"))
return templates.TemplateResponse( return templates.TemplateResponse(
request=request, request=request,

View 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. #}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View 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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -0,0 +1,2 @@
{# No per-adapter summary; the Subject cell falls back to "—" and the map
popup omits the subject line. #}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('title') %}{{ d.title }}{% endif -%}

View 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 -%}

View 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 -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('title') %}{{ d.title }}{% endif -%}

View 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 -%}

View 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 -%}

View file

@ -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 -%}

View file

@ -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 -%}

View file

@ -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 -%}

View file

@ -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 -%}

View file

@ -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 -%}

View file

@ -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 -%}

View file

@ -10,30 +10,40 @@
<tr> <tr>
<th style="width: 2rem;"></th> <th style="width: 2rem;"></th>
<th>Time</th> <th>Time</th>
<th>Adapter</th> <th>Location</th>
<th>Category</th>
<th>Geometry</th>
<th>Subject</th> <th>Subject</th>
<th>Adapter</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for event in events %} {% 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 }}" <tr class="event-row" data-row-idx="{{ loop.index0 }}"
data-event-id="{{ event.id }}" data-event-id="{{ event.id }}"
data-adapter="{{ event.adapter }}" data-adapter="{{ event.adapter }}"
data-category="{{ event.category }}" data-category="{{ event.category }}"
data-time="{{ event.time }}" data-time="{{ event.time }}"
data-subject="{{ event.subject or '' }}" data-subject="{{ subject_summary | trim }}"
{% if event.geometry %}data-geometry='{{ event.geometry | tojson }}'{% endif %}> {% if event.geometry %}data-geometry='{{ event.geometry | tojson }}'{% endif %}>
<td><button type="button" class="expand-row" aria-label="Expand">&#9656;</button></td> <td><button type="button" class="expand-row" aria-label="Expand">&#9656;</button></td>
<td>{{ event.time }}</td> <td title="{{ event.time }}">{{ event.time_human }}</td>
<td>{{ event.adapter }}</td> <td>{% if loc_parts %}{{ loc_parts | join(', ') }}{% elif gc.get('landclass') %}{{ gc.landclass }}{% else %}—{% endif %}</td>
<td>{{ event.category }}</td> <td>{{ subject_summary | trim or '—' }}</td>
<td>{{ event.geometry_summary }}</td> <td>{{ event.adapter_display }}</td>
<td>{{ event.subject or '—' }}</td>
</tr> </tr>
<tr class="event-detail" hidden> <tr class="event-detail" hidden>
<td colspan="6"> <td colspan="5">
<dl class="event-detail-list"> <dl class="event-detail-list">
<dt>Event ID</dt> <dt>Event ID</dt>
<dd><code>{{ event.id }}</code></dd> <dd><code>{{ event.id }}</code></dd>
@ -43,6 +53,11 @@
<dt>Regions</dt> <dt>Regions</dt>
<dd>{{ event.regions | join(", ") }}</dd> <dd>{{ event.regions | join(", ") }}</dd>
{% endif %} {% 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> <dt>Data</dt>
<dd><pre class="event-data-pre">{{ event.data | tojson(indent=2) }}</pre></dd> <dd><pre class="event-data-pre">{{ event.data | tojson(indent=2) }}</pre></dd>
</dl> </dl>

View file

@ -18,7 +18,8 @@
} }
.map-legend { .map-legend {
display: flex; display: flex;
gap: 1rem; flex-wrap: wrap;
gap: 0.5rem 1rem;
font-size: 0.85rem; font-size: 0.85rem;
} }
.map-legend-item { .map-legend-item {
@ -105,6 +106,12 @@
{% endblock %} {% endblock %}
{% block content %} {% 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> <h1>Events</h1>
{% if filter_error %} {% if filter_error %}
@ -123,9 +130,9 @@
<label for="adapter">Adapter</label> <label for="adapter">Adapter</label>
<select id="adapter" name="adapter"> <select id="adapter" name="adapter">
<option value="">All</option> <option value="">All</option>
<option value="nws" {% if filter_values.adapter == 'nws' %}selected{% endif %}>nws</option> {% for a in adapters %}
<option value="firms" {% if filter_values.adapter == 'firms' %}selected{% endif %}>firms</option> <option value="{{ a.name }}" {% if filter_values.adapter == a.name %}selected{% endif %}>{{ a.display_name }}</option>
<option value="usgs_quake" {% if filter_values.adapter == 'usgs_quake' %}selected{% endif %}>usgs_quake</option> {% endfor %}
</select> </select>
</div> </div>
<div> <div>
@ -163,18 +170,12 @@
<div id="events-map"></div> <div id="events-map"></div>
<div class="map-controls"> <div class="map-controls">
<div class="map-legend"> <div class="map-legend">
{% for a in adapters %}
<div class="map-legend-item"> <div class="map-legend-item">
<div class="map-legend-swatch" style="background-color: #f59e0b;"></div> <div class="map-legend-swatch" style="background-color: {{ palette[loop.index0 % palette|length] }};"></div>
<span>NWS (Weather)</span> <span>{{ a.display_name }}</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> </div>
{% endfor %}
</div> </div>
<button type="button" id="fit-to-results" class="outline secondary">Fit map to results</button> <button type="button" id="fit-to-results" class="outline secondary">Fit map to results</button>
</div> </div>
@ -189,17 +190,57 @@
var tileUrl = {{ tile_url | tojson }}; var tileUrl = {{ tile_url | tojson }};
var tileAttr = {{ tile_attribution | 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 = { var ADAPTER_COLORS = {
"nws": "#f59e0b", {% for a in adapters %}{{ a.name | tojson }}: PALETTE[{{ loop.index0 }} % PALETTE.length]{{ "," if not loop.last }}
"firms": "#dc2626", {% endfor %}
"usgs_quake": "#7c3aed"
}; };
function getAdapterColor(adapter) { function getAdapterColor(adapter) {
return ADAPTER_COLORS[adapter] || "#3388ff"; 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 // Initialize map
var map = L.map("events-map").setView([39, -98], 4); var map = L.map("events-map").setView([39, -98], 4);
@ -275,7 +316,22 @@
var adapter = row.dataset.adapter || ""; var adapter = row.dataset.adapter || "";
var color = getAdapterColor(adapter); var color = getAdapterColor(adapter);
var layer = L.geoJSON(geom, { 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: { style: {
color: color, color: color,
weight: 2, weight: 2,
@ -283,15 +339,10 @@
fillOpacity: 0.25 fillOpacity: 0.25
}, },
pointToLayer: function(feature, latlng) { pointToLayer: function(feature, latlng) {
return L.circleMarker(latlng, { return L.circleMarker(latlng, markerStyle);
radius: 8,
color: color,
weight: 2,
fillColor: color,
fillOpacity: 0.25
});
} }
}); });
}
layer.bindPopup(buildPopup(row)); layer.bindPopup(buildPopup(row));
layer.on("click", function() { layer.on("click", function() {
@ -414,17 +465,80 @@
// Fit to results button // Fit to results button
document.getElementById("fit-to-results").addEventListener("click", fitToAllLayers); 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 // Initial load - bind layers and fit bounds
rebindEventLayers(); // Initial load only rebindEventLayers(); // Initial load only
bindSortHandlers();
if (false) { // DISABLED: map never auto-fits if (false) { // DISABLED: map never auto-fits
fitToAllLayers(); fitToAllLayers();
isInitialLoad = false; 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) { document.body.addEventListener("htmx:afterSwap", function(evt) {
if (evt.detail.target.id === "events-rows") { 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 // Do NOT call fitToAllLayers - preserve user viewport
} }
}); });

View file

@ -76,9 +76,12 @@ async def apply_enrichment(
No-op when the adapter declares no enrichment_locations or no enrichers No-op when the adapter declares no enrichment_locations or no enrichers
are registered. Uses the first (lat_path, lon_path) tuple that resolves to are registered. Uses the first (lat_path, lon_path) tuple that resolves to
a non-null coordinate pair in event.data. Each enricher's result is keyed a non-null coordinate pair in event.data. If no declared pair resolves to
by enricher.name. Mutates the data dict in place (Event is frozen, but its coordinates, still attaches an all-null bundle so that every event from an
data dict is not this avoids a model_copy on every published event). enriched adapter carries _enriched (consumers get a stable field set).
Each enricher's result is keyed by enricher.name. Mutates the data dict in
place (Event is frozen, but its data dict is not this avoids a
model_copy on every published event).
""" """
if not enrichment_locations or not enrichers: if not enrichment_locations or not enrichers:
return return
@ -93,6 +96,15 @@ async def apply_enrichment(
enriched[enricher.name] = await enricher.enrich(location) enriched[enricher.name] = await enricher.enrich(location)
event.data["_enriched"] = enriched event.data["_enriched"] = enriched
return return
# No declared pair resolved to coordinates. Still attach _enriched: each
# enricher resolves the null location to its own all-null bundle (per the
# never-raise contract), so coordless events (e.g. removal tombstones)
# carry the same shape as enriched ones.
null_location = {"lat": None, "lon": None}
enriched = {}
for enricher in enrichers:
enriched[enricher.name] = await enricher.enrich(null_location)
event.data["_enriched"] = enriched
# Stream subject mappings -- derived from the registry; every stream is included # Stream subject mappings -- derived from the registry; every stream is included
# (META too: supervisor must create it in JetStream even though archive skips it). # (META too: supervisor must create it in JetStream even though archive skips it).

View file

@ -13,6 +13,25 @@ from unittest.mock import AsyncMock, MagicMock, patch
from central.bootstrap_config import Settings 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") @pytest.fixture(scope="session")
def event_loop(): def event_loop():
"""Create an event loop for the test session.""" """Create an event loop for the test session."""

View file

@ -0,0 +1,78 @@
"""Regression tests for apply_enrichment's coordless path.
Design principle: every event from an adapter that declares enrichment_locations
must carry data["_enriched"] populated when coordinates resolve, an all-null
bundle when they don't (e.g. removal tombstones with no lat/lon). Adapters that
declare no enrichment_locations are still skipped entirely.
"""
from datetime import datetime, timezone
from typing import Any
import pytest
from central.config_models import EnrichmentConfig
from central.enrichment.cache import EnrichmentCache
from central.enrichment.geocoder import GeocoderEnricher, all_null_bundle
from central.models import Event, Geo
from central.supervisor import apply_enrichment, build_enrichers
def _make_event(data: dict[str, Any]) -> Event:
return Event(
id="evt-1",
adapter="usgs_quake",
category="quake.event.test",
time=datetime(2026, 1, 1, tzinfo=timezone.utc),
geo=Geo(),
data=data,
)
class _PopulatingBackend:
"""Deterministic backend that resolves any real coords to a fixed place."""
async def reverse(self, lat: float, lon: float) -> dict[str, Any]:
return {**all_null_bundle(), "city": "Boise", "state": "ID"}
@pytest.mark.asyncio
async def test_coordless_event_with_declared_locations_gets_null_bundle(tmp_path):
"""An event whose declared coord paths are all None still gets _enriched."""
cache = EnrichmentCache(tmp_path / "enrichment_cache.db")
enrichers = build_enrichers(EnrichmentConfig(), cache)
event = _make_event(
{"latitude": None, "longitude": None, "reason": "fallen_off_current_service"}
)
assert "_enriched" not in event.data
await apply_enrichment(event, [("latitude", "longitude")], enrichers)
assert event.data["_enriched"]["geocoder"] == all_null_bundle()
@pytest.mark.asyncio
async def test_event_with_coords_still_enriches_normally(tmp_path):
"""The coord-bearing path is unchanged: the backend is consulted and its
resolved fields land in the bundle."""
cache = EnrichmentCache(tmp_path / "enrichment_cache.db")
enricher = GeocoderEnricher(_PopulatingBackend(), cache=cache)
event = _make_event({"latitude": 43.0, "longitude": -116.0})
await apply_enrichment(event, [("latitude", "longitude")], [enricher])
bundle = event.data["_enriched"]["geocoder"]
assert bundle["state"] == "ID"
assert bundle["city"] == "Boise"
@pytest.mark.asyncio
async def test_adapter_with_no_enrichment_locations_still_skipped(tmp_path):
"""Adapters declaring no enrichment_locations are skipped — no _enriched."""
cache = EnrichmentCache(tmp_path / "enrichment_cache.db")
enrichers = build_enrichers(EnrichmentConfig(), cache)
event = _make_event({"latitude": 43.0, "longitude": -116.0})
await apply_enrichment(event, [], enrichers)
assert "_enriched" not in event.data

View file

@ -12,6 +12,7 @@ from central.config_source import (
ConfigSource, ConfigSource,
DbConfigSource, DbConfigSource,
) )
from central.bootstrap_config import get_settings
from central.crypto import KEY_SIZE, clear_key_cache from central.crypto import KEY_SIZE, clear_key_cache
# Test database DSN # Test database DSN
@ -31,11 +32,20 @@ def master_key_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Configure master key path for all tests.""" """Configure master key path for all tests.
clear_key_cache()
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_DB_DSN", TEST_DB_DSN)
monkeypatch.setenv("CENTRAL_MASTER_KEY_PATH", str(master_key_path)) 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 @pytest_asyncio.fixture

View file

@ -13,6 +13,7 @@ import asyncpg
import pytest import pytest
import pytest_asyncio import pytest_asyncio
from central.bootstrap_config import get_settings
from central.config_store import ConfigStore from central.config_store import ConfigStore
from central.crypto import KEY_SIZE, clear_key_cache 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) @pytest.fixture(autouse=True)
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Configure master key path for all tests.""" """Configure master key path for all tests.
clear_key_cache()
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_DB_DSN", TEST_DB_DSN)
monkeypatch.setenv("CENTRAL_MASTER_KEY_PATH", str(master_key_path)) monkeypatch.setenv("CENTRAL_MASTER_KEY_PATH", str(master_key_path))
monkeypatch.setenv("CENTRAL_CSRF_SECRET", "test-csrf-secret-for-testing-only-32chars") 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 @pytest_asyncio.fixture
@ -338,3 +351,13 @@ class TestListenerReconnect:
pytest.fail("Listener did not stop after cancellation") pytest.fail("Listener did not stop after cancellation")
assert listen_task.cancelled() or listen_task.done() 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")

View file

@ -1,12 +1,15 @@
"""Tests for events feed frontend routes.""" """Tests for events feed frontend routes."""
import html
import json import json
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import pytest 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: class TestEventsFeedFrontendAuthenticated:
@ -683,4 +686,512 @@ class TestEventRowDataAttributes:
assert len(context["events"]) == 1 assert len(context["events"]) == 1
assert context["events"][0]["adapter"] == "usgs_quake" assert context["events"][0]["adapter"] == "usgs_quake"
assert context["events"][0]["category"] == "quake.event" 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

View file

@ -5,7 +5,7 @@ import base64
import os import os
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import MagicMock
import asyncpg import asyncpg
import pytest import pytest
@ -14,6 +14,7 @@ import pytest_asyncio
from central.config_models import AdapterConfig from central.config_models import AdapterConfig
from central.config_source import DbConfigSource from central.config_source import DbConfigSource
from central.config_store import ConfigStore from central.config_store import ConfigStore
from central.bootstrap_config import get_settings
from central.crypto import KEY_SIZE, clear_key_cache from central.crypto import KEY_SIZE, clear_key_cache
# Test database DSN # Test database DSN
@ -33,11 +34,20 @@ def master_key_path(tmp_path_factory: pytest.TempPathFactory) -> Path:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Configure master key path for all tests.""" """Configure master key path for all tests.
clear_key_cache()
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_DB_DSN", TEST_DB_DSN)
monkeypatch.setenv("CENTRAL_MASTER_KEY_PATH", str(master_key_path)) 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 @pytest_asyncio.fixture
@ -196,10 +206,7 @@ class TestRateLimitGuarantee:
state.config = new_config state.config = new_config
state.adapter.cadence_s = 90 state.adapter.cadence_s = 90
# Calculate expected next poll time
expected_next_poll = last_poll + timedelta(seconds=90)
now = datetime.now(timezone.utc) 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 # The wait time should be based on last_poll + new_cadence
# Since last_poll was 30 seconds ago and new cadence is 90, # 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, If operator increases cadence to 120s after a gap of 150s,
the poll should happen now (not wait another 120s). the poll should happen now (not wait another 120s).
""" """
from central.supervisor import AdapterState
mock_adapter = MagicMock() mock_adapter = MagicMock()
mock_adapter.name = "test" mock_adapter.name = "test"
@ -238,13 +244,6 @@ class TestRateLimitGuarantee:
updated_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc),
) )
state = AdapterState(
name="test",
adapter=mock_adapter,
config=config,
last_completed_poll=last_poll,
)
# Calculate next poll time # Calculate next poll time
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
next_poll_at = last_poll.timestamp() + config.cadence_s 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 poll should be at (last_completed_poll + cadence_s), not immediately
(unless that time has already passed). (unless that time has already passed).
""" """
from central.supervisor import AdapterState
mock_adapter = MagicMock() mock_adapter = MagicMock()
mock_adapter.name = "test" mock_adapter.name = "test"
@ -283,13 +281,6 @@ class TestRateLimitGuarantee:
updated_at=datetime.now(timezone.utc), updated_at=datetime.now(timezone.utc),
) )
state = AdapterState(
name="test",
adapter=mock_adapter,
config=config,
last_completed_poll=last_poll,
)
# Calculate next poll time # Calculate next poll time
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
next_poll_at = last_poll.timestamp() + config.cadence_s next_poll_at = last_poll.timestamp() + config.cadence_s

View file

@ -9,17 +9,16 @@ IMPORTANT: These tests are designed to:
- PASS on fixed code (last_completed_poll is preserved across disable/enable) - PASS on fixed code (last_completed_poll is preserved across disable/enable)
""" """
import asyncio
import base64 import base64
import os import os
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
import pytest_asyncio
from central.config_models import AdapterConfig from central.config_models import AdapterConfig
from central.bootstrap_config import get_settings
from central.crypto import KEY_SIZE, clear_key_cache 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) @pytest.fixture(autouse=True)
def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch):
"""Configure master key path for all tests.""" """Configure master key path for all tests.
clear_key_cache()
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_DB_DSN", TEST_DB_DSN)
monkeypatch.setenv("CENTRAL_MASTER_KEY_PATH", str(master_key_path)) 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: class MockConfigSource:
@ -139,12 +147,18 @@ class MockNWSAdapter:
@pytest.fixture @pytest.fixture
def mock_nats(): 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 = AsyncMock()
mock_nc.publish = AsyncMock() mock_nc.publish = AsyncMock()
mock_js = AsyncMock() mock_js = AsyncMock()
mock_js.publish = AsyncMock() mock_js.publish = AsyncMock()
mock_nc.jetstream.return_value = mock_js mock_nc.jetstream = MagicMock(return_value=mock_js)
return mock_nc return mock_nc
@ -179,7 +193,7 @@ class TestEnableDisableEnableIntegration:
- Assert next poll fires immediately (last+cadence is in past) - Assert next poll fires immediately (last+cadence is in past)
- Assert exactly ONE poll happens, not multiple catch-up - Assert exactly ONE poll happens, not multiple catch-up
""" """
from central.supervisor import Supervisor, AdapterState from central.supervisor import Supervisor
config_source = MockConfigSource() config_source = MockConfigSource()
initial_config = AdapterConfig( initial_config = AdapterConfig(
@ -290,7 +304,7 @@ class TestEnableDisableEnableIntegration:
- Re-enable adapter 20 seconds later (still within cadence window) - Re-enable adapter 20 seconds later (still within cadence window)
- Assert next poll fires at last_poll + 60s, NOT immediately - Assert next poll fires at last_poll + 60s, NOT immediately
""" """
from central.supervisor import Supervisor, AdapterState from central.supervisor import Supervisor
config_source = MockConfigSource() config_source = MockConfigSource()
initial_config = AdapterConfig( initial_config = AdapterConfig(
@ -574,3 +588,27 @@ class TestEnableDisableEnableIntegration:
# State should be gone # State should be gone
assert "nws" not in supervisor._adapter_states 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