Files
xiaoxia-saas/tests/unit/test_voice_clone_cleanup.py
T
xiaoxia 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
fix(voice-clone): 处理中卡死兜底 — worker重启/Celery消息丢失后processing永久卡住 (#2050)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-26 15:31:06 +08:00

157 lines
5.8 KiB
Python
Executable File
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.
"""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)}"