223 lines
8.1 KiB
Python
223 lines
8.1 KiB
Python
"""
|
||
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
|