ForcePilot/backend/test/unit/channels/plugins/qqbot/test_manifest.py
Kris 532e7c59ec test: add and fix multi-channel unit tests
1. 新增QQBot、Wecom、Dingtalk、Instagram、WechatMp等渠道的单元测试夹具与测试用例
2. 修复WechatILink的缓存键顺序与测试用例
3. 补全Feishu适配器的日志断言与测试逻辑
4. 清理WechatILink冗余的clear_context_token测试代码
2026-07-08 22:59:24 +08:00

318 lines
12 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.

"""qqbot 插件 manifest.json 一致性测试。
验证 manifest.json 关键不变量:
- ``capabilities.status`` / ``capabilities.probeable`` 为 ``true``,与
entry.py 注册的 StatusAdapter / ProbeableAdapter 一致。
- ``transport_mode`` 为 ``"stream"``,与 qqbot 仅注册 StreamConnector
(无 Puller的实际传输模式一致。
- ``capability_requirements`` 中引用的所有配置键均在 ``config_schema``
中声明F-03 单真相源:声明性字段交叉引用完整)。
- ``credential_strategy`` 声明为 static/manualrequired_fields 为
app_id / app_secret。
- ``config_schema`` 含 app_id / app_secret / intents / api_base / sandbox
等核心配置项。
测试通过 ``load_manifest_from_dir`` 加载真实 ``manifest.json``,确保校验
基于实际文件内容而非硬编码预期值。
"""
from __future__ import annotations
import os
import pytest
from yuxi.channels.application.lifecycle.manifest_loader import load_manifest_from_dir
from yuxi.channels.contract.plugin.manifest import ChannelManifest
pytestmark = pytest.mark.unit
@pytest.fixture
def qqbot_manifest() -> ChannelManifest:
"""加载 qqbot 插件真实 manifest.json。"""
plugin_dir = os.path.join(
os.path.dirname(__file__),
"..",
"..",
"..",
"..",
"..",
"package",
"yuxi",
"channels",
"plugins",
"qqbot",
)
return load_manifest_from_dir(os.path.abspath(plugin_dir))
# ------------------------------------------------------------------
# A1: status / probeable 能力声明与适配器注册一致
# ------------------------------------------------------------------
@pytest.mark.unit
class TestStatusProbeableCapabilities:
"""A1status / probeable 声明为 true与 entry.py 注册的适配器一致。"""
def test_status_capability_is_true(self, qqbot_manifest):
assert qqbot_manifest.capabilities.status is True
def test_probeable_capability_is_true(self, qqbot_manifest):
assert qqbot_manifest.capabilities.probeable is True
def test_identity_resolver_capability_is_true(self, qqbot_manifest):
assert qqbot_manifest.capabilities.identity_resolver is True
def test_lifecycle_capability_is_true(self, qqbot_manifest):
assert qqbot_manifest.capabilities.lifecycle is True
def test_rich_message_capability_is_true(self, qqbot_manifest):
assert qqbot_manifest.capabilities.rich_message is True
def test_message_recall_capability_is_true(self, qqbot_manifest):
assert qqbot_manifest.capabilities.message_recall is True
def test_streaming_capability_is_false(self, qqbot_manifest):
"""QQ Bot v1 不支持流式输出。"""
assert qqbot_manifest.capabilities.streaming is False
def test_message_edit_capability_is_false(self, qqbot_manifest):
"""QQ Bot v1 不支持消息编辑。"""
assert qqbot_manifest.capabilities.message_edit is False
def test_supports_qr_login_capability_is_false(self, qqbot_manifest):
"""QQ Bot v1 不支持扫码登录。"""
assert qqbot_manifest.capabilities.supports_qr_login is False
def test_agent_collaboration_capability_is_false(self, qqbot_manifest):
"""QQ Bot v1 不支持多 Agent 协作。"""
assert qqbot_manifest.capabilities.agent_collaboration is False
# ------------------------------------------------------------------
# A2: transport_mode 声明
# ------------------------------------------------------------------
@pytest.mark.unit
class TestTransportMode:
"""A2transport_mode 为 stream与仅注册 StreamConnector 一致。"""
def test_transport_mode_is_stream(self, qqbot_manifest):
assert qqbot_manifest.transport_mode == "stream"
# ------------------------------------------------------------------
# A3: 核心配置项存在性
# ------------------------------------------------------------------
@pytest.mark.unit
class TestConfigSchemaCoreFields:
"""A3config_schema 含核心配置项。"""
def test_app_id_in_config_schema(self, qqbot_manifest):
keys = {f.key for f in qqbot_manifest.config_schema}
assert "app_id" in keys
def test_app_secret_in_config_schema(self, qqbot_manifest):
keys = {f.key for f in qqbot_manifest.config_schema}
assert "app_secret" in keys
def test_intents_in_config_schema(self, qqbot_manifest):
keys = {f.key for f in qqbot_manifest.config_schema}
assert "intents" in keys
def test_api_base_in_config_schema(self, qqbot_manifest):
keys = {f.key for f in qqbot_manifest.config_schema}
assert "api_base" in keys
def test_gateway_url_in_config_schema(self, qqbot_manifest):
keys = {f.key for f in qqbot_manifest.config_schema}
assert "gateway_url" in keys
def test_sandbox_in_config_schema(self, qqbot_manifest):
keys = {f.key for f in qqbot_manifest.config_schema}
assert "sandbox" in keys
def test_http_timeout_ms_in_config_schema(self, qqbot_manifest):
keys = {f.key for f in qqbot_manifest.config_schema}
assert "http_timeout_ms" in keys
def test_ws_heartbeat_interval_ms_in_config_schema(self, qqbot_manifest):
keys = {f.key for f in qqbot_manifest.config_schema}
assert "ws_heartbeat_interval_ms" in keys
def test_app_id_is_required(self, qqbot_manifest):
field = next(f for f in qqbot_manifest.config_schema if f.key == "app_id")
assert field.required is True
def test_app_secret_is_required(self, qqbot_manifest):
field = next(f for f in qqbot_manifest.config_schema if f.key == "app_secret")
assert field.required is True
def test_app_secret_is_sensitive(self, qqbot_manifest):
field = next(f for f in qqbot_manifest.config_schema if f.key == "app_secret")
assert field.sensitive is True
def test_app_id_not_hot_reloadable(self, qqbot_manifest):
field = next(f for f in qqbot_manifest.config_schema if f.key == "app_id")
assert field.hot_reloadable is False
def test_sandbox_not_hot_reloadable(self, qqbot_manifest):
field = next(f for f in qqbot_manifest.config_schema if f.key == "sandbox")
assert field.hot_reloadable is False
def test_http_timeout_ms_hot_reloadable(self, qqbot_manifest):
field = next(f for f in qqbot_manifest.config_schema if f.key == "http_timeout_ms")
assert field.hot_reloadable is True
def test_ws_heartbeat_interval_ms_hot_reloadable(self, qqbot_manifest):
field = next(f for f in qqbot_manifest.config_schema if f.key == "ws_heartbeat_interval_ms")
assert field.hot_reloadable is True
# ------------------------------------------------------------------
# A4: capability_requirements 引用的配置键均在 config_schema 中声明
# ------------------------------------------------------------------
@pytest.mark.unit
class TestCapabilityRequirementsConsistency:
"""A4capability_requirements 交叉引用完整性校验。
每项 capability_requirements 为 ``(能力字段名, (所需配置键清单))``
所有引用的配置键必须在 config_schema 中声明F-03 单真相源)。
"""
def test_capability_requirements_not_empty(self, qqbot_manifest):
assert len(qqbot_manifest.capability_requirements) > 0
def test_all_referenced_config_keys_exist_in_schema(self, qqbot_manifest):
schema_keys = {f.key for f in qqbot_manifest.config_schema}
for _capability, required_keys in qqbot_manifest.capability_requirements:
for key in required_keys:
assert key in schema_keys, f"capability_requirements 引用的配置键 '{key}' 未在 config_schema 中声明"
def test_media_capabilities_require_app_credentials(self, qqbot_manifest):
"""媒体能力(图片/视频出入站)依赖 app_id 与 app_secret。"""
req = dict(qqbot_manifest.capability_requirements)
for cap in (
"supports_image_inbound",
"supports_image_outbound",
"supports_video_inbound",
"supports_video_outbound",
):
assert cap in req
required = set(req[cap])
assert "app_id" in required
assert "app_secret" in required
def test_all_capabilities_have_app_credentials_requirement(self, qqbot_manifest):
"""QQ Bot 所有能力均依赖 app_id 与 app_secret。"""
req = dict(qqbot_manifest.capability_requirements)
for _cap, keys in req.items():
assert "app_id" in keys
assert "app_secret" in keys
# ------------------------------------------------------------------
# A5: credential_strategy 声明
# ------------------------------------------------------------------
@pytest.mark.unit
class TestCredentialStrategy:
"""A5credential_strategy 声明为 static/manual。"""
def test_credential_strategy_not_none(self, qqbot_manifest):
assert qqbot_manifest.credential_strategy is not None
def test_credential_strategy_type_is_static(self, qqbot_manifest):
assert qqbot_manifest.credential_strategy is not None
assert qqbot_manifest.credential_strategy.type.value == "static"
def test_credential_strategy_acquire_method_is_manual(self, qqbot_manifest):
assert qqbot_manifest.credential_strategy is not None
assert qqbot_manifest.credential_strategy.acquire_method.value == "manual"
def test_credential_strategy_required_fields(self, qqbot_manifest):
assert qqbot_manifest.credential_strategy is not None
required = set(qqbot_manifest.credential_strategy.required_fields)
assert "app_id" in required
assert "app_secret" in required
def test_credential_strategy_supports_rotation(self, qqbot_manifest):
assert qqbot_manifest.credential_strategy is not None
assert qqbot_manifest.credential_strategy.supports_rotation is True
def test_credential_strategy_not_supports_revocation(self, qqbot_manifest):
assert qqbot_manifest.credential_strategy is not None
assert qqbot_manifest.credential_strategy.supports_revocation is False
# ------------------------------------------------------------------
# A6: resource_quota 声明
# ------------------------------------------------------------------
@pytest.mark.unit
class TestResourceQuota:
"""A6resource_quota 声明合理。"""
def test_resource_quota_not_none(self, qqbot_manifest):
assert qqbot_manifest.resource_quota is not None
def test_max_connections_positive(self, qqbot_manifest):
assert qqbot_manifest.resource_quota is not None
assert qqbot_manifest.resource_quota.max_connections is not None
assert qqbot_manifest.resource_quota.max_connections > 0
def test_max_calls_per_sec_positive(self, qqbot_manifest):
assert qqbot_manifest.resource_quota is not None
assert qqbot_manifest.resource_quota.max_calls_per_sec is not None
assert qqbot_manifest.resource_quota.max_calls_per_sec > 0
# ------------------------------------------------------------------
# A7: 其他声明性字段
# ------------------------------------------------------------------
@pytest.mark.unit
class TestDeclarativeFields:
"""A7其他声明性字段验证。"""
def test_channel_type_is_qqbot(self, qqbot_manifest):
assert qqbot_manifest.channel_type == "qqbot"
def test_requires_dm_pairing_is_true(self, qqbot_manifest):
"""QQ Bot 要求 DM 安全配对审批。"""
assert qqbot_manifest.requires_dm_pairing is True
def test_requires_outbound_delivery_is_true(self, qqbot_manifest):
assert qqbot_manifest.requires_outbound_delivery is True
def test_supports_credential_cloning_is_false(self, qqbot_manifest):
"""QQ Bot 不允许凭据克隆。"""
assert qqbot_manifest.supports_credential_cloning is False
def test_critical_is_false(self, qqbot_manifest):
assert qqbot_manifest.critical is False
def test_max_message_length_is_4000(self, qqbot_manifest):
assert qqbot_manifest.max_message_length == 4000
def test_accessible_ports_contains_required(self, qqbot_manifest):
"""accessible_ports 应包含 ConfigPort / LoggerPort / CachePort。"""
ports = set(qqbot_manifest.accessible_ports)
assert "ConfigPort" in ports
assert "LoggerPort" in ports
assert "CachePort" in ports
def test_injectable_pipelines_contains_inbound_outbound(self, qqbot_manifest):
pipelines = set(qqbot_manifest.injectable_pipelines)
assert "inbound" in pipelines
assert "outbound" in pipelines