09869ce69c
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 - Type Check (mypy) (push) Successful in 1m38s
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 / Frontend Lint (push) Successful in 1m36s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m39s
CI/CD Pipeline / Integration Tests (push) Successful in 1m54s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m28s
CI/CD Pipeline / Unit Tests (push) Successful in 4m5s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m43s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 10m45s
CI/CD Pipeline / Build Staging API Image (push) Successful in 14m30s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 13m1s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 36s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 31s
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
577 lines
18 KiB
Python
Executable File
577 lines
18 KiB
Python
Executable File
"""
|
|
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"
|