f10fd9cd5c
CI/CD Pipeline / Check if frontend-only change (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 / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / PR Build Worker Image (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 4s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
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 / Build Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (pull_request) 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 / 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 API Image (pull_request) Successful in 8s
CI/CD Pipeline / Build Staging API Image (push) Successful in 33s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 35s
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 Worker Image (pull_request) Successful in 40s
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 / 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
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m11s
CI/CD Pipeline / Validate - Style (push) Successful in 2m34s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 2m1s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m32s
CI/CD Pipeline / CI Gate (pull_request) Successful in 4s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m41s
CI/CD Pipeline / Retag skipped Staging Web Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m28s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m8s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m5s
AI Code Review / AI Code Review (pull_request) Successful in 6m28s
CI/CD Pipeline / Validate - Security (push) Successful in 7m1s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m40s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 7m40s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m22s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m29s
CI/CD Pipeline / Unit Tests (push) Successful in 13m51s
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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
203 lines
7.6 KiB
Python
203 lines
7.6 KiB
Python
"""Tests for duplicate_rate computation and API response."""
|
|
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
# Mock cv2 and numpy before any imports that need them
|
|
sys.modules.setdefault("cv2", 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"))
|
|
|
|
|
|
class TestComputeDuplicateRate:
|
|
"""Test VideoDeduplicator.compute_duplicate_rate."""
|
|
|
|
def _make_fingerprint(self, md5="abc123", phashes=None, duration_ms=10000):
|
|
from video_processing.dedup import VideoFingerprint
|
|
|
|
return VideoFingerprint(
|
|
md5=md5,
|
|
keyframe_phashes=phashes or ["ff00ff00ff00ff00"],
|
|
color_histograms=[],
|
|
duration=duration_ms,
|
|
resolution=(1920, 1080),
|
|
)
|
|
|
|
def _make_existing_video(self, vid, fingerprint_dict):
|
|
from packages.domain import GeneratedVideo
|
|
|
|
return GeneratedVideo(
|
|
id=vid,
|
|
project_id="proj1",
|
|
generation_task_id="task1",
|
|
name=f"video-{vid}",
|
|
file_url=f"https://example.com/{vid}.mp4",
|
|
file_size=1000,
|
|
duration=10.0,
|
|
width=1920,
|
|
height=1080,
|
|
fps=25.0,
|
|
video_fingerprint=fingerprint_dict,
|
|
)
|
|
|
|
def test_no_existing_videos_returns_zero(self):
|
|
from video_processing.dedup import VideoDeduplicator
|
|
|
|
deduplicator = VideoDeduplicator()
|
|
fingerprint = self._make_fingerprint()
|
|
session = MagicMock()
|
|
|
|
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
|
mock_repo = MockRepo.return_value
|
|
mock_repo.list_by_project.return_value = []
|
|
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
|
|
|
|
assert rate["duplicate_rate"] == 0.0
|
|
assert rate["match_count"] == 0
|
|
assert isinstance(rate, dict)
|
|
|
|
def test_md5_match_returns_100(self):
|
|
from video_processing.dedup import VideoDeduplicator
|
|
|
|
deduplicator = VideoDeduplicator()
|
|
fingerprint = self._make_fingerprint(md5="exact_md5")
|
|
session = MagicMock()
|
|
|
|
existing = self._make_existing_video("vid2", {"md5": "exact_md5", "keyframe_phashes": ["aa"]})
|
|
|
|
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
|
mock_repo = MockRepo.return_value
|
|
mock_repo.list_by_project.return_value = [existing]
|
|
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
|
|
|
|
assert rate["duplicate_rate"] == 100.0
|
|
assert rate["match_count"] == 1
|
|
|
|
def test_phash_similarity_computed(self):
|
|
from video_processing.dedup import VideoDeduplicator
|
|
|
|
deduplicator = VideoDeduplicator()
|
|
# Two very similar phashes
|
|
fingerprint = self._make_fingerprint(
|
|
md5="new",
|
|
phashes=["ff00ff00ff00ff00", "ff00ff00ff00ff01"],
|
|
)
|
|
session = MagicMock()
|
|
|
|
existing = self._make_existing_video(
|
|
"vid2",
|
|
{"md5": "other", "keyframe_phashes": ["ff00ff00ff00ff00", "ff00ff00ff00ff02"]},
|
|
)
|
|
|
|
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
|
mock_repo = MockRepo.return_value
|
|
mock_repo.list_by_project.return_value = [existing]
|
|
mock_repo._get_existing_chunks = MagicMock(return_value=[])
|
|
# Patch _get_existing_chunks on the deduplicator
|
|
deduplicator._get_existing_chunks = MagicMock(return_value=[])
|
|
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
|
|
|
|
# With identical phashes, frame_match_rate should be high
|
|
assert rate["duplicate_rate"] >= 0.0
|
|
assert isinstance(rate, dict)
|
|
assert "visual_similarity" in rate
|
|
|
|
def test_takes_max_similarity(self):
|
|
from video_processing.dedup import VideoDeduplicator
|
|
|
|
deduplicator = VideoDeduplicator()
|
|
fingerprint = self._make_fingerprint(
|
|
md5="new",
|
|
phashes=["aa00aa00aa00aa00"],
|
|
)
|
|
session = MagicMock()
|
|
|
|
# Two existing videos with different phashes
|
|
existing1 = self._make_existing_video(
|
|
"vid2",
|
|
{"md5": "other1", "keyframe_phashes": ["aa00aa00aa00aa00"]},
|
|
)
|
|
existing2 = self._make_existing_video(
|
|
"vid3",
|
|
{"md5": "other2", "keyframe_phashes": ["ff00ff00ff00ff00"]},
|
|
)
|
|
|
|
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
|
mock_repo = MockRepo.return_value
|
|
mock_repo.list_by_project.return_value = [existing1, existing2]
|
|
deduplicator._get_existing_chunks = MagicMock(return_value=[])
|
|
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
|
|
|
|
# Should take the max across all videos
|
|
assert rate["duplicate_rate"] >= 0.0
|
|
assert isinstance(rate["duplicate_rate"], float)
|
|
|
|
def test_user_id_scope_cross_project(self):
|
|
from video_processing.dedup import VideoDeduplicator
|
|
|
|
deduplicator = VideoDeduplicator()
|
|
fingerprint = self._make_fingerprint(md5="exact_md5_x")
|
|
session = MagicMock()
|
|
|
|
existing = self._make_existing_video("vid2", {"md5": "exact_md5_x", "keyframe_phashes": ["aa"]})
|
|
|
|
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
|
mock_repo = MockRepo.return_value
|
|
mock_repo.list_by_user.return_value = [existing]
|
|
rate = deduplicator.compute_duplicate_rate(
|
|
fingerprint,
|
|
"proj1",
|
|
"vid1",
|
|
session,
|
|
scope="user",
|
|
user_id="user1",
|
|
)
|
|
|
|
# Should use list_by_user and find the match
|
|
mock_repo.list_by_user.assert_called_once_with("user1")
|
|
assert rate["duplicate_rate"] == 100.0
|
|
|
|
def test_return_dict_structure(self):
|
|
"""compute_duplicate_rate returns dict with three fields."""
|
|
from video_processing.dedup import VideoDeduplicator
|
|
|
|
deduplicator = VideoDeduplicator()
|
|
fingerprint = self._make_fingerprint()
|
|
session = MagicMock()
|
|
|
|
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
|
mock_repo = MockRepo.return_value
|
|
mock_repo.list_by_project.return_value = []
|
|
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
|
|
|
|
assert isinstance(rate, dict)
|
|
assert "duplicate_rate" in rate
|
|
assert "visual_similarity" in rate
|
|
assert "match_count" in rate
|
|
assert isinstance(rate["duplicate_rate"], float)
|
|
assert isinstance(rate["visual_similarity"], float)
|
|
assert isinstance(rate["match_count"], int)
|
|
|
|
def test_backward_compat_no_scope(self):
|
|
"""Not passing scope defaults to project-level."""
|
|
from video_processing.dedup import VideoDeduplicator
|
|
|
|
deduplicator = VideoDeduplicator()
|
|
fingerprint = self._make_fingerprint()
|
|
session = MagicMock()
|
|
|
|
with patch("video_processing.dedup.SQLAlchemyGeneratedVideoRepository") as MockRepo:
|
|
mock_repo = MockRepo.return_value
|
|
mock_repo.list_by_project.return_value = []
|
|
rate = deduplicator.compute_duplicate_rate(fingerprint, "proj1", "vid1", session)
|
|
|
|
mock_repo.list_by_project.assert_called_once_with("proj1")
|
|
assert rate["duplicate_rate"] == 0.0
|