18bd0de3fa
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
284 lines
11 KiB
Python
Executable File
284 lines
11 KiB
Python
Executable File
"""task_enqueue 单测 — 队列限流 + 安全入队逻辑."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from app.core.task_enqueue import (
|
|
GLOBAL_PENDING_LIMIT,
|
|
USER_PENDING_LIMIT,
|
|
GlobalQueueFull,
|
|
UserPendingLimitExceeded,
|
|
check_queue_limits,
|
|
safe_enqueue_generation_task,
|
|
)
|
|
|
|
# ── Fixtures / Helpers ─────────────────────────────────────────────────────
|
|
|
|
|
|
class MockRepository:
|
|
"""Mock 任务仓储,用计数器模拟 pending 数量."""
|
|
|
|
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
|
|
task.status = "pending"
|
|
task.mark_failed = MagicMock()
|
|
return task
|
|
|
|
|
|
# ── check_queue_limits ────────────────────────────────────────────────────
|
|
|
|
|
|
class TestCheckQueueLimits:
|
|
"""check_queue_limits 预检查限流."""
|
|
|
|
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_raises(self):
|
|
"""达到用户上限即拒绝."""
|
|
repo = MockRepository(global_count=5, user_count=USER_PENDING_LIMIT)
|
|
with pytest.raises(UserPendingLimitExceeded) as exc_info:
|
|
check_queue_limits("user-1", repo)
|
|
assert exc_info.value.user_id == "user-1"
|
|
assert exc_info.value.pending_count == USER_PENDING_LIMIT
|
|
assert exc_info.value.limit == USER_PENDING_LIMIT
|
|
|
|
def test_user_over_limit_raises(self):
|
|
repo = MockRepository(global_count=5, user_count=USER_PENDING_LIMIT + 1)
|
|
with pytest.raises(UserPendingLimitExceeded):
|
|
check_queue_limits("user-1", repo)
|
|
|
|
def test_global_priority_over_user(self):
|
|
"""全局和用户都超限时,优先抛全局异常."""
|
|
repo = MockRepository(
|
|
global_count=GLOBAL_PENDING_LIMIT + 1,
|
|
user_count=USER_PENDING_LIMIT + 1,
|
|
)
|
|
with pytest.raises(GlobalQueueFull):
|
|
check_queue_limits("user-1", repo)
|
|
|
|
def test_empty_user_id_skips_user_check(self):
|
|
"""user_id 为空时跳过用户级检查."""
|
|
repo = MockRepository(global_count=5, user_count=999)
|
|
# 不抛异常 = 通过(只检查全局)
|
|
check_queue_limits("", repo)
|
|
|
|
def test_custom_limits(self):
|
|
"""支持自定义限流阈值."""
|
|
repo = MockRepository(global_count=5, user_count=5)
|
|
# 默认阈值下 user 5 > 3 会被拒
|
|
with pytest.raises(UserPendingLimitExceeded):
|
|
check_queue_limits("u1", repo)
|
|
|
|
# 自定义更高阈值就能通过
|
|
check_queue_limits("u1", repo, user_pending_limit=10, global_pending_limit=10)
|
|
|
|
|
|
# ── safe_enqueue_generation_task ──────────────────────────────────────────
|
|
|
|
|
|
class TestSafeEnqueueGenerationTask:
|
|
"""safe_enqueue_generation_task 安全入队."""
|
|
|
|
@patch("app.core.task_enqueue.celery_app")
|
|
def test_success_path(self, mock_celery):
|
|
"""正常路径:入队前检查通过 → 发送Celery → 入队后检查通过."""
|
|
repo = MockRepository(global_count=1, user_count=1)
|
|
task = make_mock_task()
|
|
|
|
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])
|
|
task.mark_failed.assert_not_called()
|
|
|
|
@patch("app.core.task_enqueue.celery_app")
|
|
def test_no_user_id_skips_user_check(self, mock_celery):
|
|
"""不传 user_id 跳过用户级限流."""
|
|
repo = MockRepository(global_count=1, user_count=999)
|
|
task = make_mock_task()
|
|
|
|
result = safe_enqueue_generation_task(task, repo, user_id="")
|
|
assert result is True
|
|
|
|
@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)
|
|
task = make_mock_task()
|
|
|
|
with pytest.raises(GlobalQueueFull):
|
|
safe_enqueue_generation_task(task, repo, user_id="user-1")
|
|
|
|
task.mark_failed.assert_called_once()
|
|
mock_celery.send_task.assert_not_called()
|
|
assert repo.update_called == 1
|
|
|
|
@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)
|
|
task = make_mock_task()
|
|
|
|
with pytest.raises(UserPendingLimitExceeded):
|
|
safe_enqueue_generation_task(task, repo, user_id="user-1")
|
|
|
|
task.mark_failed.assert_called_once()
|
|
mock_celery.send_task.assert_not_called()
|
|
|
|
@patch("app.core.task_enqueue.celery_app")
|
|
def test_celery_send_false_returns_false(self, mock_celery):
|
|
"""Celery 发送失败:返回 False,任务标记 failed."""
|
|
repo = MockRepository(global_count=1, user_count=1)
|
|
task = make_mock_task()
|
|
mock_celery.send_task.side_effect = Exception("celery down")
|
|
|
|
result = safe_enqueue_generation_task(task, repo, user_id="user-1")
|
|
|
|
assert result is False
|
|
task.mark_failed.assert_called_once()
|
|
assert "入队失败" in task.mark_failed.call_args[0][0]
|
|
|
|
@patch("app.core.task_enqueue.celery_app")
|
|
def test_celery_send_failure_update_also_fails(self, mock_celery):
|
|
"""Celery 发送失败 + mark_failed 更新也失败:不崩溃."""
|
|
repo = MockRepository(global_count=1, user_count=1)
|
|
repo.update = MagicMock(side_effect=Exception("db down"))
|
|
task = make_mock_task()
|
|
mock_celery.send_task.side_effect = Exception("celery down")
|
|
|
|
result = safe_enqueue_generation_task(task, repo, user_id="user-1")
|
|
|
|
assert result is False
|
|
# 不抛异常就是胜利
|
|
|
|
@patch("app.core.task_enqueue.celery_app")
|
|
def test_postcheck_global_over_rollback(self, mock_celery):
|
|
"""入队后全局超限(并发竞态):回滚标记 failed,抛异常."""
|
|
# 入队前刚好通过,但入队后再查发现超限
|
|
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()
|
|
|
|
with pytest.raises(GlobalQueueFull):
|
|
safe_enqueue_generation_task(task, repo, user_id="user-1")
|
|
|
|
# 异常是 GlobalQueueFull 类型,且任务已被标记为 failed(含"入队后"原因)
|
|
task.mark_failed.assert_called_once()
|
|
assert "入队后" in task.mark_failed.call_args[0][0]
|
|
mock_celery.send_task.assert_called_once()
|
|
|
|
@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)
|
|
# 入队前用 > 判断,等于上限通过;入队后模拟并发超限
|
|
original_user_count = repo.count_pending_by_user
|
|
|
|
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()
|
|
|
|
with pytest.raises(UserPendingLimitExceeded):
|
|
safe_enqueue_generation_task(task, repo, user_id="user-1")
|
|
|
|
task.mark_failed.assert_called_once()
|
|
|
|
@patch("app.core.task_enqueue.celery_app")
|
|
def test_log_task_status_enabled(self, mock_celery):
|
|
"""log_task_status=True 时日志中包含状态."""
|
|
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)
|
|
assert result is True
|
|
|
|
@patch("app.core.task_enqueue.celery_app")
|
|
def test_custom_limits_in_enqueue(self, mock_celery):
|
|
"""自定义限流阈值用于入队检查."""
|
|
repo = MockRepository(global_count=5, user_count=5)
|
|
task = make_mock_task()
|
|
|
|
# 默认阈值下用户 5 > 3 会被拒
|
|
with pytest.raises(UserPendingLimitExceeded):
|
|
safe_enqueue_generation_task(task, repo, user_id="user-1")
|
|
|
|
# 重置 mock 计数
|
|
task.mark_failed.reset_mock()
|
|
|
|
# 调大阈值后通过
|
|
result = safe_enqueue_generation_task(
|
|
task,
|
|
repo,
|
|
user_id="user-1",
|
|
user_pending_limit=10,
|
|
global_pending_limit=10,
|
|
)
|
|
assert result is True
|
|
|
|
|
|
# ── 异常类 ────────────────────────────────────────────────────────────────
|
|
|
|
|
|
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)
|