mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-06-11 01:14:45 +02:00
TWO PRE-EXISTING bugs (dormant in safe-mode for months) that the v0.5.7 staged flip exposed the moment Central became the live source for the first time. Matt observed the exact failure mode on the mesh at 2026-06-04 15:40:30 UTC:
[Roads] 🚨 ROADS: incident.tomtom_incidents, US-ID. immediate
Neither bug was authored by v0.5.7. The campaign reordered/added Central subscriptions but did not touch the consumer normalize() or the mesh renderer. The bugs surfaced because v0.5.7 was the first occasion since v0.5.2 to actually flip notifications.enabled=True with adapters set to feed_source=central. Pre-flip, no live broadcast had ever fired in prod (safe-mode held throughout the months between v0.5.2 and v0.5.7).
The v0.5.2 cooldown filter held the mesh blast radius to a single event -- subsequent tomtom_incidents broadcasts in the same 60s window hit the (toggle, category, region) cooldown key and were silently throttled. Without v0.5.2 dispatching guards the mesh would have been pummeled.
FIX 1 -- meshai/central/consumer.py:_normalize title fallback. The old chain was:
title = (data.get("title") or data.get("headline")
or cat_raw or f"{adapter} event")
Most Central adapters per the v0.10.0 guide §6 carry per-adapter payload fields (roadway, flux, magnitude, Kp, ...) but NOT a top-level title/headline. For those adapters the chain fell to cat_raw -- the raw Central hierarchical category like "incident.tomtom_incidents", "fire.hotspot.viirs_noaa20.high", "hydro.00060.usgs.06898000", "space.kindex", "quake.event.minor". That string became event.title, which compose_mesh_message() uses as the primary identifier in the friendly mesh line.
New chain inserts the meshai-friendly registry name BEFORE cat_raw:
friendly_name = get_category(category)["name"] # "Road Incident", "Wildfire Hotspot", ...
title = (data.get("title") or data.get("headline")
or friendly_name or cat_raw
or f"{adapter} event")
NWS and USGS quake supply title/headline directly and still take the first-priority slot. cat_raw stays as the last-resort tail for genuinely unknown categories. Per-adapter title synthesis (e.g. tomtom: f"{roadway} - {event_type}") is queued as v0.5.8 work -- intentionally out of scope here.
FIX 2 -- meshai/notifications/renderers/mesh.py:_format_one_line drops the [Family] prefix unconditionally. Pre-fix:
prefix = self._toggle_label(p.event_type) # -> "Roads", "Weather", ...
if prefix:
return f"[{prefix}] {p.message}" # legacy v0.5.0 debug format
return p.message
Since v0.5.2 the dispatcher hands payload.message from compose_mesh_message() whose output ALREADY starts with the family emoji + label ("🚨 ROADS:", "🔥 FIRE:", "⚠ WX:", "🌐 RF:", ...). The renderer wrap produced the visually-broken duplicate "[Roads] 🚨 ROADS: ...". The composer was supposed to be the single source of truth for mesh formatting; the renderer never got the memo.
Post-fix the renderer is a verbatim pass-through:
return p.message or ""
The _toggle_label() method and TOGGLE_LABELS table are KEPT (the digest renderer at notifications/pipeline/digest.py still uses them for the multi-line summary format -- do not remove them).
Why pytest did not catch this
-----------------------------
compose_mesh_message is unit-tested with synthetic Events that have clean titles; no test passes "incident.tomtom_incidents" as event.title to the composer. MeshRenderer.render is unit-tested with synthetic NotificationPayloads carrying legacy messages; no test feeds composer output into the renderer. The seam between consumer/composer/renderer was never end-to-end tested with a realistic Central envelope. New file tests/test_central_envelope_to_wire_v057.py closes that gap.
Tests
-----
PYTHONPATH=. pytest -q: 474 passed, 2 skipped (was 450 baseline; +24 net).
- tests/test_central_envelope_to_wire_v057.py (new): runs five representative Central envelopes (tomtom_incidents, FIRMS hotspot, NWS alert, USGS quake, SWPC alert) through _normalize -> dispatcher -> renderer and asserts the rendered wire string (a) does not start with "[", (b) does not contain any raw Central category token (".tomtom_incidents", ".firms", ".kindex", ".proton_flux"), (c) starts with the composer emoji+label, (d) for adapters lacking upstream title/headline, uses the registry-friendly name in the primary slot. Plus a focused regression-guard test test_matt_smoking_gun_no_longer_reproduces that asserts the exact 2026-06-04 15:40:30 wire string can no longer be produced.
- tests/test_renderers.py: test_mesh_render_event_type_prefix renamed to test_mesh_render_passes_message_verbatim with new assertion (no [Family] prefix); test_mesh_render_unknown_event_type_no_prefix updated for the verbatim contract.
Re-flip verification
--------------------
After the fix landed in container image sha256:0dea6ad3, the staged flip from earlier tonight was repeated in one shot (master + central + 8 adapters + 8 toggles all ON, container restart, 5-minute observation). All 12 v0.5.7-fixed Central subscriptions confirmed active, container healthy, ugly-format detector (grep for "[<Family>] " or raw-category tokens on the wire) saw zero hits, spam-fuse not tripped.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
326 lines
10 KiB
Python
326 lines
10 KiB
Python
"""Tests for Phase 2.5b per-channel-type renderers."""
|
|
|
|
import json
|
|
import re
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from meshai.notifications.events import NotificationPayload, make_event, make_payload_from_event
|
|
from meshai.notifications.renderers import MeshRenderer, EmailRenderer, WebhookRenderer
|
|
|
|
|
|
# ============================================================
|
|
# MESH RENDERER TESTS
|
|
# ============================================================
|
|
|
|
def test_mesh_render_short_message_single_chunk():
|
|
"""Short message produces a single chunk."""
|
|
payload = NotificationPayload(
|
|
message="Test alert",
|
|
category="test",
|
|
severity="routine",
|
|
timestamp=time.time(),
|
|
)
|
|
renderer = MeshRenderer()
|
|
chunks = renderer.render(payload)
|
|
assert len(chunks) == 1
|
|
assert "Test alert" in chunks[0]
|
|
|
|
|
|
def test_mesh_render_passes_message_verbatim():
|
|
"""v0.5.7-regression: MeshRenderer no longer prepends '[<Family>] '.
|
|
The composer (compose_mesh_message) is the single source of truth for
|
|
mesh formatting since v0.5.2 -- its output already starts with the
|
|
family emoji + label (e.g. '🚨 ROADS:'). The renderer used to wrap
|
|
that with '[Roads] ' producing the visually-broken duplicate
|
|
'[Roads] 🚨 ROADS: ...' that hit the live mesh during the v0.5.7
|
|
staged flip. Now the renderer is a verbatim pass-through."""
|
|
payload = NotificationPayload(
|
|
message="Severe storm",
|
|
category="weather_warning",
|
|
severity="priority",
|
|
timestamp=time.time(),
|
|
event_type="weather_warning",
|
|
)
|
|
renderer = MeshRenderer()
|
|
chunks = renderer.render(payload)
|
|
assert len(chunks) == 1
|
|
assert chunks[0] == "Severe storm", chunks[0]
|
|
assert not chunks[0].startswith("["), chunks[0]
|
|
|
|
|
|
def test_mesh_render_unknown_event_type_no_prefix():
|
|
"""v0.5.7-regression: same verbatim pass-through behavior regardless of
|
|
whether the event_type is in the registry."""
|
|
payload = NotificationPayload(
|
|
message="Hello",
|
|
category="made_up_thing",
|
|
severity="routine",
|
|
timestamp=time.time(),
|
|
event_type="made_up_thing",
|
|
)
|
|
renderer = MeshRenderer()
|
|
chunks = renderer.render(payload)
|
|
assert len(chunks) == 1
|
|
assert chunks[0] == "Hello"
|
|
assert not chunks[0].startswith("[")
|
|
|
|
|
|
def test_mesh_render_long_message_chunks():
|
|
"""Long message splits into multiple chunks with counters."""
|
|
# Build a ~500 char message
|
|
long_message = "This is a very long alert message that should definitely exceed the two hundred character limit. " * 5
|
|
payload = NotificationPayload(
|
|
message=long_message,
|
|
category="weather_warning",
|
|
severity="priority",
|
|
timestamp=time.time(),
|
|
event_type="weather_warning",
|
|
)
|
|
renderer = MeshRenderer()
|
|
chunks = renderer.render(payload)
|
|
|
|
assert len(chunks) >= 2
|
|
for chunk in chunks:
|
|
assert len(chunk) <= 200, f"Chunk exceeds limit: {len(chunk)} chars"
|
|
# Check each chunk ends with "(k/N)" counter
|
|
for chunk in chunks:
|
|
assert re.search(r"\(\d+/\d+\)$", chunk), f"Missing counter: {chunk}"
|
|
|
|
|
|
def test_mesh_render_chunks_preserve_full_content():
|
|
"""Chunking preserves all words from the original message."""
|
|
# Build a message with distinct tokens
|
|
words = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf",
|
|
"hotel", "india", "juliet", "kilo", "lima", "mike", "november",
|
|
"oscar", "papa", "quebec", "romeo", "sierra", "tango", "uniform",
|
|
"victor", "whiskey", "xray", "yankee", "zulu"]
|
|
long_message = " ".join(words * 3) # Repeat to span multiple chunks
|
|
|
|
payload = NotificationPayload(
|
|
message=long_message,
|
|
category="test",
|
|
severity="routine",
|
|
timestamp=time.time(),
|
|
)
|
|
renderer = MeshRenderer()
|
|
chunks = renderer.render(payload)
|
|
|
|
# Join all chunks and remove counters
|
|
combined = " ".join(chunks)
|
|
combined = re.sub(r"\s*\(\d+/\d+\)", "", combined)
|
|
|
|
# Every original word should appear
|
|
for word in words:
|
|
assert word in combined, f"Missing word: {word}"
|
|
|
|
|
|
def test_mesh_render_no_event_type():
|
|
"""Payload without event_type has no prefix."""
|
|
payload = NotificationPayload(
|
|
message="Plain message",
|
|
category="test",
|
|
severity="routine",
|
|
timestamp=time.time(),
|
|
event_type=None,
|
|
)
|
|
renderer = MeshRenderer()
|
|
chunks = renderer.render(payload)
|
|
assert len(chunks) == 1
|
|
assert chunks[0] == "Plain message"
|
|
|
|
|
|
# ============================================================
|
|
# EMAIL RENDERER TESTS
|
|
# ============================================================
|
|
|
|
def test_email_render_subject_includes_severity_and_type():
|
|
"""Subject line includes severity and event type."""
|
|
payload = NotificationPayload(
|
|
message="Storm approaching",
|
|
category="weather_warning",
|
|
severity="immediate",
|
|
timestamp=time.time(),
|
|
event_type="weather_warning",
|
|
)
|
|
renderer = EmailRenderer()
|
|
rendered = renderer.render(payload)
|
|
|
|
assert "IMMEDIATE" in rendered["subject"]
|
|
assert "Weather Warning" in rendered["subject"]
|
|
|
|
|
|
def test_email_render_body_includes_message_and_context():
|
|
"""Body includes message and structured context fields."""
|
|
fixed_time = 1700000000.0 # Known timestamp
|
|
payload = NotificationPayload(
|
|
message="Test alert message",
|
|
category="battery_warning",
|
|
severity="priority",
|
|
timestamp=fixed_time,
|
|
event_type="battery_warning",
|
|
region="Magic Valley",
|
|
node_name="BLD-MTN",
|
|
)
|
|
renderer = EmailRenderer()
|
|
rendered = renderer.render(payload)
|
|
body = rendered["body"]
|
|
|
|
assert "Test alert message" in body
|
|
assert "Magic Valley" in body
|
|
assert "BLD-MTN" in body
|
|
assert "priority" in body
|
|
assert "battery_warning" in body
|
|
# Check formatted date
|
|
assert "2023-11-14" in body # Date part of timestamp
|
|
|
|
|
|
def test_email_render_omits_missing_context():
|
|
"""Body omits lines for missing optional fields."""
|
|
payload = NotificationPayload(
|
|
message="Minimal alert",
|
|
category="test",
|
|
severity="routine",
|
|
timestamp=time.time(),
|
|
# No region, no node, no event_type
|
|
)
|
|
renderer = EmailRenderer()
|
|
rendered = renderer.render(payload)
|
|
body = rendered["body"]
|
|
|
|
assert "Minimal alert" in body
|
|
assert "Severity: routine" in body
|
|
assert "Region:" not in body
|
|
assert "Node:" not in body
|
|
assert "Category:" not in body
|
|
|
|
|
|
def test_email_render_includes_source_event():
|
|
"""Body includes source event details when present."""
|
|
event = make_event(
|
|
source="weather_adapter",
|
|
category="weather_warning",
|
|
severity="priority",
|
|
title="Severe Storm",
|
|
summary="Severe storm expected",
|
|
)
|
|
payload = make_payload_from_event(event)
|
|
|
|
renderer = EmailRenderer()
|
|
rendered = renderer.render(payload)
|
|
body = rendered["body"]
|
|
|
|
assert "weather_adapter" in body
|
|
assert "Severe Storm" in body
|
|
|
|
|
|
# ============================================================
|
|
# WEBHOOK RENDERER TESTS
|
|
# ============================================================
|
|
|
|
def test_webhook_render_has_schema_version():
|
|
"""Output includes schema_version field."""
|
|
payload = NotificationPayload(
|
|
message="Test",
|
|
category="test",
|
|
severity="routine",
|
|
timestamp=time.time(),
|
|
)
|
|
renderer = WebhookRenderer()
|
|
rendered = renderer.render(payload)
|
|
|
|
assert rendered["schema_version"] == "1.0"
|
|
|
|
|
|
def test_webhook_render_omits_none_fields():
|
|
"""None optional fields are omitted, not set to null."""
|
|
payload = NotificationPayload(
|
|
message="Test message",
|
|
category="test",
|
|
severity="routine",
|
|
timestamp=time.time(),
|
|
# All optional fields default to None
|
|
)
|
|
renderer = WebhookRenderer()
|
|
rendered = renderer.render(payload)
|
|
|
|
# Required fields present
|
|
assert "message" in rendered
|
|
assert "severity" in rendered
|
|
assert "timestamp" in rendered
|
|
assert "schema_version" in rendered
|
|
|
|
# Optional fields omitted
|
|
assert "node_id" not in rendered
|
|
assert "region" not in rendered
|
|
assert "chunk_index" not in rendered
|
|
|
|
|
|
def test_webhook_render_includes_source_event_when_present():
|
|
"""source_event dict included when payload has source event."""
|
|
event = make_event(
|
|
source="test_adapter",
|
|
category="test",
|
|
severity="routine",
|
|
title="Test Event",
|
|
)
|
|
payload = make_payload_from_event(event)
|
|
|
|
renderer = WebhookRenderer()
|
|
rendered = renderer.render(payload)
|
|
|
|
assert "source_event" in rendered
|
|
assert rendered["source_event"]["id"] == event.id
|
|
assert rendered["source_event"]["source"] == "test_adapter"
|
|
|
|
|
|
def test_webhook_render_is_json_serializable():
|
|
"""Rendered output is JSON-serializable (no datetime objects)."""
|
|
event = make_event(
|
|
source="test",
|
|
category="test",
|
|
severity="routine",
|
|
title="Test",
|
|
)
|
|
payload = make_payload_from_event(event)
|
|
payload.chunk_index = 1
|
|
payload.chunk_total = 3
|
|
payload.region = "Test Region"
|
|
payload.node_name = "Test-Node"
|
|
|
|
renderer = WebhookRenderer()
|
|
rendered = renderer.render(payload)
|
|
|
|
# Should not raise
|
|
json_str = json.dumps(rendered)
|
|
assert isinstance(json_str, str)
|
|
# Verify round-trip
|
|
parsed = json.loads(json_str)
|
|
assert parsed["message"] == payload.message
|
|
|
|
|
|
def test_webhook_render_includes_optional_fields_when_set():
|
|
"""Optional fields included when they have values."""
|
|
payload = NotificationPayload(
|
|
message="Test",
|
|
category="test_category",
|
|
severity="priority",
|
|
timestamp=time.time(),
|
|
event_type="battery_warning",
|
|
node_id="!abc123",
|
|
node_name="Test-Node",
|
|
region="Test Region",
|
|
chunk_index=2,
|
|
chunk_total=5,
|
|
)
|
|
renderer = WebhookRenderer()
|
|
rendered = renderer.render(payload)
|
|
|
|
assert rendered["category"] == "test_category"
|
|
assert rendered["event_type"] == "battery_warning"
|
|
assert rendered["node_id"] == "!abc123"
|
|
assert rendered["node_name"] == "Test-Node"
|
|
assert rendered["region"] == "Test Region"
|
|
assert rendered["chunk_index"] == 2
|
|
assert rendered["chunk_total"] == 5
|