"""Job 领域模型单元测试。""" from datetime import datetime, timezone import pytest from packages.domain.job import ( TERMINAL_STATUSES, Job, JobStatus, JobType, ) class TestJobTypeEnum: def test_all_types_exist(self): assert JobType.VIDEO_COMPOSE.value == "video_compose" assert JobType.RENDER_EDIT_PLAN.value == "render_edit_plan" assert JobType.ASSET_INGEST.value == "asset_ingest" assert JobType.CLASSIFICATION.value == "classification" assert JobType.VOICE_EXTRACTION.value == "voice_extraction" assert JobType.GENERATION.value == "generation" def test_from_string(self): assert JobType("video_compose") == JobType.VIDEO_COMPOSE assert JobType("generation") == JobType.GENERATION def test_invalid_type_raises(self): with pytest.raises(ValueError): JobType("invalid_type") class TestJobStatusEnum: def test_all_statuses_exist(self): assert JobStatus.PENDING.value == "pending" assert JobStatus.RUNNING.value == "running" assert JobStatus.SUCCESS.value == "success" assert JobStatus.FAILED.value == "failed" assert JobStatus.CANCELLED.value == "cancelled" def test_from_string(self): assert JobStatus("pending") == JobStatus.PENDING assert JobStatus("success") == JobStatus.SUCCESS class TestTerminalStatuses: def test_success_is_terminal(self): assert JobStatus.SUCCESS in TERMINAL_STATUSES def test_failed_is_terminal(self): assert JobStatus.FAILED in TERMINAL_STATUSES def test_cancelled_is_terminal(self): assert JobStatus.CANCELLED in TERMINAL_STATUSES def test_pending_not_terminal(self): assert JobStatus.PENDING not in TERMINAL_STATUSES def test_running_not_terminal(self): assert JobStatus.RUNNING not in TERMINAL_STATUSES class TestJobCreate: def test_create_minimal(self): job = Job.create(project_id="proj1", job_type=JobType.VIDEO_COMPOSE) assert job.id is not None assert len(job.id) == 32 assert job.project_id == "proj1" assert job.job_type == JobType.VIDEO_COMPOSE assert job.status == JobStatus.PENDING assert job.progress == 0.0 assert job.current_stage == "" assert job.payload == {} assert job.result == {} assert job.error_message == "" assert job.retry_count == 0 assert job.max_retries == 3 assert job.celery_task_id == "" assert job.source_id == "" assert job.created_by_user_id == "" assert job.started_at is None assert job.completed_at is None assert isinstance(job.created_at, datetime) assert isinstance(job.updated_at, datetime) def test_create_with_enum_type(self): job = Job.create("p1", JobType.GENERATION) assert job.job_type == JobType.GENERATION def test_create_with_string_type(self): job = Job.create("p1", "video_compose") assert job.job_type == JobType.VIDEO_COMPOSE def test_create_with_payload(self): payload = {"edit_plan_id": "plan123", "resolution": "1080p"} job = Job.create("p1", JobType.VIDEO_COMPOSE, payload=payload) assert job.payload == payload def test_create_with_none_payload(self): job = Job.create("p1", JobType.VIDEO_COMPOSE, payload=None) assert job.payload == {} def test_create_with_source_id(self): job = Job.create("p1", JobType.GENERATION, source_id="gen123") assert job.source_id == "gen123" def test_create_with_user_id(self): job = Job.create("p1", JobType.VIDEO_COMPOSE, created_by_user_id="user1") assert job.created_by_user_id == "user1" def test_create_with_custom_max_retries(self): job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=5) assert job.max_retries == 5 def test_create_strips_project_id(self): job = Job.create(" proj1 ", JobType.VIDEO_COMPOSE) assert job.project_id == "proj1" def test_create_strips_source_id(self): job = Job.create("p1", JobType.VIDEO_COMPOSE, source_id=" src1 ") assert job.source_id == "src1" def test_create_strips_user_id(self): job = Job.create("p1", JobType.VIDEO_COMPOSE, created_by_user_id=" u1 ") assert job.created_by_user_id == "u1" def test_create_empty_project_id(self): with pytest.raises(ValueError, match="project_id 不能为空"): Job.create("", JobType.VIDEO_COMPOSE) def test_create_whitespace_project_id(self): with pytest.raises(ValueError, match="project_id 不能为空"): Job.create(" \t ", JobType.VIDEO_COMPOSE) def test_create_invalid_job_type_string(self): with pytest.raises(ValueError, match="不支持的任务类型"): Job.create("p1", "invalid_type") def test_create_unique_ids(self): j1 = Job.create("p1", JobType.VIDEO_COMPOSE) j2 = Job.create("p1", JobType.VIDEO_COMPOSE) assert j1.id != j2.id class TestIsTerminal: def test_pending_not_terminal(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) assert job.is_terminal is False def test_running_not_terminal(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.RUNNING) assert job.is_terminal is False def test_success_is_terminal(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.RUNNING) job.transition_to(JobStatus.SUCCESS) assert job.is_terminal is True def test_failed_is_terminal(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.RUNNING) job.transition_to(JobStatus.FAILED) assert job.is_terminal is True def test_cancelled_is_terminal(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.CANCELLED) assert job.is_terminal is True class TestIsRetryable: def test_pending_not_retryable(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) assert job.is_retryable is False def test_running_not_retryable(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.RUNNING) assert job.is_retryable is False def test_success_not_retryable(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.mark_running() job.mark_success() assert job.is_retryable is False def test_failed_within_limit_is_retryable(self): job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=3) job.mark_running() job.mark_failed("error") assert job.is_retryable is True def test_failed_at_limit_not_retryable(self): job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=3) job.mark_running() job.mark_failed("error") job.retry_count = 3 # 已达到上限 assert job.is_retryable is False def test_failed_over_limit_not_retryable(self): job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=3) job.retry_count = 5 job.status = JobStatus.FAILED assert job.is_retryable is False def test_zero_max_retries_not_retryable(self): job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=0) job.status = JobStatus.FAILED assert job.is_retryable is False class TestTransitionTo: def test_pending_to_running(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.RUNNING) assert job.status == JobStatus.RUNNING assert job.started_at is not None def test_pending_to_success(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.SUCCESS) assert job.status == JobStatus.SUCCESS assert job.completed_at is not None def test_pending_to_cancelled(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.CANCELLED) assert job.status == JobStatus.CANCELLED def test_pending_to_failed_invalid(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) with pytest.raises(ValueError, match="非法状态转换"): job.transition_to(JobStatus.FAILED) def test_running_to_success(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.RUNNING) job.transition_to(JobStatus.SUCCESS) assert job.status == JobStatus.SUCCESS def test_running_to_failed(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.RUNNING) job.transition_to(JobStatus.FAILED) assert job.status == JobStatus.FAILED def test_running_to_cancelled(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.RUNNING) job.transition_to(JobStatus.CANCELLED) assert job.status == JobStatus.CANCELLED def test_running_to_pending_invalid(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.RUNNING) with pytest.raises(ValueError, match="非法状态转换"): job.transition_to(JobStatus.PENDING) def test_failed_to_pending(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.RUNNING) job.transition_to(JobStatus.FAILED) # 注意:_VALID_TRANSITIONS 中 FAILED → PENDING 是允许的 job.transition_to(JobStatus.PENDING) assert job.status == JobStatus.PENDING def test_success_to_anything_invalid(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.RUNNING) job.transition_to(JobStatus.SUCCESS) with pytest.raises(ValueError): job.transition_to(JobStatus.FAILED) with pytest.raises(ValueError): job.transition_to(JobStatus.RUNNING) def test_transition_with_string_status(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to("running") assert job.status == JobStatus.RUNNING def test_transition_with_invalid_string(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) with pytest.raises(ValueError, match="无效状态"): job.transition_to("invalid_status") def test_transition_updates_updated_at(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) old_time = job.updated_at job.transition_to(JobStatus.RUNNING) assert job.updated_at >= old_time def test_started_at_only_set_once(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.transition_to(JobStatus.RUNNING) first_start = job.started_at # 再次 RUNNING 不合法,但我们测试 started_at 在多次 running→success→retry→running 时的行为 # 先失败重试 job.transition_to(JobStatus.FAILED) job.transition_to(JobStatus.PENDING) job.started_at = None # 模拟 prepare_retry 的重置 job.transition_to(JobStatus.RUNNING) assert job.started_at is not None assert job.started_at != first_start class TestMarkRunning: def test_mark_running_basic(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.mark_running() assert job.status == JobStatus.RUNNING assert job.started_at is not None def test_mark_running_with_stage(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.mark_running(stage="下载素材") assert job.status == JobStatus.RUNNING assert job.current_stage == "下载素材" def test_mark_running_empty_stage_unchanged(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.current_stage = "已有阶段" job.mark_running() # 不传 stage assert job.current_stage == "已有阶段" class TestMarkSuccess: def test_mark_success_basic(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.mark_running() job.mark_success() assert job.status == JobStatus.SUCCESS assert job.progress == 100.0 assert job.current_stage == "完成" assert job.completed_at is not None def test_mark_success_with_result(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.mark_running() result = {"video_url": "https://...", "duration": 30} job.mark_success(result=result) assert job.result == result def test_mark_success_without_result(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.mark_running() original_result = job.result.copy() job.mark_success() assert job.result == original_result # 不变 class TestMarkFailed: def test_mark_failed_basic(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.mark_running() job.mark_failed("网络超时") assert job.status == JobStatus.FAILED assert job.error_message == "网络超时" assert job.current_stage == "失败" assert job.completed_at is not None def test_mark_failed_empty_message(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.mark_running() job.mark_failed("") assert job.error_message == "" class TestMarkCancelled: def test_mark_cancelled_from_pending(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.mark_cancelled() assert job.status == JobStatus.CANCELLED assert job.current_stage == "已取消" def test_mark_cancelled_from_running(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.mark_running() job.mark_cancelled() assert job.status == JobStatus.CANCELLED class TestUpdateProgress: def test_update_progress_valid(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.update_progress(50.0) assert job.progress == 50.0 def test_update_progress_zero(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.update_progress(0.0) assert job.progress == 0.0 def test_update_progress_hundred(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.update_progress(100.0) assert job.progress == 100.0 def test_update_progress_negative(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) with pytest.raises(ValueError, match="进度必须在 0~100 之间"): job.update_progress(-1.0) def test_update_progress_over_100(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) with pytest.raises(ValueError, match="进度必须在 0~100 之间"): job.update_progress(101.0) def test_update_progress_with_stage(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.update_progress(30.0, stage="渲染中") assert job.progress == 30.0 assert job.current_stage == "渲染中" def test_update_progress_without_stage_unchanged(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.current_stage = "原阶段" job.update_progress(50.0) assert job.current_stage == "原阶段" def test_update_progress_updates_timestamp(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) old_time = job.updated_at job.update_progress(25.0) assert job.updated_at >= old_time class TestPrepareRetry: def test_prepare_retry_success(self): job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=3) job.mark_running() job.mark_failed("网络错误") job.prepare_retry() assert job.status == JobStatus.PENDING assert job.retry_count == 1 assert job.progress == 0.0 assert "第 1 次重试" in job.current_stage assert job.error_message == "" assert job.started_at is None assert job.completed_at is None assert job.celery_task_id == "" def test_prepare_retry_increments_count(self): job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=5) job.mark_running() job.mark_failed("err") job.prepare_retry() assert job.retry_count == 1 # 再次失败重试 job.mark_running() job.mark_failed("err2") job.prepare_retry() assert job.retry_count == 2 def test_prepare_retry_not_retryable_raises(self): job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=0) job.mark_running() job.mark_failed("err") with pytest.raises(ValueError, match="任务不可重试"): job.prepare_retry() def test_prepare_retry_wrong_status_raises(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) with pytest.raises(ValueError, match="任务不可重试"): job.prepare_retry() class TestToDict: def test_to_dict_structure(self): job = Job.create( "p1", JobType.VIDEO_COMPOSE, payload={"key": "val"}, source_id="src1", created_by_user_id="u1", ) d = job.to_dict() assert d["id"] == job.id assert d["project_id"] == "p1" assert d["job_type"] == "video_compose" assert d["status"] == "pending" assert d["progress"] == 0.0 assert d["current_stage"] == "" assert d["payload"] == {"key": "val"} assert d["result"] == {} assert d["error_message"] == "" assert d["retry_count"] == 0 assert d["max_retries"] == 3 assert d["celery_task_id"] == "" assert d["source_id"] == "src1" assert d["created_by_user_id"] == "u1" assert d["is_retryable"] is False assert d["started_at"] is None assert d["completed_at"] is None assert d["created_at"] is not None assert d["updated_at"] is not None def test_to_dict_after_success(self): job = Job.create("p1", JobType.VIDEO_COMPOSE) job.mark_running("渲染") job.mark_success({"url": "https://..."}) d = job.to_dict() assert d["status"] == "success" assert d["progress"] == 100.0 assert d["is_retryable"] is False assert d["started_at"] is not None assert d["completed_at"] is not None assert isinstance(d["started_at"], str) assert isinstance(d["completed_at"], str) def test_to_dict_after_failed(self): job = Job.create("p1", JobType.VIDEO_COMPOSE, max_retries=3) job.mark_running() job.mark_failed("timeout") d = job.to_dict() assert d["status"] == "failed" assert d["error_message"] == "timeout" assert d["is_retryable"] is True