feat: deliver alert events through persistent outbox

This commit is contained in:
2026-08-04 15:08:06 +08:00
parent 304479441e
commit fea537b9d7
6 changed files with 1297 additions and 369 deletions
+427
View File
@@ -0,0 +1,427 @@
"""Transactional alert-event and notification-outbox workflow tests."""
import asyncio
from datetime import datetime, timedelta
import pytest
from sqlalchemy import select
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from app.config import Settings
from app.models import (
AlertEvent,
AlertTypeEnum,
Base,
Device,
DeviceTypeEnum,
NotificationOutbox,
NotificationStatus,
PingRecord,
)
from app.services.alerter import Alerter
from app.services.fping_runner import ProbeResult
from app.services.pinger import DeviceStateChange, Pinger
from app.services.scheduler import PingScheduler
@pytest.fixture
async def db_session() -> AsyncSession:
"""Provide a complete isolated persistence boundary for alert tests."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
try:
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
async with AsyncSession(engine, expire_on_commit=False) as session:
yield session
finally:
await engine.dispose()
@pytest.fixture
async def offline_change(db_session: AsyncSession) -> DeviceStateChange:
"""Persist a device and expose a real offline state-change value."""
observed_at = datetime(2026, 8, 4, 9, 30)
device = Device(
name="核心交换机",
ip="10.0.0.8",
device_type=DeviceTypeEnum.switch,
location="一楼机房",
project_name="园区网",
current_status="offline",
last_ping_time=observed_at,
)
db_session.add(device)
await db_session.commit()
return DeviceStateChange(
device=device,
old_status="online",
new_status="offline",
consecutive_failures=2,
event_type="offline",
reason="2 consecutive full-loss rounds",
)
async def test_transition_creates_event_and_pending_outbox_in_one_transaction(
db_session: AsyncSession,
offline_change: DeviceStateChange,
):
"""Removing either insert breaks the durable event-delivery contract."""
event = await Alerter().record_transition(offline_change, db_session)
outbox = await db_session.scalar(
select(NotificationOutbox).where(
NotificationOutbox.alert_event_id == event.id
)
)
assert outbox is not None
assert outbox.status == NotificationStatus.pending
assert "核心交换机" in outbox.message_content
assert "10.0.0.8" in outbox.message_content
assert "一楼机房" in outbox.message_content
assert "园区网" in outbox.message_content
assert "2 consecutive full-loss rounds" in outbox.message_content
assert "2026-08-04 09:30:00" in outbox.message_content
async def test_event_and_outbox_rollback_together(
db_session: AsyncSession,
offline_change: DeviceStateChange,
):
"""A caller rollback cannot retain an event without its notification."""
await Alerter().record_transition(offline_change, db_session)
await db_session.rollback()
assert await db_session.scalar(select(AlertEvent)) is None
assert await db_session.scalar(select(NotificationOutbox)) is None
async def test_offline_escalation_closes_and_links_open_degraded_event(
db_session: AsyncSession,
offline_change: DeviceStateChange,
):
"""Escalating degraded to offline must not leave two open incidents."""
device = offline_change.device
device.last_ping_time = datetime(2026, 8, 4, 9, 20)
degraded = await Alerter().record_transition(
DeviceStateChange(
device=device,
old_status="online",
new_status="degraded",
consecutive_failures=0,
event_type="degraded",
reason="window packet loss 20.00%",
),
db_session,
)
await db_session.commit()
device.last_ping_time = datetime(2026, 8, 4, 9, 30)
offline = await Alerter().record_transition(
DeviceStateChange(
device=device,
old_status="degraded",
new_status="offline",
consecutive_failures=2,
event_type="offline",
reason="2 consecutive full-loss rounds",
),
db_session,
)
assert degraded.is_resolved is True
assert degraded.end_at == datetime(2026, 8, 4, 9, 30)
assert degraded.duration_minutes == 10
assert degraded.related_event_id == offline.id
assert offline.related_event_id == degraded.id
async def test_recovery_closes_open_fault_and_notifies_with_duration(
db_session: AsyncSession,
offline_change: DeviceStateChange,
):
"""Recovery closes one active incident and carries its duration to operators."""
opened = await Alerter().record_transition(offline_change, db_session)
await db_session.commit()
device = offline_change.device
device.last_ping_time = datetime(2026, 8, 4, 10, 1)
recovered = await Alerter().record_transition(
DeviceStateChange(
device=device,
old_status="offline",
new_status="online",
consecutive_failures=0,
event_type="recovered",
reason="3 consecutive clean rounds",
),
db_session,
)
outbox = await db_session.scalar(
select(NotificationOutbox).where(
NotificationOutbox.alert_event_id == recovered.id
)
)
assert opened.is_resolved is True
assert opened.end_at == datetime(2026, 8, 4, 10, 1)
assert opened.duration_minutes == 31
assert recovered.alert_type == AlertTypeEnum.recovered
assert recovered.is_resolved is True
assert recovered.duration_minutes == 31
assert recovered.related_event_id == opened.id
assert "持续时间:31 分钟" in outbox.message_content
async def test_probe_state_event_and_outbox_share_the_caller_transaction(
monkeypatch,
):
"""A failed caller commit cannot persist a state transition without its event."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
observed_at = datetime.now() - timedelta(seconds=30)
try:
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
async with AsyncSession(engine, expire_on_commit=False) as setup:
device = Device(
name="edge-atomic",
ip="10.0.0.18",
device_type=DeviceTypeEnum.switch,
current_status="online",
is_enabled=True,
offline_consecutive_rounds=2,
)
setup.add(device)
await setup.flush()
device_id = device.id
setup.add(
PingRecord(
device_id=device_id,
is_alive=False,
response_time_ms=None,
round_num=1,
sent_count=3,
received_count=0,
packet_loss_percent=100.0,
average_rtt_ms=None,
is_valid=True,
created_at=observed_at,
)
)
await setup.commit()
async def fake_run_fping_count(*args, **kwargs):
return {
"10.0.0.18": ProbeResult(
"10.0.0.18",
3,
0,
None,
True,
)
}
monkeypatch.setattr(
"app.services.pinger.run_fping_count",
fake_run_fping_count,
)
async with AsyncSession(engine, expire_on_commit=False) as session:
changes = await Pinger().run_one_round(session)
await Alerter().record_transition(changes[0], session)
await session.rollback()
async with AsyncSession(engine, expire_on_commit=False) as verification:
persisted_device = await verification.get(Device, device_id)
records = list(
(
await verification.execute(
select(PingRecord).where(PingRecord.device_id == device_id)
)
)
.scalars()
.all()
)
assert persisted_device.current_status == "online"
assert len(records) == 1
assert await verification.scalar(select(AlertEvent)) is None
assert await verification.scalar(select(NotificationOutbox)) is None
finally:
await engine.dispose()
class RecordingSession:
"""Small session boundary exposing transaction and close ordering."""
def __init__(self, events: list[str]):
self.events = events
self.closed = asyncio.Event()
async def __aenter__(self):
self.events.append("session-enter")
return self
async def __aexit__(self, exc_type, exc_value, traceback):
self.events.append("session-exit")
self.closed.set()
async def commit(self):
self.events.append("commit")
async def rollback(self):
self.events.append("rollback")
class RecordingPinger:
def __init__(self, events: list[str], changes=None):
self.events = events
self.changes = list(changes or ["transition"])
async def run_one_round(self, db):
self.events.append("probe")
return self.changes
class RecordingAlerter:
def __init__(self, events: list[str]):
self.events = events
async def record_transition(self, change, db):
self.events.append(f"alert:{change}")
class RecordingDispatcher:
def __init__(self, events: list[str]):
self.events = events
async def dispatch_due(self, db, now):
assert isinstance(now, datetime)
self.events.append("dispatch")
async def test_scheduler_persists_transitions_before_dispatching_due_messages():
"""Changing scheduler order cannot expose uncommitted outbox rows to dispatch."""
events: list[str] = []
session = RecordingSession(events)
scheduler = PingScheduler(
pinger=RecordingPinger(events),
alerter=RecordingAlerter(events),
dispatcher=RecordingDispatcher(events),
session_factory=lambda: session,
)
await scheduler._run_cycle()
assert events == [
"session-enter",
"probe",
"alert:transition",
"commit",
"dispatch",
"commit",
"session-exit",
]
async def test_scheduler_lock_prevents_overlapping_cycles():
"""Even concurrent triggers cannot overlap probe or notification sessions."""
events: list[str] = []
class ConcurrencyPinger:
active = 0
maximum = 0
async def run_one_round(self, db):
self.active += 1
self.maximum = max(self.maximum, self.active)
await asyncio.sleep(0.01)
self.active -= 1
return []
pinger = ConcurrencyPinger()
scheduler = PingScheduler(
pinger=pinger,
alerter=RecordingAlerter(events),
dispatcher=RecordingDispatcher(events),
session_factory=lambda: RecordingSession(events),
)
await asyncio.gather(scheduler._run_cycle(), scheduler._run_cycle())
assert pinger.maximum == 1
async def test_scheduler_stop_waits_for_active_session_to_close():
"""Cancellation cannot return while a probe session remains open."""
events: list[str] = []
session = RecordingSession(events)
probe_started = asyncio.Event()
never_complete = asyncio.Event()
class BlockingPinger:
async def run_one_round(self, db):
probe_started.set()
await never_complete.wait()
scheduler = PingScheduler(
pinger=BlockingPinger(),
alerter=RecordingAlerter(events),
dispatcher=RecordingDispatcher(events),
session_factory=lambda: session,
interval_seconds=1,
)
scheduler.start()
first_task = scheduler._task
scheduler.start()
await probe_started.wait()
await scheduler.stop()
assert first_task is not None
assert session.closed.is_set()
assert scheduler._task is None
assert events[-2:] == ["rollback", "session-exit"]
async def test_scheduler_leaves_pending_outbox_untouched_when_delivery_disabled():
"""Disabling WeCom preserves queued notifications for a later enablement."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
session_factory = async_sessionmaker(engine, expire_on_commit=False)
class EmptyPinger:
async def run_one_round(self, db):
return []
try:
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
async with session_factory() as setup:
setup.add(
NotificationOutbox(
alert_event_id=42,
message_content="保留待发送事件",
)
)
await setup.commit()
runtime_settings = Settings(wecom_notification_enabled=False)
scheduler = PingScheduler(
pinger=EmptyPinger(),
alerter=Alerter(runtime_settings),
session_factory=session_factory,
runtime_settings=runtime_settings,
)
await scheduler._run_cycle()
async with session_factory() as verification:
outbox = await verification.scalar(select(NotificationOutbox))
assert outbox.status == NotificationStatus.pending
assert outbox.attempt_count == 0
await scheduler.stop()
finally:
await engine.dispose()
@@ -0,0 +1,327 @@
"""Persistent notification dispatch and retry behavior tests."""
import json
import logging
from datetime import datetime, timedelta
import httpx
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from app.config import Settings
from app.models import (
AlertEvent,
AlertTypeEnum,
Base,
NotificationOutbox,
NotificationStatus,
)
from app.services.notification_dispatcher import (
DeliveryResult,
MAX_EVENTS_PER_MESSAGE,
NotificationDispatcher,
WeComClient,
)
class FakeWeComClient:
"""Return one controlled outcome without any external network access."""
def __init__(self, *results: DeliveryResult):
self._results = list(results)
self.contents: list[str] = []
async def send_text(self, content: str) -> DeliveryResult:
self.contents.append(content)
return self._results.pop(0)
@pytest.fixture
async def db_session() -> AsyncSession:
"""Provide an isolated outbox database."""
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
try:
async with engine.begin() as connection:
await connection.run_sync(Base.metadata.create_all)
async with AsyncSession(engine, expire_on_commit=False) as session:
yield session
finally:
await engine.dispose()
async def test_retryable_failure_reschedules(
db_session: AsyncSession,
):
"""A transient failure must remain pending with exponential retry evidence."""
frozen_time = datetime(2026, 8, 4, 10, 0)
pending_message = NotificationOutbox(
alert_event_id=1,
message_content="设备离线",
)
db_session.add(pending_message)
await db_session.commit()
client = FakeWeComClient(DeliveryResult(False, "timeout", True))
await NotificationDispatcher(client).dispatch_due(
db_session,
frozen_time,
)
assert pending_message.status == NotificationStatus.pending
assert pending_message.attempt_count == 1
assert pending_message.next_attempt_at == datetime(2026, 8, 4, 10, 0, 30)
async def test_success_marks_outbox_and_event_delivered(
db_session: AsyncSession,
):
"""A successful transport result updates both delivery evidence records."""
now = datetime(2026, 8, 4, 10, 0)
event = AlertEvent(
device_id=7,
alert_type=AlertTypeEnum.offline,
message="full loss",
start_at=now,
is_resolved=False,
)
db_session.add(event)
await db_session.flush()
message = NotificationOutbox(
alert_event_id=event.id,
message_content="设备 7 离线",
)
db_session.add(message)
await db_session.commit()
summary = await NotificationDispatcher(
FakeWeComClient(DeliveryResult(True)),
_enabled_settings(),
).dispatch_due(db_session, now)
assert summary.sent == 1
assert message.status == NotificationStatus.sent
assert message.sent_at == now
assert message.attempt_count == 1
assert event.notification_sent is True
assert event.notification_attempts == 1
assert event.last_notification_error is None
async def test_non_retryable_or_exhausted_failure_is_terminal(
db_session: AsyncSession,
):
"""The configured attempt ceiling prevents an infinite retry loop."""
now = datetime(2026, 8, 4, 10, 0)
message = NotificationOutbox(
alert_event_id=99,
message_content="bad recipient",
attempt_count=1,
)
db_session.add(message)
await db_session.commit()
runtime_settings = _enabled_settings(wecom_notification_max_attempts=2)
summary = await NotificationDispatcher(
FakeWeComClient(DeliveryResult(False, "HTTP 503", True)),
runtime_settings,
).dispatch_due(db_session, now)
assert summary.failed == 1
assert message.status == NotificationStatus.failed
assert message.attempt_count == 2
assert message.next_attempt_at is None
async def test_only_due_messages_are_dispatched_in_fixed_safe_batches(
db_session: AsyncSession,
):
"""Large bursts are split while future retries remain untouched."""
now = datetime(2026, 8, 4, 10, 0)
due = [
NotificationOutbox(alert_event_id=index, message_content=f"事件 {index}")
for index in range(MAX_EVENTS_PER_MESSAGE + 1)
]
future = NotificationOutbox(
alert_event_id=100,
message_content="未来重试",
next_attempt_at=now + timedelta(minutes=1),
)
db_session.add_all([*due, future])
await db_session.commit()
client = FakeWeComClient(DeliveryResult(True), DeliveryResult(True))
summary = await NotificationDispatcher(
client,
_enabled_settings(),
).dispatch_due(db_session, now)
assert summary.sent == MAX_EVENTS_PER_MESSAGE + 1
assert len(client.contents) == 2
assert future.status == NotificationStatus.pending
assert future.attempt_count == 0
def _enabled_settings(**overrides) -> Settings:
values = {
"wecom_notification_enabled": True,
"WECOM_CORP_ID": "test-corp",
"WECOM_AGENT_ID": 1000001,
"WECOM_APP_SECRET": "test-secret",
}
values.update(overrides)
return Settings(**values)
@pytest.mark.parametrize(
("to_party", "expected_recipient", "unexpected_key"),
[("", ("touser", "@all"), "toparty"), ("2|3", ("toparty", "2|3"), "touser")],
)
async def test_wecom_uses_application_scope_unless_department_is_explicit(
to_party,
expected_recipient,
unexpected_key,
):
"""Recipient payloads preserve the app visibility boundary by default."""
payloads: list[dict] = []
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/gettoken"):
return httpx.Response(
200,
json={"errcode": 0, "access_token": "token-a", "expires_in": 7200},
)
payloads.append(json.loads(request.content))
return httpx.Response(200, json={"errcode": 0, "errmsg": "ok"})
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
client = WeComClient(
_enabled_settings(WECOM_TO_PARTY=to_party),
http_client=http,
)
result = await client.send_text("controlled test message")
key, value = expected_recipient
assert result.success is True
assert payloads[0][key] == value
assert unexpected_key not in payloads[0]
assert payloads[0]["agentid"] == 1000001
async def test_wecom_caches_token_until_expiry_margin():
"""Repeated messages do not request a new token before its safe expiry."""
calls = {"token": 0, "message": 0}
current_time = [datetime(2026, 8, 4, 10, 0)]
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/gettoken"):
calls["token"] += 1
return httpx.Response(
200,
json={"errcode": 0, "access_token": "token-a", "expires_in": 120},
)
calls["message"] += 1
return httpx.Response(200, json={"errcode": 0, "errmsg": "ok"})
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
client = WeComClient(
_enabled_settings(),
http_client=http,
now=lambda: current_time[0],
)
await client.send_text("first")
current_time[0] += timedelta(seconds=30)
await client.send_text("second")
assert calls == {"token": 1, "message": 2}
async def test_wecom_refreshes_token_after_safety_margin():
"""A token inside the safety margin cannot be reused for a new message."""
token_calls = 0
current_time = [datetime(2026, 8, 4, 10, 0)]
def handler(request: httpx.Request) -> httpx.Response:
nonlocal token_calls
if request.url.path.endswith("/gettoken"):
token_calls += 1
return httpx.Response(
200,
json={
"errcode": 0,
"access_token": f"token-{token_calls}",
"expires_in": 120,
},
)
return httpx.Response(200, json={"errcode": 0, "errmsg": "ok"})
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
client = WeComClient(
_enabled_settings(),
http_client=http,
now=lambda: current_time[0],
)
await client.send_text("first")
current_time[0] += timedelta(seconds=61)
await client.send_text("second")
assert token_calls == 2
async def test_wecom_rejects_non_object_json_without_raising():
"""Malformed external response shapes return sanitized retry evidence."""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(200, json=["unexpected", "shape"])
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
result = await WeComClient(
_enabled_settings(),
http_client=http,
).send_text("controlled")
assert result == DeliveryResult(False, "invalid token response", True)
@pytest.mark.parametrize("status_code", [429, 500, 503])
async def test_wecom_marks_rate_limit_and_server_errors_retryable(status_code):
"""Transient HTTP classes reach the outbox retry path without response leakage."""
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/gettoken"):
return httpx.Response(
200,
json={"errcode": 0, "access_token": "token-a", "expires_in": 7200},
)
return httpx.Response(status_code, text="sensitive-response-body")
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
result = await WeComClient(
_enabled_settings(),
http_client=http,
).send_text("sensitive-message-content")
assert result.success is False
assert result.retryable is True
assert result.error == f"HTTP {status_code}"
async def test_wecom_transport_failure_is_sanitized_and_retryable(caplog):
"""Logs and returned evidence never expose secrets, tokens, bodies, or content."""
def handler(request: httpx.Request) -> httpx.Response:
if request.url.path.endswith("/gettoken"):
return httpx.Response(
200,
json={"errcode": 0, "access_token": "token-a", "expires_in": 7200},
)
raise httpx.ConnectError("response-sensitive", request=request)
caplog.set_level(logging.WARNING)
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
result = await WeComClient(
_enabled_settings(WECOM_APP_SECRET="secret-sensitive"),
http_client=http,
).send_text("message-sensitive")
assert result == DeliveryResult(False, "transport error", True)
assert "secret-sensitive" not in caplog.text
assert "token-a" not in caplog.text
assert "response-sensitive" not in caplog.text
assert "message-sensitive" not in caplog.text