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
This commit is contained in:
Simon
2026-07-11 23:13:10 +08:00
parent 3d152fc8eb
commit bea288e414
928 changed files with 85169 additions and 54205 deletions
+841
View File
@@ -0,0 +1,841 @@
# -*- 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}"