ForcePilot/backend/test/unit/channels/adapters/test_mappers.py
Kris 3ae9c0bd21 test: 批量修复与新增单元测试用例,清理废弃测试目录
1.  删除了 test/unit/external_systems/framework/ 下的废弃空测试目录
2.  修复多处测试断言逻辑、参数传递与测试数据构造
3.  新增日志级别、缓存令牌、流事件等DTO单元测试
4.  补充路由绑定、会话仓储、outbox仓储的测试覆盖
5.  更新测试用例中的异常类型、参数校验与业务逻辑断言
2026-07-11 21:43:16 +08:00

809 lines
24 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.id = 1
orm.operator = "admin-1"
orm.operation = "account_created"
orm.target_channel = "feishu"
orm.target = "feishu:acc-1:users"
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:acc-1:users"
assert result.target_channel == "feishu"
assert result.id == 1
def test_target_falls_back_to_target_channel_when_none(self):
"""ORM target 列为 None 时回退到 target_channel兼容旧数据"""
orm = _make_audit_orm()
orm.target = None
result = mappers.orm_to_audit_log(orm)
assert result.target == "feishu"
def test_maps_id_for_keyset_pagination(self):
"""ORM 主键 id 映射到 AuditEntry.id供 keyset pagination 使用。"""
orm = _make_audit_orm()
orm.id = 42
result = mappers.orm_to_audit_log(orm)
assert result.id == 42
@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 字段独立持久化,不再回退填充 target_channel。"""
def test_preserves_target_field_independently(self):
"""target 字段独立保留,不回退填充 target_channel。"""
data = {
"operator": "admin-1",
"target_channel": "feishu",
"target": "feishu:acc-1:users",
}
result = mappers.audit_log_for_write(data)
assert result["target_channel"] == "feishu"
assert result["target"] == "feishu:acc-1:users"
assert result is not data
def test_preserves_target_without_target_channel(self):
"""target 字段独立保留,即使 target_channel 缺失也不回退。"""
data = {
"operator": "admin-1",
"target": "feishu:acc-1:users",
}
result = mappers.audit_log_for_write(data)
assert result["target"] == "feishu:acc-1:users"
assert "target_channel" not in result
def test_passes_through_when_both_missing(self):
data = {"operator": "admin-1", "result": "success"}
result = mappers.audit_log_for_write(data)
assert "target_channel" not in result
assert "target" not in result
assert result == data
def test_does_not_mutate_input(self):
data = {"target": "feishu:acc-1:users"}
result = mappers.audit_log_for_write(data)
assert data == {"target": "feishu:acc-1:users"}
assert result["target"] == "feishu:acc-1:users"
def test_serializes_params_summary_datetimes(self):
"""params_summary 中的 datetime 对象被递归转为 ISO 字符串。"""
data = {
"target": "feishu",
"params_summary": {"created_at": datetime(2026, 1, 1)},
}
result = mappers.audit_log_for_write(data)
assert result["params_summary"]["created_at"] == "2026-01-01T00:00:00"
@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]