""" Job 应用层用例单元测试(第十八波) 覆盖: - CreateJobUseCase - SubmitJobUseCase - UpdateJobProgressUseCase - CompleteJobUseCase - FailJobUseCase - RetryJobUseCase - CancelJobUseCase - GetJobUseCase - ListJobsUseCase - GetJobStatisticsUseCase """ from unittest.mock import MagicMock import pytest from packages.application.jobs import ( CancelJobUseCase, CompleteJobCommand, CompleteJobUseCase, CreateJobCommand, CreateJobUseCase, FailJobCommand, FailJobUseCase, GetJobStatisticsUseCase, GetJobUseCase, ListJobsUseCase, RetryJobUseCase, SubmitJobUseCase, UpdateJobProgressCommand, UpdateJobProgressUseCase, ) from packages.domain.job import Job, JobStatus, JobType @pytest.fixture def mock_repo(): return MagicMock() def make_job( status=JobStatus.PENDING, job_type=JobType.VIDEO_COMPOSE, project_id="proj-1", **kwargs, ): job = Job.create( project_id=project_id, job_type=job_type, **kwargs, ) # 绕过状态机直接设置状态(测试构造用) if status != JobStatus.PENDING: object.__setattr__(job, "status", status) return job # ============================================================ # CreateJobUseCase # ============================================================ class TestCreateJobUseCase: """CreateJobUseCase 创建任务""" def test_create_success(self, mock_repo): """正常创建任务""" mock_repo.create.side_effect = lambda j: j cmd = CreateJobCommand( project_id="proj-1", job_type=JobType.VIDEO_COMPOSE, payload={"key": "val"}, source_id="src-1", created_by_user_id="user-1", max_retries=5, ) uc = CreateJobUseCase(mock_repo) job = uc.execute(cmd) assert job.project_id == "proj-1" assert job.job_type == JobType.VIDEO_COMPOSE assert job.payload == {"key": "val"} assert job.source_id == "src-1" assert job.created_by_user_id == "user-1" assert job.max_retries == 5 assert job.status == JobStatus.PENDING assert job.progress == 0.0 mock_repo.create.assert_called_once() def test_create_default_values(self, mock_repo): """默认参数""" mock_repo.create.side_effect = lambda j: j cmd = CreateJobCommand(project_id="proj-1", job_type="video_compose") uc = CreateJobUseCase(mock_repo) job = uc.execute(cmd) assert job.payload == {} assert job.source_id == "" assert job.created_by_user_id == "" assert job.max_retries == 3 def test_create_string_job_type(self, mock_repo): """字符串类型的 job_type 也支持""" mock_repo.create.side_effect = lambda j: j cmd = CreateJobCommand(project_id="proj-1", job_type="asset_ingest") uc = CreateJobUseCase(mock_repo) job = uc.execute(cmd) assert job.job_type == JobType.ASSET_INGEST # ============================================================ # SubmitJobUseCase # ============================================================ class TestSubmitJobUseCase: """SubmitJobUseCase 提交任务""" def test_submit_success(self, mock_repo): """正常提交 pending 任务""" job = make_job(status=JobStatus.PENDING) mock_repo.get.return_value = job mock_repo.update.side_effect = lambda j: j uc = SubmitJobUseCase(mock_repo) result = uc.execute(job.id, celery_task_id="celery-123") assert result.status == JobStatus.RUNNING assert result.celery_task_id == "celery-123" assert result.current_stage == "已提交,等待执行" assert result.started_at is not None mock_repo.update.assert_called_once() def test_submit_job_not_found(self, mock_repo): """任务不存在""" mock_repo.get.return_value = None uc = SubmitJobUseCase(mock_repo) with pytest.raises(ValueError, match="任务不存在"): uc.execute("nonexistent") def test_submit_already_running(self, mock_repo): """已经是 running 状态不能再提交""" job = make_job(status=JobStatus.RUNNING) mock_repo.get.return_value = job uc = SubmitJobUseCase(mock_repo) with pytest.raises(ValueError, match="只有 pending 状态"): uc.execute(job.id) def test_submit_without_celery_id(self, mock_repo): """不传 celery_task_id 也可以""" job = make_job(status=JobStatus.PENDING) mock_repo.get.return_value = job mock_repo.update.side_effect = lambda j: j uc = SubmitJobUseCase(mock_repo) result = uc.execute(job.id) assert result.status == JobStatus.RUNNING assert result.celery_task_id == "" # ============================================================ # UpdateJobProgressUseCase # ============================================================ class TestUpdateJobProgressUseCase: """UpdateJobProgressUseCase 更新进度""" def test_update_progress_success(self, mock_repo): """正常更新进度""" job = make_job(status=JobStatus.RUNNING) mock_repo.get.return_value = job mock_repo.update.side_effect = lambda j: j cmd = UpdateJobProgressCommand(job_id=job.id, progress=50.0, current_stage="处理中") uc = UpdateJobProgressUseCase(mock_repo) result = uc.execute(cmd) assert result.progress == 50.0 assert result.current_stage == "处理中" mock_repo.update.assert_called_once() def test_update_progress_job_not_found(self, mock_repo): """任务不存在""" mock_repo.get.return_value = None cmd = UpdateJobProgressCommand(job_id="nope", progress=50.0) uc = UpdateJobProgressUseCase(mock_repo) with pytest.raises(ValueError, match="任务不存在"): uc.execute(cmd) def test_update_progress_not_running(self, mock_repo): """非 running 状态不能更新进度""" job = make_job(status=JobStatus.PENDING) mock_repo.get.return_value = job cmd = UpdateJobProgressCommand(job_id=job.id, progress=50.0) uc = UpdateJobProgressUseCase(mock_repo) with pytest.raises(ValueError, match="只有 running 状态"): uc.execute(cmd) # ============================================================ # CompleteJobUseCase # ============================================================ class TestCompleteJobUseCase: """CompleteJobUseCase 完成任务""" def test_complete_from_running(self, mock_repo): """从 running 状态完成""" job = make_job(status=JobStatus.RUNNING) mock_repo.get.return_value = job mock_repo.update.side_effect = lambda j: j cmd = CompleteJobCommand(job_id=job.id, result={"output": "ok"}) uc = CompleteJobUseCase(mock_repo) result = uc.execute(cmd) assert result.status == JobStatus.SUCCESS assert result.progress == 100.0 assert result.result == {"output": "ok"} assert result.completed_at is not None mock_repo.update.assert_called_once() def test_complete_from_pending(self, mock_repo): """从 pending 状态也可以直接完成""" job = make_job(status=JobStatus.PENDING) mock_repo.get.return_value = job mock_repo.update.side_effect = lambda j: j cmd = CompleteJobCommand(job_id=job.id) uc = CompleteJobUseCase(mock_repo) result = uc.execute(cmd) assert result.status == JobStatus.SUCCESS assert result.progress == 100.0 def test_complete_job_not_found(self, mock_repo): """任务不存在""" mock_repo.get.return_value = None cmd = CompleteJobCommand(job_id="nope") uc = CompleteJobUseCase(mock_repo) with pytest.raises(ValueError, match="任务不存在"): uc.execute(cmd) def test_complete_already_failed(self, mock_repo): """已失败的任务不能直接标记完成""" job = make_job(status=JobStatus.FAILED) job.error_message = "some error" mock_repo.get.return_value = job cmd = CompleteJobCommand(job_id=job.id) uc = CompleteJobUseCase(mock_repo) with pytest.raises(ValueError, match="只有 running/pending"): uc.execute(cmd) # ============================================================ # FailJobUseCase # ============================================================ class TestFailJobUseCase: """FailJobUseCase 失败任务""" def test_fail_from_running(self, mock_repo): """从 running 状态失败""" job = make_job(status=JobStatus.RUNNING) mock_repo.get.return_value = job mock_repo.update.side_effect = lambda j: j cmd = FailJobCommand(job_id=job.id, error_message="网络超时") uc = FailJobUseCase(mock_repo) result = uc.execute(cmd) assert result.status == JobStatus.FAILED assert result.error_message == "网络超时" assert result.current_stage == "失败" assert result.completed_at is not None mock_repo.update.assert_called_once() def test_fail_pending_rejected_by_domain(self, mock_repo): """pending 状态不能直接失败(领域状态机约束)""" job = make_job(status=JobStatus.PENDING) mock_repo.get.return_value = job cmd = FailJobCommand(job_id=job.id, error_message="资源不足") uc = FailJobUseCase(mock_repo) with pytest.raises(ValueError, match="非法状态转换"): uc.execute(cmd) def test_fail_job_not_found(self, mock_repo): """任务不存在""" mock_repo.get.return_value = None cmd = FailJobCommand(job_id="nope", error_message="err") uc = FailJobUseCase(mock_repo) with pytest.raises(ValueError, match="任务不存在"): uc.execute(cmd) # ============================================================ # RetryJobUseCase # ============================================================ class TestRetryJobUseCase: """RetryJobUseCase 重试任务""" def test_retry_success(self, mock_repo): """失败任务重试成功""" job = make_job(status=JobStatus.FAILED, max_retries=3) job.retry_count = 0 job.error_message = "timeout" mock_repo.get.return_value = job mock_repo.update.side_effect = lambda j: j uc = RetryJobUseCase(mock_repo) result = uc.execute(job.id) assert result.status == JobStatus.PENDING assert result.retry_count == 1 assert result.progress == 0.0 assert result.error_message == "" assert result.started_at is None assert result.completed_at is None assert result.celery_task_id == "" mock_repo.update.assert_called_once() def test_retry_job_not_found(self, mock_repo): """任务不存在""" mock_repo.get.return_value = None uc = RetryJobUseCase(mock_repo) with pytest.raises(ValueError, match="任务不存在"): uc.execute("nope") def test_retry_exceeds_max_retries(self, mock_repo): """超过最大重试次数不可重试""" job = make_job(status=JobStatus.FAILED, max_retries=3) job.retry_count = 3 mock_repo.get.return_value = job uc = RetryJobUseCase(mock_repo) with pytest.raises(ValueError, match="不可重试"): uc.execute(job.id) # ============================================================ # CancelJobUseCase # ============================================================ class TestCancelJobUseCase: """CancelJobUseCase 取消任务""" def test_cancel_pending(self, mock_repo): """取消 pending 任务""" job = make_job(status=JobStatus.PENDING) mock_repo.get.return_value = job mock_repo.update.side_effect = lambda j: j uc = CancelJobUseCase(mock_repo) result = uc.execute(job.id) assert result.status == JobStatus.CANCELLED assert result.current_stage == "已取消" mock_repo.update.assert_called_once() def test_cancel_running(self, mock_repo): """取消 running 任务""" job = make_job(status=JobStatus.RUNNING) mock_repo.get.return_value = job mock_repo.update.side_effect = lambda j: j uc = CancelJobUseCase(mock_repo) result = uc.execute(job.id) assert result.status == JobStatus.CANCELLED def test_cancel_job_not_found(self, mock_repo): """任务不存在""" mock_repo.get.return_value = None uc = CancelJobUseCase(mock_repo) with pytest.raises(ValueError, match="任务不存在"): uc.execute("nope") def test_cancel_already_success(self, mock_repo): """已成功的任务不能取消""" job = make_job(status=JobStatus.SUCCESS) mock_repo.get.return_value = job uc = CancelJobUseCase(mock_repo) with pytest.raises(ValueError, match="终态"): uc.execute(job.id) def test_cancel_already_failed(self, mock_repo): """已失败的任务不能取消(走重试)""" job = make_job(status=JobStatus.FAILED) job.error_message = "err" mock_repo.get.return_value = job uc = CancelJobUseCase(mock_repo) with pytest.raises(ValueError, match="终态"): uc.execute(job.id) # ============================================================ # GetJobUseCase # ============================================================ class TestGetJobUseCase: """GetJobUseCase 获取任务""" def test_get_existing(self, mock_repo): """获取存在的任务""" job = make_job() mock_repo.get.return_value = job uc = GetJobUseCase(mock_repo) result = uc.execute(job.id) assert result is job mock_repo.get.assert_called_once_with(job.id) def test_get_not_found(self, mock_repo): """获取不存在的任务返回 None""" mock_repo.get.return_value = None uc = GetJobUseCase(mock_repo) result = uc.execute("nope") assert result is None # ============================================================ # ListJobsUseCase # ============================================================ class TestListJobsUseCase: """ListJobsUseCase 列出任务""" def test_list_by_project(self, mock_repo): """按项目列出""" jobs = [make_job(), make_job()] mock_repo.list_by_project.return_value = jobs uc = ListJobsUseCase(mock_repo) result = uc.execute(project_id="proj-1") assert len(result) == 2 mock_repo.list_by_project.assert_called_once() def test_list_by_project_with_filters(self, mock_repo): """按项目 + 类型 + 状态过滤""" mock_repo.list_by_project.return_value = [] uc = ListJobsUseCase(mock_repo) uc.execute( project_id="proj-1", job_type=JobType.VIDEO_COMPOSE, status=JobStatus.RUNNING, limit=20, offset=10, ) mock_repo.list_by_project.assert_called_once_with( "proj-1", job_type=JobType.VIDEO_COMPOSE, status=JobStatus.RUNNING, limit=20, offset=10, ) def test_list_by_user(self, mock_repo): """按用户列出""" jobs = [make_job()] mock_repo.list_by_user.return_value = jobs uc = ListJobsUseCase(mock_repo) result = uc.execute(user_id="user-1") assert len(result) == 1 mock_repo.list_by_user.assert_called_once() def test_list_no_filter_raises(self, mock_repo): """不指定 project_id 或 user_id 报错""" uc = ListJobsUseCase(mock_repo) with pytest.raises(ValueError, match="必须指定"): uc.execute() def test_list_project_takes_precedence(self, mock_repo): """同时传 project_id 和 user_id,优先按项目查""" mock_repo.list_by_project.return_value = [] uc = ListJobsUseCase(mock_repo) uc.execute(project_id="proj-1", user_id="user-1") mock_repo.list_by_project.assert_called_once() mock_repo.list_by_user.assert_not_called() # ============================================================ # GetJobStatisticsUseCase # ============================================================ class TestGetJobStatisticsUseCase: """GetJobStatisticsUseCase 任务统计""" def test_stats_counts(self, mock_repo): """统计各状态数量""" mock_repo.count_by_project.side_effect = lambda pid, status=None: { None: 10, # total JobStatus.PENDING: 2, JobStatus.RUNNING: 3, JobStatus.SUCCESS: 4, JobStatus.FAILED: 1, }[status] uc = GetJobStatisticsUseCase(mock_repo) stats = uc.execute("proj-1") assert stats["project_id"] == "proj-1" assert stats["total"] == 10 assert stats["pending"] == 2 assert stats["running"] == 3 assert stats["success"] == 4 assert stats["failed"] == 1 # 总共调用 5 次 count_by_project assert mock_repo.count_by_project.call_count == 5 # ============================================================ # Command 对象 # ============================================================ class TestCommandObjects: """命令对象基本属性""" def test_create_job_command_defaults(self): cmd = CreateJobCommand(project_id="p1", job_type=JobType.VIDEO_COMPOSE) assert cmd.payload == {} assert cmd.source_id == "" assert cmd.created_by_user_id == "" assert cmd.max_retries == 3 def test_update_progress_command_defaults(self): cmd = UpdateJobProgressCommand(job_id="j1", progress=50.0) assert cmd.current_stage == "" def test_complete_job_command_defaults(self): cmd = CompleteJobCommand(job_id="j1") assert cmd.result == {} def test_fail_job_command(self): cmd = FailJobCommand(job_id="j1", error_message="err") assert cmd.error_message == "err"