diff --git a/tests/unit/test_generated_video_verification_code.py b/tests/unit/test_generated_video_verification_code.py new file mode 100755 index 000000000..03487ff58 --- /dev/null +++ b/tests/unit/test_generated_video_verification_code.py @@ -0,0 +1,323 @@ +"""GeneratedVideo + VerificationCode 领域模型测试.""" + +from __future__ import annotations + +from datetime import timedelta + +import pytest + +from packages.domain.generated_video import GeneratedVideo +from packages.domain.verification_code import VerificationCode + + +class TestGeneratedVideo: + """GeneratedVideo 生成视频实体测试.""" + + def test_create_success(self): + """创建成功.""" + video = GeneratedVideo.create( + project_id="p1", + generation_task_id="task_1", + name="我的视频.mp4", + file_url="https://example.com/out.mp4", + user_id="u1", + file_size=1024000, + duration=30.5, + width=1080, + height=1920, + fps=25.0, + ) + assert video.id is not None + assert len(video.id) == 32 + assert video.project_id == "p1" + assert video.generation_task_id == "task_1" + assert video.name == "我的视频.mp4" + assert video.file_url == "https://example.com/out.mp4" + assert video.user_id == "u1" + assert video.file_size == 1024000 + assert video.duration == pytest.approx(30.5) + assert video.width == 1080 + assert video.height == 1920 + assert video.fps == pytest.approx(25.0) + assert video.status == "completed" + assert video.review_status == "pending_review" + assert video.is_duplicate is False + assert video.duplicate_of is None + assert video.generation_params == {} + + def test_create_empty_project_id_raises(self): + """空project_id抛异常.""" + with pytest.raises(ValueError, match="project_id"): + GeneratedVideo.create( + project_id=" ", + generation_task_id="t1", + name="v.mp4", + file_url="https://x.com/v.mp4", + ) + + def test_create_empty_task_id_raises(self): + """空generation_task_id抛异常.""" + with pytest.raises(ValueError, match="generation_task_id"): + GeneratedVideo.create( + project_id="p1", + generation_task_id="", + name="v.mp4", + file_url="https://x.com/v.mp4", + ) + + def test_create_empty_name_raises(self): + """空name抛异常.""" + with pytest.raises(ValueError, match="name"): + GeneratedVideo.create( + project_id="p1", + generation_task_id="t1", + name=" ", + file_url="https://x.com/v.mp4", + ) + + def test_create_empty_file_url_raises(self): + """空file_url抛异常.""" + with pytest.raises(ValueError, match="file_url"): + GeneratedVideo.create( + project_id="p1", + generation_task_id="t1", + name="v.mp4", + file_url="", + ) + + def test_create_strips_whitespace(self): + """首尾空白被去除.""" + video = GeneratedVideo.create( + project_id=" p1 ", + generation_task_id=" t1 ", + name=" 视频.mp4 ", + file_url=" https://x.com/v.mp4 ", + ) + assert video.project_id == "p1" + assert video.generation_task_id == "t1" + assert video.name == "视频.mp4" + assert video.file_url == "https://x.com/v.mp4" + + def test_default_values(self): + """默认值正确.""" + video = GeneratedVideo.create( + project_id="p1", + generation_task_id="t1", + name="v.mp4", + file_url="https://x.com/v.mp4", + ) + assert video.user_id == "" + assert video.file_size == 0 + assert video.duration == 0.0 + assert video.width == 0 + assert video.height == 0 + assert video.fps == 0.0 + assert video.thumbnail_url is None + assert video.generation_params == {} + + def test_generation_params_none_becomes_empty(self): + """generation_params=None → {}.""" + video = GeneratedVideo.create( + project_id="p1", + generation_task_id="t1", + name="v.mp4", + file_url="https://x.com/v.mp4", + generation_params=None, + ) + assert video.generation_params == {} + + def test_custom_generation_params(self): + """自定义生成参数.""" + params = {"mode": "smart", "resolution": "1080x1920"} + video = GeneratedVideo.create( + project_id="p1", + generation_task_id="t1", + name="v.mp4", + file_url="https://x.com/v.mp4", + generation_params=params, + ) + assert video.generation_params == params + + def test_duplicate_flag(self): + """重复标记可以设置.""" + video = GeneratedVideo.create( + project_id="p1", + generation_task_id="t1", + name="v.mp4", + file_url="https://x.com/v.mp4", + ) + video.is_duplicate = True + video.duplicate_of = "other_video_id" + assert video.is_duplicate is True + assert video.duplicate_of == "other_video_id" + + def test_custom_status(self): + """自定义状态.""" + video = GeneratedVideo.create( + project_id="p1", + generation_task_id="t1", + name="v.mp4", + file_url="https://x.com/v.mp4", + ) + video.status = "failed" + assert video.status == "failed" + + def test_thumbnail_url(self): + """缩略图URL.""" + video = GeneratedVideo.create( + project_id="p1", + generation_task_id="t1", + name="v.mp4", + file_url="https://x.com/v.mp4", + thumbnail_url="https://x.com/thumb.jpg", + ) + assert video.thumbnail_url == "https://x.com/thumb.jpg" + + +class TestVerificationCodeCreate: + """VerificationCode 创建测试.""" + + def test_create_success(self): + """创建验证码成功.""" + vc = VerificationCode.create( + recipient="test@example.com", + code_type="email_bind", + ttl_seconds=300, + ) + assert vc.id is not None + assert len(vc.id) == 32 + assert vc.recipient == "test@example.com" + assert vc.code_type == "email_bind" + assert len(vc.code) == 6 # 默认6位数字 + assert vc.code.isdigit() # 纯数字 + assert vc.used_at is None + assert vc.attempts == 0 + + def test_create_with_custom_code(self): + """自定义验证码.""" + vc = VerificationCode.create( + recipient="u@test.com", + code_type="email_login", + custom_code="123456", + ) + assert vc.code == "123456" + + def test_create_recipient_stripped(self): + """收件人空白被去除.""" + vc = VerificationCode.create( + recipient=" test@example.com ", + code_type="email_bind", + ) + assert vc.recipient == "test@example.com" + + def test_expiry_time_correct(self): + """过期时间正确(5分钟后).""" + from datetime import datetime, timezone + + before = datetime.now(timezone.utc) + timedelta(seconds=299) + vc = VerificationCode.create( + recipient="u@test.com", + code_type="reset_password", + ttl_seconds=300, + ) + after = datetime.now(timezone.utc) + timedelta(seconds=301) + assert before <= vc.expires_at <= after + + def test_custom_ttl(self): + """自定义过期时间.""" + vc = VerificationCode.create( + recipient="u@test.com", + code_type="phone_login", + ttl_seconds=60, + ) + from datetime import datetime, timezone + + now = datetime.now(timezone.utc) + # 应该在1分钟左右过期 + diff = (vc.expires_at - now).total_seconds() + assert 0 < diff < 70 + + +class TestVerificationCodeProperties: + """VerificationCode 属性方法测试.""" + + def test_is_expired_false_for_new(self): + """新创建的验证码未过期.""" + vc = VerificationCode.create( + recipient="u@test.com", + code_type="email_bind", + ttl_seconds=300, + ) + assert vc.is_expired is False + + def test_is_expired_true_when_past(self): + """已过期的验证码is_expired=True.""" + vc = VerificationCode.create( + recipient="u@test.com", + code_type="email_bind", + ttl_seconds=1, + ) + # 手动改过期时间到过去 + from datetime import datetime, timezone, timedelta + + vc.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) + assert vc.is_expired is True + + def test_is_used_false_by_default(self): + """默认未使用.""" + vc = VerificationCode.create(recipient="u@test.com", code_type="email_bind") + assert vc.is_used is False + + def test_is_valid_fresh_code(self): + """新验证码有效.""" + vc = VerificationCode.create(recipient="u@test.com", code_type="email_bind") + assert vc.is_valid is True + + def test_is_valid_expired(self): + """过期的验证码无效.""" + vc = VerificationCode.create(recipient="u@test.com", code_type="email_bind", ttl_seconds=1) + from datetime import datetime, timezone, timedelta + + vc.expires_at = datetime.now(timezone.utc) - timedelta(seconds=1) + assert vc.is_valid is False + + def test_is_valid_used(self): + """已使用的验证码无效.""" + vc = VerificationCode.create(recipient="u@test.com", code_type="email_bind") + vc.mark_used() + assert vc.is_valid is False + + +class TestVerificationCodeActions: + """VerificationCode 操作方法测试.""" + + def test_mark_used(self): + """标记使用.""" + vc = VerificationCode.create(recipient="u@test.com", code_type="email_bind") + vc.mark_used() + assert vc.is_used is True + assert vc.used_at is not None + + def test_mark_used_twice(self): + """标记两次也没问题.""" + vc = VerificationCode.create(recipient="u@test.com", code_type="email_bind") + vc.mark_used() + first_time = vc.used_at + vc.mark_used() + # 第二次会覆盖时间 + assert vc.used_at >= first_time + + def test_increment_attempts(self): + """增加尝试次数.""" + vc = VerificationCode.create(recipient="u@test.com", code_type="email_bind") + assert vc.attempts == 0 + vc.increment_attempts() + assert vc.attempts == 1 + vc.increment_attempts() + assert vc.attempts == 2 + + def test_all_code_types_supported(self): + """支持所有code_type.""" + for code_type in ["email_bind", "phone_bind", "email_login", "phone_login", "reset_password"]: + vc = VerificationCode.create(recipient="u@test.com", code_type=code_type) + assert vc.code_type == code_type diff --git a/tests/unit/test_tts_job_domain_extended.py b/tests/unit/test_tts_job_domain_extended.py new file mode 100755 index 000000000..6f878a2c4 --- /dev/null +++ b/tests/unit/test_tts_job_domain_extended.py @@ -0,0 +1,403 @@ +"""TTSJob领域模型测试 — 状态机 + 状态转换 + 属性方法.""" + +from __future__ import annotations + +import pytest + +from packages.domain.tts_job import ( + TERMINAL_STATUSES, + TTSJob, + TTSJobStatus, +) + + +def _make_job( + *, + status: TTSJobStatus = TTSJobStatus.PENDING, + retry_count: int = 0, + max_retries: int = 3, + output_audio_url: str = "", +) -> TTSJob: + """快速创建测试用TTSJob.""" + return TTSJob( + id="job_1", + user_id="user_1", + input_text="测试文本", + voice_id="female_warm", + status=status, + output_audio_url=output_audio_url, + retry_count=retry_count, + max_retries=max_retries, + ) + + +class TestTTSJobStatus: + """TTSJobStatus 枚举测试.""" + + def test_all_statuses_exist(self): + """所有5种状态都存在.""" + assert TTSJobStatus.PENDING.value == "pending" + assert TTSJobStatus.PROCESSING.value == "processing" + assert TTSJobStatus.COMPLETED.value == "completed" + assert TTSJobStatus.FAILED.value == "failed" + assert TTSJobStatus.CANCELLED.value == "cancelled" + + def test_terminal_statuses(self): + """终态集合包含COMPLETED/FAILED/CANCELLED.""" + assert TTSJobStatus.COMPLETED in TERMINAL_STATUSES + assert TTSJobStatus.FAILED in TERMINAL_STATUSES + assert TTSJobStatus.CANCELLED in TERMINAL_STATUSES + assert TTSJobStatus.PENDING not in TERMINAL_STATUSES + assert TTSJobStatus.PROCESSING not in TERMINAL_STATUSES + + +class TestTTSJobCreate: + """TTSJob.create 创建测试.""" + + def test_create_pending_job(self): + """创建成功,默认PENDING状态.""" + job = TTSJob.create( + user_id="u1", + input_text="你好世界", + voice_id="v1", + ) + assert job.id is not None + assert len(job.id) == 32 + assert job.user_id == "u1" + assert job.input_text == "你好世界" + assert job.voice_id == "v1" + assert job.status == TTSJobStatus.PENDING + assert job.retry_count == 0 + assert job.max_retries == 3 + assert job.sample_rate == 22050 + + def test_create_empty_user_id_raises(self): + """空user_id抛异常.""" + with pytest.raises(ValueError, match="user_id"): + TTSJob.create(user_id=" ", input_text="hi", voice_id="v") + + def test_create_empty_text_raises(self): + """空input_text抛异常.""" + with pytest.raises(ValueError, match="input_text"): + TTSJob.create(user_id="u1", input_text="", voice_id="v") + + def test_create_strips_whitespace(self): + """首尾空白被去除.""" + job = TTSJob.create( + user_id=" u1 ", + input_text=" 你好 ", + voice_id=" v1 ", + ) + assert job.user_id == "u1" + assert job.input_text == "你好" + assert job.voice_id == "v1" + + +class TestTerminalStatus: + """is_terminal 终态判定测试.""" + + def test_pending_not_terminal(self): + """PENDING不是终态.""" + job = _make_job(status=TTSJobStatus.PENDING) + assert job.is_terminal is False + + def test_processing_not_terminal(self): + """PROCESSING不是终态.""" + job = _make_job(status=TTSJobStatus.PROCESSING) + assert job.is_terminal is False + + def test_completed_is_terminal(self): + """COMPLETED是终态.""" + job = _make_job(status=TTSJobStatus.COMPLETED) + assert job.is_terminal is True + + def test_failed_is_terminal(self): + """FAILED是终态.""" + job = _make_job(status=TTSJobStatus.FAILED) + assert job.is_terminal is True + + def test_cancelled_is_terminal(self): + """CANCELLED是终态.""" + job = _make_job(status=TTSJobStatus.CANCELLED) + assert job.is_terminal is True + + +class TestIsRetryable: + """is_retryable 可重试判定测试.""" + + def test_failed_within_limit_is_retryable(self): + """失败且未超上限→可重试.""" + job = _make_job(status=TTSJobStatus.FAILED, retry_count=1, max_retries=3) + assert job.is_retryable is True + + def test_failed_at_limit_not_retryable(self): + """失败且已达上限→不可重试.""" + job = _make_job(status=TTSJobStatus.FAILED, retry_count=3, max_retries=3) + assert job.is_retryable is False + + def test_failed_over_limit_not_retryable(self): + """失败且超上限→不可重试.""" + job = _make_job(status=TTSJobStatus.FAILED, retry_count=5, max_retries=3) + assert job.is_retryable is False + + def test_pending_not_retryable(self): + """PENDING不可重试.""" + job = _make_job(status=TTSJobStatus.PENDING) + assert job.is_retryable is False + + def test_completed_not_retryable(self): + """COMPLETED不可重试.""" + job = _make_job(status=TTSJobStatus.COMPLETED) + assert job.is_retryable is False + + def test_cancelled_not_retryable(self): + """CANCELLED不可重试.""" + job = _make_job(status=TTSJobStatus.CANCELLED) + assert job.is_retryable is False + + +class TestIsCompleted: + """is_completed 完成判定测试.""" + + def test_completed_with_output_is_completed(self): + """COMPLETED + 有输出URL→已完成.""" + job = _make_job( + status=TTSJobStatus.COMPLETED, + output_audio_url="https://example.com/out.mp3", + ) + assert job.is_completed is True + + def test_completed_without_output_not_completed(self): + """COMPLETED但无输出URL→不算完成.""" + job = _make_job(status=TTSJobStatus.COMPLETED, output_audio_url="") + assert job.is_completed is False + + def test_pending_not_completed(self): + """PENDING不是完成.""" + job = _make_job(status=TTSJobStatus.PENDING) + assert job.is_completed is False + + def test_failed_not_completed(self): + """FAILED不是完成.""" + job = _make_job(status=TTSJobStatus.FAILED) + assert job.is_completed is False + + +class TestStateTransitions: + """状态机转换测试.""" + + def test_pending_to_processing(self): + """PENDING → PROCESSING 合法.""" + job = _make_job(status=TTSJobStatus.PENDING) + job.transition_to(TTSJobStatus.PROCESSING) + assert job.status == TTSJobStatus.PROCESSING + + def test_pending_to_failed(self): + """PENDING → FAILED 合法.""" + job = _make_job(status=TTSJobStatus.PENDING) + job.transition_to(TTSJobStatus.FAILED) + assert job.status == TTSJobStatus.FAILED + + def test_pending_to_cancelled(self): + """PENDING → CANCELLED 合法.""" + job = _make_job(status=TTSJobStatus.PENDING) + job.transition_to(TTSJobStatus.CANCELLED) + assert job.status == TTSJobStatus.CANCELLED + + def test_pending_to_completed_illegal(self): + """PENDING → COMPLETED 非法.""" + job = _make_job(status=TTSJobStatus.PENDING) + with pytest.raises(ValueError, match="非法状态转换"): + job.transition_to(TTSJobStatus.COMPLETED) + + def test_processing_to_completed(self): + """PROCESSING → COMPLETED 合法.""" + job = _make_job(status=TTSJobStatus.PROCESSING) + job.transition_to(TTSJobStatus.COMPLETED) + assert job.status == TTSJobStatus.COMPLETED + + def test_processing_to_failed(self): + """PROCESSING → FAILED 合法.""" + job = _make_job(status=TTSJobStatus.PROCESSING) + job.transition_to(TTSJobStatus.FAILED) + assert job.status == TTSJobStatus.FAILED + + def test_failed_to_pending_retry(self): + """FAILED → PENDING 合法(重试).""" + job = _make_job(status=TTSJobStatus.FAILED) + job.transition_to(TTSJobStatus.PENDING) + assert job.status == TTSJobStatus.PENDING + + def test_failed_to_completed_illegal(self): + """FAILED → COMPLETED 非法.""" + job = _make_job(status=TTSJobStatus.FAILED) + with pytest.raises(ValueError): + job.transition_to(TTSJobStatus.COMPLETED) + + def test_completed_to_anything_illegal(self): + """COMPLETED → 任何状态都非法(终态不可转换).""" + job = _make_job(status=TTSJobStatus.COMPLETED) + with pytest.raises(ValueError): + job.transition_to(TTSJobStatus.FAILED) + with pytest.raises(ValueError): + job.transition_to(TTSJobStatus.PENDING) + + def test_cancelled_to_anything_illegal(self): + """CANCELLED → 任何状态都非法.""" + job = _make_job(status=TTSJobStatus.CANCELLED) + with pytest.raises(ValueError): + job.transition_to(TTSJobStatus.PENDING) + + def test_transition_updates_updated_at(self): + """状态转换更新updated_at.""" + job = _make_job(status=TTSJobStatus.PENDING) + old_updated = job.updated_at + job.transition_to(TTSJobStatus.PROCESSING) + assert job.updated_at >= old_updated + + def test_transition_with_string_status(self): + """字符串状态也能转换.""" + job = _make_job(status=TTSJobStatus.PENDING) + job.transition_to("processing") + assert job.status == TTSJobStatus.PROCESSING + + def test_transition_with_invalid_string_raises(self): + """无效字符串状态抛异常.""" + job = _make_job(status=TTSJobStatus.PENDING) + with pytest.raises(ValueError, match="无效状态"): + job.transition_to("invalid_status") + + +class TestMarkProcessing: + """mark_processing 标记处理中测试.""" + + def test_mark_processing_sets_status_and_time(self): + """标记处理中更新状态+开始时间+清错误.""" + job = _make_job(status=TTSJobStatus.PENDING) + job.error_message = "旧错误" + job.mark_processing() + assert job.status == TTSJobStatus.PROCESSING + assert job.started_at is not None + assert job.error_message == "" + + +class TestMarkCompleted: + """mark_completed 标记完成测试.""" + + def test_mark_completed_success(self): + """成功标记完成.""" + job = _make_job(status=TTSJobStatus.PROCESSING) + job.mark_completed( + output_audio_url="https://example.com/out.mp3", + output_audio_key="tts/jobs/job_1/out.mp3", + duration=10.5, + file_size=204800, + ) + assert job.status == TTSJobStatus.COMPLETED + assert job.output_audio_url == "https://example.com/out.mp3" + assert job.output_audio_key == "tts/jobs/job_1/out.mp3" + assert job.duration == pytest.approx(10.5) + assert job.file_size == 204800 + assert job.completed_at is not None + assert job.error_message == "" + + def test_mark_completed_empty_url_raises(self): + """空URL抛异常.""" + job = _make_job(status=TTSJobStatus.PROCESSING) + with pytest.raises(ValueError, match="output_audio_url"): + job.mark_completed(output_audio_url=" ") + + def test_mark_completed_strips_url(self): + """URL首尾空白被去除.""" + job = _make_job(status=TTSJobStatus.PROCESSING) + job.mark_completed(output_audio_url=" https://example.com/out.mp3 ") + assert job.output_audio_url == "https://example.com/out.mp3" + + def test_mark_completed_from_pending_illegal(self): + """从PENDING直接标记完成非法(先processing).""" + job = _make_job(status=TTSJobStatus.PENDING) + with pytest.raises(ValueError, match="非法状态转换"): + job.mark_completed(output_audio_url="https://x.com/out.mp3") + + +class TestMarkFailed: + """mark_failed 标记失败测试.""" + + def test_mark_failed_from_pending(self): + """从PENDING标记失败.""" + job = _make_job(status=TTSJobStatus.PENDING) + job.mark_failed("网络超时") + assert job.status == TTSJobStatus.FAILED + assert job.error_message == "网络超时" + + def test_mark_failed_from_processing(self): + """从PROCESSING标记失败.""" + job = _make_job(status=TTSJobStatus.PROCESSING) + job.mark_failed("合成失败") + assert job.status == TTSJobStatus.FAILED + assert job.error_message == "合成失败" + + def test_mark_failed_from_completed_illegal(self): + """从COMPLETED标记失败非法.""" + job = _make_job(status=TTSJobStatus.COMPLETED, output_audio_url="https://x.com/out.mp3") + with pytest.raises(ValueError): + job.mark_failed("错误") + + +class TestMarkCancelled: + """mark_cancelled 标记取消测试.""" + + def test_cancel_from_pending(self): + """从PENDING取消.""" + job = _make_job(status=TTSJobStatus.PENDING) + job.mark_cancelled() + assert job.status == TTSJobStatus.CANCELLED + + def test_cancel_from_processing(self): + """从PROCESSING取消.""" + job = _make_job(status=TTSJobStatus.PROCESSING) + job.mark_cancelled() + assert job.status == TTSJobStatus.CANCELLED + + def test_cancel_from_completed_illegal(self): + """从COMPLETED取消非法.""" + job = _make_job(status=TTSJobStatus.COMPLETED, output_audio_url="https://x.com/out.mp3") + with pytest.raises(ValueError): + job.mark_cancelled() + + +class TestPrepareRetry: + """prepare_retry 重试准备测试.""" + + def test_retry_resets_to_pending(self): + """重试重置为PENDING,retry_count+1.""" + job = _make_job(status=TTSJobStatus.FAILED, retry_count=0, max_retries=3) + job.error_message = "失败了" + 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_retry_at_max_raises(self): + """已达重试上限时不能再重试.""" + job = _make_job(status=TTSJobStatus.FAILED, retry_count=3, max_retries=3) + with pytest.raises(ValueError, match="不可重试"): + job.prepare_retry() + + def test_retry_from_pending_raises(self): + """PENDING状态不能重试.""" + job = _make_job(status=TTSJobStatus.PENDING, retry_count=0, max_retries=3) + with pytest.raises(ValueError): + job.prepare_retry() + + def test_multiple_retries_increment(self): + """多次重试计数递增.""" + job = _make_job(status=TTSJobStatus.FAILED, retry_count=0, max_retries=5) + job.prepare_retry() + assert job.retry_count == 1 + # 模拟再次失败 + job.mark_failed("又失败了") + job.prepare_retry() + assert job.retry_count == 2