""" 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, WeeklyReportMessage, format_aiops_signal_alert_card, format_host_resource_alert_card, normalize_telegram_send_message_payload, ) def test_auto_repair_status_line_distinguishes_ai_retry_queued() -> None: """自動化失敗 reply 必須明確標示 AI 續跑,且不把 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 & %d format: a real number is required, not str", ) assert "AI RETRY QUEUED|AI 自動修復失敗,已排入下一輪修復" in result assert "AI 已排入 PlayBook / transport / verifier 修復候選" in result assert "由 AI Agent 續跑" in result assert "<scheme> & %d format" in result assert "" 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 RESOLVED|AI 自動修復完成" in result assert "自動化已完成,等待後驗證觀察" in result assert "CPU 92%->30%" in result def test_host_resource_alert_card_becomes_ai_automation_event_packet() -> None: """Host resource raw dump 必須轉成 AI 自動化事件包,而不是直接洗版。""" raw_alert = """WARN h110-gitea 🔴 CPU 警告: used=42.9% load=8.04 WARN h110-gitea ⚠️ 容器內 root Node.js 進程: root 235 1.3 0.0 1092656 52564 ? Sl 05:55 0:00 npm run build:web root 247 1.7 0.0 1093752 54044 ? Sl 05:55 0:00 npm run build root 259 184 0.9 28062752 604824 ? Sl 05:55 0:33 node /workspace/wooo/stockplatform-v2/nodemodules/.bin/next build root 340 9.8 0.1 2586268 130016 ? Sl 05:56 0:01 node /workspace/wooo/stockplatform-v2/apps/web/.next/build/postcss.js 37591 root 364 181 0.7 3491396 494608 ? Rl 05:56 0:18 /opt/hostedtoolcache/node/20.20.2/x64/bin/node /workspace/wooo/stockplatform-v2/nodemodules/next/dist/compiled/jest-worker/processChild.js """ result = format_host_resource_alert_card(raw_alert) assert "P1 主機資源壓力|h110-gitea" in result assert "ai_automation_alert_card_v1" in result assert "一眼摘要" in result assert "CPU 使用率" in result assert "Load" in result assert "容器 root 進程" in result assert "AI 自動化判讀" in result assert "runner_build_resource_pressure" in result assert "controlled_playbook_queue" in result assert "runtime_write_gate=controlled" in result assert "Top evidence" in result assert "PID 259" in result assert "Next.js build" in result assert "PID 364" in result assert "Next.js build worker" in result assert "建議下一步" in result assert "禁止事項" in result assert "allowlisted PlayBook" in result assert "root 259" not in result assert "/workspace/wooo/" not in result assert "processChild.js" not in result def test_orphan_browser_alert_becomes_runaway_process_event_packet() -> None: """HostOrphanBrowserSmokeHighCpu 必須變成 runaway process 專屬事件包。""" raw_alert = ( 'alertname="HostOrphanBrowserSmokeHighCpu" host="110" ' 'rule="stockplatform_headless_smoke" ' "description=\"orphan Chrome smoke group detected\"" ) result = format_host_resource_alert_card(raw_alert) assert "P3 主機資源壓力|110" in result assert "ai_automation_alert_card_v1" in result assert "orphan_browser_smoke_runaway_process" in result assert "HostOrphanBrowserSmokeHighCpu" in result assert "stockplatform_headless_smoke" in result assert "host-runaway-process-remediation.py" in result assert "check-mode" in result assert "受控 SIGTERM" in result assert "KM / PlayBook / Verifier" in result assert "runtime_write_gate=controlled" in result assert "allowlisted PlayBook" in result assert "受控 apply" in result def test_ci_runner_load_alert_becomes_capacity_event_packet() -> None: """HostCiRunnerLoadSaturation 不可被誤導成可 kill 的 runaway process。""" raw_alert = ( 'alertname="HostCiRunnerLoadSaturation" host="110" ' "awoooi_host_gitea_actions_active_container_count 2" ) result = format_host_resource_alert_card(raw_alert) assert "P3 主機資源壓力|110" in result assert "ci_runner_load_saturation" in result assert "CI load evidence packet" in result assert "Gitea Actions run" in result assert "合法 CI" in result assert "不做 process remediation" in result assert "runtime_write_gate=controlled" in result assert "受控 cleanup" in result def test_wazuh_alert_becomes_aiops_signal_event_packet() -> None: """Wazuh raw alert 不可把內網 IP、完整路徑或 token 直接送進 Telegram。""" raw_alert = """ Wazuh alert fired rule.level=12 agent.name="web-110" full_log=/var/ossec/logs/alerts/alerts.json srcip=192.168.0.110 Authorization: Bearer abcdefghijklmnopqrstuvwxyz token=super-secret syscheck integrity changed on /etc/nginx/sites-enabled/awoooi.conf """ result = format_aiops_signal_alert_card(raw_alert) assert "P1 AIOps 訊號|Wazuh 入侵訊號" in result assert "ai_automation_alert_card_v1" in result assert "wazuh_intrusion_signal" in result assert "security_intrusion_triage" in result assert "controlled_playbook_queue" in result assert "runtime_write_gate=controlled" in result assert "Top evidence" in result assert "Timeline / KM / PlayBook" in result assert "不封鎖 IP" in result assert "192.168.0.110" not in result assert "/var/ossec" not in result assert "/etc/nginx" not in result assert "abcdefghijkl" not in result assert "super-secret" not in result def test_wazuh_dashboard_api_degraded_alert_becomes_readback_gap_event_packet() -> None: """Wazuh Dashboard/API mismatch 必須轉成讀回退化事件卡,不可誤報成已修復。""" raw_alert = """ wazuh_dashboard_api_readback_degraded dashboard agent list disappeared POST /api/check-stored-api status=429 POST /api/check-api status=500 https://127.0.0.1:55000 is unreachable manager registry readback blocked full_log=/var/ossec/logs/alerts/alerts.json Authorization: Bearer abcdefghijklmnopqrstuvwxyz """ result = format_aiops_signal_alert_card(raw_alert) assert "P1 AIOps 訊號|Wazuh Dashboard / API 讀回退化" in result assert "ai_automation_alert_card_v1" in result assert "wazuh_dashboard_api_readback_degraded" in result assert "siem_observability_readback_degraded" in result assert "controlled_playbook_queue" in result assert "runtime_write_gate=controlled" in result assert "manager registry 只讀計數" in result assert "no-false-green" in result assert "不重啟 Wazuh" in result assert "不重新註冊 agent" in result assert "127.0.0.1:55000" not in result assert "/var/ossec" not in result assert "abcdefghijkl" not in result def test_nginx_drift_alert_becomes_public_gateway_event_packet() -> None: """Nginx drift 應進 Public Gateway gate,而不是直接要求 reload。""" raw_alert = """ alertname="nginx_config_drift" target="188-gateway" nginx -t failed for /etc/nginx/sites-enabled/awoooi.conf diff_url=https://internal.example.invalid/raw/live.conf """ result = format_aiops_signal_alert_card(raw_alert) assert "AIOps 訊號|Nginx 配置漂移" in result assert "nginx_config_drift" in result assert "public_gateway_config_drift" in result assert "redacted live export" in result assert "runtime_write_gate=controlled" in result assert "不執行 nginx -t" in result assert "不 reload" in result assert "/etc/nginx" not in result assert "internal.example.invalid" not in result def test_postgresql_slow_query_alert_prioritizes_database_lane_over_backup_tokens() -> None: """PostgreSQL 慢查詢不可因同訊息含 backup/escrow 字樣而誤進 DR lane。""" raw_alert = """ P0 escalation target=postgres namespace=default alertname="PostgreSQLSlowQueries" backup restore escrow evidence required before any write candidate pg_stat_activity slow query lock waiting latency high """ result = format_aiops_signal_alert_card(raw_alert) assert "P1 AIOps 訊號|PostgreSQL 效能訊號" in result assert "database_performance_signal" in result assert "database_slow_query_triage" in result assert "pg_stat_activity" in result assert "不終止 SQL" in result assert "不重啟 PostgreSQL" in result assert "backup_restore_escrow_signal" not in result assert "backup_restore_escrow_triage" not in result @pytest.mark.parametrize( ("raw_alert", "event_type", "lane"), [ ( 'alertname="BackupAggregateRunFailed" host="110" escrow_missing=5 restic failed', "backup_restore_escrow_signal", "backup_restore_escrow_triage", ), ( 'alertname="SourceProviderIngestionStale" service="sentry" freshness window stale', "provider_freshness_signal", "source_provider_freshness_triage", ), ( "dependency_supply_chain_drift package=next CVE-2026-0001 npm audit vulnerability", "supply_chain_drift", "supply_chain_drift_review", ), ( "kali_assessment_signal target=public-gateway nmap finding high risk", "kali_assessment_signal", "security_assessment_review", ), ], ) def test_aiops_signal_formatter_covers_non_host_alert_lanes( raw_alert: str, event_type: str, lane: str, ) -> None: """非 host 類告警也必須進 AI lane / Gate / Verifier 語義。""" result = format_aiops_signal_alert_card(raw_alert) assert "ai_automation_alert_card_v1" in result assert event_type in result assert lane in result assert "controlled_playbook_queue" in result assert "runtime_write_gate=controlled" in result assert "Top evidence" in result assert "禁止事項" in result @pytest.mark.asyncio async def test_send_alert_notification_normalizes_host_resource_raw_dump(monkeypatch) -> None: """send_alert_notification 是最後出口,必須自動套用 AI 自動化事件包。""" sent_requests = [] gateway = TelegramGateway() async def fake_send_request(method, payload): sent_requests.append((method, payload)) return {"ok": True} monkeypatch.setattr(TelegramGateway, "alert_chat_id", property(lambda _self: "chat")) monkeypatch.setattr(gateway, "_send_request", fake_send_request) await gateway.send_alert_notification( text=( "WARN h110-gitea 🔴 CPU 警告: used=42.9% load=8.04\n" "root 259 184 0.9 28062752 604824 ? Sl 05:55 0:33 " "node /workspace/wooo/stockplatform-v2/nodemodules/.bin/next build" ), parse_mode="HTML", ) assert sent_requests payload = sent_requests[0][1] assert payload["chat_id"] == "chat" assert payload["parse_mode"] == "HTML" assert "ai_automation_alert_card_v1" in payload["text"] assert "AI 自動化判讀" in payload["text"] assert "root 259" not in payload["text"] assert "/workspace/wooo/" not in payload["text"] @pytest.mark.asyncio async def test_send_alert_notification_normalizes_aiops_signal_alert(monkeypatch) -> None: """send_alert_notification 也必須把 Nginx / Wazuh 類訊號轉成 AI 事件包。""" sent_requests = [] gateway = TelegramGateway() async def fake_send_request(method, payload): sent_requests.append((method, payload)) return {"ok": True} monkeypatch.setattr(TelegramGateway, "alert_chat_id", property(lambda _self: "chat")) monkeypatch.setattr(gateway, "_send_request", fake_send_request) await gateway.send_alert_notification( text=( 'alertname="nginx_config_drift" target="188-gateway" ' "nginx -t failed for /etc/nginx/sites-enabled/awoooi.conf" ), parse_mode="MarkdownV2", ) payload = sent_requests[0][1] assert payload["parse_mode"] == "HTML" assert "ai_automation_alert_card_v1" in payload["text"] assert "public_gateway_config_drift" in payload["text"] assert "runtime_write_gate=controlled" in payload["text"] assert "/etc/nginx" not in payload["text"] def test_prisma_generate_alert_redacts_raw_process_json_and_urls() -> None: """Prisma generate 類 root Node.js 告警不得把路徑、URL 或 JSON 直接送出。""" raw_alert = """WARN h110-gitea 🔴 CPU 警告: used=29.8% load=8.62 WARN h110-gitea ⚠️ 容器內 root Node.js 進程: root 365 27.5 0.1 1283324 108564 ? Sl 06:27 0:00 node /opt/hostedtoolcache/node/20.20.2/x64/bin/pnpm prisma generate root 376 15.5 0.3 11756860 217220 ? Rl 06:27 0:03 node ./node_modules/.bin/../prisma/build/index.js generate root 392 0.0 0.0 1096836 53400 ? Ssl 06:27 0:00 /opt/hostedtoolcache/node/20.20.2/x64/bin/node /workspace/wooo/vibework/node_modules/.pnpm/prisma@7.8.0_types+react@18.3.30_react@18.3.30/node_modules/prisma/build/index.js {"product":"prisma","version":"7.8.0","endpoint":"https://checkpoint.prisma.io","command":"generate"} """ result = format_host_resource_alert_card(raw_alert) assert "P1 主機資源壓力|h110-gitea" in result assert "runner_prisma_generate_resource_pressure" in result assert "Prisma generate" in result assert "容器 root 進程:3" in result assert "套件來源" in result assert "runtime_write_gate=controlled" in result assert "root 365" not in result assert "checkpoint.prisma.io" not in result assert "node_modules" not in result assert "/opt/hostedtoolcache" not in result assert "/workspace/wooo" not in result assert '"product":"prisma"' not in result @pytest.mark.asyncio async def test_send_alert_notification_forces_html_card_for_markdown_host_alert(monkeypatch) -> None: """即使呼叫端用 Markdown,host raw dump 仍必須被最後出口改成 HTML 卡。""" sent_requests = [] gateway = TelegramGateway() async def fake_send_request(method, payload): sent_requests.append((method, payload)) return {"ok": True} monkeypatch.setattr(TelegramGateway, "alert_chat_id", property(lambda _self: "chat")) monkeypatch.setattr(gateway, "_send_request", fake_send_request) await gateway.send_alert_notification( text=( "WARN h110-gitea 🔴 CPU 警告: used=29.8% load=8.62\n" "root 365 27.5 0.1 1283324 108564 ? Sl 06:27 0:00 " "node /opt/hostedtoolcache/node/20.20.2/x64/bin/pnpm prisma generate" ), parse_mode="MarkdownV2", ) payload = sent_requests[0][1] assert payload["parse_mode"] == "HTML" assert "ai_automation_alert_card_v1" in payload["text"] assert "runner_prisma_generate_resource_pressure" in payload["text"] assert "/opt/hostedtoolcache" not in payload["text"] @pytest.mark.asyncio async def test_send_text_normalizes_host_resource_alert(monkeypatch) -> None: """send_text 旁路也不能把 host resource raw dump 直接送出。""" sent_requests = [] gateway = TelegramGateway() async def fake_send_request(method, payload): sent_requests.append((method, payload)) return {"ok": True} monkeypatch.setattr(TelegramGateway, "alert_chat_id", property(lambda _self: "chat")) monkeypatch.setattr(gateway, "_send_request", fake_send_request) await gateway.send_text( text=( "WARN h110-gitea 🔴 CPU 警告: used=29.8% load=8.62\n" "root 365 27.5 0.1 1283324 108564 ? Sl 06:27 0:00 " "node /workspace/wooo/vibework/node_modules/.bin/prisma generate" ), ) payload = sent_requests[0][1] assert payload["parse_mode"] == "HTML" assert "P1 主機資源壓力" in payload["text"] assert "node_modules" not in payload["text"] assert "/workspace/wooo" not in payload["text"] def test_send_request_payload_normalizer_blocks_direct_host_raw_dump() -> None: """direct _send_request("sendMessage") 也必須在最後出口被轉成事件卡。""" raw_alert = ( "WARN h110-gitea 🔴 CPU 警告: used=80.4% load=19.10\n" "WARN h110-gitea ⚠️ 容器內 root Node.js 進程:\n" "root 311 185 0.9 29242688 606596 ? Sl 07:11 0:33 " "node /workspace/wooo/stockplatform-v2/nodemodules/.bin/next build\n" "root 830 0.0 0.0 1126624 57588 ? Rsl 07:14 0:00 " '/opt/hostedtoolcache/node/22.22.3/x64/bin/node /workspace/wooo/2026FIFAWorldCup/platform/web/nodemodules/prisma/build/child ' '{"product":"prisma","version":"5.20.0","endpoint":"https://checkpoint.prisma.io"}' ) payload = { "chat_id": "chat", "text": raw_alert, "parse_mode": "MarkdownV2", "disable_web_page_preview": True, } result = normalize_telegram_send_message_payload("sendMessage", payload) assert result["parse_mode"] == "HTML" assert "P1 主機資源壓力|h110-gitea" in result["text"] assert "ai_automation_alert_card_v1" in result["text"] assert "runtime_write_gate=controlled" in result["text"] assert "root 311" not in result["text"] assert "checkpoint.prisma.io" not in result["text"] assert "/workspace/wooo" not in result["text"] assert "/opt/hostedtoolcache" not in result["text"] assert '"product":"prisma"' not in result["text"] def test_weekly_report_marks_all_zero_as_low_trust_anomaly() -> None: report = WeeklyReportMessage( week_range="2026-W24", report_date="2026-06-12 10:00", stats_source_ok=False, k3s_source_ok=True, git_source_ok=False, cost_source_ok=False, all_zero_actionable_anomaly=True, ) body = report.format() assert "報表資料信任度" in body assert "判定: 低可信" in body assert "統計=失效" in body assert "Git=失效" in body assert "成本=缺資料" in body assert "全 0: actionable_anomaly" in body assert "總數: 缺資料" in body assert "Commits: 缺資料" in body assert "Tokens: 缺資料" in body assert "資料缺口 / 下一步" in body assert "全 0 不是健康" in body assert "report-source-gap:stats_api" in body assert "report-source-gap:gitea_activity" in body assert "告警統計" in body def test_weekly_report_keeps_nonzero_source_status_visible() -> None: report = WeeklyReportMessage( week_range="2026-W24", report_date="2026-06-12 10:00", alert_total=3, ai_proposal_count=2, commits_count=5, deploy_count=1, ai_tokens_week=1200, stats_source_ok=True, k3s_source_ok=True, git_source_ok=True, cost_source_ok=True, ) body = report.format() assert "判定: 可參考" in body assert "全 0: no" in body assert "資料源通過" in body assert "Commits: 5" in body assert "Tokens: 1,200" in body def test_weekly_report_includes_ai_slo_truth_when_available() -> None: report = WeeklyReportMessage( week_range="2026-W26", report_date="2026-06-27 15:40", alert_total=3, ai_proposal_count=4, ai_executed_count=2, ai_success_rate=50.0, commits_count=6, deploy_count=2, ai_tokens_week=1200, stats_source_ok=True, k3s_source_ok=True, git_source_ok=True, cost_source_ok=True, ai_slo_source_ok=True, ai_slo_auto_execute_success_rate=0.5, ai_slo_auto_execute_sample_count=14, ai_slo_auto_execute_threshold=0.85, ai_slo_auto_execute_violated=True, ai_slo_top_failure=( "DockerContainerMissingResourceLimit / ansible:188-ai-web ×5: " "role host-textfile-exporters not found" ), ai_slo_verifier_coverage_rate=0.857, ai_slo_unverified_auto_count=2, ) body = report.format() assert "AI 自動化 SLO" in body assert "自動執行成功率: 50.0%" in body assert "目標 85%" in body assert "樣本: 14" in body assert "Verifier 覆蓋: 85.7%" in body assert "未驗證自動執行: 2" in body assert "DockerContainerMissingResourceLimit / ansible:188-ai-web" in body def test_weekly_report_includes_report_source_health_assets() -> None: report = WeeklyReportMessage( week_range="2026-W25", report_date="2026-06-18 19:40", alert_total=4, ai_proposal_count=1, commits_count=2, deploy_count=1, stats_source_ok=True, k3s_source_ok=True, git_source_ok=True, cost_source_ok=False, report_source_confidence_percent=40, report_source_ok_count=2, report_source_total_count=5, report_source_gap_ids=[ "report-source-gap:incident_summary", "report-source-gap:resolution_stats", "report-source-gap:ai_performance", ], report_asset_state_lines=[ "KM: draft_ready 3/6", "PlayBook: draft_required 0/3", "腳本: readback_only 1/1", "排程: no_send_preview 3/3", "Verifier: source_health_ready 1/4", ], ) body = report.format() assert "報表資料源 / 沉澱" in body assert "來源: 2/5" in body assert "信心: 40%" in body assert "report-source-gap:incident_summary" in body assert "report-source-gap:resolution_stats" in body assert "report-source-gap:ai_performance" in body assert "KM: draft_ready 3/6" in body assert "PlayBook: draft_required 0/3" in body assert "Verifier: source_health_ready 1/4" in body assert "不自動改排程" in body def test_telegram_html_chunks_preserve_complete_lines() -> None: """歷史/詳情長訊息不得用 text[:500] 切壞 HTML tag。""" lines = [ "📊 事件歷史統計", "", "INC-20260513-79ED5E", "🧭 DB Truth-chain", "階段: blocked / manual_required", ] * 90 chunks = telegram_gateway_module._telegram_html_chunks(lines, limit=900) assert len(chunks) > 1 assert all(len(chunk) <= 900 for chunk in chunks) assert all(chunk.count("") == chunk.count("") for chunk in chunks) assert all(chunk.count("") == chunk.count("") for chunk in chunks) def test_telegram_html_chunks_render_single_overlong_html_line_as_safe_text() -> None: """單行過長時不得切出未閉合 ,否則 Telegram 會 400。""" line = "告警鍵: " + ("node<188>&" * 80) + "" chunks = telegram_gateway_module._telegram_html_chunks([line], limit=120) assert len(chunks) == 1 assert len(chunks[0]) <= 120 assert "" not in chunks[0] assert "" not in chunks[0] assert "告警鍵" in chunks[0] assert "<" in chunks[0] def test_awooop_status_chain_lines_show_verified_auto_repair_stage() -> None: """詳情/歷史要直接說清楚是否已 AI 自動修復、驗證到哪裡。""" lines = telegram_gateway_module._format_awooop_status_chain_lines( truth_chain={ "truth_status": { "current_stage": "execution_succeeded", "stage_status": "success", "needs_human": False, "blockers": [], }, "automation_quality": { "verdict": "auto_repaired_verified", "facts": { "auto_repair_execution_records": 1, "automation_operation_records": 1, "verification_result": "healthy", "mcp_gateway_total": 3, "knowledge_entries": 2, }, "blockers": [], }, }, remediation_history={ "total": 1, "items": [ { "agent_id": "auto_repair_executor", "tool_name": "rollout_restart", "required_scope": "write", "verification_result_preview": "healthy", "writes_incident_state": True, "writes_auto_repair_result": True, } ], }, ) joined = "\n".join(lines) assert "AwoooP 狀態鏈" in joined assert "auto_repaired_verified" in joined assert "驗證: healthy" in joined assert "auto-repair 1" in joined assert "auto_repair_executor/rollout_restart/write" in joined assert "人工: no" in joined assert "monitor_for_regression" in joined assert "處置結論" in joined assert "已驗證自動修復完成" in joined assert "自動化資產沉澱" in joined assert "KM:2" in joined assert "Verifier:ready" in joined def test_awooop_status_chain_lines_show_read_only_manual_gate() -> None: """只讀試跑不能被說成已自動修復,必須顯示等待審批/人工。""" lines = telegram_gateway_module._format_awooop_status_chain_lines( truth_chain={ "truth_status": { "current_stage": "approval_required", "stage_status": "waiting", "needs_human": True, "blockers": ["pending_human_approval"], }, "automation_quality": { "verdict": "approval_required", "facts": { "auto_repair_execution_records": 0, "automation_operation_records": 0, "verification_result": "missing", "mcp_gateway_total": 1, "knowledge_entries": 0, }, "blockers": [], }, }, remediation_history={ "total": 2, "items": [ { "agent_id": "investigator", "tool_name": "ssh_diagnose", "required_scope": "read", "verification_result_preview": "degraded", "writes_incident_state": False, "writes_auto_repair_result": False, } ], }, ) joined = "\n".join(lines) assert "read_only_dry_run" in joined assert "investigator/ssh_diagnose/read" in joined assert "人工: no" in joined assert "evaluate_controlled_apply_from_read_only_evidence" in joined assert "pending_human_approval" in joined assert "自動化資產沉澱" in joined assert "PlayBook:--" in joined def test_awooop_status_chain_lines_do_not_treat_audit_ops_as_repair() -> None: lines = telegram_gateway_module._format_awooop_status_chain_lines( truth_chain={ "truth_status": { "current_stage": "manual_required", "stage_status": "blocked", "needs_human": True, "blockers": ["approval_resolved_no_action_without_execution"], }, "automation_quality": { "verdict": "auto_repaired_verification_degraded", "facts": { "auto_repair_execution_records": 0, "automation_operation_records": 1, "effective_execution_records": 0, "verification_result": "degraded", "mcp_gateway_total": 2, "knowledge_entries": 1, }, "blockers": ["verification_recorded"], }, }, remediation_history={"total": 0}, ) joined = "\n".join(lines) assert "diagnostic_or_audit_recorded" in joined assert "auto_generate_repair_candidate_from_diagnostic_evidence" in joined assert "executed_pending_verification" not in joined assert "處置結論" in joined assert "只完成診斷/觀察;AI 已排入 PlayBook / transport / verifier 修復候選" in joined assert "通知:telegram_sre_war_room,awooop_operator_console" in joined def test_awooop_status_chain_lines_mark_ansible_check_mode_as_dry_run_only() -> None: lines = telegram_gateway_module._format_awooop_status_chain_lines( truth_chain={ "truth_status": { "current_stage": "execution_succeeded", "stage_status": "success", "needs_human": True, "blockers": ["incident_open_after_successful_execution"], }, "automation_quality": { "verdict": "execution_unverified", "facts": { "auto_repair_execution_records": 0, "automation_operation_records": 2, "effective_execution_records": 1, "verification_result": None, "mcp_gateway_total": 8, "knowledge_entries": 0, }, "blockers": ["verification_recorded"], "operator_outcome": { "state": "execution_unverified_manual_required", "needs_human": True, "next_action": "run_or_review_post_execution_verification", }, }, "execution": { "automation_operation_log": [ { "operation_type": "ansible_check_mode_executed", "status": "success", "actor": "ansible_check_mode_worker", "input_executor": "ansible", "input_catalog_id": "ansible:188-ai-web", } ], "ansible": { "considered": True, "records": [ { "operation_type": "ansible_check_mode_executed", "status": "success", "actor": "ansible_check_mode_worker", "catalog_id": "ansible:188-ai-web", "playbook_path": "infra/ansible/playbooks/188-ai-web-readonly.yml", "execution_mode": "check_mode", "check_mode": True, "apply_executed": False, "returncode": 0, } ], "candidate_catalog": {"candidates": []}, }, }, }, remediation_history={"total": 0}, ) joined = "\n".join(lines) assert "ansible_check_mode_only" in joined assert "wait_for_controlled_apply_and_post_apply_verifier" in joined assert "controlled_apply_queued" in joined assert "dry_run_passed_controlled_apply_queued" in joined assert "AI 已完成 Ansible check-mode,符合受控自動 apply 條件" in joined assert "executed_pending_verification" not in joined assert "run_or_review_post_execution_verification" not in joined def test_awooop_agent_evidence_lines_show_mcp_source_execution_playbook_km() -> None: """Telegram 詳情/歷史要像前端一樣顯示五段 AI Agent 證據鏈。""" lines = telegram_gateway_module._format_awooop_agent_evidence_lines( truth_chain={ "automation_quality": { "verdict": "approval_required", "facts": { "auto_repair_execution_records": 0, "automation_operation_records": 1, "verification_result": "degraded", "mcp_gateway_total": 2, "knowledge_entries": 1, }, }, "mcp": { "awooop_gateway": { "total": 2, "success": 1, "failed": 1, "blocked": 0, "first_class_total": 2, "legacy_bridge_total": 0, "policy_enforced_total": 2, "by_tool": [ { "tool_name": "prometheus.query", "total": 2, "success": 1, "failed": 1, "blocked": 0, } ], }, "legacy": { "total": 1, "success": 1, "failed": 0, }, }, "execution": { "automation_operation_log": [ { "operation_type": "ansible_candidate_matched", "status": "dry_run", "actor": "Hermes", "input_executor": "ansible", "input_action": "check_mode", } ], "ansible": { "considered": True, "records": [ { "operation_type": "ansible_candidate_matched", "status": "dry_run", "playbook_path": "infra/ansible/playbooks/188-ai-web.yml", "check_mode": True, } ], "candidate_catalog": { "candidates": [ { "catalog_id": "ansible:188-ai-web", "playbook_path": "infra/ansible/playbooks/188-ai-web.yml", "risk_level": "medium", "match_score": 3, } ] }, }, }, }, remediation_history={ "total": 1, "items": [ { "verification_result_preview": "degraded", "writes_incident_state": False, "writes_auto_repair_result": False, } ], }, source_correlation={ "status": "candidate_found", "verification_status": "candidate_only", "direct_ref_total": 0, "candidate_total": 1, "applied_link_total": 0, "providers": { "sentry": { "direct_ref_total": 0, "candidate_total": 1, "applied_link_total": 0, }, "signoz": { "direct_ref_total": 0, "candidate_total": 0, "applied_link_total": 0, }, }, }, ) joined = "\n".join(lines) assert "AI Agent 證據鏈" in joined assert "MCP / 自建 MCP" in joined assert "Gateway 1/2" in joined assert "prometheus.query" in joined assert "Sentry/SigNoz" in joined assert "candidate 1" in joined assert "sentry 0/1/0" in joined assert "Executor: ansible/dry_run" in joined assert "op ansible_candidate_matched" in joined assert "PlayBook / Ansible" in joined assert "infra/ansible/playbooks/188-ai-web.yml" in joined assert "ansible yes" in joined assert "check 0" in joined assert "apply 0" in joined assert "rc --" in joined assert "KM / Learning" in joined assert "KM 1" in joined assert "verify degraded" in joined def test_awooop_status_chain_lines_show_ansible_apply_proof() -> None: """詳情/歷史必須能看出 Ansible 是否真的 apply、由誰批准、returncode 幾。""" truth_chain = { "truth_status": { "current_stage": "execution_succeeded", "stage_status": "success", "needs_human": False, "blockers": [], }, "automation_quality": { "verdict": "execution_succeeded", "facts": { "auto_repair_execution_records": 0, "automation_operation_records": 2, "effective_execution_records": 1, "verification_result": "healthy", "mcp_gateway_total": 2, "knowledge_entries": 1, }, "blockers": [], }, "execution": { "automation_operation_log": [ { "operation_type": "ansible_apply_executed", "status": "success", "actor": "platform_operator", "input_executor": "ansible", "input_action": "apply_playbook", "input_catalog_id": "ansible:188-momo-backup-user", "input_playbook_path": "infra/ansible/playbooks/188-momo-backup-user.yml", } ], "ansible": { "considered": True, "records": [ { "operation_type": "ansible_apply_executed", "status": "success", "actor": "platform_operator", "catalog_id": "ansible:188-momo-backup-user", "playbook_path": "infra/ansible/playbooks/188-momo-backup-user.yml", "execution_mode": "apply", "check_mode": False, "apply_executed": True, "approval_source": "user_chat_approved_continue", "returncode": 0, }, { "operation_type": "ansible_check_mode_executed", "status": "success", "actor": "ansible_check_mode_worker", "catalog_id": "ansible:188-momo-backup-user", "playbook_path": "infra/ansible/playbooks/188-momo-backup-user.yml", "execution_mode": "check_mode", "check_mode": True, "apply_executed": False, "returncode": 0, }, ], "candidate_catalog": {"candidates": []}, }, }, } lines = telegram_gateway_module._format_awooop_status_chain_lines( truth_chain=truth_chain, remediation_history={"total": 0}, ) joined = "\n".join(lines) assert "Ansible: check 1 / apply 1" in joined assert "ansible_apply_executed/success" in joined assert "rc 0" in joined assert "PlayBook: ansible:188-momo-backup-user" in joined assert "approval user_chat_approved_continue" in joined snapshot = telegram_gateway_module._callback_reply_awooop_execution_snapshot(truth_chain) ansible = snapshot["ansible"] assert ansible["check_mode_total"] == 1 assert ansible["apply_total"] == 1 assert ansible["applied"] is True assert ansible["controlled_apply"] is True assert ansible["latest_catalog_id"] == "ansible:188-momo-backup-user" assert ansible["latest_execution_mode"] == "apply" assert ansible["latest_returncode"] == 0 assert ansible["approval_source"] == "user_chat_approved_continue" @pytest.mark.asyncio async def test_controlled_apply_result_receipt_marks_callback_reply_evidence(monkeypatch) -> None: gateway = TelegramGateway() sent_requests = [] async def fake_send_request(method, payload): sent_requests.append((method, payload)) return {"ok": True, "result": {"message_id": 12345}} async def fake_fetch_truth_chain(source_id, project_id): assert source_id == "INC-20260627-64472B" assert project_id == "awoooi" return { "truth_status": { "current_stage": "execution_succeeded", "stage_status": "success", "needs_human": False, "blockers": [], }, "automation_quality": { "verdict": "auto_repaired_verified", "facts": { "auto_repair_execution_records": 1, "automation_operation_records": 3, "effective_execution_records": 2, "verification_result": "success", "mcp_gateway_total": 8, "knowledge_entries": 1, }, "blockers": [], }, "execution": { "automation_operation_log": [ { "operation_type": "ansible_apply_executed", "status": "success", "actor": "ansible_controlled_apply_worker", "input_catalog_id": "ansible:188-momo-backup-user", "input_execution_mode": "controlled_apply", "input_playbook_path": "infra/ansible/playbooks/188-momo-backup-user.yml", } ], "ansible": { "considered": True, "records": [ { "operation_type": "ansible_apply_executed", "status": "success", "actor": "ansible_controlled_apply_worker", "catalog_id": "ansible:188-momo-backup-user", "playbook_path": "infra/ansible/playbooks/188-momo-backup-user.yml", "execution_mode": "controlled_apply", "apply_executed": True, "returncode": 0, "tags": ["ansible", "controlled_apply", "ai_agent_auto_execution"], } ], "candidate_catalog": {"candidates": []}, }, }, } async def fake_fetch_km_completion_summary(*, incident_id, project_id): assert incident_id == "INC-20260627-64472B" assert project_id == "awoooi" return { "schema_version": "km_stale_owner_review_completion_telegram_summary_v1", "status": "no_related_owner_review", "project_id": "awoooi", "incident_id": "INC-20260627-64472B", "missing_reason": "no_matching_completion_item", "ready_count": 10, "blocked_count": 0, "completed_count": 1, "failed_count": 0, "related_total": 0, "writes_on_read": False, "batch_writes_allowed": False, "manual_review_required": True, "triage": { "schema_version": "km_stale_callback_owner_review_triage_v1", "flow_stage": "callback_observed_owner_review_link_missing", "ai_lead_agent": "Hermes", "supporting_agents": ["OpenClaw"], "automation_state": "manual_owner_review_required", "safe_to_auto_repair": False, "blocking_reason": "no_matching_completion_item", "matching_strategy": "related_incident_id_exact_match", }, } monkeypatch.setattr(TelegramGateway, "alert_chat_id", property(lambda _self: "chat")) monkeypatch.setattr(gateway, "_send_request", fake_send_request) monkeypatch.setattr( "src.services.awooop_truth_chain_service.fetch_truth_chain", fake_fetch_truth_chain, ) monkeypatch.setattr( "src.services.telegram_gateway._fetch_km_stale_completion_summary_for_incident", fake_fetch_km_completion_summary, ) result = await gateway.send_controlled_apply_result_receipt( incident_id="INC-20260627-64472B", catalog_id="ansible:188-momo-backup-user", apply_op_id="73b7a95c-3652-4c0d-bb4c-729e500acedb", playbook_path="infra/ansible/playbooks/188-momo-backup-user.yml", verification_result="success", returncode=0, duration_ms=7727, verifier_written=True, learning_written=True, project_id="awoooi", ) assert result["ok"] is True assert sent_requests method, payload = sent_requests[0] assert method == "sendMessage" assert "CONTROLLED APPLY RESULT" in payload["text"] assert "INC-20260627-64472B" in payload["text"] assert "ansible:188-momo-backup-user" in payload["text"] source_extra = payload["_awooop_source_envelope_extra"] assert source_extra["callback_reply"]["action"] == "controlled_apply_result" assert source_extra["callback_reply"]["status"] == "callback_reply_sent" assert source_extra["source_refs"]["incident_ids"] == ["INC-20260627-64472B"] km_snapshot = source_extra["km_stale_completion_summary"] assert ( km_snapshot["source_schema_version"] == "km_stale_owner_review_completion_telegram_summary_v1" ) assert km_snapshot["status"] == "no_related_owner_review" assert km_snapshot["ready_count"] == 10 assert km_snapshot["triage"]["ai_lead_agent"] == "Hermes" snapshot = source_extra["awooop_status_chain"] assert snapshot["repair_state"] == "auto_repaired_verified" assert snapshot["operator_outcome"]["state"] == "completed_verified" assert snapshot["execution"]["ansible"]["controlled_apply"] is True def test_callback_reply_awooop_status_chain_snapshot_marks_manual_gate() -> None: """Callback evidence 要保存當下 AwoooP 狀態鏈,不只保存 live query 結果。""" snapshot = telegram_gateway_module._callback_reply_awooop_status_chain_snapshot( incident_id="INC-20260514-F85F21", truth_chain={ "truth_status": { "current_stage": "approval_required", "stage_status": "waiting", "needs_human": True, "blockers": ["pending_human_approval"], }, "automation_quality": { "verdict": "approval_required", "facts": { "auto_repair_execution_records": 0, "automation_operation_records": 0, "verification_result": "missing", "mcp_gateway_total": 1, "knowledge_entries": 0, }, "blockers": [], }, "mcp": { "awooop_gateway": { "total": 2, "success": 1, "failed": 1, "blocked": 0, "first_class_total": 2, "legacy_bridge_total": 0, "policy_enforced_total": 2, "by_tool": [ { "tool_name": "prometheus.query", "total": 2, "success": 1, "failed": 1, "blocked": 0, } ], }, "legacy": { "total": 1, "success": 1, "failed": 0, }, }, "execution": { "automation_operation_log": [ { "operation_type": "playbook_previewed", "status": "waiting_approval", "actor": "Hermes", "input_executor": "ansible", "input_playbook_id": "pb-host-restart", "input_playbook_path": "infra/ansible/playbooks/188-ai-web.yml", } ], "ansible": { "considered": True, "records": [ { "operation_type": "ansible_check_mode_previewed", "status": "waiting_approval", "playbook_path": "infra/ansible/playbooks/188-ai-web.yml", "check_mode": True, } ], "candidate_catalog": { "candidates": [ { "catalog_id": "ansible:188-ai-web", "playbook_path": "infra/ansible/playbooks/188-ai-web.yml", "risk_level": "medium", "match_score": 3, } ] }, }, }, "channel": { "inbound_events": [ { "channel_type": "sentry", "provider_event_id": "sentry:ISSUE-1", "source_envelope": { "source_refs": { "sentry_issue_ids": ["ISSUE-1"], "signoz_alerts": ["signoz:alert-1"], "fingerprints": ["fp-1"], } }, } ], "outbound_messages": [], }, }, remediation_history={ "total": 1, "items": [ { "agent_id": "investigator", "tool_name": "ssh_diagnose", "required_scope": "read", "safety_level": "read_only", "verification_result_preview": "degraded", "writes_incident_state": False, "writes_auto_repair_result": False, } ], }, source_correlation={ "schema_version": "source_provider_correlation_v1", "status": "candidate_found", "verification_status": "candidate_only", "direct_ref_total": 0, "candidate_total": 1, "applied_link_total": 0, "provider_event_total": 1, "latest_applied_link_at": None, "providers": { "sentry": { "direct_ref_total": 0, "candidate_total": 1, "applied_link_total": 0, "latest_event_at": "2026-05-20T00:00:00Z", }, "signoz": { "direct_ref_total": 0, "candidate_total": 0, "applied_link_total": 0, "latest_event_at": None, }, }, "top_candidates": [ { "provider": "sentry", "provider_event_id": "sentry:ISSUE-1", "score": 2, "link_state": "candidate", } ], }, ) assert snapshot is not None assert snapshot["schema_version"] == ( "awooop_status_chain_callback_reply_snapshot_v1" ) assert snapshot["repair_state"] == "read_only_dry_run" assert snapshot["needs_human"] is False assert snapshot["next_step"] == "evaluate_controlled_apply_from_read_only_evidence" assert snapshot["evidence"]["mcp_gateway_total"] == 1 assert snapshot["evidence"]["latest_route"] == "investigator/ssh_diagnose/read" assert snapshot["writes"]["incident"] is False assert snapshot["blockers"] == ["pending_human_approval"] assert snapshot["mcp"]["gateway"]["policy_enforced_total"] == 2 assert snapshot["mcp"]["top_tools"][0]["tool_name"] == "prometheus.query" assert snapshot["execution"]["latest_executor"] == "ansible" assert snapshot["execution"]["playbook_paths"] == [ "infra/ansible/playbooks/188-ai-web.yml" ] assert snapshot["execution"]["ansible"]["candidate_count"] == 1 assert snapshot["source_refs"]["refs"]["sentry_issue_ids"] == ["ISSUE-1"] assert snapshot["source_refs"]["refs"]["signoz_alerts"] == ["signoz:alert-1"] assert snapshot["source_refs"]["correlation"]["status"] == "candidate_found" def test_km_stale_completion_lines_show_owner_review_queue_state() -> None: """詳情/歷史要顯示 KM owner-review completion queue 是否卡住或可處理。""" lines = telegram_gateway_module._format_km_stale_completion_lines({ "schema_version": "km_stale_owner_review_completion_telegram_summary_v1", "status": "ok", "incident_id": "INC-20260513-205814", "ready_count": 10, "blocked_count": 0, "completed_count": 1, "failed_count": 0, "writes_on_read": False, "batch_writes_allowed": False, "items": [ { "entry_id": "bf81a30d-6abe-4c0c-b4ba-9c0ba0d761bf", "readiness": "ready", "next_action": "preview_stale_km_review_completion", } ], }) joined = "\n".join(lines) assert "KM Owner Review" in joined assert "ready 10" in joined assert "batch-write no" in joined assert "bf81a30d-6abe-4c0c-b4ba-9c0ba0d761bf" in joined assert "preview_stale_km_review_completion" in joined def test_km_stale_completion_lines_show_callback_triage_state() -> None: """詳情/歷史要顯示 callback owner-review 缺口的流程、主責與卡點。""" lines = telegram_gateway_module._format_km_stale_completion_lines({ "schema_version": "km_stale_owner_review_completion_telegram_summary_v1", "status": "no_related_owner_review", "incident_id": "INC-20260524-16109D", "ready_count": 10, "blocked_count": 0, "completed_count": 1, "failed_count": 0, "writes_on_read": False, "batch_writes_allowed": False, "items": [], "work_item": { "schema_version": "km_stale_callback_owner_review_work_item_v1", "status": "open", "triage": { "schema_version": "km_stale_callback_owner_review_triage_v1", "flow_stage": "callback_observed_owner_review_link_missing", "ai_lead_agent": "Hermes", "supporting_agents": ["OpenClaw", "ElephantAlpha"], "automation_state": "manual_owner_review_required", "safe_to_auto_repair": False, "blocking_reason": "no_matching_completion_item", "matching_strategy": "related_incident_id_exact_match", }, }, }) joined = "\n".join(lines) assert "no_related_owner_review" in joined assert "callback_observed_owner_review_link_missing" in joined assert "related_incident_id_exact_match" in joined assert "Hermes" in joined assert "OpenClaw / ElephantAlpha" in joined assert "manual_owner_review_required" in joined assert "safe-auto no" in joined assert "no_matching_completion_item" in joined def test_awooop_runs_url_for_incident_uses_public_incident_filter() -> None: """Telegram URL button 必須導到公開 AwoooP Run list,並帶 incident filter。""" url = telegram_gateway_module._awooop_runs_url_for_incident("INC-20260514-F85F21") assert url == ( "https://awoooi.wooo.work/zh-TW/awooop/runs" "?project_id=awoooi&incident_id=INC-20260514-F85F21" ) def test_awooop_alerts_url_for_incident_is_canonical_truth_chain() -> None: """Telegram 詳情/歷史要能直接導到 Alerts Incident truth-chain。""" url = telegram_gateway_module._awooop_alerts_url_for_incident( "INC-20260514-F85F21" ) assert url == ( "https://awoooi.wooo.work/zh-TW/alerts" "?project_id=awoooi&incident_id=INC-20260514-F85F21" ) def test_awooop_reply_markup_prefers_truth_chain_then_runs() -> None: """詳情/歷史 callback reply 的第一顆 URL 要是告警真相鏈。""" reply_markup = telegram_gateway_module._awooop_runs_reply_markup( "INC-20260514-F85F21" ) assert reply_markup == { "inline_keyboard": [[ { "text": "🔎 真相鏈", "url": ( "https://awoooi.wooo.work/zh-TW/alerts" "?project_id=awoooi&incident_id=INC-20260514-F85F21" ), }, { "text": "🧭 Runs", "url": ( "https://awoooi.wooo.work/zh-TW/awooop/runs" "?project_id=awoooi&incident_id=INC-20260514-F85F21" ), }, ]] } @pytest.mark.asyncio async def test_build_inline_keyboard_includes_awooop_deep_link() -> None: """主告警卡的 read-only 按鈕列要能回到 Incident truth-chain。""" gateway = TelegramGateway() keyboard = await gateway._build_inline_keyboard( approval_id="INC-20260514-F85F21", include_auto_tuning=False, incident_id="INC-20260514-F85F21", ) buttons = [ button for row in keyboard["inline_keyboard"] for button in row ] assert { "text": "🔎 真相鏈", "url": ( "https://awoooi.wooo.work/zh-TW/alerts" "?project_id=awoooi&incident_id=INC-20260514-F85F21" ), } in buttons assert { "text": "🧭 Runs", "url": ( "https://awoooi.wooo.work/zh-TW/awooop/runs" "?project_id=awoooi&incident_id=INC-20260514-F85F21" ), } in buttons @pytest.mark.asyncio async def test_build_inline_keyboard_hides_approval_for_no_action() -> None: """OBSERVE / NO_ACTION 卡片不能提供會誤導成修復執行的批准入口。""" gateway = TelegramGateway() keyboard = await gateway._build_inline_keyboard( approval_id="approval-no-repair-1", include_auto_tuning=False, incident_id="INC-20260611-NOOP", suggested_action="NO_ACTION - REPAIR_CANDIDATE_MISSING: LLM 分析失敗", ) buttons = [ button for row in keyboard["inline_keyboard"] for button in row ] button_texts = {button["text"] for button in buttons} assert "✅ 批准" not in button_texts assert "❌ 拒絕" not in button_texts assert "🧰 處置包" in button_texts assert "🔄 重診" in button_texts assert "🔕 靜默" in button_texts assert { "text": "🧭 Runs", "url": ( "https://awoooi.wooo.work/zh-TW/awooop/runs" "?project_id=awoooi&incident_id=INC-20260611-NOOP" ), } in buttons @pytest.mark.asyncio async def test_build_inline_keyboard_links_work_item_for_no_action_handoff() -> None: """NO_ACTION 卡片要直接開 owner review Work Item,不能只丟給人工找頁面。""" gateway = TelegramGateway() keyboard = await gateway._build_inline_keyboard( approval_id="approval-no-repair-work-item", include_auto_tuning=False, incident_id="INC-20260625-977E5F", suggested_action=( "DRAFT_READY - REPAIR_CANDIDATE_OWNER_REVIEW_REQUIRED: " "PlayBook 只有觀察或診斷步驟" ), repair_candidate_work_item_href=( "/awooop/work-items?project_id=awoooi" "&incident_id=INC-20260625-977E5F" "&work_item_id=repair-candidate-draft%3Aawoooi%3AINC-20260625-977E5F" ), ) buttons = [ button for row in keyboard["inline_keyboard"] for button in row ] button_texts = {button["text"] for button in buttons} assert "✅ 批准" not in button_texts assert "❌ 拒絕" not in button_texts assert { "text": "🧾 Work Item", "url": ( "https://awoooi.wooo.work/zh-TW/awooop/work-items" "?project_id=awoooi&incident_id=INC-20260625-977E5F" "&work_item_id=repair-candidate-draft%3Aawoooi%3AINC-20260625-977E5F" ), } in buttons assert "🧰 處置包" in button_texts assert "🔄 重診" in button_texts assert { "text": "🧭 Runs", "url": ( "https://awoooi.wooo.work/zh-TW/awooop/runs" "?project_id=awoooi&incident_id=INC-20260625-977E5F" ), } in buttons @pytest.mark.asyncio async def test_send_request_strips_awooop_callback_metadata_before_telegram_api(monkeypatch): """AwoooP truth-chain metadata must be mirrored, not sent to Telegram Bot API.""" captured = {} gateway = TelegramGateway() gateway._initialized = True class FakeResponse: def raise_for_status(self): return None def json(self): return {"ok": True, "result": {"message_id": 456}} class FakeClient: async def post(self, url, json): captured["payload"] = dict(json) return FakeResponse() async def fake_mirror_outbound_message( *, method, payload, provider_message_id, source_envelope_extra=None, ): captured["mirror"] = { "method": method, "payload": dict(payload), "provider_message_id": provider_message_id, "source_envelope_extra": source_envelope_extra, } gateway._http_client = FakeClient() monkeypatch.setattr(gateway, "_mirror_outbound_message", fake_mirror_outbound_message) result = await gateway._send_request( "sendMessage", { "chat_id": "chat", "text": "事件歷史統計 INC-20260513-79ED5E", "_skip_incident_thread_reply": True, "_awooop_source_envelope_extra": { "callback_reply": { "status": "callback_reply_sent", "incident_id": "INC-20260513-79ED5E", }, "awooop_status_chain": { "schema_version": "awooop_status_chain_callback_reply_snapshot_v1", "repair_state": "read_only_dry_run", "needs_human": True, "evidence": { "auto_repair_records": 0, "operation_records": 0, }, }, "km_stale_completion_summary": { "schema_version": ( "km_stale_owner_review_callback_reply_snapshot_v1" ), "status": "no_related_owner_review", "ready_count": 2, "triage": { "flow_stage": "callback_observed_owner_review_link_missing", }, }, }, }, ) assert result["ok"] is True assert "_awooop_source_envelope_extra" not in captured["payload"] assert "_skip_incident_thread_reply" not in captured["payload"] assert captured["mirror"]["source_envelope_extra"]["callback_reply"]["status"] == ( "callback_reply_sent" ) assert captured["mirror"]["source_envelope_extra"]["awooop_status_chain"][ "repair_state" ] == "read_only_dry_run" assert captured["mirror"]["source_envelope_extra"][ "km_stale_completion_summary" ]["ready_count"] == 2 @pytest.mark.asyncio async def test_send_html_line_message_falls_back_to_plain_text_on_parse_error(monkeypatch): """Telegram HTML parse 400 時要送純文字 fallback,不可回報成歷史查詢失敗。""" sent_requests = [] gateway = TelegramGateway() reply_markup = telegram_gateway_module._awooop_runs_reply_markup("INC-20260514-F85F21") async def fake_send_request(method, payload): sent_requests.append((method, payload)) if payload.get("parse_mode") == "HTML": raise telegram_gateway_module.TelegramGatewayError("HTTP error: 400") return {"ok": True} monkeypatch.setattr(gateway, "_send_request", fake_send_request) await gateway._send_html_line_message( ["📊 事件歷史統計", "階段: blocked"], chat_id="chat", failure_context="test_history", reply_markup=reply_markup, ) assert len(sent_requests) == 2 assert sent_requests[0][1]["parse_mode"] == "HTML" assert sent_requests[0][1]["reply_markup"] == reply_markup assert "parse_mode" not in sent_requests[1][1] assert sent_requests[1][1]["reply_markup"] == reply_markup assert "" not in sent_requests[1][1]["text"] assert "blocked" in sent_requests[1][1]["text"] @pytest.mark.asyncio async def test_send_html_line_message_marks_callback_reply_evidence(monkeypatch): """詳情/歷史 callback reply 要在 AwoooP envelope 標明 sent/fallback 狀態。""" sent_requests = [] gateway = TelegramGateway() reply_markup = telegram_gateway_module._awooop_runs_reply_markup("INC-20260514-F85F21") async def fake_send_request(method, payload): sent_requests.append((method, payload)) if payload.get("parse_mode") == "HTML": raise telegram_gateway_module.TelegramGatewayError("HTTP error: 400") return {"ok": True} monkeypatch.setattr(gateway, "_send_request", fake_send_request) await gateway._send_html_line_message( ["📊 事件歷史統計", "🔖 INC-20260514-F85F21"], chat_id="chat", failure_context="incident_history", reply_markup=reply_markup, incident_id="INC-20260514-F85F21", callback_action="history", awooop_status_chain={ "schema_version": "awooop_status_chain_callback_reply_snapshot_v1", "source": "telegram_callback_reply_snapshot", "source_id": "INC-20260514-F85F21", "incident_ids": ["INC-20260514-F85F21"], "current_stage": "approval_required", "stage_status": "waiting", "verdict": "approval_required", "repair_state": "read_only_dry_run", "verification": "missing", "needs_human": True, "next_step": "approve_or_escalate_from_awooop", "evidence": { "auto_repair_records": 0, "operation_records": 0, "mcp_gateway_total": 1, "knowledge_entries": 0, }, "writes": { "incident": False, "auto_repair": False, }, }, km_stale_completion_summary={ "schema_version": "km_stale_owner_review_completion_callback_summary_v1", "project_id": "awoooi", "incident_id": "INC-20260514-F85F21", "status": "no_related_owner_review", "ready_count": 3, "blocked_count": 1, "completed_count": 2, "failed_count": 0, "writes_on_read": False, "batch_writes_allowed": False, "manual_review_required": True, "related_total": 0, "work_item": { "triage": { "schema_version": "km_stale_callback_owner_review_triage_v1", "flow_stage": "callback_observed_owner_review_link_missing", "ai_lead_agent": "Hermes", "supporting_agents": ["OpenClaw", "ElephantAlpha"], "automation_state": "manual_owner_review_required", "safe_to_auto_repair": False, "blocking_reason": "no_matching_completion_item", "matching_strategy": "related_incident_id_exact_match", }, }, }, ) first_source_extra = sent_requests[0][1]["_awooop_source_envelope_extra"] fallback_source_extra = sent_requests[1][1]["_awooop_source_envelope_extra"] first_extra = first_source_extra["callback_reply"] fallback_extra = fallback_source_extra["callback_reply"] assert first_extra["status"] == "callback_reply_sent" assert first_extra["action"] == "history" assert first_extra["parse_mode"] == "HTML" assert first_source_extra["awooop_status_chain"]["repair_state"] == ( "read_only_dry_run" ) assert first_source_extra["awooop_status_chain"]["needs_human"] is True assert first_source_extra["km_stale_completion_summary"]["ready_count"] == 3 assert first_source_extra["km_stale_completion_summary"]["triage"]["flow_stage"] == ( "callback_observed_owner_review_link_missing" ) assert fallback_extra["status"] == "callback_reply_fallback_sent" assert fallback_extra["incident_id"] == "INC-20260514-F85F21" assert fallback_extra["parse_mode"] == "plain_text" assert fallback_source_extra["awooop_status_chain"]["next_step"] == ( "approve_or_escalate_from_awooop" ) assert fallback_source_extra["km_stale_completion_summary"]["triage"][ "ai_lead_agent" ] == "Hermes" @pytest.mark.asyncio async def test_send_html_line_message_uses_rescue_when_markup_fallback_fails(monkeypatch): """詳情/歷史 fallback 若仍被 Telegram 拒收,要再送無按鈕純文字救援。""" sent_requests = [] gateway = TelegramGateway() reply_markup = telegram_gateway_module._awooop_runs_reply_markup("INC-20260514-F85F21") async def fake_send_request(method, payload): sent_requests.append((method, payload)) if len(sent_requests) < 3: raise telegram_gateway_module.TelegramGatewayError("HTTP error: 400") return {"ok": True} monkeypatch.setattr(gateway, "_send_request", fake_send_request) await gateway._send_html_line_message( ["📊 事件歷史統計", "🔖 INC-20260514-F85F21"], chat_id="chat", failure_context="test_history", reply_markup=reply_markup, ) assert len(sent_requests) == 3 assert sent_requests[1][1]["reply_markup"] == reply_markup assert "reply_markup" not in sent_requests[2][1] assert sent_requests[2][1]["_skip_incident_thread_reply"] is True assert "" not in sent_requests[2][1]["text"] @pytest.mark.asyncio async def test_send_html_line_message_attaches_awooop_markup_to_first_chunk(monkeypatch): """詳情/歷史這類 HTML reply 要能帶 AwoooP evidence URL,且長訊息只掛第一段。""" sent_requests = [] gateway = TelegramGateway() reply_markup = telegram_gateway_module._awooop_runs_reply_markup("INC-20260514-F85F21") async def fake_send_request(method, payload): sent_requests.append((method, payload)) return {"ok": True} monkeypatch.setattr(gateway, "_send_request", fake_send_request) await gateway._send_html_line_message( ["📊 事件歷史統計"] + [ f"INC-20260514-F85F21 trace line {index:03d}" for index in range(80) ], chat_id="chat", failure_context="test_history", reply_markup=reply_markup, ) assert len(sent_requests) > 1 assert sent_requests[0][1]["reply_markup"] == reply_markup assert all("reply_markup" not in payload for _, payload in sent_requests[1:]) @pytest.mark.asyncio async def test_info_callback_sends_history_when_answer_callback_is_stale(monkeypatch): """Telegram answerCallbackQuery 400 不得阻斷詳情/歷史 DB truth-chain reply。""" gateway = TelegramGateway() sent_history = [] monkeypatch.setattr(gateway._security, "is_whitelisted", lambda _user_id: True) async def stale_answer(*_args, **_kwargs): raise telegram_gateway_module.TelegramGatewayError("HTTP error: 400") async def fake_history(incident_id: str): sent_history.append(incident_id) monkeypatch.setattr(gateway, "_answer_callback", stale_answer) monkeypatch.setattr(gateway, "_send_incident_history", fake_history) result = await gateway.handle_callback( callback_query_id="stale-callback", callback_data="history:INC-20260513-79ED5E", user_id=123456, message_id=789, ) assert result["success"] is True assert result["info_action"] is True assert sent_history == ["INC-20260513-79ED5E"] @pytest.mark.asyncio async def test_send_notification_retries_plain_text_on_html_parse_400(monkeypatch): """簡短 HTML 若被 Telegram 拒收,要用純文字重送,不回報成 HTTP 400。""" gateway = TelegramGateway() sent_requests = [] async def fake_send_request(method, payload): sent_requests.append((method, payload)) if len(sent_requests) == 1: raise telegram_gateway_module.TelegramGatewayError("HTTP error: 400") return {"ok": True} monkeypatch.setattr(gateway, "_send_request", fake_send_request) result = await gateway.send_notification( "⚠️ 無法取得歷史統計: broken", chat_id="chat", ) assert result == {"ok": True} assert sent_requests[0][1]["parse_mode"] == "HTML" assert "parse_mode" not in sent_requests[1][1] assert sent_requests[1][1]["text"] == "⚠️ 無法取得歷史統計: broken" @pytest.mark.asyncio async def test_send_notification_long_html_uses_plain_text_without_cutting_tags(monkeypatch): """send_notification 的 500 字限制不可切壞 HTML tag。""" gateway = TelegramGateway() sent_requests = [] async def fake_send_request(method, payload): sent_requests.append((method, payload)) return {"ok": True} monkeypatch.setattr(gateway, "_send_request", fake_send_request) long_text = "📊 事件歷史統計\n告警鍵: " + ("node<188>&" * 90) + "" await gateway.send_notification(long_text, chat_id="chat") assert len(sent_requests) == 1 payload = sent_requests[0][1] assert "parse_mode" not in payload assert len(payload["text"]) <= 500 assert "" not in payload["text"] assert "" not in payload["text"] assert "事件歷史統計" in payload["text"] 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 "規則建議待 AI controlled policy 判定" in result assert "AI 自動化鏈路" in result assert "OpenClaw" in result assert "NemoTron" in result assert "ElephantAlpha" in result assert "自動化資產沉澱" in result assert "KM" in result assert "PlayBook" in result assert "腳本/Ansible" in result assert "排程/來源" in result assert "Verifier" in result assert len(result) <= 4096 # Telegram HTML message limit def test_telegram_message_ai_proposal_marks_controlled_apply(self): """有 AI 信心分數的修復建議必須排入 controlled apply。""" 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 已提出修復建議,排入 controlled apply" in result def test_telegram_message_no_action_marks_ai_controlled_queue(self): """NO_ACTION 卡片必須一眼看得出 AI 受控處理下一步。""" 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 assert "AI 受控" in result assert "補證據:node_exporter target up" in result assert "AI 建立修復候選" in result assert "沉澱資產:KM、PlayBook、腳本/Ansible、排程/監控規則、Verifier 結果" in result assert "Runs、Work Items、Knowledge Base 要顯示資產 ID、owner、狀態與下一步" in result assert "等待人工批准" not 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 診斷工具失敗,已排入 tool/connector 修復" in result assert "AI 自動修復失敗" not in result def test_telegram_message_surfaces_read_only_remediation_evidence(self): """主告警卡必須顯示 ADR-100 只讀補救試跑與寫入旗標。""" msg = TelegramMessage( status_emoji="⚠️", risk_level="MEDIUM", resource_name="awoooi-auto-repair-canary", root_cause="post approval verification drift", suggested_action="kubectl rollout restart deployment/awoooi-api", estimated_downtime="~30s", approval_id="INC-20260513-79ED5E", confidence=0.82, remediation_summary={ "schema_version": "adr100_remediation_history_v1", "total": 3, "items": [ { "mode": "replay", "allowed": True, "success": True, "safety_level": "read_only", "verification_result_preview": "degraded", "agent_id": "auto_repair_executor", "tool_name": "ssh_diagnose", "required_scope": "read", "writes_incident_state": False, "writes_auto_repair_result": False, } ], }, ) result = msg.format() assert "AI 已完成只讀補救試跑,排入受控 apply 判定" in result assert "AI 證據" in result assert "只讀試跑 3 次" in result assert "auto_repair_executor/ssh_diagnose/read" in result assert "incident false" in result assert "auto-repair false" in result assert "自動化資產沉澱" in result assert "Verifier:blocked" in result def test_telegram_message_asset_deposition_shows_km_playbook_verifier(self): """主告警卡必須把 KM / PlayBook / Verifier 沉澱結果前移到可掃描區。""" msg = TelegramMessage( status_emoji="⚠️", risk_level="MEDIUM", resource_name="awoooi-api", root_cause="已完成診斷並產生修復候選", suggested_action="等待 AI controlled policy 判定", estimated_downtime="unknown", approval_id="INC-20260618-ASSET", playbook_name="infra/ansible/playbooks/110-devops.yml", automation_quality={ "verdict": "approval_required", "facts": { "knowledge_entries": 2, "verification_result": "healthy", "mcp_gateway_total": 7, "auto_repair_execution_records": 0, "automation_operation_records": 0, }, "blockers": [], }, remediation_summary={ "total": 1, "items": [ { "agent_id": "investigator", "tool_name": "ssh_diagnose", "required_scope": "read", "verification_result_preview": "healthy", } ], }, ) result = msg.format() assert "自動化資產沉澱" in result assert "KM:2" in result assert "PlayBook:1" in result assert "Verifier:ready" in result assert "完成 3 / 卡點 0" in result def test_telegram_message_surfaces_missing_remediation_evidence(self): """沒有補救試跑紀錄時,主卡要明確說明,不讓值班者猜。""" msg = TelegramMessage( status_emoji="ℹ️", risk_level="LOW", resource_name="awoooi-auto-repair-canary", root_cause="safe canary", suggested_action="NO_ACTION", estimated_downtime="unknown", approval_id="INC-20260513-EMPTY", remediation_summary={ "schema_version": "adr100_remediation_history_v1", "total": 0, "items": [], }, ) result = msg.format() assert "AI 證據" in result assert "尚無補救試跑紀錄" 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 = "🤖❌ [AUTO] AI 自動修復失敗,已升級人工介入" 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 = ( "🤖❌ [AUTO] AI 自動修復失敗,已升級人工介入\n" "├ 動作: ssh 192.168.0.110 uptime\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", ] @pytest.mark.asyncio async def test_send_approval_card_includes_remediation_summary(monkeypatch): """send_approval_card 要把 durable 補救試跑歷史帶進 Telegram 主卡。""" sent_requests = [] gateway = TelegramGateway() async def fake_send_request(method, payload): sent_requests.append((method, payload)) return {"ok": True, "result": {}} async def fake_keyboard(**kwargs): return {"inline_keyboard": []} async def fake_remediation_summary(**kwargs): assert kwargs["incident_id"] == "INC-20260513-79ED5E" return { "schema_version": "adr100_remediation_history_v1", "total": 1, "items": [ { "allowed": True, "success": True, "safety_level": "read_only", "verification_result_preview": "degraded", "agent_id": "auto_repair_executor", "tool_name": "ssh_diagnose", "required_scope": "read", "writes_incident_state": False, "writes_auto_repair_result": False, } ], } monkeypatch.setattr(TelegramGateway, "alert_chat_id", property(lambda _self: "chat")) monkeypatch.setattr(gateway, "_send_request", fake_send_request) monkeypatch.setattr(gateway, "_build_inline_keyboard", fake_keyboard) monkeypatch.setattr( telegram_gateway_module, "_fetch_remediation_summary_for_card", fake_remediation_summary, ) await gateway.send_approval_card( approval_id="approval-1", risk_level="medium", resource_name="awoooi-auto-repair-canary", root_cause="post approval verification drift", suggested_action="kubectl rollout restart deployment/awoooi-api", incident_id="INC-20260513-79ED5E", confidence=0.82, ) assert sent_requests text = sent_requests[0][1]["text"] assert "AI 已完成只讀補救試跑,排入受控 apply 判定" in text assert "auto_repair_executor/ssh_diagnose/read" in text 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( "📄 RUNBOOK REVIEW|待審核", {"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 "發生次數: 15" in result assert "影響用戶: 3" 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 "總修復次數: 12" in result assert "成功: ✅ 10 (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 "總數: 45" in result assert "Critical: 2" 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 "自動修復: 30" in result assert "API: 99.95%" 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