8a396303b8
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 3s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) 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 push changed paths (push) Successful in 10s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (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 / Integration Tests (push) Successful in 3m12s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m25s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m39s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 3m11s
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 3m46s
CI/CD Pipeline / Build Staging API Image (push) Successful in 3m32s
CI/CD Pipeline / Validate - Style (push) Successful in 4m16s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 4m30s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 1m14s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m24s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 50s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m43s
AI Code Review / AI Code Review (pull_request) Successful in 7m14s
CI/CD Pipeline / Validate - Security (push) Successful in 7m47s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m47s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m0s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 4m6s
CI/CD Pipeline / Unit Tests (push) Successful in 12m7s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Failing after 12m11s
CI/CD Pipeline / CI Gate (pull_request) Failing after 1s
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
157 lines
5.8 KiB
Python
Executable File
157 lines
5.8 KiB
Python
Executable File
"""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 两个条件。
|
||
|
||
注:不同测试加载顺序下 sys.modules['packages...models'] 可能是真模型类
|
||
(因为其他测试文件已先 import),所以这里不断言模型类身份,
|
||
只断言 query/filter 被正确调用。
|
||
"""
|
||
repo, session = _make_repo([])
|
||
repo.cleanup_stale_processing()
|
||
|
||
assert session.query.called, "session.query 应被调用"
|
||
# filter 被调用一次,且传入两个过滤表达式
|
||
q = session.query.return_value
|
||
assert q.filter.called, "query.filter 应被调用"
|
||
args_f, _kwargs = q.filter.call_args
|
||
assert len(args_f) == 2, f"filter 应接收 2 个位置参数(status + updated_at),实际 {len(args_f)}"
|