ForcePilot/backend/test/unit/external_systems/infrastructure/test_scheduler.py
Kris 3ae9c0bd21 test: 批量修复与新增单元测试用例,清理废弃测试目录
1.  删除了 test/unit/external_systems/framework/ 下的废弃空测试目录
2.  修复多处测试断言逻辑、参数传递与测试数据构造
3.  新增日志级别、缓存令牌、流事件等DTO单元测试
4.  补充路由绑定、会话仓储、outbox仓储的测试覆盖
5.  更新测试用例中的异常类型、参数校验与业务逻辑断言
2026-07-11 21:43:16 +08:00

1443 lines
50 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.

"""external_systems scheduler handler 单元测试。
infrastructure 包 ``__init__.py`` 在本地非容器环境导入 ``container.py`` 时会失败,
因此本测试通过 ``importlib`` 直接加载 ``scheduler.py`` 模块,避免触发包级装配代码。
"""
from __future__ import annotations
import importlib.util
import sys
import types
from datetime import timedelta
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
from yuxi.scheduler.core.contracts import TaskContext, TaskResult
from yuxi.scheduler.framework.runtime import HandlerRegistry
from yuxi.utils.datetime_utils import utc_now_naive
def _load_scheduler_module() -> Any:
"""直接加载 scheduler.py 模块,绕过 infrastructure 包 __init__.py。"""
module_path = Path(__file__).parents[5] / "backend/package/yuxi/external_systems/infrastructure/scheduler.py"
spec = importlib.util.spec_from_file_location(
"external_systems_scheduler",
module_path.resolve(),
)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _make_task_context(payload: dict[str, Any] | None = None) -> TaskContext:
return TaskContext(
task_id="task-1",
run_id="run-1",
handler_name="external_systems.webhook_event_cleanup",
payload=payload or {},
triggered_by="scheduler",
scheduled_at=utc_now_naive(),
)
def _make_repos(
deleted_count: int = 0,
near_critical: list[Any] | None = None,
near_warning: list[Any] | None = None,
expiring_quotas: list[Any] | None = None,
expiring_subscriptions: list[Any] | None = None,
audit_log_deleted_count: int = 0,
system_ids: list[int] | None = None,
) -> MagicMock:
repos = MagicMock()
repos.webhook_event = AsyncMock()
repos.webhook_event.delete_old_events = AsyncMock(return_value=deleted_count)
repos.webhook_subscription = AsyncMock()
repos.webhook_subscription.list_expiring = AsyncMock(return_value=expiring_subscriptions or [])
repos.webhook_subscription.update_renewal_result = AsyncMock()
repos.quota_usage = AsyncMock()
repos.quota_usage.list_near_critical = AsyncMock(return_value=near_critical or [])
repos.quota_usage.list_near_warning = AsyncMock(return_value=near_warning or [])
repos.quota_usage.list = AsyncMock(return_value=expiring_quotas or [])
repos.quota_usage.reset_window = AsyncMock()
repos.audit_log = AsyncMock()
repos.audit_log.delete_old_logs = AsyncMock(return_value=audit_log_deleted_count)
repos.system = AsyncMock()
repos.system.list_ids = AsyncMock(return_value=system_ids or [])
repos.alert = AsyncMock()
return repos
class _QuotaStub:
"""配额记录存根,用于 quota handler 测试。"""
def __init__(
self,
quota_id: int | None = None,
system_id: int = 1,
env_key: str = "default",
quota_key: str = "api_calls",
quota_name: str = "API Calls",
quota_window: str = "daily",
limit_value: int = 1000,
used_value: int = 0,
usage_ratio: float = 0.0,
warning_threshold: float = 80.0,
critical_threshold: float = 95.0,
window_start: Any | None = None,
window_end: Any | None = None,
) -> None:
self.id = quota_id
self.system_id = system_id
self.env_key = env_key
self.quota_key = quota_key
self.quota_name = quota_name
self.quota_window = quota_window
self.limit_value = limit_value
self.used_value = used_value
self.usage_ratio = usage_ratio
self.warning_threshold = warning_threshold
self.critical_threshold = critical_threshold
self.window_start = window_start
self.window_end = window_end
class _FakeSession:
pass
class _FakeSessionFactory:
async def __aenter__(self) -> _FakeSession:
return _FakeSession()
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
pass
@pytest.fixture
def scheduler_module(monkeypatch: pytest.MonkeyPatch) -> Any:
"""加载 scheduler 模块,并将其内部 create_repositories 替换为返回 mock repos 的工厂。"""
module = _load_scheduler_module()
repos = _make_repos(deleted_count=5)
monkeypatch.setattr(
module,
"create_repositories",
lambda _db: repos,
)
return module
@pytest.mark.unit
@pytest.mark.asyncio
async def test_cleanupHandler_uses_default_retention_days(scheduler_module: Any) -> None:
handler = scheduler_module.WebhookEventCleanupHandler(
session_factory=_FakeSessionFactory,
default_retention_days=30,
)
result = await handler.execute(_make_task_context())
assert isinstance(result, TaskResult)
assert result.success is True
assert result.output["deleted_count"] == 5
assert result.output["retention_days"] == 30
repos = scheduler_module.create_repositories(None)
repos.webhook_event.delete_old_events.assert_called_once()
before = repos.webhook_event.delete_old_events.call_args.args[0]
assert before < utc_now_naive()
assert before > utc_now_naive() - timedelta(days=31)
@pytest.mark.unit
@pytest.mark.asyncio
async def test_cleanupHandler_uses_payload_retention_days(scheduler_module: Any) -> None:
repos = _make_repos(deleted_count=0)
scheduler_module.create_repositories = lambda _db: repos
handler = scheduler_module.WebhookEventCleanupHandler(
session_factory=_FakeSessionFactory,
default_retention_days=30,
)
result = await handler.execute(_make_task_context({"retention_days": 7}))
assert result.success is True
assert result.output["retention_days"] == 7
@pytest.mark.unit
@pytest.mark.asyncio
async def test_cleanupHandler_returns_failure_on_exception(scheduler_module: Any) -> None:
repos = _make_repos()
repos.webhook_event.delete_old_events = AsyncMock(side_effect=RuntimeError("db down"))
scheduler_module.create_repositories = lambda _db: repos
handler = scheduler_module.WebhookEventCleanupHandler(session_factory=_FakeSessionFactory)
result = await handler.execute(_make_task_context())
assert result.success is False
assert "db down" in (result.error or "")
@pytest.mark.unit
def test_registerSchedulerHandlers_registers_cleanup_handler() -> None:
module = _load_scheduler_module()
registry = HandlerRegistry()
module.register_scheduler_handlers(registry)
handler = registry.get("external_systems.webhook_event_cleanup")
assert handler is not None
assert handler.name == "external_systems.webhook_event_cleanup"
assert handler.description == "清理超过保留期的 Webhook 事件"
# ─── PendingWebhookEventConsumerHandler ────────────────────────────────────
def _patch_container_for_pending_consumer(
monkeypatch: pytest.MonkeyPatch,
consume_results: list[dict[str, Any]],
) -> AsyncMock:
"""向 sys.modules 注入 fake containermock webhook_event_handler.consume_pending_event。"""
fake_service = AsyncMock()
fake_service.consume_pending_event = AsyncMock(side_effect=consume_results)
fake_use_cases = MagicMock()
fake_use_cases.webhook_event_handler = fake_service
fake_container = types.ModuleType("yuxi.external_systems.infrastructure.container")
fake_container.create_use_cases_from_db = lambda _db: fake_use_cases
monkeypatch.setitem(
sys.modules,
"yuxi.external_systems.infrastructure.container",
fake_container,
)
return fake_service
class _EventStub:
"""WebhookEvent 存根,用于 pending consumer 测试。"""
def __init__(self, event_id: int | None, subscription_slug: str = "sub") -> None:
self.id = event_id
self.subscription_slug = subscription_slug
@pytest.mark.unit
@pytest.mark.asyncio
async def test_pendingConsumerHandler_consumes_pending_events(
scheduler_module: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
events = [_EventStub(1), _EventStub(2)]
repos = _make_repos()
repos.webhook_event.list_pending = AsyncMock(return_value=events)
scheduler_module.create_repositories = lambda _db: repos
fake_service = _patch_container_for_pending_consumer(monkeypatch, [None, None])
handler = scheduler_module.PendingWebhookEventConsumerHandler(
session_factory=_FakeSessionFactory,
default_batch_size=100,
)
result = await handler.execute(_make_task_context())
assert isinstance(result, TaskResult)
assert result.success is True
assert result.output["consumed_count"] == 2
assert result.output["failed_count"] == 0
fake_service.consume_pending_event.assert_awaited()
assert repos.webhook_event.list_pending.call_args.kwargs["limit"] == 100
@pytest.mark.unit
@pytest.mark.asyncio
async def test_pendingConsumerHandler_uses_payload_batch_size(
scheduler_module: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
events = [_EventStub(1)]
repos = _make_repos()
repos.webhook_event.list_pending = AsyncMock(return_value=events)
scheduler_module.create_repositories = lambda _db: repos
_patch_container_for_pending_consumer(monkeypatch, [None])
handler = scheduler_module.PendingWebhookEventConsumerHandler(
session_factory=_FakeSessionFactory,
default_batch_size=100,
)
result = await handler.execute(_make_task_context({"batch_size": 50}))
assert result.success is True
assert repos.webhook_event.list_pending.call_args.kwargs["limit"] == 50
@pytest.mark.unit
@pytest.mark.asyncio
async def test_pendingConsumerHandler_isolates_single_failure(
scheduler_module: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
events = [_EventStub(1), _EventStub(2)]
repos = _make_repos()
repos.webhook_event.list_pending = AsyncMock(return_value=events)
scheduler_module.create_repositories = lambda _db: repos
fake_service = _patch_container_for_pending_consumer(
monkeypatch,
[RuntimeError("consume failed"), None],
)
handler = scheduler_module.PendingWebhookEventConsumerHandler(
session_factory=_FakeSessionFactory,
)
result = await handler.execute(_make_task_context())
assert result.success is True
assert result.output["consumed_count"] == 1
assert result.output["failed_count"] == 1
assert len(result.output["errors"]) == 1
fake_service.consume_pending_event.assert_awaited()
@pytest.mark.unit
@pytest.mark.asyncio
async def test_pendingConsumerHandler_skips_event_without_id(
scheduler_module: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
events = [_EventStub(None), _EventStub(2)]
repos = _make_repos()
repos.webhook_event.list_pending = AsyncMock(return_value=events)
scheduler_module.create_repositories = lambda _db: repos
fake_service = _patch_container_for_pending_consumer(monkeypatch, [None])
handler = scheduler_module.PendingWebhookEventConsumerHandler(
session_factory=_FakeSessionFactory,
)
result = await handler.execute(_make_task_context())
assert result.success is True
assert result.output["consumed_count"] == 1
assert result.output["failed_count"] == 1
assert fake_service.consume_pending_event.await_count == 1
@pytest.mark.unit
@pytest.mark.asyncio
async def test_pendingConsumerHandler_returns_failure_on_exception(
scheduler_module: Any,
) -> None:
repos = _make_repos()
repos.webhook_event.list_pending = AsyncMock(side_effect=RuntimeError("db down"))
scheduler_module.create_repositories = lambda _db: repos
handler = scheduler_module.PendingWebhookEventConsumerHandler(
session_factory=_FakeSessionFactory,
)
result = await handler.execute(_make_task_context())
assert result.success is False
assert "db down" in (result.error or "")
@pytest.mark.unit
def test_registerSchedulerHandlers_registers_pending_consumer_handler() -> None:
module = _load_scheduler_module()
registry = HandlerRegistry()
module.register_scheduler_handlers(registry)
handler = registry.get("external_systems.webhook_pending_consumer")
assert handler is not None
assert handler.name == "external_systems.webhook_pending_consumer"
assert handler.description == "定时消费 pending 状态的 Webhook 事件"
# ─── ProcessingTimeoutRecoveryHandler ──────────────────────────────────────
@pytest.mark.unit
@pytest.mark.asyncio
async def test_processingTimeoutRecoveryHandler_resets_stuck_events(
scheduler_module: Any,
) -> None:
events = [_EventStub(1), _EventStub(2)]
repos = _make_repos()
repos.webhook_event.list_processing_stuck = AsyncMock(return_value=events)
repos.webhook_event.update_processing = AsyncMock(return_value=None)
scheduler_module.create_repositories = lambda _db: repos
handler = scheduler_module.ProcessingTimeoutRecoveryHandler(
session_factory=_FakeSessionFactory,
default_timeout_seconds=300,
default_batch_size=100,
)
result = await handler.execute(_make_task_context())
assert isinstance(result, TaskResult)
assert result.success is True
assert result.output["recovered_count"] == 2
assert result.output["failed_count"] == 0
repos.webhook_event.list_processing_stuck.assert_called_once_with(
300,
limit=100,
)
assert repos.webhook_event.update_processing.await_count == 2
call = repos.webhook_event.update_processing.call_args
assert call.kwargs["status"] == "pending"
@pytest.mark.unit
@pytest.mark.asyncio
async def test_processingTimeoutRecoveryHandler_uses_payload_params(
scheduler_module: Any,
) -> None:
events = [_EventStub(1)]
repos = _make_repos()
repos.webhook_event.list_processing_stuck = AsyncMock(return_value=events)
repos.webhook_event.update_processing = AsyncMock(return_value=None)
scheduler_module.create_repositories = lambda _db: repos
handler = scheduler_module.ProcessingTimeoutRecoveryHandler(
session_factory=_FakeSessionFactory,
)
result = await handler.execute(_make_task_context({"timeout_seconds": 60, "batch_size": 10}))
assert result.success is True
repos.webhook_event.list_processing_stuck.assert_called_once_with(
60,
limit=10,
)
@pytest.mark.unit
@pytest.mark.asyncio
async def test_processingTimeoutRecoveryHandler_isolates_single_failure(
scheduler_module: Any,
) -> None:
events = [_EventStub(1), _EventStub(2)]
repos = _make_repos()
repos.webhook_event.list_processing_stuck = AsyncMock(return_value=events)
repos.webhook_event.update_processing = AsyncMock(side_effect=[RuntimeError("locked"), None])
scheduler_module.create_repositories = lambda _db: repos
handler = scheduler_module.ProcessingTimeoutRecoveryHandler(
session_factory=_FakeSessionFactory,
)
result = await handler.execute(_make_task_context())
assert result.success is True
assert result.output["recovered_count"] == 1
assert result.output["failed_count"] == 1
assert len(result.output["errors"]) == 1
@pytest.mark.unit
@pytest.mark.asyncio
async def test_processingTimeoutRecoveryHandler_skips_event_without_id(
scheduler_module: Any,
) -> None:
events = [_EventStub(None), _EventStub(2)]
repos = _make_repos()
repos.webhook_event.list_processing_stuck = AsyncMock(return_value=events)
repos.webhook_event.update_processing = AsyncMock(return_value=None)
scheduler_module.create_repositories = lambda _db: repos
handler = scheduler_module.ProcessingTimeoutRecoveryHandler(
session_factory=_FakeSessionFactory,
)
result = await handler.execute(_make_task_context())
assert result.success is True
assert result.output["recovered_count"] == 1
assert result.output["failed_count"] == 1
assert repos.webhook_event.update_processing.await_count == 1
@pytest.mark.unit
@pytest.mark.asyncio
async def test_processingTimeoutRecoveryHandler_returns_failure_on_exception(
scheduler_module: Any,
) -> None:
repos = _make_repos()
repos.webhook_event.list_processing_stuck = AsyncMock(side_effect=RuntimeError("db down"))
scheduler_module.create_repositories = lambda _db: repos
handler = scheduler_module.ProcessingTimeoutRecoveryHandler(
session_factory=_FakeSessionFactory,
)
result = await handler.execute(_make_task_context())
assert result.success is False
assert "db down" in (result.error or "")
@pytest.mark.unit
def test_registerSchedulerHandlers_registers_processing_recovery_handler() -> None:
module = _load_scheduler_module()
registry = HandlerRegistry()
module.register_scheduler_handlers(registry)
handler = registry.get("external_systems.webhook_processing_recovery")
assert handler is not None
assert handler.name == "external_systems.webhook_processing_recovery"
assert handler.description == "将 processing 状态超时的 Webhook 事件重置为 pending"
# ─── SecretRotationHandler ─────────────────────────────────────────────────
def _patch_container_for_secret_rotation(
monkeypatch: pytest.MonkeyPatch,
scheduled_rotation_output: Any,
) -> AsyncMock:
"""向 sys.modules 注入 fake container避免本地非容器环境导入 app_config 失败。"""
fake_service = AsyncMock()
fake_service.scheduled_rotation = AsyncMock(return_value=scheduled_rotation_output)
fake_use_cases = MagicMock()
fake_use_cases.secret_rotation_service = fake_service
fake_container = types.ModuleType("yuxi.external_systems.infrastructure.container")
fake_container.create_use_cases_from_db = lambda _db: fake_use_cases
monkeypatch.setitem(
sys.modules,
"yuxi.external_systems.infrastructure.container",
fake_container,
)
return fake_service
@pytest.mark.unit
@pytest.mark.asyncio
async def test_secretRotationHandler_executes_scheduled_rotation(
scheduler_module: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from yuxi.external_systems.use_cases.dto.secret_rotation import ScheduledRotationOutput
fake_service = _patch_container_for_secret_rotation(
monkeypatch,
ScheduledRotationOutput(affected=2, succeeded=2, failed=0),
)
handler = scheduler_module.SecretRotationHandler(session_factory=_FakeSessionFactory)
result = await handler.execute(_make_task_context())
assert isinstance(result, TaskResult)
assert result.success is True
assert result.output["affected"] == 2
assert result.output["succeeded"] == 2
fake_service.scheduled_rotation.assert_awaited_once()
@pytest.mark.unit
@pytest.mark.asyncio
async def test_secretRotationHandler_returns_failure_on_exception(
scheduler_module: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = AsyncMock()
fake_service.scheduled_rotation = AsyncMock(side_effect=RuntimeError("rotation failed"))
fake_use_cases = MagicMock()
fake_use_cases.secret_rotation_service = fake_service
fake_container = types.ModuleType("yuxi.external_systems.infrastructure.container")
fake_container.create_use_cases_from_db = lambda _db: fake_use_cases
monkeypatch.setitem(
sys.modules,
"yuxi.external_systems.infrastructure.container",
fake_container,
)
handler = scheduler_module.SecretRotationHandler(session_factory=_FakeSessionFactory)
result = await handler.execute(_make_task_context())
assert result.success is False
assert "rotation failed" in (result.error or "")
# ─── QuotaThresholdAlertHandler ────────────────────────────────────────────
@pytest.mark.unit
@pytest.mark.asyncio
async def test_quotaThresholdAlertHandler_fires_critical_and_warning(
scheduler_module: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
critical_quota = _QuotaStub(quota_id=1, quota_name="Q1", usage_ratio=0.96)
warning_quota = _QuotaStub(quota_id=2, quota_name="Q2", usage_ratio=0.85)
repos = _make_repos(
near_critical=[critical_quota],
near_warning=[warning_quota],
)
scheduler_module.create_repositories = lambda _db: repos
class _FakeAlertManager:
fired: list[dict[str, Any]] = []
async def fire(
self,
system_id: Any,
env_key: Any,
alert_type: Any,
**kwargs: Any,
) -> None:
self.fired.append(
{
"system_id": system_id,
"env_key": env_key,
"alert_type": alert_type,
**kwargs,
}
)
def build_dedup_key(self, *args: Any, **kwargs: Any) -> str:
return "dedup"
fake_alert_manager = _FakeAlertManager()
monkeypatch.setattr(
scheduler_module,
"AlertManagerImpl",
lambda _alert_repo: fake_alert_manager,
)
handler = scheduler_module.QuotaThresholdAlertHandler(
session_factory=_FakeSessionFactory,
)
result = await handler.execute(_make_task_context())
assert result.success is True
assert result.output["critical_count"] == 1
assert result.output["warning_count"] == 1
assert len(fake_alert_manager.fired) == 2
@pytest.mark.unit
@pytest.mark.asyncio
async def test_quotaThresholdAlertHandler_dedup_warning_when_critical(
scheduler_module: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
quota = _QuotaStub(quota_id=1, quota_name="Q1", usage_ratio=0.97)
repos = _make_repos(
near_critical=[quota],
near_warning=[quota],
)
scheduler_module.create_repositories = lambda _db: repos
class _FakeAlertManager:
fired: list[dict[str, Any]] = []
async def fire(
self,
system_id: Any,
env_key: Any,
alert_type: Any,
**kwargs: Any,
) -> None:
self.fired.append(
{
"system_id": system_id,
"env_key": env_key,
"alert_type": alert_type,
**kwargs,
}
)
fake_alert_manager = _FakeAlertManager()
monkeypatch.setattr(
scheduler_module,
"AlertManagerImpl",
lambda _alert_repo: fake_alert_manager,
)
handler = scheduler_module.QuotaThresholdAlertHandler(
session_factory=_FakeSessionFactory,
)
result = await handler.execute(_make_task_context())
assert result.success is True
assert result.output["warning_count"] == 0
assert len(fake_alert_manager.fired) == 1
@pytest.mark.unit
@pytest.mark.asyncio
async def test_quotaThresholdAlertHandler_returns_failure_on_exception(scheduler_module: Any) -> None:
repos = _make_repos()
repos.quota_usage.list_near_critical = AsyncMock(side_effect=RuntimeError("db down"))
scheduler_module.create_repositories = lambda _db: repos
handler = scheduler_module.QuotaThresholdAlertHandler(
session_factory=_FakeSessionFactory,
)
result = await handler.execute(_make_task_context())
assert result.success is False
assert "db down" in (result.error or "")
# ─── QuotaWindowResetHandler ───────────────────────────────────────────────
@pytest.mark.unit
@pytest.mark.asyncio
async def test_quotaWindowResetHandler_resets_expired_windows(scheduler_module: Any) -> None:
now = utc_now_naive()
quota = _QuotaStub(
system_id=1,
env_key="default",
quota_key="api_calls",
quota_window="daily",
window_start=now - timedelta(days=2),
window_end=now - timedelta(hours=1),
)
repos = _make_repos(expiring_quotas=[quota])
scheduler_module.create_repositories = lambda _db: repos
handler = scheduler_module.QuotaWindowResetHandler(
session_factory=_FakeSessionFactory,
)
result = await handler.execute(_make_task_context())
assert result.success is True
assert result.output["reset_count"] == 1
assert result.output["failed_count"] == 0
repos.quota_usage.reset_window.assert_called_once()
call = repos.quota_usage.reset_window.call_args
assert call.kwargs["used_value"] == 0
assert call.kwargs["updated_by"] == "scheduler"
@pytest.mark.unit
@pytest.mark.asyncio
async def test_quotaWindowResetHandler_rolling_24h_window(scheduler_module: Any) -> None:
now = utc_now_naive()
quota = _QuotaStub(
quota_window="rolling_24h",
window_start=now - timedelta(days=2),
window_end=now - timedelta(hours=1),
)
repos = _make_repos(expiring_quotas=[quota])
scheduler_module.create_repositories = lambda _db: repos
handler = scheduler_module.QuotaWindowResetHandler(
session_factory=_FakeSessionFactory,
)
result = await handler.execute(_make_task_context())
assert result.success is True
assert result.output["reset_count"] == 1
repos.quota_usage.reset_window.assert_called_once()
@pytest.mark.unit
@pytest.mark.asyncio
async def test_quotaWindowResetHandler_isolates_single_failure(scheduler_module: Any) -> None:
now = utc_now_naive()
quota = _QuotaStub(
system_id=1,
env_key="default",
quota_key="api_calls",
quota_window="daily",
window_end=now - timedelta(hours=1),
)
repos = _make_repos(expiring_quotas=[quota])
repos.quota_usage.reset_window = AsyncMock(side_effect=RuntimeError("locked"))
scheduler_module.create_repositories = lambda _db: repos
handler = scheduler_module.QuotaWindowResetHandler(
session_factory=_FakeSessionFactory,
)
result = await handler.execute(_make_task_context())
assert result.success is True
assert result.output["reset_count"] == 0
assert result.output["failed_count"] == 1
assert len(result.output["errors"]) == 1
@pytest.mark.unit
@pytest.mark.asyncio
async def test_quotaWindowResetHandler_returns_failure_on_exception(scheduler_module: Any) -> None:
repos = _make_repos()
repos.quota_usage.list = AsyncMock(side_effect=RuntimeError("db down"))
scheduler_module.create_repositories = lambda _db: repos
handler = scheduler_module.QuotaWindowResetHandler(
session_factory=_FakeSessionFactory,
)
result = await handler.execute(_make_task_context())
assert result.success is False
assert "db down" in (result.error or "")
@pytest.mark.unit
def test_registerSchedulerHandlers_registers_quota_handlers() -> None:
module = _load_scheduler_module()
registry = HandlerRegistry()
module.register_scheduler_handlers(registry)
assert registry.get("external_systems.quota_threshold_alert") is not None
assert registry.get("external_systems.quota_window_reset") is not None
@pytest.mark.unit
def test_registerSchedulerHandlers_registers_secret_rotation_handler() -> None:
module = _load_scheduler_module()
registry = HandlerRegistry()
module.register_scheduler_handlers(registry)
handler = registry.get("external_systems.secret_rotation")
assert handler is not None
assert handler.name == "external_systems.secret_rotation"
assert handler.description == "检查到期密钥轮换策略并执行轮换/告警"
# ─── TrashPurgeHandler ────────────────────────────────────────────────────
def _patch_container_for_trash_purge(
monkeypatch: pytest.MonkeyPatch,
purge_output: Any,
) -> AsyncMock:
"""向 sys.modules 注入 fake containermock trash_service.purge。"""
fake_service = AsyncMock()
fake_service.purge = AsyncMock(return_value=purge_output)
fake_use_cases = MagicMock()
fake_use_cases.trash_service = fake_service
fake_container = types.ModuleType("yuxi.external_systems.infrastructure.container")
fake_container.create_use_cases_from_db = lambda _db: fake_use_cases
monkeypatch.setitem(
sys.modules,
"yuxi.external_systems.infrastructure.container",
fake_container,
)
return fake_service
@pytest.mark.unit
@pytest.mark.asyncio
async def test_trashPurgeHandler_executes_purge_with_default_days(
scheduler_module: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from yuxi.external_systems.use_cases.dto.trash import PurgeTrashOutput
fake_service = _patch_container_for_trash_purge(
monkeypatch,
PurgeTrashOutput(purged_count=3, by_type={"system": 2, "tool": 1}, cutoff_at=utc_now_naive()),
)
handler = scheduler_module.TrashPurgeHandler(
session_factory=_FakeSessionFactory,
default_older_than_days=90,
)
result = await handler.execute(_make_task_context())
assert isinstance(result, TaskResult)
assert result.success is True
assert result.output["purged_count"] == 3
assert result.output["by_type"] == {"system": 2, "tool": 1}
fake_service.purge.assert_awaited_once()
call = fake_service.purge.call_args
assert call.args[0].older_than_days == 90
assert call.args[0].purged_by == "scheduler"
@pytest.mark.unit
@pytest.mark.asyncio
async def test_trashPurgeHandler_uses_payload_older_than_days(
scheduler_module: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from yuxi.external_systems.use_cases.dto.trash import PurgeTrashOutput
fake_service = _patch_container_for_trash_purge(
monkeypatch,
PurgeTrashOutput(purged_count=0, by_type={}, cutoff_at=utc_now_naive()),
)
handler = scheduler_module.TrashPurgeHandler(
session_factory=_FakeSessionFactory,
default_older_than_days=90,
)
result = await handler.execute(_make_task_context({"older_than_days": 30}))
assert result.success is True
assert result.output["purged_count"] == 0
call = fake_service.purge.call_args
assert call.args[0].older_than_days == 30
@pytest.mark.unit
@pytest.mark.asyncio
async def test_trashPurgeHandler_returns_failure_on_exception(
scheduler_module: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = AsyncMock()
fake_service.purge = AsyncMock(side_effect=RuntimeError("db down"))
fake_use_cases = MagicMock()
fake_use_cases.trash_service = fake_service
fake_container = types.ModuleType("yuxi.external_systems.infrastructure.container")
fake_container.create_use_cases_from_db = lambda _db: fake_use_cases
monkeypatch.setitem(
sys.modules,
"yuxi.external_systems.infrastructure.container",
fake_container,
)
handler = scheduler_module.TrashPurgeHandler(session_factory=_FakeSessionFactory)
result = await handler.execute(_make_task_context())
assert result.success is False
assert "db down" in (result.error or "")
@pytest.mark.unit
def test_registerSchedulerHandlers_registers_trash_purge_handler() -> None:
module = _load_scheduler_module()
registry = HandlerRegistry()
module.register_scheduler_handlers(registry)
handler = registry.get("external_systems.trash_purge")
assert handler is not None
assert handler.name == "external_systems.trash_purge"
assert handler.description == "定时清理回收站中超过保留期的软删除资源"
# ─── TestRegressionSchedulerHandler ────────────────────────────────────────
def _patch_container_for_test_regression(
monkeypatch: pytest.MonkeyPatch,
regression_output: Any,
) -> AsyncMock:
"""向 sys.modules 注入 fake containermock test_regression_service.scheduled_regression。"""
fake_service = AsyncMock()
fake_service.scheduled_regression = AsyncMock(return_value=regression_output)
fake_use_cases = MagicMock()
fake_use_cases.test_regression_service = fake_service
fake_container = types.ModuleType("yuxi.external_systems.infrastructure.container")
fake_container.create_use_cases_from_db = lambda _db: fake_use_cases
monkeypatch.setitem(
sys.modules,
"yuxi.external_systems.infrastructure.container",
fake_container,
)
return fake_service
@pytest.mark.unit
@pytest.mark.asyncio
async def test_testRegressionSchedulerHandler_executes_scheduled_regression(
scheduler_module: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from yuxi.external_systems.use_cases.dto.test_case import ScheduledRegressionOutput
fake_service = _patch_container_for_test_regression(
monkeypatch,
ScheduledRegressionOutput(affected=3, succeeded=2, failed=1, errors=["e1"]),
)
handler = scheduler_module.TestRegressionSchedulerHandler(session_factory=_FakeSessionFactory)
result = await handler.execute(_make_task_context())
assert isinstance(result, TaskResult)
assert result.success is True
assert result.output["affected"] == 3
assert result.output["succeeded"] == 2
assert result.output["failed"] == 1
fake_service.scheduled_regression.assert_awaited_once()
@pytest.mark.unit
@pytest.mark.asyncio
async def test_testRegressionSchedulerHandler_returns_failure_on_exception(
scheduler_module: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = AsyncMock()
fake_service.scheduled_regression = AsyncMock(side_effect=RuntimeError("regression failed"))
fake_use_cases = MagicMock()
fake_use_cases.test_regression_service = fake_service
fake_container = types.ModuleType("yuxi.external_systems.infrastructure.container")
fake_container.create_use_cases_from_db = lambda _db: fake_use_cases
monkeypatch.setitem(
sys.modules,
"yuxi.external_systems.infrastructure.container",
fake_container,
)
handler = scheduler_module.TestRegressionSchedulerHandler(session_factory=_FakeSessionFactory)
result = await handler.execute(_make_task_context())
assert result.success is False
assert "regression failed" in (result.error or "")
@pytest.mark.unit
def test_registerSchedulerHandlers_registers_test_regression_handler() -> None:
module = _load_scheduler_module()
registry = HandlerRegistry()
module.register_scheduler_handlers(registry)
handler = registry.get("external_systems.test_regression")
assert handler is not None
assert handler.name == "external_systems.test_regression"
assert handler.description == "定时执行到期的外部系统工具测试用例回归"
# ─── _compute_next_window ──────────────────────────────────────────────────
def test_computeNextWindow_rolling_24h() -> None:
module = _load_scheduler_module()
now = utc_now_naive()
start, end = module._compute_next_window("rolling_24h", None, None, now)
assert start == now
assert end == now + timedelta(hours=24)
def test_computeNextWindow_daily() -> None:
module = _load_scheduler_module()
now = utc_now_naive()
last_end = now - timedelta(hours=1)
start, end = module._compute_next_window("daily", None, last_end, now)
assert start == last_end
assert end == last_end + timedelta(days=1)
def test_computeNextWindow_hourly() -> None:
module = _load_scheduler_module()
now = utc_now_naive()
last_end = now - timedelta(minutes=30)
start, end = module._compute_next_window("hourly", None, last_end, now)
assert start == last_end
assert end == last_end + timedelta(hours=1)
def test_computeNextWindow_minute() -> None:
module = _load_scheduler_module()
now = utc_now_naive()
last_end = now - timedelta(seconds=30)
start, end = module._compute_next_window("minute", None, last_end, now)
assert start == last_end
assert end == last_end + timedelta(minutes=1)
def test_computeNextWindow_custom_preserves_previous_range() -> None:
module = _load_scheduler_module()
now = utc_now_naive()
window_start = now - timedelta(hours=2)
window_end = now - timedelta(minutes=30)
start, end = module._compute_next_window("custom", window_start, window_end, now)
assert start == window_end
assert end == window_end + (window_end - window_start)
def test_computeNextWindow_custom_invalid_range_falls_back_to_one_day() -> None:
module = _load_scheduler_module()
now = utc_now_naive()
window_start = now
window_end = now - timedelta(hours=1) # end < start
start, end = module._compute_next_window("custom", window_start, window_end, now)
assert start == window_end
assert end == window_end + timedelta(days=1)
def test_computeNextWindow_custom_without_window_range_falls_back_to_one_day() -> None:
module = _load_scheduler_module()
now = utc_now_naive()
last_end = now - timedelta(hours=1)
start, end = module._compute_next_window("custom", None, last_end, now)
assert start == last_end
assert end == last_end + timedelta(days=1)
def test_computeNextWindow_uses_now_when_window_end_none() -> None:
module = _load_scheduler_module()
now = utc_now_naive()
start, end = module._compute_next_window("daily", None, None, now)
assert start == now
assert end == now + timedelta(days=1)
# ─── WebhookRenewalHandler ─────────────────────────────────────────────────
class _SubscriptionStub:
"""WebhookSubscription 存根,用于 renewal handler 测试。"""
def __init__(
self,
subscription_id: int | None = 1,
slug: str = "sub-1",
retention_days: int = 30,
) -> None:
self.id = subscription_id
self.slug = slug
self.retention_days = retention_days
@pytest.mark.unit
@pytest.mark.asyncio
async def test_webhookRenewalHandler_renews_expiring_subscriptions(
scheduler_module: Any,
) -> None:
subscriptions = [_SubscriptionStub(subscription_id=1, retention_days=30)]
repos = _make_repos(expiring_subscriptions=subscriptions)
scheduler_module.create_repositories = lambda _db: repos
handler = scheduler_module.WebhookRenewalHandler(session_factory=_FakeSessionFactory)
result = await handler.execute(_make_task_context())
assert isinstance(result, TaskResult)
assert result.success is True
assert result.output["renewed_count"] == 1
assert result.output["failed_count"] == 0
assert result.output["threshold_days"] == 7
repos.webhook_subscription.list_expiring.assert_called_once()
before = repos.webhook_subscription.list_expiring.call_args.kwargs["before"]
assert before > utc_now_naive()
repos.webhook_subscription.update_renewal_result.assert_called_once()
call = repos.webhook_subscription.update_renewal_result.call_args
assert call.args[0] == 1
assert call.kwargs["success"] is True
assert call.kwargs["new_expires_at"] is not None
@pytest.mark.unit
@pytest.mark.asyncio
async def test_webhookRenewalHandler_uses_payload_threshold_days(
scheduler_module: Any,
) -> None:
subscriptions = [_SubscriptionStub(subscription_id=1)]
repos = _make_repos(expiring_subscriptions=subscriptions)
scheduler_module.create_repositories = lambda _db: repos
handler = scheduler_module.WebhookRenewalHandler(session_factory=_FakeSessionFactory)
result = await handler.execute(_make_task_context({"threshold_days": 14}))
assert result.success is True
assert result.output["threshold_days"] == 14
before = repos.webhook_subscription.list_expiring.call_args.kwargs["before"]
assert before > utc_now_naive() + timedelta(days=13)
assert before < utc_now_naive() + timedelta(days=15)
@pytest.mark.unit
@pytest.mark.asyncio
async def test_webhookRenewalHandler_skips_subscription_without_id(
scheduler_module: Any,
) -> None:
subscriptions = [
_SubscriptionStub(subscription_id=None),
_SubscriptionStub(subscription_id=2),
]
repos = _make_repos(expiring_subscriptions=subscriptions)
scheduler_module.create_repositories = lambda _db: repos
handler = scheduler_module.WebhookRenewalHandler(session_factory=_FakeSessionFactory)
result = await handler.execute(_make_task_context())
assert result.success is True
assert result.output["renewed_count"] == 1
assert result.output["failed_count"] == 1
assert repos.webhook_subscription.update_renewal_result.await_count == 1
@pytest.mark.unit
@pytest.mark.asyncio
async def test_webhookRenewalHandler_isolates_single_failure(
scheduler_module: Any,
) -> None:
subscriptions = [
_SubscriptionStub(subscription_id=1),
_SubscriptionStub(subscription_id=2),
]
repos = _make_repos(expiring_subscriptions=subscriptions)
repos.webhook_subscription.update_renewal_result = AsyncMock(
side_effect=[RuntimeError("locked"), None],
)
scheduler_module.create_repositories = lambda _db: repos
handler = scheduler_module.WebhookRenewalHandler(session_factory=_FakeSessionFactory)
result = await handler.execute(_make_task_context())
assert result.success is True
assert result.output["renewed_count"] == 1
assert result.output["failed_count"] == 1
@pytest.mark.unit
@pytest.mark.asyncio
async def test_webhookRenewalHandler_returns_failure_on_exception(
scheduler_module: Any,
) -> None:
repos = _make_repos()
repos.webhook_subscription.list_expiring = AsyncMock(side_effect=RuntimeError("db down"))
scheduler_module.create_repositories = lambda _db: repos
handler = scheduler_module.WebhookRenewalHandler(session_factory=_FakeSessionFactory)
result = await handler.execute(_make_task_context())
assert result.success is False
assert "db down" in (result.error or "")
@pytest.mark.unit
def test_registerSchedulerHandlers_registers_webhook_renewal_handler() -> None:
module = _load_scheduler_module()
registry = HandlerRegistry()
module.register_scheduler_handlers(registry)
handler = registry.get("external_systems.webhook_renewal")
assert handler is not None
assert handler.name == "external_systems.webhook_renewal"
assert handler.description == "自动续期即将过期的 Webhook 订阅"
# ─── AuditLogRetentionHandler ──────────────────────────────────────────────
@pytest.mark.unit
@pytest.mark.asyncio
async def test_auditLogRetentionHandler_uses_default_retention_days(
scheduler_module: Any,
) -> None:
repos = _make_repos(audit_log_deleted_count=7)
scheduler_module.create_repositories = lambda _db: repos
handler = scheduler_module.AuditLogRetentionHandler(
session_factory=_FakeSessionFactory,
default_retention_days=90,
)
result = await handler.execute(_make_task_context())
assert isinstance(result, TaskResult)
assert result.success is True
assert result.output["deleted_count"] == 7
assert result.output["retention_days"] == 90
repos.audit_log.delete_old_logs.assert_called_once()
before = repos.audit_log.delete_old_logs.call_args.args[0]
assert before < utc_now_naive()
assert before > utc_now_naive() - timedelta(days=91)
assert repos.audit_log.delete_old_logs.call_args.kwargs["updated_by"] == "scheduler"
@pytest.mark.unit
@pytest.mark.asyncio
async def test_auditLogRetentionHandler_uses_payload_retention_days(
scheduler_module: Any,
) -> None:
repos = _make_repos(audit_log_deleted_count=0)
scheduler_module.create_repositories = lambda _db: repos
handler = scheduler_module.AuditLogRetentionHandler(
session_factory=_FakeSessionFactory,
default_retention_days=90,
)
result = await handler.execute(_make_task_context({"retention_days": 30}))
assert result.success is True
assert result.output["retention_days"] == 30
@pytest.mark.unit
@pytest.mark.asyncio
async def test_auditLogRetentionHandler_returns_failure_on_exception(
scheduler_module: Any,
) -> None:
repos = _make_repos()
repos.audit_log.delete_old_logs = AsyncMock(side_effect=RuntimeError("db down"))
scheduler_module.create_repositories = lambda _db: repos
handler = scheduler_module.AuditLogRetentionHandler(session_factory=_FakeSessionFactory)
result = await handler.execute(_make_task_context())
assert result.success is False
assert "db down" in (result.error or "")
@pytest.mark.unit
def test_registerSchedulerHandlers_registers_audit_log_retention_handler() -> None:
module = _load_scheduler_module()
registry = HandlerRegistry()
module.register_scheduler_handlers(registry)
handler = registry.get("external_systems.audit_log_retention")
assert handler is not None
assert handler.name == "external_systems.audit_log_retention"
assert handler.description == "清理超过保留期的外部系统审计日志"
# ─── HealthCheckSchedulerHandler ───────────────────────────────────────────
def _patch_container_for_health_check(
monkeypatch: pytest.MonkeyPatch,
) -> AsyncMock:
"""向 sys.modules 注入 fake containermock tool_service.trigger_system_health_check。"""
fake_service = AsyncMock()
fake_service.trigger_system_health_check = AsyncMock()
fake_use_cases = MagicMock()
fake_use_cases.tool_service = fake_service
fake_container = types.ModuleType("yuxi.external_systems.infrastructure.container")
fake_container.create_use_cases_from_db = lambda _db: fake_use_cases
monkeypatch.setitem(
sys.modules,
"yuxi.external_systems.infrastructure.container",
fake_container,
)
return fake_service
@pytest.mark.unit
@pytest.mark.asyncio
async def test_healthCheckSchedulerHandler_checks_systems(
scheduler_module: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
from yuxi.external_systems.use_cases.dto.health_check import (
TriggerSystemHealthCheckInput,
)
repos = _make_repos(system_ids=[1, 2, 3])
scheduler_module.create_repositories = lambda _db: repos
fake_service = _patch_container_for_health_check(monkeypatch)
handler = scheduler_module.HealthCheckSchedulerHandler(
session_factory=_FakeSessionFactory,
default_batch_size=50,
)
result = await handler.execute(_make_task_context())
assert isinstance(result, TaskResult)
assert result.success is True
assert result.output["checked_count"] == 3
assert result.output["failed_count"] == 0
assert result.output["batch_size"] == 50
assert fake_service.trigger_system_health_check.await_count == 3
call = fake_service.trigger_system_health_check.call_args
assert isinstance(call.args[0], TriggerSystemHealthCheckInput)
assert call.args[0].triggered_by == "scheduler"
repos.system.list_ids.assert_called_once_with(
enabled=True,
sort_by="created_at",
sort_order="asc",
)
@pytest.mark.unit
@pytest.mark.asyncio
async def test_healthCheckSchedulerHandler_uses_payload_batch_size(
scheduler_module: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repos = _make_repos(system_ids=[1, 2, 3, 4])
scheduler_module.create_repositories = lambda _db: repos
fake_service = _patch_container_for_health_check(monkeypatch)
handler = scheduler_module.HealthCheckSchedulerHandler(
session_factory=_FakeSessionFactory,
default_batch_size=50,
)
result = await handler.execute(_make_task_context({"batch_size": 2}))
assert result.success is True
assert result.output["batch_size"] == 2
assert result.output["checked_count"] == 2
assert fake_service.trigger_system_health_check.await_count == 2
@pytest.mark.unit
@pytest.mark.asyncio
async def test_healthCheckSchedulerHandler_isolates_single_failure(
scheduler_module: Any,
monkeypatch: pytest.MonkeyPatch,
) -> None:
repos = _make_repos(system_ids=[1, 2])
scheduler_module.create_repositories = lambda _db: repos
fake_service = _patch_container_for_health_check(monkeypatch)
fake_service.trigger_system_health_check = AsyncMock(
side_effect=[RuntimeError("probe failed"), None],
)
handler = scheduler_module.HealthCheckSchedulerHandler(
session_factory=_FakeSessionFactory,
)
result = await handler.execute(_make_task_context())
assert result.success is True
assert result.output["checked_count"] == 1
assert result.output["failed_count"] == 1
assert len(result.output["errors"]) == 1
assert result.output["errors"][0]["system_id"] == 1
@pytest.mark.unit
@pytest.mark.asyncio
async def test_healthCheckSchedulerHandler_returns_failure_on_exception(
scheduler_module: Any,
) -> None:
repos = _make_repos()
repos.system.list_ids = AsyncMock(side_effect=RuntimeError("db down"))
scheduler_module.create_repositories = lambda _db: repos
handler = scheduler_module.HealthCheckSchedulerHandler(
session_factory=_FakeSessionFactory,
)
result = await handler.execute(_make_task_context())
assert result.success is False
assert "db down" in (result.error or "")
@pytest.mark.unit
def test_registerSchedulerHandlers_registers_health_check_handler() -> None:
module = _load_scheduler_module()
registry = HandlerRegistry()
module.register_scheduler_handlers(registry)
handler = registry.get("external_systems.health_check")
assert handler is not None
assert handler.name == "external_systems.health_check"
assert handler.description == "定时健康检查探测"
# ─── register_scheduler_handlers 完整性 ────────────────────────────────────
@pytest.mark.unit
def test_registerSchedulerHandlers_registers_all_handlers() -> None:
"""验证 register_scheduler_handlers 注册了 external_systems 全部 11 个 handler。"""
module = _load_scheduler_module()
registry = HandlerRegistry()
module.register_scheduler_handlers(registry)
expected = {
"external_systems.webhook_event_cleanup",
"external_systems.webhook_pending_consumer",
"external_systems.webhook_processing_recovery",
"external_systems.webhook_renewal",
"external_systems.quota_threshold_alert",
"external_systems.quota_window_reset",
"external_systems.secret_rotation",
"external_systems.audit_log_retention",
"external_systems.trash_purge",
"external_systems.health_check",
"external_systems.test_regression",
}
assert set(registry.list_names()) == expected