这一批提交包含: 1. 配置项敏感字段标记与测试用例修复 2. 路由绑定乐观锁支持与静态路径校验 3. 微信WOC插件能力适配与新增单元测试 4. 多个适配器的接口对齐与测试补全 5. 新增定时任务清理处理器与依赖注入容器测试 6. 错误码体系扩展与整合测试 7. 删除临时验证脚本与代码清理
755 lines
21 KiB
Python
755 lines
21 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 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]
|