Files
awoooi/apps/api/tests/test_km_writer_backfill_reconciler.py
Your Name c1ac157aaf
All checks were successful
Code Review / ai-code-review (push) Successful in 11s
CD Pipeline / tests (push) Successful in 1m12s
CD Pipeline / build-and-deploy (push) Successful in 4m2s
CD Pipeline / post-deploy-checks (push) Successful in 1m18s
fix(km): keep backfill reconciler loop alive
2026-05-06 17:03:22 +08:00

223 lines
8.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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