这一批提交包含: 1. 配置项敏感字段标记与测试用例修复 2. 路由绑定乐观锁支持与静态路径校验 3. 微信WOC插件能力适配与新增单元测试 4. 多个适配器的接口对齐与测试补全 5. 新增定时任务清理处理器与依赖注入容器测试 6. 错误码体系扩展与整合测试 7. 删除临时验证脚本与代码清理
927 lines
35 KiB
Python
927 lines
35 KiB
Python
"""yuxi.channels.infrastructure.host_shutdown 单元测试。
|
||
|
||
覆盖 ``HostShutdown`` 关停编排行为:
|
||
- ``shutdown`` 端到端:7 步顺序执行、每步不阻塞后续
|
||
- ``_markDraining``:正常标记 / 超时翻译为 ``OperationTimeoutError`` 告警 / 异常告警
|
||
- ``_waitForInflightRequests``:有回调 / 无回调 / 超时 / 异常
|
||
- ``_teardownPipelines``:清理 ``StageSlotRegistry`` / 超时 / 异常
|
||
- ``_stopTransportManager``:成功 / 超时 / 异常
|
||
- ``_stopPlugins``:空列表提前返回 / 反向拓扑 / 解析失败回退 / PAUSED 也停止
|
||
- ``_stopSinglePlugin``:成功 / failed 结果 / 超时 / 异常
|
||
- ``_closeDrivenAdapters``:应用级 ``CloseablePort`` / 非 ``CloseablePort`` 跳过 /
|
||
应用级异常继续 / 插件级异常继续 / 空列表
|
||
- ``_releaseCoreResources``:取消后台任务(None / done / cancelled / 异常)
|
||
- ``_cancelBackgroundTask``:None / done / 正常取消 / ``CancelledError`` / 其他异常
|
||
|
||
不依赖运行中的 Docker 服务,纯单元测试。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from unittest.mock import AsyncMock, MagicMock
|
||
|
||
import pytest
|
||
from yuxi.channels.application.extension.config_source_registry import (
|
||
ConfigSourceRegistry,
|
||
)
|
||
from yuxi.channels.application.extension.event_bus import EventBus
|
||
from yuxi.channels.application.extension.event_subscription_registry import (
|
||
EventSubscriptionRegistry,
|
||
)
|
||
from yuxi.channels.application.extension.stage_slot_registry import StageSlotRegistry
|
||
from yuxi.channels.application.lifecycle.plugin_dependency_resolver import (
|
||
PluginDependencyResolver,
|
||
)
|
||
from yuxi.channels.application.lifecycle.plugin_lifecycle_manager import (
|
||
PluginLifecycleManager,
|
||
)
|
||
from yuxi.channels.application.pipeline.stage_slot_injector import StageSlotInjector
|
||
from yuxi.channels.application.transport import TransportManager
|
||
from yuxi.channels.contract.dtos.lifecycle import LifecycleResult
|
||
from yuxi.channels.contract.plugin.lifecycle import LifecycleState
|
||
from yuxi.channels.contract.ports.driven.closeable_port import CloseablePort
|
||
from yuxi.channels.core.registry.plugin_registry import PluginRegistry
|
||
from yuxi.channels.infrastructure.host_bootstrap import HostBootstrap
|
||
from yuxi.channels.infrastructure.host_shutdown import HostShutdown
|
||
|
||
pytestmark = pytest.mark.unit
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 测试辅助:构造 HostShutdown 与桩依赖
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _make_manifest(plugin_id: str = "test-plugin"):
|
||
"""构造测试用 manifest 桩(仅需 ``id`` 属性)。
|
||
|
||
使用 ``MagicMock`` 替代真实 ``ChannelManifest``,避免构造 ``provides`` /
|
||
``capabilities`` / ``config_schema`` 等大量必填字段。测试仅需访问
|
||
``manifest.id`` 属性。
|
||
"""
|
||
manifest = MagicMock()
|
||
manifest.id = plugin_id
|
||
return manifest
|
||
|
||
|
||
def _make_shutdown(
|
||
fake_logger,
|
||
*,
|
||
plugin_lifecycle: PluginLifecycleManager | None = None,
|
||
plugin_registry: PluginRegistry | None = None,
|
||
resolver: PluginDependencyResolver | None = None,
|
||
stage_slot_injector: StageSlotInjector | None = None,
|
||
stage_slot_registry: StageSlotRegistry | None = None,
|
||
event_subscription_registry: EventSubscriptionRegistry | None = None,
|
||
config_source_registry: ConfigSourceRegistry | None = None,
|
||
event_bus: EventBus | None = None,
|
||
transport_manager: TransportManager | None = None,
|
||
host_bootstrap: HostBootstrap | None = None,
|
||
inflight_drain_waiter=None,
|
||
app_driven_adapters=None,
|
||
) -> HostShutdown:
|
||
"""构造测试用 HostShutdown,所有依赖默认为 MagicMock。"""
|
||
return HostShutdown(
|
||
plugin_lifecycle_manager=plugin_lifecycle or MagicMock(spec=PluginLifecycleManager),
|
||
plugin_registry=plugin_registry or MagicMock(spec=PluginRegistry),
|
||
plugin_dependency_resolver=resolver or MagicMock(spec=PluginDependencyResolver),
|
||
stage_slot_injector=stage_slot_injector or MagicMock(spec=StageSlotInjector),
|
||
stage_slot_registry=stage_slot_registry or MagicMock(spec=StageSlotRegistry),
|
||
event_subscription_registry=event_subscription_registry or MagicMock(spec=EventSubscriptionRegistry),
|
||
config_source_registry=config_source_registry or MagicMock(spec=ConfigSourceRegistry),
|
||
event_bus=event_bus or MagicMock(spec=EventBus),
|
||
transport_manager=transport_manager or MagicMock(spec=TransportManager),
|
||
logger=fake_logger,
|
||
host_bootstrap=host_bootstrap or MagicMock(spec=HostBootstrap),
|
||
inflight_drain_waiter=inflight_drain_waiter,
|
||
app_driven_adapters=app_driven_adapters,
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _markDraining
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestMarkDraining:
|
||
"""_markDraining 标记 draining 状态测试。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_sets_draining_flag_and_logs_info(self, fake_logger):
|
||
# Arrange
|
||
shutdown = _make_shutdown(fake_logger)
|
||
|
||
# Act
|
||
await shutdown._markDraining()
|
||
|
||
# Assert
|
||
assert shutdown._draining is True
|
||
fake_logger.info.assert_any_call("宿主已标记为 draining,停止接受新请求")
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_sets_draining_flag_via_shutdown(self, fake_logger):
|
||
"""shutdown() 调用后 _draining 应被置为 True。"""
|
||
# Arrange
|
||
shutdown = _make_shutdown(fake_logger)
|
||
|
||
# Act
|
||
await shutdown.shutdown()
|
||
|
||
# Assert
|
||
assert shutdown._draining is True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _waitForInflightRequests
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestWaitForInflightRequests:
|
||
"""_waitForInflightRequests 在途请求等待测试。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_no_waiter_logs_info_and_continues(self, fake_logger):
|
||
# Arrange: inflight_drain_waiter=None
|
||
shutdown = _make_shutdown(fake_logger, inflight_drain_waiter=None)
|
||
|
||
# Act
|
||
await shutdown._waitForInflightRequests(timeout=30.0)
|
||
|
||
# Assert: 记录 info 日志后直接继续
|
||
fake_logger.info.assert_any_call(
|
||
"无在途请求排空回调,直接继续: timeout=30.0s",
|
||
)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_calls_waiter_with_timeout(self, fake_logger):
|
||
# Arrange
|
||
waiter = AsyncMock()
|
||
shutdown = _make_shutdown(fake_logger, inflight_drain_waiter=waiter)
|
||
|
||
# Act
|
||
await shutdown._waitForInflightRequests(timeout=15.0)
|
||
|
||
# Assert: waiter 被调用并传入超时参数
|
||
waiter.assert_awaited_once_with(15.0)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_waiter_timeout_logs_warn(self, fake_logger):
|
||
"""waiter 抛 TimeoutError 时翻译为 OperationTimeoutError 告警。"""
|
||
# Arrange
|
||
waiter = AsyncMock(side_effect=TimeoutError())
|
||
shutdown = _make_shutdown(fake_logger, inflight_drain_waiter=waiter)
|
||
|
||
# Act: 不抛异常即证明超时被吞并记录告警
|
||
await shutdown._waitForInflightRequests(timeout=5.0)
|
||
|
||
# Assert: warn 被调用(_handleStepTimeout 内部调用 warn)
|
||
fake_logger.warn.assert_called()
|
||
warn_call = fake_logger.warn.call_args
|
||
# 验证告警携带超时信息
|
||
assert "等待在途请求" in warn_call.args[0]
|
||
assert warn_call.kwargs.get("timeout_ms") == 5000
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_waiter_exception_logs_warn_and_continues(self, fake_logger):
|
||
"""waiter 抛非 TimeoutError 异常时记录告警并继续。"""
|
||
# Arrange
|
||
waiter = AsyncMock(side_effect=RuntimeError("drain boom"))
|
||
shutdown = _make_shutdown(fake_logger, inflight_drain_waiter=waiter)
|
||
|
||
# Act: 不抛异常即证明异常被吞并
|
||
await shutdown._waitForInflightRequests(timeout=5.0)
|
||
|
||
# Assert
|
||
fake_logger.warn.assert_called()
|
||
assert "等待在途请求异常" in fake_logger.warn.call_args.args[0]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _teardownPipelines
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestTeardownPipelines:
|
||
"""_teardownPipelines 管道拆除测试。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_clears_stage_slot_registry(self, fake_logger):
|
||
# Arrange
|
||
stage_slot_registry = MagicMock(spec=StageSlotRegistry)
|
||
stage_slot_registry.findByPipeline = MagicMock(return_value=[])
|
||
shutdown = _make_shutdown(fake_logger, stage_slot_registry=stage_slot_registry)
|
||
|
||
# Act
|
||
await shutdown._teardownPipelines()
|
||
|
||
# Assert: clear 被调用,清理所有阶段槽位注册
|
||
stage_slot_registry.clear.assert_called_once()
|
||
# 三个管道的槽位都被查询(inbound / outbound / control)
|
||
assert stage_slot_registry.findByPipeline.call_count == 3
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_logs_slot_counts(self, fake_logger):
|
||
"""拆除时记录各管道槽位数量。"""
|
||
# Arrange
|
||
stage_slot_registry = MagicMock(spec=StageSlotRegistry)
|
||
# 模拟 inbound 有 2 个槽位,outbound 1 个,control 0 个
|
||
stage_slot_registry.findByPipeline = MagicMock(
|
||
side_effect=[["slot1", "slot2"], ["slot3"], []]
|
||
)
|
||
shutdown = _make_shutdown(fake_logger, stage_slot_registry=stage_slot_registry)
|
||
|
||
# Act
|
||
await shutdown._teardownPipelines()
|
||
|
||
# Assert: info 日志携带槽位数量
|
||
info_calls = fake_logger.info.call_args_list
|
||
teardown_call = next(
|
||
c for c in info_calls if "管道已拆除" in c.args[0]
|
||
)
|
||
assert teardown_call.kwargs.get("inbound_slot_count") == 2
|
||
assert teardown_call.kwargs.get("outbound_slot_count") == 1
|
||
assert teardown_call.kwargs.get("control_slot_count") == 0
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_exception_logs_warn_and_continues(self, fake_logger):
|
||
"""clear 抛异常时记录告警并继续(不中断 shutdown)。"""
|
||
# Arrange
|
||
stage_slot_registry = MagicMock(spec=StageSlotRegistry)
|
||
stage_slot_registry.findByPipeline = MagicMock(
|
||
side_effect=RuntimeError("registry corrupted")
|
||
)
|
||
shutdown = _make_shutdown(fake_logger, stage_slot_registry=stage_slot_registry)
|
||
|
||
# Act: 不抛异常即证明异常被吞并
|
||
await shutdown._teardownPipelines()
|
||
|
||
# Assert
|
||
fake_logger.warn.assert_called()
|
||
assert "拆除管道异常" in fake_logger.warn.call_args.args[0]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _stopTransportManager
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestStopTransportManager:
|
||
"""_stopTransportManager 传输引擎管理器停止测试。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_calls_transport_manager_stop(self, fake_logger):
|
||
# Arrange
|
||
transport_manager = MagicMock(spec=TransportManager)
|
||
transport_manager.stop = AsyncMock()
|
||
shutdown = _make_shutdown(fake_logger, transport_manager=transport_manager)
|
||
|
||
# Act
|
||
await shutdown._stopTransportManager()
|
||
|
||
# Assert
|
||
transport_manager.stop.assert_awaited_once()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_stop_exception_logs_warn_and_continues(self, fake_logger):
|
||
# Arrange
|
||
transport_manager = MagicMock(spec=TransportManager)
|
||
transport_manager.stop = AsyncMock(side_effect=RuntimeError("worker dead"))
|
||
shutdown = _make_shutdown(fake_logger, transport_manager=transport_manager)
|
||
|
||
# Act: 不抛异常即证明异常被吞并
|
||
await shutdown._stopTransportManager()
|
||
|
||
# Assert
|
||
fake_logger.warn.assert_called()
|
||
assert "停止传输引擎管理器异常" in fake_logger.warn.call_args.args[0]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _stopPlugins / _stopSinglePlugin
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestStopPlugins:
|
||
"""_stopPlugins 插件停止测试。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_no_plugins_logs_info_and_returns(self, fake_logger):
|
||
"""无 STARTED/PAUSED 插件时提前返回。"""
|
||
# Arrange
|
||
plugin_registry = MagicMock(spec=PluginRegistry)
|
||
plugin_registry.listPluginsByState = MagicMock(return_value=[])
|
||
shutdown = _make_shutdown(fake_logger, plugin_registry=plugin_registry)
|
||
|
||
# Act
|
||
await shutdown._stopPlugins()
|
||
|
||
# Assert
|
||
fake_logger.info.assert_any_call("无 STARTED/PAUSED 状态插件可停止")
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_stops_plugins_in_reverse_topology(self, fake_logger):
|
||
"""按反向依赖拓扑序停止插件。"""
|
||
# Arrange
|
||
manifest_a = _make_manifest("plugin-a")
|
||
manifest_b = _make_manifest("plugin-b")
|
||
|
||
plugin_manifest_a = MagicMock()
|
||
plugin_manifest_a.manifest = manifest_a
|
||
plugin_manifest_b = MagicMock()
|
||
plugin_manifest_b.manifest = manifest_b
|
||
|
||
plugin_registry = MagicMock(spec=PluginRegistry)
|
||
# STARTED 返回 [a, b],PAUSED 返回 []
|
||
plugin_registry.listPluginsByState = MagicMock(
|
||
side_effect=lambda state: [plugin_manifest_a, plugin_manifest_b]
|
||
if state == LifecycleState.STARTED
|
||
else []
|
||
)
|
||
|
||
# resolver 返回 [a, b](a 先,b 后),反向后为 [b, a]
|
||
resolver = MagicMock(spec=PluginDependencyResolver)
|
||
resolver.resolve = MagicMock(return_value=[manifest_a, manifest_b])
|
||
|
||
plugin_lifecycle = MagicMock(spec=PluginLifecycleManager)
|
||
plugin_lifecycle.stop = AsyncMock(
|
||
return_value=LifecycleResult(state="stopped")
|
||
)
|
||
|
||
shutdown = _make_shutdown(
|
||
fake_logger,
|
||
plugin_registry=plugin_registry,
|
||
resolver=resolver,
|
||
plugin_lifecycle=plugin_lifecycle,
|
||
)
|
||
|
||
# Act
|
||
await shutdown._stopPlugins()
|
||
|
||
# Assert: 按反向序停止,b 先于 a
|
||
stop_calls = plugin_lifecycle.stop.await_args_list
|
||
assert stop_calls[0].args == ("plugin-b",)
|
||
assert stop_calls[1].args == ("plugin-a",)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_resolver_failure_falls_back_to_reversed_list(self, fake_logger):
|
||
"""依赖解析失败时回退为列表反转序。"""
|
||
# Arrange
|
||
manifest_a = _make_manifest("plugin-a")
|
||
manifest_b = _make_manifest("plugin-b")
|
||
|
||
plugin_manifest_a = MagicMock()
|
||
plugin_manifest_a.manifest = manifest_a
|
||
plugin_manifest_b = MagicMock()
|
||
plugin_manifest_b.manifest = manifest_b
|
||
|
||
plugin_registry = MagicMock(spec=PluginRegistry)
|
||
plugin_registry.listPluginsByState = MagicMock(
|
||
side_effect=lambda state: [plugin_manifest_a, plugin_manifest_b]
|
||
if state == LifecycleState.STARTED
|
||
else []
|
||
)
|
||
|
||
# resolver.resolve 抛异常
|
||
resolver = MagicMock(spec=PluginDependencyResolver)
|
||
resolver.resolve = MagicMock(side_effect=RuntimeError("cycle detected"))
|
||
|
||
plugin_lifecycle = MagicMock(spec=PluginLifecycleManager)
|
||
plugin_lifecycle.stop = AsyncMock(
|
||
return_value=LifecycleResult(state="stopped")
|
||
)
|
||
|
||
shutdown = _make_shutdown(
|
||
fake_logger,
|
||
plugin_registry=plugin_registry,
|
||
resolver=resolver,
|
||
plugin_lifecycle=plugin_lifecycle,
|
||
)
|
||
|
||
# Act: 不抛异常即证明回退路径生效
|
||
await shutdown._stopPlugins()
|
||
|
||
# Assert: 记录告警 + 仍停止所有插件(反向序 [b, a])
|
||
fake_logger.warn.assert_called()
|
||
stop_calls = plugin_lifecycle.stop.await_args_list
|
||
assert len(stop_calls) == 2
|
||
assert stop_calls[0].args == ("plugin-b",)
|
||
assert stop_calls[1].args == ("plugin-a",)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_paused_plugins_also_stopped(self, fake_logger):
|
||
"""PAUSED 状态插件同样调用 stop 释放资源。"""
|
||
# Arrange
|
||
manifest_paused = _make_manifest("paused-plugin")
|
||
plugin_manifest_paused = MagicMock()
|
||
plugin_manifest_paused.manifest = manifest_paused
|
||
|
||
plugin_registry = MagicMock(spec=PluginRegistry)
|
||
# STARTED 返回 [],PAUSED 返回 [paused-plugin]
|
||
plugin_registry.listPluginsByState = MagicMock(
|
||
side_effect=lambda state: [plugin_manifest_paused]
|
||
if state == LifecycleState.PAUSED
|
||
else []
|
||
)
|
||
|
||
resolver = MagicMock(spec=PluginDependencyResolver)
|
||
resolver.resolve = MagicMock(return_value=[manifest_paused])
|
||
|
||
plugin_lifecycle = MagicMock(spec=PluginLifecycleManager)
|
||
plugin_lifecycle.stop = AsyncMock(
|
||
return_value=LifecycleResult(state="stopped")
|
||
)
|
||
|
||
shutdown = _make_shutdown(
|
||
fake_logger,
|
||
plugin_registry=plugin_registry,
|
||
resolver=resolver,
|
||
plugin_lifecycle=plugin_lifecycle,
|
||
)
|
||
|
||
# Act
|
||
await shutdown._stopPlugins()
|
||
|
||
# Assert: PAUSED 插件被停止
|
||
plugin_lifecycle.stop.assert_awaited_once_with("paused-plugin")
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestStopSinglePlugin:
|
||
"""_stopSinglePlugin 单个插件停止测试。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_successful_stop_logs_info(self, fake_logger):
|
||
# Arrange
|
||
manifest = _make_manifest("ok-plugin")
|
||
plugin_lifecycle = MagicMock(spec=PluginLifecycleManager)
|
||
plugin_lifecycle.stop = AsyncMock(
|
||
return_value=LifecycleResult(state="stopped")
|
||
)
|
||
shutdown = _make_shutdown(fake_logger, plugin_lifecycle=plugin_lifecycle)
|
||
|
||
# Act
|
||
await shutdown._stopSinglePlugin(manifest)
|
||
|
||
# Assert
|
||
plugin_lifecycle.stop.assert_awaited_once_with("ok-plugin")
|
||
fake_logger.info.assert_any_call(
|
||
"插件已停止: plugin_id=ok-plugin",
|
||
plugin_id="ok-plugin",
|
||
)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_failed_result_logs_warn(self, fake_logger):
|
||
"""stop 返回 state=failed 时记录告警。"""
|
||
# Arrange
|
||
manifest = _make_manifest("bad-plugin")
|
||
plugin_lifecycle = MagicMock(spec=PluginLifecycleManager)
|
||
plugin_lifecycle.stop = AsyncMock(
|
||
return_value=LifecycleResult(
|
||
state="failed", error="cleanup failed"
|
||
)
|
||
)
|
||
shutdown = _make_shutdown(fake_logger, plugin_lifecycle=plugin_lifecycle)
|
||
|
||
# Act
|
||
await shutdown._stopSinglePlugin(manifest)
|
||
|
||
# Assert
|
||
fake_logger.warn.assert_called()
|
||
warn_call = fake_logger.warn.call_args
|
||
assert "bad-plugin" in warn_call.args[0]
|
||
assert warn_call.kwargs.get("error") == "cleanup failed"
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_stop_exception_logs_warn_and_returns(self, fake_logger):
|
||
"""stop 抛异常时记录告警并返回(不阻塞后续插件)。"""
|
||
# Arrange
|
||
manifest = _make_manifest("crash-plugin")
|
||
plugin_lifecycle = MagicMock(spec=PluginLifecycleManager)
|
||
plugin_lifecycle.stop = AsyncMock(side_effect=RuntimeError("stop crash"))
|
||
shutdown = _make_shutdown(fake_logger, plugin_lifecycle=plugin_lifecycle)
|
||
|
||
# Act: 不抛异常即证明异常被吞并
|
||
await shutdown._stopSinglePlugin(manifest)
|
||
|
||
# Assert
|
||
fake_logger.warn.assert_called()
|
||
assert "crash-plugin" in fake_logger.warn.call_args.args[0]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _closeDrivenAdapters / _closeAllDrivenAdapters
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestCloseDrivenAdapters:
|
||
"""_closeDrivenAdapters / _closeAllDrivenAdapters 适配器关闭测试。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_closes_app_level_closeable_port(self, fake_logger):
|
||
"""应用级适配器实现 CloseablePort 时调用 aclose。"""
|
||
# Arrange
|
||
app_adapter = MagicMock(spec=CloseablePort)
|
||
app_adapter.aclose = AsyncMock()
|
||
plugin_registry = MagicMock(spec=PluginRegistry)
|
||
plugin_registry.listPluginAdapters = MagicMock(return_value=[])
|
||
shutdown = _make_shutdown(
|
||
fake_logger,
|
||
plugin_registry=plugin_registry,
|
||
app_driven_adapters=[app_adapter],
|
||
)
|
||
|
||
# Act
|
||
await shutdown._closeAllDrivenAdapters()
|
||
|
||
# Assert
|
||
app_adapter.aclose.assert_awaited_once()
|
||
fake_logger.info.assert_any_call(
|
||
"应用级被驱动适配器已关闭",
|
||
adapter_type=type(app_adapter).__name__,
|
||
)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_skips_non_closeable_app_adapter(self, fake_logger):
|
||
"""应用级适配器未实现 CloseablePort 时跳过。"""
|
||
# Arrange
|
||
non_closeable = MagicMock() # 不实现 CloseablePort
|
||
plugin_registry = MagicMock(spec=PluginRegistry)
|
||
plugin_registry.listPluginAdapters = MagicMock(return_value=[])
|
||
shutdown = _make_shutdown(
|
||
fake_logger,
|
||
plugin_registry=plugin_registry,
|
||
app_driven_adapters=[non_closeable],
|
||
)
|
||
|
||
# Act
|
||
await shutdown._closeAllDrivenAdapters()
|
||
|
||
# Assert: 记录跳过日志,不调用 aclose
|
||
fake_logger.info.assert_any_call(
|
||
"应用级被驱动适配器未实现 CloseablePort,跳过",
|
||
adapter_type=type(non_closeable).__name__,
|
||
)
|
||
non_closeable.aclose.assert_not_called()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_app_adapter_exception_continues_others(self, fake_logger):
|
||
"""应用级适配器 aclose 抛异常时记录告警并继续关闭其他。"""
|
||
# Arrange
|
||
bad_adapter = MagicMock(spec=CloseablePort)
|
||
bad_adapter.aclose = AsyncMock(side_effect=RuntimeError("close fail"))
|
||
good_adapter = MagicMock(spec=CloseablePort)
|
||
good_adapter.aclose = AsyncMock()
|
||
plugin_registry = MagicMock(spec=PluginRegistry)
|
||
plugin_registry.listPluginAdapters = MagicMock(return_value=[])
|
||
shutdown = _make_shutdown(
|
||
fake_logger,
|
||
plugin_registry=plugin_registry,
|
||
app_driven_adapters=[bad_adapter, good_adapter],
|
||
)
|
||
|
||
# Act
|
||
await shutdown._closeAllDrivenAdapters()
|
||
|
||
# Assert: bad_adapter 抛异常后 good_adapter 仍被关闭
|
||
bad_adapter.aclose.assert_awaited_once()
|
||
good_adapter.aclose.assert_awaited_once()
|
||
fake_logger.warn.assert_called()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_closes_plugin_level_adapters(self, fake_logger):
|
||
"""插件级 DrivenAdapters.close() 被调用。"""
|
||
# Arrange
|
||
plugin_adapters = MagicMock()
|
||
plugin_adapters.close = AsyncMock()
|
||
plugin_registry = MagicMock(spec=PluginRegistry)
|
||
plugin_registry.listPluginAdapters = MagicMock(
|
||
return_value=[("feishu", plugin_adapters)]
|
||
)
|
||
shutdown = _make_shutdown(
|
||
fake_logger,
|
||
plugin_registry=plugin_registry,
|
||
app_driven_adapters=None,
|
||
)
|
||
|
||
# Act
|
||
await shutdown._closeAllDrivenAdapters()
|
||
|
||
# Assert
|
||
plugin_adapters.close.assert_awaited_once()
|
||
fake_logger.info.assert_any_call(
|
||
"被驱动适配器已关闭: channel_type=feishu",
|
||
channel_type="feishu",
|
||
)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_plugin_adapter_exception_continues_others(self, fake_logger):
|
||
"""插件级适配器 close 抛异常时记录告警并继续。"""
|
||
# Arrange
|
||
bad_adapters = MagicMock()
|
||
bad_adapters.close = AsyncMock(side_effect=RuntimeError("plugin close fail"))
|
||
good_adapters = MagicMock()
|
||
good_adapters.close = AsyncMock()
|
||
plugin_registry = MagicMock(spec=PluginRegistry)
|
||
plugin_registry.listPluginAdapters = MagicMock(
|
||
return_value=[("feishu", bad_adapters), ("wecom", good_adapters)]
|
||
)
|
||
shutdown = _make_shutdown(
|
||
fake_logger,
|
||
plugin_registry=plugin_registry,
|
||
app_driven_adapters=None,
|
||
)
|
||
|
||
# Act
|
||
await shutdown._closeAllDrivenAdapters()
|
||
|
||
# Assert: bad 抛异常后 good 仍被关闭
|
||
bad_adapters.close.assert_awaited_once()
|
||
good_adapters.close.assert_awaited_once()
|
||
fake_logger.warn.assert_called()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_empty_plugin_adapters_logs_info(self, fake_logger):
|
||
"""无插件级被驱动适配器时记录 info。"""
|
||
# Arrange
|
||
plugin_registry = MagicMock(spec=PluginRegistry)
|
||
plugin_registry.listPluginAdapters = MagicMock(return_value=[])
|
||
shutdown = _make_shutdown(
|
||
fake_logger,
|
||
plugin_registry=plugin_registry,
|
||
app_driven_adapters=None,
|
||
)
|
||
|
||
# Act
|
||
await shutdown._closeAllDrivenAdapters()
|
||
|
||
# Assert
|
||
fake_logger.info.assert_any_call("无被驱动适配器需要关闭")
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# _releaseCoreResources / _cancelBackgroundTask
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestReleaseCoreResources:
|
||
"""_releaseCoreResources / _releaseCoreResourcesInternal 核心资源释放测试。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_cancels_background_tasks_in_reverse_order(self, fake_logger):
|
||
"""按启动反向序取消后台任务(transport_manager 先于 plugin_reload)。"""
|
||
# Arrange
|
||
host_bootstrap = MagicMock(spec=HostBootstrap)
|
||
transport_task = asyncio.create_task(asyncio.sleep(100))
|
||
reload_task = asyncio.create_task(asyncio.sleep(100))
|
||
host_bootstrap.transportManagerTask = transport_task
|
||
host_bootstrap.pluginReloadTask = reload_task
|
||
shutdown = _make_shutdown(fake_logger, host_bootstrap=host_bootstrap)
|
||
|
||
# Act
|
||
await shutdown._releaseCoreResourcesInternal()
|
||
|
||
# Assert: 两个任务都被取消
|
||
assert transport_task.cancelled() or transport_task.done()
|
||
assert reload_task.cancelled() or reload_task.done()
|
||
fake_logger.info.assert_any_call("领域核心资源已释放")
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_none_tasks_are_skipped(self, fake_logger):
|
||
"""bootstrap 未执行时任务为 None,跳过取消。"""
|
||
# Arrange
|
||
host_bootstrap = MagicMock(spec=HostBootstrap)
|
||
host_bootstrap.transportManagerTask = None
|
||
host_bootstrap.pluginReloadTask = None
|
||
shutdown = _make_shutdown(fake_logger, host_bootstrap=host_bootstrap)
|
||
|
||
# Act: 不抛异常即证明 None 路径安全
|
||
await shutdown._releaseCoreResourcesInternal()
|
||
|
||
# Assert
|
||
fake_logger.info.assert_any_call("领域核心资源已释放")
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_done_tasks_are_skipped(self, fake_logger):
|
||
"""已完成的任务跳过取消。"""
|
||
# Arrange
|
||
host_bootstrap = MagicMock(spec=HostBootstrap)
|
||
# 构造已完成的任务
|
||
done_transport = asyncio.get_event_loop().create_future()
|
||
done_transport.set_result(None)
|
||
done_reload = asyncio.get_event_loop().create_future()
|
||
done_reload.set_result(None)
|
||
# 包装为 Task
|
||
transport_task = asyncio.ensure_future(done_transport)
|
||
reload_task = asyncio.ensure_future(done_reload)
|
||
await asyncio.gather(transport_task, reload_task)
|
||
host_bootstrap.transportManagerTask = transport_task
|
||
host_bootstrap.pluginReloadTask = reload_task
|
||
shutdown = _make_shutdown(fake_logger, host_bootstrap=host_bootstrap)
|
||
|
||
# Act
|
||
await shutdown._releaseCoreResourcesInternal()
|
||
|
||
# Assert: 已完成的任务不会被取消,但仍记录资源释放日志
|
||
fake_logger.info.assert_any_call("领域核心资源已释放")
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestCancelBackgroundTask:
|
||
"""_cancelBackgroundTask 单任务取消测试。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_none_task_returns_immediately(self, fake_logger):
|
||
# Arrange
|
||
shutdown = _make_shutdown(fake_logger)
|
||
|
||
# Act
|
||
await shutdown._cancelBackgroundTask(None, "none-task")
|
||
|
||
# Assert: 不抛异常即证明 None 路径安全
|
||
# 不记录"已取消"日志
|
||
info_calls = [c.args[0] for c in fake_logger.info.call_args_list if c.args]
|
||
assert not any("none-task已取消" in msg for msg in info_calls)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_done_task_returns_immediately(self, fake_logger):
|
||
# Arrange
|
||
shutdown = _make_shutdown(fake_logger)
|
||
done_task = asyncio.get_event_loop().create_future()
|
||
done_task.set_result(None)
|
||
done_task_obj = asyncio.ensure_future(done_task)
|
||
await done_task_obj
|
||
|
||
# Act
|
||
await shutdown._cancelBackgroundTask(done_task_obj, "done-task")
|
||
|
||
# Assert: 不记录"已取消"日志
|
||
info_calls = [c.args[0] for c in fake_logger.info.call_args_list if c.args]
|
||
assert not any("done-task已取消" in msg for msg in info_calls)
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_running_task_cancelled_logs_info(self, fake_logger):
|
||
# Arrange
|
||
shutdown = _make_shutdown(fake_logger)
|
||
running_task = asyncio.create_task(asyncio.sleep(100))
|
||
|
||
# Act
|
||
await shutdown._cancelBackgroundTask(running_task, "running-task")
|
||
|
||
# Assert
|
||
assert running_task.cancelled() or running_task.done()
|
||
fake_logger.info.assert_any_call("running-task已取消")
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_task_raising_non_cancelled_error_logs_warn(self, fake_logger):
|
||
"""被取消的任务抛非 CancelledError 异常时记录告警。"""
|
||
# Arrange
|
||
shutdown = _make_shutdown(fake_logger)
|
||
|
||
async def _raise_on_await() -> None:
|
||
# 任务被 cancel 后 await 时会抛 CancelledError,这里模拟抛其他异常
|
||
try:
|
||
await asyncio.sleep(100)
|
||
except asyncio.CancelledError:
|
||
raise RuntimeError("cleanup error")
|
||
|
||
running_task = asyncio.create_task(_raise_on_await())
|
||
# 让任务开始执行(进入 try 块),否则 cancel 在协程启动前触发,
|
||
# CancelledError 不会被 try/except 捕获,也就不会转为 RuntimeError
|
||
await asyncio.sleep(0)
|
||
|
||
# Act: 不抛异常即证明异常被吞并
|
||
await shutdown._cancelBackgroundTask(running_task, "raising-task")
|
||
|
||
# Assert: 记录告警
|
||
fake_logger.warn.assert_called()
|
||
warn_msg = fake_logger.warn.call_args.args[0]
|
||
assert "raising-task" in warn_msg
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# shutdown 端到端
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
@pytest.mark.unit
|
||
class TestShutdownEndToEnd:
|
||
"""shutdown() 端到端 7 步顺序执行测试。"""
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_full_shutdown_executes_all_steps(self, fake_logger):
|
||
"""成功路径:7 步全部执行,记录开始与完成日志。"""
|
||
# Arrange: 构造所有依赖的桩,配置为成功路径
|
||
stage_slot_registry = MagicMock(spec=StageSlotRegistry)
|
||
stage_slot_registry.findByPipeline = MagicMock(return_value=[])
|
||
stage_slot_registry.clear = MagicMock()
|
||
|
||
transport_manager = MagicMock(spec=TransportManager)
|
||
transport_manager.stop = AsyncMock()
|
||
|
||
plugin_registry = MagicMock(spec=PluginRegistry)
|
||
plugin_registry.listPluginsByState = MagicMock(return_value=[])
|
||
plugin_registry.listPluginAdapters = MagicMock(return_value=[])
|
||
|
||
host_bootstrap = MagicMock(spec=HostBootstrap)
|
||
host_bootstrap.transportManagerTask = None
|
||
host_bootstrap.pluginReloadTask = None
|
||
|
||
shutdown = _make_shutdown(
|
||
fake_logger,
|
||
plugin_registry=plugin_registry,
|
||
stage_slot_registry=stage_slot_registry,
|
||
transport_manager=transport_manager,
|
||
host_bootstrap=host_bootstrap,
|
||
inflight_drain_waiter=None,
|
||
app_driven_adapters=None,
|
||
)
|
||
|
||
# Act
|
||
await shutdown.shutdown()
|
||
|
||
# Assert: 关键步骤被调用
|
||
assert shutdown._draining is True
|
||
stage_slot_registry.clear.assert_called_once()
|
||
transport_manager.stop.assert_awaited_once()
|
||
# 记录开始与完成日志
|
||
fake_logger.info.assert_any_call("宿主关停开始")
|
||
fake_logger.info.assert_any_call("宿主关停完成")
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_step_failure_does_not_block_subsequent_steps(self, fake_logger):
|
||
"""任一步骤异常不中断后续步骤(INV-9 回退路径完整性)。"""
|
||
# Arrange: transport_manager.stop 抛异常,但后续 _stopPlugins 仍执行
|
||
stage_slot_registry = MagicMock(spec=StageSlotRegistry)
|
||
stage_slot_registry.findByPipeline = MagicMock(return_value=[])
|
||
stage_slot_registry.clear = MagicMock()
|
||
|
||
transport_manager = MagicMock(spec=TransportManager)
|
||
transport_manager.stop = AsyncMock(side_effect=RuntimeError("stop boom"))
|
||
|
||
plugin_registry = MagicMock(spec=PluginRegistry)
|
||
plugin_registry.listPluginsByState = MagicMock(return_value=[])
|
||
plugin_registry.listPluginAdapters = MagicMock(return_value=[])
|
||
|
||
host_bootstrap = MagicMock(spec=HostBootstrap)
|
||
host_bootstrap.transportManagerTask = None
|
||
host_bootstrap.pluginReloadTask = None
|
||
|
||
shutdown = _make_shutdown(
|
||
fake_logger,
|
||
plugin_registry=plugin_registry,
|
||
stage_slot_registry=stage_slot_registry,
|
||
transport_manager=transport_manager,
|
||
host_bootstrap=host_bootstrap,
|
||
)
|
||
|
||
# Act: 不抛异常即证明 transport_manager 异常不阻塞后续步骤
|
||
await shutdown.shutdown()
|
||
|
||
# Assert: transport_manager.stop 异常记录告警,shutdown 仍完成
|
||
fake_logger.warn.assert_called()
|
||
fake_logger.info.assert_any_call("宿主关停完成")
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_shutdown_calls_inflight_waiter_when_provided(self, fake_logger):
|
||
"""提供 inflight_drain_waiter 时 shutdown 调用之。"""
|
||
# Arrange
|
||
waiter = AsyncMock()
|
||
stage_slot_registry = MagicMock(spec=StageSlotRegistry)
|
||
stage_slot_registry.findByPipeline = MagicMock(return_value=[])
|
||
stage_slot_registry.clear = MagicMock()
|
||
|
||
plugin_registry = MagicMock(spec=PluginRegistry)
|
||
plugin_registry.listPluginsByState = MagicMock(return_value=[])
|
||
plugin_registry.listPluginAdapters = MagicMock(return_value=[])
|
||
|
||
transport_manager = MagicMock(spec=TransportManager)
|
||
transport_manager.stop = AsyncMock()
|
||
|
||
host_bootstrap = MagicMock(spec=HostBootstrap)
|
||
host_bootstrap.transportManagerTask = None
|
||
host_bootstrap.pluginReloadTask = None
|
||
|
||
shutdown = _make_shutdown(
|
||
fake_logger,
|
||
plugin_registry=plugin_registry,
|
||
stage_slot_registry=stage_slot_registry,
|
||
transport_manager=transport_manager,
|
||
host_bootstrap=host_bootstrap,
|
||
inflight_drain_waiter=waiter,
|
||
)
|
||
|
||
# Act
|
||
await shutdown.shutdown()
|
||
|
||
# Assert: waiter 被调用,传入 SHUTDOWN_TIMEOUT
|
||
waiter.assert_awaited_once()
|
||
assert waiter.await_args.args[0] == HostShutdown.SHUTDOWN_TIMEOUT
|