style: auto-format with black + isort + prettier
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 / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Validate - Migration (alembic) (push) Successful in 1m42s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Validate - Type Check (mypy) (push) Successful in 1m44s
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 / Build Staging Web Image (push) Successful in 1m42s
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 1m26s
CI/CD Pipeline / Validate - Code Quality (push) Failing after 3m13s
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 2m22s
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 4m29s
CI/CD Pipeline / Integration Tests (push) Successful in 2m16s
CI/CD Pipeline / Unit Tests (push) Failing after 5m46s
CI/CD Pipeline / Build Staging API Image (push) Successful in 11m28s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 3m18s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 30s
CI/CD Pipeline / Staging E2E Tests (push) Failing after 2m13s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 5m58s

This commit is contained in:
CI Bot
2026-07-24 14:07:12 +00:00
parent 04bcd2e6f9
commit 18bd0de3fa
7 changed files with 171 additions and 152 deletions
+8 -5
View File
@@ -201,11 +201,14 @@ class TestFeatureFlagsManager:
def test_set_user_override(self, ff):
ff.set_user_override(FeatureScope.BATCH_EXPORT, "user_42", True)
assert ff.is_enabled(
FeatureScope.BATCH_EXPORT,
user_plan="free",
user_id="user_42",
) is True
assert (
ff.is_enabled(
FeatureScope.BATCH_EXPORT,
user_plan="free",
user_id="user_42",
)
is True
)
def test_set_user_override_nonexistent_raises(self, ff):
with pytest.raises(KeyError):
@@ -1,13 +1,13 @@
"""InMemoryAssetLibraryRepository 单测 — 素材库仓储内存实现."""
from __future__ import annotations
import pytest
from packages.domain import AssetLibrary, AssetLibraryKind
from packages.adapters.in_memory.asset_library_repository import (
InMemoryAssetLibraryRepository,
)
from packages.domain import AssetLibrary, AssetLibraryKind
# ── Fixtures ───────────────────────────────────────────────────────────────
@@ -21,11 +21,13 @@ def repo():
def sample_libraries(repo):
"""创建几个测试素材库."""
libs = []
for i, kind in enumerate([
AssetLibraryKind.VIDEO,
AssetLibraryKind.VOICE,
AssetLibraryKind.IMAGE,
]):
for i, kind in enumerate(
[
AssetLibraryKind.VIDEO,
AssetLibraryKind.VOICE,
AssetLibraryKind.IMAGE,
]
):
lib = AssetLibrary.create(
project_id="proj-1",
name=f"Library {i}",
@@ -49,9 +51,7 @@ class TestAssetLibraryRepoCRUD:
"""基本 CRUD 操作."""
def test_create_and_get(self, repo):
lib = AssetLibrary.create(
project_id="p1", name="Test Lib", kind=AssetLibraryKind.VIDEO
)
lib = AssetLibrary.create(project_id="p1", name="Test Lib", kind=AssetLibraryKind.VIDEO)
created = repo.create(lib)
assert created.id == lib.id
assert created.name == "Test Lib"
@@ -64,16 +64,12 @@ class TestAssetLibraryRepoCRUD:
assert repo.get("nonexistent") is None
def test_find_by_id_alias(self, repo):
lib = AssetLibrary.create(
project_id="p1", name="Test", kind=AssetLibraryKind.VOICE
)
lib = AssetLibrary.create(project_id="p1", name="Test", kind=AssetLibraryKind.VOICE)
repo.create(lib)
assert repo.find_by_id(lib.id).id == lib.id
def test_update(self, repo):
lib = AssetLibrary.create(
project_id="p1", name="Old Name", kind=AssetLibraryKind.IMAGE
)
lib = AssetLibrary.create(project_id="p1", name="Old Name", kind=AssetLibraryKind.IMAGE)
repo.create(lib)
lib.name = "New Name"
@@ -84,9 +80,7 @@ class TestAssetLibraryRepoCRUD:
assert fetched.name == "New Name"
def test_delete_existing(self, repo):
lib = AssetLibrary.create(
project_id="p1", name="To Delete", kind=AssetLibraryKind.VIDEO
)
lib = AssetLibrary.create(project_id="p1", name="To Delete", kind=AssetLibraryKind.VIDEO)
repo.create(lib)
result = repo.delete(lib.id)
@@ -129,9 +123,7 @@ class TestAssetLibraryRepoCounting:
"""素材计数相关方法."""
def test_increment_asset_count(self, repo):
lib = AssetLibrary.create(
project_id="p1", name="Test", kind=AssetLibraryKind.VIDEO
)
lib = AssetLibrary.create(project_id="p1", name="Test", kind=AssetLibraryKind.VIDEO)
repo.create(lib)
assert lib.asset_count == 0
assert lib.total_size == 0
@@ -147,9 +139,7 @@ class TestAssetLibraryRepoCounting:
assert fetched.total_size == 3072
def test_decrement_asset_count(self, repo):
lib = AssetLibrary.create(
project_id="p1", name="Test", kind=AssetLibraryKind.VIDEO
)
lib = AssetLibrary.create(project_id="p1", name="Test", kind=AssetLibraryKind.VIDEO)
lib.asset_count = 3
lib.total_size = 3000
repo.create(lib)
@@ -161,9 +151,7 @@ class TestAssetLibraryRepoCounting:
def test_decrement_not_below_zero(self, repo):
"""计数和大小不会减到负数."""
lib = AssetLibrary.create(
project_id="p1", name="Test", kind=AssetLibraryKind.VIDEO
)
lib = AssetLibrary.create(project_id="p1", name="Test", kind=AssetLibraryKind.VIDEO)
lib.asset_count = 1
lib.total_size = 100
repo.create(lib)
+88 -39
View File
@@ -1,11 +1,11 @@
"""InMemoryAssetRepository 单测 — 素材仓储内存实现."""
from __future__ import annotations
import pytest
from packages.domain import Asset, AssetStatus
from packages.adapters.in_memory.asset_repository import InMemoryAssetRepository
from packages.domain import Asset, AssetStatus
# ── Fixtures ───────────────────────────────────────────────────────────────
@@ -112,16 +112,25 @@ class TestAssetRepoQueries:
def test_find_by_library_and_file_type_video(self, repo):
video = Asset.create(
project_id="p1", library_id="lib-1", name="v.mp4",
storage_key="v.mp4", mime_type="video/mp4",
project_id="p1",
library_id="lib-1",
name="v.mp4",
storage_key="v.mp4",
mime_type="video/mp4",
)
audio = Asset.create(
project_id="p1", library_id="lib-1", name="a.mp3",
storage_key="a.mp3", mime_type="audio/mp3",
project_id="p1",
library_id="lib-1",
name="a.mp3",
storage_key="a.mp3",
mime_type="audio/mp3",
)
image = Asset.create(
project_id="p1", library_id="lib-1", name="i.jpg",
storage_key="i.jpg", mime_type="image/jpeg",
project_id="p1",
library_id="lib-1",
name="i.jpg",
storage_key="i.jpg",
mime_type="image/jpeg",
)
repo.create(video)
repo.create(audio)
@@ -144,18 +153,27 @@ class TestAssetRepoQueries:
def test_find_by_tag_ids_single_tag(self, repo):
a1 = Asset.create(
project_id="p1", library_id="l1", name="a1.mp4",
storage_key="a1.mp4", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="a1.mp4",
storage_key="a1.mp4",
mime_type="video/mp4",
)
a1.tag_ids = ["tag1", "tag2"]
a2 = Asset.create(
project_id="p1", library_id="l1", name="a2.mp4",
storage_key="a2.mp4", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="a2.mp4",
storage_key="a2.mp4",
mime_type="video/mp4",
)
a2.tag_ids = ["tag1"]
a3 = Asset.create(
project_id="p1", library_id="l1", name="a3.mp4",
storage_key="a3.mp4", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="a3.mp4",
storage_key="a3.mp4",
mime_type="video/mp4",
)
a3.tag_ids = ["tag3"]
repo.create(a1)
@@ -168,13 +186,19 @@ class TestAssetRepoQueries:
def test_find_by_tag_ids_multiple_tags_all_match(self, repo):
"""必须包含所有指定标签(AND 逻辑)."""
a1 = Asset.create(
project_id="p1", library_id="l1", name="a1.mp4",
storage_key="a1.mp4", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="a1.mp4",
storage_key="a1.mp4",
mime_type="video/mp4",
)
a1.tag_ids = ["tag1", "tag2"]
a2 = Asset.create(
project_id="p1", library_id="l1", name="a2.mp4",
storage_key="a2.mp4", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="a2.mp4",
storage_key="a2.mp4",
mime_type="video/mp4",
)
a2.tag_ids = ["tag1"]
repo.create(a1)
@@ -212,12 +236,18 @@ class TestAssetRepoBatchOperations:
def test_batch_delete_marks_deleted(self, repo):
a1 = Asset.create(
project_id="p1", library_id="l1", name="a1.mp4",
storage_key="a1.mp4", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="a1.mp4",
storage_key="a1.mp4",
mime_type="video/mp4",
)
a2 = Asset.create(
project_id="p1", library_id="l1", name="a2.mp4",
storage_key="a2.mp4", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="a2.mp4",
storage_key="a2.mp4",
mime_type="video/mp4",
)
repo.create(a1)
repo.create(a2)
@@ -231,14 +261,20 @@ class TestAssetRepoBatchOperations:
def test_batch_delete_skip_already_deleted(self, repo):
a1 = Asset.create(
project_id="p1", library_id="l1", name="a1.mp4",
storage_key="a1.mp4", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="a1.mp4",
storage_key="a1.mp4",
mime_type="video/mp4",
)
a1.status = AssetStatus.DELETED
repo.create(a1)
a2 = Asset.create(
project_id="p1", library_id="l1", name="a2.mp4",
storage_key="a2.mp4", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="a2.mp4",
storage_key="a2.mp4",
mime_type="video/mp4",
)
repo.create(a2)
@@ -251,20 +287,24 @@ class TestAssetRepoBatchOperations:
def test_batch_update_metadata(self, repo):
a1 = Asset.create(
project_id="p1", library_id="l1", name="a1.mp4",
storage_key="a1.mp4", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="a1.mp4",
storage_key="a1.mp4",
mime_type="video/mp4",
)
a1.metadata = {"key1": "val1"}
a2 = Asset.create(
project_id="p1", library_id="l1", name="a2.mp4",
storage_key="a2.mp4", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="a2.mp4",
storage_key="a2.mp4",
mime_type="video/mp4",
)
repo.create(a1)
repo.create(a2)
count = repo.batch_update_metadata(
[a1.id, a2.id], {"key2": "val2"}
)
count = repo.batch_update_metadata([a1.id, a2.id], {"key2": "val2"})
assert count == 2
# 合并而非覆盖
@@ -274,8 +314,11 @@ class TestAssetRepoBatchOperations:
def test_batch_add_tags(self, repo):
a1 = Asset.create(
project_id="p1", library_id="l1", name="a1.mp4",
storage_key="a1.mp4", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="a1.mp4",
storage_key="a1.mp4",
mime_type="video/mp4",
)
a1.tag_ids = ["existing"]
repo.create(a1)
@@ -291,8 +334,11 @@ class TestAssetRepoBatchOperations:
def test_batch_add_tags_dedup(self, repo):
"""添加已存在的标签不会重复."""
a1 = Asset.create(
project_id="p1", library_id="l1", name="a1.mp4",
storage_key="a1.mp4", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="a1.mp4",
storage_key="a1.mp4",
mime_type="video/mp4",
)
a1.tag_ids = ["tag1"]
repo.create(a1)
@@ -305,8 +351,11 @@ class TestAssetRepoBatchOperations:
def test_batch_replace_tags(self, repo):
a1 = Asset.create(
project_id="p1", library_id="l1", name="a1.mp4",
storage_key="a1.mp4", mime_type="video/mp4",
project_id="p1",
library_id="l1",
name="a1.mp4",
storage_key="a1.mp4",
mime_type="video/mp4",
)
a1.tag_ids = ["old1", "old2"]
repo.create(a1)
+5 -3
View File
@@ -8,14 +8,14 @@ from datetime import datetime
import pytest
from packages.adapters.in_memory.project_repository import InMemoryProjectRepository
from packages.adapters.in_memory.classification_job_repository import InMemoryClassificationJobRepository
from packages.adapters.in_memory.ingest_job_repository import InMemoryIngestJobRepository
from packages.domain import Project, ClassificationJob, IngestJob
from packages.adapters.in_memory.project_repository import InMemoryProjectRepository
from packages.domain import ClassificationJob, IngestJob, Project
# ── Project Repository ──────────────────────────────────────────────────────
@pytest.fixture
def project_repo():
return InMemoryProjectRepository()
@@ -97,6 +97,7 @@ class TestInMemoryProjectRepository:
# ── Classification Job Repository ──────────────────────────────────────────
@pytest.fixture
def classif_repo():
return InMemoryClassificationJobRepository()
@@ -149,6 +150,7 @@ class TestInMemoryClassificationJobRepository:
# ── Ingest Job Repository ──────────────────────────────────────────────────
@pytest.fixture
def ingest_repo():
return InMemoryIngestJobRepository()
+41 -42
View File
@@ -1,11 +1,10 @@
"""ReverseEngine 单测 — 倒放引擎配置解析 + 滤镜构建."""
from __future__ import annotations
import pytest
from video_processing.reverse_engine import ReverseConfig, ReverseEngine
# ── ReverseConfig.from_dict ────────────────────────────────────────────────
@@ -34,31 +33,37 @@ class TestReverseConfigFromDict:
assert config.reverse_audio is True
def test_disable_video_only(self):
config = ReverseConfig.from_dict({
"enabled": True,
"reverse_video": False,
"reverse_audio": True,
})
config = ReverseConfig.from_dict(
{
"enabled": True,
"reverse_video": False,
"reverse_audio": True,
}
)
assert config.enabled is True
assert config.reverse_video is False
assert config.reverse_audio is True
def test_disable_audio_only(self):
config = ReverseConfig.from_dict({
"enabled": True,
"reverse_video": True,
"reverse_audio": False,
})
config = ReverseConfig.from_dict(
{
"enabled": True,
"reverse_video": True,
"reverse_audio": False,
}
)
assert config.enabled is True
assert config.reverse_video is True
assert config.reverse_audio is False
def test_both_disabled(self):
config = ReverseConfig.from_dict({
"enabled": True,
"reverse_video": False,
"reverse_audio": False,
})
config = ReverseConfig.from_dict(
{
"enabled": True,
"reverse_video": False,
"reverse_audio": False,
}
)
assert config.enabled is True
assert config.reverse_video is False
assert config.reverse_audio is False
@@ -75,22 +80,26 @@ class TestReverseConfigFromDict:
def test_truthy_values(self):
"""非布尔真值也能被 bool() 转换."""
config = ReverseConfig.from_dict({
"enabled": 1,
"reverse_video": 1,
"reverse_audio": "yes",
})
config = ReverseConfig.from_dict(
{
"enabled": 1,
"reverse_video": 1,
"reverse_audio": "yes",
}
)
assert config.enabled is True
assert config.reverse_video is True
assert config.reverse_audio is True
def test_falsy_values(self):
"""非布尔假值也能被 bool() 转换."""
config = ReverseConfig.from_dict({
"enabled": True,
"reverse_video": 0,
"reverse_audio": "",
})
config = ReverseConfig.from_dict(
{
"enabled": True,
"reverse_video": 0,
"reverse_audio": "",
}
)
assert config.enabled is True
assert config.reverse_video is False
assert config.reverse_audio is False
@@ -125,17 +134,13 @@ class TestBuildVideoFilter:
def test_exactly_max_safe_duration(self):
"""刚好等于上限,允许."""
config = ReverseConfig(enabled=True)
result = ReverseEngine.build_video_filter(
config, duration=ReverseEngine.MAX_SAFE_DURATION
)
result = ReverseEngine.build_video_filter(config, duration=ReverseEngine.MAX_SAFE_DURATION)
assert result == "reverse"
def test_exceeds_max_safe_duration_skips(self):
"""超过上限,跳过倒放,返回空字符串."""
config = ReverseConfig(enabled=True)
result = ReverseEngine.build_video_filter(
config, duration=ReverseEngine.MAX_SAFE_DURATION + 1
)
result = ReverseEngine.build_video_filter(config, duration=ReverseEngine.MAX_SAFE_DURATION + 1)
assert result == ""
def test_negative_duration(self):
@@ -173,22 +178,16 @@ class TestBuildAudioFilter:
def test_exactly_max_safe_duration(self):
config = ReverseConfig(enabled=True)
result = ReverseEngine.build_audio_filter(
config, duration=ReverseEngine.MAX_SAFE_DURATION
)
result = ReverseEngine.build_audio_filter(config, duration=ReverseEngine.MAX_SAFE_DURATION)
assert result == "areverse"
def test_exceeds_max_safe_duration_skips(self):
config = ReverseConfig(enabled=True)
result = ReverseEngine.build_audio_filter(
config, duration=ReverseEngine.MAX_SAFE_DURATION + 1
)
result = ReverseEngine.build_audio_filter(config, duration=ReverseEngine.MAX_SAFE_DURATION + 1)
assert result == ""
def test_both_video_and_audio_disabled(self):
"""两个都关,两个滤镜都为空."""
config = ReverseConfig(
enabled=True, reverse_video=False, reverse_audio=False
)
config = ReverseConfig(enabled=True, reverse_video=False, reverse_audio=False)
assert ReverseEngine.build_video_filter(config, 10) == ""
assert ReverseEngine.build_audio_filter(config, 10) == ""
+13 -34
View File
@@ -1,20 +1,19 @@
"""task_enqueue 单测 — 队列限流 + 安全入队逻辑."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from app.core.task_enqueue import (
USER_PENDING_LIMIT,
GLOBAL_PENDING_LIMIT,
UserPendingLimitExceeded,
USER_PENDING_LIMIT,
GlobalQueueFull,
UserPendingLimitExceeded,
check_queue_limits,
safe_enqueue_generation_task,
)
# ── Fixtures / Helpers ─────────────────────────────────────────────────────
@@ -57,18 +56,14 @@ class TestCheckQueueLimits:
def test_global_at_limit_raises(self):
"""达到全局上限即拒绝."""
repo = MockRepository(
global_count=GLOBAL_PENDING_LIMIT, user_count=1
)
repo = MockRepository(global_count=GLOBAL_PENDING_LIMIT, user_count=1)
with pytest.raises(GlobalQueueFull) as exc_info:
check_queue_limits("user-1", repo)
assert exc_info.value.pending_count == GLOBAL_PENDING_LIMIT
assert exc_info.value.limit == GLOBAL_PENDING_LIMIT
def test_global_over_limit_raises(self):
repo = MockRepository(
global_count=GLOBAL_PENDING_LIMIT + 1, user_count=1
)
repo = MockRepository(global_count=GLOBAL_PENDING_LIMIT + 1, user_count=1)
with pytest.raises(GlobalQueueFull):
check_queue_limits("user-1", repo)
@@ -124,14 +119,10 @@ class TestSafeEnqueueGenerationTask:
repo = MockRepository(global_count=1, user_count=1)
task = make_mock_task()
result = safe_enqueue_generation_task(
task, repo, user_id="user-1"
)
result = safe_enqueue_generation_task(task, repo, user_id="user-1")
assert result is True
mock_celery.send_task.assert_called_once_with(
"worker.generate_video", args=[task.id]
)
mock_celery.send_task.assert_called_once_with("worker.generate_video", args=[task.id])
task.mark_failed.assert_not_called()
@patch("app.core.task_enqueue.celery_app")
@@ -146,9 +137,7 @@ class TestSafeEnqueueGenerationTask:
@patch("app.core.task_enqueue.celery_app")
def test_precheck_global_over_marks_failed(self, mock_celery):
"""入队前全局超限:标记 failed,抛异常."""
repo = MockRepository(
global_count=GLOBAL_PENDING_LIMIT + 1, user_count=0
)
repo = MockRepository(global_count=GLOBAL_PENDING_LIMIT + 1, user_count=0)
task = make_mock_task()
with pytest.raises(GlobalQueueFull):
@@ -161,9 +150,7 @@ class TestSafeEnqueueGenerationTask:
@patch("app.core.task_enqueue.celery_app")
def test_precheck_user_over_marks_failed(self, mock_celery):
"""入队前用户超限:标记 failed,抛异常."""
repo = MockRepository(
global_count=5, user_count=USER_PENDING_LIMIT + 1
)
repo = MockRepository(global_count=5, user_count=USER_PENDING_LIMIT + 1)
task = make_mock_task()
with pytest.raises(UserPendingLimitExceeded):
@@ -211,9 +198,7 @@ class TestSafeEnqueueGenerationTask:
return GLOBAL_PENDING_LIMIT + 1 # 入队后再查,超限
repo = MockRepository(global_count=GLOBAL_PENDING_LIMIT, user_count=0)
repo.count_pending_total = MagicMock(
side_effect=count_pending_total_side_effect
)
repo.count_pending_total = MagicMock(side_effect=count_pending_total_side_effect)
task = make_mock_task()
with pytest.raises(GlobalQueueFull):
@@ -227,9 +212,7 @@ class TestSafeEnqueueGenerationTask:
@patch("app.core.task_enqueue.celery_app")
def test_postcheck_user_over_rollback(self, mock_celery):
"""入队后用户超限:回滚标记 failed,抛异常."""
repo = MockRepository(
global_count=5, user_count=USER_PENDING_LIMIT
)
repo = MockRepository(global_count=5, user_count=USER_PENDING_LIMIT)
# 入队前用 > 判断,等于上限通过;入队后模拟并发超限
original_user_count = repo.count_pending_by_user
@@ -241,9 +224,7 @@ class TestSafeEnqueueGenerationTask:
return USER_PENDING_LIMIT # 用 > 判断,等于时通过
return USER_PENDING_LIMIT + 1 # 入队后,超限
repo.count_pending_by_user = MagicMock(
side_effect=count_by_user_side_effect
)
repo.count_pending_by_user = MagicMock(side_effect=count_by_user_side_effect)
task = make_mock_task()
with pytest.raises(UserPendingLimitExceeded):
@@ -257,9 +238,7 @@ class TestSafeEnqueueGenerationTask:
repo = MockRepository(global_count=1, user_count=1)
task = make_mock_task()
result = safe_enqueue_generation_task(
task, repo, user_id="user-1", log_task_status=True
)
result = safe_enqueue_generation_task(task, repo, user_id="user-1", log_task_status=True)
assert result is True
@patch("app.core.task_enqueue.celery_app")
@@ -8,7 +8,6 @@
from __future__ import annotations
import pytest
from app.api.routes.templates_editor.schemas import (
ExportUpdateRequest,
)