835 lines
34 KiB
Python
835 lines
34 KiB
Python
|
|
# -*- 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
|