ForcePilot/backend/test/unit/external_systems/infrastructure/test_scheduler.py

499 lines
16 KiB
Python
Raw Normal View History

"""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 == "检查到期密钥轮换策略并执行轮换/告警"