Files
awoooi/apps/api/tests/test_telegram_message_templates.py
Your Name 3f69e03fcb
All checks were successful
Code Review / ai-code-review (push) Successful in 11s
CD Pipeline / tests (push) Successful in 1m17s
CD Pipeline / build-and-deploy (push) Successful in 3m47s
CD Pipeline / post-deploy-checks (push) Successful in 1m57s
fix(telegram): clarify auto repair handoff cards
2026-05-07 02:07:43 +08:00

624 lines
20 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
"""
test_telegram_message_templates.py - Telegram 訊息模板測試
2026-03-29 ogt: 新增,驗證 6 種新訊息模板格式正確
符合 feedback_no_mock_testing.md 規範 - 測試實際格式化輸出
"""
import pytest
import src.services.telegram_gateway as telegram_gateway_module
from src.services.decision_manager import _format_auto_repair_status_line
from src.services.telegram_gateway import (
DailySummaryMessage,
DeploySuccessMessage,
RateLimitMessage,
RepairReportMessage,
ResourceWarnMessage,
SentryErrorMessage,
TelegramGateway,
TelegramMessage,
)
def test_auto_repair_status_line_distinguishes_handoff_required() -> None:
"""自動化失敗 reply 必須明確標示轉人工,且不把 raw error 當純文字噴出。"""
result = _format_auto_repair_status_line(
incident_id="INC-20260507-AAAAAA",
target="node-exporter-110",
action='ssh 192.168.0.110 "ps aux --sort=-%cpu | head -15"',
success=False,
error="Unsupported <scheme> & %d format: a real number is required, not str",
)
assert "HANDOFF REQUIREDAI 自動修復失敗,已轉人工" in result
assert "自動化已停止,不再重試" in result
assert "請 SRE 依 AwoooP Run / 原告警卡處理" in result
assert "&lt;scheme&gt; &amp; %d format" in result
assert "<scheme>" not in result
def test_auto_repair_status_line_distinguishes_auto_resolved() -> None:
"""自動化成功 reply 必須明確標示已自動解決。"""
result = _format_auto_repair_status_line(
incident_id="INC-20260507-BBBBBB",
target="awoooi-api",
action="kubectl rollout restart deployment/awoooi-api",
success=True,
metrics_delta_text="CPU 92%->30%",
)
assert "AUTO RESOLVEDAI 自動修復完成" in result
assert "自動化已完成,等待後驗證觀察" in result
assert "CPU 92%-&gt;30%" in result
class TestTelegramMessageFormat:
"""測試現有 TelegramMessage 格式化"""
def test_telegram_message_format_basic(self):
"""測試基本訊息格式化"""
msg = TelegramMessage(
status_emoji="🚨",
risk_level="CRITICAL",
resource_name="test-pod-123",
root_cause="Test root cause",
suggested_action="Restart pod",
estimated_downtime="~30s",
approval_id="INC-20260329-0001",
)
result = msg.format()
assert "🚨" in result
assert "嚴重" in result
assert "test-pod-123" in result
assert "處置狀態" in result
assert "規則建議待審批" in result
assert "AI 自動化鏈路" in result
assert "OpenClaw" in result
assert "NemoTron" in result
assert "ElephantAlpha" in result
assert len(result) <= 4096 # Telegram HTML message limit
def test_telegram_message_ai_proposal_marks_approval_wait(self):
"""有 AI 信心分數的修復建議必須標示為 AI 待審批。"""
msg = TelegramMessage(
status_emoji="⚠️",
risk_level="MEDIUM",
resource_name="awoooi-api",
root_cause="CPU sustained high",
suggested_action="kubectl rollout restart deployment/awoooi-api",
estimated_downtime="~30s",
approval_id="INC-20260506-0000",
confidence=0.82,
ai_provider="ollama_gcp_a",
)
result = msg.format()
assert "處置狀態" in result
assert "AI 已提出修復建議,等待人工批准" in result
def test_telegram_message_no_action_marks_manual_judgement(self):
"""NO_ACTION 卡片必須一眼看得出需要人工判斷。"""
msg = TelegramMessage(
status_emoji="",
risk_level="LOW",
resource_name="node-exporter-110",
root_cause="規則命中但沒有安全可執行動作",
suggested_action="NO_ACTION",
estimated_downtime="unknown",
approval_id="INC-20260506-0001",
)
result = msg.format()
assert "處置狀態" in result
assert "AI 無可安全執行動作,需人工判斷" in result
def test_telegram_message_diagnosis_state_is_not_auto_repair(self):
"""SSH 只讀診斷 lane 不得被顯示成自動修復。"""
msg = TelegramMessage(
status_emoji="⚠️",
risk_level="MEDIUM",
resource_name="node-110",
root_cause="SSH diagnosis collected",
suggested_action="ssh 192.168.0.110 'uptime'",
estimated_downtime="unknown",
approval_id="INC-20260506-DIAG",
automation_state="diagnosis_collected_manual_required",
)
result = msg.format()
assert "AI 已完成只讀診斷,需人工判斷" in result
assert "AI 自動修復失敗" not in result
def test_telegram_message_diagnosis_failure_state(self):
"""SSH 診斷工具失敗必須標成診斷失敗,而不是修復失敗。"""
msg = TelegramMessage(
status_emoji="🚨",
risk_level="CRITICAL",
resource_name="node-110",
root_cause="SSH MCP execution failed",
suggested_action="ssh 192.168.0.110 'uptime'",
estimated_downtime="unknown",
approval_id="INC-20260506-DIAGFAIL",
automation_state="diagnosis_failed_manual_required",
)
result = msg.format()
assert "AI 診斷工具失敗,需人工排查" in result
assert "AI 自動修復失敗" not in result
def test_telegram_message_with_token_cost(self):
"""測試含 Token/Cost 的訊息"""
msg = TelegramMessage(
status_emoji="⚠️",
risk_level="MEDIUM",
resource_name="api-pod",
root_cause="High CPU",
suggested_action="Scale up",
estimated_downtime="0s",
approval_id="INC-20260329-0002",
ai_tokens=1500,
ai_cost=0.0015,
)
result = msg.format()
assert "💰 Tokens: 1,500 / $0.0015" in result
@pytest.mark.asyncio
async def test_append_incident_update_deduplicates_same_status(monkeypatch):
"""同一 Incident 的相同狀態更新 5 分鐘內不可重複洗版。"""
class FakeRedis:
def __init__(self):
self.values = {}
async def get(self, key):
assert key == "tg_msg:INC-DEDUP"
return "12345"
async def set(self, key, value, **kwargs):
assert kwargs["nx"] is True
assert kwargs["ex"] > 0
if key in self.values:
return False
self.values[key] = value
return True
fake_redis = FakeRedis()
sent_requests = []
gateway = TelegramGateway()
async def fake_send_request(method, payload):
sent_requests.append((method, payload))
return {"ok": True}
monkeypatch.setattr(telegram_gateway_module, "get_redis", lambda: fake_redis)
monkeypatch.setattr(gateway, "_send_request", fake_send_request)
status_line = "🤖❌ <b>[AUTO] AI 自動修復失敗,已升級人工介入</b>"
assert await gateway.append_incident_update("INC-DEDUP", status_line) is True
assert await gateway.append_incident_update("INC-DEDUP", status_line) is True
assert [method for method, _ in sent_requests] == [
"editMessageReplyMarkup",
"sendMessage",
]
@pytest.mark.asyncio
async def test_append_incident_update_suppresses_duplicate_failure_across_incidents(monkeypatch):
"""不同 Incident 卡在相同失敗摘要時,只回覆第一則,避免 Telegram 洗版。"""
class FakeRedis:
def __init__(self):
self.values = {}
async def get(self, key):
if key == "tg_msg:INC-A":
return "111"
if key == "tg_msg:INC-B":
return "222"
return None
async def set(self, key, value, **kwargs):
assert kwargs["nx"] is True
assert kwargs["ex"] > 0
if key in self.values:
return False
self.values[key] = value
return True
fake_redis = FakeRedis()
sent_requests = []
gateway = TelegramGateway()
async def fake_send_request(method, payload):
sent_requests.append((method, payload))
return {"ok": True}
monkeypatch.setattr(telegram_gateway_module, "get_redis", lambda: fake_redis)
monkeypatch.setattr(gateway, "_send_request", fake_send_request)
status_line = (
"🤖❌ <b>[AUTO] AI 自動修復失敗,已升級人工介入</b>\n"
"├ 動作: <code>ssh 192.168.0.110 uptime</code>\n"
"└ 錯誤: unsupported action"
)
assert await gateway.append_incident_update("INC-A", status_line) is True
assert await gateway.append_incident_update("INC-B", status_line) is True
assert [method for method, _ in sent_requests] == [
"editMessageReplyMarkup",
"sendMessage",
"editMessageReplyMarkup",
]
def test_outbound_message_type_inference():
"""Legacy Telegram 訊息 mirror 到 Channel Hub 時,必須映射成有限分類。"""
assert (
telegram_gateway_module._infer_outbound_message_type(
" ACTION REQUIRED | 低風險",
{"reply_markup": {"inline_keyboard": []}},
)
== "approval_request"
)
assert (
telegram_gateway_module._infer_outbound_message_type(
"🤖❌ [AUTO] AI 自動修復失敗",
{"reply_to_message_id": 123},
)
== "error"
)
assert (
telegram_gateway_module._infer_outbound_message_type(
"✅ 執行成功",
{"reply_parameters": {"message_id": 123}},
)
== "final"
)
assert (
telegram_gateway_module._infer_outbound_message_type(
"📄 <b>RUNBOOK REVIEW待審核</b>",
{"reply_parameters": {"message_id": 123}},
)
== "approval_request"
)
def test_extract_incident_id_from_text():
"""Telegram 出站文字可擷取 Incident ID供後續訊息接回原告警卡片。"""
assert (
telegram_gateway_module._extract_incident_id_from_text(
"Incident: INC-20260506-E54736\nEntry ID: abc"
)
== "INC-20260506-E54736"
)
assert telegram_gateway_module._extract_incident_id_from_text("沒有事件編號") is None
@pytest.mark.asyncio
async def test_attach_incident_thread_reply(monkeypatch):
"""非主卡、含 Incident ID 的後續訊息,應自動 reply 原告警 message_id。"""
class FakeRedis:
async def get(self, key):
assert key == "tg_msg:INC-20260506-E54736"
return "9876"
gateway = TelegramGateway()
payload = {
"chat_id": gateway.alert_chat_id,
"text": "📄 RUNBOOK REVIEW待審核\nIncident: INC-20260506-E54736",
}
monkeypatch.setattr(telegram_gateway_module, "get_redis", lambda: FakeRedis())
await gateway._attach_incident_thread_reply("sendMessage", payload)
assert payload["reply_parameters"] == {
"message_id": 9876,
"allow_sending_without_reply": True,
}
@pytest.mark.asyncio
async def test_attach_incident_thread_skips_root_action_card(monkeypatch):
"""ACTION REQUIRED 主告警卡不應自動 reply 舊訊息。"""
class FakeRedis:
async def get(self, key): # pragma: no cover - 不應被呼叫
raise AssertionError(key)
gateway = TelegramGateway()
payload = {
"chat_id": gateway.alert_chat_id,
"text": (
" ACTION REQUIRED | 低風險\n"
"📋 INC-20260506-E54736\n"
"🤖 AI 自動化鏈路"
),
}
monkeypatch.setattr(telegram_gateway_module, "get_redis", lambda: FakeRedis())
await gateway._attach_incident_thread_reply("sendMessage", payload)
assert "reply_parameters" not in payload
def test_legacy_outbound_run_id_is_stable():
"""沒有正式 run_id 的 legacy Telegram 發送,要有穩定 soft run_id 供查詢串接。"""
first = telegram_gateway_module._legacy_outbound_run_id("-1001", "42")
second = telegram_gateway_module._legacy_outbound_run_id("-1001", "42")
other = telegram_gateway_module._legacy_outbound_run_id("-1001", "43")
assert first == second
assert first != other
class TestSentryErrorMessage:
"""測試 Sentry 錯誤訊息"""
def test_sentry_error_format_basic(self):
"""測試基本 Sentry 錯誤格式"""
msg = SentryErrorMessage(
error_id="SENTRY-abc123",
error_type="TypeError",
error_message="Cannot read property 'x' of undefined",
service_name="awoooi-api",
file_location="src/api/v1/incidents.py:123",
)
result = msg.format()
assert "🐛" in result
assert "SENTRY ERROR" in result
assert "TypeError" in result
assert "awoooi-api" in result
assert len(result) <= 900
def test_sentry_error_with_stack_trace(self):
"""測試含 Stack Trace 的 Sentry 錯誤"""
msg = SentryErrorMessage(
error_id="SENTRY-xyz789",
error_type="ValueError",
error_message="Invalid input",
service_name="awoooi-web",
file_location="src/components/App.tsx:45",
occurrence_count=15,
affected_users=3,
first_seen="10 分鐘前",
stack_trace=[
"incidents.py:123 in get_incident",
"service.py:45 in fetch_data",
"db.py:89 in query",
],
)
result = msg.format()
assert "發生次數: <code>15</code>" in result
assert "影響用戶: <code>3</code>" in result
assert "Stack Trace" in result
class TestResourceWarnMessage:
"""測試資源告警訊息"""
def test_resource_warn_format_basic(self):
"""測試基本資源告警格式"""
msg = ResourceWarnMessage(
resource_id="RES-20260329-0001",
pod_name="awoooi-api-7d4b8c9f5-abc12",
namespace="awoooi-prod",
cpu_percent=92.5,
memory_percent=78.0,
disk_percent=45.0,
)
result = msg.format()
assert "⚠️" in result
assert "資源告警" in result
assert "CPU: 🔴" in result # 92.5% > 90%
assert "Memory: 🟡" in result # 78% >= 70%
assert "Disk: 🟢" in result # 45% < 70%
assert len(result) <= 900
def test_resource_warn_with_limits(self):
"""測試含限制資訊的資源告警"""
msg = ResourceWarnMessage(
resource_id="RES-20260329-0002",
pod_name="test-pod",
cpu_percent=85.0,
cpu_limit="500m",
memory_percent=60.0,
memory_limit="512Mi",
)
result = msg.format()
assert "(limit: 500m)" in result
assert "(limit: 512Mi)" in result
class TestRepairReportMessage:
"""測試自動修復報告"""
def test_repair_report_format_basic(self):
"""測試基本修復報告格式"""
msg = RepairReportMessage(
report_date="2026-03-29",
total_repairs=12,
success_count=10,
failure_count=2,
saved_minutes=45,
)
result = msg.format()
assert "🔧" in result
assert "自動修復報告" in result
assert "總修復次數: <code>12</code>" in result
assert "成功: ✅ <code>10</code> (83%)" in result
assert len(result) <= 900
def test_repair_report_with_top_issues(self):
"""測試含 Top 問題的修復報告"""
msg = RepairReportMessage(
report_date="2026-03-29",
total_repairs=12,
success_count=10,
failure_count=2,
top_issues=[
("Pod CrashLoopBackOff", 5),
("OOM Killed", 4),
("Image Pull Failed", 3),
],
ai_cost_gemini=0.0234,
ai_tokens_total=1823,
)
result = msg.format()
assert "Top 3 問題" in result
assert "Pod CrashLoopBackOff" in result
assert "Gemini: $0.0234" in result
class TestDailySummaryMessage:
"""測試每日摘要"""
def test_daily_summary_format_basic(self):
"""測試基本每日摘要格式"""
msg = DailySummaryMessage(
summary_date="2026-03-29",
alert_total=45,
alert_critical=2,
alert_medium=18,
alert_low=25,
)
result = msg.format()
assert "📊" in result
assert "每日摘要" in result
assert "總數: <code>45</code>" in result
assert "Critical: <code>2</code>" in result
assert len(result) <= 900
def test_daily_summary_with_full_stats(self):
"""測試完整統計的每日摘要"""
msg = DailySummaryMessage(
summary_date="2026-03-29",
alert_total=45,
auto_repair_count=30,
manual_approval_count=10,
ignored_count=5,
api_availability=99.95,
ai_cost=0.15,
budget_remaining=9.85,
)
result = msg.format()
assert "自動修復: <code>30</code>" in result
assert "API: <code>99.95%</code>" in result
assert "預算剩餘: $9.85" in result
class TestDeploySuccessMessage:
"""測試部署成功訊息"""
def test_deploy_success_format_basic(self):
"""測試基本部署成功格式"""
msg = DeploySuccessMessage(
commit_sha="abc1234567",
triggered_by="ogt",
environment="Production",
)
result = msg.format()
assert "" in result
assert "部署成功" in result
assert "abc12345" in result # 前 8 字元
assert "@ogt" in result
assert len(result) <= 900
def test_deploy_success_with_e2e(self):
"""測試含 E2E 結果的部署成功"""
msg = DeploySuccessMessage(
commit_sha="abc1234567",
triggered_by="ogt",
api_version="v1.2.3",
web_version="v1.2.3",
duration_seconds=225, # 3m 45s
e2e_passed=26,
e2e_total=26,
health_check_passed=True,
)
result = msg.format()
assert "v1.2.3" in result
assert "3m 45s" in result
assert "✅ 26/26 PASSED" in result
class TestRateLimitMessage:
"""測試 API 限額警告"""
def test_rate_limit_format_basic(self):
"""測試基本限額警告格式"""
msg = RateLimitMessage(
provider="gemini",
daily_usage=450,
daily_limit=500,
token_usage=85000,
token_limit=100000,
cost_usd=0.08,
)
result = msg.format()
assert "⚠️" in result
assert "API 限額警告" in result
assert "GEMINI API" in result
assert "450/500" in result
assert "(90%)" in result
assert len(result) <= 900
def test_rate_limit_with_suggestions(self):
"""測試含建議的限額警告"""
msg = RateLimitMessage(
provider="openai",
daily_usage=90,
daily_limit=100,
suggestions=[
"考慮切換到 Ollama 優先",
"或增加每日限額",
],
reset_time="明日 00:00",
)
result = msg.format()
assert "建議" in result
assert "切換到 Ollama" in result
assert "明日 00:00" in result