1. 为测试桩函数新增version、debug/exception日志等必要字段 2. 重构SQLAlchemy事务上下文测试,修复隐式事务提交逻辑 3. 修复微信WOC插件适配器测试用例,补充缺失的日志方法与测试场景 4. 调整路由阶段测试,移除过时的会话刷新逻辑 5. 新增内容审核 retention 定时任务测试用例 6. 完善配对、会话、账号模型测试用例 7. 删除过时的报表集成测试文件 8. 为持久化适配器添加配对状态更新的乐观锁测试 9. 修复配置验证测试的断言逻辑
559 lines
15 KiB
Python
559 lines
15 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``,使用 ``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.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
|
||
|
||
|
||
@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
|