ForcePilot/backend/test/unit/external_systems/infrastructure/test_scheduler.py
Kris 15c1cd95ba test: 新增并完善外部系统单元测试覆盖
- 新增多个集成服务(Slack、Notion、Twilio、Microsoft365等)的元数据、常量、凭证、错误处理相关单元测试
- 新增持久化适配器、工作单元、健康检查仓储的单元测试
- 修复认证插件测试中缺失的凭证类型注册
- 修正密钥轮换调度器的异常类型匹配
- 完善审计日志测试用例,新增创建者/更新者字段校验
- 新增告警触发时持久化通知渠道的测试
- 精简核心模型测试代码,移除冗余的超时测试用例
2026-07-11 05:42:04 +08:00

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