# ============================================================================= # 企微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