test(voice-clone): 为 cleanup_stale_processing 补单测,满足增量覆盖率门槛
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 0s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 52s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 54s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m19s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 1m46s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m16s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m30s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m6s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 3m10s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 6m18s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 3m15s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Successful in 6m44s

新增 5 个用例覆盖:
- 无卡死记录不 commit
- 单条超时记录标 failed + 错误信息含分钟数
- 自定义 timeout 反映在错误信息中
- 多条记录批量清理且只 commit 一次
- query 链路包含 status/updated_at 双过滤条件

使用 sys.modules 预注入假模型类,避免 SQLAlchemy/DB 依赖。
This commit is contained in:
saas-backend
2026-09-26 15:13:06 +08:00
parent c4150311a6
commit 08a52c7bba
+153
View File
@@ -0,0 +1,153 @@
"""SQLAlchemyVoiceCloneProfileRepository.cleanup_stale_processing 单元测试。
通过 monkeypatch sys.modules['packages.adapters.sqlalchemy_impl.models'],
注入一个具备 SQLAlchemy 列比较语义(== / < 返回可链式 .all() 的 mock)的假模型类,
不依赖真实 DB,也不会触发 SQLAlchemy 映射。
"""
from __future__ import annotations
import sys
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
class _Col:
"""模拟 SQLAlchemy Column:比较运算返回 MagicMock,可被 filter 链式调用。"""
def __init__(self, name: str):
self._name = name
def __eq__(self, other): # type: ignore[override]
return MagicMock(name=f"{self._name}=={other!r}")
def __ne__(self, other): # type: ignore[override]
return MagicMock(name=f"{self._name}!={other!r}")
def __lt__(self, other):
return MagicMock(name=f"{self._name}<{other!r}")
def __gt__(self, other):
return MagicMock(name=f"{self._name}>{other!r}")
def __le__(self, other):
return MagicMock(name=f"{self._name}<={other!r}")
def __ge__(self, other):
return MagicMock(name=f"{self._name}>={other!r}")
def __hash__(self):
return id(self)
class _FakeVoiceCloneProfileModel:
"""假模型:类属性是 _Col;实例上可读写 status/error_message/updated_at。"""
status = _Col("status")
updated_at = _Col("updated_at")
id = _Col("id")
error_message = _Col("error_message")
def __init__(self, **kwargs):
self.__dict__.update(kwargs)
# ── 预注入 mock 模型模块,避免真实 import 拉起 DB / SQLAlchemy 映射 ──
_fake_models = SimpleNamespace(VoiceCloneProfileModel=_FakeVoiceCloneProfileModel)
sys.modules.setdefault("packages.adapters.sqlalchemy_impl.models", _fake_models)
if "packages.adapters.sqlalchemy_impl.voice_clone_profile_repository" in sys.modules:
mod = sys.modules["packages.adapters.sqlalchemy_impl.voice_clone_profile_repository"]
mod.VoiceCloneProfileModel = _FakeVoiceCloneProfileModel # type: ignore[attr-defined]
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import (
SQLAlchemyVoiceCloneProfileRepository,
)
def _make_fake_row(
*,
status: str = "processing",
updated_at: datetime | None = None,
error_message: str = "",
) -> _FakeVoiceCloneProfileModel:
return _FakeVoiceCloneProfileModel(
status=status,
error_message=error_message,
updated_at=updated_at or datetime.now(UTC),
)
def _make_repo(fake_rows: list[_FakeVoiceCloneProfileModel]):
"""构造 repo + mock session。
生产代码使用 .query(Model).filter(A, B).all()(一次 filter,两个表达式参数)。
"""
session = MagicMock()
filtered = MagicMock()
filtered.all.return_value = list(fake_rows)
session.query.return_value.filter.return_value = filtered
repo = SQLAlchemyVoiceCloneProfileRepository.__new__(SQLAlchemyVoiceCloneProfileRepository)
repo.session = session
return repo, session
class TestCleanupStaleProcessing:
"""cleanup_stale_processing 行为测试。"""
def test_no_stale_records_returns_zero_and_no_commit(self):
"""无卡死记录时返回 0,不调用 commit。"""
repo, session = _make_repo([])
assert repo.cleanup_stale_processing() == 0
session.commit.assert_not_called()
def test_stale_record_marked_failed_with_timeout_message(self):
"""超时 processing 记录被标记为 failed,错误信息包含超时分钟数。"""
old = _make_fake_row(updated_at=datetime.now(UTC) - timedelta(minutes=15))
repo, session = _make_repo([old])
count = repo.cleanup_stale_processing(timeout_minutes=10)
assert count == 1
assert old.status == "failed"
assert "超时" in old.error_message
assert "10" in old.error_message
session.commit.assert_called_once()
def test_error_message_reflects_custom_timeout(self):
"""自定义 timeout_minutes 会反映在错误信息里。"""
old = _make_fake_row(updated_at=datetime.now(UTC) - timedelta(hours=1))
repo, _session = _make_repo([old])
repo.cleanup_stale_processing(timeout_minutes=5)
assert old.status == "failed"
assert "5" in old.error_message
def test_multiple_stale_records_all_cleaned_in_single_commit(self):
"""多条卡死记录都被清理,返回正确计数并只 commit 一次。"""
m1 = _make_fake_row(updated_at=datetime.now(UTC) - timedelta(minutes=20))
m2 = _make_fake_row(updated_at=datetime.now(UTC) - timedelta(minutes=11))
repo, session = _make_repo([m1, m2])
assert repo.cleanup_stale_processing(timeout_minutes=10) == 2
assert m1.status == "failed"
assert m2.status == "failed"
session.commit.assert_called_once()
def test_queries_model_with_status_and_updated_at_filters(self):
"""query 以模型类为参数,filter 同时传入 status=='processing' 与 updated_at<cutoff。"""
repo, session = _make_repo([])
repo.cleanup_stale_processing()
assert session.query.called
args_q, _ = session.query.call_args
assert args_q[0] is _FakeVoiceCloneProfileModel
# filter 被调用一次,且传入两个过滤表达式
assert session.query.return_value.filter.called
args_f, kwargs_f = session.query.return_value.filter.call_args
assert len(args_f) == 2, f"filter 应接收 2 个位置参数(status + updated_at),实际 {len(args_f)}"