Files
wecom_it_smart_desk/backend/tests/test_bugfix_ki_suggestions.py
T
Simon bea288e414 feat: 2026-07-11 全量更新 - 代办集成+会议室预定+知识迭代修复+UI统一+Bug修复
== 已部署上线 (9项) ==
- 代办事项真实数据源集成 (企微审批API 8bug修复链)
- H5/坐席端 Logo样式统一+绿色背景
- 视频引导页修复 (localStorage key v2)
- 坐席端 v9 Vue版本修复 (ElMessage._context)
- 截图按钮 v10 修复 (getDisplayMedia user gesture)
- 扫码样式恢复+H5扫码登录跳转修复
- H5截图快捷键提示

== 代码完成待部署 (3项) ==
- 知识迭代3Bug修复 (#8 POST端点/#7 MERGE幂等/#6 过期检查)
- 会议室预定-小鱼易联终端 (40文件, 40/40测试通过)
- IT资产升级审批推送 (asset_service.py)

== 需求文档 (2项) ==
- 坐席端AI辅助消息框-PRD (4项新功能确认)
- 坐席端布局优化建议 v2.0 (7天计划)

== 新增文档 ==
- 日报-2026-07-11.md
- 知识迭代Bug修复报告-20260711.md
- 会议室预定-部署指南.md
- CHANGELOG.md 更新

== 测试 ==
- test_todo_integration.py: 40/40
- test_meetingroom.py: 40/40
- test_bugfix_ki_suggestions.py: 21/21
2026-07-11 23:13:10 +08:00

842 lines
32 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.
# -*- coding: utf-8 -*-
"""Bug 修复回归测试 — 知识迭代模块 (Bug #8 / #7 / #6)
Bug #8: 新增 POST /admin/knowledge-iteration/suggestions 端点(手动创建知识建议)
Bug #7: Neo4j create_relation 中 CREATE → MERGE(关系创建幂等化)
Bug #6: 新增 expire_pending_suggestions 定时任务(72 小时过期 pending 建议)
测试依赖: conftest.py 提供的 client / db_session / mock_redis fixtures
"""
import uuid
from datetime import datetime, timedelta, timezone
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from httpx import AsyncClient
from sqlalchemy import select, text as sa_text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.models.knowledge_suggestion import KnowledgeSuggestion
from app.models.neo4j_schema import IssueNode, ActionNode, RelationEdge
# ============================================================================
# 辅助函数 — 管理员/普通用户登录(复用 test_tier1_api.py 模式)
# ============================================================================
async def _login_admin(client: AsyncClient, db_session: AsyncSession) -> str:
"""创建 admin 角色用户并返回 Bearer token。"""
from app.models.role import Role
from app.models.user_role import UserRole
admin_id = f"test_admin_{uuid.uuid4().hex[:8]}"
# 1. 确保 admin 角色存在
stmt = select(Role).where(Role.name == "admin")
result = await db_session.execute(stmt)
admin_role = result.scalars().first()
if not admin_role:
admin_role = Role(
name="admin", display_name="管理员",
description="系统管理员", permissions=[],
)
db_session.add(admin_role)
await db_session.flush()
# 2. 创建 UserRole 关联
ur_stmt = select(UserRole).where(
UserRole.employee_id == admin_id,
UserRole.role_id == admin_role.id,
)
ur_result = await db_session.execute(ur_stmt)
if not ur_result.scalars().first():
db_session.add(UserRole(
employee_id=admin_id, role_id=admin_role.id,
source="manual", assigned_by="test_fixture",
))
await db_session.flush()
# 3. 登录
resp = await client.post("/agents/login", json={
"user_id": admin_id, "name": "测试管理员",
})
data = resp.json()
return data["data"]["token"]
async def _login_any_user(client: AsyncClient, db_session: AsyncSession) -> str:
"""创建普通用户并返回 Bearer token(用于权限测试)。"""
from app.models.role import Role
from app.models.user_role import UserRole
user_id = f"test_user_{uuid.uuid4().hex[:8]}"
# 确保 user 角色存在
stmt = select(Role).where(Role.name == "user")
result = await db_session.execute(stmt)
user_role = result.scalars().first()
if not user_role:
user_role = Role(
name="user", display_name="普通用户",
description="普通员工", permissions=[],
)
db_session.add(user_role)
await db_session.flush()
# 创建 UserRole 关联(不含 admin 角色)
ur_stmt = select(UserRole).where(
UserRole.employee_id == user_id,
UserRole.role_id == user_role.id,
)
ur_result = await db_session.execute(ur_stmt)
if not ur_result.scalars().first():
db_session.add(UserRole(
employee_id=user_id, role_id=user_role.id,
source="manual", assigned_by="test_fixture",
))
await db_session.flush()
# 登录
resp = await client.post("/agents/login", json={
"user_id": user_id, "name": "测试用户",
})
data = resp.json()
return data["data"]["token"]
# ============================================================================
# Section A — Bug #8: POST /admin/knowledge-iteration/suggestions
# ============================================================================
class TestCreateSuggestion:
"""测试手动创建知识建议端点(Bug #8 修复)。"""
@pytest.mark.asyncio
async def test_create_suggestion_success(self, client: AsyncClient, db_session: AsyncSession):
"""正常创建建议,验证返回 code=0、status=pending、数据字段正确。"""
token = await _login_admin(client, db_session)
body = {
"suggestion_type": "new_faq",
"title": "VPN 连接失败怎么办",
"content": "1. 检查网络 2. 重启 VPN 客户端 3. 联系 IT 支持",
"source_type": "manual",
}
resp = await client.post(
"/admin/knowledge-iteration/suggestions",
json=body,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert "创建成功" in data["message"]
suggestion = data["data"]
assert suggestion["status"] == "pending"
assert suggestion["title"] == "VPN 连接失败怎么办"
assert suggestion["content"] == "1. 检查网络 2. 重启 VPN 客户端 3. 联系 IT 支持"
assert suggestion["source_type"] == "manual"
assert suggestion["suggestion_type"] == "new_faq"
assert suggestion["id"] # UUID 非空
assert suggestion["graph_sync_status"] == "pending"
@pytest.mark.asyncio
async def test_create_suggestion_missing_title(self, client: AsyncClient, db_session: AsyncSession):
"""缺少 title 字段 — Pydantic 校验拒绝(422)。"""
token = await _login_admin(client, db_session)
body = {
"suggestion_type": "new_faq",
"content": "测试内容",
"source_type": "manual",
# 缺少 title
}
resp = await client.post(
"/admin/knowledge-iteration/suggestions",
json=body,
headers={"Authorization": f"Bearer {token}"},
)
# Pydantic 对缺失的必填字段返回 422
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_create_suggestion_missing_content(self, client: AsyncClient, db_session: AsyncSession):
"""缺少 content 字段 — Pydantic 校验拒绝(422)。"""
token = await _login_admin(client, db_session)
body = {
"suggestion_type": "new_faq",
"title": "测试标题",
"source_type": "manual",
# 缺少 content
}
resp = await client.post(
"/admin/knowledge-iteration/suggestions",
json=body,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_create_suggestion_missing_source_type(self, client: AsyncClient, db_session: AsyncSession):
"""缺少 source_type 字段 — Pydantic 校验拒绝(422)。"""
token = await _login_admin(client, db_session)
body = {
"suggestion_type": "new_faq",
"title": "测试标题",
"content": "测试内容",
# 缺少 source_type
}
resp = await client.post(
"/admin/knowledge-iteration/suggestions",
json=body,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_create_suggestion_empty_title(self, client: AsyncClient, db_session: AsyncSession):
"""title 为空字符串 — 业务校验返回 code=400。"""
token = await _login_admin(client, db_session)
body = {
"suggestion_type": "new_faq",
"title": "",
"content": "测试内容",
"source_type": "manual",
}
resp = await client.post(
"/admin/knowledge-iteration/suggestions",
json=body,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 400
assert "标题" in data["message"]
@pytest.mark.asyncio
async def test_create_suggestion_empty_content(self, client: AsyncClient, db_session: AsyncSession):
"""content 为空字符串 — 业务校验返回 code=400。"""
token = await _login_admin(client, db_session)
body = {
"suggestion_type": "new_faq",
"title": "测试标题",
"content": "",
"source_type": "manual",
}
resp = await client.post(
"/admin/knowledge-iteration/suggestions",
json=body,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 400
assert "内容" in data["message"]
@pytest.mark.asyncio
async def test_create_suggestion_whitespace_title(self, client: AsyncClient, db_session: AsyncSession):
"""title 为纯空格 — strip 后为空,业务校验返回 code=400。"""
token = await _login_admin(client, db_session)
body = {
"suggestion_type": "new_faq",
"title": " ",
"content": "测试内容",
"source_type": "manual",
}
resp = await client.post(
"/admin/knowledge-iteration/suggestions",
json=body,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 400
@pytest.mark.asyncio
async def test_create_suggestion_with_optional_fields(self, client: AsyncClient, db_session: AsyncSession):
"""带可选字段(audience、issue、action、relation_type)创建成功。"""
token = await _login_admin(client, db_session)
body = {
"suggestion_type": "new_faq",
"title": "带图字段的知识建议",
"content": "包含 audience、issue、action、relation_type 等可选字段",
"source_type": "manual",
"category": "网络",
"tags": ["VPN", "网络"],
"confidence": 0.88,
"audience": "employee_quick_reply",
"issue": "VPN 连接问题",
"action": "重启 VPN 客户端",
"relation_type": "LEADS_TO",
"parent_issue": "网络故障",
"graph_meta": {"source": "test"},
}
resp = await client.post(
"/admin/knowledge-iteration/suggestions",
json=body,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
suggestion = data["data"]
assert suggestion["status"] == "pending"
assert suggestion["audience"] == "employee_quick_reply"
assert suggestion["issue"] == "VPN 连接问题"
assert suggestion["action"] == "重启 VPN 客户端"
assert suggestion["relation_type"] == "LEADS_TO"
assert suggestion["parent_issue"] == "网络故障"
assert suggestion["confidence"] == 0.88
assert suggestion["category"] == "网络"
assert "VPN" in suggestion["tags"]
@pytest.mark.asyncio
async def test_create_suggestion_requires_admin(self, client: AsyncClient, db_session: AsyncSession):
"""非管理员请求被拒(403)。"""
token = await _login_any_user(client, db_session)
body = {
"suggestion_type": "new_faq",
"title": "权限测试",
"content": "测试内容",
"source_type": "manual",
}
resp = await client.post(
"/admin/knowledge-iteration/suggestions",
json=body,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_create_suggestion_no_auth(self, client: AsyncClient):
"""未携带 Token — 401/403。"""
body = {
"suggestion_type": "new_faq",
"title": "无认证测试",
"content": "测试内容",
"source_type": "manual",
}
resp = await client.post(
"/admin/knowledge-iteration/suggestions",
json=body,
)
assert resp.status_code in (401, 403)
@pytest.mark.asyncio
async def test_create_suggestion_default_category(self, client: AsyncClient, db_session: AsyncSession):
"""不传 category 时使用默认值 "其他""""
token = await _login_admin(client, db_session)
body = {
"suggestion_type": "update",
"title": "默认分类测试",
"content": "不传 category 字段",
"source_type": "manual",
}
resp = await client.post(
"/admin/knowledge-iteration/suggestions",
json=body,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert data["data"]["category"] == "其他"
# ============================================================================
# Section B — Bug #7: Neo4j create_relation MERGE 幂等性
# ============================================================================
class TestCreateRelationMerge:
"""测试 create_relation 使用 MERGE 替代 CREATEBug #7 修复)。"""
@pytest.mark.asyncio
async def test_create_relation_uses_merge(self):
"""验证 create_relation 生成的 Cypher 语句包含 MERGE 而非 CREATE。"""
from app.services.neo4j_client import Neo4jClient
# 用 __new__ 绕过 __init__,避免连接真实 Neo4j
client = Neo4jClient.__new__(Neo4jClient)
client._driver = None
# Mock execute_write_query 捕获 Cypher 语句
captured_cyphers = []
async def _capture_write(cypher, params=None):
captured_cyphers.append(cypher)
return [{"created": 1}]
client.execute_write_query = _capture_write
rel = RelationEdge(
from_uuid="uuid-from-001",
to_uuid="uuid-to-001",
type="LEADS_TO",
order=1,
weight=0.9,
)
await client.create_relation("uuid-from-001", "uuid-to-001", rel)
assert len(captured_cyphers) == 1
cypher = captured_cyphers[0]
# 核心断言:Cypher 必须使用 MERGE 而非 CREATE
assert "MERGE" in cypher, f"Cypher 应包含 MERGE,实际: {cypher}"
assert "CREATE" not in cypher.replace("CREATE", "__X__", 0) or "MERGE" in cypher, \
f"Cypher 不应使用 CREATE 创建关系,实际: {cypher}"
# 更精确的检查:不应有独立的 CREATE 关系语句
# CREATE CONSTRAINT 等不含在此 Cypher 中,所以直接检查)
assert "MERGE (from_node)-[" in cypher, f"应使用 MERGE 创建关系边,实际: {cypher}"
@pytest.mark.asyncio
async def test_create_relation_idempotent(self):
"""调用 create_relation 两次相同参数,验证 Cypher 语句一致(MERGE 幂等)。
MERGE 语义:相同参数重复执行不会创建重复边。
本测试通过验证两次调用生成相同的 MERGE Cypher 来确认幂等性。
"""
from app.services.neo4j_client import Neo4jClient
client = Neo4jClient.__new__(Neo4jClient)
client._driver = None
captured_cyphers = []
call_count = {"value": 0}
async def _capture_write(cypher, params=None):
captured_cyphers.append((cypher, params))
call_count["value"] += 1
# MERGE 幂等:两次调用都返回成功
return [{"created": 1}]
client.execute_write_query = _capture_write
rel = RelationEdge(
from_uuid="uuid-from-002",
to_uuid="uuid-to-002",
type="RELATES_TO",
order=2,
weight=0.5,
)
# 第一次调用
result1 = await client.create_relation("uuid-from-002", "uuid-to-002", rel)
# 第二次调用(相同参数)
result2 = await client.create_relation("uuid-from-002", "uuid-to-002", rel)
assert result1 is True
assert result2 is True
# 两次调用的 Cypher 应完全相同(MERGE 幂等语义)
assert len(captured_cyphers) == 2
cypher1, params1 = captured_cyphers[0]
cypher2, params2 = captured_cyphers[1]
assert cypher1 == cypher2, "两次调用的 Cypher 语句应相同"
assert params1 == params2, "两次调用的参数应相同"
# 确认使用 MERGE
assert "MERGE" in cypher1
@pytest.mark.asyncio
async def test_create_relation_different_types(self):
"""验证不同关系类型生成的 Cypher 都使用 MERGE。"""
from app.services.neo4j_client import Neo4jClient
client = Neo4jClient.__new__(Neo4jClient)
client._driver = None
captured_cyphers = []
async def _capture_write(cypher, params=None):
captured_cyphers.append(cypher)
return [{"created": 1}]
client.execute_write_query = _capture_write
for rel_type in ["LEADS_TO", "RELATES_TO", "CAN_JUMP_TO"]:
rel = RelationEdge(
from_uuid=f"from-{rel_type}",
to_uuid=f"to-{rel_type}",
type=rel_type,
order=1,
weight=1.0,
)
await client.create_relation(f"from-{rel_type}", f"to-{rel_type}", rel)
assert len(captured_cyphers) == 3
for cypher in captured_cyphers:
assert "MERGE" in cypher, f"所有关系类型都应使用 MERGE,实际: {cypher}"
# ============================================================================
# Section C — Bug #6: expire_pending_suggestions 定时任务
# ============================================================================
def _sqlite_compatible_text(sql_string):
"""将 PostgreSQL 方言 SQL 翻译为 SQLite 兼容 SQL。
expire_pending_suggestions() 内部使用 NOW() 和 INTERVAL '72 hours'
这些是 PostgreSQL 专属语法。测试使用 SQLite 内存数据库,
需要翻译为 datetime('now') 和 datetime('now', '-72 hours')。
"""
sqlite_sql = (
sql_string
.replace("NOW() - INTERVAL '72 hours'", "datetime('now', '-72 hours')")
.replace("NOW()", "datetime('now')")
)
return sa_text(sqlite_sql)
def _utcnow():
"""返回 UTC naive datetime,与 SQLite datetime('now') 时区一致。
_utcnow() 返回本地时间(如 UTC+8),而 SQLite datetime('now') 返回 UTC。
若用本地时间设置 created_atWHERE created_at < datetime('now', '-72 hours')
会因时区偏移导致匹配失败。测试中统一使用 UTC 时间避免此问题。
"""
return datetime.now(timezone.utc).replace(tzinfo=None)
class TestExpirePendingSuggestions:
"""测试 expire_pending_suggestions 定时任务(Bug #6 修复)。"""
@pytest.mark.asyncio
async def test_expire_pending_suggestions(self, db_session: AsyncSession):
"""超过 72 小时的 pending 建议被标记为 expired。"""
from app.main import expire_pending_suggestions
# 创建一条 created_at 超过 72 小时的 pending 建议
# 注意: 使用 UTC 时间(_utcnow)而非 datetime.now(),因为 SQLite 的
# datetime('now') 返回 UTC,而 datetime.now() 返回本地时间(UTC+8),
# 时区不一致会导致 WHERE 条件匹配失败
old_time = _utcnow() - timedelta(hours=73)
suggestion = KnowledgeSuggestion(
suggestion_type="new_faq",
status="pending",
title="过期测试建议",
content="这条建议创建超过 72 小时,应被过期",
category="网络",
tags=["测试"],
source_type="conversation",
source_data=["conv-old-001"],
reason="过期测试",
confidence=0.8,
audience="employee_quick_reply",
created_at=old_time,
)
db_session.add(suggestion)
await db_session.commit()
# 构建与 db_session 同引擎的 session factoryStaticPool 共享连接)
session_factory = async_sessionmaker(
db_session.bind,
class_=AsyncSession,
expire_on_commit=False,
)
# Patch _get_session_factory 返回测试 session factory
# Patch text 为 SQLite 兼容版本
with patch("app.database._get_session_factory", return_value=session_factory):
with patch("app.main.text", side_effect=_sqlite_compatible_text):
await expire_pending_suggestions()
# 刷新 session 缓存并查询
suggestion_id = suggestion.id
db_session.expire_all()
stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id)
result = await db_session.execute(stmt)
updated = result.scalar_one_or_none()
assert updated is not None
assert updated.status == "expired", \
f"超时 pending 建议应变为 expired,实际: {updated.status}"
@pytest.mark.asyncio
async def test_expire_does_not_affect_recent(self, db_session: AsyncSession):
"""新创建的 pending 建议(created_at 为当前时间)不受影响。"""
from app.main import expire_pending_suggestions
# 创建一条新的 pending 建议(created_at 为当前时间)
suggestion = KnowledgeSuggestion(
suggestion_type="new_faq",
status="pending",
title="新建待审建议",
content="这条建议刚创建,不应被过期",
category="软件",
tags=["测试"],
source_type="manual",
source_data=None,
reason="近期测试",
confidence=0.75,
audience="employee_quick_reply",
created_at=_utcnow(),
)
db_session.add(suggestion)
await db_session.commit()
session_factory = async_sessionmaker(
db_session.bind,
class_=AsyncSession,
expire_on_commit=False,
)
with patch("app.database._get_session_factory", return_value=session_factory):
with patch("app.main.text", side_effect=_sqlite_compatible_text):
await expire_pending_suggestions()
suggestion_id = suggestion.id
db_session.expire_all()
stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id)
result = await db_session.execute(stmt)
updated = result.scalar_one_or_none()
assert updated is not None
assert updated.status == "pending", \
f"近期 pending 建议不应被过期,实际: {updated.status}"
@pytest.mark.asyncio
async def test_expire_does_not_affect_queued(self, db_session: AsyncSession):
"""queued 状态的建议(即使超过 72 小时)不被过期。"""
from app.main import expire_pending_suggestions
old_time = _utcnow() - timedelta(hours=73)
suggestion = KnowledgeSuggestion(
suggestion_type="update",
status="queued",
title="队列中的旧建议",
content="这条建议在队列中,即使超过 72 小时也不应被过期",
category="账号",
tags=["队列", "测试"],
source_type="conversation",
source_data=["conv-queued-old"],
reason="队列过期测试",
confidence=0.6,
audience="engineer_workguide",
created_at=old_time,
)
db_session.add(suggestion)
await db_session.commit()
session_factory = async_sessionmaker(
db_session.bind,
class_=AsyncSession,
expire_on_commit=False,
)
with patch("app.database._get_session_factory", return_value=session_factory):
with patch("app.main.text", side_effect=_sqlite_compatible_text):
await expire_pending_suggestions()
suggestion_id = suggestion.id
db_session.expire_all()
stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id)
result = await db_session.execute(stmt)
updated = result.scalar_one_or_none()
assert updated is not None
assert updated.status == "queued", \
f"queued 建议不应被过期,实际: {updated.status}"
@pytest.mark.asyncio
async def test_expire_does_not_affect_approved(self, db_session: AsyncSession):
"""approved 状态的建议(超过 72 小时)不被过期。"""
from app.main import expire_pending_suggestions
old_time = _utcnow() - timedelta(hours=73)
suggestion = KnowledgeSuggestion(
suggestion_type="new_faq",
status="approved",
title="已通过的旧建议",
content="这条建议已通过审核,不应被过期",
category="安全",
tags=["审批", "测试"],
source_type="annotation",
source_data=["annot-old-001"],
reason="审批过期测试",
confidence=0.9,
audience="employee_quick_reply",
created_at=old_time,
)
db_session.add(suggestion)
await db_session.commit()
session_factory = async_sessionmaker(
db_session.bind,
class_=AsyncSession,
expire_on_commit=False,
)
with patch("app.database._get_session_factory", return_value=session_factory):
with patch("app.main.text", side_effect=_sqlite_compatible_text):
await expire_pending_suggestions()
suggestion_id = suggestion.id
db_session.expire_all()
stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id)
result = await db_session.execute(stmt)
updated = result.scalar_one_or_none()
assert updated is not None
assert updated.status == "approved", \
f"approved 建议不应被过期,实际: {updated.status}"
@pytest.mark.asyncio
async def test_expire_does_not_affect_rejected(self, db_session: AsyncSession):
"""rejected 状态的建议(超过 72 小时)不被过期。"""
from app.main import expire_pending_suggestions
old_time = _utcnow() - timedelta(hours=73)
suggestion = KnowledgeSuggestion(
suggestion_type="outdated",
status="rejected",
title="已拒绝的旧建议",
content="这条建议已被拒绝,不应被过期",
category="硬件",
tags=["拒绝", "测试"],
source_type="ai_uncertain",
source_data=None,
reason="拒绝过期测试",
confidence=0.3,
audience="engineer_workguide",
reject_reason="内容不准确",
created_at=old_time,
)
db_session.add(suggestion)
await db_session.commit()
session_factory = async_sessionmaker(
db_session.bind,
class_=AsyncSession,
expire_on_commit=False,
)
with patch("app.database._get_session_factory", return_value=session_factory):
with patch("app.main.text", side_effect=_sqlite_compatible_text):
await expire_pending_suggestions()
suggestion_id = suggestion.id
db_session.expire_all()
stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id)
result = await db_session.execute(stmt)
updated = result.scalar_one_or_none()
assert updated is not None
assert updated.status == "rejected", \
f"rejected 建议不应被过期,实际: {updated.status}"
@pytest.mark.asyncio
async def test_expire_multiple_pending_only(self, db_session: AsyncSession):
"""混合状态下只有超时 pending 被过期,其他状态不受影响。"""
from app.main import expire_pending_suggestions
old_time = _utcnow() - timedelta(hours=80)
# 创建多条不同状态的建议,created_at 均超过 72 小时
suggestions = []
for status in ["pending", "queued", "approved", "rejected", "pending"]:
s = KnowledgeSuggestion(
suggestion_type="new_faq",
status=status,
title=f"混合测试-{status}",
content=f"状态 {status} 的旧建议",
category="网络",
tags=["混合测试"],
source_type="conversation",
source_data=[f"conv-mix-{status}"],
reason="混合状态测试",
confidence=0.7,
audience="employee_quick_reply",
created_at=old_time,
)
suggestions.append(s)
db_session.add(s)
await db_session.commit()
session_factory = async_sessionmaker(
db_session.bind,
class_=AsyncSession,
expire_on_commit=False,
)
with patch("app.database._get_session_factory", return_value=session_factory):
with patch("app.main.text", side_effect=_sqlite_compatible_text):
await expire_pending_suggestions()
# 在 expire_all 之前捕获 id 和原始 status,避免 expire 后触发同步懒加载
captured = [(s.id, s.status) for s in suggestions]
db_session.expire_all()
# 验证:只有 pending → expired,其他状态不变
for sid, orig_status in captured:
stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == sid)
result = await db_session.execute(stmt)
updated = result.scalar_one_or_none()
assert updated is not None
if orig_status == "pending":
assert updated.status == "expired", \
f"pending 应变为 expired,实际: {updated.status}"
else:
assert updated.status == orig_status, \
f"{orig_status} 状态不应改变,实际: {updated.status}"
@pytest.mark.asyncio
async def test_expire_boundary_72_hours(self, db_session: AsyncSession):
"""边界测试:created_at 恰好 71 小时(未超 72 小时)的 pending 不被过期。"""
from app.main import expire_pending_suggestions
# 71 小时前 — 未达到 72 小时阈值
boundary_time = _utcnow() - timedelta(hours=71)
suggestion = KnowledgeSuggestion(
suggestion_type="new_faq",
status="pending",
title="边界测试建议",
content="created_at 71 小时前,不应被过期",
category="软件",
tags=["边界"],
source_type="manual",
source_data=None,
reason="边界测试",
confidence=0.8,
audience="employee_quick_reply",
created_at=boundary_time,
)
db_session.add(suggestion)
await db_session.commit()
session_factory = async_sessionmaker(
db_session.bind,
class_=AsyncSession,
expire_on_commit=False,
)
with patch("app.database._get_session_factory", return_value=session_factory):
with patch("app.main.text", side_effect=_sqlite_compatible_text):
await expire_pending_suggestions()
suggestion_id = suggestion.id
db_session.expire_all()
stmt = select(KnowledgeSuggestion).where(KnowledgeSuggestion.id == suggestion_id)
result = await db_session.execute(stmt)
updated = result.scalar_one_or_none()
assert updated is not None
# 71 小时 < 72 小时,不应被过期
assert updated.status == "pending", \
f"71 小时的 pending 不应被过期,实际: {updated.status}"