Files
xiaoxia-saas/tests/unit/test_voice_clone_profile_domain.py
CI Bot 2df7bc9dc8
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m41s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m45s
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m54s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m1s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m34s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 5m49s
CI/CD Pipeline / Frontend Lint (push) Successful in 6m0s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 4m27s
CI/CD Pipeline / Integration Tests (push) Successful in 2m20s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m11s
CI/CD Pipeline / Unit Tests (push) Failing after 5m52s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 30s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m48s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m10s
style: auto-format with black + isort + prettier
2026-07-24 13:35:05 +00:00

360 lines
13 KiB
Python
Executable File
Raw Permalink 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.
"""VoiceCloneProfile 领域层单元测试 - voice_clone_profile.py"""
import pytest
from packages.domain.voice_clone_profile import (
TERMINAL_STATUSES,
VoiceCloneProfile,
VoiceCloneStatus,
)
class TestVoiceCloneStatus:
"""VoiceCloneStatus 枚举测试"""
def test_all_statuses_have_values(self):
for s in VoiceCloneStatus:
assert isinstance(s.value, str)
assert s.value
def test_str_enum(self):
assert VoiceCloneStatus.PENDING == "pending"
assert isinstance(VoiceCloneStatus.PENDING, str)
def test_terminal_statuses(self):
assert VoiceCloneStatus.READY in TERMINAL_STATUSES
assert VoiceCloneStatus.FAILED in TERMINAL_STATUSES
assert VoiceCloneStatus.DISABLED in TERMINAL_STATUSES
def test_non_terminal(self):
assert VoiceCloneStatus.PENDING not in TERMINAL_STATUSES
assert VoiceCloneStatus.PROCESSING not in TERMINAL_STATUSES
class TestVoiceCloneProfileCreate:
"""create 工厂方法测试"""
def test_create_basic(self):
profile = VoiceCloneProfile.create(user_id="user-1", name="我的音色")
assert profile.id
assert len(profile.id) == 32
assert profile.user_id == "user-1"
assert profile.name == "我的音色"
assert profile.status == VoiceCloneStatus.PENDING
assert profile.description == ""
assert profile.source_audio_url == ""
assert profile.voice_id == ""
assert profile.language == "zh-CN"
assert profile.gender == "unknown"
assert profile.retry_count == 0
assert profile.max_retries == 3
assert profile.metadata == {}
def test_create_empty_user_id_raises(self):
with pytest.raises(ValueError, match="user_id 不能为空"):
VoiceCloneProfile.create(user_id=" ", name="test")
def test_create_empty_name_raises(self):
with pytest.raises(ValueError, match="name 不能为空"):
VoiceCloneProfile.create(user_id="u1", name=" ")
def test_create_name_too_long_raises(self):
long_name = "a" * 101
with pytest.raises(ValueError, match="name 长度不能超过 100"):
VoiceCloneProfile.create(user_id="u1", name=long_name)
def test_create_name_exactly_100_ok(self):
name = "a" * 100
profile = VoiceCloneProfile.create(user_id="u1", name=name)
assert profile.name == name
def test_create_with_description(self):
profile = VoiceCloneProfile.create(user_id="u1", name="test", description="温暖男声")
assert profile.description == "温暖男声"
def test_create_with_source_audio(self):
profile = VoiceCloneProfile.create(user_id="u1", name="test", source_audio_url="http://audio.com/source.wav")
assert profile.source_audio_url == "http://audio.com/source.wav"
def test_create_with_language(self):
profile = VoiceCloneProfile.create(user_id="u1", name="test", language="en-US")
assert profile.language == "en-US"
def test_create_gender_lowercased(self):
profile = VoiceCloneProfile.create(user_id="u1", name="test", gender="MALE")
assert profile.gender == "male"
def test_create_with_metadata(self):
meta = {"source": "upload", "duration": 30}
profile = VoiceCloneProfile.create(user_id="u1", name="test", metadata=meta)
assert profile.metadata == meta
def test_create_none_metadata_defaults_empty(self):
profile = VoiceCloneProfile.create(user_id="u1", name="test", metadata=None)
assert profile.metadata == {}
def test_create_fields_stripped(self):
profile = VoiceCloneProfile.create(
user_id=" u1 ",
name=" test ",
description=" desc ",
source_audio_url=" url ",
voice_model=" model ",
language=" zh-CN ",
)
assert profile.user_id == "u1"
assert profile.name == "test"
assert profile.description == "desc"
assert profile.source_audio_url == "url"
assert profile.voice_model == "model"
assert profile.language == "zh-CN"
def test_create_custom_max_retries(self):
profile = VoiceCloneProfile.create(user_id="u1", name="test", max_retries=5)
assert profile.max_retries == 5
class TestVoiceCloneProfileProperties:
"""属性测试"""
def test_is_terminal_pending(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
assert p.is_terminal is False
def test_is_terminal_ready(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
p.mark_processing()
p.mark_ready("voice-123")
assert p.is_terminal is True
def test_is_terminal_failed(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
p.mark_processing()
p.mark_failed("error")
assert p.is_terminal is True
def test_is_terminal_disabled(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
p.mark_disabled()
assert p.is_terminal is True
def test_is_retryable_failed_within_limit(self):
p = VoiceCloneProfile.create(user_id="u1", name="test", max_retries=3)
p.mark_processing()
p.mark_failed("error")
assert p.is_retryable is True
def test_is_retryable_failed_at_limit(self):
p = VoiceCloneProfile.create(user_id="u1", name="test", max_retries=1)
p.mark_processing()
p.mark_failed("error")
p.retry_count = 1
assert p.is_retryable is False
def test_is_ready_true(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
p.mark_processing()
p.mark_ready("voice-123")
assert p.is_ready is True
def test_is_ready_no_voice_id(self):
"""ready 状态但没有 voice_idis_ready 为 False"""
p = VoiceCloneProfile.create(user_id="u1", name="test")
p.status = VoiceCloneStatus.READY
p.voice_id = ""
assert p.is_ready is False
def test_is_ready_not_ready_status(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
p.voice_id = "some-id"
assert p.is_ready is False # 状态是 PENDING
class TestVoiceCloneProfileTransitions:
"""状态转换测试"""
def test_pending_to_processing(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
p.transition_to(VoiceCloneStatus.PROCESSING)
assert p.status == VoiceCloneStatus.PROCESSING
def test_pending_to_failed(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
p.transition_to(VoiceCloneStatus.FAILED)
assert p.status == VoiceCloneStatus.FAILED
def test_pending_to_disabled(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
p.transition_to(VoiceCloneStatus.DISABLED)
assert p.status == VoiceCloneStatus.DISABLED
def test_processing_to_ready(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
p.transition_to(VoiceCloneStatus.PROCESSING)
p.transition_to(VoiceCloneStatus.READY)
assert p.status == VoiceCloneStatus.READY
def test_processing_to_failed(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
p.transition_to(VoiceCloneStatus.PROCESSING)
p.transition_to(VoiceCloneStatus.FAILED)
assert p.status == VoiceCloneStatus.FAILED
def test_processing_to_disabled(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
p.transition_to(VoiceCloneStatus.PROCESSING)
p.transition_to(VoiceCloneStatus.DISABLED)
assert p.status == VoiceCloneStatus.DISABLED
def test_ready_to_disabled(self):
"""已就绪音色可以被禁用"""
p = VoiceCloneProfile.create(user_id="u1", name="test")
p.mark_processing()
p.mark_ready("voice-1")
p.transition_to(VoiceCloneStatus.DISABLED)
assert p.status == VoiceCloneStatus.DISABLED
def test_failed_to_pending_retry(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
p.transition_to(VoiceCloneStatus.PROCESSING)
p.transition_to(VoiceCloneStatus.FAILED)
p.transition_to(VoiceCloneStatus.PENDING)
assert p.status == VoiceCloneStatus.PENDING
def test_invalid_transition_raises(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
with pytest.raises(ValueError, match="非法状态转换"):
p.transition_to(VoiceCloneStatus.READY) # pending 不能直接到 ready
def test_disabled_to_pending_raises(self):
"""禁用后不能回到 pending"""
p = VoiceCloneProfile.create(user_id="u1", name="test")
p.mark_disabled()
with pytest.raises(ValueError, match="非法状态转换"):
p.transition_to(VoiceCloneStatus.PENDING)
def test_transition_with_string(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
p.transition_to("processing")
assert p.status == VoiceCloneStatus.PROCESSING
def test_transition_invalid_string_raises(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
with pytest.raises(ValueError, match="无效状态"):
p.transition_to("invalid")
def test_transition_updates_updated_at(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
old = p.updated_at
import time
time.sleep(0.001)
p.transition_to(VoiceCloneStatus.PROCESSING)
assert p.updated_at >= old
class TestVoiceCloneProfileMarkMethods:
"""便捷标记方法测试"""
def test_mark_processing(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
p.error_message = "prev error"
p.mark_processing()
assert p.status == VoiceCloneStatus.PROCESSING
assert p.error_message == ""
def test_mark_ready(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
p.mark_processing()
p.mark_ready("voice-abc123")
assert p.status == VoiceCloneStatus.READY
assert p.voice_id == "voice-abc123"
assert p.error_message == ""
def test_mark_ready_empty_voice_id_raises(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
p.mark_processing()
with pytest.raises(ValueError, match="voice_id 不能为空"):
p.mark_ready(" ")
def test_mark_failed(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
p.mark_processing()
p.mark_failed("音频质量太差")
assert p.status == VoiceCloneStatus.FAILED
assert p.error_message == "音频质量太差"
def test_mark_disabled(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
p.mark_disabled()
assert p.status == VoiceCloneStatus.DISABLED
class TestVoiceCloneProfileRetry:
"""重试逻辑测试"""
def test_prepare_retry(self):
p = VoiceCloneProfile.create(user_id="u1", name="test", max_retries=3)
p.mark_processing()
p.mark_failed("超时")
p.voice_id = "partial-id"
p.prepare_retry()
assert p.status == VoiceCloneStatus.PENDING
assert p.retry_count == 1
assert p.error_message == ""
assert p.voice_id == "" # 重试时清空 voice_id
def test_prepare_retry_not_retryable_raises(self):
p = VoiceCloneProfile.create(user_id="u1", name="test", max_retries=0)
p.mark_processing()
p.mark_failed("error")
with pytest.raises(ValueError, match="不可重试"):
p.prepare_retry()
def test_multiple_retries(self):
p = VoiceCloneProfile.create(user_id="u1", name="test", max_retries=3)
for i in range(3):
p.mark_processing()
p.mark_failed(f"error-{i}")
p.prepare_retry()
assert p.retry_count == i + 1
assert p.is_retryable is False
class TestVoiceCloneProfileToDict:
"""to_dict 序列化测试"""
def test_to_dict_contains_fields(self):
p = VoiceCloneProfile.create(
user_id="u1",
name="我的音色",
description="测试",
language="en-US",
gender="female",
)
d = p.to_dict()
assert d["id"] == p.id
assert d["user_id"] == "u1"
assert d["name"] == "我的音色"
assert d["description"] == "测试"
assert d["status"] == "pending"
assert d["language"] == "en-US"
assert d["gender"] == "female"
assert d["is_retryable"] is False
assert d["is_ready"] is False
def test_to_dict_datetime_are_strings(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
d = p.to_dict()
assert isinstance(d["created_at"], str)
assert isinstance(d["updated_at"], str)
def test_to_dict_after_ready(self):
p = VoiceCloneProfile.create(user_id="u1", name="test")
p.mark_processing()
p.mark_ready("voice-123")
d = p.to_dict()
assert d["status"] == "ready"
assert d["voice_id"] == "voice-123"
assert d["is_ready"] is True