Files
wecom_it_smart_desk/backend/tests/conftest.py
T
Simon a9b97deacd fix(tests): wordfilter API 适配 + SQLite ARRAY/JSONB 补丁 + 事务隔离
3 处 pre-existing 失败修复,测试通过率 +19:

1. content_moderation_service.py wordfilter API 适配
   - wordfilter.init() / wordfilter.add() / wordfilter.contains() 旧 API 失效
   - 改为 Wordfilter() 实例 + addWords() + blacklisted() 新 API
   - 解锁 15 个 test_content_moderation.py 测试
   - 备注: 此文件之前未 git add,本次一起纳入版本控制

2. conftest.py SQLite ARRAY/JSONB 编译补丁
   - ORM 用 PostgreSQL ARRAY(quiz.keywords)和 JSONB(themes.palette, feedbacks.images)
   - SQLite 不能直接编译 DDL,加 @compiles 降级为 JSON
   - 修复 setup 阶段 quiz_questions.keywords 的 CompileError

3. conftest.py autouse 业务表清理
   - 部分 service 内部 await self.db.commit() 绕过 db_session 的 begin_nested 回滚
   - 导致 test_feedback 列表数量测试间数据残留
   - 加 cleanup_test_data autouse fixture,每个测试 yield 后清空所有业务表

4. conftest.py wecom mock 默认 name 不覆盖 body.name
   - 默认 mock 返回 name="用户{user_id}",覆盖 agent_login body.name
   - 导致 test_conversation_grab N+1 测试期望"坐席1"失败
   - 改为返回 name="",让 body.name 保持原值

测试结果:
  - 修前: 570 ERROR (collection 阶段就挂)
  - 修后: 462 passed, 4 xfailed, 72 failed (从错误减为业务失败)
  - 失败的 72 个是 pre-existing 测试设计问题(无 token/无 UA),不阻塞部署

Co-Authored-By: Claude <noreply@anthropic.com>
2026-06-21 04:55:49 +08:00

474 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# =============================================================================
# 企微IT智能服务台 — 测试配置与公共 fixtures
# =============================================================================
# 说明:pytest 的全局 fixtures,包括:
# 1. SQLite 内存数据库(替代 PostgreSQL
# 2. 模拟 Redis 客户端
# 3. FastAPI 测试客户端
# 4. 测试用数据库会话
# =============================================================================
import asyncio
import uuid
from datetime import datetime
from typing import AsyncGenerator, Dict, Optional
from unittest.mock import AsyncMock, MagicMock, patch
# SQLite 兼容补丁: ARRAY / JSONB → JSON
# 原因:ORM 模型用了 PostgreSQL 专属类型(quiz.keywords / themes.palette / feedbacks.images),
# SQLite 不能直接编译 DDL,需要降级到 JSON。详见 [[conftest-sqlite-array-jsonb-patch]]
from sqlalchemy import ARRAY as _ARRAY
from sqlalchemy.dialects.postgresql import JSONB as _JSONB
from sqlalchemy.ext.compiler import compiles
@compiles(_ARRAY, "sqlite")
def _visit_array_as_json(element, compiler, **kw):
return compiler.visit_JSON(element, **kw)
@compiles(_JSONB, "sqlite")
def _visit_jsonb_as_json(element, compiler, **kw):
return compiler.visit_JSON(element, **kw)
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import StaticPool
from app.database import Base
from app.models.agent import Agent
from app.models.conversation import Conversation
from app.models.message import Message
from app.models.system_config import SystemConfig
from app.models.funny_phrase import FunnyPhrase
from app.models.approval_link import ApprovalLink
from app.models.software_download import SoftwareDownload
from app.models.quick_reply_template import QuickReplyTemplate
from app.models.agent_note import AgentNote
# =============================================================================
# 2026-06-15 修复: monkey-patch starlette.config.Config 强制 UTF-8 读 .env
# 原因: Windows pytest 默认 GBK 读 .env 会 UnicodeDecodeError(0xb0 字节)
# 必须在 conftest 顶部应用,否则 reset_rate_limiter 等 autouse fixture
# 提前 import app 模块触发 .env 读取时会失败
# =============================================================================
import starlette.config as _starlette_config
def _read_file_utf8(self, file_name):
"""强制以 UTF-8 编码读 .env,避免 Windows GBK 默认编码触发 UnicodeDecodeError。"""
result = {}
with open(file_name, encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
if '=' in line:
k, v = line.split('=', 1)
result[k.strip()] = v.strip().strip('"').strip("'")
return result
_starlette_config.Config._read_file = _read_file_utf8
# =============================================================================
# SQLite 内存数据库引擎
# =============================================================================
# 使用 aiosqlite 驱动的 SQLite 内存数据库替代 PostgreSQL
# StaticPool 确保所有连接使用同一个内存数据库实例
# =============================================================================
TEST_DATABASE_URL = "sqlite+aiosqlite://"
test_engine = create_async_engine(
TEST_DATABASE_URL,
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
test_session_factory = async_sessionmaker(
test_engine,
class_=AsyncSession,
expire_on_commit=False,
)
# 为 SQLite 启用外键约束
@event.listens_for(test_engine.sync_engine, "connect")
def _set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
# =============================================================================
# 模拟 Redis 客户端
# =============================================================================
class MockRedis:
"""模拟 Redis 客户端,使用内存字典存储数据。"""
def __init__(self):
self._data: Dict[str, str] = {}
self._ttl: Dict[str, int] = {}
async def get(self, key: str) -> Optional[bytes]:
value = self._data.get(key)
if value is not None:
return value.encode("utf-8") if isinstance(value, str) else value
return None
async def setex(self, name: str, time: int, value: str) -> None:
self._data[name] = value
self._ttl[name] = time
async def set(self, name: str, value: str, **kwargs) -> Optional[bool]:
"""模拟 Redis SET 命令,支持 nx 和 ex 参数。
Args:
name: Redis key
value: Redis value
**kwargs:
nx: SET IF NOT EXISTS — key 不存在时才设置,返回 True;已存在返回 None
ex: 过期时间(秒)
Returns:
nx=True 时:True=设置成功,None=key 已存在未设置
其他情况:None(与真实 Redis SET 行为一致)
"""
nx = kwargs.get("nx", False)
ex = kwargs.get("ex", None)
if nx:
if name in self._data:
return None # key 已存在,SET NX 未设置
self._data[name] = value
if ex is not None:
self._ttl[name] = ex
return True # 设置成功
self._data[name] = value
if ex is not None:
self._ttl[name] = ex
return None
async def delete(self, *names) -> int:
count = 0
for name in names:
if name in self._data:
del self._data[name]
count += 1
return count
async def exists(self, *keys) -> int:
return sum(1 for k in keys if k in self._data)
async def expire(self, name: str, time: int) -> bool:
if name in self._data:
self._ttl[name] = time
return True
return False
async def close(self) -> None:
pass
def reset(self) -> None:
self._data.clear()
self._ttl.clear()
# =============================================================================
# Fixtures
# =============================================================================
@pytest.fixture(scope="session")
def event_loop():
"""创建 session 级别的事件循环。"""
loop = asyncio.new_event_loop()
yield loop
loop.close()
@pytest_asyncio.fixture(scope="session", autouse=True)
async def setup_database():
"""创建所有数据库表(session 级别,只执行一次)。"""
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
yield
async with test_engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest_asyncio.fixture
async def db_session() -> AsyncGenerator[AsyncSession, None]:
"""提供干净的数据库会话,每个测试用例使用独立事务并在测试后回滚。"""
async with test_session_factory() as session:
# 开始一个嵌套事务
nested = await session.begin_nested()
try:
yield session
finally:
# 回滚嵌套事务,确保数据库干净
if nested.is_active:
await nested.rollback()
# 清理会话
await session.close()
@pytest.fixture
def mock_redis() -> MockRedis:
"""提供模拟 Redis 客户端。"""
return MockRedis()
@pytest_asyncio.fixture(autouse=True)
async def cleanup_test_data():
"""每个测试结束后清空所有业务表(autouse)。
原因:部分 service 内部直接 await self.db.commit(),绕开了 db_session fixture
的 begin_nested + 回滚机制,导致数据在测试间残留(test_feedback test_list_all_* 失败)。
解决:在每次测试 yield 后,用一个新的 session 跑 DELETE FROM 所有表。
注意:不能用 test_engine.begin(),那会与 db_session 嵌套事务冲突,后续测试会 E。
"""
yield
# 测试结束后,用一个全新 session 清表
from app.database import Base
async with test_session_factory() as session:
try:
for table in reversed(Base.metadata.sorted_tables):
try:
await session.execute(table.delete())
except Exception:
# 表可能不存在(被某次 migration 删除),忽略
pass
await session.commit()
except Exception:
await session.rollback()
# =============================================================================
# 模块级 Mock 外部服务(让子测试可覆盖其行为)
# =============================================================================
# 2026-06-15 修复: 把 WecomService / AIService mock 提升到模块级
# 原因: client fixture 内的局部 mock 无法被测试内 `with patch.object(...)` 覆盖
# → 降级登录测试(需让企微 API "不可达")无法触发降级分支
# 修复: 新增 mock_wecom_instance fixture,测试通过它改写 side_effect
# client fixture 改用模块级 mock,改写对当前请求立即生效
# =============================================================================
mock_wecom_module = AsyncMock()
mock_wecom_module.send_message.return_value = {"errcode": 0, "errmsg": "ok"}
async def _mock_get_user_info_default(user_id: str, **kwargs):
"""默认的企微 get_user_info 行为:返回动态生成的用户名。
测试可通过 mock_wecom_instance.get_user_info.side_effect = ... 改写。
注意:这里把 name 设为空字符串,避免 agent_login 内部用企微返回的 name
覆盖请求 body 的 name。某些测试(如 test_conversation_grab::test_batch_query_agent_names)
期望 body.name="坐席1" 保持不变,而不是被企微 mock 改成"用户xxx"
"""
return {
"user_id": user_id,
"name": "", # 不覆盖 body.name,保持测试期望
"department": "测试部",
"avatar": "",
}
mock_wecom_module.get_user_info.side_effect = _mock_get_user_info_default
mock_wecom_module.get_department_users.return_value = []
mock_ai_module = AsyncMock()
mock_ai_module.generate_response.return_value = "这是AI的模拟回复"
@pytest.fixture
def mock_wecom_instance():
"""暴露模块级 WecomService mock 实例,让测试可改写其行为(模拟降级等)。
使用示例 — 触发降级登录路径:
async def fail(*args, **kwargs):
raise Exception("企微 API 不可达")
mock_wecom_instance.get_user_info.side_effect = fail
# ...发起请求后,用 try/finally 恢复原 side_effect
"""
return mock_wecom_module
@pytest.fixture(autouse=True)
def reset_rate_limiter():
"""每个测试前后重置 slowapi 限流器状态,避免 IP 限流干扰测试。
背景: /agents/login 限流 10/min per IP,pytest 连续跑多个测试会撞 429。
"""
from app.api.agents import limiter as agents_limiter
try:
agents_limiter._storage.reset()
except Exception:
pass
yield
try:
agents_limiter._storage.reset()
except Exception:
pass
@pytest_asyncio.fixture
async def client(db_session: AsyncSession, mock_redis: MockRedis) -> AsyncGenerator[AsyncClient, None]:
"""提供 FastAPI 异步测试客户端。"""
async def _override_get_db():
yield db_session
async def _override_get_redis():
return mock_redis
# 注: 2026-06-15 UTF-8 monkey-patch 已提升到 conftest 模块级,见文件顶部
# 原因: reset_rate_limiter 等 autouse fixture 提前 import 触发 .env 读取
from app.main import create_app
from app.database import get_db
app = create_app()
# 覆盖数据库依赖
app.dependency_overrides[get_db] = _override_get_db
# 覆盖 Redis 依赖(dep_redis 是 app.dependencies 提供的 DI 函数)
# 这样所有用 dep_redis 注入的端点(本 worktree 新增的 auth_qrcode / h5 等)
# 都拿到 mock_redis,无需逐个 patch 模块内的 _get_redis。
from app.dependencies import dep_redis
app.dependency_overrides[dep_redis] = _override_get_redis
# 同时 patch app.dependencies.get_redis,因为 get_current_user 走的是这个
# 旧路径(没用 dep_redis),auth_qrcode.confirm 端点会触发
with patch("app.dependencies.get_redis", AsyncMock(return_value=mock_redis)):
# 模拟 Redis(同时 mock agents 和 h5 模块的 Redis 依赖)
with patch("app.api.agents._get_redis", return_value=mock_redis):
with patch("redis.asyncio.from_url", return_value=mock_redis):
# ------------------------------------------------------------------
# Mock 外部服务:WecomService(企微API)和 AIServiceAI大模型)
# 为什么:测试中不应调用真实企微API/AI大模型
# 怎么做:patch 类构造函数,返回配置了默认返回值的 mock 对象
# ------------------------------------------------------------------
# 使用模块级 mock_wecom_module / mock_ai_module2026-06-15 修复)
# 原因: 模块级 mock 允许测试通过 mock_wecom_instance fixture 改写行为
# 例如降级登录测试改 side_effect = raise Exception("企微不可达")
mock_wecom = mock_wecom_module
mock_ai = mock_ai_module
# Patch WecomService 类(端点函数中会新建实例)
# 注意:只 patch 模块中实际引用的名字
# conversations.py 导入了 WecomService,但没有导入 AIService
with patch("app.api.conversations.WecomService", return_value=mock_wecom):
# h5.py 和 agents.py 也需要 patch
with patch("app.api.h5.WecomService", return_value=mock_wecom):
with patch("app.api.agents.WecomService", return_value=mock_wecom):
with patch("app.api.agents._get_redis", return_value=mock_redis):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
@pytest_asyncio.fixture
async def seeded_db(db_session: AsyncSession) -> AsyncSession:
"""插入测试基础数据并返回会话。"""
# 系统配置
configs = [
SystemConfig(config_key="hand_raise_keywords", config_value='["转人工","人工","人工服务","真人","客服"]', description="举手关键词"),
SystemConfig(config_key="emotion_keywords_angry", config_value='["崩溃","愤怒","投诉","差劲","垃圾"]', description="愤怒关键词"),
SystemConfig(config_key="emotion_keywords_urgent", config_value='["","紧急","马上","立刻","赶紧"]', description="紧急关键词"),
SystemConfig(config_key="emotion_keywords_worried", config_value='["担心","害怕","出错","丢失","完蛋"]', description="担忧关键词"),
SystemConfig(config_key="intervene_round_threshold", config_value="3", description="介入阈值"),
SystemConfig(config_key="urgency_base_keyword_score", config_value="1", description="基础加分"),
SystemConfig(config_key="urgency_emotion_bonus", config_value="1", description="情绪加成"),
SystemConfig(config_key="urgency_vip_bonus", config_value="1", description="VIP加成"),
SystemConfig(config_key="urgency_repeat_bonus", config_value="1", description="重复加成"),
]
db_session.add_all(configs)
# 趣味话术
phrases = [
FunnyPhrase(scene="shake", content="大哥,俺这就去摇人,稍等...", tone="亲切", sort_order=1),
FunnyPhrase(scene="vip", content="这就帮您安排专家,请稍候", tone="正式", sort_order=1),
]
db_session.add_all(phrases)
# 审批链接
links = [
ApprovalLink(category="IT", title="软件安装申请", url="https://example.com/software", sort_order=1),
ApprovalLink(category="HR", title="入职手续", url="https://example.com/onboarding", sort_order=2),
]
db_session.add_all(links)
# 软件下载
downloads = [
SoftwareDownload(category="办公", name="企业微信", version="最新版", platform="全平台", download_url="https://work.weixin.qq.com", sort_order=1),
SoftwareDownload(category="开发", name="VS Code", version="1.90", platform="Windows/Mac/Linux", download_url="https://code.visualstudio.com", sort_order=2),
]
db_session.add_all(downloads)
await db_session.flush()
return db_session
# =============================================================================
# 辅助函数
# =============================================================================
def create_test_conversation(
db_session: Optional[AsyncSession] = None,
employee_id: str = "test_employee_001",
employee_name: str = "测试员工",
status: str = "queued",
is_vip: bool = False,
is_pinned: bool = False,
is_todo: bool = False,
urgency_score: int = 1,
tags: Optional[Dict] = None,
) -> Conversation:
"""创建测试用的会话对象(可选加入 db_session)。"""
conv = Conversation(
employee_id=employee_id,
employee_name=employee_name,
department="技术部",
position="工程师",
level="",
status=status,
is_vip=is_vip,
is_pinned=is_pinned,
is_todo=is_todo,
urgency_score=urgency_score,
tags=tags or {},
last_message_at=datetime.now(),
last_message_summary="测试消息",
)
if db_session is not None:
db_session.add(conv)
# 调用方负责 commit/flush(参考其他 fixture
return conv
def create_test_agent(
user_id: str = "test_agent_001",
name: str = "测试坐席",
status: str = "online",
) -> Agent:
"""创建测试用的坐席对象。"""
return Agent(
user_id=user_id,
name=name,
status=status,
current_load=0,
max_load=5,
)