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

461 lines
14 KiB
Python

"""Delivery-truth regressions for gateway and webhook Telegram callers."""
from __future__ import annotations
from contextlib import asynccontextmanager
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from src.api.v1 import sentry_webhook, signoz_webhook, telegram, webhooks
from src.services import telegram_gateway as gateway_module
from src.services.telegram_gateway import (
TelegramGateway,
_telegram_send_delivery_succeeded,
)
NO_SEND_RECEIPT = {
"ok": True,
"_awooop_delivery_status": "blocked_no_egress",
"_awooop_provider_send_performed": False,
"_awoooi_canonical_route_receipt": {
"decision": "blocked_no_egress",
"provider_send_performed": False,
},
}
SENT_RECEIPT = {
"ok": True,
"_awooop_delivery_status": "sent",
"_awooop_provider_send_performed": True,
"result": {"message_id": 42},
}
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))
def exception(self, event: str, **_kwargs) -> None:
self.events.append(("exception", event))
class FakeRedis:
def __init__(self, values: dict[str, str] | None = None) -> None:
self.values = dict(values or {})
self.set_calls: list[tuple[str, str, dict]] = []
async def get(self, key: str):
return self.values.get(key)
async def set(self, key: str, value: str, **kwargs):
self.set_calls.append((key, value, kwargs))
if kwargs.get("nx") and key in self.values:
return False
self.values[key] = value
return True
async def setex(self, key: str, _ttl: int, value: str):
self.values[key] = value
return True
def test_delivery_helper_rejects_truthy_structured_no_send() -> None:
assert _telegram_send_delivery_succeeded(dict(NO_SEND_RECEIPT)) is False
assert _telegram_send_delivery_succeeded({"ok": True}) is True
@pytest.mark.asyncio
async def test_mark_auto_repaired_returns_false_for_structured_no_send(
monkeypatch: pytest.MonkeyPatch,
) -> None:
redis = FakeRedis({"tg_approval:APR-1": "42"})
gateway = TelegramGateway()
async def send_request(method: str, _payload: dict):
if method == "sendMessage":
return dict(NO_SEND_RECEIPT)
return {"ok": True}
monkeypatch.setattr(gateway_module, "get_redis", lambda: redis)
monkeypatch.setattr(gateway, "_send_request", send_request)
assert await gateway.mark_auto_repaired("APR-1", "bounded", 10) is False
@pytest.mark.asyncio
@pytest.mark.parametrize("method_name", ["incident_update", "grouped_digest"])
async def test_lifecycle_no_send_does_not_write_dedup_receipt(
monkeypatch: pytest.MonkeyPatch,
method_name: str,
) -> None:
redis = FakeRedis({"tg_msg:INC-1": "42"})
gateway = TelegramGateway()
async def send_request(method: str, _payload: dict):
if method == "sendMessage":
return dict(NO_SEND_RECEIPT)
return {"ok": True}
monkeypatch.setattr(gateway_module, "get_redis", lambda: redis)
monkeypatch.setattr(gateway, "_send_request", send_request)
if method_name == "incident_update":
result = await gateway.append_incident_update("INC-1", "healthy")
else:
result = await gateway.append_grouped_alert_digest(
incident_id="INC-1",
group_key="group-1",
digest_text="digest",
)
assert result is False
assert redis.set_calls == []
@pytest.mark.asyncio
@pytest.mark.parametrize("method_name", ["incident_update", "grouped_digest"])
async def test_lifecycle_writes_dedup_only_after_affirmative_ack(
monkeypatch: pytest.MonkeyPatch,
method_name: str,
) -> None:
redis = FakeRedis({"tg_msg:INC-1": "42"})
gateway = TelegramGateway()
async def send_request(method: str, _payload: dict):
if method == "sendMessage":
return dict(SENT_RECEIPT)
return {"ok": True}
monkeypatch.setattr(gateway_module, "get_redis", lambda: redis)
monkeypatch.setattr(gateway, "_send_request", send_request)
if method_name == "incident_update":
result = await gateway.append_incident_update("INC-1", "healthy")
else:
result = await gateway.append_grouped_alert_digest(
incident_id="INC-1",
group_key="group-1",
digest_text="digest",
)
assert result is True
assert len(redis.set_calls) == 1
assert redis.set_calls[0][1] == "provider_ack"
@pytest.mark.asyncio
async def test_analyzing_placeholder_does_not_return_or_log_sent_on_no_send(
monkeypatch: pytest.MonkeyPatch,
) -> None:
logger = FakeLogger()
gateway = TelegramGateway()
monkeypatch.setattr(gateway_module.settings, "OPENCLAW_TG_BOT_TOKEN", "configured")
monkeypatch.setattr(gateway_module, "logger", logger)
monkeypatch.setattr(
gateway,
"_send_request",
AsyncMock(return_value=dict(NO_SEND_RECEIPT)),
)
result = await gateway.send_analyzing_placeholder("cpu", "api", "medium")
assert result is None
assert ("info", "analyzing_placeholder_no_send") in logger.events
assert ("info", "analyzing_placeholder_sent") not in logger.events
@pytest.mark.asyncio
async def test_approval_card_records_failed_and_never_sent_for_no_send(
monkeypatch: pytest.MonkeyPatch,
) -> None:
logger = FakeLogger()
gateway = TelegramGateway()
executed_params: list[dict] = []
class FakeDB:
async def execute(self, _statement, params: dict) -> None:
executed_params.append(params)
@asynccontextmanager
async def fake_db_context(*_args, **_kwargs):
yield FakeDB()
monkeypatch.setattr(gateway_module, "logger", logger)
monkeypatch.setattr(
gateway_module,
"_fetch_remediation_summary_for_card",
AsyncMock(return_value=None),
)
monkeypatch.setattr(gateway, "_build_inline_keyboard", AsyncMock(return_value={}))
monkeypatch.setattr(
gateway,
"_send_request",
AsyncMock(return_value=dict(NO_SEND_RECEIPT)),
)
monkeypatch.setattr("src.db.base.get_db_context", fake_db_context)
result = await gateway.send_approval_card(
approval_id="APR-1",
risk_level="medium",
resource_name="api",
root_cause="cpu",
suggested_action="inspect",
notification_type="TYPE-3",
)
assert result == NO_SEND_RECEIPT
assert executed_params and executed_params[0]["ds"] == "failed"
assert ("warning", "telegram_approval_card_no_send") in logger.events
assert ("info", "telegram_approval_card_sent") not in logger.events
@pytest.mark.asyncio
async def test_html_callback_no_send_exhausts_fallback_and_records_failure(
monkeypatch: pytest.MonkeyPatch,
) -> None:
gateway = TelegramGateway()
send_request = AsyncMock(return_value=dict(NO_SEND_RECEIPT))
record_failure = AsyncMock()
monkeypatch.setattr(gateway, "_send_request", send_request)
monkeypatch.setattr(gateway, "_record_callback_reply_failure", record_failure)
await gateway._send_html_line_message(
["<b>reply</b>"],
failure_context="test",
inbound_chat_id="room",
inbound_message_id=42,
incident_id="INC-1",
)
assert send_request.await_count == 3
record_failure.assert_awaited_once()
statuses = [
call.args[1].get(gateway_module._AWOOOP_SOURCE_ENVELOPE_EXTRA_KEY, {})
.get("callback_reply", {})
.get("status")
for call in send_request.await_args_list
]
assert statuses == [
"callback_reply_attempt",
"callback_reply_fallback_attempt",
"callback_reply_rescue_attempt",
]
@pytest.mark.asyncio
@pytest.mark.parametrize("reply_kind", ["advisory", "category"])
async def test_interactive_reply_no_send_logs_failure_not_sent(
monkeypatch: pytest.MonkeyPatch,
reply_kind: str,
) -> None:
from src.services import ai_advisory_helpers, callback_dispatcher
logger = FakeLogger()
gateway = TelegramGateway()
monkeypatch.setattr(gateway_module, "logger", logger)
monkeypatch.setattr(gateway, "_answer_callback", AsyncMock())
monkeypatch.setattr(
gateway,
"_send_request",
AsyncMock(return_value=dict(NO_SEND_RECEIPT)),
)
if reply_kind == "advisory":
monkeypatch.setattr(
ai_advisory_helpers,
"handle_ai_advisory_callback",
AsyncMock(
return_value={
"feedback_text": "ack",
"success": True,
}
),
)
await gateway._handle_ai_advisory_action(
"ai_advisory_view",
"cost:ADV-1",
"callback-1",
1,
"operator",
{"id": 1},
inbound_chat_id="room",
inbound_message_id=42,
)
failed_event = "ai_advisory_group_reply_failed"
sent_event = "ai_advisory_group_reply_sent"
else:
class Repo:
async def get_by_id(self, _incident_id: str):
return None
monkeypatch.setattr(
callback_dispatcher,
"get_action_spec",
lambda _action: SimpleNamespace(emoji="info"),
)
monkeypatch.setattr(
callback_dispatcher,
"dispatch_action",
AsyncMock(
return_value=SimpleNamespace(
result_text="result",
success=True,
duration_ms=1.0,
)
),
)
monkeypatch.setattr(
"src.repositories.incident_repository.get_incident_repository",
lambda: Repo(),
)
await gateway._dispatch_category_action(
"callback-1",
"history",
"INC-1",
1,
inbound_chat_id="room",
inbound_message_id=42,
)
failed_event = "category_action_reply_failed"
sent_event = "category_action_reply_sent"
assert ("warning", failed_event) in logger.events
assert ("info", sent_event) not in logger.events
@pytest.mark.asyncio
@pytest.mark.parametrize(
("notification_type", "alert_category"),
[("generic", ""), ("TYPE-4D", ""), ("TYPE-8M", "alertchain_health")],
)
async def test_background_no_send_never_confirms_or_writes_sent(
monkeypatch: pytest.MonkeyPatch,
notification_type: str,
alert_category: str,
) -> None:
calls: list[dict] = []
class Gateway:
async def send_approval_card(self, **kwargs):
calls.append(kwargs)
return dict(NO_SEND_RECEIPT)
async def send_drift_card(self, **kwargs):
calls.append(kwargs)
return dict(NO_SEND_RECEIPT)
async def send_meta_alert(self, **kwargs):
calls.append(kwargs)
return dict(NO_SEND_RECEIPT)
async def delete_message(self, *_args, **_kwargs):
raise AssertionError("placeholder must not be deleted before delivery")
class ApprovalService:
async def mark_telegram_confirmed(self, *_args, **_kwargs):
raise AssertionError("no-send must not be marked confirmed")
monkeypatch.setattr(webhooks.settings, "OPENCLAW_TG_BOT_TOKEN", "configured")
monkeypatch.setattr(webhooks, "get_telegram_gateway", lambda: Gateway())
monkeypatch.setattr(webhooks, "get_approval_service", lambda: ApprovalService())
await webhooks._push_to_telegram_background(
approval_id="APR-1",
risk_level="medium",
resource_name="api",
root_cause="cpu",
suggested_action="inspect",
estimated_downtime="0s",
notification_type=notification_type,
alert_category=alert_category,
fingerprint="fp",
placeholder_message_id=42,
)
assert len(calls) == 1
if notification_type == "generic":
assert calls[0]["notification_type"] == "TYPE-3"
@pytest.mark.asyncio
@pytest.mark.parametrize("module_name", ["sentry", "signoz"])
async def test_monitoring_webhooks_use_type3_and_do_not_false_log_sent(
monkeypatch: pytest.MonkeyPatch,
module_name: str,
) -> None:
logger = FakeLogger()
captured: list[dict] = []
class Gateway:
async def initialize(self) -> bool:
return True
async def send_approval_card(self, **kwargs):
captured.append(kwargs)
return dict(NO_SEND_RECEIPT)
if module_name == "sentry":
monkeypatch.setattr(sentry_webhook, "logger", logger)
monkeypatch.setattr(sentry_webhook, "get_telegram_gateway", lambda: Gateway())
await sentry_webhook.send_sentry_telegram_alert(
{"title": "error", "culprit": "api.py", "level": "error"},
None,
"APR-1",
)
sent_event = "sentry_telegram_sent"
no_send_event = "sentry_telegram_no_send"
else:
monkeypatch.setattr(signoz_webhook, "logger", logger)
monkeypatch.setattr(signoz_webhook, "get_telegram_gateway", lambda: Gateway())
await signoz_webhook.send_signoz_telegram(
"APR-1",
"INC-1",
"HighCPU",
{"service": "api"},
{"summary": "cpu"},
"warning",
)
sent_event = "signoz_telegram_sent"
no_send_event = "signoz_telegram_no_send"
assert captured[0]["notification_type"] == "TYPE-3"
assert ("warning", no_send_event) in logger.events
assert ("info", sent_event) not in logger.events
@pytest.mark.asyncio
async def test_dev_test_push_returns_false_for_structured_no_send(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: list[dict] = []
class Gateway:
async def send_approval_card(self, **kwargs):
captured.append(kwargs)
return dict(NO_SEND_RECEIPT)
monkeypatch.setattr(telegram.settings, "ENVIRONMENT", "dev")
monkeypatch.setattr(telegram, "get_telegram_gateway", lambda: Gateway())
response = await telegram.test_push(
telegram.TestPushRequest(approval_id="APR-1")
)
assert response["ok"] is False
assert response["message"] == "Test push no-send"
assert captured[0]["notification_type"] == "TYPE-3"