ForcePilot/backend/test/unit/channels/adapters/test_mappers.py
Kris 696f5f44ec chore: 完成多模块迭代优化与测试覆盖
这一批提交包含:
1. 配置项敏感字段标记与测试用例修复
2. 路由绑定乐观锁支持与静态路径校验
3. 微信WOC插件能力适配与新增单元测试
4. 多个适配器的接口对齐与测试补全
5. 新增定时任务清理处理器与依赖注入容器测试
6. 错误码体系扩展与整合测试
7. 删除临时验证脚本与代码清理
2026-07-08 12:58:43 +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)
# Assert
assert isinstance(result, ChannelSession)
assert result.session_id == "sess-1"
assert result.account_id == "100"
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)
# 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)
# Assert
assert isinstance(result, OutboxEntry)
assert result.outbox_id == 1
assert result.message_id == "500"
assert result.channel_account_id == "100"
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)
# 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]