"""v0.5.13 tests for consumer._normalize() default-deny gate. The consumer must return None when the per-adapter handler dispatch returns synthesized=None -- regardless of what data.title / data.headline say. Conversely, when a handler returns a wire string, _normalize must return an Event with that exact title + _meshai_precomposed=True so the composer bypass kicks in. Covers four cases: (a) envelope with NO matching handler (adapter='avalanche' has no Central adapter wired) -> _normalize returns None (b) envelope hits handler, handler returns None (e.g. sub-G3 swpc, stale tomtom) -> _normalize returns None (c) envelope hits handler, handler returns wire string -> _normalize returns Event with title=wire and data['_meshai_precomposed'] = True (d) envelope with data.title and data.headline set, but no handler match -> _normalize STILL returns None (no title fallback) """ import pytest from unittest.mock import patch, MagicMock from meshai.config import Config from meshai.central.consumer import CentralConsumer from meshai.persistence import close_thread_connection, init_db from meshai.persistence import db as persistence_db @pytest.fixture def mem_db(monkeypatch, tmp_path): db_path = str(tmp_path / "v0513-test.sqlite") monkeypatch.setenv("MESHAI_DB_PATH", db_path) persistence_db._initialised.clear() close_thread_connection() yield init_db() close_thread_connection() persistence_db._initialised.discard(db_path) @pytest.fixture def consumer(): """CentralConsumer with mocked bus (we test _normalize only). CentralConsumer.__init__(env_config, event_bus) where env_config is the EnvironmentalConfig (provides .central + per-adapter source). """ cfg = Config() cfg.notifications.cold_start_grace_seconds = 0 bus = MagicMock() c = CentralConsumer(cfg.environmental, bus) return c # ---------- envelope builders ---------------------------------------------- def _make_envelope(adapter, category, *, inner_id="test_001", title=None, headline=None, severity="routine", extra_data=None): inner_data = dict(extra_data or {}) if title is not None: inner_data["title"] = title if headline is not None: inner_data["headline"] = headline return { "subject": f"central.{adapter}.test", "id": f"env_{inner_id}", "data": { "id": inner_id, "adapter": adapter, "category": category, "severity": severity, "time": "2026-06-05T15:00:00Z", "geo": {"primary_region": "US-ID"}, "data": inner_data, }, } # ============================================================================ # (a) envelope with NO matching handler -> default-deny # ============================================================================ def test_no_handler_match_returns_none(consumer, mem_db): """Avalanche has no handler (no Central adapter). envelope must drop at consumer._normalize as the default-deny baseline.""" env = _make_envelope("avalanche", "avalanche.forecast", inner_id="aval_001") out = consumer._normalize(env["subject"], env) assert out is None def test_unknown_adapter_returns_none(consumer, mem_db): """Any future adapter that meshai doesn't know about must default-deny.""" env = _make_envelope("future_adapter", "some.category.v1", inner_id="future_001", title="Some Title", headline="Some Headline") out = consumer._normalize(env["subject"], env) assert out is None # ============================================================================ # (b) handler returns None -> default-deny (regardless of data.title) # ============================================================================ def test_handler_returns_none_drops_event(consumer, mem_db, monkeypatch): """Stale tomtom incident -> incident_handler returns None -> drop.""" env = _make_envelope("tomtom_incidents", "incident.tomtom_incidents", inner_id="ID:tomtom:TTI-stale", title="Old Jam", headline="Headline Jam", extra_data={ "id": "ID:tomtom:TTI-stale", "magnitude_of_delay": 4, "icon_category": 6, "time_validity": "past", # filtered "start_time": "2024-01-01T00:00:00Z", "latitude": 43.5, "longitude": -116.0, }) out = consumer._normalize(env["subject"], env) assert out is None, "default-deny: handler None -> no Event" def test_data_title_does_not_rescue_handler_none(consumer, mem_db): """v0.5.13: even when envelope has data.title set, if no handler\ synthesized, the broadcast is denied.""" env = _make_envelope("swpc_kindex", "space.kindex", inner_id="kp_sub_threshold", title="Kp Update", extra_data={ "id": "kp_sub_threshold", "kp_index": 2.0, # well below G3 (Kp>=7) "time": "2026-06-05T15:00:00Z", }) out = consumer._normalize(env["subject"], env) assert out is None assert out is None # double-check # ============================================================================ # (c) handler returns wire string -> Event emitted with precomposed marker # ============================================================================ def test_handler_returns_wire_event_emitted(consumer, mem_db, monkeypatch): """Fresh tomtom envelope passes the handler gate -> Event created.""" # Disable Photon to avoid network calls in test. import meshai.central_normalizer as cn monkeypatch.setattr(cn, "_photon_reverse_places", lambda lat, lon: []) if hasattr(cn, "_H3_NEAREST_CACHE"): cn._H3_NEAREST_CACHE.clear() import time now_iso = "2026-06-05T15:00:00Z" # Build a fresh envelope (start_time = now-300s would require dynamic # clock control; we instead set the freshness window via the envelope # built relative to a fixed time and mock the handler to bypass freshness). env = _make_envelope( "tomtom_incidents", "incident.tomtom_incidents", inner_id="ID:tomtom:TTI-aaaa1111-2222-3333-4444-555555555555-TTR1", extra_data={ "id": "ID:tomtom:TTI-aaaa1111-2222-3333-4444-555555555555-TTR1", "magnitude_of_delay": 4, "icon_category": 6, "time_validity": "present", "start_time": now_iso, "latitude": 43.6, "longitude": -116.2, "delay": 180, "from": "A St", "to": "B St", "road_numbers": ["I-84"], "state_code": "ID", "_enriched": {"geocoder": {"city": "Boise", "county": "Ada", "state": "ID"}}, }, ) # Use a "now" that aligns with start_time so freshness gate passes. import datetime as _dt now_epoch = int(_dt.datetime.fromisoformat( now_iso.replace("Z","+00:00")).timestamp()) + 60 # 1 min after start with patch("time.time", return_value=now_epoch): out = consumer._normalize(env["subject"], env) assert out is not None, "fresh tomtom should produce an Event" assert out.data.get("_meshai_precomposed") is True assert out.title.startswith("🚗") # jam emoji assert "Boise" in out.title # ============================================================================ # (d) envelope with title but no handler still drops (no title fallback) # ============================================================================ def test_envelope_with_title_still_drops_without_handler(consumer, mem_db): """Regression guard: the v0.5.7-fallback path (data.title -> headline ->\ friendly_name -> cat_raw) is GONE in v0.5.13. Uses an unhandled adapter (avalanche) since v0.6-1 added the FIRMS handler.""" env = _make_envelope("avalanche", "avalanche.forecast", inner_id="aval_with_title", title="Avalanche Warning", headline="Backcountry advisory") out = consumer._normalize(env["subject"], env) assert out is None, ( "v0.5.13 default-deny: data.title and data.headline must NOT rescue\n" "an envelope that no handler synthesized for." ) # ============================================================================ # (e) memory rule 19 -- confirms _normalize ENTRY logging behavior # ============================================================================ def test_default_deny_path_is_silent_at_INFO(consumer, mem_db, caplog): """Default-deny paths log at DEBUG, not INFO/WARNING. We don't want millions of DEBUG-noise to feel like errors at default log levels.""" import logging caplog.set_level(logging.INFO, logger="meshai.central.consumer") env = _make_envelope("firms", "fire.hotspot.viirs", inner_id="silent_check") out = consumer._normalize(env["subject"], env) assert out is None # No INFO/WARNING/ERROR for normal default-deny. info_or_higher = [r for r in caplog.records if r.levelno >= logging.INFO and r.name == "meshai.central.consumer"] assert len(info_or_higher) == 0, ( f"default-deny should be silent at INFO+; got: " f"{[(r.levelname, r.message) for r in info_or_higher]}" )