1. 修复钉钉客户端API调用参数格式,将unionid从URL路径移至params 2. 补充服务账号mock的active状态,完善权限校验测试前置条件 3. 更新钉钉消息发送缓存计数断言,修正缓存键数量预期 4. 补充分类阶段测试的日志mock与新增字段校验 5. 新增事务端口的get_session方法测试,完善事务上下文接口覆盖 6. 修正代理访问配置默认权限等级为global,更新对应测试用例 7. 重构权限阶段未知请求处理逻辑,改为失败关闭而非默认管理员权限 8. 更新ack记录失败处理逻辑,改为抛出异常而非仅告警 9. 新增能力校验阶段异常降级测试用例 10. 重构发件箱持久化阶段测试,移除冗余查询并验证结果 11. 新增消息撤回测试的会话ID缓存获取逻辑 12. 修复映射器测试的账号ID匹配逻辑 13. 新增输入幂等阶段的缓存锁依赖,完善并发测试支持 14. 补充围栏代际回滚方法的完整测试覆盖 15. 新增路由匹配注册表的原子替换测试用例 16. 新增会话统计接口的截断状态与总数统计测试 17. 修正配置schema统计计数,更新热更新字段与总条目数 18. 补充调度任务处理器的缓存端口依赖,完善健康检查与锁机制测试
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, _make_account_orm())
|
||
|
||
# Assert
|
||
assert isinstance(result, ChannelSession)
|
||
assert result.session_id == "sess-1"
|
||
assert result.account_id == "acc-1"
|
||
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, _make_account_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, _make_account_orm())
|
||
|
||
# Assert
|
||
assert isinstance(result, OutboxEntry)
|
||
assert result.outbox_id == 1
|
||
assert result.message_id == "500"
|
||
assert result.channel_account_id == "acc-1"
|
||
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, _make_account_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]
|