bea288e414
== 已部署上线 (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
842 lines
32 KiB
Python
842 lines
32 KiB
Python
# -*- 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 替代 CREATE(Bug #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_at,WHERE 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 factory(StaticPool 共享连接)
|
||
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}"
|