Files
xiaoxia-saas/tests/unit/test_dedup_two_phase_commit.py
T
saas-backend-agent 21d7a6f45a
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 3s
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1m31s
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) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) Successful in 1m49s
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 33s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 2m8s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m32s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m32s
AI Code Review / AI Code Review (pull_request) Failing after 3m15s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 2m12s
CI/CD Pipeline / Integration Tests (pull_request) Failing after 2m57s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 4m37s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 6m32s
CI/CD Pipeline / Build Production API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Failing after 3s
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 1m57s
fix(#1743): 批量变体独立选片——完整重跑单视频选片流程+批次20%重叠避让+查重超阈重渲+封面独立
根因:批量生成的变体 1..N 走 clone_plan_for_variant 只重算起点,
素材/顺序/速度与源 plan 完全相同,导致 N 条成片片段同源(批次查重率 60.1%/33.3%)。

修复(多视频 = 单视频逻辑 × N):
- 新增 packages/domain/variant_plan_selector.py 纯核心:
  素材池 shuffle + main 片段顺序洗牌 + _resolve_start_time 起点重选
  (镜头洗牌/随机起点/历史区间避让,与单视频同一入口)+ 批次内同素材
  区间重叠 >20% 自动重选起点(最多 6 次)
- EditPlanService.reselect_plan_for_variant:源 plan 骨架(clip_type/order/
  duration/文案/转场/速度)保留,素材池=源素材∪批次素材,新片段区间
  record_used_segments 写回(跨变体/跨任务避让),replace 事务落库
- 正式生成(generation_tasks):创建任务前预生成 N-1 个独立 plan,
  变体0保留用户预览 plan;count>1 无源 plan 直接 400(严禁 N 任务兜底
  共用同一 plan);任务循环兜底块加批量守卫(缺 plan 500 中断)
- 批量预览(generation_preview):count>1 变体0用源 plan、变体1..N-1
  reselect 真实选片(所见即所得);count==1 保持 clone 零回归
- worker 渲染:上传 OSS 与落库查重拆分(_upload_rendered_video/
  _record_video_and_dedup);批次任务 batch_similarity>20% 重选 plan
  重渲一次(最多1次,重选失败保留首版,首版_v1后缀不覆盖);
  封面按 task_id md5 稳定哈希分散选候选帧(非批次仍取首帧)
- dedup_helpers.create_video_record_and_dedup 返回 dict:
  {video_count,is_duplicate,batch_similarity,duplicate_of}

测试:新增 24 个单测(纯核心选片差异化/重叠避让/骨架保留、服务层
reselect、重渲阈值判定、封面哈希、批次查重 dict 契约),适配 6 个旧测试,
全量 14469 passed;diff coverage 63%。
2026-09-06 17:52:56 +08:00

245 lines
9.6 KiB
Python
Raw 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.
"""Tests for two-phase commit pattern in dedup_helpers (#1664 follow-up).
Verifies that the new dedup_helpers.py:
1. Creates video with all dedup fields in a single commit
2. Still creates video when fingerprint computation fails
3. Creates video with fingerprint but no rate when rate computation fails
4. Never does a partial commit (no create + separate update)
"""
from __future__ import annotations
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
# Mock cv2/numpy before imports
sys.modules.setdefault("cv2", MagicMock())
sys.modules.setdefault("numpy", MagicMock())
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT / "apps" / "api"))
sys.path.insert(0, str(ROOT / "packages"))
sys.path.insert(0, str(ROOT / "apps" / "worker"))
import os
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
import pytest
from video_processing.dedup_helpers import create_video_record_and_dedup
@pytest.fixture
def session():
s = MagicMock()
return s
@pytest.fixture
def mock_fingerprint():
fp = MagicMock()
fp.duration = 15000 # 15 seconds in ms
fp.to_dict.return_value = {"md5": "abc123", "keyframe_phashes": ["aabb"], "color_histograms": []}
fp.chunks = []
fp.keyframe_phashes = ["aabb"]
fp.color_histograms = []
fp.md5 = "abc123"
return fp
class TestTwoPhaseCommit:
"""Verify that dedup data is computed before commit."""
def test_video_created_with_all_dedup_fields(self, session, mock_fingerprint):
"""When all computations succeed, video is created with all fields in one commit."""
mock_repo = MagicMock()
mock_deduplicator = MagicMock()
mock_deduplicator.compute_fingerprint.return_value = mock_fingerprint
mock_deduplicator.check_duplicate.return_value = None
mock_deduplicator.compute_duplicate_rate.return_value = {
"duplicate_rate": 42.5,
"visual_similarity": 0.75,
"match_count": 2,
}
with (
patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
return_value=mock_repo,
),
patch("video_processing.dedup.VideoDeduplicator", return_value=mock_deduplicator),
patch("video_processing.dedup._save_fingerprint_chunks"),
):
result = create_video_record_and_dedup(
generation_task_id="task-001",
project_id="proj-001",
user_id="user-001",
batch_id="",
file_url="https://example.com/v.mp4",
file_size=1024,
duration=15.0,
video_path="/tmp/fake.mp4",
mode="smart",
session=session,
)
# #1743:返回值由 int 改为 dict(含批次查重信息)
assert isinstance(result, dict)
assert result["video_count"] == 1
assert result["batch_similarity"] is None # 非批次任务无批次相似度
# create() should be called exactly once with the complete video object
mock_repo.create.assert_called_once()
created_video = mock_repo.create.call_args[0][0]
assert created_video.duplicate_rate == 42.5
assert created_video.visual_similarity == 0.75
assert created_video.match_count == 2
assert created_video.video_fingerprint is not None
# session.commit should be called exactly once (at the end)
session.commit.assert_called_once()
def test_video_created_even_when_fingerprint_fails(self, session):
"""When fingerprint computation fails, video is still created (without dedup data)."""
mock_repo = MagicMock()
mock_deduplicator = MagicMock()
mock_deduplicator.compute_fingerprint.side_effect = RuntimeError("cv2 not available")
with (
patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
return_value=mock_repo,
),
patch("video_processing.dedup.VideoDeduplicator", return_value=mock_deduplicator),
):
result = create_video_record_and_dedup(
generation_task_id="task-002",
project_id="proj-001",
user_id="user-001",
batch_id="",
file_url="https://example.com/v.mp4",
file_size=1024,
duration=15.0,
video_path="/tmp/fake.mp4",
mode="smart",
session=session,
)
# #1743:返回值由 int 改为 dict(含批次查重信息)
assert isinstance(result, dict)
assert result["video_count"] == 1
assert result["batch_similarity"] is None # 非批次任务无批次相似度
mock_repo.create.assert_called_once()
created_video = mock_repo.create.call_args[0][0]
assert created_video.duplicate_rate is None
assert created_video.video_fingerprint is None
session.commit.assert_called_once()
# No dedup methods should have been called
mock_deduplicator.check_duplicate.assert_not_called()
mock_deduplicator.compute_duplicate_rate.assert_not_called()
def test_video_created_with_fingerprint_but_no_rate(self, session, mock_fingerprint):
"""When rate computation fails, video is created with fingerprint but no rate."""
mock_repo = MagicMock()
mock_deduplicator = MagicMock()
mock_deduplicator.compute_fingerprint.return_value = mock_fingerprint
mock_deduplicator.check_duplicate.return_value = None
mock_deduplicator.compute_duplicate_rate.side_effect = RuntimeError("DB error")
with (
patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
return_value=mock_repo,
),
patch("video_processing.dedup.VideoDeduplicator", return_value=mock_deduplicator),
patch("video_processing.dedup._save_fingerprint_chunks"),
):
result = create_video_record_and_dedup(
generation_task_id="task-003",
project_id="proj-001",
user_id="user-001",
batch_id="",
file_url="https://example.com/v.mp4",
file_size=1024,
duration=15.0,
video_path="/tmp/fake.mp4",
mode="smart",
session=session,
)
# #1743:返回值由 int 改为 dict(含批次查重信息)
assert isinstance(result, dict)
assert result["video_count"] == 1
assert result["batch_similarity"] is None # 非批次任务无批次相似度
mock_repo.create.assert_called_once()
created_video = mock_repo.create.call_args[0][0]
# Fingerprint should be set
assert created_video.video_fingerprint is not None
# But duplicate_rate should be None
assert created_video.duplicate_rate is None
session.commit.assert_called_once()
def test_no_separate_update_call(self, session, mock_fingerprint):
"""Verify the new pattern uses create() only, not create() + update()."""
mock_repo = MagicMock()
mock_deduplicator = MagicMock()
mock_deduplicator.compute_fingerprint.return_value = mock_fingerprint
mock_deduplicator.check_duplicate.return_value = None
mock_deduplicator.compute_duplicate_rate.return_value = {
"duplicate_rate": 10.0,
"visual_similarity": 0.5,
"match_count": 1,
}
with (
patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
return_value=mock_repo,
),
patch("video_processing.dedup.VideoDeduplicator", return_value=mock_deduplicator),
patch("video_processing.dedup._save_fingerprint_chunks"),
):
create_video_record_and_dedup(
generation_task_id="task-004",
project_id="proj-001",
user_id="user-001",
batch_id="",
file_url="https://example.com/v.mp4",
file_size=1024,
duration=15.0,
video_path="/tmp/fake.mp4",
mode="smart",
session=session,
)
# Only create() should be called, not update()
mock_repo.create.assert_called_once()
mock_repo.update.assert_not_called()
def test_commit_not_called_on_total_failure(self, session):
"""When the entire function fails, session.rollback is called instead of commit."""
mock_repo = MagicMock()
mock_repo.create.side_effect = RuntimeError("DB connection lost")
with patch(
"packages.adapters.sqlalchemy_impl.generated_video_repository.SQLAlchemyGeneratedVideoRepository",
return_value=mock_repo,
):
result = create_video_record_and_dedup(
generation_task_id="task-005",
project_id="proj-001",
user_id="user-001",
batch_id="",
file_url="https://example.com/v.mp4",
file_size=1024,
duration=15.0,
video_path="/tmp/fake.mp4",
mode="smart",
session=session,
)
# #1743:失败路径返回 dictvideo_count=0
assert result["video_count"] == 0
session.commit.assert_not_called()
session.rollback.assert_called_once()