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
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com> Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
379 lines
13 KiB
Python
379 lines
13 KiB
Python
"""#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 method(self 已绑定),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
|