"""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.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"