c67aadcb2b
CI/CD Pipeline / Deploy Staging (push) Has been skipped
CI/CD Pipeline / Staging E2E Tests (push) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Failing after 75h39m10s
CI/CD Pipeline / Validate Code Quality And Tests (push) Failing after 75h39m22s
251 lines
10 KiB
Python
251 lines
10 KiB
Python
"""StrEnum 反序列化单元测试。
|
||
|
||
验证 SQLAlchemy 仓储从 DB 读取数据后,实体的 status 字段是正确的 StrEnum 类型,
|
||
而不是纯字符串。这是 500 错误的根因修复测试。
|
||
|
||
根因:SQLAlchemy Column(String(20)) 读出的是 str,直接传给 domain 实体后,
|
||
路由层 job.status.value 会抛 AttributeError(str 没有 .value 属性)。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import datetime, timezone
|
||
from unittest.mock import MagicMock
|
||
|
||
import pytest
|
||
|
||
from packages.adapters.sqlalchemy_impl.generation_task_repository import SQLAlchemyGenerationTaskRepository
|
||
from packages.adapters.sqlalchemy_impl.tts_job_repository import SQLAlchemyTTSJobRepository
|
||
from packages.adapters.sqlalchemy_impl.voice_clone_profile_repository import SQLAlchemyVoiceCloneProfileRepository
|
||
from packages.domain.generation_task import GenerationTaskStatus
|
||
from packages.domain.tts_job import TTSJobStatus
|
||
from packages.domain.voice_clone_profile import VoiceCloneStatus
|
||
|
||
|
||
def _make_tts_model(status: str = "pending"):
|
||
"""模拟 SQLAlchemy TTSJobModel(status 是纯字符串)。"""
|
||
model = MagicMock()
|
||
model.id = "job_001"
|
||
model.user_id = "user_001"
|
||
model.input_text = "测试文本"
|
||
model.voice_id = "voice_001"
|
||
model.voice_model = ""
|
||
model.project_id = ""
|
||
model.voice_clone_profile_id = ""
|
||
model.status = status # 纯字符串,模拟 DB 读取
|
||
model.output_audio_url = ""
|
||
model.output_audio_key = ""
|
||
model.duration = 0.0
|
||
model.file_size = 0
|
||
model.sample_rate = 22050
|
||
model.format = "mp3"
|
||
model.error_message = ""
|
||
model.retry_count = 0
|
||
model.max_retries = 3
|
||
model.metadata_ = {}
|
||
model.started_at = None
|
||
model.completed_at = None
|
||
model.created_at = datetime.now(timezone.utc)
|
||
model.updated_at = datetime.now(timezone.utc)
|
||
return model
|
||
|
||
|
||
def _make_voice_clone_model(status: str = "pending"):
|
||
"""模拟 SQLAlchemy VoiceCloneProfileModel(status 是纯字符串)。"""
|
||
model = MagicMock()
|
||
model.id = "profile_001"
|
||
model.user_id = "user_001"
|
||
model.name = "测试音色"
|
||
model.description = ""
|
||
model.source_audio_url = "https://example.com/audio.wav"
|
||
model.voice_id = ""
|
||
model.voice_model = ""
|
||
model.language = "zh-CN"
|
||
model.gender = "unknown"
|
||
model.status = status # 纯字符串,模拟 DB 读取
|
||
model.error_message = ""
|
||
model.retry_count = 0
|
||
model.max_retries = 3
|
||
model.metadata_ = {}
|
||
model.created_at = datetime.now(timezone.utc)
|
||
model.updated_at = datetime.now(timezone.utc)
|
||
return model
|
||
|
||
|
||
def _make_generation_task_model(status: str = "pending"):
|
||
"""模拟 SQLAlchemy GenerationTaskModel(status 是纯字符串)。"""
|
||
model = MagicMock()
|
||
model.id = "task_001"
|
||
model.project_id = "project_001"
|
||
model.strategy_id = "strategy_001"
|
||
model.asset_library_id = "lib_001"
|
||
model.voice_library_id = "voice_lib_001"
|
||
model.template_id = "template_001"
|
||
model.asset_ids = []
|
||
model.title_ids = []
|
||
model.voice_ids = []
|
||
model.status = status # 纯字符串,模拟 DB 读取
|
||
model.progress = 0.0
|
||
model.result_count = 0
|
||
model.error_message = ""
|
||
model.started_at = None
|
||
model.completed_at = None
|
||
model.created_by_user_id = "user_001"
|
||
model.source_edit_plan_id = ""
|
||
model.created_at = datetime.now(timezone.utc)
|
||
return model
|
||
|
||
|
||
class TestTTSJobStatusDeserialization:
|
||
"""TTSJob 仓储:DB 读出的 status 字符串应转为 TTSJobStatus 枚举。"""
|
||
|
||
@pytest.mark.parametrize("status_str", ["pending", "processing", "completed", "failed", "cancelled"])
|
||
def test_model_to_entity_converts_status_to_enum(self, status_str: str) -> None:
|
||
"""_model_to_entity 将纯字符串 status 转为 TTSJobStatus 枚举。"""
|
||
model = _make_tts_model(status=status_str)
|
||
entity = SQLAlchemyTTSJobRepository._model_to_entity(model)
|
||
|
||
assert isinstance(
|
||
entity.status, TTSJobStatus
|
||
), f"status 应该是 TTSJobStatus 枚举,实际是 {type(entity.status).__name__}"
|
||
assert entity.status.value == status_str
|
||
|
||
def test_status_value_attribute_works(self) -> None:
|
||
"""路由层 job.status.value 不再抛 AttributeError。"""
|
||
model = _make_tts_model(status="processing")
|
||
entity = SQLAlchemyTTSJobRepository._model_to_entity(model)
|
||
|
||
# 这是之前 500 错误的直接原因
|
||
assert entity.status.value == "processing"
|
||
|
||
def test_status_string_comparison_still_works(self) -> None:
|
||
"""StrEnum 继承自 str,字符串比较仍然兼容。"""
|
||
model = _make_tts_model(status="completed")
|
||
entity = SQLAlchemyTTSJobRepository._model_to_entity(model)
|
||
|
||
assert entity.status == "completed" # StrEnum 与 str 可比较
|
||
assert entity.status == TTSJobStatus.COMPLETED
|
||
|
||
|
||
class TestVoiceCloneStatusDeserialization:
|
||
"""VoiceCloneProfile 仓储:DB 读出的 status 字符串应转为 VoiceCloneStatus 枚举。"""
|
||
|
||
@pytest.mark.parametrize("status_str", ["pending", "processing", "ready", "failed", "disabled"])
|
||
def test_model_to_entity_converts_status_to_enum(self, status_str: str) -> None:
|
||
"""_model_to_entity 将纯字符串 status 转为 VoiceCloneStatus 枚举。"""
|
||
model = _make_voice_clone_model(status=status_str)
|
||
entity = SQLAlchemyVoiceCloneProfileRepository._model_to_entity(model)
|
||
|
||
assert isinstance(
|
||
entity.status, VoiceCloneStatus
|
||
), f"status 应该是 VoiceCloneStatus 枚举,实际是 {type(entity.status).__name__}"
|
||
assert entity.status.value == status_str
|
||
|
||
def test_status_value_attribute_works(self) -> None:
|
||
"""profile.status.value 不再抛 AttributeError。"""
|
||
model = _make_voice_clone_model(status="processing")
|
||
entity = SQLAlchemyVoiceCloneProfileRepository._model_to_entity(model)
|
||
|
||
assert entity.status.value == "processing"
|
||
|
||
|
||
class TestGenerationTaskStatusDeserialization:
|
||
"""GenerationTask 仓储:DB 读出的 status 字符串应转为 GenerationTaskStatus 枚举。"""
|
||
|
||
@pytest.mark.parametrize("status_str", ["pending", "running", "completed", "failed", "cancelled"])
|
||
def test_to_domain_converts_status_to_enum(self, status_str: str) -> None:
|
||
"""_to_domain 将纯字符串 status 转为 GenerationTaskStatus 枚举。"""
|
||
model = _make_generation_task_model(status=status_str)
|
||
|
||
# _to_domain 是模块级函数,直接导入使用
|
||
from packages.adapters.sqlalchemy_impl.generation_task_repository import _to_domain
|
||
|
||
entity = _to_domain(model)
|
||
|
||
assert isinstance(
|
||
entity.status, GenerationTaskStatus
|
||
), f"status 应该是 GenerationTaskStatus 枚举,实际是 {type(entity.status).__name__}"
|
||
assert entity.status.value == status_str
|
||
|
||
def test_status_value_attribute_works(self) -> None:
|
||
"""task.status.value 不再抛 AttributeError。"""
|
||
model = _make_generation_task_model(status="running")
|
||
|
||
from packages.adapters.sqlalchemy_impl.generation_task_repository import _to_domain
|
||
|
||
entity = _to_domain(model)
|
||
|
||
assert entity.status.value == "running"
|
||
|
||
|
||
class TestAlreadyCorrectRepositories:
|
||
"""验证已经正确转换枚举的仓储(回归测试,防止退化)。"""
|
||
|
||
def test_edit_plan_status_converted(self) -> None:
|
||
"""EditPlan 仓储已正确转换 status。"""
|
||
from packages.adapters.sqlalchemy_impl.edit_plan_repository import SQLAlchemyEditPlanRepository
|
||
from packages.domain.edit_plan import EditPlanStatus
|
||
|
||
model = MagicMock()
|
||
model.id = "plan_001"
|
||
model.template_id = "template_001"
|
||
model.name = "测试计划"
|
||
model.status = "editing" # 纯字符串
|
||
model.total_duration = 0.0
|
||
model.source_edit_plan_id = ""
|
||
model.project_id = ""
|
||
model.created_by_user_id = "user_001"
|
||
model.config = {}
|
||
model.created_at = datetime.now(timezone.utc)
|
||
model.updated_at = datetime.now(timezone.utc)
|
||
|
||
entity = SQLAlchemyEditPlanRepository._model_to_entity(model)
|
||
assert isinstance(entity.status, EditPlanStatus)
|
||
assert entity.status.value == "editing"
|
||
|
||
def test_edit_template_status_converted(self) -> None:
|
||
"""EditTemplate 仓储已正确转换 status。"""
|
||
from packages.adapters.sqlalchemy_impl.edit_template_repository import SQLAlchemyEditTemplateRepository
|
||
from packages.domain.edit_template import EditTemplateStatus
|
||
|
||
model = MagicMock()
|
||
model.id = "template_001"
|
||
model.name = "测试模板"
|
||
model.description = ""
|
||
model.template_type = "default"
|
||
model.config = {}
|
||
model.preview_url = ""
|
||
model.sort_weight = 0
|
||
model.status = "active" # 纯字符串
|
||
model.created_at = datetime.now(timezone.utc)
|
||
model.updated_at = datetime.now(timezone.utc)
|
||
|
||
entity = SQLAlchemyEditTemplateRepository._model_to_entity(model)
|
||
assert isinstance(entity.status, EditTemplateStatus)
|
||
assert entity.status.value == "active"
|
||
|
||
def test_edit_plan_clip_status_converted(self) -> None:
|
||
"""EditPlanClip 仓储已正确转换 status。"""
|
||
from packages.adapters.sqlalchemy_impl.edit_plan_clip_repository import SQLAlchemyEditPlanClipRepository
|
||
from packages.domain.edit_plan_clip import EditPlanClipStatus
|
||
|
||
model = MagicMock()
|
||
model.id = "clip_001"
|
||
model.plan_id = "plan_001"
|
||
model.clip_type = "main"
|
||
model.order = 0
|
||
model.template_clip_config_id = ""
|
||
model.asset_id = ""
|
||
model.text_content = ""
|
||
model.start_time = 0.0
|
||
model.duration = 0.0
|
||
model.transition_effect = "cut"
|
||
model.status = "ready" # 纯字符串
|
||
model.config = {}
|
||
model.created_at = datetime.now(timezone.utc)
|
||
model.updated_at = datetime.now(timezone.utc)
|
||
|
||
entity = SQLAlchemyEditPlanClipRepository._model_to_entity(model)
|
||
assert isinstance(entity.status, EditPlanClipStatus)
|
||
assert entity.status.value == "ready"
|