""" test_rule_to_playbook_migrator.py — 規則 → Playbook 遷移測試 ============================================================= W1 PR-R1: 覆蓋遷移邏輯,不真寫 DB 測試策略: - 所有測試用 conftest.py 設定 MOCK_MODE=true,避免 DB 連線 - DB 寫入用 AsyncMock 模擬 - yaml 解析用臨時 fixture 檔案(不依賴實際 alert_rules.yaml 避免路徑問題) 2026-04-28 ogt + Claude Sonnet 4.6 """ from __future__ import annotations import textwrap from pathlib import Path from unittest.mock import AsyncMock, MagicMock, patch import pytest import yaml # ============================================================================= # Fixtures # ============================================================================= MINIMAL_RULE_KUBECTL = { "id": "test_pod_crash", "priority": 60, "description": "Test Pod CrashLoopBackOff", "match": { "alertname": ["KubePodCrashLooping", "PodCrashLoopBackOff"], "alert_type": ["pod_crash"], "message": ["crashloop", "crash"], }, "response": { "action_title": "診斷 CrashLoop 根因", "description": "⚙️ 規則匹配: Pod 進入 CrashLoopBackOff", "suggested_action": "NO_ACTION", "kubectl_command": "kubectl logs {target} -n {namespace} --previous --tail=50", "risk": "critical", "responsibility": "BE", "optimization": [ { "type": "LIVENESS_PROBE", "description": "調整 liveness probe", "command": "# 調整 initialDelaySeconds", } ], }, } MINIMAL_RULE_SSH = { "id": "test_ollama_down", "priority": 90, "description": "Test Ollama Down", "match": { "alertname": ["OllamaDown"], "message": ["ollama"], }, "response": { "action_title": "重啟 Ollama", "description": "⚙️ Ollama 下線", "suggested_action": "RESTART_DEPLOYMENT", "kubectl_command": "ssh {host} 'systemctl restart ollama'", "risk": "medium", "responsibility": "INFRA", "optimization": [], }, } MINIMAL_RULE_NO_ACTION = { "id": "test_no_action", "priority": 110, "description": "Test No Action Rule", "match": { "alertname": ["GiteaDown"], "message": ["gitea"], }, "response": { "action_title": "Gitea 下線 — 人工確認", "description": "⚠️ Gitea 無法連線,不自動修復", "suggested_action": "NO_ACTION", "kubectl_command": "", "risk": "critical", "responsibility": "INFRA", "optimization": [], }, } SEVERITY_TEST_CASES = [ ("low", "LOW"), ("medium", "MEDIUM"), ("high", "HIGH"), ("critical", "CRITICAL"), ("", "MEDIUM"), (None, "MEDIUM"), ] @pytest.fixture def minimal_yaml(tmp_path: Path) -> Path: """建立含 3 條規則的最小 yaml fixture""" data = { "version": "1.0.0", "updated_at": "2026-04-28", "rules": [ MINIMAL_RULE_KUBECTL, MINIMAL_RULE_SSH, MINIMAL_RULE_NO_ACTION, ], } yaml_file = tmp_path / "test_alert_rules.yaml" yaml_file.write_text(yaml.dump(data, allow_unicode=True), encoding="utf-8") return yaml_file @pytest.fixture def empty_yaml(tmp_path: Path) -> Path: """建立空規則 yaml""" data = {"version": "1.0.0", "rules": []} yaml_file = tmp_path / "empty_rules.yaml" yaml_file.write_text(yaml.dump(data), encoding="utf-8") return yaml_file # ============================================================================= # 1. test_parse_yaml_rule_extracts_alertname # ============================================================================= def test_parse_yaml_rule_extracts_alertname(minimal_yaml: Path) -> None: """parse_yaml_rules 能正確讀取 alertname list""" from src.services.rule_to_playbook_migrator import parse_yaml_rules rules = parse_yaml_rules(minimal_yaml) assert len(rules) == 3 # 第一條規則的 alertnames alertnames = rules[0]["match"]["alertname"] assert "KubePodCrashLooping" in alertnames assert "PodCrashLoopBackOff" in alertnames def test_parse_yaml_rule_all_fields(minimal_yaml: Path) -> None: """parse_yaml_rules 保留所有欄位""" from src.services.rule_to_playbook_migrator import parse_yaml_rules rules = parse_yaml_rules(minimal_yaml) rule = rules[0] assert rule["id"] == "test_pod_crash" assert rule["priority"] == 60 assert "response" in rule assert rule["response"]["risk"] == "critical" # ============================================================================= # 2. test_migration_creates_draft_playbook # ============================================================================= def test_build_playbook_dict_creates_draft() -> None: """build_playbook_dict 產生 status=draft 的 dict""" from src.services.rule_to_playbook_migrator import build_playbook_dict pb_dict = build_playbook_dict(MINIMAL_RULE_KUBECTL) assert pb_dict["status"] == "draft" assert pb_dict["source"] == "yaml_rule" assert pb_dict["ai_confidence"] == 0.3 assert pb_dict["trust_score"] == 0.3 def test_build_playbook_dict_name_prefix() -> None: """name 格式必須是 'AutoMigrated: {rule_id}'""" from src.services.rule_to_playbook_migrator import build_playbook_dict pb_dict = build_playbook_dict(MINIMAL_RULE_KUBECTL) assert pb_dict["name"] == "AutoMigrated: test_pod_crash" def test_build_playbook_dict_symptom_pattern_alertnames() -> None: """symptom_pattern.alert_names 來自 match.alertname""" from src.services.rule_to_playbook_migrator import build_playbook_dict pb_dict = build_playbook_dict(MINIMAL_RULE_KUBECTL) sp = pb_dict["symptom_pattern"] assert "KubePodCrashLooping" in sp["alert_names"] assert "PodCrashLoopBackOff" in sp["alert_names"] def test_build_playbook_dict_kubectl_action_type() -> None: """kubectl 指令 → action_type=kubectl""" from src.services.rule_to_playbook_migrator import build_playbook_dict pb_dict = build_playbook_dict(MINIMAL_RULE_KUBECTL) step = pb_dict["repair_steps"][0] assert step["action_type"] == "kubectl" assert "kubectl logs" in step["command"] def test_build_playbook_dict_ssh_action_type() -> None: """ssh 指令 → action_type=ssh_command""" from src.services.rule_to_playbook_migrator import build_playbook_dict pb_dict = build_playbook_dict(MINIMAL_RULE_SSH) step = pb_dict["repair_steps"][0] assert step["action_type"] == "ssh_command" assert step["command"].startswith("ssh ") def test_build_playbook_dict_no_action_uses_manual() -> None: """kubectl_command 為空時 → action_type=manual,command 為描述文字""" from src.services.rule_to_playbook_migrator import build_playbook_dict pb_dict = build_playbook_dict(MINIMAL_RULE_NO_ACTION) step = pb_dict["repair_steps"][0] assert step["action_type"] == "manual" assert len(step["command"]) > 0 # 有描述文字 assert step["requires_approval"] is True # ============================================================================= # 3. test_migration_idempotent_on_conflict # ============================================================================= @pytest.mark.asyncio async def test_migration_idempotent_on_conflict(minimal_yaml: Path) -> None: """已存在的 AutoMigrated: name → 跳過,不重複建立""" from src.services.rule_to_playbook_migrator import migrate_yaml_rules_to_playbooks # 模擬 DB 已有全部 3 條規則 existing_names = { "AutoMigrated: test_pod_crash", "AutoMigrated: test_ollama_down", "AutoMigrated: test_no_action", } mock_result = MagicMock() mock_result.fetchall.return_value = [(name,) for name in existing_names] mock_db = AsyncMock() mock_db.execute = AsyncMock(return_value=mock_result) mock_cm = MagicMock() mock_cm.__aenter__ = AsyncMock(return_value=mock_db) mock_cm.__aexit__ = AsyncMock(return_value=False) mock_repo = MagicMock() mock_repo.create = AsyncMock(side_effect=AssertionError("不應呼叫 create")) # patch src.db.base.get_db_context(lazy import 的攔截點) with ( patch("src.db.base.get_db_context", return_value=mock_cm), patch("src.repositories.playbook_repository.get_playbook_repository", return_value=mock_repo), ): report = await migrate_yaml_rules_to_playbooks( yaml_path=minimal_yaml, dry_run=False, enable_migration=True, ) assert report.created == 0 assert report.skipped == 3 assert report.failed == 0 # ============================================================================= # 4. test_migration_dry_run_no_db_write # ============================================================================= @pytest.mark.asyncio async def test_migration_dry_run_no_db_write(minimal_yaml: Path) -> None: """dry_run=True 時不查 DB、不寫 DB""" from src.services.rule_to_playbook_migrator import migrate_yaml_rules_to_playbooks with ( patch("src.db.base.get_db_context") as mock_db_ctx, patch("src.repositories.playbook_repository.get_playbook_repository") as mock_repo, ): report = await migrate_yaml_rules_to_playbooks( yaml_path=minimal_yaml, dry_run=True, enable_migration=True, ) # 不呼叫 DB mock_db_ctx.assert_not_called() mock_repo.assert_not_called() # dry-run 時 created = 規則總數(全部「待建立」) assert report.dry_run is True assert report.total_rules == 3 assert report.created == 3 assert report.failed == 0 # ============================================================================= # 5. test_kubectl_command_validation_via_regex # ============================================================================= def test_infer_action_type_kubectl() -> None: """kubectl 指令 → kubectl""" from src.services.rule_to_playbook_migrator import _infer_action_type assert _infer_action_type("kubectl delete pod foo -n default") == "kubectl" assert _infer_action_type("kubectl rollout restart deployment/api -n awoooi") == "kubectl" def test_infer_action_type_ssh() -> None: """ssh 指令 → ssh_command""" from src.services.rule_to_playbook_migrator import _infer_action_type assert _infer_action_type("ssh {host} 'docker restart minio'") == "ssh_command" assert _infer_action_type("ssh root@192.168.0.111 'systemctl restart ollama'") == "ssh_command" def test_infer_action_type_empty() -> None: """空指令 → manual""" from src.services.rule_to_playbook_migrator import _infer_action_type assert _infer_action_type("") == "manual" assert _infer_action_type(" ") == "manual" # ============================================================================= # 6. test_severity_to_risk_level_mapping # ============================================================================= @pytest.mark.parametrize("risk_str,expected", SEVERITY_TEST_CASES) def test_severity_to_risk_level_mapping(risk_str: str | None, expected: str) -> None: """YAML risk 欄位 → RiskLevel 字串映射""" from src.services.rule_to_playbook_migrator import _infer_risk_level result = _infer_risk_level(risk_str) assert result == expected # ============================================================================= # 7. test_feature_flag_disabled_skips_db_insert # ============================================================================= @pytest.mark.asyncio async def test_feature_flag_disabled_skips_db_insert(minimal_yaml: Path) -> None: """enable_migration=False 時直接 return 空報告,不查 DB""" from src.services.rule_to_playbook_migrator import migrate_yaml_rules_to_playbooks with ( patch("src.db.base.get_db_context") as mock_db_ctx, patch("src.repositories.playbook_repository.get_playbook_repository") as mock_repo, ): report = await migrate_yaml_rules_to_playbooks( yaml_path=minimal_yaml, dry_run=False, enable_migration=False, ) mock_db_ctx.assert_not_called() mock_repo.assert_not_called() assert report.total_rules == 0 assert report.created == 0 assert report.skipped == 0 # ============================================================================= # 8. 整合路徑:1 條已存在 + 2 條新建 # ============================================================================= @pytest.mark.asyncio async def test_migration_partial_existing(minimal_yaml: Path) -> None: """1 條已存在 → skipped=1,2 條新建 → created=2""" from src.services.rule_to_playbook_migrator import migrate_yaml_rules_to_playbooks # 只有第一條已存在 existing_names = {"AutoMigrated: test_pod_crash"} mock_result = MagicMock() mock_result.fetchall.return_value = [(name,) for name in existing_names] mock_db = AsyncMock() mock_db.execute = AsyncMock(return_value=mock_result) mock_cm = MagicMock() mock_cm.__aenter__ = AsyncMock(return_value=mock_db) mock_cm.__aexit__ = AsyncMock(return_value=False) mock_repo = MagicMock() mock_repo.create = AsyncMock(side_effect=lambda pb: pb) with ( patch("src.db.base.get_db_context", return_value=mock_cm), patch("src.repositories.playbook_repository.PlaybookRepository", return_value=mock_repo), patch("src.repositories.playbook_repository.get_playbook_repository", return_value=mock_repo), ): report = await migrate_yaml_rules_to_playbooks( yaml_path=minimal_yaml, dry_run=False, enable_migration=True, ) assert report.skipped == 1 assert report.created == 2 assert report.failed == 0 assert mock_repo.create.call_count == 2 # ============================================================================= # 9. yaml 不存在 → 回傳有錯誤的報告 # ============================================================================= @pytest.mark.asyncio async def test_migration_yaml_not_found(tmp_path: Path) -> None: """yaml 不存在 → 回傳有錯誤的報告,不 raise""" from src.services.rule_to_playbook_migrator import migrate_yaml_rules_to_playbooks nonexistent = tmp_path / "nonexistent.yaml" report = await migrate_yaml_rules_to_playbooks( yaml_path=nonexistent, dry_run=False, enable_migration=True, ) assert report.failed == 0 assert len(report.errors) == 1 assert "不存在" in report.errors[0] # ============================================================================= # 10. symptom_pattern keywords 過濾萬用符 # ============================================================================= def test_build_symptom_pattern_filters_wildcard() -> None: """generic_fallback 的 alertname=['*'] 不應進入 keywords""" from src.services.rule_to_playbook_migrator import _build_symptom_pattern generic_rule = { "id": "generic_fallback", "match": { "alertname": ["*"], "message": ["fallback"], }, "response": {"risk": "medium"}, } sp = _build_symptom_pattern(generic_rule) # alertname=['*'] 進入 alert_names 沒問題(就是萬用符) # keywords 不應含 '*' assert "*" not in sp["keywords"]