feat: deliver alert events through persistent outbox
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user