ForcePilot/backend/test/unit/channels/plugins/feishu/test_entry.py
Kris d58331e0c0 test: 批量修复单元测试与集成测试的兼容性问题
本次提交修复了多个测试文件中的问题:
1. 将 ChannelType 枚举调用改为字符串实例化方式
2. 修正了日志断言、异步mock使用、配置参数等多处测试细节
3. 新增了会话聚合根、跨渠道关联策略等单元测试用例
4. 修复了路由测试中的路径方法错误与断言逻辑
5. 调整了依赖导入与测试夹具的兼容性
6. 统一了重试回退调度的列表/元组使用规范
2026-07-04 00:18:04 +08:00

478 lines
14 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.

"""entry 模块单元测试。
覆盖 ``channel_entry(host)`` 入口函数(端口声明、适配器注册、生命周期钩子
注册、PluginManifest 返回)、``_build_config_schema()``18 个 ConfigField
与 ``_build_env_vars()``7 个 EnvVar。使用 fake host 记录所有调用,
验证 18 个适配器均被注册且 manifest 字段正确。
"""
from __future__ import annotations
from unittest.mock import AsyncMock, MagicMock
import pytest
from yuxi.channels.contract.dtos.channel import ChannelType
from yuxi.channels.contract.dtos.config import ConfigField
from yuxi.channels.contract.dtos.plugin import EnvVar
from yuxi.channels.contract.plugin.manifest import (
FailurePolicy,
PluginManifest,
)
from yuxi.channels.plugins.feishu.entry import (
_ACCESSIBLE_PORTS,
_ADAPTER_TYPES,
_INJECTABLE_PIPELINES,
_build_config_schema,
_build_env_vars,
channel_entry,
)
from yuxi.channels.plugins.feishu.lifecycle import FeishuLifecycleHandler
pytestmark = pytest.mark.unit
# ------------------------------------------------------------------
# 桩辅助
# ------------------------------------------------------------------
class _FakeHost:
"""PluginHost 替身,记录所有注册调用。"""
def __init__(self) -> None:
self.config_port: AsyncMock = AsyncMock()
self.logger_port: AsyncMock = AsyncMock()
self.cache_port: AsyncMock = AsyncMock()
self.persistence_port: AsyncMock = AsyncMock()
self.registered_adapters: dict[str, object] = {}
self.lifecycle_handler: object | None = None
def getConfigPort(self) -> AsyncMock:
return self.config_port
def getLoggerPort(self) -> AsyncMock:
return self.logger_port
def getCachePort(self) -> AsyncMock:
return self.cache_port
def getPersistencePort(self) -> AsyncMock:
return self.persistence_port
def registerAdapter(self, adapter_type: str, adapter: object) -> None:
self.registered_adapters[adapter_type] = adapter
def registerLifecycleHandler(self, handler: object) -> None:
self.lifecycle_handler = handler
# ------------------------------------------------------------------
# channel_entry
# ------------------------------------------------------------------
@pytest.mark.unit
class TestChannelEntry:
"""channel_entry 入口函数测试。"""
def test_manifest_declares_accessible_ports(self) -> None:
# Arrange
host = _FakeHost()
# Act
manifest = channel_entry(host)
# Assert - 能力边界由 manifest 静态字段声明(不再运行时 declareXxx
assert manifest.manifest.accessible_ports == _ACCESSIBLE_PORTS
assert len(manifest.manifest.accessible_ports) == 7
def test_manifest_declares_injectable_pipelines(self) -> None:
# Arrange
host = _FakeHost()
# Act
manifest = channel_entry(host)
# Assert
assert manifest.manifest.injectable_pipelines == _INJECTABLE_PIPELINES
assert "inbound" in manifest.manifest.injectable_pipelines
assert "outbound" in manifest.manifest.injectable_pipelines
def test_manifest_declares_resource_quota(self) -> None:
# Arrange
host = _FakeHost()
# Act
manifest = channel_entry(host)
# Assert
quota = manifest.manifest.resource_quota
assert quota is not None
assert quota.max_cpu == "20.0"
assert quota.max_memory == "256MB"
assert quota.max_connections == 50
assert quota.max_calls_per_sec == 50
def test_registers_all_18_adapters(self) -> None:
# Arrange
host = _FakeHost()
# Act
channel_entry(host)
# Assert
assert len(host.registered_adapters) == 18
for adapter_type in _ADAPTER_TYPES:
assert adapter_type in host.registered_adapters, f"Missing adapter: {adapter_type}"
def test_registers_lifecycle_handler(self) -> None:
# Arrange
host = _FakeHost()
# Act
channel_entry(host)
# Assert
assert host.lifecycle_handler is not None
assert isinstance(host.lifecycle_handler, FeishuLifecycleHandler)
def test_returns_plugin_manifest(self) -> None:
# Arrange
host = _FakeHost()
# Act
manifest = channel_entry(host)
# Assert
assert isinstance(manifest, PluginManifest)
def test_manifest_has_correct_id_and_name(self) -> None:
# Arrange
host = _FakeHost()
# Act
manifest = channel_entry(host)
# Assert
assert manifest.manifest.id == "com.yuxi.channels.feishu"
assert manifest.manifest.name == "飞书"
assert manifest.manifest.version == "1.0.0"
def test_manifest_has_correct_channel_type(self) -> None:
# Arrange
host = _FakeHost()
# Act
manifest = channel_entry(host)
# Assert
assert manifest.manifest.channel_type == ChannelType("feishu")
def test_manifest_has_correct_failure_policy(self) -> None:
# Arrange
host = _FakeHost()
# Act
manifest = channel_entry(host)
# Assert
assert manifest.manifest.failure_policy == FailurePolicy.DEGRADE
def test_manifest_adapters_match_registered_types(self) -> None:
# Arrange
host = _FakeHost()
# Act
manifest = channel_entry(host)
# Assert
assert manifest.adapters == _ADAPTER_TYPES
assert len(manifest.adapters) == 18
def test_manifest_critical_flag_is_false(self) -> None:
# Arrange
host = _FakeHost()
# Act
manifest = channel_entry(host)
# Assert
assert manifest.manifest.critical is False
def test_manifest_requires_dm_pairing_is_true(self) -> None:
# Arrange
host = _FakeHost()
# Act
manifest = channel_entry(host)
# Assert
assert manifest.manifest.requires_dm_pairing is True
def test_manifest_capabilities_flags(self) -> None:
# Arrange
host = _FakeHost()
# Act
manifest = channel_entry(host)
# Assert
caps = manifest.manifest.capabilities
assert caps.rich_message is True
assert caps.streaming is True
assert caps.typing_indicator is True
assert caps.message_edit is True
assert caps.message_recall is True
assert caps.mention is True
assert caps.command is True
assert caps.directory is True
assert caps.doctor is True
assert caps.whitelist is True
assert caps.supports_reaction is True
assert caps.supports_pin is True
assert caps.supports_card_update is True
assert caps.supports_card_update_streaming is True
assert caps.supports_image_inbound is True
assert caps.supports_video_inbound is False
assert caps.supports_image_outbound is True
assert caps.supports_video_outbound is True
assert caps.lifecycle is True
assert caps.supports_qr_login is True
assert caps.identity_resolver is True
def test_manifest_accessible_ports_and_pipelines(self) -> None:
# Arrange
host = _FakeHost()
# Act
manifest = channel_entry(host)
# Assert
assert manifest.manifest.accessible_ports == _ACCESSIBLE_PORTS
assert manifest.manifest.injectable_pipelines == _INJECTABLE_PIPELINES
def test_manifest_lifecycle_hooks(self) -> None:
# Arrange
host = _FakeHost()
# Act
manifest = channel_entry(host)
# Assert
assert manifest.manifest.lifecycle == ("init", "start", "stop", "unload")
def test_manifest_compatibility_info(self) -> None:
# Arrange
host = _FakeHost()
# Act
manifest = channel_entry(host)
# Assert
assert manifest.manifest.compatibility is not None
assert manifest.manifest.compatibility["min_host_version"] == "1.0.0"
assert manifest.manifest.compatibility["feishu_api_version"] == "v2"
# ------------------------------------------------------------------
# _build_config_schema
# ------------------------------------------------------------------
@pytest.mark.unit
class TestBuildConfigSchema:
"""_build_config_schema 配置 schema 构建测试。"""
def test_returns_18_config_fields(self) -> None:
# Arrange / Act
schema = _build_config_schema()
# Assert
assert len(schema) == 18
assert all(isinstance(f, ConfigField) for f in schema)
def test_app_id_not_hot_reloadable_with_pattern(self) -> None:
# Arrange / Act
schema = _build_config_schema()
field = next(f for f in schema if f.key == "app_id")
# Assert
assert field.required is True
assert field.hot_reloadable is False
assert field.constraints is not None
assert "pattern" in field.constraints
def test_app_secret_not_hot_reloadable_and_sensitive(self) -> None:
# Arrange / Act
schema = _build_config_schema()
field = next(f for f in schema if f.key == "app_secret")
# Assert
assert field.required is True
assert field.hot_reloadable is False
assert field.constraints is not None
assert field.constraints.get("sensitive") is True
def test_event_mode_not_hot_reloadable_enum(self) -> None:
# Arrange / Act
schema = _build_config_schema()
field = next(f for f in schema if f.key == "event_mode")
# Assert
assert field.type == "enum"
assert field.hot_reloadable is False
assert field.default == "websocket"
assert "websocket" in field.constraints["values"]
assert "webhook" in field.constraints["values"]
def test_webhook_url_not_hot_reloadable(self) -> None:
# Arrange / Act
schema = _build_config_schema()
field = next(f for f in schema if f.key == "webhook_url")
# Assert
assert field.hot_reloadable is False
def test_bot_name_hot_reloadable(self) -> None:
# Arrange / Act
schema = _build_config_schema()
field = next(f for f in schema if f.key == "bot_name")
# Assert
assert field.required is True
assert field.hot_reloadable is True
def test_bot_open_id_hot_reloadable_with_pattern(self) -> None:
# Arrange / Act
schema = _build_config_schema()
field = next(f for f in schema if f.key == "bot_open_id")
# Assert
assert field.hot_reloadable is True
assert "pattern" in field.constraints
def test_enable_qr_login_boolean_default_false(self) -> None:
# Arrange / Act
schema = _build_config_schema()
field = next(f for f in schema if f.key == "enable_qr_login")
# Assert
assert field.type == "boolean"
assert field.default is False
assert field.hot_reloadable is True
def test_qr_timeout_ms_has_min_max_constraints(self) -> None:
# Arrange / Act
schema = _build_config_schema()
field = next(f for f in schema if f.key == "qr_timeout_ms")
# Assert
assert field.type == "integer"
assert field.default == 120000
assert field.constraints["min"] == 30000
assert field.constraints["max"] == 300000
def test_api_base_url_has_default(self) -> None:
# Arrange / Act
schema = _build_config_schema()
field = next(f for f in schema if f.key == "api_base_url")
# Assert
assert field.default == "https://open.feishu.cn"
def test_all_keys_unique(self) -> None:
# Arrange / Act
schema = _build_config_schema()
keys = [f.key for f in schema]
# Assert
assert len(keys) == len(set(keys))
# ------------------------------------------------------------------
# _build_env_vars
# ------------------------------------------------------------------
@pytest.mark.unit
class TestBuildEnvVars:
"""_build_env_vars 环境变量声明构建测试。"""
def test_returns_7_env_vars(self) -> None:
# Arrange / Act
env_vars = _build_env_vars()
# Assert
assert len(env_vars) == 7
assert all(isinstance(v, EnvVar) for v in env_vars)
def test_feishu_app_id_required(self) -> None:
# Arrange / Act
env_vars = _build_env_vars()
var = next(v for v in env_vars if v.name == "FEISHU_APP_ID")
# Assert
assert var.required is True
assert var.sensitive is False
def test_feishu_app_secret_required_and_sensitive(self) -> None:
# Arrange / Act
env_vars = _build_env_vars()
var = next(v for v in env_vars if v.name == "FEISHU_APP_SECRET")
# Assert
assert var.required is True
assert var.sensitive is True
def test_feishu_encrypt_key_optional_and_sensitive(self) -> None:
# Arrange / Act
env_vars = _build_env_vars()
var = next(v for v in env_vars if v.name == "FEISHU_ENCRYPT_KEY")
# Assert
assert var.required is False
assert var.sensitive is True
def test_feishu_verification_token_optional_and_sensitive(self) -> None:
# Arrange / Act
env_vars = _build_env_vars()
var = next(v for v in env_vars if v.name == "FEISHU_VERIFICATION_TOKEN")
# Assert
assert var.required is False
assert var.sensitive is True
def test_feishu_bot_name_required(self) -> None:
# Arrange / Act
env_vars = _build_env_vars()
var = next(v for v in env_vars if v.name == "FEISHU_BOT_NAME")
# Assert
assert var.required is True
def test_feishu_qr_redirect_uri_optional(self) -> None:
# Arrange / Act
env_vars = _build_env_vars()
var = next(v for v in env_vars if v.name == "FEISHU_QR_REDIRECT_URI")
# Assert
assert var.required is False
def test_feishu_qr_timeout_ms_has_default(self) -> None:
# Arrange / Act
env_vars = _build_env_vars()
var = next(v for v in env_vars if v.name == "FEISHU_QR_TIMEOUT_MS")
# Assert
assert var.required is False
assert var.default == "120000"
def test_all_env_var_names_unique(self) -> None:
# Arrange / Act
env_vars = _build_env_vars()
names = [v.name for v in env_vars]
# Assert
assert len(names) == len(set(names))