Files
xiaoxia-saas/tests/unit/test_duplication_check_worker.py
T
xiaoxia 2205adb8fb
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 / 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 3s
CI/CD Pipeline / Build Staging Web Image (pull_request) Has been skipped
CI/CD Pipeline / Build Staging API Image (pull_request) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (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 / Check if frontend-only change (pull_request) Successful in 3s
CI/CD Pipeline / Check push changed paths (push) Successful in 5s
CI/CD Pipeline / Frontend Lint (push) 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 / 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 / Build Staging Web Image (push) Successful in 29s
CI/CD Pipeline / Build Staging API Image (push) Successful in 31s
CI/CD Pipeline / Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (pull_request) Has been skipped
CI/CD Pipeline / Frontend Lint (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 31s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 33s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 39s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / Build Production Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Staging API Integration Tests (pull_request) Has been skipped
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 / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Staging E2E Tests (pull_request) Has been skipped
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 / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) 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 29s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m49s
CI/CD Pipeline / CI Gate (pull_request) Successful in 11s
CI/CD Pipeline / Integration Tests (push) Successful in 2m20s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m25s
CI/CD Pipeline / Validate - Style (push) Successful in 2m46s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 1m11s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m58s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
AI Code Review / AI Code Review (pull_request) Failing after 3m50s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m33s
CI/CD Pipeline / Frontend Unit Tests (push) Failing after 5m13s
CI/CD Pipeline / Validate - Security (push) Successful in 5m26s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m10s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 5m9s
CI/CD Pipeline / Unit Tests (push) Successful in 8m9s
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
feat(worker): 手动查重 worker task + visual_similarity/match_count 字段 #1661 (#1679)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-04 01:28:35 +08:00

379 lines
13 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.
"""#1661 手动查重 worker task 测试:成功/失败/重试/片段映射/schema 字段。"""
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch
# cv2/numpy 在测试环境不可用,提前 mock
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"))
def _get_task(mod):
"""返回 (run_callable, real_task)。
- celery task 环境:run 是 bound methodself 已绑定),retry 用 patch.object 打桩
- 原始函数环境:用一个 mock_self 作为 self
"""
task_obj = mod.process_duplication_check
real = task_obj._get_current_object() if hasattr(task_obj, "_get_current_object") else task_obj
if hasattr(real, "run") and hasattr(real, "retry"):
return real.run, real, True # bound
return real, None, False
def _run(mod, record_id, retries=0):
"""执行 task,返回 (result_or_None, raised_exc, mock_self_or_None)。"""
from celery.exceptions import Retry as CeleryRetry
func, real_task, bound = _get_task(mod)
raised = None
result = None
if bound:
mock_retry = MagicMock(side_effect=CeleryRetry("retry"))
with patch.object(real_task, "retry", mock_retry):
real_task.request.retries = retries
real_task.max_retries = 3
try:
result = func(record_id)
except CeleryRetry as e:
raised = e
return result, raised, None
mock_self = MagicMock()
mock_self.request.retries = retries
mock_self.max_retries = 3
mock_self.retry = MagicMock(side_effect=CeleryRetry("retry"))
try:
result = func(mock_self, record_id)
except CeleryRetry as e:
raised = e
return result, raised, mock_self
def _make_record(status="pending"):
from packages.domain.duplication import DuplicationRecord
record = DuplicationRecord.create(
user_id="user-1",
filename="query.mp4",
file_size=1024,
storage_key="duplication/abc/query.mp4",
)
if status != "pending":
record.status = status
return record
def _make_fingerprint():
from video_processing.dedup import FingerprintChunk, VideoFingerprint
chunks = [
FingerprintChunk(start_time_ms=0, end_time_ms=2000, phash_binary="0" * 16, color_histogram=[], frame_count=1),
FingerprintChunk(
start_time_ms=2000, end_time_ms=4000, phash_binary="1" * 16, color_histogram=[], frame_count=1
),
]
return VideoFingerprint(
md5="qmd5",
keyframe_phashes=[c.phash_binary for c in chunks],
color_histograms=[],
duration=10000.0,
resolution=(720, 1280),
chunks=chunks,
)
def _patch_common(record, storage=None, dedup=None, session=None):
from worker_app.tasks import duplication_check as mod
fake_repo = MagicMock()
fake_repo.get.return_value = record
return [
patch.object(mod, "SessionLocal", return_value=session or MagicMock()),
patch.object(mod, "SQLAlchemyDuplicationRecordRepository", return_value=fake_repo),
patch.object(mod, "get_storage_service", return_value=storage or MagicMock()),
patch.object(mod, "VideoDeduplicator", return_value=dedup or MagicMock()),
], fake_repo
class TestProcessDuplicationCheckSuccess:
def test_success_flow_updates_record(self):
from worker_app.tasks import duplication_check as mod
record = _make_record()
fake_session = MagicMock()
fake_storage = MagicMock()
fake_dedup = MagicMock()
fake_dedup.compute_fingerprint.return_value = _make_fingerprint()
fake_dedup.compute_duplicate_rate.return_value = {
"duplicate_rate": 42.5,
"visual_similarity": 0.83,
"match_count": 1,
}
patches, fake_repo = _patch_common(record, storage=fake_storage, dedup=fake_dedup, session=fake_session)
patches.append(patch.object(mod, "_build_domain_segments", return_value=(["SEG"], 1)))
for p in patches:
p.start()
try:
result, raised, _ = _run(mod, record.id)
finally:
for p in patches:
p.stop()
assert raised is None
assert result["ok"] is True
assert result["status"] == "completed"
assert result["duplicate_rate"] == 42.5
assert result["visual_similarity"] == 0.83
assert result["match_count"] == 1
assert result["segments"] == 1
assert record.status == "completed"
assert record.duplicate_rate == 42.5
assert record.visual_similarity == 0.83
assert record.match_count == 1
assert record.duplicate_count == 1
assert record.segments == ["SEG"]
fake_storage.download_file.assert_called_once()
fake_dedup.compute_fingerprint.assert_called_once()
_, kwargs = fake_dedup.compute_duplicate_rate.call_args
assert kwargs["scope"] == "user"
assert kwargs["user_id"] == "user-1"
assert kwargs["current_video_id"] is None
assert fake_repo.update.call_count >= 2
fake_session.commit.assert_called()
fake_session.close.assert_called()
def test_already_completed_is_skipped(self):
from worker_app.tasks import duplication_check as mod
record = _make_record(status="completed")
patches, fake_repo = _patch_common(record)
for p in patches:
p.start()
try:
result, raised, _ = _run(mod, record.id)
finally:
for p in patches:
p.stop()
assert raised is None
assert result.get("skipped") is True
fake_repo.update.assert_not_called()
class TestProcessDuplicationCheckFailure:
def test_record_not_found_raises(self):
from worker_app.tasks import duplication_check as mod
fake_repo = MagicMock()
fake_repo.get.return_value = None
patches = [
patch.object(mod, "SessionLocal", return_value=MagicMock()),
patch.object(mod, "SQLAlchemyDuplicationRecordRepository", return_value=fake_repo),
patch.object(mod, "get_storage_service", return_value=MagicMock()),
]
for p in patches:
p.start()
try:
_result, raised, _ = _run(mod, "nope", retries=0)
finally:
for p in patches:
p.stop()
# 找不到记录触发异常 → retry(第一次)
assert raised is not None
def test_download_failure_retries_then_marks_failed(self):
from worker_app.tasks import duplication_check as mod
# 第一次失败(retries=0):保持 pending
record = _make_record()
fake_storage = MagicMock()
fake_storage.download_file.side_effect = RuntimeError("oss network down")
patches, _ = _patch_common(record, storage=fake_storage)
for p in patches:
p.start()
try:
_, raised, _ = _run(mod, record.id, retries=0)
finally:
for p in patches:
p.stop()
assert raised is not None
assert record.status == "processing", "首次失败不应标记 failed(已进入 processing 等待重试)"
# 最后一次(retries==max_retries=3):标记 failed
record2 = _make_record()
patches2, fake_repo2 = _patch_common(record2, storage=fake_storage)
for p in patches2:
p.start()
try:
_run(mod, record2.id, retries=3)
finally:
for p in patches2:
p.stop()
assert record2.status == "failed"
assert "查重失败" in record2.error_message
fake_repo2.update.assert_called()
def test_temp_dir_cleaned_after_failure(self):
import os
import tempfile
from worker_app.tasks import duplication_check as mod
record = _make_record()
fake_storage = MagicMock()
fake_storage.download_file.side_effect = RuntimeError("boom")
created_dirs = []
real_mkdtemp = tempfile.mkdtemp
def fake_mkdtemp(prefix=None):
d = real_mkdtemp(prefix=prefix)
created_dirs.append(d)
return d
patches, _ = _patch_common(record, storage=fake_storage)
patches.append(patch.object(mod.tempfile, "mkdtemp", fake_mkdtemp))
for p in patches:
p.start()
try:
_run(mod, record.id, retries=0)
finally:
for p in patches:
p.stop()
assert created_dirs, "mkdtemp should have been called"
assert not os.path.isdir(created_dirs[0]), "temp dir should be removed in finally"
class TestBuildDomainSegments:
def test_maps_worker_segments_to_domain_with_seconds_and_percent(self):
from video_processing.dedup import DuplicateSegment as WorkerSegment
from worker_app.tasks import duplication_check as mod
fingerprint = _make_fingerprint()
from packages.domain import GeneratedVideo
existing = GeneratedVideo(
id="vid-1",
project_id="proj-1",
generation_task_id="t1",
name="成片A",
file_url="oss://x",
file_size=1,
duration=10.0,
width=720,
height=1280,
fps=30.0,
video_fingerprint={"md5": "x"},
)
fake_video_repo = MagicMock()
fake_video_repo.list_by_user.return_value = [existing]
fake_dedup = MagicMock()
fake_dedup._get_existing_chunks.return_value = [
{"phash_binary": "0" * 16, "start_time_ms": 0, "end_time_ms": 2000, "color_histogram": []},
]
worker_seg = WorkerSegment(
query_start_ms=1000,
query_end_ms=3000,
target_start_ms=5000,
target_end_ms=7000,
avg_distance=6.0,
)
with (
patch.object(mod, "SQLAlchemyGeneratedVideoRepository", return_value=fake_video_repo),
patch.object(mod, "find_duplicate_segments", return_value=[worker_seg]),
):
segments, dup_count = mod._build_domain_segments(fingerprint, MagicMock(), fake_dedup, "user-1")
assert dup_count == 1
assert len(segments) == 1
seg = segments[0]
assert seg.source_start == 1.0
assert seg.source_end == 3.0
assert seg.matched_start == 5.0
assert seg.matched_end == 7.0
assert seg.matched_video_id == "vid-1"
assert seg.matched_video_name == "成片A"
assert abs(seg.similarity - 90.6) < 0.2
def test_skips_videos_without_chunks(self):
from worker_app.tasks import duplication_check as mod
fingerprint = _make_fingerprint()
from packages.domain import GeneratedVideo
existing = GeneratedVideo(
id="vid-2",
project_id="p",
generation_task_id="t",
name="老视频",
file_url="oss://x",
file_size=1,
duration=5.0,
width=720,
height=1280,
fps=30.0,
video_fingerprint={"md5": "old"},
)
fake_video_repo = MagicMock()
fake_video_repo.list_by_user.return_value = [existing]
fake_dedup = MagicMock()
fake_dedup._get_existing_chunks.return_value = []
with patch.object(mod, "SQLAlchemyGeneratedVideoRepository", return_value=fake_video_repo):
segments, dup_count = mod._build_domain_segments(fingerprint, MagicMock(), fake_dedup, "u")
assert segments == []
assert dup_count == 0
class TestDuplicationSchemaAndDomainNewFields:
def test_record_response_includes_new_fields(self):
from app.schemas.duplication import DuplicationRecordResponse
resp = DuplicationRecordResponse(
id="r1",
filename="f.mp4",
file_size=1,
status="completed",
duplicate_rate=10.0,
duplicate_count=1,
visual_similarity=0.5,
match_count=2,
created_at="2026-09-04T00:00:00",
updated_at="2026-09-04T00:00:00",
)
assert resp.visual_similarity == 0.5
assert resp.match_count == 2
def test_record_response_new_fields_default_none(self):
from app.schemas.duplication import DuplicationRecordResponse
resp = DuplicationRecordResponse(id="r1", filename="f.mp4", file_size=1, created_at="x", updated_at="y")
assert resp.visual_similarity is None
assert resp.match_count is None
def test_domain_mark_completed_accepts_new_fields(self):
record = _make_record()
record.mark_completed(33.0, 2, [], visual_similarity=0.77, match_count=3)
assert record.status == "completed"
assert record.visual_similarity == 0.77
assert record.match_count == 3
def test_reset_for_retry_clears_new_fields(self):
record = _make_record()
record.mark_completed(10.0, 1, [], visual_similarity=0.5, match_count=1)
record.status = "failed"
record.reset_for_retry()
assert record.status == "pending"
assert record.visual_similarity is None
assert record.match_count is None