Files
xiaoxia-saas/tests/unit/test_asset_count_sync_1776.py
xiaoxia 5678779232
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 3s
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 (pull_request) Successful in 4s
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Check if frontend-only change (pull_request) Successful in 4s
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 / Check push changed paths (push) Successful in 12s
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 / 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 12s
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
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m58s
CI/CD Pipeline / Retag skipped Staging Worker Image (pull_request) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 2m1s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 2m2s
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 / PR Build Worker Image (pull_request) Successful in 34s
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 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 / ACR Image Cleanup (pull_request) Has been skipped
CI/CD Pipeline / Production Browser E2E (pull_request) Has been skipped
CI/CD Pipeline / Canary Release to Production (pull_request) Has been skipped
CI/CD Pipeline / Validate - Style (push) Successful in 2m32s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 26s
CI/CD Pipeline / PR Build Web Image (pull_request) Successful in 1m56s
CI/CD Pipeline / Build Staging API Image (push) Successful in 59s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 3m40s
CI/CD Pipeline / Build Staging Web Image (push) Successful in 2m9s
PR Automation / Auto Merge on CI Green + Approved (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 / CI Gate (pull_request) Successful in 13s
CI/CD Pipeline / Retag skipped Staging Worker Image (push) 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 6m29s
CI/CD Pipeline / Validate - Security (push) Successful in 6m24s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 1m48s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m53s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 8m1s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m40s
CI/CD Pipeline / Unit Tests (push) Failing after 10m13s
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 / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
feat: #1776 素材库 asset_count 自动同步维护 (#1787)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-09-08 10:30:21 +08:00

306 lines
11 KiB
Python
Raw Permalink 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 #1776: asset_libraries.asset_count 同步维护测试。
覆盖场景:
1. 素材创建 → count +1
2. 素材硬删除 → count -1
3. 素材软删除(batch_delete)→ count -N
4. 幂等上传(prepare 占位 + complete 复用)→ 不重复计数
5. 重试场景(complete 重试)→ 不重复计数
6. recount 方法修正计数
"""
import uuid
from datetime import datetime, timezone
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from packages.adapters.sqlalchemy_impl.asset_library_repository import SQLAlchemyAssetLibraryRepository
from packages.adapters.sqlalchemy_impl.asset_repository import SQLAlchemyAssetRepository
from packages.adapters.sqlalchemy_impl.models import AssetLibraryModel, AssetModel, Base
from packages.domain import Asset, AssetLibrary, AssetLibraryKind, AssetStatus
@pytest.fixture
def db_session():
"""创建测试用 SQLite 内存数据库。"""
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
session = Session(engine)
yield session
session.close()
@pytest.fixture
def asset_repo(db_session):
return SQLAlchemyAssetRepository(db_session)
@pytest.fixture
def library_repo(db_session):
return SQLAlchemyAssetLibraryRepository(db_session)
def _make_library(project_id: str, kind: AssetLibraryKind = AssetLibraryKind.VIDEO) -> AssetLibrary:
return AssetLibrary.create(project_id=project_id, name=f"测试{kind.value}库", kind=kind)
def _make_asset(
library_id: str,
project_id: str,
*,
status: AssetStatus = AssetStatus.PROCESSING,
file_size: int = 1024,
file_hash: str = "",
client_upload_id: str = "",
) -> Asset:
return Asset.create(
project_id=project_id,
library_id=library_id,
name=f"test_{uuid.uuid4().hex[:8]}.mp4",
storage_key=f"uploads/{uuid.uuid4().hex[:8]}/test.mp4",
mime_type="video/mp4",
status=status,
uploaded_by_user_id="test-user",
file_hash=file_hash,
client_upload_id=client_upload_id,
file_size=file_size,
)
class TestAssetCountOnCreate:
"""素材创建时计数递增。"""
def test_create_asset_increments_count(self, library_repo, asset_repo, db_session):
"""创建一个素材 → count 从 0 变 1。"""
library = library_repo.create(_make_library("project-1"))
assert library.asset_count == 0
asset = _make_asset(library.id, "project-1")
asset_repo.create(asset)
# 重新查询验证计数
updated_library = library_repo.get(library.id)
assert updated_library.asset_count == 1
assert updated_library.total_size == 1024
def test_create_multiple_assets_increments_count(self, library_repo, asset_repo, db_session):
"""创建多个素材 → count 累加。"""
library = library_repo.create(_make_library("project-1"))
for _i in range(3):
asset = _make_asset(library.id, "project-1", file_size=100 * (_i + 1))
asset_repo.create(asset)
updated_library = library_repo.get(library.id)
assert updated_library.asset_count == 3
assert updated_library.total_size == 100 + 200 + 300
class TestAssetCountOnDelete:
"""素材删除时计数递减。"""
def test_hard_delete_decrements_count(self, library_repo, asset_repo, db_session):
"""硬删除素材 → count -1。"""
library = library_repo.create(_make_library("project-1"))
asset = asset_repo.create(_make_asset(library.id, "project-1"))
assert library_repo.get(library.id).asset_count == 1
asset_repo.delete(asset.id)
assert library_repo.get(library.id).asset_count == 0
def test_hard_delete_already_deleted_no_change(self, library_repo, asset_repo, db_session):
"""删除已删除的素材 → count 不变。"""
library = library_repo.create(_make_library("project-1"))
asset = asset_repo.create(_make_asset(library.id, "project-1"))
# 先软删除(count 已经 -1
asset_repo.batch_delete([asset.id])
assert library_repo.get(library.id).asset_count == 0
# 再硬删除(不应再 -1
asset_repo.delete(asset.id)
assert library_repo.get(library.id).asset_count == 0
def test_batch_delete_decrements_count(self, library_repo, asset_repo, db_session):
"""批量软删除 → count -N。"""
library = library_repo.create(_make_library("project-1"))
asset_ids = []
for _i in range(5):
asset = asset_repo.create(_make_asset(library.id, "project-1", file_size=200))
asset_ids.append(asset.id)
assert library_repo.get(library.id).asset_count == 5
# 删除 3 个
deleted_count = asset_repo.batch_delete(asset_ids[:3])
assert deleted_count == 3
assert library_repo.get(library.id).asset_count == 2
assert library_repo.get(library.id).total_size == 200 * 2
def test_batch_delete_skips_already_deleted(self, library_repo, asset_repo, db_session):
"""批量删除已删除的素材 → count 不变。"""
library = library_repo.create(_make_library("project-1"))
asset_ids = []
for _i in range(3):
asset = asset_repo.create(_make_asset(library.id, "project-1"))
asset_ids.append(asset.id)
assert library_repo.get(library.id).asset_count == 3
# 先删除 2 个
asset_repo.batch_delete(asset_ids[:2])
assert library_repo.get(library.id).asset_count == 1
# 再删除同样的 2 个(应被跳过)
deleted_count = asset_repo.batch_delete(asset_ids[:2])
assert deleted_count == 0
assert library_repo.get(library.id).asset_count == 1
def test_count_never_negative(self, library_repo, asset_repo, db_session):
"""计数下限为 0,不会出现负数。"""
library = library_repo.create(_make_library("project-1"))
# 手动设置计数为 0
library.asset_count = 0
library_repo.update(library)
# 尝试递减(通过直接调用 decrement)
library_repo.decrement_asset_count(library.id, count_delta=5)
db_session.commit()
updated = library_repo.get(library.id)
assert updated.asset_count == 0
class TestIdempotentUpload:
"""幂等上传场景:不重复计数。"""
def test_prepare_then_complete_no_double_count(self, library_repo, asset_repo, db_session):
"""prepare 创建占位 + complete 复用占位 → count 只 +1。"""
library = library_repo.create(_make_library("project-1"))
# prepare 阶段:创建占位
placeholder = _make_asset(
library.id,
"project-1",
status=AssetStatus.PROCESSING,
client_upload_id="upload-token-123",
)
asset_repo.create(placeholder)
assert library_repo.get(library.id).asset_count == 1
# complete 阶段:查找已有占位并复用(通过 client_upload_id
existing = asset_repo.find_by_library_and_client_upload_id(
library_id=library.id,
client_upload_id="upload-token-123",
)
assert existing is not None
# 复用占位,不创建新记录 → count 不变
assert library_repo.get(library.id).asset_count == 1
def test_complete_retry_no_double_count(self, library_repo, asset_repo, db_session):
"""complete 重试(通过 file_hash 去重)→ count 只 +1。"""
library = library_repo.create(_make_library("project-1"))
# 第一次 complete:创建素材
asset1 = _make_asset(
library.id,
"project-1",
file_hash="hash-abc-123",
)
asset_repo.create(asset1)
assert library_repo.get(library.id).asset_count == 1
# 重试 complete:通过 file_hash 查找已有
existing = asset_repo.find_by_library_and_file_hash(
library_id=library.id,
file_hash="hash-abc-123",
)
assert existing is not None
assert existing.id == asset1.id
# 不创建新记录 → count 不变
assert library_repo.get(library.id).asset_count == 1
class TestRecountAssets:
"""recount_assets 方法修正计数。"""
def test_recount_fixes_drift(self, library_repo, asset_repo, db_session):
"""计数漂移后,recount 能修正。"""
library = library_repo.create(_make_library("project-1"))
# 创建 3 个素材
for _ in range(3):
asset_repo.create(_make_asset(library.id, "project-1"))
assert library_repo.get(library.id).asset_count == 3
# 手动破坏计数(模拟历史数据问题)
db_session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library.id).update(
{AssetLibraryModel.asset_count: 999, AssetLibraryModel.total_size: 999999}
)
db_session.commit()
assert library_repo.get(library.id).asset_count == 999
# recount 修正
actual = library_repo.recount_assets(library.id)
db_session.commit()
assert actual == 3
assert library_repo.get(library.id).asset_count == 3
assert library_repo.get(library.id).total_size == 1024 * 3
def test_recount_excludes_deleted(self, library_repo, asset_repo, db_session):
"""recount 排除已删除素材。"""
library = library_repo.create(_make_library("project-1"))
assets = []
for _ in range(5):
asset = asset_repo.create(_make_asset(library.id, "project-1"))
assets.append(asset)
assert library_repo.get(library.id).asset_count == 5
# 软删除 2 个
asset_repo.batch_delete([assets[0].id, assets[1].id])
assert library_repo.get(library.id).asset_count == 3
# 破坏计数
db_session.query(AssetLibraryModel).filter(AssetLibraryModel.id == library.id).update(
{AssetLibraryModel.asset_count: 100}
)
db_session.commit()
# recount 应排除 deleted
actual = library_repo.recount_assets(library.id)
db_session.commit()
assert actual == 3
assert library_repo.get(library.id).asset_count == 3
class TestConcurrentSafety:
"""并发安全测试(SQLite 模拟有限并发)。"""
def test_increment_is_atomic(self, library_repo, db_session):
"""increment_asset_count 使用 SQL 级 UPDATE,并发安全。"""
library = library_repo.create(_make_library("project-1"))
# 多次递增
for _ in range(10):
library_repo.increment_asset_count(library.id, count_delta=1, size_delta=100)
db_session.commit()
updated = library_repo.get(library.id)
assert updated.asset_count == 10
assert updated.total_size == 1000
def test_decrement_with_floor_zero(self, library_repo, db_session):
"""decrement_asset_count 下限为 0。"""
library = library_repo.create(_make_library("project-1"))
library.asset_count = 3
library_repo.update(library)
# 尝试递减 10 次
for _ in range(10):
library_repo.decrement_asset_count(library.id, count_delta=1)
db_session.commit()
updated = library_repo.get(library.id)
assert updated.asset_count == 0