Files
CI Bot 4c31026f81
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (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 2m37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m38s
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 2m48s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 3m29s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 4m7s
CI/CD Pipeline / Integration Tests (push) Successful in 2m46s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 7m34s
CI/CD Pipeline / Unit Tests (push) Failing after 8m43s
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 / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 15m49s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 30s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 17s
CI/CD Pipeline / ACR Image Cleanup (push) Failing after 17s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m22s
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
style: auto-format with black + isort + prettier [skip ci-format-check]
2026-07-29 02:18:43 +00:00

524 lines
17 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""job 单测.
domain 层统一异步任务纯逻辑模块,0 外部依赖。
覆盖:枚举、create工厂、状态机、进度更新、重试机制、序列化。
"""
from __future__ import annotations
from packages.domain.job import TERMINAL_STATUSES, Job, JobStatus, JobType
class TestJobType:
"""JobType 枚举测试."""
def test_six_types(self):
"""六种任务类型."""
assert len(JobType) == 6
def test_video_compose(self):
assert JobType.VIDEO_COMPOSE == "video_compose"
def test_render_edit_plan(self):
assert JobType.RENDER_EDIT_PLAN == "render_edit_plan"
def test_asset_ingest(self):
assert JobType.ASSET_INGEST == "asset_ingest"
def test_classification(self):
assert JobType.CLASSIFICATION == "classification"
def test_voice_extraction(self):
assert JobType.VOICE_EXTRACTION == "voice_extraction"
def test_generation(self):
assert JobType.GENERATION == "generation"
class TestJobStatus:
"""JobStatus 枚举测试."""
def test_five_statuses(self):
assert len(JobStatus) == 5
def test_pending(self):
assert JobStatus.PENDING == "pending"
def test_running(self):
assert JobStatus.RUNNING == "running"
def test_success(self):
assert JobStatus.SUCCESS == "success"
def test_failed(self):
assert JobStatus.FAILED == "failed"
def test_cancelled(self):
assert JobStatus.CANCELLED == "cancelled"
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:
"""Job.create 工厂测试."""
def test_create_minimal(self):
"""最简创建."""
job = Job.create(project_id="proj1", job_type=JobType.VIDEO_COMPOSE)
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.source_id == ""
assert job.created_by_user_id == ""
assert job.started_at is None
assert job.completed_at is None
assert isinstance(job.id, str)
assert len(job.id) > 0
def test_create_with_string_job_type(self):
"""用字符串传 job_type."""
job = Job.create(project_id="p1", job_type="video_compose")
assert job.job_type == JobType.VIDEO_COMPOSE
def test_create_invalid_job_type_string(self):
"""无效的 job_type 字符串."""
try:
Job.create(project_id="p1", job_type="invalid_type")
raise AssertionError("unexpected success")
except ValueError as e:
assert "不支持的任务类型" in str(e)
def test_create_full(self):
"""带全部字段."""
job = Job.create(
project_id=" proj1 ",
job_type=JobType.CLASSIFICATION,
payload={"asset_id": "a1"},
source_id=" src1 ",
created_by_user_id=" user1 ",
max_retries=5,
)
assert job.project_id == "proj1" # strip
assert job.job_type == JobType.CLASSIFICATION
assert job.payload == {"asset_id": "a1"}
assert job.source_id == "src1" # strip
assert job.created_by_user_id == "user1" # strip
assert job.max_retries == 5
def test_create_empty_project_id(self):
"""空 project_id 无效."""
try:
Job.create(project_id="", job_type=JobType.VIDEO_COMPOSE)
raise AssertionError("unexpected success")
except ValueError as e:
assert "project_id" in str(e)
def test_create_whitespace_project_id(self):
"""空白 project_id 无效."""
try:
Job.create(project_id=" ", job_type=JobType.VIDEO_COMPOSE)
raise AssertionError("unexpected success")
except ValueError as e:
assert "project_id" in str(e)
def test_create_payload_none_defaults_empty(self):
"""payload=None 默认为空 dict."""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, payload=None)
assert job.payload == {}
def test_create_unique_id(self):
"""不同任务 id 不同."""
j1 = Job.create("p", JobType.VIDEO_COMPOSE)
j2 = Job.create("p", JobType.VIDEO_COMPOSE)
assert j1.id != j2.id
def test_create_has_timestamps(self):
"""有创建和更新时间."""
job = Job.create("p", JobType.VIDEO_COMPOSE)
assert job.created_at is not None
assert job.updated_at is not None
class TestJobIsTerminal:
"""is_terminal 属性测试."""
def _make_job(self):
return Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
def test_pending_not_terminal(self):
job = self._make_job()
assert job.is_terminal is False
def test_running_not_terminal(self):
job = self._make_job()
job.mark_running()
assert job.is_terminal is False
def test_success_is_terminal(self):
job = self._make_job()
job.mark_running()
job.mark_success()
assert job.is_terminal is True
def test_failed_is_terminal(self):
job = self._make_job()
job.mark_running()
job.mark_failed("error")
assert job.is_terminal is True
def test_cancelled_is_terminal(self):
job = self._make_job()
job.mark_cancelled()
assert job.is_terminal is True
class TestJobStatusTransitions:
"""状态转换测试."""
def _make_job(self):
return Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
def test_pending_to_running(self):
"""pending → running."""
job = self._make_job()
job.transition_to(JobStatus.RUNNING)
assert job.status == JobStatus.RUNNING
def test_pending_to_success(self):
"""pending → success(瞬时任务)."""
job = self._make_job()
job.transition_to(JobStatus.SUCCESS)
assert job.status == JobStatus.SUCCESS
def test_pending_to_cancelled(self):
"""pending → cancelled."""
job = self._make_job()
job.transition_to(JobStatus.CANCELLED)
assert job.status == JobStatus.CANCELLED
def test_running_to_success(self):
"""running → success."""
job = self._make_job()
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.SUCCESS)
assert job.status == JobStatus.SUCCESS
def test_running_to_failed(self):
"""running → failed."""
job = self._make_job()
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.FAILED)
assert job.status == JobStatus.FAILED
def test_running_to_cancelled(self):
"""running → cancelled."""
job = self._make_job()
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.CANCELLED)
assert job.status == JobStatus.CANCELLED
def test_failed_to_pending_retry(self):
"""failed → pending(重试)."""
job = self._make_job()
job.transition_to(JobStatus.RUNNING)
job.transition_to(JobStatus.FAILED)
job.transition_to(JobStatus.PENDING)
assert job.status == JobStatus.PENDING
def test_cannot_success_to_running(self):
"""success 不能回 running."""
job = self._make_job()
job.mark_running()
job.mark_success()
try:
job.transition_to(JobStatus.RUNNING)
raise AssertionError("unexpected success")
except ValueError as e:
assert "非法状态转换" in str(e)
def test_cannot_pending_to_failed_directly(self):
"""pending 不能直接到 failed(必须经过 running."""
job = self._make_job()
try:
job.transition_to(JobStatus.FAILED)
raise AssertionError("unexpected success")
except ValueError as e:
assert "非法状态转换" in str(e)
def test_transition_with_string(self):
"""用字符串传状态."""
job = self._make_job()
job.transition_to("running")
assert job.status == JobStatus.RUNNING
def test_transition_invalid_string(self):
"""无效状态字符串."""
job = self._make_job()
try:
job.transition_to("invalid")
raise AssertionError("unexpected success")
except ValueError as e:
assert "无效状态" in str(e)
def test_transition_sets_started_at(self):
"""第一次到 running 设置 started_at."""
job = self._make_job()
assert job.started_at is None
job.transition_to(JobStatus.RUNNING)
assert job.started_at is not None
def test_transition_sets_completed_at_on_success(self):
"""success 设置 completed_at."""
job = self._make_job()
job.mark_running()
assert job.completed_at is None
job.mark_success()
assert job.completed_at is not None
def test_transition_sets_completed_at_on_failed(self):
"""failed 设置 completed_at."""
job = self._make_job()
job.mark_running()
job.mark_failed("err")
assert job.completed_at is not None
class TestJobMarkMethods:
"""便捷标记方法测试."""
def _make_job(self):
return Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
def test_mark_running(self):
job = self._make_job()
job.mark_running()
assert job.status == JobStatus.RUNNING
def test_mark_running_with_stage(self):
job = self._make_job()
job.mark_running(stage="正在合成视频")
assert job.status == JobStatus.RUNNING
assert job.current_stage == "正在合成视频"
def test_mark_success(self):
job = self._make_job()
job.mark_running()
job.mark_success()
assert job.status == JobStatus.SUCCESS
assert job.progress == 100.0
assert job.current_stage == "完成"
def test_mark_success_with_result(self):
job = self._make_job()
job.mark_running()
job.mark_success(result={"video_url": "http://x/v.mp4"})
assert job.result == {"video_url": "http://x/v.mp4"}
def test_mark_failed(self):
job = self._make_job()
job.mark_running()
job.mark_failed("网络超时")
assert job.status == JobStatus.FAILED
assert job.error_message == "网络超时"
assert job.current_stage == "失败"
def test_mark_cancelled(self):
job = self._make_job()
job.mark_cancelled()
assert job.status == JobStatus.CANCELLED
assert job.current_stage == "已取消"
class TestJobProgress:
"""进度更新测试."""
def _make_job(self):
return Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
def test_update_progress(self):
"""正常更新进度."""
job = self._make_job()
job.update_progress(50.0)
assert job.progress == 50.0
def test_update_progress_with_stage(self):
"""更新进度同时更新阶段."""
job = self._make_job()
job.update_progress(30.0, stage="合成中")
assert job.progress == 30.0
assert job.current_stage == "合成中"
def test_update_progress_zero(self):
"""0% 合法."""
job = self._make_job()
job.update_progress(0.0)
assert job.progress == 0.0
def test_update_progress_100(self):
"""100% 合法."""
job = self._make_job()
job.update_progress(100.0)
assert job.progress == 100.0
def test_update_progress_negative(self):
"""负进度无效."""
job = self._make_job()
try:
job.update_progress(-1.0)
raise AssertionError("unexpected success")
except ValueError as e:
assert "进度" in str(e)
def test_update_progress_over_100(self):
"""超过100%无效."""
job = self._make_job()
try:
job.update_progress(101.0)
raise AssertionError("unexpected success")
except ValueError as e:
assert "进度" in str(e)
def test_update_progress_updates_timestamp(self):
"""更新进度时更新 updated_at."""
job = self._make_job()
old = job.updated_at
job.update_progress(50.0)
assert job.updated_at >= old
class TestJobRetry:
"""重试机制测试."""
def _make_failed_job(self, max_retries=3):
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE, max_retries=max_retries)
job.mark_running()
job.mark_failed("error")
return job
def test_is_retryable_true(self):
"""失败且未超过重试次数时可重试."""
job = self._make_failed_job(max_retries=3)
assert job.is_retryable is True
def test_is_retryable_false_not_failed(self):
"""非失败状态不可重试."""
job = Job.create("p", JobType.VIDEO_COMPOSE)
assert job.is_retryable is False
def test_is_retryable_false_exceeded(self):
"""超过重试次数不可重试."""
job = self._make_failed_job(max_retries=0)
assert job.is_retryable is False
def test_prepare_retry(self):
"""准备重试."""
job = self._make_failed_job(max_retries=3)
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_multiple_times(self):
"""多次重试."""
job = self._make_failed_job(max_retries=3)
job.prepare_retry()
job.mark_running()
job.mark_failed("err2")
assert job.retry_count == 1
job.prepare_retry()
assert job.retry_count == 2
assert "第 2 次重试" in job.current_stage
def test_prepare_retry_not_retryable(self):
"""不可重试时报错."""
job = self._make_failed_job(max_retries=0)
try:
job.prepare_retry()
raise AssertionError("unexpected success")
except ValueError as e:
assert "不可重试" in str(e)
class TestJobToDict:
"""to_dict 序列化测试."""
def test_to_dict_keys(self):
"""包含所有必要字段."""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
d = job.to_dict()
assert isinstance(d, dict)
keys = [
"id",
"project_id",
"job_type",
"status",
"progress",
"payload",
"result",
"error_message",
"retry_count",
"max_retries",
"source_id",
"is_retryable",
"created_at",
"updated_at",
]
for k in keys:
assert k in d, f"缺少字段: {k}"
def test_to_dict_enums_as_strings(self):
"""枚举值序列化为字符串."""
job = Job.create(project_id="p1", job_type=JobType.CLASSIFICATION)
d = job.to_dict()
assert d["job_type"] == "classification"
assert d["status"] == "pending"
def test_to_dict_none_timestamps(self):
"""None 时间戳序列化为 None."""
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_to_dict_after_success(self):
"""成功后的序列化."""
job = Job.create(project_id="p1", job_type=JobType.VIDEO_COMPOSE)
job.mark_running("test")
job.mark_success({"url": "http://x"})
d = job.to_dict()
assert d["status"] == "success"
assert d["progress"] == 100.0
assert d["result"] == {"url": "http://x"}
assert d["started_at"] is not None
assert d["completed_at"] is not None
assert d["is_retryable"] is False