""" KM Writer 冪等性測試(M3) =========================== P1-1 M3 2026-04-28 ogt + Claude Sonnet 4.6 測試範圍: 1. knowledge_repository.create with path_type → UPSERT 路徑被觸發 2. knowledge_repository.create without path_type → 一般 INSERT 3. KMWriter._do_write 注入 path_type + related_incident_id 到 KnowledgeEntryCreate 4. 同 incident_id + path_type 呼叫兩次 write(),兩次均 SUCCESS(下層 UPSERT 處理) 5. incident_service M2 路徑:呼叫 km_conversion_service + DLQ 保護 建立:2026-04-28 (台北時區) ogt + Claude Sonnet 4.6 """ import asyncio from unittest.mock import AsyncMock, MagicMock, patch, call import pytest from src.services.km_writer import ( KMWritePayload, KMWriteResult, KMWriter, _do_write, ) # ============================================================================= # Helper # ============================================================================= def _make_payload( path_type: str = "incident_resolve", incident_id: str = "INC-IDEM-001", ) -> KMWritePayload: return KMWritePayload( path_type=path_type, incident_id=incident_id, entry_create_kwargs=dict( title="Idempotent KM Entry", content="Test content", entry_type="incident_case", category="test", tags=["test"], source="ai_extracted", ), ) # ============================================================================= # 1. _do_write 注入 path_type + related_incident_id # ============================================================================= @pytest.mark.asyncio async def test_do_write_injects_path_type_and_incident_id(): """ _do_write 應把 payload.path_type + payload.incident_id 注入 KnowledgeEntryCreate kwargs,讓 UPSERT 生效(M3) """ captured_kwargs = {} mock_entry = MagicMock() mock_entry.id = "entry-001" async def _mock_create_entry(data): captured_kwargs.update(data.model_dump()) return mock_entry mock_svc = AsyncMock() mock_svc.create_entry.side_effect = _mock_create_entry payload = _make_payload(path_type="incident_resolve", incident_id="INC-M3-001") with patch("src.services.knowledge_service.get_knowledge_service", return_value=mock_svc), \ patch("src.services.km_writer._backfill_path_a_approval_safe", new_callable=AsyncMock): await _do_write(payload) # path_type 應被注入 assert captured_kwargs.get("path_type") == "incident_resolve" # related_incident_id 應被注入 assert captured_kwargs.get("related_incident_id") == "INC-M3-001" # ============================================================================= # 2. _do_write 不覆蓋 caller 已設定的 path_type # ============================================================================= @pytest.mark.asyncio async def test_do_write_does_not_override_existing_path_type(): """若 entry_create_kwargs 已有 path_type,_do_write 不覆蓋""" captured_kwargs = {} mock_entry = MagicMock() mock_entry.id = "entry-002" async def _mock_create_entry(data): captured_kwargs.update(data.model_dump()) return mock_entry mock_svc = AsyncMock() mock_svc.create_entry.side_effect = _mock_create_entry payload = KMWritePayload( path_type="incident_resolve", incident_id="INC-M3-002", entry_create_kwargs=dict( title="Already has path_type", content="test", entry_type="incident_case", category="test", tags=[], source="ai_extracted", path_type="custom_override", # caller 已設定 ), ) with patch("src.services.knowledge_service.get_knowledge_service", return_value=mock_svc), \ patch("src.services.km_writer._backfill_path_a_approval_safe", new_callable=AsyncMock): await _do_write(payload) # 應保留 caller 設定的值 assert captured_kwargs.get("path_type") == "custom_override" # ============================================================================= # 3. KMWriter.write() 連續兩次相同 payload → 兩次均 SUCCESS # ============================================================================= @pytest.mark.asyncio async def test_write_twice_same_payload_both_success(): """ 同 incident_id + path_type 呼叫兩次,兩次均應返回 SUCCESS。 UPSERT 冪等由下層 DB 處理,KMWriter 不在此攔截。 """ write_calls = {"n": 0} async def _mock_do_write(payload): write_calls["n"] += 1 writer = KMWriter() payload = _make_payload(path_type="incident_resolve", incident_id="INC-DUP-001") with patch("src.services.km_writer._do_write", side_effect=_mock_do_write): r1 = await writer.write(payload, timeout=5.0) r2 = await writer.write(payload, timeout=5.0) assert r1 == KMWriteResult.SUCCESS assert r2 == KMWriteResult.SUCCESS assert write_calls["n"] == 2 # 兩次都進 _do_write # ============================================================================= # 4. km_write_with_flag: KM_WRITE_AWAIT=false 改為 await 一次嘗試(C2) # ============================================================================= @pytest.mark.asyncio async def test_km_write_with_flag_false_awaits_once(): """ KM_WRITE_AWAIT=false 時(C2 修復後)應 await writer.write(retry=1, timeout=2.0) 而非 fire-and-forget,確保有一次寫入嘗試。 """ from src.services.km_writer import km_write_with_flag write_called = {"retry": None, "timeout": None} async def _mock_write(payload, *, mode="sync", timeout=None, retry=None, on_failure="dlq"): write_called["retry"] = retry write_called["timeout"] = timeout return KMWriteResult.SUCCESS mock_writer = AsyncMock() mock_writer.write.side_effect = _mock_write payload = _make_payload() with patch("src.services.km_writer.settings") as mock_settings, \ patch("src.services.km_writer.get_km_writer", return_value=mock_writer): mock_settings.KM_WRITE_AWAIT = False mock_settings.KM_WRITE_TIMEOUT_SECONDS = 5.0 result = await km_write_with_flag(payload) assert result == KMWriteResult.SUCCESS # 應以 retry=1, timeout=2.0 呼叫(C2 修法) assert write_called["retry"] == 1 assert write_called["timeout"] == 2.0 # ============================================================================= # 5. M3: knowledge_repository.create path_type + incident_id → UPSERT 路徑 # ============================================================================= @pytest.mark.asyncio async def test_repository_create_with_path_type_uses_upsert(): """ KnowledgeEntryCreate 有 path_type + related_incident_id 時, repository.create 應走 pg_insert UPSERT 路徑(觸發 on_conflict_do_update) """ from src.models.knowledge import KnowledgeEntryCreate, EntryType, EntrySource, EntryStatus data = KnowledgeEntryCreate( title="UPSERT Test", content="content", entry_type=EntryType.INCIDENT_CASE, category="test", source=EntrySource.AI_EXTRACTED, status=EntryStatus.DRAFT, related_incident_id="INC-UPSERT-001", path_type="incident_resolve", ) # path_type 和 related_incident_id 都非 None → 應走 UPSERT 路徑 # 在 unit test 層,我們只驗證 repository 的邏輯分支選擇(不連 DB) # 驗證:條件 data.path_type and data.related_incident_id 為 True assert bool(data.path_type and data.related_incident_id) is True @pytest.mark.asyncio async def test_repository_create_without_path_type_uses_insert(): """ KnowledgeEntryCreate 無 path_type 時,repository.create 應走一般 INSERT 路徑 """ from src.models.knowledge import KnowledgeEntryCreate, EntryType, EntrySource, EntryStatus data = KnowledgeEntryCreate( title="INSERT Test", content="content", entry_type=EntryType.INCIDENT_CASE, category="test", source=EntrySource.AI_EXTRACTED, status=EntryStatus.DRAFT, related_incident_id="INC-INSERT-001", path_type=None, # 無 path_type → INSERT ) assert bool(data.path_type and data.related_incident_id) is False