diff --git a/tests/unit/test_job.py b/tests/unit/test_job.py new file mode 100644 index 000000000..123bcac11 --- /dev/null +++ b/tests/unit/test_job.py @@ -0,0 +1,459 @@ +"""Job 领域模型单测 — 状态机 + 实体方法全覆盖.""" + +from __future__ import annotations + +import pytest + +from packages.domain.job import ( + Job, + JobStatus, + JobType, + TERMINAL_STATUSES, +) + + +# ── 枚举常量 ──────────────────────────────────────────────────────────────── + + +class TestJobType: + def test_all_types_exist(self): + assert JobType.VIDEO_COMPOSE == "video_compose" + assert JobType.RENDER_EDIT_PLAN == "render_edit_plan" + assert JobType.ASSET_INGEST == "asset_ingest" + assert JobType.CLASSIFICATION == "classification" + assert JobType.VOICE_EXTRACTION == "voice_extraction" + assert JobType.GENERATION == "generation" + + def test_from_string(self): + assert JobType("video_compose") == JobType.VIDEO_COMPOSE + assert JobType("render_edit_plan") == JobType.RENDER_EDIT_PLAN + + def test_invalid_type_raises(self): + with pytest.raises(ValueError): + JobType("invalid_type") + + +class TestJobStatus: + def test_all_statuses_exist(self): + assert JobStatus.PENDING == "pending" + assert JobStatus.RUNNING == "running" + assert JobStatus.SUCCESS == "success" + assert JobStatus.FAILED == "failed" + assert JobStatus.CANCELLED == "cancelled" + + def test_from_string(self): + assert JobStatus("pending") == JobStatus.PENDING + assert JobStatus("running") == JobStatus.RUNNING + + +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 + + +# ── Job.create ───────────────────────────────────────────────────────────── + + +class TestJobCreate: + def test_basic_creation(self): + job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE) + assert job.id # 自动生成 + assert job.project_id == "proj_1" + assert job.job_type == JobType.VIDEO_COMPOSE + assert job.status == JobStatus.PENDING + assert job.progress == 0.0 + assert job.retry_count == 0 + assert job.max_retries == 3 + assert job.payload == {} + assert job.result == {} + + def test_job_type_string_conversion(self): + job = Job.create(project_id="proj_1", job_type="video_compose") + assert job.job_type == JobType.VIDEO_COMPOSE + + def test_invalid_job_type_raises(self): + with pytest.raises(ValueError, match="不支持的任务类型"): + Job.create(project_id="proj_1", job_type="invalid") + + def test_empty_project_id_raises(self): + with pytest.raises(ValueError, match="project_id 不能为空"): + Job.create(project_id=" ", job_type=JobType.VIDEO_COMPOSE) + + def test_project_id_stripped(self): + job = Job.create(project_id=" proj_1 ", job_type=JobType.VIDEO_COMPOSE) + assert job.project_id == "proj_1" + + def test_with_payload(self): + payload = {"video_url": "http://example.com/v.mp4"} + job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE, payload=payload) + assert job.payload == payload + + def test_payload_none_defaults_empty_dict(self): + job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE, payload=None) + assert job.payload == {} + + def test_with_source_id(self): + job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE, source_id="plan_123") + assert job.source_id == "plan_123" + + def test_source_id_stripped(self): + job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE, source_id=" src ") + assert job.source_id == "src" + + def test_with_created_by(self): + job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE, created_by_user_id="user_1") + assert job.created_by_user_id == "user_1" + + def test_custom_max_retries(self): + job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE, max_retries=5) + assert job.max_retries == 5 + + def test_created_at_auto_set(self): + job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE) + assert job.created_at is not None + assert job.updated_at is not None + + def test_started_at_none_initially(self): + job = Job.create(project_id="proj_1", job_type=JobType.VIDEO_COMPOSE) + assert job.started_at is None + assert job.completed_at is None + + +# ── 属性判断 ──────────────────────────────────────────────────────────────── + + +class TestJobProperties: + def test_is_terminal_pending_false(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + assert not job.is_terminal + + def test_is_terminal_running_false(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.transition_to(JobStatus.RUNNING) + assert not job.is_terminal + + def test_is_terminal_success_true(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.transition_to(JobStatus.RUNNING) + job.transition_to(JobStatus.SUCCESS) + assert job.is_terminal + + def test_is_terminal_failed_true(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.transition_to(JobStatus.RUNNING) + job.transition_to(JobStatus.FAILED) + assert job.is_terminal + + def test_is_terminal_cancelled_true(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.transition_to(JobStatus.CANCELLED) + assert job.is_terminal + + def test_is_retryable_failed_within_limit(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3) + job.transition_to(JobStatus.RUNNING) + job.transition_to(JobStatus.FAILED) + assert job.is_retryable + + def test_is_retryable_failed_at_limit(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3) + job.transition_to(JobStatus.RUNNING) + job.transition_to(JobStatus.FAILED) + job.retry_count = 3 + assert not job.is_retryable + + def test_is_retryable_pending_false(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + assert not job.is_retryable + + def test_is_retryable_success_false(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.transition_to(JobStatus.RUNNING) + job.transition_to(JobStatus.SUCCESS) + assert not job.is_retryable + + +# ── 状态转换 ──────────────────────────────────────────────────────────────── + + +class TestTransitionTo: + def test_pending_to_running(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.transition_to(JobStatus.RUNNING) + assert job.status == JobStatus.RUNNING + + def test_pending_to_success(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.transition_to(JobStatus.SUCCESS) + assert job.status == JobStatus.SUCCESS + + def test_pending_to_cancelled(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.transition_to(JobStatus.CANCELLED) + assert job.status == JobStatus.CANCELLED + + def test_running_to_success(self): + job = Job.create(project_id="p1", job_type=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(project_id="p1", job_type=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(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.transition_to(JobStatus.RUNNING) + job.transition_to(JobStatus.CANCELLED) + assert job.status == JobStatus.CANCELLED + + def test_failed_to_pending_retry(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.transition_to(JobStatus.RUNNING) + job.transition_to(JobStatus.FAILED) + job.transition_to(JobStatus.PENDING) + assert job.status == JobStatus.PENDING + + def test_invalid_transition_raises(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + # success → running 是非法的 + job.transition_to(JobStatus.SUCCESS) + with pytest.raises(ValueError, match="非法状态转换"): + job.transition_to(JobStatus.RUNNING) + + def test_string_status_conversion(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.transition_to("running") + assert job.status == JobStatus.RUNNING + + def test_invalid_string_status_raises(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + with pytest.raises(ValueError, match="无效状态"): + job.transition_to("invalid_status") + + def test_transition_updates_updated_at(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + old_updated = job.updated_at + import time; time.sleep(0.001) + job.transition_to(JobStatus.RUNNING) + assert job.updated_at >= old_updated + + def test_running_sets_started_at(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + assert job.started_at is None + job.transition_to(JobStatus.RUNNING) + assert job.started_at is not None + + def test_success_sets_completed_at(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.transition_to(JobStatus.RUNNING) + assert job.completed_at is None + job.transition_to(JobStatus.SUCCESS) + assert job.completed_at is not None + + def test_failed_sets_completed_at(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.transition_to(JobStatus.RUNNING) + job.transition_to(JobStatus.FAILED) + assert job.completed_at is not None + + +# ── 便捷方法 ──────────────────────────────────────────────────────────────── + + +class TestMarkMethods: + def test_mark_running(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.mark_running(stage="初始化") + assert job.status == JobStatus.RUNNING + assert job.current_stage == "初始化" + + def test_mark_running_no_stage(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.mark_running() + assert job.status == JobStatus.RUNNING + assert job.current_stage == "" + + def test_mark_success(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.transition_to(JobStatus.RUNNING) + result = {"output_url": "http://example.com/out.mp4"} + job.mark_success(result=result) + assert job.status == JobStatus.SUCCESS + assert job.progress == 100.0 + assert job.current_stage == "完成" + assert job.result == result + + def test_mark_success_no_result(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.transition_to(JobStatus.RUNNING) + job.mark_success() + assert job.status == JobStatus.SUCCESS + assert job.result == {} # 保持默认空字典 + + def test_mark_failed(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.transition_to(JobStatus.RUNNING) + job.mark_failed("网络超时") + assert job.status == JobStatus.FAILED + assert job.error_message == "网络超时" + assert job.current_stage == "失败" + + def test_mark_cancelled(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.mark_cancelled() + assert job.status == JobStatus.CANCELLED + assert job.current_stage == "已取消" + + +# ── 进度更新 ──────────────────────────────────────────────────────────────── + + +class TestUpdateProgress: + def test_normal_progress(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.update_progress(50.0, stage="渲染中") + assert job.progress == 50.0 + assert job.current_stage == "渲染中" + + def test_progress_zero(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.update_progress(0.0) + assert job.progress == 0.0 + + def test_progress_100(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.update_progress(100.0) + assert job.progress == 100.0 + + def test_progress_negative_raises(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + with pytest.raises(ValueError, match="进度必须在 0~100 之间"): + job.update_progress(-1.0) + + def test_progress_over_100_raises(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + with pytest.raises(ValueError, match="进度必须在 0~100 之间"): + job.update_progress(100.1) + + def test_progress_without_stage(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.update_progress(30.0) + assert job.progress == 30.0 + + def test_progress_updates_updated_at(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + old_updated = job.updated_at + import time; time.sleep(0.001) + job.update_progress(50.0) + assert job.updated_at > old_updated + + +# ── 重试 ──────────────────────────────────────────────────────────────────── + + +class TestPrepareRetry: + def test_normal_retry(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3) + job.transition_to(JobStatus.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_retry_increments_count(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=3) + job.transition_to(JobStatus.RUNNING) + job.mark_failed("超时") + job.prepare_retry() + assert job.retry_count == 1 + job.transition_to(JobStatus.RUNNING) + job.mark_failed("又超时了") + job.prepare_retry() + assert job.retry_count == 2 + + def test_retry_exceeds_limit_raises(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=1) + job.transition_to(JobStatus.RUNNING) + job.mark_failed("超时") + job.prepare_retry() # 第1次重试,ok + job.transition_to(JobStatus.RUNNING) + job.mark_failed("又超时了") + # retry_count=1, max_retries=1 → 不能再重试 + with pytest.raises(ValueError, match="不可重试"): + job.prepare_retry() + + def test_retry_from_success_raises(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.transition_to(JobStatus.RUNNING) + job.mark_success() + with pytest.raises(ValueError, match="不可重试"): + job.prepare_retry() + + def test_retry_from_pending_raises(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + with pytest.raises(ValueError, match="不可重试"): + job.prepare_retry() + + +# ── 序列化 ────────────────────────────────────────────────────────────────── + + +class TestToDict: + def test_basic_fields(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, source_id="src1") + 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["source_id"] == "src1" + assert d["retry_count"] == 0 + assert d["max_retries"] == 3 + + def test_is_retryable_in_output(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + d = job.to_dict() + assert "is_retryable" in d + assert d["is_retryable"] is False + + def test_datetime_fields_are_strings(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + d = job.to_dict() + assert isinstance(d["created_at"], str) + assert isinstance(d["updated_at"], str) + + def test_started_at_none_when_not_started(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + d = job.to_dict() + assert d["started_at"] is None + assert d["completed_at"] is None + + def test_started_at_present_after_running(self): + job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE) + job.mark_running() + d = job.to_dict() + assert d["started_at"] is not None + assert isinstance(d["started_at"], str)