23fe5f9822
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Check push changed paths (pull_request) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Validate - Style (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
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 / Check push changed paths (push) Successful in 10s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 56s
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 2m37s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 48s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m19s
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m3s
CI/CD Pipeline / CI Gate (pull_request) Successful in 2s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 3m58s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 54s
CI/CD Pipeline / Integration Tests (push) Successful in 4m1s
CI/CD Pipeline / Validate - Style (push) Successful in 4m29s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m27s
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
CI/CD Pipeline / Build Production API Image (push) Has been cancelled
CI/CD Pipeline / Build Production Web Image (push) Has been cancelled
CI/CD Pipeline / Build Production Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Production (push) Has been cancelled
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Production Browser E2E (push) Has been cancelled
CI/CD Pipeline / ACR Image Cleanup (push) Has been cancelled
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
CI/CD Pipeline / CI Gate (push) Has been cancelled
CI/CD Pipeline / Validate - Security (push) Has been cancelled
CI/CD Pipeline / Unit Tests (push) Has been cancelled
AI Code Review / AI Code Review (pull_request) Has been cancelled
Preview Deploy / Deploy Preview Environment (pull_request) Has been cancelled
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
430 lines
17 KiB
Python
430 lines
17 KiB
Python
"""#2024 GenerationFinalizeService 单元测试。
|
|
|
|
覆盖 service 层:存在性校验、幂等分支、状态门、封面决策、异常映射、成功路径。
|
|
同时为 packages/application/generated_video_finalize.py 的缺失分支补测。
|
|
"""
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
# ---------- helpers ----------
|
|
|
|
|
|
def _make_task(
|
|
task_id="task-1",
|
|
status="awaiting_cover",
|
|
project_id="proj-1",
|
|
user_id="user-1",
|
|
cover_url="",
|
|
extra_meta=None,
|
|
error_message="",
|
|
):
|
|
t = MagicMock()
|
|
t.id = task_id
|
|
t.project_id = project_id
|
|
t.created_by_user_id = user_id
|
|
t.cover_url = cover_url
|
|
t.extra_meta = extra_meta if extra_meta is not None else {}
|
|
t.error_message = error_message
|
|
s = MagicMock()
|
|
s.value = status
|
|
t.status = s
|
|
|
|
def _mark_completed(result_count=1):
|
|
s.value = "completed"
|
|
t.completed_at = "now"
|
|
|
|
t.mark_completed = MagicMock(side_effect=_mark_completed)
|
|
|
|
def _mark_confirmed():
|
|
t.is_preview = False
|
|
|
|
t.mark_confirmed = MagicMock(side_effect=_mark_confirmed)
|
|
return t
|
|
|
|
|
|
def _make_db():
|
|
db = MagicMock()
|
|
db.query.return_value.filter.return_value.first.return_value = None
|
|
db.commit = MagicMock()
|
|
db.rollback = MagicMock()
|
|
db.bulk_save_objects = MagicMock()
|
|
return db
|
|
|
|
|
|
def _make_rendered_dict(**overrides):
|
|
base = {
|
|
"file_url": "https://oss.example.com/v.mp4",
|
|
"file_size": 123456,
|
|
"duration": 10.5,
|
|
"width": 1280,
|
|
"height": 720,
|
|
"fps": 25.0,
|
|
"name": "demo.mp4",
|
|
"thumbnail_url": "https://oss.example.com/thumb.jpg",
|
|
"mode": "narrative",
|
|
"batch_id": "",
|
|
"project_id": "proj-1",
|
|
"user_id": "user-1",
|
|
"fingerprint_dict": {"phash": "abc"},
|
|
"fingerprint_chunks": [
|
|
{
|
|
"start_time_ms": 0,
|
|
"end_time_ms": 1000,
|
|
"phash_binary": "0101",
|
|
"color_histogram": [0.1, 0.2, 0.3],
|
|
"frame_count": 25,
|
|
},
|
|
],
|
|
"is_duplicate": False,
|
|
"duplicate_of": None,
|
|
"duplicate_rate": 0.0,
|
|
"match_count": 0,
|
|
"visual_similarity": 0.0,
|
|
"video_fingerprint_md5": "md5-abc",
|
|
}
|
|
base.update(overrides)
|
|
return base
|
|
|
|
|
|
_PATCHES = [
|
|
"packages.adapters.sqlalchemy_impl.generation_task_repository.SQLAlchemyGenerationTaskRepository",
|
|
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
|
"packages.adapters.sqlalchemy_impl.models.GeneratedVideoModel",
|
|
"packages.application.generated_video_finalize.finalize_generated_video",
|
|
]
|
|
|
|
|
|
def _svc(db):
|
|
from app.services.generation_finalize_service import GenerationFinalizeService
|
|
|
|
return GenerationFinalizeService(db)
|
|
|
|
|
|
# ---------- service tests ----------
|
|
|
|
|
|
class TestFinalizeService:
|
|
def test_task_not_found_raises_404(self):
|
|
from app.services.generation_finalize_service import GenerationFinalizeError
|
|
|
|
db = _make_db()
|
|
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]), patch(_PATCHES[2]), patch(_PATCHES[3]):
|
|
TR.return_value.get.return_value = None
|
|
svc = _svc(db)
|
|
with pytest.raises(GenerationFinalizeError) as ei:
|
|
svc.finalize_task("nope", "user-1")
|
|
assert ei.value.status_code == 404
|
|
assert ei.value.code == "TaskNotFound"
|
|
|
|
def test_invalid_status_raises(self):
|
|
from app.services.generation_finalize_service import GenerationFinalizeError
|
|
|
|
db = _make_db()
|
|
task = _make_task(status="running")
|
|
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]), patch(_PATCHES[2]) as GVM, patch(_PATCHES[3]):
|
|
TR.return_value.get.return_value = task
|
|
GVM.query.filter.return_value.first.return_value = None
|
|
svc = _svc(db)
|
|
with pytest.raises(GenerationFinalizeError) as ei:
|
|
svc.finalize_task("task-1", "user-1")
|
|
assert ei.value.code == "InvalidTaskStatus"
|
|
assert ei.value.status_code == 400
|
|
|
|
def test_idempotent_when_video_already_exists_updates_cover_and_completes(self):
|
|
db = _make_db()
|
|
task = _make_task(status="awaiting_cover")
|
|
existing = MagicMock()
|
|
existing.id = "video-exist"
|
|
existing.thumbnail_url = "https://old-cover.jpg"
|
|
db.query.return_value.filter.return_value.first.return_value = existing
|
|
existing_video = MagicMock()
|
|
existing_video.id = "video-exist"
|
|
|
|
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]) as VR, patch(_PATCHES[2]), patch(_PATCHES[3]):
|
|
TR.return_value.get.return_value = task
|
|
TR.return_value.update = MagicMock()
|
|
VR.return_value.get.return_value = existing_video
|
|
svc = _svc(db)
|
|
result = svc.finalize_task("task-1", "user-1", cover_url="https://new-cover.jpg")
|
|
assert result.id == "video-exist"
|
|
assert existing.thumbnail_url == "https://new-cover.jpg"
|
|
assert task.cover_url == "https://new-cover.jpg"
|
|
task.mark_completed.assert_called()
|
|
TR.return_value.update.assert_called_with(task)
|
|
db.commit.assert_called()
|
|
|
|
def test_idempotent_already_completed_skips_mark_completed(self):
|
|
db = _make_db()
|
|
task = _make_task(status="completed")
|
|
existing = MagicMock()
|
|
existing.id = "v-exist"
|
|
existing.thumbnail_url = "https://c.jpg"
|
|
db.query.return_value.filter.return_value.first.return_value = existing
|
|
existing_video = MagicMock()
|
|
|
|
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]) as VR, patch(_PATCHES[2]), patch(_PATCHES[3]):
|
|
TR.return_value.get.return_value = task
|
|
VR.return_value.get.return_value = existing_video
|
|
svc = _svc(db)
|
|
svc.finalize_task("task-1", "user-1")
|
|
task.mark_completed.assert_not_called()
|
|
|
|
def test_missing_rendered_output_raises(self):
|
|
from app.services.generation_finalize_service import GenerationFinalizeError
|
|
|
|
db = _make_db()
|
|
task = _make_task(status="awaiting_cover", extra_meta={})
|
|
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]), patch(_PATCHES[2]), patch(_PATCHES[3]) as fu:
|
|
TR.return_value.get.return_value = task
|
|
TR.return_value.update = MagicMock()
|
|
fu.side_effect = ValueError("file_url 为空")
|
|
svc = _svc(db)
|
|
with pytest.raises(GenerationFinalizeError) as ei:
|
|
svc.finalize_task("task-1", "user-1")
|
|
assert ei.value.code == "RenderedOutputMissing"
|
|
|
|
def test_success_creates_video_and_marks_completed(self):
|
|
db = _make_db()
|
|
task = _make_task(
|
|
status="awaiting_cover",
|
|
cover_url="https://task-cover.jpg",
|
|
extra_meta={"rendered_output": _make_rendered_dict()},
|
|
)
|
|
created_video = MagicMock()
|
|
created_video.id = "video-new"
|
|
|
|
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]) as VR, patch(_PATCHES[2]), patch(_PATCHES[3]) as fu:
|
|
TR.return_value.get.return_value = task
|
|
TR.return_value.update = MagicMock()
|
|
fu.return_value = {"video_id": "video-new", "is_duplicate": False, "duplicate_of": None}
|
|
VR.return_value.get.return_value = created_video
|
|
svc = _svc(db)
|
|
v = svc.finalize_task("task-1", "user-1")
|
|
assert v.id == "video-new"
|
|
task.mark_completed.assert_called_once_with(result_count=1)
|
|
assert "rendered_output" not in task.extra_meta
|
|
TR.return_value.update.assert_called_with(task)
|
|
db.commit.assert_called()
|
|
|
|
def test_cover_fallback_to_task_cover_url(self):
|
|
db = _make_db()
|
|
task = _make_task(
|
|
status="awaiting_cover",
|
|
cover_url="https://task-cover.jpg",
|
|
extra_meta={"rendered_output": _make_rendered_dict(thumbnail_url="")},
|
|
)
|
|
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]) as VR, patch(_PATCHES[2]), patch(_PATCHES[3]) as fu:
|
|
TR.return_value.get.return_value = task
|
|
TR.return_value.update = MagicMock()
|
|
fu.return_value = {"video_id": "v1", "is_duplicate": False, "duplicate_of": None}
|
|
VR.return_value.get.return_value = MagicMock(id="v1")
|
|
svc = _svc(db)
|
|
svc.finalize_task("task-1", "user-1")
|
|
assert task.cover_url == "https://task-cover.jpg"
|
|
kwargs = fu.call_args.kwargs
|
|
assert kwargs["effective_cover_url"] == "https://task-cover.jpg"
|
|
|
|
def test_explicit_cover_url_overrides_task_cover(self):
|
|
db = _make_db()
|
|
task = _make_task(
|
|
status="awaiting_cover",
|
|
cover_url="https://old.jpg",
|
|
extra_meta={"rendered_output": _make_rendered_dict()},
|
|
)
|
|
with patch(_PATCHES[0]) as TR, patch(_PATCHES[1]) as VR, patch(_PATCHES[2]), patch(_PATCHES[3]) as fu:
|
|
TR.return_value.get.return_value = task
|
|
TR.return_value.update = MagicMock()
|
|
fu.return_value = {"video_id": "v1", "is_duplicate": False, "duplicate_of": None}
|
|
VR.return_value.get.return_value = MagicMock(id="v1")
|
|
svc = _svc(db)
|
|
svc.finalize_task("task-1", "user-1", cover_url=" https://new.jpg ")
|
|
kwargs = fu.call_args.kwargs
|
|
assert kwargs["effective_cover_url"] == "https://new.jpg"
|
|
|
|
|
|
# ---------- packages/application/generated_video_finalize.py 覆盖补测 ----------
|
|
|
|
|
|
class TestFinalizeUseCaseCoverage:
|
|
def test_rendered_output_non_dict_raises(self):
|
|
from packages.application.generated_video_finalize import RenderedOutput
|
|
|
|
with pytest.raises(ValueError):
|
|
RenderedOutput.from_dict("not-a-dict")
|
|
|
|
def test_safe_float_handles_invalid(self):
|
|
from packages.application.generated_video_finalize import _safe_float, _safe_int
|
|
|
|
assert _safe_float(None) is None
|
|
assert _safe_float("abc") is None
|
|
assert _safe_float("3.14") == pytest.approx(3.14)
|
|
assert _safe_int(None) is None
|
|
assert _safe_int("xyz") is None
|
|
assert _safe_int("42") == 42
|
|
|
|
def test_fingerprint_chunks_non_dict_entry_is_skipped(self):
|
|
"""非 dict chunk 被 continue 跳过;bulk_save 只处理合法 chunk。"""
|
|
from packages.application import generated_video_finalize as mod
|
|
|
|
task = _make_task(
|
|
extra_meta={
|
|
"rendered_output": _make_rendered_dict(
|
|
fingerprint_chunks=[
|
|
"not-a-dict",
|
|
{
|
|
"start_time_ms": 0,
|
|
"end_time_ms": 500,
|
|
"phash_binary": "xx",
|
|
"color_histogram": [0.1, 0.2],
|
|
"frame_count": 10,
|
|
},
|
|
],
|
|
)
|
|
}
|
|
)
|
|
db = MagicMock()
|
|
db.bulk_save_objects = MagicMock()
|
|
db.commit = MagicMock()
|
|
# 模块内的 SQLAlchemyGeneratedVideoRepository/GeneratedVideo/VideoFingerprintChunkModel
|
|
# 都是在函数内部 import 的,直接 patch 到被 patch 模块的属性上
|
|
fake_repo = MagicMock()
|
|
fake_repo.create = MagicMock()
|
|
with (
|
|
patch(
|
|
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
|
return_value=fake_repo,
|
|
),
|
|
patch(
|
|
"packages.adapters.sqlalchemy_impl.models.VideoFingerprintChunkModel",
|
|
side_effect=lambda **kw: MagicMock(**kw),
|
|
),
|
|
patch("packages.domain.generated_video.GeneratedVideo", side_effect=lambda **kw: MagicMock(**kw)),
|
|
):
|
|
result = mod.finalize_generated_video(
|
|
task=task,
|
|
session=db,
|
|
effective_cover_url="https://cover.jpg",
|
|
)
|
|
assert "video_id" in result
|
|
assert db.bulk_save_objects.call_count == 1
|
|
saved_chunks = db.bulk_save_objects.call_args[0][0]
|
|
assert len(saved_chunks) == 1
|
|
db.commit.assert_called()
|
|
fake_repo.create.assert_called_once()
|
|
|
|
def test_missing_file_url_raises(self):
|
|
from packages.application import generated_video_finalize as mod
|
|
|
|
task = _make_task(
|
|
extra_meta={
|
|
"rendered_output": _make_rendered_dict(file_url=""),
|
|
}
|
|
)
|
|
db = MagicMock()
|
|
with pytest.raises(ValueError):
|
|
mod.finalize_generated_video(task=task, session=db, effective_cover_url="")
|
|
|
|
def test_no_fingerprint_chunks_skips_bulk_save(self):
|
|
from packages.application import generated_video_finalize as mod
|
|
|
|
task = _make_task(
|
|
extra_meta={
|
|
"rendered_output": _make_rendered_dict(fingerprint_chunks=None),
|
|
}
|
|
)
|
|
db = MagicMock()
|
|
db.bulk_save_objects = MagicMock()
|
|
db.commit = MagicMock()
|
|
fake_repo = MagicMock()
|
|
with (
|
|
patch(
|
|
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
|
return_value=fake_repo,
|
|
),
|
|
patch("packages.domain.generated_video.GeneratedVideo", side_effect=lambda **kw: MagicMock(**kw)),
|
|
):
|
|
mod.finalize_generated_video(task=task, session=db, effective_cover_url="")
|
|
db.bulk_save_objects.assert_not_called()
|
|
fake_repo.create.assert_called_once()
|
|
db.commit.assert_called()
|
|
|
|
def test_name_fallback_when_empty(self):
|
|
from packages.application import generated_video_finalize as mod
|
|
|
|
task = _make_task(
|
|
task_id="abcd1234ef567890",
|
|
extra_meta={
|
|
"rendered_output": _make_rendered_dict(name=" ", thumbnail_url=""),
|
|
},
|
|
)
|
|
db = MagicMock()
|
|
db.bulk_save_objects = MagicMock()
|
|
db.commit = MagicMock()
|
|
fake_repo = MagicMock()
|
|
captured = {}
|
|
|
|
def _capture(**kw):
|
|
captured.update(kw)
|
|
return MagicMock(**kw)
|
|
|
|
with (
|
|
patch(
|
|
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
|
return_value=fake_repo,
|
|
),
|
|
patch("packages.domain.generated_video.GeneratedVideo", side_effect=_capture),
|
|
patch(
|
|
"packages.adapters.sqlalchemy_impl.models.VideoFingerprintChunkModel",
|
|
side_effect=lambda **kw: MagicMock(**kw),
|
|
),
|
|
):
|
|
mod.finalize_generated_video(task=task, session=db, effective_cover_url="")
|
|
assert captured["name"].startswith("generated-abcd1234")
|
|
assert captured["thumbnail_url"] is None
|
|
|
|
def test_chunk_exception_is_swallowed(self):
|
|
"""chunk 构造异常时 logger.warning,不阻塞主流程。"""
|
|
from packages.application import generated_video_finalize as mod
|
|
|
|
task = _make_task(
|
|
extra_meta={
|
|
"rendered_output": _make_rendered_dict(
|
|
fingerprint_chunks=[
|
|
{
|
|
"start_time_ms": 0,
|
|
"end_time_ms": 500,
|
|
"phash_binary": "xx",
|
|
"color_histogram": ["not-a-number"],
|
|
"frame_count": 10,
|
|
},
|
|
],
|
|
)
|
|
}
|
|
)
|
|
db = MagicMock()
|
|
db.bulk_save_objects = MagicMock()
|
|
db.commit = MagicMock()
|
|
fake_repo = MagicMock()
|
|
with (
|
|
patch(
|
|
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
|
|
return_value=fake_repo,
|
|
),
|
|
patch("packages.domain.generated_video.GeneratedVideo", side_effect=lambda **kw: MagicMock(**kw)),
|
|
patch(
|
|
"packages.adapters.sqlalchemy_impl.models.VideoFingerprintChunkModel",
|
|
side_effect=lambda **kw: MagicMock(**kw),
|
|
),
|
|
):
|
|
# color_histogram 里 "not-a-number" 触发 float() 异常,被 except chunk_err 吞掉
|
|
# 但此时 chunk_models 中仍有 1 个元素(MagicMock 构造不会因 float() 失败)——
|
|
# 因为我们把 float 列表推导也放在 try 内,float("not-a-number") 抛 ValueError
|
|
# 所以要让 float 真的抛。但 MagicMock side_effect 不触发 float(),这里直接构造:
|
|
# 通过真实验证路径
|
|
result = mod.finalize_generated_video(task=task, session=db, effective_cover_url="")
|
|
assert "video_id" in result
|
|
db.commit.assert_called()
|
|
fake_repo.create.assert_called_once()
|