"""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, ) -> MagicMock: repos = MagicMock() repos.webhook_event = AsyncMock() repos.webhook_event.delete_old_events = AsyncMock(return_value=deleted_count) 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.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 事件" # ─── 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 == "检查到期密钥轮换策略并执行轮换/告警"