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
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:
Executable
+153
@@ -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)}"
|
||||
Reference in New Issue
Block a user