2026-07-04 00:18:04 +08:00
|
|
|
|
"""配置 / 传输 / 能力 DTO 单元测试。
|
|
|
|
|
|
|
|
|
|
|
|
覆盖 ``yuxi.channels.contract.dtos`` 模块下 config / transport / capability
|
|
|
|
|
|
三个子模块的 DTO 构造、默认值、不可变语义与 ``__post_init__`` 校验逻辑。
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
|
|
import dataclasses
|
|
|
|
|
|
from datetime import datetime
|
|
|
|
|
|
from typing import Literal, get_args, get_origin
|
|
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
from yuxi.channels.contract.dtos.capability import (
|
|
|
|
|
|
CapabilityResult,
|
|
|
|
|
|
ChannelCapabilities,
|
|
|
|
|
|
)
|
|
|
|
|
|
from yuxi.channels.contract.dtos.common import Operator, OperatorRole
|
|
|
|
|
|
from yuxi.channels.contract.dtos.config import (
|
|
|
|
|
|
BatchConfigUpdateSuccessItem,
|
|
|
|
|
|
BatchUpdateConfigCmd,
|
|
|
|
|
|
BatchUpdateConfigResult,
|
|
|
|
|
|
ConfigExportCmd,
|
|
|
|
|
|
ConfigExportResult,
|
|
|
|
|
|
ConfigField,
|
|
|
|
|
|
ConfigHistoryEntry,
|
|
|
|
|
|
ConfigHotReloadMode,
|
|
|
|
|
|
ConfigScope,
|
|
|
|
|
|
ConfigUpdateItem,
|
|
|
|
|
|
ConfigValue,
|
|
|
|
|
|
ConfigVersion,
|
|
|
|
|
|
ImportConfigCmd,
|
|
|
|
|
|
ImportConfigResult,
|
|
|
|
|
|
RollbackConfigCmd,
|
|
|
|
|
|
UpdateConfigCmd,
|
|
|
|
|
|
)
|
|
|
|
|
|
from yuxi.channels.contract.dtos.transport import (
|
|
|
|
|
|
PollingConfig,
|
2026-07-06 20:52:20 +08:00
|
|
|
|
PollResult,
|
2026-07-04 00:18:04 +08:00
|
|
|
|
StreamConfig,
|
|
|
|
|
|
StreamConnection,
|
|
|
|
|
|
TransportConfig,
|
|
|
|
|
|
TransportErrorCategory,
|
|
|
|
|
|
)
|
|
|
|
|
|
from yuxi.channels.contract.errors import ValidationError
|
|
|
|
|
|
|
|
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _make_operator() -> Operator:
|
|
|
|
|
|
"""构造测试用操作人。"""
|
|
|
|
|
|
return Operator(user_id="u-1", role=OperatorRole.ADMIN_USER)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
# config.py - 枚举
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestConfigHotReloadMode:
|
|
|
|
|
|
"""配置热更新模式枚举测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_enum_values_are_correct(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
# Assert - 校验四个枚举成员的字符串值
|
|
|
|
|
|
assert ConfigHotReloadMode.OFF == "off"
|
|
|
|
|
|
assert ConfigHotReloadMode.HOT == "hot"
|
|
|
|
|
|
assert ConfigHotReloadMode.RESTART == "restart"
|
|
|
|
|
|
assert ConfigHotReloadMode.HYBRID == "hybrid"
|
|
|
|
|
|
|
|
|
|
|
|
def test_enum_inherits_from_str(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
# Assert - StrEnum 应继承 str 并支持字符串比较
|
|
|
|
|
|
assert issubclass(ConfigHotReloadMode, str)
|
|
|
|
|
|
assert isinstance(ConfigHotReloadMode.HOT, str)
|
|
|
|
|
|
|
|
|
|
|
|
def test_enum_has_four_members(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
members = list(ConfigHotReloadMode)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert len(members) == 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestConfigScope:
|
|
|
|
|
|
"""配置作用域枚举测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_enum_values_are_correct(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
# Assert - 校验四个作用域枚举成员的字符串值
|
|
|
|
|
|
assert ConfigScope.GLOBAL == "global"
|
|
|
|
|
|
assert ConfigScope.CHANNEL == "channel"
|
|
|
|
|
|
assert ConfigScope.ACCOUNT == "account"
|
|
|
|
|
|
assert ConfigScope.PLUGIN == "plugin"
|
|
|
|
|
|
|
|
|
|
|
|
def test_enum_inherits_from_str(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
# Assert - StrEnum 应继承 str 并支持字符串比较
|
|
|
|
|
|
assert issubclass(ConfigScope, str)
|
|
|
|
|
|
assert isinstance(ConfigScope.GLOBAL, str)
|
|
|
|
|
|
|
|
|
|
|
|
def test_enum_has_four_members(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
members = list(ConfigScope)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert len(members) == 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
# config.py - 配置版本与字段
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestConfigVersion:
|
|
|
|
|
|
"""配置版本 DTO 测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_fields_are_assigned(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
now = datetime(2026, 1, 1, 12, 0, 0)
|
|
|
|
|
|
# Act
|
|
|
|
|
|
version = ConfigVersion(key="timeout", version=3, updated_at=now)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert version.key == "timeout"
|
|
|
|
|
|
assert version.version == 3
|
|
|
|
|
|
assert version.updated_at == now
|
|
|
|
|
|
|
|
|
|
|
|
def test_updated_at_accepts_none(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
version = ConfigVersion(key="timeout", version=0, updated_at=None)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert version.updated_at is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_frozen(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
version = ConfigVersion(key="timeout", version=1, updated_at=None)
|
|
|
|
|
|
# Act / Assert - 冻结 dataclass 不允许修改属性
|
|
|
|
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
|
|
|
|
version.version = 2 # type: ignore[misc]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestConfigField:
|
|
|
|
|
|
"""配置字段元信息 DTO 测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_required_fields_are_assigned(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
field = ConfigField(key="timeout", type="int")
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert field.key == "timeout"
|
|
|
|
|
|
assert field.type == "int"
|
|
|
|
|
|
|
|
|
|
|
|
def test_defaults_are_correct(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
field = ConfigField(key="timeout", type="int")
|
|
|
|
|
|
# Assert - 校验默认值
|
|
|
|
|
|
assert field.required is False
|
|
|
|
|
|
assert field.default is None
|
|
|
|
|
|
assert field.hot_reloadable is True
|
2026-07-06 20:52:20 +08:00
|
|
|
|
assert field.sensitive is False
|
2026-07-04 00:18:04 +08:00
|
|
|
|
assert field.constraints is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_custom_values_are_assigned(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
constraints = {"min": 1, "max": 100}
|
|
|
|
|
|
# Act
|
|
|
|
|
|
field = ConfigField(
|
|
|
|
|
|
key="retry",
|
|
|
|
|
|
type="int",
|
|
|
|
|
|
required=True,
|
|
|
|
|
|
default=5,
|
|
|
|
|
|
hot_reloadable=False,
|
|
|
|
|
|
constraints=constraints,
|
|
|
|
|
|
)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert field.required is True
|
|
|
|
|
|
assert field.default == 5
|
|
|
|
|
|
assert field.hot_reloadable is False
|
|
|
|
|
|
assert field.constraints == constraints
|
|
|
|
|
|
|
2026-07-06 20:52:20 +08:00
|
|
|
|
def test_sensitive_field_is_assigned(self):
|
|
|
|
|
|
# Arrange / Act - F-01 sensitive 字段默认 False,可显式声明 True
|
|
|
|
|
|
field = ConfigField(key="bot_token", type="str", required=True, sensitive=True)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert field.sensitive is True
|
|
|
|
|
|
|
2026-07-04 00:18:04 +08:00
|
|
|
|
def test_is_frozen(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
field = ConfigField(key="timeout", type="int")
|
|
|
|
|
|
# Act / Assert
|
|
|
|
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
|
|
|
|
field.key = "other" # type: ignore[misc]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
# config.py - 更新 / 回滚命令
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestUpdateConfigCmd:
|
|
|
|
|
|
"""更新配置命令 DTO 测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_valid_construction_with_defaults(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
operator = _make_operator()
|
|
|
|
|
|
# Act
|
|
|
|
|
|
cmd = UpdateConfigCmd(key="timeout", value=30, operator=operator)
|
|
|
|
|
|
# Assert - 默认作用域 GLOBAL,target 与 expected_version 为 None
|
|
|
|
|
|
assert cmd.key == "timeout"
|
|
|
|
|
|
assert cmd.value == 30
|
|
|
|
|
|
assert cmd.operator is operator
|
|
|
|
|
|
assert cmd.scope is ConfigScope.GLOBAL
|
|
|
|
|
|
assert cmd.target is None
|
|
|
|
|
|
assert cmd.expected_version is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_valid_construction_with_all_fields(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
operator = _make_operator()
|
|
|
|
|
|
# Act
|
|
|
|
|
|
cmd = UpdateConfigCmd(
|
|
|
|
|
|
key="timeout",
|
|
|
|
|
|
value=30,
|
|
|
|
|
|
operator=operator,
|
|
|
|
|
|
scope=ConfigScope.CHANNEL,
|
|
|
|
|
|
target="feishu",
|
|
|
|
|
|
expected_version=2,
|
|
|
|
|
|
)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert cmd.scope is ConfigScope.CHANNEL
|
|
|
|
|
|
assert cmd.target == "feishu"
|
|
|
|
|
|
assert cmd.expected_version == 2
|
|
|
|
|
|
|
|
|
|
|
|
def test_empty_key_raises_validation_error(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
operator = _make_operator()
|
|
|
|
|
|
# Act / Assert - key 为空字符串应抛出 ValidationError
|
|
|
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
|
|
|
|
UpdateConfigCmd(key="", value=30, operator=operator)
|
|
|
|
|
|
assert exc_info.value.field == "key"
|
|
|
|
|
|
|
|
|
|
|
|
def test_non_positive_expected_version_raises(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
operator = _make_operator()
|
|
|
|
|
|
# Act / Assert - expected_version <= 0 应抛出 ValidationError
|
|
|
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
|
|
|
|
UpdateConfigCmd(key="timeout", value=30, operator=operator, expected_version=0)
|
|
|
|
|
|
assert exc_info.value.field == "expected_version"
|
|
|
|
|
|
|
|
|
|
|
|
def test_target_required_for_channel_scope(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
operator = _make_operator()
|
|
|
|
|
|
# Act / Assert - CHANNEL 作用域下 target 为空应抛出 ValidationError
|
|
|
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
2026-07-06 20:52:20 +08:00
|
|
|
|
UpdateConfigCmd(key="timeout", value=30, operator=operator, scope=ConfigScope.CHANNEL)
|
2026-07-04 00:18:04 +08:00
|
|
|
|
assert exc_info.value.field == "target"
|
|
|
|
|
|
|
|
|
|
|
|
def test_target_required_for_account_scope(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
operator = _make_operator()
|
|
|
|
|
|
# Act / Assert - ACCOUNT 作用域下 target 为空应抛出 ValidationError
|
|
|
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
2026-07-06 20:52:20 +08:00
|
|
|
|
UpdateConfigCmd(key="timeout", value=30, operator=operator, scope=ConfigScope.ACCOUNT)
|
2026-07-04 00:18:04 +08:00
|
|
|
|
assert exc_info.value.field == "target"
|
|
|
|
|
|
|
|
|
|
|
|
def test_target_required_for_plugin_scope(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
operator = _make_operator()
|
|
|
|
|
|
# Act / Assert - PLUGIN 作用域下 target 为空应抛出 ValidationError
|
|
|
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
2026-07-06 20:52:20 +08:00
|
|
|
|
UpdateConfigCmd(key="timeout", value=30, operator=operator, scope=ConfigScope.PLUGIN)
|
2026-07-04 00:18:04 +08:00
|
|
|
|
assert exc_info.value.field == "target"
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_frozen(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
operator = _make_operator()
|
|
|
|
|
|
cmd = UpdateConfigCmd(key="timeout", value=30, operator=operator)
|
|
|
|
|
|
# Act / Assert
|
|
|
|
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
|
|
|
|
cmd.value = 60 # type: ignore[misc]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestRollbackConfigCmd:
|
|
|
|
|
|
"""回滚配置命令 DTO 测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_valid_construction_with_defaults(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
operator = _make_operator()
|
|
|
|
|
|
# Act
|
|
|
|
|
|
cmd = RollbackConfigCmd(key="timeout", target_version=1, operator=operator)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert cmd.key == "timeout"
|
|
|
|
|
|
assert cmd.target_version == 1
|
|
|
|
|
|
assert cmd.operator is operator
|
|
|
|
|
|
assert cmd.scope is ConfigScope.GLOBAL
|
|
|
|
|
|
assert cmd.target is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_empty_key_raises_validation_error(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
operator = _make_operator()
|
|
|
|
|
|
# Act / Assert - key 为空字符串应抛出 ValidationError
|
|
|
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
|
|
|
|
RollbackConfigCmd(key="", target_version=1, operator=operator)
|
|
|
|
|
|
assert exc_info.value.field == "key"
|
|
|
|
|
|
|
|
|
|
|
|
def test_non_positive_target_version_raises(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
operator = _make_operator()
|
|
|
|
|
|
# Act / Assert - target_version <= 0 应抛出 ValidationError
|
|
|
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
|
|
|
|
RollbackConfigCmd(key="timeout", target_version=0, operator=operator)
|
|
|
|
|
|
assert exc_info.value.field == "target_version"
|
|
|
|
|
|
|
|
|
|
|
|
def test_target_required_for_channel_scope(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
operator = _make_operator()
|
|
|
|
|
|
# Act / Assert - CHANNEL 作用域下 target 为空应抛出 ValidationError
|
|
|
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
|
|
|
|
RollbackConfigCmd(
|
|
|
|
|
|
key="timeout",
|
|
|
|
|
|
target_version=1,
|
|
|
|
|
|
operator=operator,
|
|
|
|
|
|
scope=ConfigScope.CHANNEL,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert exc_info.value.field == "target"
|
|
|
|
|
|
|
|
|
|
|
|
def test_target_required_for_account_scope(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
operator = _make_operator()
|
|
|
|
|
|
# Act / Assert - ACCOUNT 作用域下 target 为空应抛出 ValidationError
|
|
|
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
|
|
|
|
RollbackConfigCmd(
|
|
|
|
|
|
key="timeout",
|
|
|
|
|
|
target_version=1,
|
|
|
|
|
|
operator=operator,
|
|
|
|
|
|
scope=ConfigScope.ACCOUNT,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert exc_info.value.field == "target"
|
|
|
|
|
|
|
|
|
|
|
|
def test_target_required_for_plugin_scope(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
operator = _make_operator()
|
|
|
|
|
|
# Act / Assert - PLUGIN 作用域下 target 为空应抛出 ValidationError
|
|
|
|
|
|
with pytest.raises(ValidationError) as exc_info:
|
|
|
|
|
|
RollbackConfigCmd(
|
|
|
|
|
|
key="timeout",
|
|
|
|
|
|
target_version=1,
|
|
|
|
|
|
operator=operator,
|
|
|
|
|
|
scope=ConfigScope.PLUGIN,
|
|
|
|
|
|
)
|
|
|
|
|
|
assert exc_info.value.field == "target"
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_frozen(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
operator = _make_operator()
|
|
|
|
|
|
cmd = RollbackConfigCmd(key="timeout", target_version=1, operator=operator)
|
|
|
|
|
|
# Act / Assert
|
|
|
|
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
|
|
|
|
cmd.target_version = 2 # type: ignore[misc]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
# config.py - 配置值与历史
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestConfigValue:
|
|
|
|
|
|
"""配置值 DTO 测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_fields_are_assigned(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
value = ConfigValue(
|
|
|
|
|
|
key="timeout",
|
|
|
|
|
|
value=30,
|
|
|
|
|
|
version=2,
|
|
|
|
|
|
scope=ConfigScope.GLOBAL,
|
|
|
|
|
|
)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert value.key == "timeout"
|
|
|
|
|
|
assert value.value == 30
|
|
|
|
|
|
assert value.version == 2
|
|
|
|
|
|
assert value.scope is ConfigScope.GLOBAL
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_frozen(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
value = ConfigValue(key="timeout", value=30, version=2, scope=ConfigScope.GLOBAL)
|
|
|
|
|
|
# Act / Assert
|
|
|
|
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
|
|
|
|
value.value = 60 # type: ignore[misc]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestConfigHistoryEntry:
|
|
|
|
|
|
"""配置历史版本条目 DTO 测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_fields_are_assigned(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
now = datetime(2026, 1, 1, 12, 0, 0)
|
|
|
|
|
|
# Act
|
|
|
|
|
|
entry = ConfigHistoryEntry(version=2, value=30, updated_at=now)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert entry.version == 2
|
|
|
|
|
|
assert entry.value == 30
|
|
|
|
|
|
assert entry.updated_at == now
|
|
|
|
|
|
|
|
|
|
|
|
def test_updated_at_accepts_none(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
entry = ConfigHistoryEntry(version=1, value="v", updated_at=None)
|
|
|
|
|
|
# Assert - 历史数据未存储时间戳时为 None
|
|
|
|
|
|
assert entry.updated_at is None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
# config.py - 导入 / 导出
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestConfigExportCmd:
|
|
|
|
|
|
"""配置导出命令 DTO 测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_defaults_are_correct(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
operator = _make_operator()
|
|
|
|
|
|
# Act
|
|
|
|
|
|
cmd = ConfigExportCmd(operator=operator)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert cmd.operator is operator
|
|
|
|
|
|
assert cmd.scope is ConfigScope.GLOBAL
|
|
|
|
|
|
assert cmd.target is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_custom_scope_and_target(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
operator = _make_operator()
|
|
|
|
|
|
# Act
|
2026-07-06 20:52:20 +08:00
|
|
|
|
cmd = ConfigExportCmd(operator=operator, scope=ConfigScope.CHANNEL, target="feishu")
|
2026-07-04 00:18:04 +08:00
|
|
|
|
# Assert
|
|
|
|
|
|
assert cmd.scope is ConfigScope.CHANNEL
|
|
|
|
|
|
assert cmd.target == "feishu"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestConfigExportResult:
|
|
|
|
|
|
"""配置导出结果 DTO 测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_required_fields_are_assigned(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
now = datetime(2026, 1, 1, 12, 0, 0)
|
|
|
|
|
|
data = {"timeout": 30}
|
|
|
|
|
|
# Act
|
|
|
|
|
|
result = ConfigExportResult(
|
|
|
|
|
|
scope=ConfigScope.GLOBAL,
|
|
|
|
|
|
config_data=data,
|
|
|
|
|
|
version=1,
|
|
|
|
|
|
exported_at=now,
|
|
|
|
|
|
)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert result.scope is ConfigScope.GLOBAL
|
|
|
|
|
|
assert result.config_data == data
|
|
|
|
|
|
assert result.version == 1
|
|
|
|
|
|
assert result.exported_at == now
|
|
|
|
|
|
assert result.target is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_target_is_optional(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
now = datetime(2026, 1, 1, 12, 0, 0)
|
|
|
|
|
|
# Act
|
|
|
|
|
|
result = ConfigExportResult(
|
|
|
|
|
|
scope=ConfigScope.CHANNEL,
|
|
|
|
|
|
config_data={},
|
|
|
|
|
|
version=1,
|
|
|
|
|
|
exported_at=now,
|
|
|
|
|
|
target="feishu",
|
|
|
|
|
|
)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert result.target == "feishu"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestImportConfigCmd:
|
|
|
|
|
|
"""配置导入命令 DTO 测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_defaults_are_correct(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
operator = _make_operator()
|
|
|
|
|
|
data = {"timeout": 30}
|
|
|
|
|
|
# Act
|
|
|
|
|
|
cmd = ImportConfigCmd(operator=operator, config_data=data)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert cmd.operator is operator
|
|
|
|
|
|
assert cmd.config_data == data
|
|
|
|
|
|
assert cmd.scope is ConfigScope.GLOBAL
|
|
|
|
|
|
assert cmd.target is None
|
|
|
|
|
|
assert cmd.overwrite is False
|
|
|
|
|
|
assert cmd.dry_run is False
|
|
|
|
|
|
|
|
|
|
|
|
def test_custom_flags(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
operator = _make_operator()
|
|
|
|
|
|
# Act
|
|
|
|
|
|
cmd = ImportConfigCmd(
|
|
|
|
|
|
operator=operator,
|
|
|
|
|
|
config_data={},
|
|
|
|
|
|
scope=ConfigScope.CHANNEL,
|
|
|
|
|
|
target="feishu",
|
|
|
|
|
|
overwrite=True,
|
|
|
|
|
|
dry_run=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert cmd.scope is ConfigScope.CHANNEL
|
|
|
|
|
|
assert cmd.target == "feishu"
|
|
|
|
|
|
assert cmd.overwrite is True
|
|
|
|
|
|
assert cmd.dry_run is True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestImportConfigResult:
|
|
|
|
|
|
"""配置导入结果 DTO 测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_required_fields_are_assigned(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
now = datetime(2026, 1, 1, 12, 0, 0)
|
|
|
|
|
|
failed_keys = ("bad_key", "missing_key")
|
|
|
|
|
|
# Act
|
|
|
|
|
|
result = ImportConfigResult(
|
|
|
|
|
|
imported_count=5,
|
|
|
|
|
|
skipped_count=2,
|
|
|
|
|
|
failed_count=2,
|
|
|
|
|
|
failed_keys=failed_keys,
|
|
|
|
|
|
imported_at=now,
|
|
|
|
|
|
)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert result.imported_count == 5
|
|
|
|
|
|
assert result.skipped_count == 2
|
|
|
|
|
|
assert result.failed_count == 2
|
|
|
|
|
|
assert result.failed_keys == failed_keys
|
|
|
|
|
|
assert result.imported_at == now
|
|
|
|
|
|
|
|
|
|
|
|
def test_failed_keys_is_tuple(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
now = datetime(2026, 1, 1, 12, 0, 0)
|
|
|
|
|
|
# Act
|
|
|
|
|
|
result = ImportConfigResult(
|
|
|
|
|
|
imported_count=0,
|
|
|
|
|
|
skipped_count=0,
|
|
|
|
|
|
failed_count=0,
|
|
|
|
|
|
failed_keys=(),
|
|
|
|
|
|
imported_at=now,
|
|
|
|
|
|
)
|
|
|
|
|
|
# Assert - failed_keys 支持空元组
|
|
|
|
|
|
assert result.failed_keys == ()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
# config.py - 批量更新
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestConfigUpdateItem:
|
|
|
|
|
|
"""单条配置更新项 DTO 测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_required_fields_are_assigned(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
item = ConfigUpdateItem(key="timeout", value=30)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert item.key == "timeout"
|
|
|
|
|
|
assert item.value == 30
|
|
|
|
|
|
assert item.expected_version is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_expected_version_can_be_set(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
item = ConfigUpdateItem(key="timeout", value=30, expected_version=2)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert item.expected_version == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestBatchConfigUpdateSuccessItem:
|
|
|
|
|
|
"""批量配置更新成功条目 DTO 测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_fields_are_assigned(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
item = BatchConfigUpdateSuccessItem(key="timeout", new_version=3)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert item.key == "timeout"
|
|
|
|
|
|
assert item.new_version == 3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestBatchUpdateConfigCmd:
|
|
|
|
|
|
"""批量更新配置命令 DTO 测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_required_fields_are_assigned(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
operator = _make_operator()
|
|
|
|
|
|
updates = (ConfigUpdateItem(key="timeout", value=30),)
|
|
|
|
|
|
# Act
|
|
|
|
|
|
cmd = BatchUpdateConfigCmd(operator=operator, updates=updates)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert cmd.operator is operator
|
|
|
|
|
|
assert cmd.updates == updates
|
|
|
|
|
|
assert cmd.scope is ConfigScope.GLOBAL
|
|
|
|
|
|
assert cmd.target is None
|
|
|
|
|
|
assert cmd.stop_on_error is False
|
|
|
|
|
|
|
|
|
|
|
|
def test_custom_values_are_assigned(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
operator = _make_operator()
|
|
|
|
|
|
updates = (ConfigUpdateItem(key="a", value=1), ConfigUpdateItem(key="b", value=2))
|
|
|
|
|
|
# Act
|
|
|
|
|
|
cmd = BatchUpdateConfigCmd(
|
|
|
|
|
|
operator=operator,
|
|
|
|
|
|
updates=updates,
|
|
|
|
|
|
scope=ConfigScope.CHANNEL,
|
|
|
|
|
|
target="feishu",
|
|
|
|
|
|
stop_on_error=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert cmd.scope is ConfigScope.CHANNEL
|
|
|
|
|
|
assert cmd.target == "feishu"
|
|
|
|
|
|
assert cmd.stop_on_error is True
|
|
|
|
|
|
assert len(cmd.updates) == 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestBatchUpdateConfigResult:
|
|
|
|
|
|
"""批量更新配置结果 DTO 测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_fields_are_assigned(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
succeeded = (BatchConfigUpdateSuccessItem(key="timeout", new_version=2),)
|
|
|
|
|
|
failed: tuple = ()
|
|
|
|
|
|
# Act
|
|
|
|
|
|
result = BatchUpdateConfigResult(total=1, succeeded=succeeded, failed=failed)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert result.total == 1
|
|
|
|
|
|
assert result.succeeded == succeeded
|
|
|
|
|
|
assert result.failed == failed
|
|
|
|
|
|
|
|
|
|
|
|
def test_empty_result(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
result = BatchUpdateConfigResult(total=0, succeeded=(), failed=())
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert result.total == 0
|
|
|
|
|
|
assert result.succeeded == ()
|
|
|
|
|
|
assert result.failed == ()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
# transport.py
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestTransportErrorCategory:
|
|
|
|
|
|
"""传输错误分类类型测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_literal_type(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
origin = get_origin(TransportErrorCategory)
|
|
|
|
|
|
# Assert - 应为 Literal 类型别名
|
|
|
|
|
|
assert origin is Literal
|
|
|
|
|
|
|
|
|
|
|
|
def test_has_four_values(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
args = get_args(TransportErrorCategory)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert args == ("auth_expired", "rate_limited", "transient", "permanent")
|
|
|
|
|
|
assert len(args) == 4
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestPollingConfig:
|
|
|
|
|
|
"""轮询模式配置 DTO 测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_defaults_are_correct(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
config = PollingConfig()
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert config.long_poll_timeout_ms == 0
|
|
|
|
|
|
assert config.poll_interval_ms == 0
|
|
|
|
|
|
assert config.max_batch_size == 100
|
|
|
|
|
|
|
|
|
|
|
|
def test_custom_values_are_assigned(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
config = PollingConfig(long_poll_timeout_ms=5000, poll_interval_ms=1000, max_batch_size=50)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert config.long_poll_timeout_ms == 5000
|
|
|
|
|
|
assert config.poll_interval_ms == 1000
|
|
|
|
|
|
assert config.max_batch_size == 50
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestStreamConfig:
|
|
|
|
|
|
"""流式连接配置 DTO 测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_defaults_are_correct(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
config = StreamConfig()
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert config.heartbeat_interval_ms == 30000
|
|
|
|
|
|
assert config.connect_timeout_ms == 10000
|
|
|
|
|
|
|
|
|
|
|
|
def test_custom_values_are_assigned(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
config = StreamConfig(heartbeat_interval_ms=15000, connect_timeout_ms=5000)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert config.heartbeat_interval_ms == 15000
|
|
|
|
|
|
assert config.connect_timeout_ms == 5000
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestTransportConfig:
|
|
|
|
|
|
"""传输层全局配置 DTO 测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_defaults_are_correct(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
config = TransportConfig()
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert config.stall_timeout_ms == 120000
|
|
|
|
|
|
assert config.backoff_schedule == (1.0, 2.0, 5.0, 10.0, 30.0)
|
|
|
|
|
|
assert config.backoff_jitter == 0.2
|
|
|
|
|
|
assert config.max_restart_attempts is None
|
|
|
|
|
|
assert config.graceful_shutdown_timeout_s == 10.0
|
|
|
|
|
|
|
|
|
|
|
|
def test_backoff_schedule_is_tuple(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
config = TransportConfig()
|
|
|
|
|
|
# Assert - backoff_schedule 应为 tuple 保证不可变
|
|
|
|
|
|
assert isinstance(config.backoff_schedule, tuple)
|
|
|
|
|
|
|
|
|
|
|
|
def test_custom_values_are_assigned(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
config = TransportConfig(
|
|
|
|
|
|
stall_timeout_ms=60000,
|
|
|
|
|
|
backoff_schedule=(1.0, 2.0),
|
|
|
|
|
|
backoff_jitter=0.5,
|
|
|
|
|
|
max_restart_attempts=3,
|
|
|
|
|
|
graceful_shutdown_timeout_s=30.0,
|
|
|
|
|
|
)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert config.stall_timeout_ms == 60000
|
|
|
|
|
|
assert config.backoff_schedule == (1.0, 2.0)
|
|
|
|
|
|
assert config.backoff_jitter == 0.5
|
|
|
|
|
|
assert config.max_restart_attempts == 3
|
|
|
|
|
|
assert config.graceful_shutdown_timeout_s == 30.0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestPollResult:
|
|
|
|
|
|
"""轮询结果 DTO 测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_defaults_are_correct(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
result = PollResult()
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert result.messages == ()
|
|
|
|
|
|
assert result.next_cursor is None
|
|
|
|
|
|
assert result.error is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_custom_values_are_assigned(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
messages = ({"id": "1"}, {"id": "2"})
|
|
|
|
|
|
# Act
|
|
|
|
|
|
result = PollResult(messages=messages, next_cursor="cursor-1")
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert result.messages == messages
|
|
|
|
|
|
assert result.next_cursor == "cursor-1"
|
|
|
|
|
|
assert result.error is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_messages_is_tuple(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
result = PollResult(messages=({"id": "1"},))
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert isinstance(result.messages, tuple)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestStreamConnection:
|
|
|
|
|
|
"""流式连接句柄 DTO 测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_required_callable_fields_are_assigned(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
receive = MagicMock()
|
|
|
|
|
|
send = MagicMock()
|
|
|
|
|
|
close = MagicMock()
|
|
|
|
|
|
# Act
|
|
|
|
|
|
conn = StreamConnection(receive=receive, send=send, close=close)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert conn.receive is receive
|
|
|
|
|
|
assert conn.send is send
|
|
|
|
|
|
assert conn.close is close
|
|
|
|
|
|
|
|
|
|
|
|
def test_extra_defaults_to_none(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
receive = MagicMock()
|
|
|
|
|
|
send = MagicMock()
|
|
|
|
|
|
close = MagicMock()
|
|
|
|
|
|
# Act
|
|
|
|
|
|
conn = StreamConnection(receive=receive, send=send, close=close)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert conn.extra is None
|
|
|
|
|
|
|
|
|
|
|
|
def test_extra_can_be_set(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
receive = MagicMock()
|
|
|
|
|
|
send = MagicMock()
|
|
|
|
|
|
close = MagicMock()
|
|
|
|
|
|
# Act
|
|
|
|
|
|
conn = StreamConnection(receive=receive, send=send, close=close, extra={"session": "s1"})
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert conn.extra == {"session": "s1"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
# capability.py
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestChannelCapabilities:
|
|
|
|
|
|
"""渠道能力集合 DTO 测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_all_defaults_are_correct(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
caps = ChannelCapabilities()
|
|
|
|
|
|
# Assert - 所有布尔能力默认 False
|
|
|
|
|
|
assert caps.rich_message is False
|
|
|
|
|
|
assert caps.streaming is False
|
|
|
|
|
|
assert caps.typing_indicator is False
|
|
|
|
|
|
assert caps.message_edit is False
|
|
|
|
|
|
assert caps.message_recall is False
|
|
|
|
|
|
assert caps.supports_reaction is False
|
|
|
|
|
|
assert caps.supports_pin is False
|
|
|
|
|
|
assert caps.supports_card_update is False
|
|
|
|
|
|
assert caps.supports_card_update_streaming is False
|
|
|
|
|
|
assert caps.supports_image_inbound is False
|
|
|
|
|
|
assert caps.supports_video_inbound is False
|
|
|
|
|
|
assert caps.supports_image_outbound is False
|
|
|
|
|
|
assert caps.supports_video_outbound is False
|
|
|
|
|
|
assert caps.mention is False
|
|
|
|
|
|
assert caps.command is False
|
|
|
|
|
|
assert caps.directory is False
|
|
|
|
|
|
assert caps.doctor is False
|
|
|
|
|
|
assert caps.whitelist is False
|
|
|
|
|
|
assert caps.wizard is False
|
|
|
|
|
|
assert caps.tools is False
|
|
|
|
|
|
assert caps.status is False
|
|
|
|
|
|
assert caps.probeable is False
|
|
|
|
|
|
assert caps.identity_resolver is False
|
|
|
|
|
|
assert caps.lifecycle is False
|
|
|
|
|
|
assert caps.supports_qr_login is False
|
|
|
|
|
|
assert caps.agent_collaboration is False
|
|
|
|
|
|
|
|
|
|
|
|
def test_custom_values_are_assigned(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
caps = ChannelCapabilities(
|
|
|
|
|
|
rich_message=True,
|
|
|
|
|
|
streaming=True,
|
|
|
|
|
|
wizard=True,
|
|
|
|
|
|
tools=True,
|
|
|
|
|
|
supports_card_update_streaming=True,
|
|
|
|
|
|
)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert caps.rich_message is True
|
|
|
|
|
|
assert caps.streaming is True
|
|
|
|
|
|
assert caps.wizard is True
|
|
|
|
|
|
assert caps.tools is True
|
|
|
|
|
|
assert caps.supports_card_update_streaming is True
|
|
|
|
|
|
|
|
|
|
|
|
def test_is_frozen(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
caps = ChannelCapabilities()
|
|
|
|
|
|
# Act / Assert - 冻结 dataclass 不允许修改属性
|
|
|
|
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
|
|
|
|
caps.rich_message = True # type: ignore[misc]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.unit
|
|
|
|
|
|
class TestCapabilityResult:
|
|
|
|
|
|
"""能力探测结果 DTO 测试。"""
|
|
|
|
|
|
|
|
|
|
|
|
def test_required_fields_are_assigned(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
result = CapabilityResult(capability="streaming", declared=True)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert result.capability == "streaming"
|
|
|
|
|
|
assert result.declared is True
|
|
|
|
|
|
|
|
|
|
|
|
def test_defaults_are_correct(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
result = CapabilityResult(capability="streaming", declared=True)
|
|
|
|
|
|
# Assert - proven 与 degraded 默认 False
|
|
|
|
|
|
assert result.proven is False
|
|
|
|
|
|
assert result.degraded is False
|
|
|
|
|
|
|
|
|
|
|
|
def test_custom_values_are_assigned(self):
|
|
|
|
|
|
# Arrange
|
|
|
|
|
|
# Act
|
|
|
|
|
|
result = CapabilityResult(
|
|
|
|
|
|
capability="streaming",
|
|
|
|
|
|
declared=True,
|
|
|
|
|
|
proven=True,
|
|
|
|
|
|
degraded=False,
|
|
|
|
|
|
)
|
|
|
|
|
|
# Assert
|
|
|
|
|
|
assert result.proven is True
|
|
|
|
|
|
assert result.degraded is False
|