- 自動修復 import 排序、unused imports - 手動修復 raise from、isinstance union、unused variable - scripts/ 暫時保留 (非 CI 阻擋) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
197 lines
6.9 KiB
Python
197 lines
6.9 KiB
Python
"""
|
|
AnomalyCounter 整合測試
|
|
=======================
|
|
ADR-037: 監控增強架構 - 異常頻率統計
|
|
|
|
🔴🔴 遵循「禁止 Mock 測試鐵律」- 使用真實 Redis
|
|
|
|
建立: 2026-03-29 (台北時區) Claude Code
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from src.services.anomaly_counter import AnomalyCounter, AnomalyFrequency
|
|
|
|
|
|
class TestAnomalyCounterHashSignature:
|
|
"""測試異常簽名 Hash 生成"""
|
|
|
|
def test_same_input_same_hash(self):
|
|
"""相同輸入應產生相同 hash"""
|
|
sig1 = {"alert_name": "PodCrash", "service": "api"}
|
|
sig2 = {"alert_name": "PodCrash", "service": "api"}
|
|
assert AnomalyCounter.hash_signature(sig1) == AnomalyCounter.hash_signature(sig2)
|
|
|
|
def test_different_input_different_hash(self):
|
|
"""不同輸入應產生不同 hash"""
|
|
sig1 = {"alert_name": "PodCrash", "service": "api"}
|
|
sig2 = {"alert_name": "PodCrash", "service": "web"}
|
|
assert AnomalyCounter.hash_signature(sig1) != AnomalyCounter.hash_signature(sig2)
|
|
|
|
def test_ignores_extra_fields(self):
|
|
"""應忽略非關鍵欄位 (如 timestamp)"""
|
|
sig1 = {"alert_name": "PodCrash", "service": "api"}
|
|
sig2 = {"alert_name": "PodCrash", "service": "api", "timestamp": "2026-01-01"}
|
|
assert AnomalyCounter.hash_signature(sig1) == AnomalyCounter.hash_signature(sig2)
|
|
|
|
def test_alertname_alias(self):
|
|
"""應支援 alertname (Prometheus 格式) 別名"""
|
|
sig1 = {"alert_name": "PodCrash", "service": "api"}
|
|
sig2 = {"alertname": "PodCrash", "service": "api"}
|
|
assert AnomalyCounter.hash_signature(sig1) == AnomalyCounter.hash_signature(sig2)
|
|
|
|
def test_job_alias(self):
|
|
"""應支援 job (Prometheus 格式) 別名"""
|
|
sig1 = {"alert_name": "PodCrash", "service": "api"}
|
|
sig2 = {"alert_name": "PodCrash", "job": "api"}
|
|
assert AnomalyCounter.hash_signature(sig1) == AnomalyCounter.hash_signature(sig2)
|
|
|
|
|
|
class TestAnomalyFrequencyToDict:
|
|
"""測試 AnomalyFrequency.to_dict()"""
|
|
|
|
def test_to_dict_returns_all_fields(self):
|
|
"""to_dict 應返回所有欄位"""
|
|
from datetime import datetime
|
|
|
|
freq = AnomalyFrequency(
|
|
anomaly_key="abc123",
|
|
count_1h=3,
|
|
count_24h=10,
|
|
count_7d=50,
|
|
count_30d=200,
|
|
first_seen=datetime(2026, 3, 1),
|
|
last_seen=datetime(2026, 3, 29),
|
|
auto_repair_count=5,
|
|
permanent_fix_applied=False,
|
|
escalation_level="ESCALATE",
|
|
)
|
|
|
|
d = freq.to_dict()
|
|
|
|
assert d["anomaly_key"] == "abc123"
|
|
assert d["count_1h"] == 3
|
|
assert d["count_24h"] == 10
|
|
assert d["count_7d"] == 50
|
|
assert d["count_30d"] == 200
|
|
assert d["auto_repair_count"] == 5
|
|
assert d["permanent_fix_applied"] is False
|
|
assert d["escalation_level"] == "ESCALATE"
|
|
|
|
|
|
class TestAnomalyCounterEscalationLevel:
|
|
"""測試升級等級判斷"""
|
|
|
|
def test_no_escalation_below_threshold(self):
|
|
"""低於閾值不應升級"""
|
|
counter = AnomalyCounter.__new__(AnomalyCounter)
|
|
assert counter._get_escalation_level(2) is None
|
|
|
|
def test_repeat_at_threshold(self):
|
|
"""達到 REPEAT 閾值 (3 次)"""
|
|
counter = AnomalyCounter.__new__(AnomalyCounter)
|
|
assert counter._get_escalation_level(3) == "REPEAT"
|
|
|
|
def test_escalate_at_threshold(self):
|
|
"""達到 ESCALATE 閾值 (5 次)"""
|
|
counter = AnomalyCounter.__new__(AnomalyCounter)
|
|
assert counter._get_escalation_level(5) == "ESCALATE"
|
|
|
|
def test_permanent_fix_at_threshold(self):
|
|
"""達到 PERMANENT_FIX 閾值 (10 次)"""
|
|
counter = AnomalyCounter.__new__(AnomalyCounter)
|
|
assert counter._get_escalation_level(10) == "PERMANENT_FIX"
|
|
|
|
def test_highest_level_wins(self):
|
|
"""超過最高閾值應返回最高等級"""
|
|
counter = AnomalyCounter.__new__(AnomalyCounter)
|
|
assert counter._get_escalation_level(100) == "PERMANENT_FIX"
|
|
|
|
|
|
# =============================================================================
|
|
# 整合測試 (需要真實 Redis)
|
|
# =============================================================================
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.integration
|
|
class TestAnomalyCounterIntegration:
|
|
"""
|
|
整合測試 - 需要真實 Redis
|
|
|
|
執行方式: pytest -m integration tests/test_anomaly_counter.py
|
|
"""
|
|
|
|
@pytest.fixture
|
|
async def counter(self):
|
|
"""取得 AnomalyCounter 實例"""
|
|
from src.core.redis_client import get_redis
|
|
|
|
redis = get_redis()
|
|
counter = AnomalyCounter(redis)
|
|
|
|
# 清理測試資料
|
|
test_key = "test_anomaly_key"
|
|
await redis.delete(f"anomaly:timeline:{test_key}")
|
|
await redis.delete(f"anomaly:repair_count:{test_key}")
|
|
await redis.delete(f"anomaly:permanent_fix:{test_key}")
|
|
await redis.delete(f"anomaly:metadata:{test_key}")
|
|
await redis.delete(f"anomaly:repair_history:{test_key}")
|
|
|
|
return counter
|
|
|
|
async def test_record_anomaly_returns_frequency(self, counter):
|
|
"""record_anomaly 應返回頻率統計"""
|
|
signature = {
|
|
"alert_name": "TestAlert",
|
|
"service": "test-service",
|
|
"namespace": "test",
|
|
}
|
|
|
|
freq = await counter.record_anomaly(signature)
|
|
|
|
assert freq.count_1h >= 1
|
|
assert freq.count_24h >= 1
|
|
assert freq.anomaly_key is not None
|
|
|
|
async def test_record_anomaly_increments_count(self, counter):
|
|
"""多次記錄應遞增計數"""
|
|
signature = {
|
|
"alert_name": "TestAlert",
|
|
"service": "test-service",
|
|
"namespace": "test",
|
|
}
|
|
|
|
freq1 = await counter.record_anomaly(signature)
|
|
_ = await counter.record_anomaly(signature)
|
|
freq3 = await counter.record_anomaly(signature)
|
|
|
|
assert freq3.count_1h == freq1.count_1h + 2
|
|
assert freq3.count_24h == freq1.count_24h + 2
|
|
|
|
async def test_record_repair_attempt(self, counter):
|
|
"""記錄修復嘗試"""
|
|
anomaly_key = "test_repair_key"
|
|
|
|
await counter.record_repair_attempt(anomaly_key, "restart_pod", True)
|
|
await counter.record_repair_attempt(anomaly_key, "restart_pod", False)
|
|
|
|
stats = await counter.get_repair_success_rate(anomaly_key, "restart_pod")
|
|
|
|
assert stats["total"] == 2
|
|
assert stats["success"] == 1
|
|
assert stats["success_rate"] == 0.5
|
|
|
|
async def test_should_skip_action_low_success_rate(self, counter):
|
|
"""低成功率動作應跳過"""
|
|
anomaly_key = "test_skip_key"
|
|
|
|
# 模擬多次失敗
|
|
await counter.record_repair_attempt(anomaly_key, "bad_action", False)
|
|
await counter.record_repair_attempt(anomaly_key, "bad_action", False)
|
|
await counter.record_repair_attempt(anomaly_key, "bad_action", False)
|
|
|
|
should_skip = await counter.should_skip_action(anomaly_key, "bad_action")
|
|
|
|
assert should_skip is True
|