Files
xiaoxia-saas/tests/unit/test_dedup_two_phase_commit.py
T
xiaoxia 0469272bd6
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 (push) Successful in 1s
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 2s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Validate - Security (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) 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 (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check push changed paths (push) Successful in 9s
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 22s
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 / Build Staging API Image (push) Successful in 38s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (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 / Staging E2E Tests (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 1m59s
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 / Build Staging Worker Image (push) Successful in 33s
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 2m23s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m24s
CI/CD Pipeline / Integration Tests (push) Successful in 2m40s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m31s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m16s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (pull_request) Successful in 3s
CI/CD Pipeline / Validate - Style (push) Successful in 3m6s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m42s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m48s
CI/CD Pipeline / Validate - Security (push) Successful in 5m28s
AI Code Review / AI Code Review (pull_request) Failing after 5m35s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 6m5s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 4m22s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 5m6s
CI/CD Pipeline / Unit Tests (push) Successful in 8m54s
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 / Canary Release to Production (push) Has been skipped
fix(#1743): 批量变体独立选片——完整重跑单视频选片+批次20%重叠避让+查重超阈重渲+封面独立 (#1745)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-06 18:07:24 +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()