1a57878f76
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1m12s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m17s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1m33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m14s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (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 / Production Browser E2E (pull_request) Has been skipped
1. 未使用依赖清理:
- 从 requirements-base.txt 移除 cryptography 和 pyOpenSSL
2. pyflakes 警告清零 (apps/ + packages/ + tests/):
- 移除 17 处未使用的 import (F401)
- 修复 26 处未使用的局部变量 (F841):
* 有副作用的赋值转为裸调用
* 无副作用的赋值直接删除
- 修复 1 处未使用的异常变量 (F841)
- 修复 1 处空 except 块
3. 测试文件冗余清理:
- 删除 tests/integration/test_project_management.py (模块级 skip,测试不存在的模块)
- 删除 tests/integration/fixtures/duplication_routes_fixed.py (未被引用)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
250 lines
9.9 KiB
Python
250 lines
9.9 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.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"
|