318 lines
12 KiB
Python
318 lines
12 KiB
Python
|
|
"""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/manual,required_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:
|
|||
|
|
"""A1:status / 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:
|
|||
|
|
"""A2:transport_mode 为 stream,与仅注册 StreamConnector 一致。"""
|
|||
|
|
|
|||
|
|
def test_transport_mode_is_stream(self, qqbot_manifest):
|
|||
|
|
assert qqbot_manifest.transport_mode == "stream"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
# A3: 核心配置项存在性
|
|||
|
|
# ------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
@pytest.mark.unit
|
|||
|
|
class TestConfigSchemaCoreFields:
|
|||
|
|
"""A3:config_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:
|
|||
|
|
"""A4:capability_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:
|
|||
|
|
"""A5:credential_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:
|
|||
|
|
"""A6:resource_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
|