ForcePilot/backend/test/unit/channels/adapters/test_mappers.py
Kris b4ea53a447 test: 修复多单元测试用例,完善测试覆盖与校验逻辑
1.  修复钉钉客户端API调用参数格式,将unionid从URL路径移至params
2.  补充服务账号mock的active状态,完善权限校验测试前置条件
3.  更新钉钉消息发送缓存计数断言,修正缓存键数量预期
4.  补充分类阶段测试的日志mock与新增字段校验
5.  新增事务端口的get_session方法测试,完善事务上下文接口覆盖
6.  修正代理访问配置默认权限等级为global,更新对应测试用例
7.  重构权限阶段未知请求处理逻辑,改为失败关闭而非默认管理员权限
8.  更新ack记录失败处理逻辑,改为抛出异常而非仅告警
9.  新增能力校验阶段异常降级测试用例
10. 重构发件箱持久化阶段测试,移除冗余查询并验证结果
11. 新增消息撤回测试的会话ID缓存获取逻辑
12. 修复映射器测试的账号ID匹配逻辑
13. 新增输入幂等阶段的缓存锁依赖,完善并发测试支持
14. 补充围栏代际回滚方法的完整测试覆盖
15. 新增路由匹配注册表的原子替换测试用例
16. 新增会话统计接口的截断状态与总数统计测试
17. 修正配置schema统计计数,更新热更新字段与总条目数
18. 补充调度任务处理器的缓存端口依赖,完善健康检查与锁机制测试
2026-07-09 13:53:26 +08:00

755 lines
21 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.

"""yuxi.channels.adapters.mappers 单元测试。
覆盖 ORM ↔ dataclass 映射函数,包括 ``orm_to_channel_account`` /
``channel_account_for_write`` / ``orm_to_channel_session`` /
``orm_to_pairing`` / ``orm_to_audit_log`` / ``orm_to_outbox_entry`` /
``orm_to_user_identity``,使用 ``patch`` 替换
加解密函数,不依赖真实密钥配置。
"""
from __future__ import annotations
from datetime import date, datetime
from unittest.mock import MagicMock, patch
import pytest
from yuxi.channels.adapters import mappers
from yuxi.channels.contract.dtos.audit import AuditEntry, AuditOperationType
from yuxi.channels.contract.dtos.channel import (
AccountStatus,
ChannelAccount,
ChannelSession,
ChannelType,
UserIdentity,
)
from yuxi.channels.contract.dtos.outbox import (
MessageDurabilityPolicy,
OutboxEntry,
OutboxStatus,
)
from yuxi.channels.contract.dtos.pairing import PairingRecord, PairingStatus
from yuxi.channels.contract.dtos.route import RouteBindingRule
from yuxi.channels.contract.errors import DependencyError
pytestmark = pytest.mark.unit
def _make_account_orm() -> MagicMock:
"""构造 ChannelAccount ORM 桩。"""
orm = MagicMock()
orm.channel_type = "feishu"
orm.account_id = "acc-1"
orm.display_name = "Account 1"
orm.config = {"token": "enc"}
orm.enabled = True
orm.status = "active"
orm.created_at = datetime(2026, 1, 1)
orm.updated_at = datetime(2026, 1, 1)
orm.transport_cursor = "cursor-1"
orm.last_rotated_at = None
orm.onboarding_status = "online"
orm.service_user_uid = None
orm.credential_ref = None
orm.credential_version = 0
orm.last_error = None
orm.plugin_status = "stopped"
orm.version = 1
return orm
def _make_session_orm() -> MagicMock:
"""构造 ChannelSession ORM 桩。"""
orm = MagicMock()
orm.session_id = "sess-1"
orm.channel_type = "feishu"
orm.account_id = 100
orm.peer_id = "peer-1"
orm.chat_type = "p2p"
orm.conversation_id = 200
orm.unified_identity_id = "uid-1"
orm.owner_peer_id = None
orm.is_temporary = False
orm.created_at = datetime(2026, 1, 1)
orm.updated_at = datetime(2026, 1, 1)
orm.deleted_at = None
orm.closed_at = None
orm.last_message_at = None
return orm
def _make_pairing_orm() -> MagicMock:
"""构造 ChannelPairing ORM 桩。"""
orm = MagicMock()
orm.pairing_id = "pair-1"
orm.account_id = 100
orm.peer_id = "peer-1"
orm.status = "pending"
orm.peer_name = "Peer One"
orm.approver_id = None
orm.approved_at = None
orm.rejected_at = None
orm.revoked_at = None
orm.expired_at = None
orm.reason = None
orm.created_at = datetime(2026, 1, 1)
orm.updated_at = datetime(2026, 1, 1)
orm.expires_at = None
orm.requested_at = None
orm.version = 1
return orm
def _make_audit_orm() -> MagicMock:
"""构造 ChannelAuditLog ORM 桩。"""
orm = MagicMock()
orm.operator = "admin-1"
orm.operation = "account_created"
orm.target_channel = "feishu"
orm.result = "success"
orm.timestamp = datetime(2026, 1, 1)
orm.params_summary = {"key": "value"}
orm.trace_id = "trace-1"
orm.source_ip = "127.0.0.1"
orm.request_id = "req-1"
orm.message_id = None
orm.content_summary = None
orm.target_account = "acc-1"
return orm
def _make_outbox_orm() -> MagicMock:
"""构造 ChannelOutboxEntry ORM 桩。"""
orm = MagicMock()
orm.outbox_id = 1
orm.message_id = 500
orm.account_id = 100
orm.status = "pending"
orm.durability_policy = "required"
orm.retry_count = 0
orm.max_retry = 3
orm.next_retry_at = None
orm.last_error = None
orm.created_at = datetime(2026, 1, 1)
orm.updated_at = datetime(2026, 1, 1)
orm.expires_at = None
orm.channel_msg_id = None
orm.version = 1
orm.channel_session_id = 200
orm.latency_ms = None
orm.funnel_node = None
return orm
def _make_identity_orm() -> MagicMock:
"""构造 ChannelUserIdentity ORM 桩。"""
orm = MagicMock()
orm.identity_id = "uid-1"
orm.user_id = 300
orm.identity_type = "email"
orm.identity_value = "user@example.com"
orm.channel_type = "feishu"
orm.channel_sender_id = "sender-1"
orm.source = "explicit_mapping"
orm.created_at = datetime(2026, 1, 1)
orm.updated_at = datetime(2026, 1, 1)
return orm
def _make_route_binding_orm() -> MagicMock:
"""构造 ChannelRouteBinding ORM 桩。"""
orm = MagicMock()
orm.binding_id = "bind-1"
orm.channel_type = "feishu"
orm.account_id = "acc-1"
orm.match_source = "session_key"
orm.match_value = "peer-1"
orm.agent_binding = "agent-slug"
orm.enabled = True
orm.description = "test binding"
orm.version = 1
orm.created_by = "admin-1"
orm.updated_by = "admin-1"
orm.created_at = datetime(2026, 1, 1)
orm.updated_at = datetime(2026, 1, 1)
return orm
@pytest.mark.unit
class TestOrmToChannelAccount:
def test_returns_channel_account_with_decrypted_config(self):
# Arrange
orm = _make_account_orm()
# Act
with patch(
"yuxi.channels.adapters.mappers.decrypt_sensitive_fields",
return_value={"token": "plain"},
):
result = mappers.orm_to_channel_account(orm)
# Assert
assert isinstance(result, ChannelAccount)
assert result.account_id == "acc-1"
assert result.config == {"token": "plain"}
assert result.channel_type == ChannelType("feishu")
assert result.status == AccountStatus.ACTIVE
def test_raises_dependency_error_when_decryption_fails(self):
# Arrange
orm = _make_account_orm()
# Act / Assert
with patch(
"yuxi.channels.adapters.mappers.decrypt_sensitive_fields",
side_effect=ValueError("bad key"),
):
with pytest.raises(DependencyError):
mappers.orm_to_channel_account(orm)
@pytest.mark.unit
class TestChannelAccountForWrite:
def test_encrypts_config_field(self):
# Arrange
data = {"account_id": "acc-1", "config": {"token": "plain"}}
# Act
with patch(
"yuxi.channels.adapters.mappers.encrypt_sensitive_fields",
return_value={"token": "enc"},
) as enc_mock:
result = mappers.channel_account_for_write(data)
# Assert
assert result["config"] == {"token": "enc"}
enc_mock.assert_called_once_with({"token": "plain"})
def test_returns_new_dict_without_mutating_input(self):
# Arrange
data = {"account_id": "acc-1", "config": {"token": "plain"}}
# Act
with patch(
"yuxi.channels.adapters.mappers.encrypt_sensitive_fields",
return_value={"token": "enc"},
):
result = mappers.channel_account_for_write(data)
# Assert
assert data["config"] == {"token": "plain"}
assert result is not data
@pytest.mark.unit
class TestOrmToChannelSession:
def test_returns_channel_session_with_str_ids(self):
# Arrange
orm = _make_session_orm()
# Act
result = mappers.orm_to_channel_session(orm, _make_account_orm())
# Assert
assert isinstance(result, ChannelSession)
assert result.session_id == "sess-1"
assert result.account_id == "acc-1"
assert result.conversation_id == "200"
assert result.channel_type == ChannelType("feishu")
def test_handles_none_conversation_id(self):
# Arrange
orm = _make_session_orm()
orm.conversation_id = None
# Act
result = mappers.orm_to_channel_session(orm, _make_account_orm())
# Assert
assert result.conversation_id is None
@pytest.mark.unit
class TestOrmToPairing:
def test_returns_pairing_record_without_account_orm(self):
# Arrange
orm = _make_pairing_orm()
# Act
result = mappers.orm_to_pairing(orm)
# Assert
assert isinstance(result, PairingRecord)
assert result.pairing_id == "pair-1"
assert result.channel_account_id == "100"
assert result.channel_type is None
assert result.status == PairingStatus.PENDING
def test_returns_pairing_record_with_account_orm(self):
# Arrange
orm = _make_pairing_orm()
account_orm = _make_account_orm()
# Act
result = mappers.orm_to_pairing(orm, account_orm)
# Assert
assert result.channel_account_id == "acc-1"
assert result.channel_type == ChannelType("feishu")
@pytest.mark.unit
class TestOrmToAuditLog:
def test_returns_audit_entry(self):
# Arrange
orm = _make_audit_orm()
# Act
result = mappers.orm_to_audit_log(orm)
# Assert
assert isinstance(result, AuditEntry)
assert result.operator == "admin-1"
assert result.operation == AuditOperationType.ACCOUNT_CREATED
assert result.target == "feishu"
assert result.target_channel == "feishu"
@pytest.mark.unit
class TestOrmToOutboxEntry:
def test_returns_outbox_entry_with_str_ids(self):
# Arrange
orm = _make_outbox_orm()
# Act
result = mappers.orm_to_outbox_entry(orm, _make_account_orm())
# Assert
assert isinstance(result, OutboxEntry)
assert result.outbox_id == 1
assert result.message_id == "500"
assert result.channel_account_id == "acc-1"
assert result.channel_session_id == "200"
assert result.status == OutboxStatus.PENDING
assert result.durability_policy == MessageDurabilityPolicy.REQUIRED
def test_handles_none_channel_session_id(self):
# Arrange
orm = _make_outbox_orm()
orm.channel_session_id = None
# Act
result = mappers.orm_to_outbox_entry(orm, _make_account_orm())
# Assert
assert result.channel_session_id is None
@pytest.mark.unit
class TestOrmToUserIdentity:
def test_returns_user_identity_with_str_user_id(self):
# Arrange
orm = _make_identity_orm()
# Act
result = mappers.orm_to_user_identity(orm)
# Assert
assert isinstance(result, UserIdentity)
assert result.identity_id == "uid-1"
assert result.user_id == "300"
assert result.channel_type == ChannelType("feishu")
def test_handles_none_user_id(self):
# Arrange
orm = _make_identity_orm()
orm.user_id = None
# Act
result = mappers.orm_to_user_identity(orm)
# Assert
assert result.user_id is None
def test_handles_none_channel_type(self):
# Arrange
orm = _make_identity_orm()
orm.channel_type = None
# Act
result = mappers.orm_to_user_identity(orm)
# Assert
assert result.channel_type is None
@pytest.mark.unit
class TestChannelSessionForWrite:
"""``channel_session_for_write`` 无敏感字段,直接透传 ``dict(data)``。"""
def test_returns_new_dict_with_same_content(self):
# Arrange
data = {"session_id": "sess-1", "peer_id": "peer-1", "is_temporary": False}
# Act
result = mappers.channel_session_for_write(data)
# Assert
assert result == data
assert result is not data
def test_does_not_mutate_input(self):
# Arrange
data = {"session_id": "sess-1", "tags": ["a"]}
# Act
result = mappers.channel_session_for_write(data)
result["tags"].append("b")
# Assert
# 浅拷贝:嵌套可变对象仍共享引用,但顶层 key 修改不影响原 dict
assert "session_id" in data
assert data["tags"] == ["a", "b"] # 浅拷贝共享嵌套 list
@pytest.mark.unit
class TestPairingForWrite:
"""``pairing_for_write`` 无敏感字段,直接透传 ``dict(data)``。"""
def test_returns_new_dict_with_same_content(self):
# Arrange
data = {"pairing_id": "pair-1", "peer_id": "peer-1", "status": "pending"}
# Act
result = mappers.pairing_for_write(data)
# Assert
assert result == data
assert result is not data
def test_does_not_mutate_input(self):
# Arrange
data = {"pairing_id": "pair-1"}
# Act
result = mappers.pairing_for_write(data)
result["new_key"] = "value"
# Assert
assert "new_key" not in data
@pytest.mark.unit
class TestAuditLogForWrite:
"""``audit_log_for_write`` 处理 ``target_channel`` / ``target`` 字段回退。"""
def test_passes_through_when_target_channel_provided(self):
# Arrange
data = {
"operator": "admin-1",
"target_channel": "feishu",
"target": "feishu",
}
# Act
result = mappers.audit_log_for_write(data)
# Assert
# target_channel 已存在,不消费 target 字段
assert result["target_channel"] == "feishu"
assert result["target"] == "feishu"
assert result is not data
def test_falls_back_to_target_when_target_channel_missing(self):
# Arrange
data = {
"operator": "admin-1",
"target": "feishu",
}
# Act
result = mappers.audit_log_for_write(data)
# Assert
# target_channel 缺失:将 target 重命名为 target_channel
assert result["target_channel"] == "feishu"
assert "target" not in result
def test_falls_back_to_target_when_target_channel_empty(self):
# Arrange
data = {
"target_channel": "",
"target": "feishu",
}
# Act
result = mappers.audit_log_for_write(data)
# Assert
# 空字符串视为 falsy触发回退
assert result["target_channel"] == "feishu"
assert "target" not in result
def test_passes_through_when_both_missing(self):
# Arrange
data = {"operator": "admin-1", "result": "success"}
# Act
result = mappers.audit_log_for_write(data)
# Assert
assert "target_channel" not in result
assert "target" not in result
assert result == data
def test_does_not_mutate_input_when_falling_back(self):
# Arrange
data = {"target": "feishu"}
# Act
result = mappers.audit_log_for_write(data)
# Assert
# 原 dict 不被修改
assert data == {"target": "feishu"}
assert result["target_channel"] == "feishu"
@pytest.mark.unit
class TestOutboxEntryForWrite:
"""``outbox_entry_for_write`` 无敏感字段,直接透传 ``dict(data)``。"""
def test_returns_new_dict_with_same_content(self):
# Arrange
data = {
"outbox_id": 1,
"message_id": "500",
"status": "pending",
"latency_ms": 100,
"funnel_node": "ingest",
}
# Act
result = mappers.outbox_entry_for_write(data)
# Assert
assert result == data
assert result is not data
def test_does_not_mutate_input(self):
# Arrange
data = {"outbox_id": 1}
# Act
result = mappers.outbox_entry_for_write(data)
result["new_key"] = "value"
# Assert
assert "new_key" not in data
@pytest.mark.unit
class TestUserIdentityForWrite:
"""``user_identity_for_write`` 无敏感字段,直接透传 ``dict(data)``。"""
def test_returns_new_dict_with_same_content(self):
# Arrange
data = {
"identity_id": "uid-1",
"user_id": "300",
"identity_type": "email",
}
# Act
result = mappers.user_identity_for_write(data)
# Assert
assert result == data
assert result is not data
def test_does_not_mutate_input(self):
# Arrange
data = {"identity_id": "uid-1"}
# Act
result = mappers.user_identity_for_write(data)
result["new_key"] = "value"
# Assert
assert "new_key" not in data
@pytest.mark.unit
class TestOrmToRouteBinding:
"""``orm_to_route_binding`` ORM → RouteBindingRule 映射。"""
def test_maps_all_fields_correctly(self):
# Arrange
orm = _make_route_binding_orm()
# Act
result = mappers.orm_to_route_binding(orm)
# Assert
assert isinstance(result, RouteBindingRule)
assert result.binding_id == "bind-1"
assert result.channel_type == ChannelType("feishu")
assert result.account_id == "acc-1"
assert result.match_source == "session_key"
assert result.match_value == "peer-1"
assert result.agent_binding == "agent-slug"
assert result.enabled is True
assert result.description == "test binding"
assert result.version == 1
assert result.created_by == "admin-1"
assert result.updated_by == "admin-1"
def test_derives_priority_from_match_source_session_key(self):
# Arrange — session_key 在 _TIER_PRIORITY_MAP 中对应 800
orm = _make_route_binding_orm()
orm.match_source = "session_key"
# Act
result = mappers.orm_to_route_binding(orm)
# Assert
assert result.priority == 800
def test_derives_priority_from_match_source_peer_id(self):
# Arrange — peer_id 对应 600验证不同 tier 派生不同 priority
orm = _make_route_binding_orm()
orm.match_source = "peer_id"
# Act
result = mappers.orm_to_route_binding(orm)
# Assert
assert result.priority == 600
def test_returns_none_priority_for_unknown_match_source(self):
# Arrange — 未知 match_source 不在 _TIER_PRIORITY_MAP 中
orm = _make_route_binding_orm()
orm.match_source = "unknown_tier"
# Act
result = mappers.orm_to_route_binding(orm)
# Assert
assert result.priority is None
def test_coerces_none_enabled_to_false(self):
# Arrange — enabled 为 None 时 bool(None) = False
# 注ORM 列定义为 nullable=False default=True此处验证 mapper 实际行为
orm = _make_route_binding_orm()
orm.enabled = None
# Act
result = mappers.orm_to_route_binding(orm)
# Assert
assert result.enabled is False
def test_passes_none_description_through(self):
# Arrange — description 为 None 时直接透传dataclass 字段类型为 str | None
orm = _make_route_binding_orm()
orm.description = None
# Act
result = mappers.orm_to_route_binding(orm)
# Assert
assert result.description is None
@pytest.mark.unit
class TestEnumHelper:
"""``_enum`` 安全构造枚举,非法值翻译为 DependencyError。"""
def test_returns_enum_instance_for_valid_value(self):
# Arrange — AccountStatus.ACTIVE 对应 "active"
# Act
result = mappers._enum("active", AccountStatus, "channel_account")
# Assert
assert result == AccountStatus.ACTIVE
def test_raises_dependency_error_for_invalid_value(self):
# Arrange — "invalid_status" 不是 AccountStatus 合法成员
# Act / Assert
with pytest.raises(DependencyError) as exc_info:
mappers._enum("invalid_status", AccountStatus, "channel_account")
err = exc_info.value
# 资源标识field写入 dep 属性
assert err.dep == "channel_account"
# 枚举类名与原始非法值出现在 cause 的错误信息中
cause_msg = err.cause.message
assert "AccountStatus" in cause_msg
assert "invalid_status" in cause_msg
@pytest.mark.unit
class TestSerializeForJson:
"""``_serialize_for_json`` 递归将 datetime/date 转为 ISO 字符串。"""
def test_serializes_datetime_to_isoformat(self):
# Arrange
value = datetime(2026, 1, 2, 3, 4, 5)
# Act
result = mappers._serialize_for_json(value)
# Assert
assert result == "2026-01-02T03:04:05"
def test_serializes_date_to_isoformat(self):
# Arrange
value = date(2026, 1, 2)
# Act
result = mappers._serialize_for_json(value)
# Assert
assert result == "2026-01-02"
def test_serializes_dict_recursively(self):
# Arrange — dict 内嵌 datetime 应递归序列化
value = {"created_at": datetime(2026, 1, 2, 3, 4, 5), "name": "test"}
# Act
result = mappers._serialize_for_json(value)
# Assert
assert result == {"created_at": "2026-01-02T03:04:05", "name": "test"}
def test_serializes_list_recursively(self):
# Arrange — list 内嵌 datetime 应递归序列化
value = [datetime(2026, 1, 2, 3, 4, 5), "str"]
# Act
result = mappers._serialize_for_json(value)
# Assert
assert result == ["2026-01-02T03:04:05", "str"]
def test_serializes_tuple_recursively(self):
# Arrange — tuple 内嵌 datetime 应递归序列化
value = (datetime(2026, 1, 2, 3, 4, 5), "str")
# Act
result = mappers._serialize_for_json(value)
# Assert
assert result == ("2026-01-02T03:04:05", "str")
def test_passes_through_plain_types(self):
# Arrange — str/int/float/bool/None 不命中 datetime/date/容器分支,直接返回
values = ["str", 1, 1.5, True, None]
# Act
results = [mappers._serialize_for_json(v) for v in values]
# Assert
assert results == ["str", 1, 1.5, True, None]