- 新增多个集成服务(Slack、Notion、Twilio、Microsoft365等)的元数据、常量、凭证、错误处理相关单元测试 - 新增持久化适配器、工作单元、健康检查仓储的单元测试 - 修复认证插件测试中缺失的凭证类型注册 - 修正密钥轮换调度器的异常类型匹配 - 完善审计日志测试用例,新增创建者/更新者字段校验 - 新增告警触发时持久化通知渠道的测试 - 精简核心模型测试代码,移除冗余的超时测试用例
499 lines
16 KiB
Python
499 lines
16 KiB
Python
"""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 == "检查到期密钥轮换策略并执行轮换/告警"
|