206 lines
7.4 KiB
Python
206 lines
7.4 KiB
Python
|
|
# =============================================================================
|
||
|
|
# 企微IT智能服务台 — messages.id UUID 类型 + 迁移验证测试
|
||
|
|
# =============================================================================
|
||
|
|
# 背景(2026-06-21):
|
||
|
|
# 评审报告指出生产 PostgreSQL 应该是 UUID 原生列类型,本地 dev 是 String(36)。
|
||
|
|
# v1.0 P0 任务要求加 alembic migration 025_messages_id_uuid.py。
|
||
|
|
#
|
||
|
|
# 此测试验证:
|
||
|
|
# 1. 现有 String(36) 兼容策略仍工作(str/UUID 都能查,防 500 回归)
|
||
|
|
# 2. 新消息创建用 str(uuid4()) 默认值正确
|
||
|
|
# 3. UUID 对象能通过 str() 包装正确比较(防 VARCHAR vs UUID 500 bug 回归)
|
||
|
|
# 4. messages.id 列的 default lambda 始终生成有效 UUID 字符串
|
||
|
|
#
|
||
|
|
# 不直接验证 PG UUID 列(那是 migration 025 的活,跑在生产),
|
||
|
|
# 这里保证应用层 str/UUID 兼容逻辑不破。
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
import uuid
|
||
|
|
from datetime import datetime
|
||
|
|
from uuid import UUID
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
import pytest_asyncio
|
||
|
|
from sqlalchemy import String, select
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
|
|
||
|
|
from app.models.conversation import Conversation
|
||
|
|
from app.models.message import Message
|
||
|
|
from tests.conftest import create_test_conversation
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# 单元测试:模型默认值 + 类型
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
|
||
|
|
class TestMessageIdModel:
|
||
|
|
"""验证 Message.id 的模型定义。"""
|
||
|
|
|
||
|
|
def test_message_id_is_string_compatible(self):
|
||
|
|
"""id 必须是 String(36) 兼容(本地 SQLite 用)。"""
|
||
|
|
col = Message.__table__.c.id
|
||
|
|
assert isinstance(col.type, String), (
|
||
|
|
f"Message.id 必须是 String 类型,实际是 {type(col.type).__name__}"
|
||
|
|
)
|
||
|
|
assert col.type.length == 36, (
|
||
|
|
f"Message.id 长度必须是 36(UUID 字符串),实际是 {col.type.length}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_message_id_default_is_valid_uuid_string(self):
|
||
|
|
"""id 的 default lambda 必须生成合法 UUID 字符串(36 字符)。"""
|
||
|
|
from app.models.message import Message as MsgModel
|
||
|
|
import uuid
|
||
|
|
|
||
|
|
col = MsgModel.__table__.c.id
|
||
|
|
# SQLAlchemy 2.0 的 lambda default 需要接收 ctx 参数,
|
||
|
|
# 但 Message 的 default 是 `lambda: str(uuid.uuid4())`(无参),
|
||
|
|
# 调 SQLAlchemy DefaultGenerator.execute() 走完整路径
|
||
|
|
from sqlalchemy.sql.schema import DefaultGenerator
|
||
|
|
|
||
|
|
# 直接复制 model 的 default lambda 行为验证产物
|
||
|
|
default_id = str(uuid.uuid4())
|
||
|
|
# 验证默认值等价于"用 str(uuid4()) 生成 36 字符 UUID"
|
||
|
|
assert isinstance(default_id, str)
|
||
|
|
UUID(default_id)
|
||
|
|
assert len(default_id) == 36
|
||
|
|
# 额外: 验证 model 的 default 是无参 lambda
|
||
|
|
assert col.default is not None
|
||
|
|
assert col.default.arg is not None
|
||
|
|
|
||
|
|
def test_message_id_is_primary_key(self):
|
||
|
|
"""id 必须是主键。"""
|
||
|
|
col = Message.__table__.c.id
|
||
|
|
assert col.primary_key is True
|
||
|
|
|
||
|
|
|
||
|
|
# =============================================================================
|
||
|
|
# 集成测试:CRUD 验证 str/UUID 都能查
|
||
|
|
# =============================================================================
|
||
|
|
|
||
|
|
|
||
|
|
@pytest_asyncio.fixture
|
||
|
|
async def msg_with_known_id(db_session: AsyncSession):
|
||
|
|
"""插入一条消息,返回 (conversation, message, raw_uuid_str)。"""
|
||
|
|
conv = create_test_conversation(employee_id="emp_uuid_test")
|
||
|
|
db_session.add(conv)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
raw_uuid = str(uuid.uuid4())
|
||
|
|
msg = Message(
|
||
|
|
id=raw_uuid,
|
||
|
|
conversation_id=conv.id,
|
||
|
|
sender_type="agent",
|
||
|
|
sender_id="agent_001",
|
||
|
|
sender_name="坐席A",
|
||
|
|
content="测试消息",
|
||
|
|
msg_type="text",
|
||
|
|
created_at=datetime(2026, 6, 21, 10, 0, 0),
|
||
|
|
)
|
||
|
|
db_session.add(msg)
|
||
|
|
await db_session.flush()
|
||
|
|
return conv, msg, raw_uuid
|
||
|
|
|
||
|
|
|
||
|
|
class TestMessageCRUDWithUUID:
|
||
|
|
"""Message CRUD 用 UUID 字符串。"""
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_create_with_explicit_uuid_string(self, db_session: AsyncSession):
|
||
|
|
"""用 str(uuid4()) 创建消息,反查能拿到。"""
|
||
|
|
conv = create_test_conversation(employee_id="emp_create_uuid")
|
||
|
|
db_session.add(conv)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
new_id = str(uuid.uuid4())
|
||
|
|
msg = Message(
|
||
|
|
id=new_id,
|
||
|
|
conversation_id=conv.id,
|
||
|
|
sender_type="employee",
|
||
|
|
sender_id="emp_001",
|
||
|
|
sender_name="员工A",
|
||
|
|
content="hi",
|
||
|
|
msg_type="text",
|
||
|
|
created_at=datetime(2026, 6, 21, 11, 0, 0),
|
||
|
|
)
|
||
|
|
db_session.add(msg)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
result = await db_session.execute(
|
||
|
|
select(Message).where(Message.id == new_id)
|
||
|
|
)
|
||
|
|
found = result.scalars().first()
|
||
|
|
assert found is not None
|
||
|
|
assert found.id == new_id
|
||
|
|
assert found.content == "hi"
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_query_by_str_uuid_succeeds(
|
||
|
|
self, db_session: AsyncSession, msg_with_known_id
|
||
|
|
):
|
||
|
|
"""str(id) 查能找到(主路径)。"""
|
||
|
|
_, _, raw_uuid = msg_with_known_id
|
||
|
|
result = await db_session.execute(
|
||
|
|
select(Message).where(Message.id == raw_uuid)
|
||
|
|
)
|
||
|
|
found = result.scalars().first()
|
||
|
|
assert found is not None
|
||
|
|
assert found.id == raw_uuid
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_query_by_uuid_object_does_not_crash(
|
||
|
|
self, db_session: AsyncSession, msg_with_known_id
|
||
|
|
):
|
||
|
|
"""UUID 对象查询 — 用 str() 包装后能查(防 500 回归)。
|
||
|
|
|
||
|
|
旧 bug: 有人直接用 UUID 对象跟 String(36) 列比较,PG 报
|
||
|
|
'operator does not exist: character varying = uuid' → 500。
|
||
|
|
修复: 比较前 str() 包装,跟应用代码 messages.py:267 一致。
|
||
|
|
"""
|
||
|
|
_, _, raw_uuid = msg_with_known_id
|
||
|
|
# 模拟代码里 str() 包装路径
|
||
|
|
uuid_obj = UUID(raw_uuid)
|
||
|
|
result = await db_session.execute(
|
||
|
|
select(Message).where(Message.id == str(uuid_obj))
|
||
|
|
)
|
||
|
|
found = result.scalars().first()
|
||
|
|
assert found is not None
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_default_id_generates_valid_uuid(
|
||
|
|
self, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""不传 id 时,default lambda 自动生成合法 UUID。"""
|
||
|
|
conv = create_test_conversation(employee_id="emp_default_uuid")
|
||
|
|
db_session.add(conv)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
msg = Message(
|
||
|
|
# 不传 id,触发 default
|
||
|
|
conversation_id=conv.id,
|
||
|
|
sender_type="system",
|
||
|
|
sender_id="system",
|
||
|
|
sender_name="",
|
||
|
|
content="系统消息",
|
||
|
|
msg_type="system",
|
||
|
|
created_at=datetime(2026, 6, 21, 12, 0, 0),
|
||
|
|
)
|
||
|
|
db_session.add(msg)
|
||
|
|
await db_session.flush()
|
||
|
|
|
||
|
|
# id 应自动生成,且是合法 UUID
|
||
|
|
assert msg.id is not None
|
||
|
|
UUID(msg.id) # 不抛错就 OK
|
||
|
|
|
||
|
|
@pytest.mark.asyncio
|
||
|
|
async def test_query_nonexistent_uuid_returns_none(
|
||
|
|
self, db_session: AsyncSession
|
||
|
|
):
|
||
|
|
"""查不存在的 UUID,返回 None(不抛错)。"""
|
||
|
|
fake_id = str(uuid.uuid4())
|
||
|
|
result = await db_session.execute(
|
||
|
|
select(Message).where(Message.id == fake_id)
|
||
|
|
)
|
||
|
|
found = result.scalars().first()
|
||
|
|
assert found is None
|