ForcePilot/backend/test/unit/channels/adapters/test_mappers.py

755 lines
21 KiB
Python
Raw Normal View History

"""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]