""" KM → Playbook 互饋回路單元測試 ================================ W2 PR-L1: 飛輪斷鏈 C3 + C4 修復測試 測試範圍: 1. test_playbook_promotion_writes_km_entry — _promote_playbook 觸發後,KMWriter 被呼叫寫 playbook_evolution 條目 2. test_playbook_demotion_writes_km_entry — _demote_playbook 觸發後,KMWriter 被呼叫寫 playbook_evolution 條目 3. test_km_accumulation_triggers_playbook_review — 同 symptoms_hash 累積 5 條 → UPDATE playbooks.review_required=true 4. test_km_accumulation_below_threshold_no_update — KM 條目 < threshold → 不執行 UPDATE 5. test_playbook_deprecated_demotes_alert_rule_confidence — DEPRECATED Playbook → alert_rule_catalog.confidence *= 0.5 6. test_feature_flag_disabled — ENABLE_KM_PLAYBOOK_FEEDBACK_LOOP=false → 三條邏輯全部跳過,不呼叫 DB 設計原則: - 外部服務(DB / KMWriter / PlaybookRepository)以 AsyncMock 替換 - 每個 test 只測一條主路徑(單一職責) - Feature flag 透過 patch 'src.core.config.settings' 控制 - get_db_context patch 路徑:src.db.base.get_db_context(local import 的來源模組) - get_playbook_repository patch 路徑: src.repositories.playbook_repository.get_playbook_repository 建立:2026-04-28 (台北時區) ogt + Claude Sonnet 4.6 """ from __future__ import annotations from contextlib import asynccontextmanager from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch import pytest # ============================================================================= # Helpers # ============================================================================= def _make_playbook( playbook_id: str = "PB-20260428-AAAAAA", name: str = "TestPlaybook", trust_score: float = 0.5, success_count: int = 3, failure_count: int = 1, status: str = "approved", alert_names: list[str] | None = None, ) -> SimpleNamespace: """ 建立一個最小可用的 Playbook mock 物件。 使用 SimpleNamespace 讓屬性存取與 Pydantic model 相同, 但不引入真實 ORM / Pydantic 依賴(防止 DB 連線)。 symptom_pattern.compute_hash() 返回固定 'abc123' 供測試使用。 """ symptom = SimpleNamespace( alert_names=alert_names or ["HighCpuUsage"], affected_services=["api"], label_patterns={}, compute_hash=lambda: "abc123", ) from src.models.playbook import PlaybookStatus status_enum = PlaybookStatus(status) return SimpleNamespace( playbook_id=playbook_id, name=name, trust_score=trust_score, success_count=success_count, failure_count=failure_count, status=status_enum, symptom_pattern=symptom, ) def _make_learning_service(): """ 建立 LearningService 實例,所有外部依賴 mock 掉。 repository 和 trust_repository 均使用 AsyncMock 防止 Redis 連線。 """ from src.services.learning_service import LearningService mock_repo = AsyncMock() mock_trust_repo = AsyncMock() mock_trust_mgr = MagicMock() mock_trust_mgr.get_trust_record.return_value = None svc = LearningService( repository=mock_repo, trust_repository=mock_trust_repo, ) svc._trust_manager = mock_trust_mgr return svc def _make_settings(enable_loop: bool = True, threshold: int = 5) -> MagicMock: """ 建立 settings mock。 patch 路徑:src.core.config.settings(learning_service 各方法均 local import 自此模組) """ m = MagicMock() m.ENABLE_KM_PLAYBOOK_FEEDBACK_LOOP = enable_loop m.KM_PLAYBOOK_REVIEW_THRESHOLD = threshold m.KM_WRITE_AWAIT = True m.KM_WRITE_TIMEOUT_SECONDS = 5.0 return m def _make_db_context_factory(mock_db): """ 返回一個可多次呼叫的 async context manager factory。 每次呼叫 factory() 返回新的 async context manager 實例, 防止同一 cm 物件被複用(async generator 只能迭代一次)。 """ def factory(): @asynccontextmanager async def _ctx(): yield mock_db return _ctx() return factory # ============================================================================= # 1. Promote 觸發 → 寫 KM 演化條目 # ============================================================================= @pytest.mark.asyncio async def test_playbook_promotion_writes_km_entry(): """ _promote_playbook 觸發後,若 ENABLE_KM_PLAYBOOK_FEEDBACK_LOOP=True, km_write_with_flag 應被呼叫一次,path_type 含 'playbook_evolution'。 """ svc = _make_learning_service() playbook = _make_playbook(trust_score=0.5, status="approved") km_calls: list = [] async def _mock_km_write(payload, *, timeout=None): km_calls.append(payload) from src.services.km_writer import KMWriteResult return KMWriteResult.SUCCESS mock_pb_repo = AsyncMock() mock_pb_repo.find_by_source_incident = AsyncMock(return_value=[playbook]) mock_pb_repo.adjust_confidence = AsyncMock(return_value=True) mock_settings = _make_settings(enable_loop=True) with ( patch("src.core.config.settings", mock_settings), patch("src.services.km_writer.km_write_with_flag", side_effect=_mock_km_write), patch( "src.repositories.playbook_repository.get_playbook_repository", return_value=mock_pb_repo, ), ): result = await svc._promote_playbook("INC-TEST-001") assert result is True assert len(km_calls) == 1, "KMWriter 應被呼叫一次(一個 Playbook promote)" assert "playbook_evolution" in km_calls[0].path_type assert km_calls[0].metadata["evolution_type"] == "promote" assert km_calls[0].metadata["playbook_id"] == playbook.playbook_id assert km_calls[0].metadata["previous_trust"] == 0.5 # ============================================================================= # 2. Demote 觸發 → 寫 KM 演化條目 # ============================================================================= @pytest.mark.asyncio async def test_playbook_demotion_writes_km_entry(): """ _demote_playbook 觸發後,若 ENABLE_KM_PLAYBOOK_FEEDBACK_LOOP=True, km_write_with_flag 應被呼叫一次,evolution_type='demote'。 status='approved'(非 DEPRECATED)→ 邏輯 3 不觸發,保持單一職責。 """ svc = _make_learning_service() playbook = _make_playbook(trust_score=0.4, status="approved") km_calls: list = [] async def _mock_km_write(payload, *, timeout=None): km_calls.append(payload) from src.services.km_writer import KMWriteResult return KMWriteResult.SUCCESS mock_pb_repo = AsyncMock() mock_pb_repo.find_by_source_incident = AsyncMock(return_value=[playbook]) mock_pb_repo.adjust_confidence = AsyncMock(return_value=True) mock_settings = _make_settings(enable_loop=True) with ( patch("src.core.config.settings", mock_settings), patch("src.services.km_writer.km_write_with_flag", side_effect=_mock_km_write), patch( "src.repositories.playbook_repository.get_playbook_repository", return_value=mock_pb_repo, ), ): result = await svc._demote_playbook("INC-TEST-002") assert result is True assert len(km_calls) == 1, "KMWriter 應被呼叫一次(一個 Playbook demote)" assert "playbook_evolution" in km_calls[0].path_type assert km_calls[0].metadata["evolution_type"] == "demote" # ============================================================================= # 3. KM 累積 N=5 → review_required=True # ============================================================================= @pytest.mark.asyncio async def test_km_accumulation_triggers_playbook_review(): """ 同 symptoms_hash 的 KM 條目達到 threshold(預設 5)時, _check_and_mark_playbook_review 應執行 COUNT + UPDATE,並 commit。 """ svc = _make_learning_service() symptoms_hash = "abc123" mock_db = AsyncMock() execute_call_count = {"n": 0} mock_count_result = MagicMock() mock_count_result.scalar.return_value = 5 mock_update_result = MagicMock() mock_update_result.fetchall.return_value = [("PB-20260428-AAAAAA",)] async def _multi_execute(stmt, params=None): execute_call_count["n"] += 1 if execute_call_count["n"] == 1: return mock_count_result return mock_update_result mock_db.execute = _multi_execute mock_db.commit = AsyncMock() mock_settings = _make_settings(enable_loop=True, threshold=5) with ( patch("src.core.config.settings", mock_settings), patch( "src.db.base.get_db_context", side_effect=_make_db_context_factory(mock_db), ), ): await svc._check_and_mark_playbook_review(symptoms_hash) assert execute_call_count["n"] == 2, "應執行兩次 SQL(COUNT + UPDATE)" mock_db.commit.assert_called_once() @pytest.mark.asyncio async def test_km_accumulation_below_threshold_no_update(): """ KM 條目數 < threshold → 不執行 UPDATE,不 commit。 """ svc = _make_learning_service() symptoms_hash = "abc123" mock_db = AsyncMock() execute_call_count = {"n": 0} mock_count_result = MagicMock() mock_count_result.scalar.return_value = 3 # < 5 async def _single_execute(stmt, params=None): execute_call_count["n"] += 1 return mock_count_result mock_db.execute = _single_execute mock_db.commit = AsyncMock() mock_settings = _make_settings(enable_loop=True, threshold=5) with ( patch("src.core.config.settings", mock_settings), patch( "src.db.base.get_db_context", side_effect=_make_db_context_factory(mock_db), ), ): await svc._check_and_mark_playbook_review(symptoms_hash) assert execute_call_count["n"] == 1, "只執行 COUNT,不執行 UPDATE" mock_db.commit.assert_not_called() # ============================================================================= # 4. DEPRECATED → alert_rule_catalog.confidence *= 0.5 # ============================================================================= @pytest.mark.asyncio async def test_playbook_deprecated_demotes_alert_rule_confidence(): """ DEPRECATED Playbook 的 _demote_alert_rule_catalog_confidence 執行後, 每個 alert_name 執行一次 UPDATE,最後 commit 一次。 """ svc = _make_learning_service() from src.models.playbook import PlaybookStatus playbook = _make_playbook( status="deprecated", alert_names=["HighCpuUsage", "PodCrashLooping"], ) playbook.status = PlaybookStatus.DEPRECATED mock_db = AsyncMock() execute_call_count = {"n": 0} async def _track_execute(stmt, params=None): execute_call_count["n"] += 1 m = MagicMock() m.rowcount = 1 return m mock_db.execute = _track_execute mock_db.commit = AsyncMock() mock_settings = _make_settings(enable_loop=True) with ( patch("src.core.config.settings", mock_settings), patch( "src.db.base.get_db_context", side_effect=_make_db_context_factory(mock_db), ), ): await svc._demote_alert_rule_catalog_confidence(playbook) assert execute_call_count["n"] == 2, "2 條 alert_names → 2 次 UPDATE" mock_db.commit.assert_called_once() # ============================================================================= # 5. Feature flag disabled → 所有邏輯跳過 # ============================================================================= @pytest.mark.asyncio async def test_feature_flag_disabled(): """ ENABLE_KM_PLAYBOOK_FEEDBACK_LOOP=False 時, _write_playbook_evolution_km / _check_and_mark_playbook_review / _demote_alert_rule_catalog_confidence 均不應呼叫任何 DB 或 KMWriter。 """ svc = _make_learning_service() from src.models.playbook import PlaybookStatus playbook = _make_playbook(trust_score=0.3, status="deprecated") playbook.status = PlaybookStatus.DEPRECATED km_write_calls: list = [] db_execute_calls: list = [] async def _mock_km_write(payload, *, timeout=None): km_write_calls.append(payload) from src.services.km_writer import KMWriteResult return KMWriteResult.SUCCESS mock_db = AsyncMock() async def _track_execute(stmt, params=None): db_execute_calls.append(stmt) return MagicMock() mock_db.execute = _track_execute mock_db.commit = AsyncMock() mock_settings = _make_settings(enable_loop=False) with ( patch("src.core.config.settings", mock_settings), patch("src.services.km_writer.km_write_with_flag", side_effect=_mock_km_write), patch( "src.db.base.get_db_context", side_effect=_make_db_context_factory(mock_db), ), ): # 邏輯 1 await svc._write_playbook_evolution_km( playbook=playbook, previous_trust=0.5, evolution_type="promote", incident_id="INC-TEST-FLAG", ) # 邏輯 2 await svc._check_and_mark_playbook_review("abc123") # 邏輯 3 await svc._demote_alert_rule_catalog_confidence(playbook) assert len(km_write_calls) == 0, "KMWriter 不應被呼叫(flag=False)" assert len(db_execute_calls) == 0, "DB execute 不應被呼叫(flag=False)" mock_db.commit.assert_not_called()