style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
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 2s
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 Web Image (pull_request) Has been skipped
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 / 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 API Image (pull_request) Successful in 47s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m28s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m54s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 7m6s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 8m43s
AI Code Review / AI Code Review (pull_request) Successful in 10m46s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m49s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 15m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 24m52s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m25s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 5m1s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 1h3m21s
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 1s
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
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 2s
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 Web Image (pull_request) Has been skipped
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 / 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 API Image (pull_request) Successful in 47s
CI/CD Pipeline / PR Build Worker Image (pull_request) Successful in 1m28s
Preview Deploy / Deploy Preview Environment (pull_request) Successful in 1m47s
PR Automation / Auto Approve on CI Green (pull_request) Successful in 2m54s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 7m6s
CI/CD Pipeline / Validate - Style (pull_request) Successful in 8m43s
AI Code Review / AI Code Review (pull_request) Successful in 10m46s
PR Automation / Auto Merge on CI Green + Approved (pull_request) Successful in 10m49s
CI/CD Pipeline / Unit Tests (pull_request) Successful in 15m58s
CI/CD Pipeline / Validate - Python (mypy + alembic) (pull_request) Successful in 24m52s
ACR Cleanup / ACR Image Cleanup (pull_request_target) Successful in 4m25s
Preview Cleanup / Cleanup Preview Environment (pull_request) Successful in 5m1s
CI/CD Pipeline / Validate - Security (pull_request) Successful in 1h3m21s
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 1s
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
This commit is contained in:
@@ -9,7 +9,7 @@
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import MagicMock, patch, patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from app.core.task_enqueue import GlobalQueueFull, UserPendingLimitExceeded
|
||||
|
||||
@@ -744,6 +744,7 @@ class TestCreatePreviewRoute:
|
||||
def test_user_pending_limit_no_longer_rejects(self):
|
||||
"""#2098: 用户待处理任务超限不再 429 拒绝(预检查仅全局 503)。"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
repo = MagicMock()
|
||||
repo.count_pending_by_user.return_value = 100
|
||||
repo.count_pending_total.return_value = 5
|
||||
@@ -851,9 +852,11 @@ class TestCreatePreviewRoute:
|
||||
repo.count_pending_by_user.return_value = 0
|
||||
repo.count_pending_total.return_value = 0
|
||||
task = _make_task()
|
||||
|
||||
def _set_failed(reason="", **_kw):
|
||||
task.status = GenerationTaskStatus.FAILED
|
||||
task.error_message = reason
|
||||
|
||||
task.mark_failed.side_effect = _set_failed
|
||||
repo.create.return_value = task
|
||||
with patch(
|
||||
|
||||
@@ -14,18 +14,23 @@ from app.core.task_enqueue import (
|
||||
safe_enqueue_generation_task,
|
||||
)
|
||||
|
||||
|
||||
class MockRepository:
|
||||
def __init__(self, global_count: int = 0, user_count: int = 0):
|
||||
self._global = global_count
|
||||
self._user = user_count
|
||||
self.update_called = 0
|
||||
|
||||
def count_pending_total(self) -> int:
|
||||
return self._global
|
||||
|
||||
def count_pending_by_user(self, user_id: str) -> int:
|
||||
return self._user
|
||||
|
||||
def update(self, task):
|
||||
self.update_called += 1
|
||||
|
||||
|
||||
def make_mock_task(task_id: str = "task-1"):
|
||||
task = MagicMock()
|
||||
task.id = task_id
|
||||
@@ -33,35 +38,43 @@ def make_mock_task(task_id: str = "task-1"):
|
||||
task.mark_failed = MagicMock()
|
||||
return task
|
||||
|
||||
|
||||
class TestCheckQueueLimits:
|
||||
def test_below_limits_passes(self):
|
||||
repo = MockRepository(global_count=5, user_count=1)
|
||||
check_queue_limits("user-1", repo)
|
||||
|
||||
def test_global_at_limit_raises(self):
|
||||
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)
|
||||
with pytest.raises(GlobalQueueFull):
|
||||
check_queue_limits("user-1", repo)
|
||||
|
||||
def test_user_at_limit_no_longer_raises(self):
|
||||
repo = MockRepository(global_count=5, user_count=USER_PENDING_LIMIT)
|
||||
check_queue_limits("user-1", repo)
|
||||
|
||||
def test_user_over_limit_no_longer_raises(self):
|
||||
repo = MockRepository(global_count=5, user_count=USER_PENDING_LIMIT + 100)
|
||||
check_queue_limits("user-1", repo)
|
||||
|
||||
def test_empty_user_id_skips_user_check(self):
|
||||
repo = MockRepository(global_count=5, user_count=999)
|
||||
check_queue_limits("", repo)
|
||||
|
||||
def test_custom_global_limit_still_honored(self):
|
||||
repo = MockRepository(global_count=15, user_count=999)
|
||||
with pytest.raises(GlobalQueueFull):
|
||||
check_queue_limits("u1", repo, user_pending_limit=999, global_pending_limit=10)
|
||||
check_queue_limits("u1", repo, user_pending_limit=999, global_pending_limit=20)
|
||||
|
||||
|
||||
class TestSafeEnqueueGenerationTask:
|
||||
@patch("app.core.task_enqueue.celery_app")
|
||||
def test_success_path(self, mock_celery):
|
||||
@@ -92,6 +105,7 @@ class TestSafeEnqueueGenerationTask:
|
||||
@patch("app.core.task_enqueue.celery_app")
|
||||
def test_precheck_user_over_still_enqueues(self, mock_celery, caplog):
|
||||
import logging
|
||||
|
||||
caplog.set_level(logging.WARNING)
|
||||
repo = MockRepository(global_count=5, user_count=USER_PENDING_LIMIT + 100)
|
||||
task = make_mock_task()
|
||||
@@ -123,11 +137,13 @@ class TestSafeEnqueueGenerationTask:
|
||||
@patch("app.core.task_enqueue.celery_app")
|
||||
def test_postcheck_global_over_rollback(self, mock_celery):
|
||||
call_count = [0]
|
||||
|
||||
def count_pending_total_side_effect():
|
||||
call_count[0] += 1
|
||||
if call_count[0] == 1:
|
||||
return GLOBAL_PENDING_LIMIT
|
||||
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)
|
||||
task = make_mock_task()
|
||||
@@ -140,14 +156,17 @@ class TestSafeEnqueueGenerationTask:
|
||||
@patch("app.core.task_enqueue.celery_app")
|
||||
def test_postcheck_user_over_does_not_rollback(self, mock_celery, caplog):
|
||||
import logging
|
||||
|
||||
caplog.set_level(logging.WARNING)
|
||||
repo = MockRepository(global_count=5, user_count=USER_PENDING_LIMIT)
|
||||
call_count = [0]
|
||||
|
||||
def count_by_user_side_effect(user_id):
|
||||
call_count[0] += 1
|
||||
if call_count[0] <= 1:
|
||||
return USER_PENDING_LIMIT
|
||||
return USER_PENDING_LIMIT + 1
|
||||
|
||||
repo.count_pending_by_user = MagicMock(side_effect=count_by_user_side_effect)
|
||||
task = make_mock_task()
|
||||
result = safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||||
@@ -174,15 +193,18 @@ class TestSafeEnqueueGenerationTask:
|
||||
with pytest.raises(GlobalQueueFull):
|
||||
safe_enqueue_generation_task(task2, repo, user_id="user-1", user_pending_limit=999, global_pending_limit=10)
|
||||
|
||||
|
||||
class TestExceptionClasses:
|
||||
def test_user_pending_limit_message(self):
|
||||
exc = UserPendingLimitExceeded("u1", 5, 3)
|
||||
assert "u1" in str(exc)
|
||||
assert "5" in str(exc)
|
||||
assert "3" in str(exc)
|
||||
|
||||
def test_global_queue_full_message(self):
|
||||
exc = GlobalQueueFull(25, 20)
|
||||
assert "25" in str(exc)
|
||||
assert "20" in str(exc)
|
||||
|
||||
def test_user_pending_limit_constant(self):
|
||||
assert USER_PENDING_LIMIT == 20
|
||||
|
||||
@@ -26,13 +26,17 @@ class MockRepository:
|
||||
self._global_pending = global_pending
|
||||
self._send_task_called = False
|
||||
self.updated_tasks = []
|
||||
|
||||
def count_pending_by_user(self, user_id: str) -> int:
|
||||
return self._user_pending
|
||||
|
||||
def count_pending_total(self) -> int:
|
||||
return self._global_pending
|
||||
|
||||
def update(self, task):
|
||||
self.updated_tasks.append(task)
|
||||
return task
|
||||
|
||||
def set_pending(self, *, user_pending: int | None = None, global_pending: int | None = None):
|
||||
if user_pending is not None:
|
||||
self._user_pending = user_pending
|
||||
@@ -45,6 +49,7 @@ class MockTask:
|
||||
self.id = task_id
|
||||
self.status = status
|
||||
self.error_message = ""
|
||||
|
||||
def mark_failed(self, reason: str):
|
||||
self.status = "failed"
|
||||
self.error_message = reason
|
||||
@@ -120,6 +125,7 @@ class TestSafeEnqueueWithLimits:
|
||||
def test_user_limit_exceeded_still_enqueues(self, mock_celery, caplog):
|
||||
"""#2098: 用户远超软上限仍入队,任务不被标记 failed。"""
|
||||
import logging
|
||||
|
||||
caplog.set_level(logging.WARNING)
|
||||
repo = MockRepository(user_pending=100, global_pending=5)
|
||||
task = MockTask("task-1")
|
||||
@@ -179,8 +185,10 @@ class TestPostEnqueueFinalCheck:
|
||||
def test_post_enqueue_global_overflow_rollback(self, mock_celery):
|
||||
repo = MockRepository(user_pending=1, global_pending=20)
|
||||
task = MockTask("task-1")
|
||||
|
||||
def side_effect(*args, **kwargs):
|
||||
repo.set_pending(global_pending=21)
|
||||
|
||||
mock_celery.side_effect = side_effect
|
||||
with pytest.raises(GlobalQueueFull) as exc_info:
|
||||
safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||||
@@ -193,11 +201,14 @@ class TestPostEnqueueFinalCheck:
|
||||
def test_post_enqueue_user_overflow_no_rollback(self, mock_celery, caplog):
|
||||
"""#2098: 入队后用户超软上限仅 warning,不回滚。"""
|
||||
import logging
|
||||
|
||||
caplog.set_level(logging.WARNING)
|
||||
repo = MockRepository(user_pending=USER_PENDING_LIMIT, global_pending=5)
|
||||
task = MockTask("task-1")
|
||||
|
||||
def side_effect(*args, **kwargs):
|
||||
repo.set_pending(user_pending=USER_PENDING_LIMIT + 1)
|
||||
|
||||
mock_celery.side_effect = side_effect
|
||||
result = safe_enqueue_generation_task(task, repo, user_id="user-1")
|
||||
assert result is True
|
||||
@@ -218,8 +229,10 @@ class TestPostEnqueueFinalCheck:
|
||||
def test_post_enqueue_no_user_id_skips_user_check(self, mock_celery):
|
||||
repo = MockRepository(user_pending=10, global_pending=5)
|
||||
task = MockTask("task-1")
|
||||
|
||||
def side_effect(*args, **kwargs):
|
||||
repo.set_pending(user_pending=15, global_pending=5)
|
||||
|
||||
mock_celery.side_effect = side_effect
|
||||
result = safe_enqueue_generation_task(task, repo, user_id="")
|
||||
assert result is True
|
||||
|
||||
@@ -74,11 +74,14 @@ def test_build_task_config_override_is_plain_function():
|
||||
assert not hasattr(fn, "run"), f"{fn.__name__} must be a plain function, not a Celery task"
|
||||
|
||||
# Bug A: override 对 title_config 做 key 归一化 (font_size→size, font_color→color)
|
||||
override = _build_task_config_override({
|
||||
"title_config": {"font_size": 48, "font_color": "#ff0000", "text": "hi"},
|
||||
"bgm_config": {"url": "http://x/bgm.mp3"},
|
||||
"output_width": 1080, "output_height": 1920,
|
||||
})
|
||||
override = _build_task_config_override(
|
||||
{
|
||||
"title_config": {"font_size": 48, "font_color": "#ff0000", "text": "hi"},
|
||||
"bgm_config": {"url": "http://x/bgm.mp3"},
|
||||
"output_width": 1080,
|
||||
"output_height": 1920,
|
||||
}
|
||||
)
|
||||
assert override["title"]["size"] == 48
|
||||
assert override["title"]["color"] == "#ff0000"
|
||||
assert override["bgm"]["url"] == "http://x/bgm.mp3"
|
||||
|
||||
Reference in New Issue
Block a user