Files
xiaoxia-saas/tests/unit/test_job_service.py
CI Bot 1a57878f76
CI/CD Pipeline / Validate Code Quality And Tests (pull_request) Failing after 1m12s
CI/CD Pipeline / Unit Tests (pull_request) Failing after 2m17s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 1m33s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 3m14s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Runtime Images (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
chore(backend): Phase 3 清理 — 未使用依赖删除 + pyflakes 警告清零 + 测试文件冗余清理
1. 未使用依赖清理:
   - 从 requirements-base.txt 移除 cryptography 和 pyOpenSSL

2. pyflakes 警告清零 (apps/ + packages/ + tests/):
   - 移除 17 处未使用的 import (F401)
   - 修复 26 处未使用的局部变量 (F841):
     * 有副作用的赋值转为裸调用
     * 无副作用的赋值直接删除
   - 修复 1 处未使用的异常变量 (F841)
   - 修复 1 处空 except 块

3. 测试文件冗余清理:
   - 删除 tests/integration/test_project_management.py (模块级 skip,测试不存在的模块)
   - 删除 tests/integration/fixtures/duplication_routes_fixed.py (未被引用)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 16:14:46 +08:00

585 lines
22 KiB
Python
Executable File

"""Unit tests for JobService — Phase 8 任务 2.10.
测试 Job 领域模型、Use Cases 和 Service 层。
"""
from __future__ import annotations
import os
import sys
from pathlib import Path
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
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
# ── Fixtures ──────────────────────────────────────────────────────────────────
class FakeJobRepo:
"""内存中的 JobRepository 测试替身。"""
def __init__(self):
self._store: dict[str, Job] = {}
def create(self, job: Job) -> Job:
self._store[job.id] = job
return job
def get(self, job_id: str) -> Job | None:
return self._store.get(job_id)
def update(self, job: Job) -> Job:
if job.id not in self._store:
raise ValueError(f"Job {job.id} not found")
self._store[job.id] = job
return job
def list_by_project(self, project_id, *, job_type=None, status=None, limit=50, offset=0):
results = [
j
for j in self._store.values()
if j.project_id == project_id
and (job_type is None or j.job_type == job_type or j.job_type == JobType(job_type))
and (status is None or j.status == status or j.status == JobStatus(status))
]
return results[offset : offset + limit]
def list_by_user(self, user_id, *, job_type=None, status=None, limit=50, offset=0):
results = [
j
for j in self._store.values()
if j.created_by_user_id == user_id
and (job_type is None or j.job_type == job_type or j.job_type == JobType(job_type))
and (status is None or j.status == status or j.status == JobStatus(status))
]
return results[offset : offset + limit]
def count_by_project(self, project_id, *, status=None):
return len(
[
j
for j in self._store.values()
if j.project_id == project_id
and (status is None or j.status == status or j.status == JobStatus(status))
]
)
def find_active_by_source(self, source_id, job_type):
jt = job_type.value if isinstance(job_type, JobType) else job_type
for j in self._store.values():
if (
j.source_id == source_id
and j.job_type.value == jt
and j.status in (JobStatus.PENDING, JobStatus.RUNNING)
):
return j
return None
@pytest.fixture
def repo():
return FakeJobRepo()
# ── Job 领域模型测试 ──────────────────────────────────────────────────────────
class TestJobDomainModel:
def test_create_job(self):
job = Job.create(
project_id="proj-1",
job_type=JobType.VIDEO_COMPOSE,
payload={"plan_id": "plan-1"},
source_id="plan-1",
created_by_user_id="user-1",
)
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 == {"plan_id": "plan-1"}
def test_create_job_with_string_type(self):
job = Job.create(project_id="proj-1", job_type="render_edit_plan")
assert job.job_type == JobType.RENDER_EDIT_PLAN
def test_create_job_invalid_type_raises(self):
with pytest.raises(ValueError, match="不支持的任务类型"):
Job.create(project_id="proj-1", job_type="invalid_type")
def test_create_job_empty_project_id_raises(self):
with pytest.raises(ValueError, match="project_id 不能为空"):
Job.create(project_id=" ", job_type=JobType.VIDEO_COMPOSE)
def test_is_terminal(self):
job = Job.create(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE)
assert not job.is_terminal
job.status = JobStatus.SUCCESS
assert job.is_terminal
job.status = JobStatus.FAILED
assert job.is_terminal
job.status = JobStatus.CANCELLED
assert job.is_terminal
def test_is_retryable(self):
job = Job.create(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE, max_retries=2)
job.status = JobStatus.FAILED
assert job.is_retryable
job.retry_count = 2
assert not job.is_retryable
def test_mark_running(self):
job = Job.create(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running("初始化中")
assert job.status == JobStatus.RUNNING
assert job.current_stage == "初始化中"
assert job.started_at is not None
def test_mark_success(self):
job = Job.create(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_success(result={"output_url": "https://example.com/video.mp4"})
assert job.status == JobStatus.SUCCESS
assert job.progress == 100.0
assert job.current_stage == "完成"
assert job.result == {"output_url": "https://example.com/video.mp4"}
assert job.completed_at is not None
def test_mark_failed(self):
job = Job.create(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_failed("FFmpeg 执行失败")
assert job.status == JobStatus.FAILED
assert job.error_message == "FFmpeg 执行失败"
assert job.completed_at is not None
def test_mark_cancelled(self):
job = Job.create(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE)
job.mark_cancelled()
assert job.status == JobStatus.CANCELLED
assert job.current_stage == "已取消"
def test_update_progress(self):
job = Job.create(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE)
job.update_progress(50.0, "合成中")
assert job.progress == 50.0
assert job.current_stage == "合成中"
def test_update_progress_invalid_value_raises(self):
job = Job.create(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE)
with pytest.raises(ValueError, match="进度必须在 0~100 之间"):
job.update_progress(150.0)
def test_prepare_retry(self):
job = Job.create(project_id="proj-1", job_type=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 job.error_message == ""
assert "第 1 次重试" in job.current_stage
def test_prepare_retry_exceeded_raises(self):
job = Job.create(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE, max_retries=1)
job.mark_running()
job.mark_failed("错误")
job.prepare_retry() # retry_count = 1, max_retries = 1 → not retryable
with pytest.raises(ValueError, match="任务不可重试"):
job.prepare_retry()
def test_invalid_transition_raises(self):
job = Job.create(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running()
job.mark_success()
with pytest.raises(ValueError, match="非法状态转换"):
job.transition_to(JobStatus.RUNNING) # success → running 不合法(终态不可转换)
def test_to_dict(self):
job = Job.create(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE)
d = job.to_dict()
assert d["project_id"] == "proj-1"
assert d["job_type"] == "video_compose"
assert d["status"] == "pending"
assert "is_retryable" in d
# ── Use Case 测试 ─────────────────────────────────────────────────────────────
class TestCreateJobUseCase:
def test_execute(self, repo):
use_case = CreateJobUseCase(repo)
cmd = CreateJobCommand(
project_id="proj-1",
job_type=JobType.VIDEO_COMPOSE,
payload={"plan_id": "plan-1"},
source_id="plan-1",
created_by_user_id="user-1",
)
job = use_case.execute(cmd)
assert job.id in repo._store
assert job.status == JobStatus.PENDING
def test_execute_with_string_type(self, repo):
use_case = CreateJobUseCase(repo)
cmd = CreateJobCommand(
project_id="proj-1",
job_type="video_compose",
)
job = use_case.execute(cmd)
assert job.job_type == JobType.VIDEO_COMPOSE
class TestSubmitJobUseCase:
def test_submit_pending_job(self, repo):
# 先创建
create_uc = CreateJobUseCase(repo)
job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
# 提交
submit_uc = SubmitJobUseCase(repo)
submitted = submit_uc.execute(job.id, celery_task_id="celery-abc")
assert submitted.status == JobStatus.RUNNING
assert submitted.celery_task_id == "celery-abc"
assert submitted.started_at is not None
def test_submit_non_pending_raises(self, repo):
create_uc = CreateJobUseCase(repo)
job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
job.mark_running()
repo.update(job)
submit_uc = SubmitJobUseCase(repo)
with pytest.raises(ValueError, match="只有 pending 状态"):
submit_uc.execute(job.id)
def test_submit_nonexistent_raises(self, repo):
submit_uc = SubmitJobUseCase(repo)
with pytest.raises(ValueError, match="任务不存在"):
submit_uc.execute("nonexistent-id")
class TestUpdateJobProgressUseCase:
def test_update_running_job(self, repo):
create_uc = CreateJobUseCase(repo)
job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
job.mark_running()
repo.update(job)
progress_uc = UpdateJobProgressUseCase(repo)
updated = progress_uc.execute(UpdateJobProgressCommand(job_id=job.id, progress=75.0, current_stage="渲染中"))
assert updated.progress == 75.0
assert updated.current_stage == "渲染中"
def test_update_non_running_raises(self, repo):
create_uc = CreateJobUseCase(repo)
job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
progress_uc = UpdateJobProgressUseCase(repo)
with pytest.raises(ValueError, match="只有 running 状态"):
progress_uc.execute(UpdateJobProgressCommand(job_id=job.id, progress=50.0))
class TestCompleteJobUseCase:
def test_complete_running_job(self, repo):
create_uc = CreateJobUseCase(repo)
job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
job.mark_running()
repo.update(job)
complete_uc = CompleteJobUseCase(repo)
completed = complete_uc.execute(CompleteJobCommand(job_id=job.id, result={"url": "https://example.com/v.mp4"}))
assert completed.status == JobStatus.SUCCESS
assert completed.progress == 100.0
assert completed.result == {"url": "https://example.com/v.mp4"}
def test_complete_non_running_raises(self, repo):
create_uc = CreateJobUseCase(repo)
job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
job.mark_running()
job.mark_success() # 已经是终态
complete_uc = CompleteJobUseCase(repo)
with pytest.raises(ValueError, match="只有 running/pending 状态"):
complete_uc.execute(CompleteJobCommand(job_id=job.id))
class TestFailJobUseCase:
def test_fail_job(self, repo):
create_uc = CreateJobUseCase(repo)
job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
job.mark_running()
repo.update(job)
fail_uc = FailJobUseCase(repo)
failed = fail_uc.execute(FailJobCommand(job_id=job.id, error_message="磁盘空间不足"))
assert failed.status == JobStatus.FAILED
assert failed.error_message == "磁盘空间不足"
class TestRetryJobUseCase:
def test_retry_failed_job(self, repo):
create_uc = CreateJobUseCase(repo)
job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE, max_retries=3))
job.mark_running()
job.mark_failed("网络超时")
repo.update(job)
retry_uc = RetryJobUseCase(repo)
retried = retry_uc.execute(job.id)
assert retried.status == JobStatus.PENDING
assert retried.retry_count == 1
assert retried.error_message == ""
def test_retry_non_failed_raises(self, repo):
create_uc = CreateJobUseCase(repo)
job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
retry_uc = RetryJobUseCase(repo)
with pytest.raises(ValueError, match="任务不可重试"):
retry_uc.execute(job.id)
class TestCancelJobUseCase:
def test_cancel_pending_job(self, repo):
create_uc = CreateJobUseCase(repo)
job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
cancel_uc = CancelJobUseCase(repo)
cancelled = cancel_uc.execute(job.id)
assert cancelled.status == JobStatus.CANCELLED
def test_cancel_completed_raises(self, repo):
create_uc = CreateJobUseCase(repo)
job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
job.mark_running()
job.mark_success()
repo.update(job)
cancel_uc = CancelJobUseCase(repo)
with pytest.raises(ValueError, match="已处于终态"):
cancel_uc.execute(job.id)
class TestGetJobUseCase:
def test_get_existing_job(self, repo):
create_uc = CreateJobUseCase(repo)
job = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
get_uc = GetJobUseCase(repo)
fetched = get_uc.execute(job.id)
assert fetched is not None
assert fetched.id == job.id
def test_get_nonexistent_returns_none(self, repo):
get_uc = GetJobUseCase(repo)
assert get_uc.execute("nonexistent") is None
class TestListJobsUseCase:
def test_list_by_project(self, repo):
create_uc = CreateJobUseCase(repo)
create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.RENDER_EDIT_PLAN))
create_uc.execute(CreateJobCommand(project_id="proj-2", job_type=JobType.VIDEO_COMPOSE))
list_uc = ListJobsUseCase(repo)
jobs = list_uc.execute(project_id="proj-1")
assert len(jobs) == 2
def test_list_by_project_with_type_filter(self, repo):
create_uc = CreateJobUseCase(repo)
create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.RENDER_EDIT_PLAN))
list_uc = ListJobsUseCase(repo)
jobs = list_uc.execute(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE)
assert len(jobs) == 1
assert jobs[0].job_type == JobType.VIDEO_COMPOSE
def test_list_by_project_with_status_filter(self, repo):
create_uc = CreateJobUseCase(repo)
create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
j2 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
j2.mark_running()
repo.update(j2)
list_uc = ListJobsUseCase(repo)
running_jobs = list_uc.execute(project_id="proj-1", status=JobStatus.RUNNING)
assert len(running_jobs) == 1
def test_list_by_user(self, repo):
create_uc = CreateJobUseCase(repo)
create_uc.execute(
CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE, created_by_user_id="user-1")
)
create_uc.execute(
CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE, created_by_user_id="user-2")
)
list_uc = ListJobsUseCase(repo)
jobs = list_uc.execute(user_id="user-1")
assert len(jobs) == 1
def test_list_without_project_or_user_raises(self, repo):
list_uc = ListJobsUseCase(repo)
with pytest.raises(ValueError, match="必须指定"):
list_uc.execute()
class TestGetJobStatisticsUseCase:
def test_statistics(self, repo):
create_uc = CreateJobUseCase(repo)
create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
j2 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
j2.mark_running()
repo.update(j2)
j3 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
j3.mark_running()
j3.mark_success()
repo.update(j3)
j4 = create_uc.execute(CreateJobCommand(project_id="proj-1", job_type=JobType.VIDEO_COMPOSE))
j4.mark_running()
j4.mark_failed("err")
repo.update(j4)
stats_uc = GetJobStatisticsUseCase(repo)
stats = stats_uc.execute("proj-1")
assert stats["total"] == 4
assert stats["pending"] == 1
assert stats["running"] == 1
assert stats["success"] == 1
assert stats["failed"] == 1
# ── JobService 集成测试 ───────────────────────────────────────────────────────
class TestJobService:
def test_create_compose_job(self, repo):
from app.services.job_service import JobService
svc = JobService(repo)
job = svc.create_compose_job(
project_id="proj-1",
plan_id="plan-1",
user_id="user-1",
)
assert job.job_type == JobType.VIDEO_COMPOSE
assert job.payload == {"plan_id": "plan-1"}
assert job.source_id == "plan-1"
assert job.created_by_user_id == "user-1"
def test_create_render_job(self, repo):
from app.services.job_service import JobService
svc = JobService(repo)
job = svc.create_render_job(
project_id="proj-1",
plan_id="plan-1",
user_id="user-1",
)
assert job.job_type == JobType.RENDER_EDIT_PLAN
def test_submit_compose_if_not_exists_creates_new(self, repo):
from app.services.job_service import JobService
svc = JobService(repo)
job, created = svc.submit_compose_if_not_exists("proj-1", "plan-1", "user-1", "celery-1")
assert created is True
assert job.status == JobStatus.RUNNING
assert job.celery_task_id == "celery-1"
def test_submit_compose_if_not_exists_returns_existing(self, repo):
from app.services.job_service import JobService
svc = JobService(repo)
job1, created1 = svc.submit_compose_if_not_exists("proj-1", "plan-1", "user-1", "celery-1")
job2, created2 = svc.submit_compose_if_not_exists("proj-1", "plan-1", "user-1", "celery-2")
assert created2 is False
assert job2.id == job1.id
def test_full_lifecycle(self, repo):
"""完整生命周期测试:创建 → 提交 → 更新进度 → 完成。"""
from app.services.job_service import JobService
svc = JobService(repo)
# 创建
job = svc.create_compose_job("proj-1", "plan-1", "user-1")
assert job.status == JobStatus.PENDING
# 提交
job = svc.submit_job(job.id, "celery-xyz")
assert job.status == JobStatus.RUNNING
assert job.celery_task_id == "celery-xyz"
# 更新进度
job = svc.update_progress(job.id, 50.0, "合成中")
assert job.progress == 50.0
job = svc.update_progress(job.id, 80.0, "上传结果")
assert job.progress == 80.0
# 完成
job = svc.complete_job(job.id, result={"url": "https://example.com/v.mp4"})
assert job.status == JobStatus.SUCCESS
assert job.progress == 100.0
def test_full_lifecycle_with_retry(self, repo):
"""带重试的完整生命周期测试。"""
from app.services.job_service import JobService
svc = JobService(repo)
# 创建并执行
job = svc.create_compose_job("proj-1", "plan-1", "user-1", max_retries=2)
job = svc.submit_job(job.id, "celery-1")
# 失败
job = svc.fail_job(job.id, "临时网络错误")
assert job.status == JobStatus.FAILED
# 重试
job = svc.retry_job(job.id)
assert job.status == JobStatus.PENDING
assert job.retry_count == 1
# 再次提交
job = svc.submit_job(job.id, "celery-2")
assert job.status == JobStatus.RUNNING
# 成功
job = svc.complete_job(job.id, result={"url": "ok"})
assert job.status == JobStatus.SUCCESS