diff --git a/src/central/gui/routes.py b/src/central/gui/routes.py index 74a6d6b..a056496 100644 --- a/src/central/gui/routes.py +++ b/src/central/gui/routes.py @@ -1,7 +1,6 @@ """Route handlers for Central GUI.""" import base64 -import html import json import logging import re @@ -2728,24 +2727,6 @@ 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. @@ -2813,6 +2794,7 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult: received, adapter, category, + payload->>'subject' as subject, ST_AsGeoJSON(geom) as geometry, payload as data, regions @@ -2842,23 +2824,17 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult: if row["geometry"]: geometry = json.loads(row["geometry"]) - event = { + events.append({ "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 diff --git a/tests/test_events_feed_frontend.py b/tests/test_events_feed_frontend.py index f1ab1e7..a2b557c 100644 --- a/tests/test_events_feed_frontend.py +++ b/tests/test_events_feed_frontend.py @@ -1,15 +1,12 @@ """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.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 +from central.gui.routes import events_list, events_rows, events_json class TestEventsFeedFrontendAuthenticated: @@ -686,11 +683,7 @@ class TestEventRowDataAttributes: assert len(context["events"]) == 1 assert context["events"][0]["adapter"] == "usgs_quake" assert context["events"][0]["category"] == "quake.event" - # `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] + assert context["events"][0]["subject"] == "M4.2 Earthquake" # --- PR L-b: operator /events tab polish --------------------------------- @@ -1092,106 +1085,3 @@ class TestTableRendersThroughHTTP: 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 diff --git a/tests/test_supervisor_hotreload.py b/tests/test_supervisor_hotreload.py index 9fa40af..10343b8 100644 --- a/tests/test_supervisor_hotreload.py +++ b/tests/test_supervisor_hotreload.py @@ -5,7 +5,7 @@ import base64 import os from datetime import datetime, timedelta, timezone from pathlib import Path -from unittest.mock import MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import asyncpg import pytest @@ -206,7 +206,10 @@ 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, @@ -227,6 +230,7 @@ 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" @@ -244,6 +248,13 @@ 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 @@ -263,6 +274,7 @@ 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" @@ -281,6 +293,13 @@ 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 diff --git a/tests/test_supervisor_integration.py b/tests/test_supervisor_integration.py index 47b5b58..517dbe5 100644 --- a/tests/test_supervisor_integration.py +++ b/tests/test_supervisor_integration.py @@ -9,13 +9,15 @@ 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 +from unittest.mock import AsyncMock, MagicMock, patch import pytest +import pytest_asyncio from central.config_models import AdapterConfig from central.bootstrap_config import get_settings @@ -193,7 +195,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 + from central.supervisor import Supervisor, AdapterState config_source = MockConfigSource() initial_config = AdapterConfig( @@ -304,7 +306,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 + from central.supervisor import Supervisor, AdapterState config_source = MockConfigSource() initial_config = AdapterConfig(