test(tests): 添加全面的API接口测试套件

- 新增认证API测试,覆盖登录、权限验证、用户管理等关键流程
- 新增对话API测试,包含智能体调用、线程管理、工具获取及配置查询
- 新增系统API测试,涵盖系统健康检查、信息查询及配置管理权限控制
- 配置pytest支持异步测试、标记分类及详细输出
- 添加测试环境配置示例文件 .env.test.example
- 新增测试用例README文档,说明测试架构及运行方法
- 优化Makefile,添加格式化差异检查任务 format_diff
- 更新.gitignore,支持忽略测试环境配置文件 .env.test
This commit is contained in:
Wenjie Zhang 2025-09-20 22:53:37 +08:00
parent 25842eb33e
commit 2a3e18b444
21 changed files with 1259 additions and 26 deletions

1
.gitignore vendored
View File

@ -11,6 +11,7 @@ node_modules
### common gitignore
.DS_Store
.env
.env.test
.env.local
.env.*.local
npm-debug.log*

View File

@ -22,6 +22,9 @@ lint:
uv run python -m ruff format src --diff
uv run python -m ruff check --select I src
format format_diff:
format:
uv run ruff format .
uv run ruff check . --fix
uv run ruff check . --fix
format_diff:
uv run ruff format --diff .

View File

@ -12,7 +12,8 @@
- [ ] 集成智能体评估,首先使用命令行来实现,然后考虑放在 UI 里面展示
- [ ] 开发与生产环境隔离
- [ ] 添加统计信息
- [ ] 添加内容审查功能
- [x] 添加内容审查功能
- [x] 补充 embedding 模型和 reranker 模型的配置说明
📝 **Base**
@ -28,7 +29,7 @@
- [x] 默认智能体设置后,在一些情况下依然仅加载第一个智能体
- [x] 目前只能获取默认的 tools单个智能体的tools没法展示
- [x] 生成的过程中切换对话,会出现消息渲染混乱的问题,消息完全记载完成之后不会出现混乱
- [ ] 当消息生成的时候有报错的时候,前端无显示
- [ ] 当消息生成的时候有报错的时候,前端无显示(需要解决存储的问题)
- [ ] 偶然会出现 embedding 报错的情况,但不好复现
- [x] GraphCanvas 在智能体消息页面有时候会出现渲染不出现的问题可以添加一个slot 在右上角(加载 button这样就可以强制重新渲染
@ -38,7 +39,7 @@
- [x] 支持数据库查询功能
- [x] 消息内容支持图片显示
- [ ] 添加 SQL 读取工具
- [x] 添加 SQL 读取工具
- [x] 添加绘图工具(这里的绘图是指绘制固定格式的图和表等,暂时没想好如何支持自定义绘图)
- [ ] 添加测试脚本,覆盖最常见的功能
- [ ] 优化对文档信息的检索展示(检索结果页、详情页)

View File

@ -66,8 +66,26 @@ lint.select = [ # 选择的规则
"UP",
]
[tool.pytest.ini_options]
addopts = "-v --tb=short"
testpaths = ["test"]
markers = [
"auth: marks tests that require authentication",
"slow: marks tests as slow",
"integration: marks tests as integration tests"
]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
[dependency-groups]
dev = [
"ipykernel>=6.30.0",
"ruff>=0.12.1",
]
test = [
"pytest>=8.0.0",
"pytest-asyncio>=0.24.0",
"pytest-httpx>=0.32.0",
"pytest-cov>=6.0.0",
]

View File

@ -3,9 +3,9 @@ from dotenv import load_dotenv
load_dotenv("src/.env", override=True)
from concurrent.futures import ThreadPoolExecutor # noqa: E402
from src.config import config as config # noqa: E402
from src.knowledge import knowledge_base as knowledge_base # noqa: E402
from src.knowledge import graph_base as graph_base # noqa: E402
from src.config import config as config # noqa: E402
from src.knowledge import graph_base as graph_base # noqa: E402
from src.knowledge import knowledge_base as knowledge_base # noqa: E402
executor = ThreadPoolExecutor() # noqa: E402

View File

@ -1,14 +1,13 @@
import os
import requests
from typing import Any
import requests
from langchain_core.tools import tool
from src.agents.common.toolkits.mysql import get_mysql_tools
from src.agents.common.tools import get_buildin_tools
from src.utils import logger
from src.utils.minio_utils import upload_image_to_minio
from src.agents.common.toolkits.mysql import get_mysql_tools
@tool

View File

@ -27,6 +27,19 @@ MCP_SERVERS = {
# "mcp-server-chart": {
# "url": "https://mcp.api-inference.modelscope.net/9993ae42524c4c/mcp",
# "transport": "streamable_http",
# },
# 需要在 docker 内安装 npx
# "mysql": {
# "command": "npx",
# "args": ["-y", "@benborla29/mcp-server-mysql@2.0.2"],
# "env": {
# "MYSQL_HOST": "172.19.13.6",
# "MYSQL_PORT": "3306",
# "MYSQL_USER": "read-only",
# "MYSQL_PASS": "password123",
# "MYSQL_DB": "feed"
# },
# "transport": "stdio"
# }
}
@ -69,8 +82,8 @@ async def get_mcp_tools(server_name: str) -> list[Callable[..., Any]]:
except AssertionError as e:
logger.warning(f"Failed to load tools from MCP server '{server_name}': {e}")
return []
except Exception:
logger.opt(exception=True).warning(f"Failed to load tools from MCP server '{server_name}'")
except Exception as e:
logger.error(f"Failed to load tools from MCP server '{server_name}': {e}")
return []

View File

@ -1,11 +1,13 @@
import signal
import time
from typing import Any
from contextlib import contextmanager
import threading
import time
from contextlib import contextmanager
from typing import Any
import pymysql
from pymysql.cursors import DictCursor
from pymysql import MySQLError
from pymysql.cursors import DictCursor
from src.utils import logger

View File

@ -1,12 +1,13 @@
from typing import Annotated, Any
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from .connection import MySQLConnectionManager, limit_result_size
from .security import MySQLSecurityChecker
from .exceptions import MySQLConnectionError
from src.utils import logger
from .connection import MySQLConnectionManager, limit_result_size
from .exceptions import MySQLConnectionError
from .security import MySQLSecurityChecker
# 全局连接管理器实例
_connection_manager: MySQLConnectionManager | None = None

View File

@ -1,8 +1,8 @@
import os
from ..config import config
from .graphbase import GraphDatabase
from .chroma_kb import ChromaKB
from .graphbase import GraphDatabase
from .kb_factory import KnowledgeBaseFactory
from .kb_manager import KnowledgeBaseManager
from .lightrag_kb import LightRagKB

View File

@ -1,4 +1,4 @@
from src.models.chat_model import select_model, get_custom_model
from src.models.chat_model import get_custom_model, select_model
from src.models.embedding import select_embedding_model
__all__ = ["select_model", "select_embedding_model", "get_custom_model"]

View File

@ -2,7 +2,7 @@ import os
import traceback
from openai import OpenAI
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type, before_sleep_log
from tenacity import before_sleep_log, retry, retry_if_exception_type, stop_after_attempt, wait_exponential
from src import config
from src.utils import get_docker_safe_url, logger

15
test/.env.test.example Normal file
View File

@ -0,0 +1,15 @@
# 测试环境配置
# 复制并重命名为 .env.test 用于测试
# 测试服务器地址
TEST_BASE_URL=http://localhost:5050
# 测试用户凭据
TEST_USERNAME=zwj
TEST_PASSWORD=zwj12138
# 数据库连接(如果需要)
# DATABASE_URL=sqlite:///test.db
# 其他测试相关配置
# ENABLE_DEBUG=true

252
test/README.md Normal file
View File

@ -0,0 +1,252 @@
# Yuxi-Know API 测试脚本
## 概述
本测试套件为Yuxi-Know项目提供了全面的API接口测试涵盖认证、系统管理、对话等核心功能模块。
## 测试架构
### 测试工具栈
- **pytest**: Python测试框架
- **pytest-asyncio**: 异步测试支持
- **httpx**: 现代HTTP客户端
- **pytest-cov**: 测试覆盖率分析
### 测试模块结构
```
test/
├── api/ # API接口测试
│ ├── test_auth_api.py # 认证API测试
│ ├── test_system_api.py # 系统API测试
│ └── test_chat_api.py # 对话API测试
├── conftest.py # pytest配置和公共fixtures
├── run_tests.sh # 测试运行脚本
├── .env.test.example # 测试环境配置示例
└── README.md # 测试文档
```
## 快速开始
### 1. 准备环境
```bash
# 复制测试配置文件
cp test/.env.test.example test/.env.test
# 编辑配置文件,设置测试服务器地址和认证信息
vim test/.env.test
```
### 2. 运行测试
```bash
# 使用测试脚本(推荐)
./test/run_tests.sh all
# 或直接使用pytest
uv run pytest test/api/ -v
```
## 测试类型
### 认证测试 (test_auth_api.py)
- ✅ 健康检查(无需认证)
- ✅ 首次运行状态检查
- ✅ 登录凭据验证
- ✅ 用户信息获取
- ✅ 用户管理权限
- ✅ 无效令牌处理
### 系统测试 (test_system_api.py)
- ✅ 系统健康检查
- ✅ 系统信息获取
- ✅ 配置管理(获取/更新)
- ✅ 批量配置更新
- ✅ 系统日志获取
- ✅ 权限控制验证
### 对话测试 (test_chat_api.py)
- ✅ 智能体列表获取
- ✅ 简单对话调用
- ✅ 流式对话测试
- ✅ 线程管理(创建/更新/删除)
- ✅ 工具列表获取
- ✅ 智能体配置管理
## 运行命令
### 使用测试脚本
```bash
# 所有测试
./test/run_tests.sh all
# 认证测试
./test/run_tests.sh auth
# 系统测试
./test/run_tests.sh system
# 对话测试
./test/run_tests.sh chat
# 快速测试(排除慢速测试)
./test/run_tests.sh quick
# 检查服务器状态
./test/run_tests.sh check
```
### 使用pytest直接运行
```bash
# 运行所有API测试
uv run pytest test/api/ -v
# 运行特定测试文件
uv run pytest test/api/test_auth_api.py -v
# 运行特定测试方法
uv run pytest test/api/test_auth_api.py::TestAuthAPI::test_login_valid_credentials -v
# 运行带标记的测试
uv run pytest test/api/ -v -m "auth"
uv run pytest test/api/ -v -m "not slow"
# 生成覆盖率报告
uv run pytest test/api/ --cov=server --cov=src --cov-report=html
```
## 测试标记
测试使用pytest标记进行分类
- `@pytest.mark.auth`: 需要认证的测试
- `@pytest.mark.slow`: 慢速测试(如流式对话)
- `@pytest.mark.integration`: 集成测试
## 配置说明
### 环境变量 (test/.env.test)
```bash
# 测试服务器地址
TEST_BASE_URL=http://localhost:5050
# 测试用户凭据
TEST_USERNAME=zwj
TEST_PASSWORD=zwj12138
```
### pytest配置 (pyproject.toml)
```toml
[tool.pytest.ini_options]
addopts = "-v --tb=short"
testpaths = ["test"]
markers = [
"auth: marks tests that require authentication",
"slow: marks tests as slow",
"integration: marks tests as integration tests"
]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "session"
```
## 测试特性
### 智能错误处理
- 自动检测服务器可用性
- 优雅处理认证失败
- 权限不足时跳过相关测试
### 异步支持
- 完整的异步测试支持
- 高效的并发测试执行
- 会话级别的资源管理
### 灵活配置
- 环境变量配置
- 可配置的服务器地址
- 支持不同环境切换
## 故障排除
### 常见问题
1. **连接失败**
```bash
# 检查服务器是否运行
curl http://localhost:5050/api/system/health
# 检查Docker服务
docker-compose ps
```
2. **认证失败**
- 检查 `test/.env.test` 中的用户凭据
- 确认用户存在且密码正确
- 验证用户权限级别
3. **依赖问题**
```bash
# 重新安装测试依赖
uv add --group test pytest pytest-asyncio pytest-httpx pytest-cov
```
### 调试选项
```bash
# 详细输出
uv run pytest test/api/ -v -s
# 显示完整错误信息
uv run pytest test/api/ --tb=long
# 停在第一个失败
uv run pytest test/api/ -x
# 运行最后失败的测试
uv run pytest test/api/ --lf
```
## 扩展开发
### 添加新的测试
1. 在 `test/api/` 目录下创建新的测试文件
2. 继承测试基类或直接编写测试函数
3. 使用现有的fixtures (test_client, auth_headers)
4. 添加适当的pytest标记
### 自定义fixtures
`test/conftest.py` 中添加新的fixtures
```python
@pytest_asyncio.fixture
async def custom_fixture():
# 初始化资源
yield resource
# 清理资源
```
## 持续集成
测试脚本可以轻松集成到CI/CD流水线
```yaml
# GitHub Actions示例
- name: Run API Tests
run: |
cp test/.env.test.example test/.env.test
./test/run_tests.sh all
```
## 性能考虑
- 会话级别的HTTP客户端复用
- 异步并发执行
- 智能跳过不可用的测试
- 最小化资源占用

3
test/api/__init__.py Normal file
View File

@ -0,0 +1,3 @@
"""
API测试模块初始化文件
"""

190
test/api/test_auth_api.py Normal file
View File

@ -0,0 +1,190 @@
"""
认证API测试
测试用户认证用户管理等相关接口
"""
import pytest
import httpx
class TestAuthAPI:
"""认证API测试类"""
@pytest.mark.asyncio
async def test_health_check_no_auth(self, test_client: httpx.AsyncClient):
"""测试健康检查接口(无需认证)"""
response = await test_client.get("/api/system/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert "message" in data
@pytest.mark.asyncio
async def test_check_first_run(self, test_client: httpx.AsyncClient):
"""测试检查首次运行状态"""
response = await test_client.get("/api/auth/check-first-run")
assert response.status_code == 200
data = response.json()
assert "first_run" in data
assert isinstance(data["first_run"], bool)
@pytest.mark.asyncio
async def test_login_invalid_credentials(self, test_client: httpx.AsyncClient):
"""测试无效凭据登录"""
login_data = {"username": "invalid_user", "password": "invalid_password"}
response = await test_client.post("/api/auth/token", data=login_data)
# 期望返回401未授权错误
assert response.status_code == 401
data = response.json()
assert "detail" in data
@pytest.mark.asyncio
async def test_login_valid_credentials(self, test_client: httpx.AsyncClient):
"""测试有效凭据登录(如果存在管理员用户)"""
# 首先检查是否是首次运行
first_run_response = await test_client.get("/api/auth/check-first-run")
first_run_data = first_run_response.json()
if first_run_data.get("first_run", False):
# 如果是首次运行,先初始化管理员
init_data = {"username": "admin", "password": "admin123"}
init_response = await test_client.post("/api/auth/initialize", json=init_data)
if init_response.status_code == 200:
init_result = init_response.json()
assert "access_token" in init_result
assert init_result["token_type"] == "bearer"
assert init_result["username"] == "admin"
assert init_result["role"] == "superadmin"
# 测试用新创建的管理员账户登录
login_data = {"username": "admin", "password": "admin123"}
login_response = await test_client.post("/api/auth/token", data=login_data)
assert login_response.status_code == 200
login_result = login_response.json()
assert "access_token" in login_result
assert login_result["token_type"] == "bearer"
assert login_result["username"] == "admin"
return
# 如果不是首次运行,尝试用默认凭据登录
login_data = {"username": "zwj", "password": "zwj12138"}
response = await test_client.post("/api/auth/token", data=login_data)
# 如果登录成功
if response.status_code == 200:
data = response.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
assert "username" in data
assert "role" in data
else:
# 如果登录失败,记录信息但不失败测试(可能没有预设用户)
print(f"Login failed with status {response.status_code}: {response.json()}")
@pytest.mark.asyncio
@pytest.mark.auth
async def test_get_current_user(self, test_client: httpx.AsyncClient, auth_headers: dict):
"""测试获取当前用户信息(需要认证)"""
if not auth_headers:
pytest.skip("No auth token available, skipping authenticated test")
response = await test_client.get("/api/auth/me", headers=auth_headers)
if response.status_code == 200:
data = response.json()
assert "id" in data
assert "username" in data
assert "role" in data
assert "created_at" in data
else:
# 如果认证失败,记录但不使测试失败
print(f"Auth test failed with status {response.status_code}: {response.json()}")
@pytest.mark.asyncio
@pytest.mark.auth
async def test_get_users_list(self, test_client: httpx.AsyncClient, auth_headers: dict):
"""测试获取用户列表(需要管理员权限)"""
if not auth_headers:
pytest.skip("No auth token available, skipping authenticated test")
response = await test_client.get("/api/auth/users", headers=auth_headers)
if response.status_code == 200:
data = response.json()
assert isinstance(data, list)
# 检查每个用户对象的基本字段
if len(data) > 0:
user = data[0]
assert "id" in user
assert "username" in user
assert "role" in user
elif response.status_code == 403:
# 权限不足,这是预期的情况之一
print("User does not have admin privileges")
else:
print(f"Get users test failed with status {response.status_code}: {response.json()}")
@pytest.mark.asyncio
async def test_unauthorized_access(self, test_client: httpx.AsyncClient):
"""测试未授权访问受保护的端点"""
# 尝试不带认证访问受保护的端点
response = await test_client.get("/api/auth/me")
# 注意根据实际API行为可能返回500而不是401
assert response.status_code in [401, 500] # 接受两种情况
response = await test_client.get("/api/auth/users")
assert response.status_code in [401, 500] # 接受两种情况
@pytest.mark.asyncio
async def test_invalid_token_access(self, test_client: httpx.AsyncClient):
"""测试使用无效token访问受保护的端点"""
invalid_headers = {"Authorization": "Bearer invalid_token_here"}
response = await test_client.get("/api/auth/me", headers=invalid_headers)
assert response.status_code == 401
response = await test_client.get("/api/auth/users", headers=invalid_headers)
assert response.status_code == 401
@pytest.mark.asyncio
@pytest.mark.auth
async def test_create_user_permission(self, test_client: httpx.AsyncClient, auth_headers: dict):
"""测试创建用户权限(需要管理员权限)"""
if not auth_headers:
pytest.skip("No auth token available, skipping authenticated test")
# 尝试创建一个测试用户
user_data = {
"username": f"test_user_{id(self)}", # 使用对象ID作为唯一标识
"password": "test_password_123",
"role": "user",
}
response = await test_client.post("/api/auth/users", json=user_data, headers=auth_headers)
if response.status_code == 200:
data = response.json()
assert data["username"] == user_data["username"]
assert data["role"] == user_data["role"]
assert "id" in data
# 清理:删除创建的测试用户
user_id = data["id"]
delete_response = await test_client.delete(f"/api/auth/users/{user_id}", headers=auth_headers)
# 删除可能成功也可能失败,取决于权限,不强制断言
assert delete_response.status_code in [200, 404]
elif response.status_code == 400:
# 用户已存在或其他业务逻辑错误
print(f"Create user failed due to business logic: {response.json()}")
elif response.status_code == 403:
# 权限不足
print("User does not have permission to create users")
else:
print(f"Create user test failed with status {response.status_code}: {response.json()}")
# 添加一些集成测试标记
pytestmark = [pytest.mark.integration]

308
test/api/test_chat_api.py Normal file
View File

@ -0,0 +1,308 @@
"""
对话API测试
测试智能体对话线程管理等相关接口
"""
import pytest
import httpx
import json
class TestChatAPI:
"""对话API测试类"""
@pytest.mark.asyncio
@pytest.mark.auth
async def test_get_default_agent(self, test_client: httpx.AsyncClient, auth_headers: dict):
"""测试获取默认智能体"""
if not auth_headers:
pytest.skip("No auth token available, skipping authenticated test")
response = await test_client.get("/api/chat/default_agent", headers=auth_headers)
if response.status_code == 200:
data = response.json()
assert "default_agent_id" in data
print(f"Default agent ID: {data['default_agent_id']}")
else:
print(f"Get default agent failed with status {response.status_code}: {response.json()}")
@pytest.mark.asyncio
@pytest.mark.auth
async def test_get_agents(self, test_client: httpx.AsyncClient, auth_headers: dict):
"""测试获取智能体列表"""
if not auth_headers:
pytest.skip("No auth token available, skipping authenticated test")
response = await test_client.get("/api/chat/agent", headers=auth_headers)
if response.status_code == 200:
data = response.json()
assert "agents" in data
assert isinstance(data["agents"], list)
print(f"Found {len(data['agents'])} agents")
if data["agents"]:
agent = data["agents"][0]
print(f"First agent: {agent}")
else:
print(f"Get agents failed with status {response.status_code}: {response.json()}")
@pytest.mark.asyncio
@pytest.mark.auth
async def test_simple_call(self, test_client: httpx.AsyncClient, auth_headers: dict, test_query: str):
"""测试简单对话调用"""
if not auth_headers:
pytest.skip("No auth token available, skipping authenticated test")
call_data = {"query": test_query, "meta": {"test": True}}
response = await test_client.post("/api/chat/call", json=call_data, headers=auth_headers)
if response.status_code == 200:
data = response.json()
assert "response" in data
assert isinstance(data["response"], str)
print(f"Chat response: {data['response'][:100]}...")
else:
# 安全地处理响应
try:
error_data = response.json()
print(f"Simple call failed with status {response.status_code}: {error_data}")
except Exception:
print(f"Simple call failed with status {response.status_code}: {response.text[:200]}...")
@pytest.mark.asyncio
@pytest.mark.auth
async def test_unauthorized_chat_access(self, test_client: httpx.AsyncClient):
"""测试未授权访问对话接口"""
# 测试简单调用
response = await test_client.post("/api/chat/call", json={"query": "test"})
assert response.status_code == 401
# 测试获取智能体
response = await test_client.get("/api/chat/agent")
assert response.status_code == 401
# 测试获取默认智能体
response = await test_client.get("/api/chat/default_agent")
assert response.status_code == 401
@pytest.mark.asyncio
@pytest.mark.auth
@pytest.mark.slow
async def test_agent_chat_streaming(self, test_client: httpx.AsyncClient, auth_headers: dict):
"""测试智能体流式对话"""
if not auth_headers:
pytest.skip("No auth token available, skipping authenticated test")
# 首先获取可用的智能体
agents_response = await test_client.get("/api/chat/agent", headers=auth_headers)
if agents_response.status_code != 200:
pytest.skip("Cannot get agents list, skipping agent chat test")
agents_data = agents_response.json()
if not agents_data.get("agents"):
pytest.skip("No agents available, skipping agent chat test")
agent_id = agents_data["agents"][0].get("id")
if not agent_id:
pytest.skip("No valid agent ID found, skipping agent chat test")
# 测试智能体对话
chat_data = {"query": "Hello, this is a test message", "config": {"test": True}, "meta": {"test": True}}
response = await test_client.post(f"/api/chat/agent/{agent_id}", json=chat_data, headers=auth_headers)
if response.status_code == 200:
# 处理流式响应
content = await response.aread()
lines = content.decode("utf-8").strip().split("\n")
# 验证流式响应格式
for i, line in enumerate(lines[:3]): # 只检查前几行
try:
json_data = json.loads(line)
assert "status" in json_data
print(f"Stream line {i}: status={json_data.get('status')}")
except json.JSONDecodeError:
print(f"Invalid JSON in line {i}: {line[:50]}...")
print(f"Received {len(lines)} stream lines from agent {agent_id}")
else:
print(f"Agent chat failed with status {response.status_code}: {response.json()}")
@pytest.mark.asyncio
@pytest.mark.auth
async def test_create_thread(self, test_client: httpx.AsyncClient, auth_headers: dict):
"""测试创建对话线程"""
if not auth_headers:
pytest.skip("No auth token available, skipping authenticated test")
# 获取可用的智能体
agents_response = await test_client.get("/api/chat/agent", headers=auth_headers)
if agents_response.status_code != 200:
pytest.skip("Cannot get agents list, skipping thread test")
agents_data = agents_response.json()
if not agents_data.get("agents"):
pytest.skip("No agents available, skipping thread test")
agent_id = agents_data["agents"][0].get("id")
if not agent_id:
pytest.skip("No valid agent ID found, skipping thread test")
# 创建线程
thread_data = {"title": "测试对话线程", "agent_id": agent_id, "description": "这是一个测试创建的对话线程"}
response = await test_client.post("/api/chat/thread", json=thread_data, headers=auth_headers)
if response.status_code == 200:
data = response.json()
assert "id" in data
assert data["agent_id"] == agent_id
assert data["title"] == thread_data["title"]
print(f"Created thread: {data['id']}")
# 测试获取线程列表
threads_response = await test_client.get(f"/api/chat/threads?agent_id={agent_id}", headers=auth_headers)
if threads_response.status_code == 200:
threads_data = threads_response.json()
assert isinstance(threads_data, list)
# 验证我们创建的线程在列表中
thread_ids = [t["id"] for t in threads_data]
assert data["id"] in thread_ids
print(f"Found {len(threads_data)} threads for agent {agent_id}")
# 清理:删除创建的线程
delete_response = await test_client.delete(f"/api/chat/thread/{data['id']}", headers=auth_headers)
if delete_response.status_code == 200:
print(f"Successfully deleted thread {data['id']}")
else:
print(f"Create thread failed with status {response.status_code}: {response.json()}")
@pytest.mark.asyncio
@pytest.mark.auth
async def test_get_tools(self, test_client: httpx.AsyncClient, auth_headers: dict):
"""测试获取工具列表"""
if not auth_headers:
pytest.skip("No auth token available, skipping authenticated test")
# 获取可用的智能体
agents_response = await test_client.get("/api/chat/agent", headers=auth_headers)
if agents_response.status_code != 200:
pytest.skip("Cannot get agents list, skipping tools test")
agents_data = agents_response.json()
if not agents_data.get("agents"):
pytest.skip("No agents available, skipping tools test")
agent_id = agents_data["agents"][0].get("id")
if not agent_id:
pytest.skip("No valid agent ID found, skipping tools test")
response = await test_client.get(f"/api/chat/tools?agent_id={agent_id}", headers=auth_headers)
if response.status_code == 200:
data = response.json()
assert "tools" in data
assert isinstance(data["tools"], dict)
print(f"Found {len(data['tools'])} tools for agent {agent_id}")
if data["tools"]:
tool_names = list(data["tools"].keys())
print(f"Available tools: {tool_names[:5]}...") # 显示前5个工具名
else:
print(f"Get tools failed with status {response.status_code}: {response.json()}")
@pytest.mark.asyncio
@pytest.mark.auth
async def test_agent_config(self, test_client: httpx.AsyncClient, auth_headers: dict):
"""测试智能体配置获取"""
if not auth_headers:
pytest.skip("No auth token available, skipping authenticated test")
# 获取可用的智能体
agents_response = await test_client.get("/api/chat/agent", headers=auth_headers)
if agents_response.status_code != 200:
pytest.skip("Cannot get agents list, skipping config test")
agents_data = agents_response.json()
if not agents_data.get("agents"):
pytest.skip("No agents available, skipping config test")
agent_id = agents_data["agents"][0].get("id")
if not agent_id:
pytest.skip("No valid agent ID found, skipping config test")
response = await test_client.get(f"/api/chat/agent/{agent_id}/config", headers=auth_headers)
if response.status_code == 200:
data = response.json()
assert "success" in data
assert "config" in data
print(f"Agent {agent_id} config keys: {list(data['config'].keys()) if data['config'] else 'None'}")
else:
print(f"Get agent config failed with status {response.status_code}: {response.json()}")
@pytest.mark.asyncio
@pytest.mark.auth
async def test_thread_management_full_cycle(self, test_client: httpx.AsyncClient, auth_headers: dict):
"""测试完整的线程管理周期:创建、更新、删除"""
if not auth_headers:
pytest.skip("No auth token available, skipping authenticated test")
# 获取智能体
agents_response = await test_client.get("/api/chat/agent", headers=auth_headers)
if agents_response.status_code != 200:
pytest.skip("Cannot get agents list")
agents_data = agents_response.json()
if not agents_data.get("agents"):
pytest.skip("No agents available")
agent_id = agents_data["agents"][0].get("id")
if not agent_id:
pytest.skip("No valid agent ID found")
# 1. 创建线程
thread_data = {"title": "完整测试线程", "agent_id": agent_id, "description": "用于完整周期测试的线程"}
create_response = await test_client.post("/api/chat/thread", json=thread_data, headers=auth_headers)
assert create_response.status_code == 200
thread = create_response.json()
thread_id = thread["id"]
# 2. 更新线程
update_data = {"title": "更新后的标题", "description": "更新后的描述"}
update_response = await test_client.put(f"/api/chat/thread/{thread_id}", json=update_data, headers=auth_headers)
if update_response.status_code == 200:
updated_thread = update_response.json()
assert updated_thread["title"] == update_data["title"]
assert updated_thread["description"] == update_data["description"]
print(f"Successfully updated thread {thread_id}")
# 3. 删除线程
delete_response = await test_client.delete(f"/api/chat/thread/{thread_id}", headers=auth_headers)
if delete_response.status_code == 200:
print(f"Successfully deleted thread {thread_id}")
# 验证线程确实被删除(不应该再出现在列表中)
threads_response = await test_client.get(f"/api/chat/threads?agent_id={agent_id}", headers=auth_headers)
if threads_response.status_code == 200:
threads_data = threads_response.json()
thread_ids = [t["id"] for t in threads_data]
assert thread_id not in thread_ids
print("Confirmed thread deletion")
# 标记为集成测试
pytestmark = [pytest.mark.integration]

238
test/api/test_system_api.py Normal file
View File

@ -0,0 +1,238 @@
"""
系统API测试
测试系统管理配置管理等相关接口
"""
import pytest
import httpx
class TestSystemAPI:
"""系统API测试类"""
@pytest.mark.asyncio
async def test_health_check(self, test_client: httpx.AsyncClient):
"""测试系统健康检查接口"""
response = await test_client.get("/api/system/health")
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert "message" in data
print(f"Health check response: {data}")
@pytest.mark.asyncio
async def test_get_system_info(self, test_client: httpx.AsyncClient):
"""测试获取系统信息接口(公开接口)"""
response = await test_client.get("/api/system/info")
if response.status_code == 200:
data = response.json()
assert "success" in data
assert "data" in data
print(f"System info response: {data}")
else:
# 如果接口不存在或有其他问题,记录但不失败
print(f"System info request failed with status {response.status_code}: {response.json()}")
@pytest.mark.asyncio
@pytest.mark.auth
async def test_get_config_unauthorized(self, test_client: httpx.AsyncClient):
"""测试未授权访问配置接口"""
response = await test_client.get("/api/system/config")
assert response.status_code == 401
print("Config access properly requires authentication")
@pytest.mark.asyncio
@pytest.mark.auth
async def test_get_config_authorized(self, test_client: httpx.AsyncClient, auth_headers: dict):
"""测试已授权访问配置接口"""
if not auth_headers:
pytest.skip("No auth token available, skipping authenticated test")
response = await test_client.get("/api/system/config", headers=auth_headers)
if response.status_code == 200:
data = response.json()
# 验证配置数据结构
assert isinstance(data, dict)
# 检查一些常见的配置项
expected_config_keys = [
"enable_reranker",
"enable_web_search",
"model_provider",
"model_name",
"embed_model",
]
for key in expected_config_keys:
if key in data:
print(f"Config contains {key}: {data[key]}")
print(f"Config keys: {list(data.keys())}")
elif response.status_code == 403:
print("User does not have admin privileges to access config")
else:
print(f"Get config failed with status {response.status_code}: {response.json()}")
@pytest.mark.asyncio
@pytest.mark.auth
async def test_update_config_unauthorized(self, test_client: httpx.AsyncClient):
"""测试未授权更新配置"""
config_data = {"key": "test_key", "value": "test_value"}
response = await test_client.post("/api/system/config", json=config_data)
assert response.status_code == 401
@pytest.mark.asyncio
@pytest.mark.auth
@pytest.mark.slow
async def test_update_single_config(self, test_client: httpx.AsyncClient, auth_headers: dict):
"""测试更新单个配置项"""
if not auth_headers:
pytest.skip("No auth token available, skipping authenticated test")
# 先获取当前配置
get_response = await test_client.get("/api/system/config", headers=auth_headers)
if get_response.status_code != 200:
pytest.skip("Cannot access config, skipping update test")
original_config = get_response.json()
# 尝试更新一个安全的配置项(如果存在的话)
test_config_updates = [
{"key": "enable_web_search", "value": True},
{"key": "enable_reranker", "value": False},
]
for config_update in test_config_updates:
key = config_update["key"]
if key in original_config:
original_value = original_config[key]
new_value = config_update["value"]
# 更新配置
response = await test_client.post("/api/system/config", json=config_update, headers=auth_headers)
if response.status_code == 200:
updated_config = response.json()
assert updated_config[key] == new_value
print(f"Successfully updated {key} from {original_value} to {new_value}")
# 恢复原值
restore_data = {"key": key, "value": original_value}
restore_response = await test_client.post(
"/api/system/config", json=restore_data, headers=auth_headers
)
if restore_response.status_code == 200:
print(f"Successfully restored {key} to {original_value}")
break
elif response.status_code == 403:
print("User does not have permission to update config")
break
else:
print(f"Config update failed with status {response.status_code}: {response.json()}")
@pytest.mark.asyncio
@pytest.mark.auth
@pytest.mark.slow
async def test_batch_update_config(self, test_client: httpx.AsyncClient, auth_headers: dict):
"""测试批量更新配置项"""
if not auth_headers:
pytest.skip("No auth token available, skipping authenticated test")
# 准备批量更新数据
batch_updates = {"enable_web_search": True, "enable_reranker": False}
response = await test_client.post("/api/system/config/update", json=batch_updates, headers=auth_headers)
if response.status_code == 200:
updated_config = response.json()
for key, value in batch_updates.items():
if key in updated_config:
assert updated_config[key] == value
print(f"Batch update verified: {key} = {value}")
elif response.status_code == 403:
print("User does not have permission to batch update config")
else:
print(f"Batch config update failed with status {response.status_code}: {response.json()}")
@pytest.mark.asyncio
@pytest.mark.auth
async def test_get_system_logs(self, test_client: httpx.AsyncClient, auth_headers: dict):
"""测试获取系统日志"""
if not auth_headers:
pytest.skip("No auth token available, skipping authenticated test")
response = await test_client.get("/api/system/logs", headers=auth_headers)
if response.status_code == 200:
data = response.json()
assert "log" in data
assert "message" in data
assert data["message"] == "success"
assert "log_file" in data
print(f"Log file path: {data.get('log_file')}")
print(f"Log length: {len(data.get('log', ''))}")
elif response.status_code == 403:
print("User does not have permission to access logs")
else:
print(f"Get logs failed with status {response.status_code}: {response.json()}")
@pytest.mark.asyncio
@pytest.mark.auth
async def test_restart_system_permission(self, test_client: httpx.AsyncClient, auth_headers: dict):
"""测试系统重启权限(仅超级管理员)"""
if not auth_headers:
pytest.skip("No auth token available, skipping authenticated test")
response = await test_client.post("/api/system/restart", headers=auth_headers)
if response.status_code == 200:
data = response.json()
assert "message" in data
print(f"Restart response: {data}")
elif response.status_code == 403:
print("User does not have superadmin privileges to restart system")
else:
print(f"Restart request failed with status {response.status_code}: {response.json()}")
@pytest.mark.asyncio
@pytest.mark.auth
async def test_reload_info_config(self, test_client: httpx.AsyncClient, auth_headers: dict):
"""测试重新加载信息配置"""
if not auth_headers:
pytest.skip("No auth token available, skipping authenticated test")
response = await test_client.post("/api/system/info/reload", headers=auth_headers)
if response.status_code == 200:
data = response.json()
assert "success" in data
assert "message" in data
print(f"Info reload response: {data}")
elif response.status_code == 403:
print("User does not have permission to reload info config")
else:
print(f"Info reload failed with status {response.status_code}: {response.json()}")
@pytest.mark.asyncio
@pytest.mark.auth
async def test_get_ocr_stats(self, test_client: httpx.AsyncClient, auth_headers: dict):
"""测试获取OCR统计信息"""
if not auth_headers:
pytest.skip("No auth token available, skipping authenticated test")
response = await test_client.get("/api/system/ocr/stats", headers=auth_headers)
if response.status_code == 200:
data = response.json()
print(f"OCR stats response: {data}")
elif response.status_code == 403:
print("User does not have permission to access OCR stats")
elif response.status_code == 404:
print("OCR stats endpoint not available")
else:
print(f"OCR stats request failed with status {response.status_code}: {response.json()}")
# 标记为集成测试
pytestmark = [pytest.mark.integration]

84
test/conftest.py Normal file
View File

@ -0,0 +1,84 @@
"""
pytest配置文件和公共fixtures
提供测试环境配置和公共的测试工具
"""
import os
import pytest
import pytest_asyncio
import httpx
from collections.abc import AsyncGenerator
from dotenv import load_dotenv
# 加载测试环境变量
load_dotenv("test/.env.test")
# 测试配置
TEST_BASE_URL = os.getenv("TEST_BASE_URL", "http://localhost:5050")
TEST_USERNAME = os.getenv("TEST_USERNAME", "zwj")
TEST_PASSWORD = os.getenv("TEST_PASSWORD", "zwj12138")
@pytest_asyncio.fixture(scope="function")
async def test_client() -> AsyncGenerator[httpx.AsyncClient, None]:
"""创建异步HTTP客户端"""
timeout = httpx.Timeout(30.0, connect=5.0)
async with httpx.AsyncClient(base_url=TEST_BASE_URL, timeout=timeout, follow_redirects=True) as client:
yield client
@pytest_asyncio.fixture(scope="function")
async def auth_token(test_client: httpx.AsyncClient) -> str:
"""获取认证令牌用于需要认证的测试"""
try:
# 尝试登录获取token
login_data = {"username": TEST_USERNAME, "password": TEST_PASSWORD}
response = await test_client.post("/api/auth/token", data=login_data)
if response.status_code == 200:
data = response.json()
return data.get("access_token", "")
else:
# 如果登录失败返回空token某些测试可能不需要认证
print(f"Login failed with status {response.status_code}, continuing without auth token")
return ""
except Exception as e:
print(f"Auth setup failed: {e}, continuing without auth token")
return ""
@pytest_asyncio.fixture
async def auth_headers(auth_token: str) -> dict:
"""返回包含认证信息的headers"""
if auth_token:
return {"Authorization": f"Bearer {auth_token}"}
return {}
@pytest.fixture
def test_query():
"""测试用的简单查询"""
return "你好,这是一个测试查询"
@pytest.fixture
def test_chat_payload(test_query: str):
"""测试对话的请求负载"""
return {"query": test_query, "meta": {"test": True}}
# pytest配置选项
def pytest_configure(config):
"""pytest配置"""
config.addinivalue_line("markers", "auth: marks tests that require authentication")
config.addinivalue_line("markers", "slow: marks tests as slow")
config.addinivalue_line("markers", "integration: marks tests as integration tests")
# 设置异步模式
pytest_plugins = ["pytest_asyncio"]
# 测试标记
pytest.mark.auth = pytest.mark.auth
pytest.mark.slow = pytest.mark.slow
pytest.mark.integration = pytest.mark.integration

105
test/run_tests.sh Executable file
View File

@ -0,0 +1,105 @@
#!/bin/bash
# 测试脚本运行器
# 提供快速运行不同类型测试的方法
echo "Yuxi-Know API 测试运行器"
echo "========================"
# 检查测试服务是否运行
check_server() {
echo "检查测试服务器状态..."
curl -s http://localhost:5050/api/system/health > /dev/null 2>&1
if [ $? -eq 0 ]; then
echo "✓ 测试服务器运行正常"
return 0
else
echo "✗ 警告: 测试服务器未运行或无法访问"
echo " 请确保服务器在 http://localhost:5050 运行"
return 1
fi
}
# 运行所有测试
run_all_tests() {
echo "运行所有API测试..."
uv run pytest test/api/ -v
}
# 运行认证测试
run_auth_tests() {
echo "运行认证API测试..."
uv run pytest test/api/test_auth_api.py -v
}
# 运行系统测试
run_system_tests() {
echo "运行系统API测试..."
uv run pytest test/api/test_system_api.py -v
}
# 运行对话测试
run_chat_tests() {
echo "运行对话API测试..."
uv run pytest test/api/test_chat_api.py -v
}
# 运行快速测试(只测试基础功能)
run_quick_tests() {
echo "运行快速测试(基础功能)..."
uv run pytest test/api/ -v -m "not slow"
}
# 显示帮助
show_help() {
echo "用法: $0 [选项]"
echo ""
echo "选项:"
echo " all - 运行所有测试"
echo " auth - 运行认证测试"
echo " system - 运行系统测试"
echo " chat - 运行对话测试"
echo " quick - 运行快速测试(排除慢速测试)"
echo " check - 检查服务器状态"
echo " help - 显示此帮助"
echo ""
echo "示例:"
echo " $0 all # 运行所有测试"
echo " $0 quick # 快速测试"
echo " $0 check # 检查服务器"
}
# 主逻辑
case "${1:-all}" in
"all")
check_server
run_all_tests
;;
"auth")
check_server
run_auth_tests
;;
"system")
check_server
run_system_tests
;;
"chat")
check_server
run_chat_tests
;;
"quick")
check_server
run_quick_tests
;;
"check")
check_server
;;
"help"|"-h"|"--help")
show_help
;;
*)
echo "未知选项: $1"
show_help
exit 1
;;
esac

View File

@ -267,9 +267,9 @@ div.header, #app-router-view {
}
.logo {
width: 40px;
height: 40px;
margin: 8px 0 14px 0;
width: 34px;
height: 34px;
margin: 12px 0 20px 0;
img {
width: 100%;