"""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