ForcePilot/backend/test/unit/channels/adapters/test_mappers.py
Kris 713fcdcd8e refactor(test): 批量重构测试代码,简化调度器handler测试逻辑
1.  统一替换多个调度器handler测试用例,移除冗余的session_factory相关代码和fake session上下文
2.  调整ChannelPersistenceAdapter测试,使用模块级patcher管理并统一初始化方式
3.  修正OutboxEntry聚合根操作的版本号逻辑,移除不必要的版本递增
4.  新增UTC时区转换相关测试用例,完善datetime类型映射测试
5.  更新微信WOC适配器测试,适配新的session_type枚举值
6.  优化测试代码的可读性和一致性,统一测试辅助函数的实现方式
2026-07-10 14:29:23 +08:00

805 lines
23 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 UTC, 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 TestToAwareUtc:
def test_converts_naive_datetime_to_aware_utc(self):
naive = datetime(2026, 1, 1, 12, 0, 0)
result = mappers._to_aware_utc(naive)
assert result == datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
assert result.tzinfo is UTC
def test_returns_aware_datetime_unchanged(self):
aware = datetime(2026, 1, 1, 12, 0, 0, tzinfo=UTC)
assert mappers._to_aware_utc(aware) is aware
def test_returns_none_unchanged(self):
assert mappers._to_aware_utc(None) is None
@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_datetime_fields_are_aware_utc(self):
"""DB 读出的 naive datetime 必须转为 aware UTC避免领域层时区混用。"""
orm = _make_outbox_orm()
orm.created_at = datetime(2026, 1, 1)
orm.updated_at = datetime(2026, 1, 2)
orm.expires_at = datetime(2026, 1, 3)
orm.sent_at = datetime(2026, 1, 4)
orm.last_retry_at = datetime(2026, 1, 5)
orm.next_retry_at = datetime(2026, 1, 6)
result = mappers.orm_to_outbox_entry(orm, _make_account_orm())
assert result.created_at.tzinfo is UTC
assert result.updated_at.tzinfo is UTC
assert result.expires_at.tzinfo is UTC
assert result.sent_at.tzinfo is UTC
assert result.last_retry_at.tzinfo is UTC
assert result.next_retry_at.tzinfo is UTC
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
def test_handles_none_datetime_fields(self):
"""可空 datetime 字段为 None 时不应报错。"""
orm = _make_outbox_orm()
orm.expires_at = None
orm.sent_at = None
orm.last_retry_at = None
orm.next_retry_at = None
result = mappers.orm_to_outbox_entry(orm, _make_account_orm())
assert result.expires_at is None
assert result.sent_at is None
assert result.last_retry_at is None
assert result.next_retry_at 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]