Files
awoooi/apps/api/tests/test_telegram_delivery_truth_callers.py

421 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Regression tests for callers that must distinguish routing from delivery."""
from __future__ import annotations
from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from src.models.incident import Incident, Severity, Signal
from src.repositories.alert_operation_log_repository import ALERT_EVENT_TYPES
from src.services import decision_manager as decision_module
from src.services import report_generation_service as report_module
from src.services import telegram_gateway as gateway_module
from src.services.ai_rate_limiter import AIRateLimiter
from src.services.approval_execution import ApprovalExecutionService
from src.services.report_generation_service import ReportGenerationService
NO_SEND_RECEIPT = {
"ok": True,
"_awooop_delivery_status": "blocked_no_egress",
"_awooop_provider_send_performed": False,
"_awoooi_canonical_route_receipt": {
"decision": "blocked",
"provider_send_performed": False,
},
}
_SENT_CHAT_ID = -1001
_SENT_DESTINATION_BINDING = gateway_module._telegram_destination_binding(
_SENT_CHAT_ID
)
SENT_RECEIPT = {
"ok": True,
"_awooop_delivery_status": "sent",
"_awooop_provider_send_performed": True,
"result": {"message_id": 42, "chat": {"id": -1001}},
"_awoooi_canonical_route_receipt": {
"schema_version": "telegram_canonical_egress_receipt_v1",
"decision": "allowed",
"destination_alias": "awoooi_sre_war_room",
"destination_binding": _SENT_DESTINATION_BINDING,
"sender_bot_alias": "tsenyang_bot",
"provider_send_performed": True,
},
gateway_module._DELIVERY_CONTEXT_KEY: {
"schema_version": "telegram_delivery_context_v1",
"method": "sendMessage",
"sender_bot_alias": "tsenyang_bot",
"destination_alias": "awoooi_sre_war_room",
"destination_binding": _SENT_DESTINATION_BINDING,
"payload_destination_binding": _SENT_DESTINATION_BINDING,
"provider_destination_binding": _SENT_DESTINATION_BINDING,
"destination_binding_verified": True,
},
}
class FakeRedis:
def __init__(self) -> None:
self.values: dict[str, str] = {}
self.set_calls: list[tuple[str, str, int | None]] = []
self.setex_calls: list[tuple[str, int, str]] = []
async def get(self, key: str):
return self.values.get(key)
async def exists(self, key: str) -> bool:
return key in self.values
async def set(self, key: str, value: str, ex: int | None = None, **_kwargs):
self.values[key] = value
self.set_calls.append((key, value, ex))
return True
async def setex(self, key: str, ttl: int, value: str) -> bool:
self.values[key] = value
self.setex_calls.append((key, ttl, value))
return True
class FakeLogger:
def __init__(self) -> None:
self.events: list[tuple[str, str]] = []
def debug(self, event: str, **_kwargs) -> None:
self.events.append(("debug", event))
def info(self, event: str, **_kwargs) -> None:
self.events.append(("info", event))
def warning(self, event: str, **_kwargs) -> None:
self.events.append(("warning", event))
def error(self, event: str, **_kwargs) -> None:
self.events.append(("error", event))
@pytest.mark.asyncio
@pytest.mark.parametrize("method_name", ["_send_cost_alert", "_send_cost_warning"])
@pytest.mark.parametrize("delivery_result", [NO_SEND_RECEIPT, None])
async def test_cost_alert_dedup_is_written_only_after_affirmative_delivery(
monkeypatch,
method_name: str,
delivery_result: object,
) -> None:
redis = FakeRedis()
class Gateway:
def canonical_destination_chat_id(self, **_route) -> str:
return "sre-room"
async def send_canonical_message(self, **_payload):
return delivery_result
monkeypatch.setattr(
"src.services.telegram_gateway.get_telegram_gateway",
lambda: Gateway(),
)
limiter = AIRateLimiter()
limiter._redis = redis
if method_name == "_send_cost_alert":
await limiter._send_cost_alert("gemini", 5.1, 5.0)
else:
await limiter._send_cost_warning("gemini", 4.1, 4.0)
assert redis.set_calls == []
@pytest.mark.asyncio
async def test_cost_alert_sets_dedup_after_affirmative_delivery(monkeypatch) -> None:
redis = FakeRedis()
class Gateway:
def canonical_destination_chat_id(self, **_route) -> str:
return "sre-room"
async def send_canonical_message(self, **_payload):
return dict(SENT_RECEIPT)
monkeypatch.setattr(
"src.services.telegram_gateway.get_telegram_gateway",
lambda: Gateway(),
)
limiter = AIRateLimiter()
limiter._redis = redis
await limiter._send_cost_alert("gemini", 5.1, 5.0)
assert len(redis.set_calls) == 1
assert redis.set_calls[0][0] == "ai_rate:cost_alert_sent:gemini"
@pytest.mark.asyncio
async def test_p3_reports_fail_closed_before_collection_when_route_missing(
monkeypatch,
) -> None:
class Gateway:
def canonical_destination_chat_id(self, **_route) -> str:
return ""
async def send_to_group(self, *_args, **_kwargs):
raise AssertionError("provider must not be called without a route")
monkeypatch.setattr(
"src.services.telegram_gateway.get_telegram_gateway",
lambda: Gateway(),
)
service = ReportGenerationService()
daily = AsyncMock()
monthly = AsyncMock()
monkeypatch.setattr(service, "collect_daily_kpi", daily)
monkeypatch.setattr(service, "collect_report_source_health", monthly)
await service.send_daily_report()
monthly_result = await service.send_monthly_report()
daily.assert_not_awaited()
monthly.assert_not_awaited()
assert monthly_result is False
@pytest.mark.asyncio
async def test_postmortem_no_ack_retries_and_verifies_fallback(monkeypatch) -> None:
send = AsyncMock(return_value=dict(NO_SEND_RECEIPT))
class Gateway:
send_to_group = send
monkeypatch.setattr(
"src.services.telegram_gateway.get_telegram_gateway",
lambda: Gateway(),
)
monkeypatch.setattr(report_module.asyncio, "sleep", AsyncMock())
service = ReportGenerationService()
monkeypatch.setattr(service, "_persist_postmortem_km", AsyncMock())
now = datetime.now(UTC)
await service.trigger_postmortem(
incident_id="INC-NO-ACK",
title="provider did not acknowledge",
created_at=now - timedelta(minutes=12),
resolved_at=now,
)
# Three bounded attempts plus one simplified fallback; all remain no-send.
assert send.await_count == 4
@pytest.mark.asyncio
async def test_approval_blocked_receipt_writes_no_send_not_sent(monkeypatch) -> None:
redis = FakeRedis()
operations: list[dict] = []
class Gateway:
def canonical_destination_chat_id(self, **_route) -> str:
return "sre-room"
async def _send_request(self, *_args, **_kwargs):
return dict(SENT_RECEIPT)
async def send_canonical_message(self, **_payload):
return dict(NO_SEND_RECEIPT)
class OperationRepository:
async def append(self, event_type: str, **kwargs) -> None:
operations.append({"event_type": event_type, **kwargs})
class BrokenDB:
async def execute(self, *_args, **_kwargs):
raise RuntimeError("expected test DB bypass")
@asynccontextmanager
async def fake_db_context():
yield BrokenDB()
monkeypatch.setattr("src.core.redis_client.get_redis", lambda: redis)
monkeypatch.setattr("src.db.base.get_db_context", fake_db_context)
monkeypatch.setattr(
"src.services.telegram_gateway.get_telegram_gateway",
lambda: Gateway(),
)
monkeypatch.setattr(
"src.repositories.alert_operation_log_repository.get_alert_operation_log_repository",
lambda: OperationRepository(),
)
monkeypatch.setattr(
"src.services.approval_execution.incident_truth_chain_button_row",
lambda _incident_id: None,
)
service = ApprovalExecutionService()
monkeypatch.setattr(
service,
"_fetch_operator_outcome_for_result",
AsyncMock(
return_value={
"state": "completed_verified",
"summary_zh": "verified",
"needs_human": False,
"next_action": "monitor",
"notification": {"channels": []},
"execution_result": {},
}
),
)
approval = SimpleNamespace(
id="approval-no-ack",
incident_id="INC-NO-ACK",
action="kubectl rollout restart deployment/api",
requested_by="operator",
)
await service._push_execution_result_to_alert(
approval,
success=True,
error=None,
execution_kind="controlled_apply",
repair_executed=True,
repair_attempted=True,
)
assert [row["event_type"] for row in operations] == ["NOTIFICATION_CLASSIFIED"]
assert operations[0]["event_type"] in ALERT_EVENT_TYPES
assert operations[0]["action_detail"] == "telegram_execution_result_no_send"
assert operations[0]["success"] is False
assert operations[0]["context"]["execution_success"] is True
def _incident() -> Incident:
return Incident(
incident_id="INC-DECISION-NO-ACK",
severity=Severity.P3,
signals=[
Signal(
alert_name="ContainerPressure",
severity=Severity.P3,
source="alertmanager",
fired_at=datetime.now(UTC),
labels={"alertname": "ContainerPressure"},
)
],
affected_services=["api"],
)
@pytest.mark.asyncio
async def test_decision_no_ack_does_not_set_fingerprint_dedup(monkeypatch) -> None:
from src.services.telegram_gateway import NotificationType
redis = FakeRedis()
class Gateway:
async def send_info_notification(self, **_payload):
return dict(NO_SEND_RECEIPT)
class ApprovalService:
async def update_action_by_incident_id(self, **_payload) -> None:
return None
monkeypatch.setattr(decision_module.settings, "OPENCLAW_TG_BOT_TOKEN", "configured")
monkeypatch.setattr("src.core.redis_client.get_redis", lambda: redis)
monkeypatch.setattr(
"src.services.telegram_gateway.get_telegram_gateway",
lambda: Gateway(),
)
monkeypatch.setattr(
"src.services.telegram_gateway.classify_notification",
lambda **_kwargs: NotificationType.TYPE_1,
)
monkeypatch.setattr(
"src.services.approval_db.get_approval_service",
lambda: ApprovalService(),
)
await decision_module._push_decision_to_telegram(
_incident(),
{
"action": "OBSERVE",
"description": "bounded observation",
"reasoning": "診斷bounded observation",
"risk_level": "low",
"confidence": 0.9,
"source": "test",
},
)
assert not any(key.startswith("telegram_sent:fp:") for key in redis.values)
@pytest.mark.asyncio
async def test_log_summary_route_preflight_runs_before_llm(monkeypatch) -> None:
summarize = AsyncMock(return_value="summary")
class Gateway:
def canonical_destination_chat_id(self, **_route) -> str:
return ""
async def send_as_nemotron(self, *_args, **_kwargs):
raise AssertionError("provider must not be called without a route")
monkeypatch.setattr(
"src.services.telegram_gateway.get_telegram_gateway",
lambda: Gateway(),
)
monkeypatch.setattr(
"src.services.log_summary_service.get_log_summary_service",
lambda: SimpleNamespace(summarize_with_soft_timeout=summarize),
)
await decision_module._send_log_summary(_incident())
summarize.assert_not_awaited()
@pytest.mark.asyncio
async def test_auto_repair_fallback_requires_affirmative_delivery(monkeypatch) -> None:
test_logger = FakeLogger()
class Gateway:
async def append_incident_update(self, **_payload) -> bool:
return False
def canonical_destination_chat_id(self, **_route) -> str:
return "sre-room"
async def send_alert_notification(self, *_args, **_kwargs):
return dict(NO_SEND_RECEIPT)
class DB:
async def execute(self, *_args, **_kwargs) -> None:
return None
async def commit(self) -> None:
return None
@asynccontextmanager
async def fake_db_context():
yield DB()
monkeypatch.setattr(decision_module, "logger", test_logger)
monkeypatch.setattr("src.db.base.get_db_context", fake_db_context)
monkeypatch.setattr(
"src.services.telegram_gateway.get_telegram_gateway",
lambda: Gateway(),
)
await decision_module._push_auto_repair_result(
_incident(),
action="bounded observe",
success=False,
error="verifier_failed",
)
event_names = [event for _level, event in test_logger.events]
assert "auto_repair_result_no_send" in event_names
assert "auto_repair_result_sent" not in event_names