"""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_CREATE 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 == {}