meshai/work/tests/test_destinations_c1.py

348 lines
13 KiB
Python
Raw Normal View History

"""Integration C1 — reusable NotificationDestinations (additive, inline fallback).
The delivery config was DUPLICATED inline on every NotificationToggle and
NotificationRuleConfig. C1 introduces a shared, named NotificationDestination
that toggles/rules reference by name. The safety contract is ADDITIVE +
regression-free:
* EMPTY `destinations` (all pre-C1 config) -> the existing inline-field
delivery path runs BYTE-IDENTICALLY (proven here against _toggle_to_rule /
the rule object itself).
* NON-EMPTY `destinations` -> delivery resolves from the shared destination's
fields, gated only by region scope + the min_severity floor. The
severity_channels matrix is intentionally BYPASSED on the destination path.
"""
import asyncio
import dataclasses
import pytest
from meshai.config import (
Config,
NotificationDestination,
NotificationRuleConfig,
load_config,
save_config,
synthesize_destinations,
)
from meshai.notifications.pipeline.dispatcher import Dispatcher
from meshai.notifications.events import make_event
class RecChannel:
"""Records the FULL rule object handed to create_channel/deliver so tests
can assert delivery params are byte-identical."""
def __init__(self, rule, rec, succeed=True):
self._rule = rule
self._rec = rec
self._succeed = succeed
async def deliver(self, payload, rule):
self._rec.append({
"delivery_type": rule.delivery_type,
"rule": rule,
"message": payload.message,
})
return self._succeed
def _disp(cfg, succeed=True):
rec: list = []
d = Dispatcher(cfg,
lambda rule, conn: RecChannel(rule, rec, succeed),
connector=None)
return d, rec
def _base_cfg():
cfg = Config()
cfg.notifications.rules = []
cfg.notifications.cold_start_grace_seconds = 0
return cfg
def _weather_ev(eid="ev-1", severity="priority"):
ev = make_event(source="nws", category="weather.alert.severe",
severity=severity, title="t")
ev.id = eid
return ev
def _rule_fields(rule):
"""Delivery-relevant subset for byte-identical comparison."""
return {
k: getattr(rule, k) for k in (
"delivery_type", "broadcast_channel", "meshcore_channel",
"node_ids", "meshcore_dm_contacts", "smtp_host", "smtp_port",
"smtp_user", "smtp_password", "smtp_tls", "from_address",
"recipients", "webhook_url", "webhook_headers",
)
}
# --------------------------------------------------------------- inline path
def test_inline_toggle_delivery_is_byte_identical():
"""A toggle with inline delivery fields + EMPTY destinations must deliver
via the EXACT same synthesized rule params as the unchanged inline path
(_toggle_to_rule)."""
cfg = _base_cfg()
t = cfg.notifications.toggles["weather"]
t.enabled = True
t.min_severity = "routine"
t.cooldown_seconds = 0
t.severity_channels = {"priority": ["mesh_broadcast", "email", "webhook"]}
t.broadcast_channel = 3
t.smtp_host = "smtp.example.com"
t.smtp_port = 2525
t.smtp_user = "u"
t.smtp_password = "p"
t.from_address = "alerts@example.com"
t.recipients = ["ops@example.com", "oncall@example.com"]
t.webhook_url = "https://hook.example.com/x"
t.webhook_headers = {"X-Token": "abc"}
assert t.destinations == [] # opted out -> inline fallback
d, rec = _disp(cfg)
ev = _weather_ev(severity="priority")
asyncio.run(d.dispatch(ev))
# One delivery per channel type in the matrix row, same order.
assert [r["delivery_type"] for r in rec] == \
["mesh_broadcast", "email", "webhook"]
# Each delivered rule is byte-identical to the unchanged _toggle_to_rule
# construction -> proves the inline path routes exactly as before.
for ct, r in zip(["mesh_broadcast", "email", "webhook"], rec):
expected = _rule_fields(d._toggle_to_rule(t, ct, ev))
assert _rule_fields(r["rule"]) == expected
def test_inline_rule_delivery_unchanged_when_no_destinations():
"""A condition rule with inline delivery + EMPTY destinations delivers via
the rule object itself (drule IS rule)."""
cfg = _base_cfg()
rule = NotificationRuleConfig(
name="ops-email", enabled=True, trigger_type="condition",
categories=["weather.alert.severe"], min_severity="routine",
delivery_type="email", smtp_host="smtp.example.com",
recipients=["ops@example.com"], from_address="a@example.com",
)
assert rule.destinations == []
cfg.notifications.rules = [rule]
d, rec = _disp(cfg)
asyncio.run(d.dispatch(_weather_ev(severity="immediate")))
assert len(rec) == 1
# The delivered rule is the SAME object -> byte-identical, zero regression.
assert rec[0]["rule"] is rule
# ----------------------------------------------------------- destination path
def test_toggle_destination_path_uses_destination_params():
"""A toggle referencing a destination delivers via the DESTINATION's fields,
not the toggle's inline fields (inline values differ to prove it)."""
cfg = _base_cfg()
cfg.notifications.destinations = {
"email_ops": NotificationDestination(
name="email_ops", type="email",
smtp_host="dest-smtp.example.com", smtp_port=465, smtp_user="du",
smtp_password="dp", from_address="dest@example.com",
recipients=["dest-ops@example.com"],
),
}
t = cfg.notifications.toggles["weather"]
t.enabled = True
t.min_severity = "routine"
t.cooldown_seconds = 0
t.destinations = ["email_ops"]
# Divergent inline values that MUST be ignored on the destination path.
t.severity_channels = {"priority": ["mesh_broadcast"]}
t.smtp_host = "INLINE-should-not-be-used"
t.recipients = ["inline@example.com"]
d, rec = _disp(cfg)
asyncio.run(d.dispatch(_weather_ev(severity="priority")))
assert len(rec) == 1
r = rec[0]["rule"]
assert r.delivery_type == "email"
assert r.smtp_host == "dest-smtp.example.com"
assert r.smtp_port == 465
assert r.recipients == ["dest-ops@example.com"]
assert r.from_address == "dest@example.com"
def test_toggle_destinations_bypass_severity_channels_matrix():
"""The destination path is gated by the min_severity FLOOR only; an empty
severity_channels matrix does NOT suppress it (matrix is bypassed)."""
cfg = _base_cfg()
cfg.notifications.destinations = {
"mesh_a": NotificationDestination(
name="mesh_a", type="mesh_broadcast", broadcast_channel=7),
}
t = cfg.notifications.toggles["weather"]
t.enabled = True
t.min_severity = "priority"
t.cooldown_seconds = 0
t.destinations = ["mesh_a"]
t.severity_channels = {} # empty matrix would kill the inline path
d, rec = _disp(cfg)
# Above the floor -> fires despite empty matrix.
asyncio.run(d.dispatch(_weather_ev(eid="a", severity="priority")))
assert len(rec) == 1
assert rec[0]["rule"].delivery_type == "mesh_broadcast"
assert rec[0]["rule"].broadcast_channel == 7
def test_toggle_destinations_respect_min_severity_floor():
"""Below the floor, the destination path does not fire (floor still gates)."""
cfg = _base_cfg()
cfg.notifications.destinations = {
"mesh_a": NotificationDestination(
name="mesh_a", type="mesh_broadcast", broadcast_channel=7),
}
t = cfg.notifications.toggles["weather"]
t.enabled = True
t.min_severity = "immediate"
t.cooldown_seconds = 0
t.destinations = ["mesh_a"]
t.severity_channels = {}
d, rec = _disp(cfg)
asyncio.run(d.dispatch(_weather_ev(severity="priority"))) # below floor
assert rec == []
def test_toggle_multiple_destinations_all_fire():
"""A toggle may fan out to several destinations of mixed types; digest-typed
destinations are skipped on the live path."""
cfg = _base_cfg()
cfg.notifications.destinations = {
"mesh_a": NotificationDestination(
name="mesh_a", type="mesh_broadcast", broadcast_channel=1),
"email_ops": NotificationDestination(
name="email_ops", type="email", smtp_host="s", recipients=["r"]),
"daily": NotificationDestination(name="daily", type="digest"),
}
t = cfg.notifications.toggles["weather"]
t.enabled = True
t.min_severity = "routine"
t.cooldown_seconds = 0
t.destinations = ["mesh_a", "email_ops", "daily"]
d, rec = _disp(cfg)
asyncio.run(d.dispatch(_weather_ev(severity="priority")))
# digest is excluded from the live broadcast path.
assert sorted(r["delivery_type"] for r in rec) == ["email", "mesh_broadcast"]
def test_unknown_destination_reference_is_skipped_not_fatal():
"""A dangling destination name degrades gracefully (skipped, no crash) and,
when it leaves nothing to deliver, drops rather than falling back to inline."""
cfg = _base_cfg()
cfg.notifications.destinations = {} # registry empty
t = cfg.notifications.toggles["weather"]
t.enabled = True
t.min_severity = "routine"
t.cooldown_seconds = 0
t.destinations = ["nope"]
t.broadcast_channel = 9 # inline present but not used
d, rec = _disp(cfg)
asyncio.run(d.dispatch(_weather_ev(severity="priority")))
assert rec == []
def test_rule_destination_fanout():
"""A condition rule referencing destinations fans out to each; the rule's
own inline delivery_type is superseded."""
cfg = _base_cfg()
cfg.notifications.destinations = {
"mesh_a": NotificationDestination(
name="mesh_a", type="mesh_broadcast", broadcast_channel=2),
"email_ops": NotificationDestination(
name="email_ops", type="email", smtp_host="s", recipients=["r"]),
}
rule = NotificationRuleConfig(
name="fanout", enabled=True, trigger_type="condition",
categories=["weather.alert.severe"], min_severity="routine",
delivery_type="webhook", webhook_url="https://inline-not-used",
destinations=["mesh_a", "email_ops"],
)
cfg.notifications.rules = [rule]
d, rec = _disp(cfg)
asyncio.run(d.dispatch(_weather_ev(severity="immediate")))
assert sorted(r["delivery_type"] for r in rec) == ["email", "mesh_broadcast"]
assert all(r["rule"] is not rule for r in rec) # synthesized, not inline
# ------------------------------------------------------------------ round-trip
def test_destinations_roundtrip_save_load(tmp_path):
"""destinations dict + toggle.destinations + rule.destinations survive a
YAML save/load cycle as real dataclasses / lists."""
cfg = _base_cfg()
cfg.notifications.destinations = {
"email_ops": NotificationDestination(
name="email_ops", type="email", smtp_host="smtp.example.com",
smtp_port=465, recipients=["a@example.com", "b@example.com"],
webhook_headers={},
),
"mesh_a": NotificationDestination(
name="mesh_a", type="mesh_broadcast", broadcast_channel=4),
}
cfg.notifications.toggles["weather"].destinations = ["email_ops", "mesh_a"]
cfg.notifications.rules = [NotificationRuleConfig(
name="r1", destinations=["email_ops"])]
p = tmp_path / "config.yaml"
save_config(cfg, p)
loaded = load_config(p)
dests = loaded.notifications.destinations
assert set(dests) == {"email_ops", "mesh_a"}
assert isinstance(dests["email_ops"], NotificationDestination)
assert dests["email_ops"].smtp_port == 465
assert dests["email_ops"].recipients == ["a@example.com", "b@example.com"]
assert dests["mesh_a"].broadcast_channel == 4
assert loaded.notifications.toggles["weather"].destinations == \
["email_ops", "mesh_a"]
assert loaded.notifications.rules[0].destinations == ["email_ops"]
# ----------------------------------------------- synthesize_destinations helper
def test_synthesize_destinations_populates_refs_and_dedups():
"""The C2 migration helper creates named destinations from inline fields and
de-duplicates identical configs. It is NOT auto-run by delivery."""
cfg = _base_cfg()
# Two rules with an IDENTICAL email delivery -> should share one destination.
common = dict(delivery_type="email", smtp_host="s", from_address="f",
recipients=["r"])
cfg.notifications.rules = [
NotificationRuleConfig(name="r1", **common),
NotificationRuleConfig(name="r2", **common),
]
for t in cfg.notifications.toggles.values():
t.severity_channels = {} # keep toggle synthesis empty for this test
synthesize_destinations(cfg)
assert cfg.notifications.rules[0].destinations
# Same inline config -> same shared destination name.
assert cfg.notifications.rules[0].destinations == \
cfg.notifications.rules[1].destinations
ref = cfg.notifications.rules[0].destinations[0]
dest = cfg.notifications.destinations[ref]
assert dest.type == "email"
assert dest.recipients == ["r"]