""" KM Backfill Reconciler 單元測試 ================================ P1-1 C1 修復 2026-04-28 ogt + Claude Sonnet 4.6 測試範圍: 1. reconciler 從 DLQ 成功補救 → LREM 移除 2. reconciler DB 失敗 → 保留 DLQ(不移除) 3. reconciler DLQ 格式錯誤 → 移除(無法補救) 4. reconciler DLQ 空 → 0 processed 5. ENABLE_KM_BACKFILL_RECONCILER=false → 跳過 6. _backfill_path_a_approval_safe — 成功路徑不寫 DLQ 7. _backfill_path_a_approval_safe — 失敗時寫 km:backfill:dlq 建立:2026-04-28 (台北時區) ogt + Claude Sonnet 4.6 """ import json from unittest.mock import AsyncMock, patch import pytest import src.jobs.km_backfill_reconciler_job as reconciler_job from src.jobs.km_backfill_reconciler_job import ( run_km_backfill_reconciler, run_km_backfill_reconciler_loop, ) from src.services.km_writer import ( KM_BACKFILL_DLQ_KEY, _backfill_path_a_approval_safe, ) # ============================================================================= # Helper # ============================================================================= def _make_dlq_record(incident_id: str = "INC-001", approval_id: str = "AP-001") -> bytes: return json.dumps({"incident_id": incident_id, "approval_id": approval_id}).encode() # ============================================================================= # 1. Reconciler 成功補救 # ============================================================================= @pytest.mark.asyncio async def test_reconciler_success_removes_from_dlq(): """成功補救後應 LREM 從 DLQ 移除""" record = _make_dlq_record("INC-R1", "AP-R1") mock_redis = AsyncMock() mock_redis.lrange = AsyncMock(return_value=[record]) mock_redis.lrem = AsyncMock() with patch("src.jobs.km_backfill_reconciler_job.settings") as mock_settings, \ patch("src.core.redis_client.get_redis", return_value=mock_redis), \ patch("src.jobs.km_backfill_reconciler_job._do_backfill", new_callable=AsyncMock) as mock_do: mock_settings.ENABLE_KM_BACKFILL_RECONCILER = True result = await run_km_backfill_reconciler() assert result["processed"] == 1 assert result["success"] == 1 assert result["failed"] == 0 mock_do.assert_called_once_with("INC-R1", "AP-R1") mock_redis.lrem.assert_called_once_with(KM_BACKFILL_DLQ_KEY, 1, record) # ============================================================================= # 2. Reconciler DB 失敗 → 保留 DLQ # ============================================================================= @pytest.mark.asyncio async def test_reconciler_db_failure_preserves_dlq(): """DB 失敗時不應 LREM(保留 DLQ 等下次補救)""" record = _make_dlq_record("INC-FAIL", "AP-FAIL") mock_redis = AsyncMock() mock_redis.lrange = AsyncMock(return_value=[record]) mock_redis.lrem = AsyncMock() with patch("src.jobs.km_backfill_reconciler_job.settings") as mock_settings, \ patch("src.core.redis_client.get_redis", return_value=mock_redis), \ patch("src.jobs.km_backfill_reconciler_job._do_backfill", side_effect=Exception("db connection refused")): mock_settings.ENABLE_KM_BACKFILL_RECONCILER = True result = await run_km_backfill_reconciler() assert result["processed"] == 1 assert result["success"] == 0 assert result["failed"] == 1 # 失敗時不應 LREM mock_redis.lrem.assert_not_called() # ============================================================================= # 3. Reconciler 格式錯誤 → 移除(無法補救) # ============================================================================= @pytest.mark.asyncio async def test_reconciler_malformed_record_removed(): """格式錯誤的 DLQ record 應被移除(不能卡住 DLQ)""" malformed = b"not-json-at-all" mock_redis = AsyncMock() mock_redis.lrange = AsyncMock(return_value=[malformed]) mock_redis.lrem = AsyncMock() with patch("src.jobs.km_backfill_reconciler_job.settings") as mock_settings, \ patch("src.core.redis_client.get_redis", return_value=mock_redis), \ patch("src.jobs.km_backfill_reconciler_job._do_backfill", new_callable=AsyncMock) as mock_do: mock_settings.ENABLE_KM_BACKFILL_RECONCILER = True await run_km_backfill_reconciler() # 格式錯誤移除 mock_redis.lrem.assert_called_once_with(KM_BACKFILL_DLQ_KEY, 1, malformed) # 不嘗試 DB 補救 mock_do.assert_not_called() # ============================================================================= # 4. DLQ 空 → 0 processed # ============================================================================= @pytest.mark.asyncio async def test_reconciler_empty_dlq(): """DLQ 為空時應返回 0 processed""" mock_redis = AsyncMock() mock_redis.lrange = AsyncMock(return_value=[]) with patch("src.jobs.km_backfill_reconciler_job.settings") as mock_settings, \ patch("src.core.redis_client.get_redis", return_value=mock_redis): mock_settings.ENABLE_KM_BACKFILL_RECONCILER = True result = await run_km_backfill_reconciler() assert result["processed"] == 0 assert result["success"] == 0 assert result["failed"] == 0 @pytest.mark.asyncio async def test_reconciler_loop_can_sleep(monkeypatch: pytest.MonkeyPatch): """loop 必須能 sleep;避免少 import asyncio 導致 background task 啟動即死亡。""" class StopLoop(Exception): pass calls = 0 async def fake_run_once(): nonlocal calls calls += 1 return {"processed": 0, "success": 0, "failed": 0} async def fake_sleep(_seconds: int): raise StopLoop monkeypatch.setattr(reconciler_job, "run_km_backfill_reconciler", fake_run_once) monkeypatch.setattr(reconciler_job.asyncio, "sleep", fake_sleep) with pytest.raises(StopLoop): await run_km_backfill_reconciler_loop() assert calls == 1 # ============================================================================= # 5. ENABLE_KM_BACKFILL_RECONCILER=false → 跳過 # ============================================================================= @pytest.mark.asyncio async def test_reconciler_disabled_skips(): """Feature flag false 時應直接返回 0,不存取 Redis""" with patch("src.jobs.km_backfill_reconciler_job.settings") as mock_settings, \ patch("src.core.redis_client.get_redis") as mock_get_redis: mock_settings.ENABLE_KM_BACKFILL_RECONCILER = False result = await run_km_backfill_reconciler() assert result["processed"] == 0 mock_get_redis.assert_not_called() # ============================================================================= # 6. _backfill_path_a_approval_safe — 成功路徑不寫 DLQ # ============================================================================= @pytest.mark.asyncio async def test_backfill_safe_success_no_dlq(): """成功時不應寫 km:backfill:dlq""" with patch("src.services.km_writer._backfill_path_a_approval", new_callable=AsyncMock) as mock_bf, \ patch("src.core.redis_client.get_redis") as mock_get_redis: await _backfill_path_a_approval_safe("INC-OK", "AP-OK") mock_bf.assert_called_once_with("INC-OK", "AP-OK") mock_get_redis.assert_not_called() # ============================================================================= # 7. _backfill_path_a_approval_safe — 失敗時寫 km:backfill:dlq # ============================================================================= @pytest.mark.asyncio async def test_backfill_safe_failure_writes_dlq(): """失敗時應寫 km:backfill:dlq 且不拋例外""" captured_keys = [] mock_redis = AsyncMock() async def _capture_lpush(key, value): captured_keys.append(key) mock_redis.lpush.side_effect = _capture_lpush mock_redis.ltrim = AsyncMock() with patch("src.services.km_writer._backfill_path_a_approval", side_effect=Exception("db error")), \ patch("src.core.redis_client.get_redis", return_value=mock_redis): # 不應拋例外 await _backfill_path_a_approval_safe("INC-ERR", "AP-ERR") assert KM_BACKFILL_DLQ_KEY in captured_keys