"""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 date, 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.route import RouteBindingRule 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 def _make_route_binding_orm() -> MagicMock: """构造 ChannelRouteBinding ORM 桩。""" orm = MagicMock() orm.binding_id = "bind-1" orm.channel_type = "feishu" orm.account_id = "acc-1" orm.match_source = "session_key" orm.match_value = "peer-1" orm.agent_binding = "agent-slug" orm.enabled = True orm.description = "test binding" orm.version = 1 orm.created_by = "admin-1" orm.updated_by = "admin-1" 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 @pytest.mark.unit class TestOrmToRouteBinding: """``orm_to_route_binding`` ORM → RouteBindingRule 映射。""" def test_maps_all_fields_correctly(self): # Arrange orm = _make_route_binding_orm() # Act result = mappers.orm_to_route_binding(orm) # Assert assert isinstance(result, RouteBindingRule) assert result.binding_id == "bind-1" assert result.channel_type == ChannelType("feishu") assert result.account_id == "acc-1" assert result.match_source == "session_key" assert result.match_value == "peer-1" assert result.agent_binding == "agent-slug" assert result.enabled is True assert result.description == "test binding" assert result.version == 1 assert result.created_by == "admin-1" assert result.updated_by == "admin-1" def test_derives_priority_from_match_source_session_key(self): # Arrange — session_key 在 _TIER_PRIORITY_MAP 中对应 800 orm = _make_route_binding_orm() orm.match_source = "session_key" # Act result = mappers.orm_to_route_binding(orm) # Assert assert result.priority == 800 def test_derives_priority_from_match_source_peer_id(self): # Arrange — peer_id 对应 600,验证不同 tier 派生不同 priority orm = _make_route_binding_orm() orm.match_source = "peer_id" # Act result = mappers.orm_to_route_binding(orm) # Assert assert result.priority == 600 def test_returns_none_priority_for_unknown_match_source(self): # Arrange — 未知 match_source 不在 _TIER_PRIORITY_MAP 中 orm = _make_route_binding_orm() orm.match_source = "unknown_tier" # Act result = mappers.orm_to_route_binding(orm) # Assert assert result.priority is None def test_coerces_none_enabled_to_false(self): # Arrange — enabled 为 None 时 bool(None) = False # 注:ORM 列定义为 nullable=False default=True,此处验证 mapper 实际行为 orm = _make_route_binding_orm() orm.enabled = None # Act result = mappers.orm_to_route_binding(orm) # Assert assert result.enabled is False def test_passes_none_description_through(self): # Arrange — description 为 None 时直接透传,dataclass 字段类型为 str | None orm = _make_route_binding_orm() orm.description = None # Act result = mappers.orm_to_route_binding(orm) # Assert assert result.description is None @pytest.mark.unit class TestEnumHelper: """``_enum`` 安全构造枚举,非法值翻译为 DependencyError。""" def test_returns_enum_instance_for_valid_value(self): # Arrange — AccountStatus.ACTIVE 对应 "active" # Act result = mappers._enum("active", AccountStatus, "channel_account") # Assert assert result == AccountStatus.ACTIVE def test_raises_dependency_error_for_invalid_value(self): # Arrange — "invalid_status" 不是 AccountStatus 合法成员 # Act / Assert with pytest.raises(DependencyError) as exc_info: mappers._enum("invalid_status", AccountStatus, "channel_account") err = exc_info.value # 资源标识(field)写入 dep 属性 assert err.dep == "channel_account" # 枚举类名与原始非法值出现在 cause 的错误信息中 cause_msg = err.cause.message assert "AccountStatus" in cause_msg assert "invalid_status" in cause_msg @pytest.mark.unit class TestSerializeForJson: """``_serialize_for_json`` 递归将 datetime/date 转为 ISO 字符串。""" def test_serializes_datetime_to_isoformat(self): # Arrange value = datetime(2026, 1, 2, 3, 4, 5) # Act result = mappers._serialize_for_json(value) # Assert assert result == "2026-01-02T03:04:05" def test_serializes_date_to_isoformat(self): # Arrange value = date(2026, 1, 2) # Act result = mappers._serialize_for_json(value) # Assert assert result == "2026-01-02" def test_serializes_dict_recursively(self): # Arrange — dict 内嵌 datetime 应递归序列化 value = {"created_at": datetime(2026, 1, 2, 3, 4, 5), "name": "test"} # Act result = mappers._serialize_for_json(value) # Assert assert result == {"created_at": "2026-01-02T03:04:05", "name": "test"} def test_serializes_list_recursively(self): # Arrange — list 内嵌 datetime 应递归序列化 value = [datetime(2026, 1, 2, 3, 4, 5), "str"] # Act result = mappers._serialize_for_json(value) # Assert assert result == ["2026-01-02T03:04:05", "str"] def test_serializes_tuple_recursively(self): # Arrange — tuple 内嵌 datetime 应递归序列化 value = (datetime(2026, 1, 2, 3, 4, 5), "str") # Act result = mappers._serialize_for_json(value) # Assert assert result == ("2026-01-02T03:04:05", "str") def test_passes_through_plain_types(self): # Arrange — str/int/float/bool/None 不命中 datetime/date/容器分支,直接返回 values = ["str", 1, 1.5, True, None] # Act results = [mappers._serialize_for_json(v) for v in values] # Assert assert results == ["str", 1, 1.5, True, None]