Files
xiaoxia-saas/tests/unit/test_tts_job_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

345 lines
12 KiB
Python
Executable File

"""TTSJob 领域层单元测试 - tts_job.py"""
import pytest
from packages.domain.tts_job import (
TERMINAL_STATUSES,
TTSJob,
TTSJobStatus,
)
class TestTTSJobStatus:
"""TTSJobStatus 枚举测试"""
def test_all_statuses_have_values(self):
for s in TTSJobStatus:
assert isinstance(s.value, str)
assert s.value
def test_str_enum_behavior(self):
assert TTSJobStatus.PENDING == "pending"
assert isinstance(TTSJobStatus.PENDING, str)
def test_terminal_statuses(self):
assert TTSJobStatus.COMPLETED in TERMINAL_STATUSES
assert TTSJobStatus.FAILED in TERMINAL_STATUSES
assert TTSJobStatus.CANCELLED in TERMINAL_STATUSES
def test_non_terminal_statuses(self):
assert TTSJobStatus.PENDING not in TERMINAL_STATUSES
assert TTSJobStatus.PROCESSING not in TERMINAL_STATUSES
class TestTTSJobCreate:
"""TTSJob.create 工厂方法测试"""
def test_create_basic(self):
job = TTSJob.create(user_id="user-1", input_text="你好世界")
assert job.id
assert len(job.id) == 32
assert job.user_id == "user-1"
assert job.input_text == "你好世界"
assert job.status == TTSJobStatus.PENDING
assert job.voice_id == ""
assert job.voice_model == ""
assert job.format == "mp3"
assert job.sample_rate == 22050
assert job.retry_count == 0
assert job.max_retries == 3
assert job.metadata == {}
def test_create_empty_user_id_raises(self):
with pytest.raises(ValueError, match="user_id 不能为空"):
TTSJob.create(user_id=" ", input_text="test")
def test_create_empty_input_text_raises(self):
with pytest.raises(ValueError, match="input_text 不能为空"):
TTSJob.create(user_id="u1", input_text=" ")
def test_create_text_too_long_raises(self):
long_text = "a" * 10001
with pytest.raises(ValueError, match="input_text 长度不能超过 10000"):
TTSJob.create(user_id="u1", input_text=long_text)
def test_create_text_exactly_10000_ok(self):
text = "a" * 10000
job = TTSJob.create(user_id="u1", input_text=text)
assert len(job.input_text) == 10000
def test_create_invalid_format_raises(self):
with pytest.raises(ValueError, match="不支持的输出格式"):
TTSJob.create(user_id="u1", input_text="test", format="flac")
def test_create_mp3_format(self):
job = TTSJob.create(user_id="u1", input_text="test", format="mp3")
assert job.format == "mp3"
def test_create_wav_format(self):
job = TTSJob.create(user_id="u1", input_text="test", format="wav")
assert job.format == "wav"
def test_create_pcm_format(self):
job = TTSJob.create(user_id="u1", input_text="test", format="pcm")
assert job.format == "pcm"
def test_create_with_voice_id(self):
job = TTSJob.create(user_id="u1", input_text="test", voice_id="voice-1")
assert job.voice_id == "voice-1"
def test_create_with_project_id(self):
job = TTSJob.create(user_id="u1", input_text="test", project_id="proj-1")
assert job.project_id == "proj-1"
def test_create_with_clone_profile(self):
job = TTSJob.create(user_id="u1", input_text="test", voice_clone_profile_id="vc-1")
assert job.voice_clone_profile_id == "vc-1"
def test_create_with_metadata(self):
meta = {"source": "api", "priority": "high"}
job = TTSJob.create(user_id="u1", input_text="test", metadata=meta)
assert job.metadata == meta
def test_create_none_metadata_defaults_empty(self):
job = TTSJob.create(user_id="u1", input_text="test", metadata=None)
assert job.metadata == {}
def test_create_input_text_stripped(self):
job = TTSJob.create(user_id=" u1 ", input_text=" hello ")
assert job.user_id == "u1"
assert job.input_text == "hello"
def test_create_custom_max_retries(self):
job = TTSJob.create(user_id="u1", input_text="test", max_retries=5)
assert job.max_retries == 5
class TestTTSJobProperties:
"""属性测试"""
def test_is_terminal_pending(self):
job = TTSJob.create(user_id="u1", input_text="test")
assert job.is_terminal is False
def test_is_terminal_completed(self):
job = TTSJob.create(user_id="u1", input_text="test")
job.mark_processing()
job.mark_completed("http://audio.com/a.mp3")
assert job.is_terminal is True
def test_is_terminal_failed(self):
job = TTSJob.create(user_id="u1", input_text="test")
job.mark_processing()
job.mark_failed("error")
assert job.is_terminal is True
def test_is_retryable_failed_within_limit(self):
job = TTSJob.create(user_id="u1", input_text="test", max_retries=3)
job.mark_processing()
job.mark_failed("error")
assert job.is_retryable is True
def test_is_retryable_failed_at_limit(self):
job = TTSJob.create(user_id="u1", input_text="test", max_retries=1)
job.mark_processing()
job.mark_failed("error")
job.retry_count = 1
assert job.is_retryable is False
def test_is_completed_true(self):
job = TTSJob.create(user_id="u1", input_text="test")
job.mark_processing()
job.mark_completed("http://audio.com/a.mp3")
assert job.is_completed is True
def test_is_completed_no_url(self):
"""completed 状态但没有 output_audio_url 为空,is_completed 为 False"""
job = TTSJob.create(user_id="u1", input_text="test")
job.status = TTSJobStatus.COMPLETED
job.output_audio_url = ""
assert job.is_completed is False
class TestTTSJobTransitions:
"""状态转换测试"""
def test_pending_to_processing(self):
job = TTSJob.create(user_id="u1", input_text="test")
job.transition_to(TTSJobStatus.PROCESSING)
assert job.status == TTSJobStatus.PROCESSING
def test_pending_to_failed(self):
job = TTSJob.create(user_id="u1", input_text="test")
job.transition_to(TTSJobStatus.FAILED)
assert job.status == TTSJobStatus.FAILED
def test_pending_to_cancelled(self):
job = TTSJob.create(user_id="u1", input_text="test")
job.transition_to(TTSJobStatus.CANCELLED)
assert job.status == TTSJobStatus.CANCELLED
def test_processing_to_completed(self):
job = TTSJob.create(user_id="u1", input_text="test")
job.transition_to(TTSJobStatus.PROCESSING)
job.transition_to(TTSJobStatus.COMPLETED)
assert job.status == TTSJobStatus.COMPLETED
def test_processing_to_failed(self):
job = TTSJob.create(user_id="u1", input_text="test")
job.transition_to(TTSJobStatus.PROCESSING)
job.transition_to(TTSJobStatus.FAILED)
assert job.status == TTSJobStatus.FAILED
def test_failed_to_pending_retry(self):
job = TTSJob.create(user_id="u1", input_text="test")
job.transition_to(TTSJobStatus.PROCESSING)
job.transition_to(TTSJobStatus.FAILED)
job.transition_to(TTSJobStatus.PENDING)
assert job.status == TTSJobStatus.PENDING
def test_invalid_transition_raises(self):
job = TTSJob.create(user_id="u1", input_text="test")
with pytest.raises(ValueError, match="非法状态转换"):
job.transition_to(TTSJobStatus.COMPLETED) # pending 不能直接到 completed
def test_transition_with_string(self):
job = TTSJob.create(user_id="u1", input_text="test")
job.transition_to("processing")
assert job.status == TTSJobStatus.PROCESSING
def test_transition_invalid_string_raises(self):
job = TTSJob.create(user_id="u1", input_text="test")
with pytest.raises(ValueError, match="无效状态"):
job.transition_to("invalid")
def test_transition_updates_updated_at(self):
job = TTSJob.create(user_id="u1", input_text="test")
old = job.updated_at
import time
time.sleep(0.001)
job.transition_to(TTSJobStatus.PROCESSING)
assert job.updated_at >= old
class TestTTSJobMarkMethods:
"""便捷标记方法测试"""
def test_mark_processing(self):
job = TTSJob.create(user_id="u1", input_text="test")
job.error_message = "previous error"
job.mark_processing()
assert job.status == TTSJobStatus.PROCESSING
assert job.started_at is not None
assert job.error_message == ""
def test_mark_completed(self):
job = TTSJob.create(user_id="u1", input_text="test")
job.mark_processing()
job.mark_completed(
"http://example.com/audio.mp3",
output_audio_key="audio/key.mp3",
duration=5.5,
file_size=102400,
)
assert job.status == TTSJobStatus.COMPLETED
assert job.output_audio_url == "http://example.com/audio.mp3"
assert job.output_audio_key == "audio/key.mp3"
assert job.duration == 5.5
assert job.file_size == 102400
assert job.completed_at is not None
assert job.error_message == ""
def test_mark_completed_empty_url_raises(self):
job = TTSJob.create(user_id="u1", input_text="test")
job.mark_processing()
with pytest.raises(ValueError, match="output_audio_url 不能为空"):
job.mark_completed(" ")
def test_mark_failed(self):
job = TTSJob.create(user_id="u1", input_text="test")
job.mark_processing()
job.mark_failed("API 调用超时")
assert job.status == TTSJobStatus.FAILED
assert job.error_message == "API 调用超时"
def test_mark_cancelled(self):
job = TTSJob.create(user_id="u1", input_text="test")
job.mark_cancelled()
assert job.status == TTSJobStatus.CANCELLED
class TestTTSJobRetry:
"""重试逻辑测试"""
def test_prepare_retry(self):
job = TTSJob.create(user_id="u1", input_text="test", max_retries=3)
job.mark_processing()
job.mark_failed("超时")
job.prepare_retry()
assert job.status == TTSJobStatus.PENDING
assert job.retry_count == 1
assert job.error_message == ""
assert job.started_at is None
assert job.completed_at is None
def test_prepare_retry_not_retryable_raises(self):
job = TTSJob.create(user_id="u1", input_text="test", max_retries=0)
job.mark_processing()
job.mark_failed("error")
with pytest.raises(ValueError, match="不可重试"):
job.prepare_retry()
def test_multiple_retries(self):
job = TTSJob.create(user_id="u1", input_text="test", max_retries=3)
for i in range(3):
job.mark_processing()
job.mark_failed(f"error-{i}")
job.prepare_retry()
assert job.retry_count == i + 1
# 第3次重试后 retry_count=3,等于 max_retries=3,不可再重试
assert job.is_retryable is False
class TestTTSJobToDict:
"""to_dict 序列化测试"""
def test_to_dict_contains_fields(self):
job = TTSJob.create(
user_id="u1",
input_text="test",
voice_id="voice-1",
project_id="proj-1",
)
d = job.to_dict()
assert d["id"] == job.id
assert d["user_id"] == "u1"
assert d["input_text"] == "test"
assert d["voice_id"] == "voice-1"
assert d["status"] == "pending"
assert d["is_retryable"] is False
assert d["is_completed"] is False
def test_to_dict_datetime_are_strings(self):
job = TTSJob.create(user_id="u1", input_text="test")
d = job.to_dict()
assert isinstance(d["created_at"], str)
assert isinstance(d["updated_at"], str)
def test_to_dict_none_datetime(self):
job = TTSJob.create(user_id="u1", input_text="test")
d = job.to_dict()
assert d["started_at"] is None
assert d["completed_at"] is None
def test_to_dict_after_completion(self):
job = TTSJob.create(user_id="u1", input_text="test")
job.mark_processing()
job.mark_completed("http://test.mp3", duration=10.0)
d = job.to_dict()
assert d["status"] == "completed"
assert d["duration"] == 10.0
assert d["is_completed"] is True
assert d["started_at"] is not None
assert d["completed_at"] is not None