Files
xiaoxia-saas/tests/unit/test_voice_library_use_cases.py
T
灵应 e539105256
Deploy / Build Production Runtime Images (push) Has been skipped
Deploy / Deploy Production (push) Has been skipped
Deploy / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Failing after 163h53m1s
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 163h53m6s
Deploy / Deploy Staging (push) Failing after 164h23m43s
CI/CD Pipeline / Frontend Lint (push) Failing after 164h23m43s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 164h23m50s
fix: 3.09 审计问题修复 — P1-1 TTS Celery 执行器 + P2-1~P2-4
P1-1 (阻塞性): TTS 合成完整异步链路
- CosyVoiceService 新增 submit_synthesize_task() + poll_synthesize_task()
- 新建 TTSWorkflowService 编排层 (packages/application/tts_job/workflow.py)
- 新建 Celery 任务 process_tts_synthesis (apps/worker/worker_app/tasks/tts_synthesis.py)
- 注册到 celery_app.conf.imports + tasks/__init__.py 懒加载
- TTS 路由 synthesize() 增加 CosyVoice 提交 + Celery 调度

P2-1: voice_clone.py 添加详细 Celery 重试策略注释
P2-2: 修复 voice_clone.py Session 泄漏 (session=None 安全模式)
P2-3: ListVoiceLibraryUseCase 返回 (items, count) 元组,消除重复 count_by_user()
P2-4: 新增 find_profile_ids_by_voice_ids() 批量方法,填充 voice_clone_profile_id

测试: 749 passed, 0 failed
2026-07-02 16:30:37 +08:00

692 lines
25 KiB
Python
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.
"""
配音库(Voice LibraryUse Case 回归测试
测试目标:
1. CreateVoiceLibraryUseCase - 创建配音库条目,验证 voice_id 字段映射正确(PR#74 P0 bug 修复)
2. UpdateVoiceLibraryUseCase - 更新配音库条目,验证 voice_id 字段映射正确
3. 配额逻辑覆盖 - free=10, basic=100, premium=100
4. 边界条件与异常场景
"""
from unittest.mock import MagicMock, Mock, call
import pytest
from packages.application.voice_library.commands import (
CreateVoiceLibraryCommand,
UpdateVoiceLibraryCommand,
)
from packages.application.voice_library.use_cases import (
CreateVoiceLibraryUseCase,
DeleteVoiceLibraryUseCase,
GetVoiceLibraryUseCase,
ListVoiceLibraryUseCase,
NotFoundError,
QuotaExceededError,
UpdateVoiceLibraryUseCase,
)
from packages.domain.voice_library import VoiceLibraryItem
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def mock_repo():
"""创建 Mock 仓储"""
repo = Mock()
repo.count_by_user = Mock(return_value=0)
repo.create = Mock(side_effect=lambda item: item)
repo.update = Mock(side_effect=lambda item: item)
repo.get = Mock(return_value=None)
repo.delete = Mock(return_value=True)
repo.list_by_user = Mock(return_value=[])
return repo
@pytest.fixture
def create_use_case(mock_repo):
return CreateVoiceLibraryUseCase(repository=mock_repo)
@pytest.fixture
def update_use_case(mock_repo):
return UpdateVoiceLibraryUseCase(repository=mock_repo)
@pytest.fixture
def sample_create_command():
"""标准创建命令"""
return CreateVoiceLibraryCommand(
user_id="user-001",
name="测试配音",
text="你好世界",
voice_provider="aliyun",
voice_id="voice-abc-123",
voice_name="小云",
audio_url="https://oss.example.com/audio/abc.wav",
duration=3.5,
file_size=56000,
status="completed",
project_id="proj-001",
tags=["测试", "中文"],
metadata_={"source": "unit_test"},
)
@pytest.fixture
def existing_voice_item():
"""模拟已存在的配音条目"""
return VoiceLibraryItem(
id="existing-voice-001",
user_id="user-001",
name="旧配音",
text="旧文本",
voice_provider="old_provider",
voice_id="old-voice-id",
voice_name="旧声音",
audio_url="https://oss.example.com/old.wav",
duration=1.0,
file_size=16000,
status="completed",
project_id="proj-001",
tags=["旧"],
metadata_={},
)
# ===========================================================================
# 1. CreateVoiceLibraryUseCase 测试
# ===========================================================================
class TestCreateVoiceLibraryUseCase:
"""配音库创建 UseCase 测试"""
def test_create_success_all_fields(self, create_use_case, mock_repo, sample_create_command):
"""测试创建成功 - 所有字段完整传入"""
result = create_use_case.execute(sample_create_command, plan_name="free")
assert result is not None
assert result.user_id == "user-001"
assert result.name == "测试配音"
assert result.text == "你好世界"
assert result.voice_provider == "aliyun"
assert result.voice_name == "小云"
assert result.audio_url == "https://oss.example.com/audio/abc.wav"
assert result.duration == 3.5
assert result.file_size == 56000
assert result.status == "completed"
assert result.project_id == "proj-001"
assert result.tags == ["测试", "中文"]
assert result.metadata_ == {"source": "unit_test"}
mock_repo.count_by_user.assert_called_once_with("user-001")
mock_repo.create.assert_called_once()
def test_create_voice_id_field_mapping(self, create_use_case, mock_repo):
"""
【P0 回归】验证 voice_id 字段映射正确
PR#74 修复了 command.id 被错误使用的问题。
此测试确保 CreateVoiceLibraryCommand 中的 voice_id 字段
被正确传递到 VoiceLibraryItem 的 voice_id 属性上,
而非被其他字段(如 item 自身的 id)覆盖。
"""
command = CreateVoiceLibraryCommand(
user_id="user-001",
name="voice_id 回归测试",
voice_id="specific-voice-id-xyz",
voice_provider="azure",
voice_name="Azure Xiaoxiao",
)
result = create_use_case.execute(command, plan_name="free")
# 核心断言:voice_id 必须来自 command.voice_id
assert result.voice_id == "specific-voice-id-xyz", "voice_id 应来自 command.voice_id,而非其他字段"
# 同时确保 item 自身生成的 id 与 voice_id 不同
assert result.id != "specific-voice-id-xyz", "item.idUUID)不应与 voice_id 混淆"
def test_create_voice_id_empty_string(self, create_use_case, mock_repo):
"""测试 voice_id 为空字符串的合法场景"""
command = CreateVoiceLibraryCommand(
user_id="user-001",
name="无 voice_id 配音",
voice_id="",
voice_provider="custom",
)
result = create_use_case.execute(command, plan_name="free")
assert result.voice_id == ""
def test_create_default_values(self, create_use_case, mock_repo):
"""测试默认值填充"""
command = CreateVoiceLibraryCommand(
user_id="user-001",
name="最小化创建",
)
result = create_use_case.execute(command, plan_name="free")
assert result.text == ""
assert result.voice_provider == ""
assert result.voice_id == ""
assert result.voice_name == ""
assert result.audio_url == ""
assert result.duration == 0
assert result.file_size == 0
assert result.status == "completed"
assert result.project_id is None
assert result.tags == []
assert result.metadata_ == {}
def test_create_generates_uuid(self, create_use_case, mock_repo):
"""测试创建时自动生成 UUID 作为 id"""
command = CreateVoiceLibraryCommand(
user_id="user-001",
name="UUID 测试",
)
result = create_use_case.execute(command, plan_name="free")
assert result.id is not None
assert len(result.id) == 32 # uuid4().hex 长度为 32
assert result.id.isalnum()
# ===========================================================================
# 2. 配额逻辑测试(Create 时的配额检查)
# ===========================================================================
class TestCreateVoiceLibraryQuota:
"""配音库创建配额检查测试"""
def test_quota_free_plan_under_limit(self, create_use_case, mock_repo, sample_create_command):
"""free 套餐(上限10),当前 5 个,允许创建"""
mock_repo.count_by_user.return_value = 5
result = create_use_case.execute(sample_create_command, plan_name="free")
assert result is not None
mock_repo.create.assert_called_once()
def test_quota_free_plan_at_limit(self, create_use_case, mock_repo, sample_create_command):
"""free 套餐(上限10),当前 10 个,拒绝创建"""
mock_repo.count_by_user.return_value = 10
with pytest.raises(QuotaExceededError) as exc_info:
create_use_case.execute(sample_create_command, plan_name="free")
assert exc_info.value.dimension == "max_voiceovers"
assert exc_info.value.limit == 10
assert exc_info.value.used == 10
mock_repo.create.assert_not_called()
def test_quota_free_plan_over_limit(self, create_use_case, mock_repo, sample_create_command):
"""free 套餐(上限10),当前 15 个,拒绝创建"""
mock_repo.count_by_user.return_value = 15
with pytest.raises(QuotaExceededError) as exc_info:
create_use_case.execute(sample_create_command, plan_name="free")
assert exc_info.value.dimension == "max_voiceovers"
assert exc_info.value.limit == 10
assert exc_info.value.used == 15
def test_quota_free_plan_just_under_limit(self, create_use_case, mock_repo, sample_create_command):
"""free 套餐(上限10),当前 9 个,允许创建(边界)"""
mock_repo.count_by_user.return_value = 9
result = create_use_case.execute(sample_create_command, plan_name="free")
assert result is not None
mock_repo.create.assert_called_once()
def test_quota_basic_plan_under_limit(self, create_use_case, mock_repo, sample_create_command):
"""basic 套餐(上限100),当前 50 个,允许创建"""
mock_repo.count_by_user.return_value = 50
result = create_use_case.execute(sample_create_command, plan_name="basic")
assert result is not None
mock_repo.create.assert_called_once()
def test_quota_basic_plan_at_limit(self, create_use_case, mock_repo, sample_create_command):
"""basic 套餐(上限100),当前 100 个,拒绝创建"""
mock_repo.count_by_user.return_value = 100
with pytest.raises(QuotaExceededError) as exc_info:
create_use_case.execute(sample_create_command, plan_name="basic")
assert exc_info.value.dimension == "max_voiceovers"
assert exc_info.value.limit == 100
assert exc_info.value.used == 100
def test_quota_basic_plan_just_under_limit(self, create_use_case, mock_repo, sample_create_command):
"""basic 套餐(上限100),当前 99 个,允许创建(边界)"""
mock_repo.count_by_user.return_value = 99
result = create_use_case.execute(sample_create_command, plan_name="basic")
assert result is not None
mock_repo.create.assert_called_once()
def test_quota_premium_plan_under_limit(self, create_use_case, mock_repo, sample_create_command):
"""premium 套餐(上限100),当前 50 个,允许创建"""
mock_repo.count_by_user.return_value = 50
result = create_use_case.execute(sample_create_command, plan_name="premium")
assert result is not None
mock_repo.create.assert_called_once()
def test_quota_premium_plan_at_limit(self, create_use_case, mock_repo, sample_create_command):
"""premium 套餐(上限100),当前 100 个,拒绝创建"""
mock_repo.count_by_user.return_value = 100
with pytest.raises(QuotaExceededError) as exc_info:
create_use_case.execute(sample_create_command, plan_name="premium")
assert exc_info.value.dimension == "max_voiceovers"
assert exc_info.value.limit == 100
assert exc_info.value.used == 100
def test_quota_premium_plan_just_under_limit(self, create_use_case, mock_repo, sample_create_command):
"""premium 套餐(上限100),当前 99 个,允许创建(边界)"""
mock_repo.count_by_user.return_value = 99
result = create_use_case.execute(sample_create_command, plan_name="premium")
assert result is not None
mock_repo.create.assert_called_once()
def test_quota_zero_usage(self, create_use_case, mock_repo, sample_create_command):
"""新用户零使用量,所有套餐均可创建"""
mock_repo.count_by_user.return_value = 0
for plan in ["free", "basic", "premium"]:
mock_repo.create.reset_mock()
mock_repo.count_by_user.reset_mock()
mock_repo.count_by_user.return_value = 0
result = create_use_case.execute(sample_create_command, plan_name=plan)
assert result is not None, f"{plan} 套餐零使用量应允许创建"
def test_quota_unknown_plan_defaults_to_zero(self, create_use_case, mock_repo, sample_create_command):
"""未知套餐名默认配额为 0,即使 0 使用量也无法创建"""
mock_repo.count_by_user.return_value = 0
with pytest.raises(QuotaExceededError):
create_use_case.execute(sample_create_command, plan_name="unknown_plan")
def test_quota_exceeded_error_attributes(self, create_use_case, mock_repo, sample_create_command):
"""QuotaExceededError 异常属性完整性"""
mock_repo.count_by_user.return_value = 10
with pytest.raises(QuotaExceededError) as exc_info:
create_use_case.execute(sample_create_command, plan_name="free")
err = exc_info.value
assert hasattr(err, "dimension")
assert hasattr(err, "limit")
assert hasattr(err, "used")
assert "max_voiceovers" in str(err)
assert "10" in str(err)
# ===========================================================================
# 3. UpdateVoiceLibraryUseCase 测试
# ===========================================================================
class TestUpdateVoiceLibraryUseCase:
"""配音库更新 UseCase 测试"""
def test_update_success_all_fields(self, update_use_case, mock_repo, existing_voice_item):
"""测试全字段更新成功"""
mock_repo.get.return_value = existing_voice_item
command = UpdateVoiceLibraryCommand(
id="existing-voice-001",
user_id="user-001",
name="更新后的名称",
text="更新后的文本",
voice_provider="new_provider",
voice_id="new-voice-id-456",
voice_name="新声音",
audio_url="https://oss.example.com/new.wav",
duration=5.0,
file_size=80000,
status="processing",
tags=["新标签"],
metadata_={"updated": True},
)
result = update_use_case.execute(command)
assert result.name == "更新后的名称"
assert result.text == "更新后的文本"
assert result.voice_provider == "new_provider"
assert result.voice_name == "新声音"
assert result.audio_url == "https://oss.example.com/new.wav"
assert result.duration == 5.0
assert result.file_size == 80000
assert result.status == "processing"
assert result.tags == ["新标签"]
assert result.metadata_ == {"updated": True}
mock_repo.update.assert_called_once()
def test_update_voice_id_field_mapping(self, update_use_case, mock_repo, existing_voice_item):
"""
【P0 回归】验证 update 时 voice_id 字段映射正确
PR#74 修复了 API 路由层将 command.id 错误传给 voice_id 的 bug。
此测试确保 UpdateVoiceLibraryCommand 中 voice_id 字段
被正确写入 VoiceLibraryItem.voice_id,而非被 item.id 覆盖。
"""
mock_repo.get.return_value = existing_voice_item
command = UpdateVoiceLibraryCommand(
id="existing-voice-001",
user_id="user-001",
voice_id="completely-different-voice-id",
)
result = update_use_case.execute(command)
# 核心断言:voice_id 应被更新为新值
assert result.voice_id == "completely-different-voice-id", "voice_id 应被更新为 command.voice_id 的值"
# item 自身的 id 保持不变
assert result.id == "existing-voice-001"
def test_update_partial_only_voice_id(self, update_use_case, mock_repo, existing_voice_item):
"""测试仅更新 voice_id 一个字段"""
mock_repo.get.return_value = existing_voice_item
command = UpdateVoiceLibraryCommand(
id="existing-voice-001",
user_id="user-001",
voice_id="only-voice-id-changed",
)
result = update_use_case.execute(command)
assert result.voice_id == "only-voice-id-changed"
# 其他字段保持不变
assert result.name == "旧配音"
assert result.text == "旧文本"
assert result.voice_provider == "old_provider"
assert result.voice_name == "旧声音"
assert result.audio_url == "https://oss.example.com/old.wav"
assert result.duration == 1.0
assert result.file_size == 16000
def test_update_partial_only_name(self, update_use_case, mock_repo, existing_voice_item):
"""测试仅更新 name"""
mock_repo.get.return_value = existing_voice_item
command = UpdateVoiceLibraryCommand(
id="existing-voice-001",
user_id="user-001",
name="仅改名",
)
result = update_use_case.execute(command)
assert result.name == "仅改名"
assert result.voice_id == "old-voice-id" # voice_id 不变
def test_update_not_found(self, update_use_case, mock_repo):
"""测试更新不存在的条目"""
mock_repo.get.return_value = None
command = UpdateVoiceLibraryCommand(
id="nonexistent-id",
user_id="user-001",
name="不存在",
)
with pytest.raises(NotFoundError, match="nonexistent-id"):
update_use_case.execute(command)
mock_repo.update.assert_not_called()
def test_update_wrong_user(self, update_use_case, mock_repo):
"""测试用户隔离 - 不能更新其他用户的条目"""
mock_repo.get.return_value = None # repo 返回 None 表示找不到(不同 user_id
command = UpdateVoiceLibraryCommand(
id="existing-voice-001",
user_id="other-user-999",
name="恶意修改",
)
with pytest.raises(NotFoundError):
update_use_case.execute(command)
def test_update_none_fields_not_changed(self, update_use_case, mock_repo, existing_voice_item):
"""测试 None 字段不覆盖原有值"""
mock_repo.get.return_value = existing_voice_item
command = UpdateVoiceLibraryCommand(
id="existing-voice-001",
user_id="user-001",
# 所有可选字段保持 None
)
result = update_use_case.execute(command)
# 所有字段应保持不变
assert result.name == "旧配音"
assert result.text == "旧文本"
assert result.voice_id == "old-voice-id"
assert result.voice_provider == "old_provider"
assert result.voice_name == "旧声音"
assert result.audio_url == "https://oss.example.com/old.wav"
assert result.duration == 1.0
assert result.file_size == 16000
assert result.status == "completed"
def test_update_voice_id_empty_string(self, update_use_case, mock_repo, existing_voice_item):
"""测试 voice_id 更新为空字符串(合法场景:清除 voice_id"""
mock_repo.get.return_value = existing_voice_item
command = UpdateVoiceLibraryCommand(
id="existing-voice-001",
user_id="user-001",
voice_id="",
)
result = update_use_case.execute(command)
assert result.voice_id == ""
# ===========================================================================
# 4. DeleteVoiceLibraryUseCase 测试
# ===========================================================================
class TestDeleteVoiceLibraryUseCase:
"""配音库删除 UseCase 测试"""
def test_delete_success(self, mock_repo):
"""测试删除成功"""
mock_repo.delete.return_value = True
use_case = DeleteVoiceLibraryUseCase(repository=mock_repo)
result = use_case.execute("voice-001", "user-001")
assert result is True
mock_repo.delete.assert_called_once_with("voice-001", "user-001")
def test_delete_not_found(self, mock_repo):
"""测试删除不存在的条目"""
mock_repo.delete.return_value = False
use_case = DeleteVoiceLibraryUseCase(repository=mock_repo)
result = use_case.execute("nonexistent", "user-001")
assert result is False
# ===========================================================================
# 5. GetVoiceLibraryUseCase 测试
# ===========================================================================
class TestGetVoiceLibraryUseCase:
"""配音库查询 UseCase 测试"""
def test_get_existing(self, mock_repo):
"""测试查询存在的条目"""
expected = VoiceLibraryItem(
id="v-001",
user_id="user-001",
name="测试",
voice_id="voice-xyz",
)
mock_repo.get.return_value = expected
use_case = GetVoiceLibraryUseCase(repository=mock_repo)
result = use_case.execute("v-001", "user-001")
assert result is not None
assert result.id == "v-001"
assert result.voice_id == "voice-xyz"
mock_repo.get.assert_called_once_with("v-001", "user-001")
def test_get_not_found(self, mock_repo):
"""测试查询不存在的条目"""
mock_repo.get.return_value = None
use_case = GetVoiceLibraryUseCase(repository=mock_repo)
result = use_case.execute("nonexistent", "user-001")
assert result is None
# ===========================================================================
# 6. ListVoiceLibraryUseCase 测试
# ===========================================================================
class TestListVoiceLibraryUseCase:
"""配音库列表 UseCase 测试"""
def test_list_default(self, mock_repo):
"""测试默认列表查询"""
items = [
VoiceLibraryItem(id="v1", user_id="user-001", name="A"),
VoiceLibraryItem(id="v2", user_id="user-001", name="B"),
]
mock_repo.list_by_user.return_value = items
mock_repo.count_by_user.return_value = 2
use_case = ListVoiceLibraryUseCase(repository=mock_repo)
result_items, total = use_case.execute("user-001")
assert len(result_items) == 2
assert total == 2
mock_repo.list_by_user.assert_called_once_with("user-001", status=None, skip=0, limit=50)
def test_list_with_status_filter(self, mock_repo):
"""测试按状态筛选"""
mock_repo.list_by_user.return_value = []
use_case = ListVoiceLibraryUseCase(repository=mock_repo)
use_case.execute("user-001", status="completed", skip=10, limit=20)
mock_repo.list_by_user.assert_called_once_with("user-001", status="completed", skip=10, limit=20)
def test_list_empty(self, mock_repo):
"""测试空列表"""
mock_repo.list_by_user.return_value = []
mock_repo.count_by_user.return_value = 0
use_case = ListVoiceLibraryUseCase(repository=mock_repo)
items, total = use_case.execute("user-001")
assert items == []
assert total == 0
# ===========================================================================
# 7. voice_id 与 id 字段隔离专项回归测试
# ===========================================================================
class TestVoiceIdFieldIsolation:
"""
PR#74 P0 Bug 回归:voice_id 与 item.id 字段隔离
原 bugAPI 路由层误将 command.iditem 主键)用作 voice_id
导致 voice_id 字段值错误。本测试类从 UseCase 层验证
这两个字段在整个 CRUD 生命周期中互不干扰。
"""
def test_create_id_and_voice_id_are_independent(self, create_use_case, mock_repo):
"""创建时 id 自动生成,voice_id 来自 command"""
command = CreateVoiceLibraryCommand(
user_id="user-001",
name="隔离测试",
voice_id="tts-voice-001",
voice_provider="openai",
)
result = create_use_case.execute(command, plan_name="free")
assert result.id != result.voice_id, "id 和 voice_id 应为不同值"
assert result.voice_id == "tts-voice-001"
assert len(result.id) == 32 # UUID hex
def test_update_voice_id_does_not_change_id(self, update_use_case, mock_repo):
"""更新 voice_id 不影响 item 主键 id"""
existing = VoiceLibraryItem(
id="stable-id-001",
user_id="user-001",
name="测试",
voice_id="old-voice",
)
mock_repo.get.return_value = existing
command = UpdateVoiceLibraryCommand(
id="stable-id-001",
user_id="user-001",
voice_id="new-voice-999",
)
result = update_use_case.execute(command)
assert result.id == "stable-id-001", "item 主键 id 不应改变"
assert result.voice_id == "new-voice-999", "voice_id 应被更新"
def test_create_then_update_voice_id_preserves_id(self, create_use_case, update_use_case, mock_repo):
"""创建后再更新 voice_idid 始终不变"""
# 创建
create_cmd = CreateVoiceLibraryCommand(
user_id="user-001",
name="生命周期测试",
voice_id="initial-voice",
)
created = create_use_case.execute(create_cmd, plan_name="free")
original_id = created.id
# 更新
mock_repo.get.return_value = created
update_cmd = UpdateVoiceLibraryCommand(
id=original_id,
user_id="user-001",
voice_id="updated-voice",
)
updated = update_use_case.execute(update_cmd)
assert updated.id == original_id, "经过创建和更新,id 应保持一致"
assert updated.voice_id == "updated-voice"
assert updated.voice_id != "initial-voice"