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
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
692 lines
25 KiB
Python
692 lines
25 KiB
Python
"""
|
||
配音库(Voice Library)Use 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.id(UUID)不应与 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 字段隔离
|
||
|
||
原 bug:API 路由层误将 command.id(item 主键)用作 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_id,id 始终不变"""
|
||
# 创建
|
||
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"
|