refactor(tests): 技術債清零 — 移除 FakeRepo/FakeSession Mock DB 違規
Some checks failed
CD Pipeline / build-and-deploy (push) Failing after 35s

## ai_router.py
- 抽取 _aggregate_feedback_stats() 純函數,feedback_from_aider_events 呼叫它

## aider_event_processor.py
- _process_one 加 _session_factory=None DI 參數(預設 get_session_factory())
- 可注入測試 factory,不改既有生產邏輯

## test_ai_router_feedback.py(完全重寫)
- 移除 FakeRepo/FakeSession,改為直接測試 _aggregate_feedback_stats 純函數
- 新增 test_feedback_skips_missing_model 邊界條件
- DB 失敗降級行為 test 保留(只 patch get_session_factory,無 FakeRepo)

## test_aider_event_processor.py(完全重寫)
- 移除 FakeRepo/FakeSession,改用真實 PostgreSQL(real_factory fixture)
- Redis xack + IncidentEngine 保留 mock(外部 broker/AI 服務,符合例外)
- 每個測試後 rollback,不污染 dev DB

## setup_test_schema.sql
- 補入 aider_events_payload_gin GIN index(與 adr091 生產 migration 一致)

## integration/conftest.py
- 補注解說明密碼名稱 awoooi_prod_2026 的歷史混淆
- 修正 assert 邏輯:檢查 DB 名稱而非 URL 字串,避免密碼含 prod 觸發誤判

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-04-22 01:33:30 +08:00
parent d0591c54b0
commit 479f8d8971
6 changed files with 154 additions and 180 deletions

View File

@@ -689,6 +689,13 @@ class AIRouter:
logger.debug("ai_router_feedback_aggregation_failed")
return {}
return self._aggregate_feedback_stats(stats, repo=repo)
@staticmethod
def _aggregate_feedback_stats(
stats: list[dict], repo: str | None = None
) -> dict[str, float]:
"""純函數:過濾 repo 並將 stats 轉換為 {model: success_rate}(可獨立單元測試)。"""
out: dict[str, float] = {}
for row in stats:
if repo and row.get("repo") != repo:

View File

@@ -119,8 +119,12 @@ class AiderEventProcessor:
logger.exception("aider_processor_loop_error", error=str(e))
await asyncio.sleep(1.0)
async def _process_one(self, stream_key: str, msg_id: Any, data: dict) -> None:
"""處理單筆 messageparse → (maybe) incident → DB write → ACK。"""
async def _process_one(
self, stream_key: str, msg_id: Any, data: dict, _session_factory=None
) -> None:
"""處理單筆 messageparse → (maybe) incident → DB write → ACK。
_session_factory: 可注入測試用 factory預設使用 get_session_factory()。
"""
try:
raw = data.get(b"payload") or data.get("payload")
if isinstance(raw, bytes):
@@ -147,7 +151,7 @@ class AiderEventProcessor:
# 不中斷 — 即使 incident 失敗event 仍要持久化
try:
session_factory = get_session_factory()
session_factory = _session_factory or get_session_factory()
async with session_factory() as session:
repo = AiderEventRepository(session)
await repo.insert(

View File

@@ -23,12 +23,14 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
DEV_DB_URL = os.environ.get(
"TEST_DATABASE_URL",
# 注意:密碼 awoooi_prod_2026 是 PostgreSQL 帳號的實際密碼(歷史命名),
# 並非指向 prod DB — 連線目標為 awoooi_dev開發資料庫
"postgresql+asyncpg://awoooi:awoooi_prod_2026@192.168.0.188:5432/awoooi_dev?ssl=disable",
)
# 確保不會誤打 prod
assert "prod" not in DEV_DB_URL or "awoooi_prod_2026" in DEV_DB_URL, (
"TEST_DATABASE_URL 不可指向 prod DB"
# 確保不會誤打 prod(允許密碼含 "prod" 字串,檢查 DB 名稱)
assert "awoooi_prod" not in DEV_DB_URL.split("/")[-1], (
"TEST_DATABASE_URL 不可指向 prod DBawoooi_prod"
)

View File

@@ -119,3 +119,4 @@ CREATE TABLE IF NOT EXISTS aider_events (
CREATE INDEX IF NOT EXISTS aider_events_session_idx ON aider_events(session_id);
CREATE INDEX IF NOT EXISTS aider_events_type_ts_idx ON aider_events(type, ts DESC);
CREATE INDEX IF NOT EXISTS aider_events_ts_idx ON aider_events(ts DESC);
CREATE INDEX IF NOT EXISTS aider_events_payload_gin ON aider_events USING GIN (payload);

View File

@@ -1,99 +1,65 @@
# apps/api/tests/test_ai_router_feedback.py | 2026-04-20 @ Asia/Taipei
# 2026-04-22 @ Asia/Taipei: FakeRepo / FakeSession 違反 feedback_no_mock_testing.md
# → DB 聚合查詢測試已遷移至 integration/test_ai_router_feedback_integration.py真實 DB
# 此檔案保留的測試驗證「DB 不可用時的降級行為」fail_sf — 此為錯誤路徑邏輯,
# 非正常 DB 查詢,可留作 unit 層覆蓋。
# FakeRepo 測試test_feedback_aggregates_by_model 等)已被 integration test 取代,
# 下方保留作參考,但實際 DB 行為請以 integration test 為準。
"""Task A8: AIRouter.feedback_from_aider_events — 降級行為 + 邊界條件測試。"""
# 2026-04-22 @ Asia/Taipei: 重構移除 FakeRepo/FakeSession違反 feedback_no_mock_testing.md
# 方案:抽取 AIRouter._aggregate_feedback_stats 純函數,直接單元測試,零 DB 依賴。
# DB 聚合查詢行為已由 integration/test_ai_router_feedback_integration.py 覆蓋。
"""Unit tests for AIRouter._aggregate_feedback_stats — 純邏輯,無 DB。"""
import pytest
from unittest.mock import AsyncMock, MagicMock
from src.services.ai_router import AIRouter
@pytest.mark.asyncio
async def test_feedback_aggregates_by_model(monkeypatch):
# =============================================================================
# _aggregate_feedback_stats 純函數測試(無 DB 依賴)
# =============================================================================
def test_feedback_aggregates_by_model():
stats = [
{"repo": "awoooi", "model": "elephant-alpha", "total": 10,
"errors": 2, "success_rate": 0.8},
{"repo": "awoooi", "model": "gemini-pro", "total": 5,
"errors": 0, "success_rate": 1.0},
]
class FakeRepo:
def __init__(self, sess): pass
async def model_stats_since(self, days): return stats
class FakeSession:
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
monkeypatch.setattr("src.services.ai_router.get_session_factory",
lambda: (lambda: FakeSession()), raising=False)
monkeypatch.setattr("src.repositories.aider_event_repository.AiderEventRepository",
FakeRepo)
r = AIRouter()
out = await r.feedback_from_aider_events(days=7)
out = AIRouter._aggregate_feedback_stats(stats)
assert out["elephant-alpha"] == 0.8
assert out["gemini-pro"] == 1.0
@pytest.mark.asyncio
async def test_feedback_filters_by_repo(monkeypatch):
def test_feedback_filters_by_repo():
stats = [
{"repo": "awoooi", "model": "elephant-alpha", "total": 5,
"errors": 1, "success_rate": 0.8},
{"repo": "other-repo", "model": "elephant-alpha", "total": 3,
"errors": 3, "success_rate": 0.0},
]
out = AIRouter._aggregate_feedback_stats(stats, repo="awoooi")
assert out == {"elephant-alpha": 0.8}
class FakeRepo:
def __init__(self, sess): pass
async def model_stats_since(self, days): return stats
class FakeSession:
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
def test_feedback_handles_empty_stats():
out = AIRouter._aggregate_feedback_stats([])
assert out == {}
monkeypatch.setattr("src.services.ai_router.get_session_factory",
lambda: (lambda: FakeSession()), raising=False)
monkeypatch.setattr("src.repositories.aider_event_repository.AiderEventRepository",
FakeRepo)
r = AIRouter()
out = await r.feedback_from_aider_events(repo="awoooi", days=7)
assert out == {"elephant-alpha": 0.8} # other-repo 過濾掉
def test_feedback_skips_missing_model():
stats = [
{"repo": "awoooi", "model": None, "success_rate": 0.9},
{"repo": "awoooi", "model": "gemini-pro", "success_rate": 0.7},
]
out = AIRouter._aggregate_feedback_stats(stats)
assert list(out.keys()) == ["gemini-pro"]
# =============================================================================
# feedback_from_aider_events — DB 失敗降級行為error path無 FakeRepo
# =============================================================================
@pytest.mark.asyncio
async def test_feedback_returns_empty_on_db_failure(monkeypatch):
def fail_sf():
raise RuntimeError("DB unavailable")
monkeypatch.setattr("src.services.ai_router.get_session_factory",
fail_sf, raising=False)
monkeypatch.setattr(
"src.services.ai_router.get_session_factory",
lambda: (_ for _ in ()).throw(RuntimeError("DB unavailable")),
raising=False,
)
r = AIRouter()
out = await r.feedback_from_aider_events(days=7)
assert out == {} # 降級為空 dictcaller 不該崩
@pytest.mark.asyncio
async def test_feedback_handles_empty_stats(monkeypatch):
class FakeRepo:
def __init__(self, sess): pass
async def model_stats_since(self, days): return []
class FakeSession:
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
monkeypatch.setattr("src.services.ai_router.get_session_factory",
lambda: (lambda: FakeSession()), raising=False)
monkeypatch.setattr("src.repositories.aider_event_repository.AiderEventRepository",
FakeRepo)
r = AIRouter()
out = await r.feedback_from_aider_events()
assert out == {}

View File

@@ -1,169 +1,163 @@
# test_aider_event_processor | 2026-04-20 @ Asia/Taipei
# 2026-04-22 @ Asia/Taipei: DB/Redis mock 違反 feedback_no_mock_testing.md
# - FakeRepo / FakeSession → 已遷移至 integration/test_aider_event_repository.py真實 DB
# - fake_r (Redis xack) → 屬外部 broker保留 mock 符合「外部 API 例外」
# - fake_engine (IncidentEngine) → 屬外部 AI 呼叫,保留 mock 符合「外部 API 例外」
# 此檔案保留 _process_one 的 parse / ACK / incident routing 邏輯測試,
# DB 寫入行為已由 integration test 覆蓋。
"""Unit tests for AiderEventProcessor — parse/ACK/incident routing 邏輯。"""
import pytest
# test_aider_event_processor | 2026-04-22 @ Asia/Taipei
# 重構:移除 FakeRepo/FakeSession違反 feedback_no_mock_testing.md
# 方案_process_one 加 _session_factory DI 參數,測試注入真實 asyncpg 連線。
# Redis xack + IncidentEngine 仍 mock外部 broker/AI 服務,符合「外部 API 例外」)。
"""Unit tests for AiderEventProcessor — 真實 DB + mock 外部服務。"""
import json
from datetime import datetime, timezone, timedelta
from unittest.mock import AsyncMock, MagicMock, patch
import os
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock
import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from src.repositories.aider_event_repository import AiderEventRepository
from src.workers.aider_event_processor import AiderEventProcessor
TAIPEI = timezone(timedelta(hours=8))
_DEV_DB_URL = os.environ.get(
"TEST_DATABASE_URL",
"postgresql+asyncpg://awoooi:awoooi_prod_2026@192.168.0.188:5432/awoooi_dev?ssl=disable",
)
def _payload_dict():
"""基本的 aider event payload。"""
return {
# =============================================================================
# 真實 DB session factory fixture每個測試後 rollback
# =============================================================================
@pytest_asyncio.fixture
async def real_factory():
"""提供真實 PostgreSQL session factory測試後 rollback不污染 DB"""
engine = create_async_engine(_DEV_DB_URL, echo=False)
conn = await engine.connect().__aenter__()
await conn.begin()
session = AsyncSession(bind=conn, expire_on_commit=False, autoflush=False)
# 回傳一個永遠回傳同一個 session 的 factory讓 _process_one 正常使用 async with
class _SingleSessionFactory:
def __call__(self):
return _SessionCtx(session)
class _SessionCtx:
def __init__(self, sess): self._sess = sess
async def __aenter__(self): return self._sess
async def __aexit__(self, *a): pass
yield _SingleSessionFactory()
await conn.rollback()
await conn.__aexit__(None, None, None)
await engine.dispose()
def _payload_dict(type_="error"):
base = {
"ts": datetime.now(TAIPEI).isoformat(),
"session_id": "s1", "host": "ogt-mac",
"type": "error",
"session_id": "test-s1", "host": "ogt-mac",
"type": type_,
"payload": {"cwd": "/r", "model": "elephant-alpha",
"kind": "api_rate_limit", "message": "429",
"context_50chars": ""},
}
if type_ == "session_start":
base["payload"] = {"cwd": "/r", "model": "m", "aider_args": [],
"aider_pid": 1, "cli_version": "0"}
return base
# =============================================================================
# Tests — 真實 DB write
# =============================================================================
@pytest.mark.asyncio
async def test_process_one_error_event_creates_incident_and_writes_db(monkeypatch):
"""error event 應建 incident + 寫 DB。"""
# Mock incident engine
async def test_process_one_error_event_creates_incident_and_writes_db(real_factory):
fake_incident = MagicMock()
fake_incident.incident_id = "inc-123"
fake_engine = MagicMock()
fake_engine.process_signal = AsyncMock(return_value=fake_incident)
monkeypatch.setattr("src.workers.aider_event_processor.get_incident_engine",
lambda: fake_engine)
# Mock DB session factory + repo
inserted = {}
class FakeRepo:
def __init__(self, sess): pass
async def insert(self, **kw): inserted.update(kw); return 1
class FakeSession:
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
async def commit(self): pass
monkeypatch.setattr("src.workers.aider_event_processor.AiderEventRepository",
FakeRepo)
monkeypatch.setattr("src.workers.aider_event_processor.get_session_factory",
lambda: (lambda: FakeSession()))
# Mock redis ack
fake_r = MagicMock()
fake_r.xack = AsyncMock()
monkeypatch.setattr("src.workers.aider_event_processor.get_worker_redis",
lambda: fake_r)
# Act
proc = AiderEventProcessor()
payload = _payload_dict()
data = {b"payload": json.dumps(payload).encode()}
await proc._process_one("stream", "1-0", data)
# Patch 外部服務Redis + IncidentEngine保留真實 DB
proc._redis = fake_r
data = {b"payload": json.dumps(_payload_dict("error")).encode()}
import unittest.mock as um
with um.patch("src.workers.aider_event_processor.get_incident_engine", return_value=fake_engine), \
um.patch("src.workers.aider_event_processor.get_worker_redis", return_value=fake_r):
await proc._process_one("stream", "1-0", data, _session_factory=real_factory)
# Assert
assert fake_engine.process_signal.called
assert inserted.get("incident_id") == "inc-123"
assert inserted.get("type_") == "error"
fake_r.xack.assert_called_once()
# 驗證真實 DB 寫入
async with real_factory() as sess:
repo = AiderEventRepository(sess)
count = await repo.count_by_session("test-s1")
assert count == 1
@pytest.mark.asyncio
async def test_process_one_session_start_no_incident(monkeypatch):
"""session_start 不應建 incident但應寫 DB。"""
async def test_process_one_session_start_no_incident(real_factory):
fake_engine = MagicMock()
fake_engine.process_signal = AsyncMock()
monkeypatch.setattr("src.workers.aider_event_processor.get_incident_engine",
lambda: fake_engine)
inserted = {}
class FakeRepo:
def __init__(self, sess): pass
async def insert(self, **kw): inserted.update(kw); return 1
class FakeSession:
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
async def commit(self): pass
monkeypatch.setattr("src.workers.aider_event_processor.AiderEventRepository",
FakeRepo)
monkeypatch.setattr("src.workers.aider_event_processor.get_session_factory",
lambda: (lambda: FakeSession()))
fake_r = MagicMock()
fake_r.xack = AsyncMock()
monkeypatch.setattr("src.workers.aider_event_processor.get_worker_redis",
lambda: fake_r)
# Act
proc = AiderEventProcessor()
payload = _payload_dict()
payload["type"] = "session_start"
payload["payload"] = {"cwd": "/r", "model": "m", "aider_args": [],
"aider_pid": 1, "cli_version": "0"}
data = {b"payload": json.dumps(payload).encode()}
await proc._process_one("stream", "1-0", data)
data = {b"payload": json.dumps(_payload_dict("session_start")).encode()}
import unittest.mock as um
with um.patch("src.workers.aider_event_processor.get_incident_engine", return_value=fake_engine), \
um.patch("src.workers.aider_event_processor.get_worker_redis", return_value=fake_r):
await proc._process_one("stream", "1-0", data, _session_factory=real_factory)
# Assert
assert not fake_engine.process_signal.called # session_start 不建 incident
assert inserted.get("incident_id") is None # DB 依然寫入
assert inserted.get("type_") == "session_start"
fake_r.xack.assert_called_once()
async with real_factory() as sess:
repo = AiderEventRepository(sess)
count = await repo.count_by_session("test-s1")
assert count == 1
@pytest.mark.asyncio
async def test_process_one_malformed_payload_acks_and_skips(monkeypatch, caplog):
"""malformed JSON 應 ACK 避免卡 pending但不建 DB record"""
async def test_process_one_malformed_payload_acks_and_skips(monkeypatch):
"""malformed JSON 應 ACK 避免卡 pending且不觸碰 DB"""
fake_r = MagicMock()
fake_r.xack = AsyncMock()
monkeypatch.setattr("src.workers.aider_event_processor.get_worker_redis",
lambda: fake_r)
# Act
proc = AiderEventProcessor()
data = {b"payload": b"this is not json"}
await proc._process_one("stream", "1-0", data)
# Assert
fake_r.xack.assert_called_once() # 壞 payload ACK 避免卡 pending
fake_r.xack.assert_called_once()
@pytest.mark.asyncio
async def test_incident_failure_still_writes_db(monkeypatch):
async def test_incident_failure_still_writes_db(real_factory):
"""incident engine 壞掉時event 仍要進 DB不丟資料"""
fake_engine = MagicMock()
fake_engine.process_signal = AsyncMock(side_effect=RuntimeError("engine down"))
monkeypatch.setattr("src.workers.aider_event_processor.get_incident_engine",
lambda: fake_engine)
inserted = {}
class FakeRepo:
def __init__(self, sess): pass
async def insert(self, **kw): inserted.update(kw); return 1
class FakeSession:
async def __aenter__(self): return self
async def __aexit__(self, *a): return False
async def commit(self): pass
monkeypatch.setattr("src.workers.aider_event_processor.AiderEventRepository",
FakeRepo)
monkeypatch.setattr("src.workers.aider_event_processor.get_session_factory",
lambda: (lambda: FakeSession()))
fake_r = MagicMock()
fake_r.xack = AsyncMock()
monkeypatch.setattr("src.workers.aider_event_processor.get_worker_redis",
lambda: fake_r)
# Act
proc = AiderEventProcessor()
payload = _payload_dict()
data = {b"payload": json.dumps(payload).encode()}
await proc._process_one("stream", "1-0", data)
data = {b"payload": json.dumps(_payload_dict("error")).encode()}
# Assert
assert inserted.get("type_") == "error"
assert inserted.get("incident_id") is None # engine 壞,無 id
fake_r.xack.assert_called_once() # 仍 ACK
import unittest.mock as um
with um.patch("src.workers.aider_event_processor.get_incident_engine", return_value=fake_engine), \
um.patch("src.workers.aider_event_processor.get_worker_redis", return_value=fake_r):
await proc._process_one("stream", "1-0", data, _session_factory=real_factory)
fake_r.xack.assert_called_once() # 仍 ACK
async with real_factory() as sess:
repo = AiderEventRepository(sess)
count = await repo.count_by_session("test-s1")
assert count == 1 # DB 依然寫入,即使 incident engine 壞了