Files
xiaoxia-saas/tests/unit/test_upload_complete_idempotency_1714.py
T
xiaoxia 0a7ae8db4f
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 1s
CI/CD Pipeline / Check push changed paths (push) Successful in 4s
CI/CD Pipeline / Frontend Lint (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 / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Build Staging API Image (push) Successful in 41s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 32s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 2m5s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m8s
AI Code Review / AI Code Review (pull_request) Failing after 2m24s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 1m51s
CI/CD Pipeline / Validate - Style (push) Successful in 2m36s
CI/CD Pipeline / Integration Tests (push) Successful in 2m33s
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
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m52s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 5m21s
CI/CD Pipeline / Validate - Security (push) Successful in 5m43s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m1s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m20s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m32s
CI/CD Pipeline / Unit Tests (push) Successful in 8m29s
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 / Staging API Integration Tests (push) Successful in 3m18s
CI/CD Pipeline / Canary Release to Production (push) Has been cancelled
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 1s
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 1s
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 / 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 / Deploy Staging (Watchtower auto-deploy) (pull_request) Has been skipped
CI/CD Pipeline / PR Build API Image (pull_request) Successful in 21s
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 / PR Build Web Image (pull_request) Successful in 24s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 28s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 1m43s
CI/CD Pipeline / Frontend Unit Tests (pull_request) Successful in 1m41s
CI/CD Pipeline / Frontend Lint (pull_request) Successful in 1m50s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 2m4s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 2m25s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 4m35s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 7m16s
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 / CI Gate (pull_request) Successful in 17s
CI/CD Pipeline / Deploy Production (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
fix(upload): complete 接口幂等去重 + HEVC 转码回写占位 asset 禁止兜底新建 (#1714) (#1715)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-05 16:07:45 +08:00

339 lines
14 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.
"""Issue #1714POST /upload/direct/complete 幂等 + multipart 幂等。
覆盖:
- 同 client_upload_id 重复 complete → 只建一条 asset、不重复派 ingest job
- 同 file_hash 重复 complete → 返回已存在记录
- 旧客户端不传 hash/token:近期同库同名 processing 占位 → 兜底幂等返回
- 旧客户端不传 hash/token:READY 历史同名 → 不兜底(正常新建)
- 兜底窗口外(>30 分钟)→ 不兜底
- 旧仓储(无新方法)鸭子类型降级 → 不报错、正常新建
- 重复 complete 时即使 OSS 已无文件(file_exists=False)也返回已存在记录
(模拟 complete 超时后 OSS 侧对象已过期/清理,重试仍不重复建库)
- multipart 上传:同 client_upload_id 重复提交 → 第二次直接 duplicated,不再传 OSS
"""
from __future__ import annotations
import os
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import MagicMock
os.environ.setdefault("JWT_SECRET_KEY", "unit-test-secret-key-for-testing")
os.environ.setdefault("DATABASE_URL", "sqlite:///test.db")
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "apps" / "api"))
from fastapi import FastAPI # noqa: E402
from fastapi.testclient import TestClient # noqa: E402
from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus, IngestJob, Project # noqa: E402
class StubProjectRepository:
def __init__(self, projects: dict | None = None):
self._projects = projects or {}
def get(self, project_id: str):
return self._projects.get(project_id)
def find_by_id(self, project_id: str):
return self._projects.get(project_id)
class StubAssetLibraryRepository:
def __init__(self, libraries: dict | None = None):
self._libraries = libraries or {}
def find_by_project(self, project_id: str, kind=None) -> list:
return list(self._libraries.values())
class StubAssetRepository:
"""支持三种幂等查询的内存仓储,并统计 create 次数。"""
def __init__(self, assets: list[Asset] | None = None):
self._assets = list(assets or [])
self.created: list[Asset] = []
def find_by_library_and_file_hash(self, library_id: str, file_hash: str) -> Asset | None:
if not file_hash:
return None
return next((a for a in self._assets if a.library_id == library_id and a.file_hash == file_hash), None)
def find_by_library_and_client_upload_id(self, library_id: str, client_upload_id: str) -> Asset | None:
if not client_upload_id:
return None
return next(
(a for a in self._assets if a.library_id == library_id and a.client_upload_id == client_upload_id),
None,
)
def find_recent_active_by_library_and_name(
self, library_id: str, name: str, within_minutes: int = 30, file_size: int = 0
) -> Asset | None:
cutoff = datetime.now(timezone.utc) - timedelta(minutes=within_minutes)
candidates = [
a
for a in self._assets
if a.library_id == library_id
and a.name == name
and a.status in (AssetStatus.UPLOADING, AssetStatus.PROCESSING)
and a.created_at >= cutoff
and (not file_size or a.file_size == file_size)
]
return max(candidates, key=lambda a: a.created_at) if candidates else None
def create(self, asset: Asset) -> Asset:
self._assets.append(asset)
self.created.append(asset)
return asset
def update(self, asset: Asset) -> Asset:
return asset
class LegacyStubAssetRepository:
"""旧仓储:只有 file_hash 去重,没有新方法(鸭子类型降级验证)。"""
def __init__(self, assets: list[Asset] | None = None):
self._assets = list(assets or [])
self.created: list[Asset] = []
def find_by_library_and_file_hash(self, library_id: str, file_hash: str) -> Asset | None:
if not file_hash:
return None
return next((a for a in self._assets if a.library_id == library_id and a.file_hash == file_hash), None)
def create(self, asset: Asset) -> Asset:
self._assets.append(asset)
self.created.append(asset)
return asset
class StubIngestJobRepository:
def __init__(self):
self._jobs: dict[str, IngestJob] = {}
self.created_count = 0
def create(self, job: IngestJob) -> IngestJob:
self._jobs[job.id] = job
self.created_count += 1
return job
def get(self, job_id: str) -> IngestJob | None:
return self._jobs.get(job_id)
def update(self, job: IngestJob) -> IngestJob:
self._jobs[job.id] = job
return job
def _make_project() -> Project:
return Project(id="proj-1", name="Test Project", owner_user_id="user-1")
def _make_library() -> AssetLibrary:
return AssetLibrary(id="lib-1", name="Test Library", project_id="proj-1", kind=AssetLibraryKind.VIDEO)
def _build_app(asset_repo=None, ingest_repo=None, storage=None):
from app.api.routes.upload import router
from app.auth import AuthenticatedUser, get_current_user
from app.core.storage import get_storage_service
from app.dependencies import (
get_asset_library_repository,
get_asset_repository,
get_ingest_job_repository,
get_project_repository,
)
app = FastAPI()
app.include_router(router, prefix="/api/v1")
project_repo = StubProjectRepository({"proj-1": _make_project()})
library_repo = StubAssetLibraryRepository({"lib-1": _make_library()})
asset_repo = asset_repo or StubAssetRepository()
ingest_repo = ingest_repo or StubIngestJobRepository()
storage = storage or MagicMock()
storage.is_configured = True
storage._normalize_storage_key = lambda key: key
storage.file_exists = MagicMock(return_value=True)
storage.upload_file = MagicMock(return_value="https://oss.example.com/file.mp4")
storage.get_url = MagicMock(return_value="https://oss.example.com/file.mp4")
mock_user = MagicMock(spec=AuthenticatedUser)
mock_user.id = "user-1"
mock_user.user = MagicMock(id="user-1")
mock_user.email = "test@example.com"
app.dependency_overrides[get_current_user] = lambda: mock_user
app.dependency_overrides[get_project_repository] = lambda: project_repo
app.dependency_overrides[get_asset_library_repository] = lambda: library_repo
app.dependency_overrides[get_asset_repository] = lambda: asset_repo
app.dependency_overrides[get_ingest_job_repository] = lambda: ingest_repo
app.dependency_overrides[get_storage_service] = lambda: storage
return app, asset_repo, ingest_repo, storage
def _client(**kwargs):
app, asset_repo, ingest_repo, storage = _build_app(**kwargs)
return TestClient(app), asset_repo, ingest_repo, storage
COMPLETE_BODY = {
"project_id": "proj-1",
"library_id": "lib-1",
"storage_key": "uploads/abc/IMG_2282.MOV",
}
class TestDirectCompleteIdempotency:
def test_same_client_upload_id_creates_single_asset_and_job(self):
"""同一 client_upload_id 连发两次 complete:只建 1 条 asset、1 个 job。"""
client, asset_repo, ingest_repo, _ = _client()
body = {**COMPLETE_BODY, "client_upload_id": "up-token-1", "file_size": 12345}
r1 = client.post("/api/v1/direct/complete", json=body)
r2 = client.post("/api/v1/direct/complete", json={**body, "storage_key": "uploads/zzz/IMG_2282.MOV"})
assert r1.status_code == 200 and r2.status_code == 200
b1, b2 = r1.json(), r2.json()
assert b1["duplicated"] is False
assert b2["duplicated"] is True
assert b1["asset_id"] == b2["asset_id"]
assert len(asset_repo.created) == 1
assert ingest_repo.created_count == 1
# 第二次返回的是已存在记录(其 storage_key 为第一次的 key
assert b2["storage_key"] == "uploads/abc/IMG_2282.MOV"
def test_same_file_hash_returns_existing(self):
"""同 file_hash(不同 token)重复 complete → 返回已存在记录。"""
client, asset_repo, ingest_repo, _ = _client()
body1 = {**COMPLETE_BODY, "file_hash": "h" * 32, "client_upload_id": "tok-a"}
body2 = {
**COMPLETE_BODY,
"storage_key": "uploads/def/IMG_2282.MOV",
"file_hash": "h" * 32,
"client_upload_id": "tok-b",
}
client.post("/api/v1/direct/complete", json=body1)
r2 = client.post("/api/v1/direct/complete", json=body2)
assert r2.json()["duplicated"] is True
assert len(asset_repo.created) == 1
assert ingest_repo.created_count == 1
def test_fallback_dedup_when_no_hash_no_token(self):
"""旧客户端不传 hash/token:近期同库同名 processing 占位 → 兜底幂等。
模拟 complete 超时重试:第一次已建好占位,第二次(OSS 重传拿到新 key)
不应再建第二条。
"""
client, asset_repo, ingest_repo, _ = _client()
# 第一次 complete(旧客户端无 token/hash
r1 = client.post("/api/v1/direct/complete", json=COMPLETE_BODY)
assert r1.json()["duplicated"] is False
# 重试:重新 prepare 产生新 storage_key(仅 uuid 目录不同,文件名一致——
# 前端重试传的是同一个 File),且近期
r2 = client.post(
"/api/v1/direct/complete",
json={**COMPLETE_BODY, "storage_key": "uploads/retry/IMG_2282.MOV", "file_size": 0},
)
assert r2.status_code == 200
assert r2.json()["duplicated"] is True
assert r2.json()["asset_id"] == r1.json()["asset_id"]
assert len(asset_repo.created) == 1
assert ingest_repo.created_count == 1
def test_fallback_dedup_ignores_ready_history(self):
"""READY 历史同名素材不触发兜底(允许用户再次上传同名文件)。"""
ready = Asset(
id="ready-1",
project_id="proj-1",
library_id="lib-1",
name="IMG_2282.MOV",
storage_key="uploads/old/IMG_2282.MOV",
mime_type="video/quicktime",
status=AssetStatus.READY,
)
client, asset_repo, ingest_repo, _ = _client(asset_repo=StubAssetRepository([ready]))
r = client.post("/api/v1/direct/complete", json=COMPLETE_BODY)
assert r.status_code == 200
assert r.json()["duplicated"] is False
assert len(asset_repo.created) == 1
def test_fallback_dedup_window_expired(self):
"""占位记录超过 30 分钟 → 不再兜底(视为孤儿,正常新建)。"""
stale = Asset(
id="stale-1",
project_id="proj-1",
library_id="lib-1",
name="IMG_2282.MOV",
storage_key="uploads/stale/IMG_2282.MOV",
mime_type="video/quicktime",
status=AssetStatus.PROCESSING,
)
stale.created_at = datetime.now(timezone.utc) - timedelta(minutes=45)
client, asset_repo, ingest_repo, _ = _client(asset_repo=StubAssetRepository([stale]))
r = client.post("/api/v1/direct/complete", json=COMPLETE_BODY)
assert r.status_code == 200
assert r.json()["duplicated"] is False
assert len(asset_repo.created) == 1
def test_legacy_repo_without_new_methods_still_works(self):
"""旧仓储没有新幂等方法 → 鸭子类型降级,不报错、正常创建。"""
client, asset_repo, ingest_repo, _ = _client(asset_repo=LegacyStubAssetRepository())
r = client.post(
"/api/v1/direct/complete",
json={**COMPLETE_BODY, "client_upload_id": "tok-x", "file_hash": "f" * 32},
)
assert r.status_code == 200
assert r.json()["duplicated"] is False
assert len(asset_repo.created) == 1
def test_duplicate_complete_returns_existing_even_if_oss_missing(self):
"""重复 complete 幂等检查先于 OSS file_exists
第一次成功建占位后,重试时即使 OSS 对象已不存在(file_exists=False),
也必须返回已存在记录而不是 404/重复建库。"""
client, _, _, storage = _client()
body = {**COMPLETE_BODY, "client_upload_id": "tok-oss-gone"}
r1 = client.post("/api/v1/direct/complete", json=body)
assert r1.status_code == 200
storage.file_exists = MagicMock(return_value=False)
r2 = client.post(
"/api/v1/direct/complete",
json={**body, "storage_key": "uploads/retry2/IMG_2282.MOV"},
)
assert r2.status_code == 200
assert r2.json()["duplicated"] is True
assert r2.json()["asset_id"] == r1.json()["asset_id"]
class TestMultipartUploadIdempotency:
def test_same_client_upload_id_second_submit_deduplicated(self):
"""multipart 重复提交同 token:第二次直接 duplicated,不再上传 OSS。"""
client, asset_repo, ingest_repo, storage = _client()
def _post():
return client.post(
"/api/v1",
data={"project_id": "proj-1", "library_id": "lib-1", "client_upload_id": "mp-tok-1"},
files={"file": ("IMG_2282.MOV", b"fake-mov-data", "video/quicktime")},
)
r1 = _post()
r2 = _post()
assert r1.json()["duplicated"] is False
assert r2.json()["duplicated"] is True
assert r2.json()["asset_id"] == r1.json()["asset_id"]
assert len(asset_repo.created) == 1
assert ingest_repo.created_count == 1
# OSS 上传只发生一次(第二次在幂等检查处直接返回)
assert storage.upload_file.call_count == 1