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_config_source.py b/tests/test_config_source.py index a26a12a..bc944c1 100644 --- a/tests/test_config_source.py +++ b/tests/test_config_source.py @@ -12,7 +12,6 @@ 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 @@ -32,20 +31,11 @@ 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): - """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. - """ +def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Configure master key path for all tests.""" + clear_key_cache() 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 diff --git a/tests/test_config_store.py b/tests/test_config_store.py index e80d515..4653e32 100644 --- a/tests/test_config_store.py +++ b/tests/test_config_store.py @@ -13,7 +13,6 @@ 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 @@ -35,24 +34,12 @@ 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): - """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. - """ +def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Configure master key path for all tests.""" + clear_key_cache() 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 @@ -351,13 +338,3 @@ 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") 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..54db782 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 @@ -14,7 +14,6 @@ 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 @@ -34,20 +33,11 @@ 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): - """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. - """ +def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Configure master key path for all tests.""" + clear_key_cache() 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 @@ -206,7 +196,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 +220,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 +238,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 +264,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 +283,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..fa3a420 100644 --- a/tests/test_supervisor_integration.py +++ b/tests/test_supervisor_integration.py @@ -9,16 +9,17 @@ 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 from central.crypto import KEY_SIZE, clear_key_cache @@ -55,20 +56,11 @@ 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): - """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. - """ +def setup_master_key(master_key_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Configure master key path for all tests.""" + clear_key_cache() 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: @@ -193,7 +185,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 +296,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(