Files
wecom_it_smart_desk/backend/tests/test_tier1_api.py
T
Simon 5e53146a9a test(backend): unit/integration tests for automation, otp, neo4j, contract
新增自动化审批状态机/执行器/意图路由/会话管理、OTP 绑定流程、neo4j 客户端、响应契约、置信度门禁、环境门控、Tier1 API 等测试。
2026-07-09 11:49:50 +08:00

835 lines
34 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 -*-
"""Tier1 新增 API 测试(Round 2
覆盖:
1. Vision API — POST /api/vision/analyze + GET /api/vision/models
2. RAGFlow Ingestion API — POST /api/ragflow/ingest + GET /api/ragflow/tasks/{id}
3. 独立审批队列 API — GET /admin/approval-queue/queued + stats + dequeue-approve
4. 知识迭代 API admin 端点 — 路由挂载 + audience/confidence 筛选
依赖: conftest.py 提供的 client / db_session / mock_redis / login_test_agent
"""
import io
import uuid
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import pytest_asyncio
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.knowledge_suggestion import KnowledgeSuggestion
# ═══════════════════════════════════════════════════════════════════════════════
# 辅助函数 — 管理员登录
# ═══════════════════════════════════════════════════════════════════════════════
async def _login_admin(client: AsyncClient, db_session: AsyncSession) -> str:
"""创建 admin 角色用户并返回 Bearer token。
与 conftest.login_test_agent 同模式,但角色为 admin。
"""
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(用于 require_any_user 测试)。"""
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 关联
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"]
def _make_test_png() -> bytes:
"""生成一个最小的有效 PNG 图片(1x1 白色像素)。"""
import struct, zlib
def chunk(ctype, data):
c = ctype + data
return struct.pack(">I", len(data)) + c + struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF)
ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 2, 0, 0, 0)
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", ihdr) + chunk(b"IDAT", zlib.compress(b"\x00")) + chunk(b"IEND", b"")
# ═══════════════════════════════════════════════════════════════════════════════
# Section A — Vision API
# ═══════════════════════════════════════════════════════════════════════════════
class TestVisionModels:
"""GET /api/vision/models — 无需认证,公开查询。"""
@pytest.mark.asyncio
async def test_list_models_no_auth(self, client: AsyncClient):
"""无需 Token 即可获取模型列表。"""
resp = await client.get("/api/vision/models")
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert "models" in data["data"]
assert len(data["data"]["models"]) >= 1
assert "default_model" in data["data"]
class TestVisionAnalyze:
"""POST /api/vision/analyze — 截图分析(需认证)。"""
@pytest.mark.asyncio
async def test_analyze_requires_auth(self, client: AsyncClient):
"""未携带 Token → 401(或 403)。"""
resp = await client.post("/api/vision/analyze")
assert resp.status_code in (401, 403)
@pytest.mark.asyncio
async def test_missing_image_field(self, client: AsyncClient, db_session: AsyncSession):
"""缺少必填 image 字段。"""
token = await _login_any_user(client, db_session)
resp = await client.post(
"/api/vision/analyze",
data={"conversation_id": "conv-test-001"},
headers={"Authorization": f"Bearer {token}"},
)
# FastAPI 会返回 422(缺少必填的 UploadFile 字段)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_missing_conversation_id(self, client: AsyncClient, db_session: AsyncSession):
"""缺少必填 conversation_id 字段。"""
token = await _login_any_user(client, db_session)
png = _make_test_png()
resp = await client.post(
"/api/vision/analyze",
files={"image": ("test.png", io.BytesIO(png), "image/png")},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 422
@pytest.mark.asyncio
async def test_invalid_file_type(self, client: AsyncClient, db_session: AsyncSession):
"""上传不支持的 MIME 类型 → 400。"""
token = await _login_any_user(client, db_session)
resp = await client.post(
"/api/vision/analyze",
data={"conversation_id": "conv-test-001"},
files={"image": ("test.svg", b"<svg></svg>", "image/svg+xml")},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200 # 业务 400 由 code 字段表达
data = resp.json()
assert data["code"] == 400
assert "不支持" in data["message"]
@pytest.mark.asyncio
async def test_oversized_file(self, client: AsyncClient, db_session: AsyncSession):
"""上传超过 10MB 的文件 → 400。"""
token = await _login_any_user(client, db_session)
big_data = b"\x00" * (11 * 1024 * 1024) # 11MB
resp = await client.post(
"/api/vision/analyze",
data={"conversation_id": "conv-test-001"},
files={"image": ("big.png", io.BytesIO(big_data), "image/png")},
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_analyze_ok_with_mock_vision_service(
self, client: AsyncClient, db_session: AsyncSession,
):
"""正常截图分析 — Mock VisionService 返回结构化描述。"""
token = await _login_any_user(client, db_session)
png = _make_test_png()
mock_service = MagicMock()
mock_service.analyze_screenshot = AsyncMock(return_value={
"description": "这是一个蓝色背景的错误弹窗,显示'网络连接失败'",
"confidence": 0.92,
"metadata": {"ui_elements": ["error_dialog", "retry_button"]},
})
mock_service.inject_to_conversation_context = AsyncMock(return_value=True)
mock_service.close = AsyncMock()
with patch("app.api.vision.VisionService", return_value=mock_service):
resp = await client.post(
"/api/vision/analyze",
data={
"conversation_id": "conv-test-001",
"vision_model": "Qwen3-VL-8B-Instruct",
},
files={"image": ("test.png", io.BytesIO(png), "image/png")},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert data["message"] == "视觉分析完成"
assert data["data"]["description"] != ""
assert data["data"]["confidence"] > 0.8
assert data["data"]["injected"] is True
@pytest.mark.asyncio
async def test_analyze_handles_service_exception(
self, client: AsyncClient, db_session: AsyncSession,
):
"""VisionService 抛出异常 → 500 降级。"""
token = await _login_any_user(client, db_session)
png = _make_test_png()
mock_service = MagicMock()
mock_service.analyze_screenshot = AsyncMock(
side_effect=RuntimeError("模型推理超时")
)
mock_service.close = AsyncMock()
with patch("app.api.vision.VisionService", return_value=mock_service):
resp = await client.post(
"/api/vision/analyze",
data={"conversation_id": "conv-test-001"},
files={"image": ("test.png", io.BytesIO(png), "image/png")},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 500
assert "视觉分析失败" in data["message"]
# ═══════════════════════════════════════════════════════════════════════════════
# Section B — RAGFlow Ingestion API
# ═══════════════════════════════════════════════════════════════════════════════
class TestRagflowIngestion:
"""POST /api/ragflow/ingest — 文档摄入(需管理员权限)。"""
@pytest.mark.asyncio
async def test_ingest_requires_admin(self, client: AsyncClient, db_session: AsyncSession):
"""普通用户调用 → 403。发送有效文件以确保先通过参数校验再到权限检查。"""
token = await _login_any_user(client, db_session)
resp = await client.post(
"/api/ragflow/ingest",
data={"category_hint": "其他"},
files={"file": ("test.txt", io.BytesIO(b"hello"), "text/plain")},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_ingest_no_auth(self, client: AsyncClient):
"""未认证 → 401 或 403。"""
resp = await client.post("/api/ragflow/ingest")
assert resp.status_code in (401, 403)
@pytest.mark.asyncio
async def test_invalid_extension(self, client: AsyncClient, db_session: AsyncSession):
"""上传不支持的文件格式 → 400。"""
token = await _login_admin(client, db_session)
resp = await client.post(
"/api/ragflow/ingest",
data={"category_hint": "网络"},
files={"file": ("test.exe", b"binary", "application/octet-stream")},
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_empty_file(self, client: AsyncClient, db_session: AsyncSession):
"""上传空文件 → 400。"""
token = await _login_admin(client, db_session)
resp = await client.post(
"/api/ragflow/ingest",
data={"category_hint": "软件"},
files={"file": ("empty.txt", b"", "text/plain")},
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_oversized_file(self, client: AsyncClient, db_session: AsyncSession):
"""上传超过 20MB 的文件 → 400。"""
token = await _login_admin(client, db_session)
big_data = b"\x00" * (21 * 1024 * 1024) # 21MB
resp = await client.post(
"/api/ragflow/ingest",
data={"category_hint": "其他"},
files={"file": ("big.pdf", io.BytesIO(big_data), "application/pdf")},
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_ingest_ok_txt(self, client: AsyncClient, db_session: AsyncSession):
"""正常上传 .txt 文档 → 200,生成 KnowledgeSuggestion。"""
token = await _login_admin(client, db_session)
mock_service = MagicMock()
mock_service.upload_and_process = AsyncMock(return_value={
"task_id": "task-001",
"status": "completed",
"suggestions": [{
"suggestion_type": "new_faq",
"title": "VPN 连接失败排查",
"content": "1. 检查网络 2. 重启 VPN",
"category": "网络",
"tags": ["VPN", "连接"],
"source_data": ["chunk-001"],
"reason": "RAGFlow 提取",
"confidence": 0.85,
"issue": "VPN",
"action": "重启",
"relation_type": "LEADS_TO",
"parent_issue": "",
"graph_meta": {},
"source_failed": False,
}],
})
with patch("app.api.ragflow_ingestion.RagflowIngestionService", return_value=mock_service):
resp = await client.post(
"/api/ragflow/ingest",
data={"category_hint": "网络"},
files={"file": ("vpn_faq.txt", io.BytesIO("VPN troubleshooting steps...".encode()), "text/plain")},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert data["data"]["status"] == "completed"
assert data["data"]["suggestions_count"] == 1
assert data["data"]["suggestions"][0]["title"] == "VPN 连接失败排查"
# 验证 KnowledgeSuggestion 已入库
stmt = select(KnowledgeSuggestion).where(
KnowledgeSuggestion.source_type == "document_ragflow"
)
result = await db_session.execute(stmt)
suggestions = result.scalars().all()
assert len(suggestions) >= 1
assert suggestions[0].audience == "engineer_workguide"
@pytest.mark.asyncio
async def test_ingest_ok_docx(self, client: AsyncClient, db_session: AsyncSession):
"""正常上传 .docx 文档 → 200。"""
token = await _login_admin(client, db_session)
mock_service = MagicMock()
mock_service.upload_and_process = AsyncMock(return_value={
"task_id": "task-002",
"status": "completed",
"suggestions": [],
})
with patch("app.api.ragflow_ingestion.RagflowIngestionService", return_value=mock_service):
resp = await client.post(
"/api/ragflow/ingest",
data={"category_hint": "安全"},
files={"file": ("policy.docx", io.BytesIO(b"PK\x03\x04 fake docx"), "application/vnd.openxmlformats-officedocument.wordprocessingml.document")},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert data["data"]["suggestions_count"] == 0
class TestRagflowTasks:
"""GET /api/ragflow/tasks/{task_id} — 查询任务状态(需管理员)。"""
@pytest.mark.asyncio
async def test_task_not_found(self, client: AsyncClient, db_session: AsyncSession):
"""不存在的 task_id → 404。"""
token = await _login_admin(client, db_session)
resp = await client.get(
"/api/ragflow/tasks/nonexistent-task-id",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 404
@pytest.mark.asyncio
async def test_task_found(self, client: AsyncClient, db_session: AsyncSession):
"""先 ingest 再查状态 → 200 找到。"""
token = await _login_admin(client, db_session)
mock_service = MagicMock()
mock_service.upload_and_process = AsyncMock(return_value={
"task_id": "task-found-001",
"status": "completed",
"suggestions": [],
})
with patch("app.api.ragflow_ingestion.RagflowIngestionService", return_value=mock_service):
# 先上传文档创建 task
ingest_resp = await client.post(
"/api/ragflow/ingest",
data={"category_hint": "其他"},
files={"file": ("note.txt", io.BytesIO(b"content"), "text/plain")},
headers={"Authorization": f"Bearer {token}"},
)
task_id = ingest_resp.json()["data"]["task_id"]
# 再查询该 task
resp = await client.get(
f"/api/ragflow/tasks/{task_id}",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert data["data"]["task_id"] == task_id
assert data["data"]["status"] == "completed"
# ═══════════════════════════════════════════════════════════════════════════════
# Section C — 独立审批队列 API
# ═══════════════════════════════════════════════════════════════════════════════
def _seed_suggestions(db_session: AsyncSession, count: int = 3):
"""播种测试用 KnowledgeSuggestion 数据。"""
statuses = ["pending", "pending", "queued", "approved", "rejected"]
for i in range(min(count, len(statuses))):
db_session.add(KnowledgeSuggestion(
suggestion_type="new_faq",
status=statuses[i],
title=f"测试建议 {i+1}",
content=f"测试内容 {i+1}",
category="网络" if i % 2 == 0 else "软件",
tags=["测试"],
source_type="conversation",
source_data=[f"conv-{i}"],
reason="队列测试",
confidence=0.75 + i * 0.05,
audience="employee_quick_reply" if i % 2 == 0 else "engineer_workguide",
))
class TestApprovalQueueList:
"""GET /admin/approval-queue/queued — 队列列表。"""
@pytest.mark.asyncio
async def test_list_requires_admin(self, client: AsyncClient, db_session: AsyncSession):
"""普通用户 → 403。"""
token = await _login_any_user(client, db_session)
resp = await client.get(
"/admin/approval-queue/queued",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_list_empty(self, client: AsyncClient, db_session: AsyncSession):
"""无数据时返回空列表。"""
token = await _login_admin(client, db_session)
resp = await client.get(
"/admin/approval-queue/queued",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert data["data"]["total"] == 0
assert data["data"]["items"] == []
@pytest.mark.asyncio
async def test_list_with_data(self, client: AsyncClient, db_session: AsyncSession):
"""有 pending+queued 数据时返回正确列表。"""
_seed_suggestions(db_session)
await db_session.commit()
token = await _login_admin(client, db_session)
resp = await client.get(
"/admin/approval-queue/queued",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
# pending(1) + queued(1) = 2
assert data["data"]["total"] >= 2
items = data["data"]["items"]
# 所有 item 的 status 应为 pending 或 queued
for item in items:
assert item["status"] in ("pending", "queued")
@pytest.mark.asyncio
async def test_list_filter_by_status(self, client: AsyncClient, db_session: AsyncSession):
"""按 status=queued 筛选。"""
_seed_suggestions(db_session)
await db_session.commit()
token = await _login_admin(client, db_session)
resp = await client.get(
"/admin/approval-queue/queued?status=queued",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
for item in data["data"]["items"]:
assert item["status"] == "queued"
@pytest.mark.asyncio
async def test_list_filter_by_audience(self, client: AsyncClient, db_session: AsyncSession):
"""按 audience 筛选。"""
_seed_suggestions(db_session)
await db_session.commit()
token = await _login_admin(client, db_session)
resp = await client.get(
"/admin/approval-queue/queued?audience=employee_quick_reply",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
for item in data["data"]["items"]:
assert item["audience"] == "employee_quick_reply"
@pytest.mark.asyncio
async def test_list_pagination(self, client: AsyncClient, db_session: AsyncSession):
"""分页参数生效。"""
_seed_suggestions(db_session, count=5)
await db_session.commit()
token = await _login_admin(client, db_session)
resp = await client.get(
"/admin/approval-queue/queued?page=1&page_size=2",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert len(data["data"]["items"]) <= 2
class TestApprovalQueueStats:
"""GET /admin/approval-queue/queued/stats — 队列统计。"""
@pytest.mark.asyncio
async def test_stats_requires_admin(self, client: AsyncClient, db_session: AsyncSession):
"""普通用户 → 403。"""
token = await _login_any_user(client, db_session)
resp = await client.get(
"/admin/approval-queue/queued/stats",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_stats_with_data(self, client: AsyncClient, db_session: AsyncSession):
"""正常统计返回。"""
_seed_suggestions(db_session)
await db_session.commit()
token = await _login_admin(client, db_session)
resp = await client.get(
"/admin/approval-queue/queued/stats",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert "queued_total" in data["data"]
assert "pending_total" in data["data"]
assert "by_audience" in data["data"]
assert "by_source_type" in data["data"]
class TestApprovalQueueDequeueApprove:
"""POST /admin/approval-queue/queued/{id}/dequeue-approve — 队列审批。"""
@pytest.mark.asyncio
async def test_dequeue_requires_admin(self, client: AsyncClient, db_session: AsyncSession):
"""普通用户 → 403。"""
token = await _login_any_user(client, db_session)
resp = await client.post(
"/admin/approval-queue/queued/fake-id/dequeue-approve",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_dequeue_not_found(self, client: AsyncClient, db_session: AsyncSession):
"""不存在的 suggestion_id → 404。"""
token = await _login_admin(client, db_session)
resp = await client.post(
"/admin/approval-queue/queued/nonexistent-id/dequeue-approve",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 404
@pytest.mark.asyncio
async def test_dequeue_approve_ok(self, client: AsyncClient, db_session: AsyncSession):
"""正常队列审批流程:queued → approved → applied → graph_synced。"""
# 创建一个 queued 状态的建议
suggestion = KnowledgeSuggestion(
suggestion_type="new_faq",
status="queued",
title="队列审批测试",
content="测试内容",
category="软件",
tags=["测试"],
source_type="conversation",
source_data=["conv-dequeue-test"],
reason="独立队列审批测试",
confidence=0.88,
audience="employee_quick_reply",
issue="测试问题",
action="测试动作",
relation_type="LEADS_TO",
parent_issue="",
)
db_session.add(suggestion)
await db_session.commit()
await db_session.refresh(suggestion)
token = await _login_admin(client, db_session)
resp = await client.post(
f"/admin/approval-queue/queued/{suggestion.id}/dequeue-approve",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert "队列审批通过" in data["message"]
# 状态应为 applied(经 approve_suggestion 全流程)
assert data["data"]["status"] in ("applied", "graph_synced")
# ═══════════════════════════════════════════════════════════════════════════════
# Section D — 知识迭代 API 路由挂载 + Tier1 扩展字段验证
# ═══════════════════════════════════════════════════════════════════════════════
class TestKnowledgeIterationRouting:
"""确认 /admin/knowledge-iteration/* 路由正确挂载(Tier1 新增端点)。"""
@pytest.mark.asyncio
async def test_suggestions_endpoint_accessible(self, client: AsyncClient, db_session: AsyncSession):
"""GET /admin/knowledge-iteration/suggestions 路由存在且需管理员。"""
# 无 Token → 401/403
resp = await client.get("/admin/knowledge-iteration/suggestions")
assert resp.status_code in (401, 403)
# 管理员 Token → 200
token = await _login_admin(client, db_session)
resp = await client.get(
"/admin/knowledge-iteration/suggestions",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
@pytest.mark.asyncio
async def test_suggestions_audience_filter(self, client: AsyncClient, db_session: AsyncSession):
"""GET /suggestions?audience=... 筛选生效。"""
# 播种不同 audience 的建议
db_session.add(KnowledgeSuggestion(
suggestion_type="new_faq", status="pending",
title="A", content="A", category="网络", tags=[],
source_type="conversation", source_data=["c-a"],
reason="测试", confidence=0.8, audience="employee_quick_reply",
))
db_session.add(KnowledgeSuggestion(
suggestion_type="new_faq", status="pending",
title="B", content="B", category="网络", tags=[],
source_type="conversation", source_data=["c-b"],
reason="测试", confidence=0.8, audience="engineer_workguide",
))
await db_session.commit()
token = await _login_admin(client, db_session)
resp = await client.get(
"/admin/knowledge-iteration/suggestions?audience=employee_quick_reply",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
for item in data["data"]["items"]:
assert item["audience"] == "employee_quick_reply"
@pytest.mark.asyncio
async def test_suggestions_confidence_filter(self, client: AsyncClient, db_session: AsyncSession):
"""GET /suggestions?confidence_min=...&confidence_max=... 筛选生效。"""
db_session.add(KnowledgeSuggestion(
suggestion_type="new_faq", status="pending",
title="Low", content="Low", category="软件", tags=[],
source_type="conversation", source_data=["c-low"],
reason="测试", confidence=0.45, audience="employee_quick_reply",
))
db_session.add(KnowledgeSuggestion(
suggestion_type="new_faq", status="pending",
title="High", content="High", category="软件", tags=[],
source_type="conversation", source_data=["c-high"],
reason="测试", confidence=0.92, audience="employee_quick_reply",
))
await db_session.commit()
token = await _login_admin(client, db_session)
# 只查 >= 0.7 的
resp = await client.get(
"/admin/knowledge-iteration/suggestions?confidence_min=0.7",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
for item in data["data"]["items"]:
assert item["confidence"] >= 0.7
@pytest.mark.asyncio
async def test_stats_endpoint(self, client: AsyncClient, db_session: AsyncSession):
"""GET /admin/knowledge-iteration/stats 返回正确统计。"""
_seed_suggestions(db_session)
await db_session.commit()
token = await _login_admin(client, db_session)
resp = await client.get(
"/admin/knowledge-iteration/stats",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert "pending" in data["data"]
assert "queued" in data["data"]
assert "total" in data["data"]
@pytest.mark.asyncio
async def test_rewrite_endpoint(self, client: AsyncClient, db_session: AsyncSession):
"""POST /suggestions/{id}/rewrite 改写提案并重置为 pending。"""
suggestion = KnowledgeSuggestion(
suggestion_type="new_faq", status="rejected",
title="原标题", content="原内容", category="网络", tags=[""],
source_type="conversation", source_data=["conv-rewrite"],
reason="改写测试", confidence=0.8, audience="employee_quick_reply",
)
db_session.add(suggestion)
await db_session.commit()
await db_session.refresh(suggestion)
token = await _login_admin(client, db_session)
resp = await client.post(
f"/admin/knowledge-iteration/suggestions/{suggestion.id}/rewrite",
json={"title": "改写的标题", "content": "改写的内容"},
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert data["data"]["title"] == "改写的标题"
assert data["data"]["status"] == "pending"
@pytest.mark.asyncio
async def test_queue_endpoint(self, client: AsyncClient, db_session: AsyncSession):
"""POST /suggestions/{id}/queue 放入独立队列。"""
suggestion = KnowledgeSuggestion(
suggestion_type="new_faq", status="pending",
title="待入队", content="待入队内容", category="网络", tags=[],
source_type="conversation", source_data=["conv-queue"],
reason="入队测试", confidence=0.8, audience="employee_quick_reply",
)
db_session.add(suggestion)
await db_session.commit()
await db_session.refresh(suggestion)
token = await _login_admin(client, db_session)
resp = await client.post(
f"/admin/knowledge-iteration/suggestions/{suggestion.id}/queue",
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 200
data = resp.json()
assert data["code"] == 0
assert data["data"]["status"] == "queued"
assert data["data"]["queued_at"] is not None