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
524 lines
17 KiB
Python
524 lines
17 KiB
Python
"""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
|