本次提交修复了多个测试文件中的问题: 1. 将 ChannelType 枚举调用改为字符串实例化方式 2. 修正了日志断言、异步mock使用、配置参数等多处测试细节 3. 新增了会话聚合根、跨渠道关联策略等单元测试用例 4. 修复了路由测试中的路径方法错误与断言逻辑 5. 调整了依赖导入与测试夹具的兼容性 6. 统一了重试回退调度的列表/元组使用规范
600 lines
16 KiB
Python
600 lines
16 KiB
Python
"""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`` / ``orm_to_channel_report``,使用 ``patch`` 替换
|
||
加解密函数,不依赖真实密钥配置。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import 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.report import Report
|
||
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
|
||
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.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_report_orm() -> MagicMock:
|
||
"""构造 ChannelReport ORM 桩。"""
|
||
orm = MagicMock()
|
||
orm.report_id = "rpt-1"
|
||
orm.task_id = "task-1"
|
||
orm.report_type = "message_stats"
|
||
orm.status = "ready"
|
||
orm.params = {"range": "7d"}
|
||
orm.content = {"data": 1}
|
||
orm.error_message = None
|
||
orm.created_at = datetime(2026, 1, 1)
|
||
orm.ready_at = datetime(2026, 1, 2)
|
||
orm.created_by = "user-1"
|
||
orm.retried_from = None
|
||
orm.retried_at = None
|
||
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 TestOrmToChannelReport:
|
||
def test_returns_report_dto(self):
|
||
# Arrange
|
||
orm = _make_report_orm()
|
||
|
||
# Act
|
||
result = mappers.orm_to_channel_report(orm)
|
||
|
||
# Assert
|
||
assert isinstance(result, Report)
|
||
assert result.report_id == "rpt-1"
|
||
assert result.task_id == "task-1"
|
||
assert result.status == "ready"
|
||
assert result.content == {"data": 1}
|
||
|
||
def test_handles_none_params(self):
|
||
# Arrange
|
||
orm = _make_report_orm()
|
||
orm.params = None
|
||
|
||
# Act
|
||
result = mappers.orm_to_channel_report(orm)
|
||
|
||
# Assert
|
||
assert result.params == {}
|
||
|
||
|
||
@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
|