"""dingtalk 插件 manifest.json 一致性测试。 验证 manifest.json 关键不变量: - 标识字段(id / channel_type / provides)一致。 - capabilities 关键能力声明与 entry.py 注册的适配器一致。 - transport_mode 为 stream,与仅注册 StreamConnector 的传输模式一致。 - failure_policy 为 degrade。 - max_message_length 为 5000。 - accessible_ports 含 ConfigPort / CachePort / LoggerPort 等基础端口。 - config_schema 含 client_id / client_secret / robot_code 等必填项。 - capability_requirements 中引用的所有配置键均在 config_schema 中声明 (F-03 单真相源:声明性字段交叉引用完整)。 测试通过 ``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 dingtalk_manifest() -> ChannelManifest: """加载 dingtalk 插件真实 manifest.json。""" plugin_dir = os.path.join( os.path.dirname(__file__), "..", "..", "..", "..", "..", "package", "yuxi", "channels", "plugins", "dingtalk", ) return load_manifest_from_dir(os.path.abspath(plugin_dir)) # ------------------------------------------------------------------ # 标识字段一致性 # ------------------------------------------------------------------ @pytest.mark.unit class TestManifestIdentity: """manifest 标识字段(id / channel_type / provides)一致性。""" def test_id_is_com_yuxi_channels_dingtalk(self, dingtalk_manifest): assert dingtalk_manifest.id == "com.yuxi.channels.dingtalk" def test_channel_type_is_dingtalk(self, dingtalk_manifest): assert dingtalk_manifest.channel_type == "dingtalk" def test_provides_contains_dingtalk(self, dingtalk_manifest): assert "dingtalk" in dingtalk_manifest.provides # ------------------------------------------------------------------ # capabilities 关键能力声明 # ------------------------------------------------------------------ @pytest.mark.unit class TestCapabilities: """capabilities 关键能力声明与 entry.py 注册的适配器一致。""" def test_rich_message_is_true(self, dingtalk_manifest): assert dingtalk_manifest.capabilities.rich_message is True def test_streaming_is_true(self, dingtalk_manifest): assert dingtalk_manifest.capabilities.streaming is True def test_message_recall_is_true(self, dingtalk_manifest): assert dingtalk_manifest.capabilities.message_recall is True def test_typing_indicator_is_false(self, dingtalk_manifest): assert dingtalk_manifest.capabilities.typing_indicator is False def test_supports_card_update_is_true(self, dingtalk_manifest): assert dingtalk_manifest.capabilities.supports_card_update is True def test_supports_card_update_streaming_is_true(self, dingtalk_manifest): assert dingtalk_manifest.capabilities.supports_card_update_streaming is True def test_identity_resolver_is_true(self, dingtalk_manifest): assert dingtalk_manifest.capabilities.identity_resolver is True def test_lifecycle_is_true(self, dingtalk_manifest): assert dingtalk_manifest.capabilities.lifecycle is True def test_status_is_true(self, dingtalk_manifest): assert dingtalk_manifest.capabilities.status is True def test_probeable_is_true(self, dingtalk_manifest): assert dingtalk_manifest.capabilities.probeable is True def test_supports_qr_login_is_false(self, dingtalk_manifest): """supports_qr_login=false,entry.py 不注册 login_adapter。""" assert dingtalk_manifest.capabilities.supports_qr_login is False # ------------------------------------------------------------------ # transport_mode / failure_policy / max_message_length # ------------------------------------------------------------------ @pytest.mark.unit class TestTransportAndPolicy: """transport_mode / failure_policy / max_message_length 声明。""" def test_transport_mode_is_stream(self, dingtalk_manifest): assert dingtalk_manifest.transport_mode == "stream" def test_failure_policy_is_degrade(self, dingtalk_manifest): assert dingtalk_manifest.failure_policy == "degrade" def test_max_message_length_is_5000(self, dingtalk_manifest): assert dingtalk_manifest.max_message_length == 5000 # ------------------------------------------------------------------ # accessible_ports # ------------------------------------------------------------------ @pytest.mark.unit class TestAccessiblePorts: """accessible_ports 含基础端口与钉钉所需端口。""" def test_contains_config_port(self, dingtalk_manifest): assert "ConfigPort" in dingtalk_manifest.accessible_ports def test_contains_cache_port(self, dingtalk_manifest): assert "CachePort" in dingtalk_manifest.accessible_ports def test_contains_logger_port(self, dingtalk_manifest): assert "LoggerPort" in dingtalk_manifest.accessible_ports def test_contains_persistence_port(self, dingtalk_manifest): assert "PersistencePort" in dingtalk_manifest.accessible_ports def test_contains_tracer_port(self, dingtalk_manifest): assert "TracerPort" in dingtalk_manifest.accessible_ports # ------------------------------------------------------------------ # config_schema 必填项 # ------------------------------------------------------------------ @pytest.mark.unit class TestConfigSchema: """config_schema 含钉钉必填配置项。""" def test_contains_client_id(self, dingtalk_manifest): keys = {f.key for f in dingtalk_manifest.config_schema} assert "client_id" in keys def test_contains_client_secret(self, dingtalk_manifest): keys = {f.key for f in dingtalk_manifest.config_schema} assert "client_secret" in keys def test_contains_robot_code(self, dingtalk_manifest): keys = {f.key for f in dingtalk_manifest.config_schema} assert "robot_code" in keys def test_client_id_is_required(self, dingtalk_manifest): field = next(f for f in dingtalk_manifest.config_schema if f.key == "client_id") assert field.required is True def test_client_secret_is_required(self, dingtalk_manifest): field = next(f for f in dingtalk_manifest.config_schema if f.key == "client_secret") assert field.required is True def test_robot_code_is_required(self, dingtalk_manifest): field = next(f for f in dingtalk_manifest.config_schema if f.key == "robot_code") assert field.required is True def test_stream_endpoint_path_in_config_schema(self, dingtalk_manifest): """stream_connector 读取 stream_endpoint_path,应在 config_schema 声明。""" keys = {f.key for f in dingtalk_manifest.config_schema} assert "stream_endpoint_path" in keys # ------------------------------------------------------------------ # capability_requirements 交叉引用完整性 # ------------------------------------------------------------------ @pytest.mark.unit class TestCapabilityRequirementsConsistency: """capability_requirements 交叉引用完整性校验。 每项 capability_requirements 为 ``(能力字段名, (所需配置键清单))``, 所有引用的配置键必须在 config_schema 中声明(F-03 单真相源)。 """ def test_capability_requirements_not_empty(self, dingtalk_manifest): assert len(dingtalk_manifest.capability_requirements) > 0 def test_all_referenced_config_keys_exist_in_schema(self, dingtalk_manifest): schema_keys = {f.key for f in dingtalk_manifest.config_schema} for _capability, required_keys in dingtalk_manifest.capability_requirements: for key in required_keys: assert key in schema_keys, f"capability_requirements 引用的配置键 '{key}' 未在 config_schema 中声明" def test_rich_message_requires_app_credentials(self, dingtalk_manifest): """rich_message 依赖 client_id / client_secret / robot_code。""" req = dict(dingtalk_manifest.capability_requirements) assert "rich_message" in req required = set(req["rich_message"]) assert "client_id" in required assert "client_secret" in required assert "robot_code" in required def test_streaming_requires_app_credentials(self, dingtalk_manifest): req = dict(dingtalk_manifest.capability_requirements) assert "streaming" in req required = set(req["streaming"]) assert {"client_id", "client_secret", "robot_code"} <= required def test_lifecycle_probeable_status_have_empty_requirements(self, dingtalk_manifest): """lifecycle / probeable / status 为基础能力,无额外配置依赖。""" req = dict(dingtalk_manifest.capability_requirements) for cap in ("lifecycle", "probeable", "status"): assert cap in req assert req[cap] == ()