# -*- 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"", "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