ForcePilot/backend/test/unit/channels/adapters/test_mappers.py
Kris ff07cef881 test: 批量完善单元测试与集成测试用例
1. 为测试桩函数新增version、debug/exception日志等必要字段
2. 重构SQLAlchemy事务上下文测试,修复隐式事务提交逻辑
3. 修复微信WOC插件适配器测试用例,补充缺失的日志方法与测试场景
4. 调整路由阶段测试,移除过时的会话刷新逻辑
5. 新增内容审核 retention 定时任务测试用例
6. 完善配对、会话、账号模型测试用例
7. 删除过时的报表集成测试文件
8. 为持久化适配器添加配对状态更新的乐观锁测试
9. 修复配置验证测试的断言逻辑
2026-07-08 03:58:05 +08:00

559 lines
15 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 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