- 将 server/, src/, scripts/, test/ 等目录移动到 backend/ 目录下 - 使用 git rename 保留文件历史记录 - 更新 docker-compose.yml 和 api.Dockerfile 配置 WIP: 项目结构重构进行中
245 lines
9.1 KiB
Python
245 lines
9.1 KiB
Python
"""
|
||
Shared pytest fixtures for exercising FastAPI routers over the running API service.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import uuid
|
||
from collections.abc import AsyncGenerator
|
||
|
||
import anyio
|
||
import httpx
|
||
import pytest
|
||
import pytest_asyncio
|
||
from dotenv import load_dotenv
|
||
|
||
# Load project and test specific environment variables.
|
||
load_dotenv(".env", override=False)
|
||
load_dotenv("test/.env.test", override=False)
|
||
|
||
API_BASE_URL = os.getenv("TEST_BASE_URL", "http://localhost:5050").rstrip("/")
|
||
ADMIN_LOGIN = os.getenv("TEST_USERNAME")
|
||
ADMIN_PASSWORD = os.getenv("TEST_PASSWORD")
|
||
|
||
assert ADMIN_LOGIN, "TEST_USERNAME is not set"
|
||
assert ADMIN_PASSWORD, "TEST_PASSWORD is not set"
|
||
|
||
_ADMIN_TOKEN_CACHE: str | None = None
|
||
HTTP_TIMEOUT = httpx.Timeout(30.0, connect=5.0)
|
||
|
||
|
||
@pytest_asyncio.fixture(scope="function")
|
||
async def test_client() -> AsyncGenerator[httpx.AsyncClient, None]:
|
||
"""Async HTTP client bound to the live API base URL."""
|
||
async with httpx.AsyncClient(base_url=API_BASE_URL, timeout=HTTP_TIMEOUT, follow_redirects=True) as client:
|
||
yield client
|
||
|
||
|
||
@pytest_asyncio.fixture(scope="function")
|
||
async def admin_token() -> str:
|
||
"""Authenticate with super admin credentials and cache the bearer token."""
|
||
global _ADMIN_TOKEN_CACHE
|
||
|
||
if _ADMIN_TOKEN_CACHE:
|
||
return _ADMIN_TOKEN_CACHE
|
||
|
||
if not ADMIN_LOGIN or not ADMIN_PASSWORD:
|
||
pytest.skip("Admin credentials are not configured via environment variables.")
|
||
|
||
async with httpx.AsyncClient(
|
||
base_url=API_BASE_URL, timeout=HTTP_TIMEOUT, follow_redirects=True
|
||
) as bootstrap_client:
|
||
response = await bootstrap_client.post(
|
||
"/api/auth/token", data={"username": ADMIN_LOGIN, "password": ADMIN_PASSWORD}
|
||
)
|
||
|
||
if response.status_code == 401:
|
||
first_run_response = await bootstrap_client.get("/api/auth/check-first-run")
|
||
if first_run_response.status_code == 200 and first_run_response.json().get("first_run", False):
|
||
pytest.fail(
|
||
"Super admin account has not been initialized. Please complete `/api/auth/initialize` "
|
||
"before running router tests."
|
||
)
|
||
|
||
if response.status_code != 200:
|
||
pytest.fail(f"Failed to authenticate as admin (status={response.status_code}): {response.text}")
|
||
|
||
token = response.json().get("access_token")
|
||
if not token:
|
||
pytest.fail("Admin authentication did not return an access token.")
|
||
|
||
_ADMIN_TOKEN_CACHE = token
|
||
return token
|
||
|
||
|
||
@pytest.fixture(scope="function")
|
||
def admin_headers(admin_token: str) -> dict[str, str]:
|
||
"""Authorization headers for the super admin user."""
|
||
return {"Authorization": f"Bearer {admin_token}"}
|
||
|
||
|
||
@pytest.fixture(scope="session", autouse=True)
|
||
def cleanup_test_knowledge_databases():
|
||
"""
|
||
Best-effort cleanup for leftover test knowledge databases (e.g. pytest_* / py_test*).
|
||
"""
|
||
|
||
async def run_cleanup() -> None:
|
||
global _ADMIN_TOKEN_CACHE
|
||
|
||
if not ADMIN_LOGIN or not ADMIN_PASSWORD:
|
||
return
|
||
|
||
if not _ADMIN_TOKEN_CACHE:
|
||
async with httpx.AsyncClient(
|
||
base_url=API_BASE_URL, timeout=HTTP_TIMEOUT, follow_redirects=True
|
||
) as bootstrap_client:
|
||
response = await bootstrap_client.post(
|
||
"/api/auth/token", data={"username": ADMIN_LOGIN, "password": ADMIN_PASSWORD}
|
||
)
|
||
if response.status_code != 200:
|
||
return
|
||
token = response.json().get("access_token")
|
||
if not token:
|
||
return
|
||
_ADMIN_TOKEN_CACHE = token
|
||
|
||
headers = {"Authorization": f"Bearer {_ADMIN_TOKEN_CACHE}"}
|
||
|
||
async with httpx.AsyncClient(base_url=API_BASE_URL, timeout=HTTP_TIMEOUT, follow_redirects=True) as client:
|
||
try:
|
||
list_response = await client.get("/api/knowledge/databases", headers=headers)
|
||
except Exception as e:
|
||
print(f"Warning: Failed to list knowledge databases for cleanup: {e}")
|
||
return
|
||
|
||
if list_response.status_code != 200:
|
||
return
|
||
|
||
databases = list_response.json().get("databases", [])
|
||
prefixes = ("pytest_", "py_test")
|
||
for entry in databases:
|
||
name = entry.get("name") or ""
|
||
db_id = entry.get("db_id")
|
||
if not db_id or not isinstance(name, str) or not name.startswith(prefixes):
|
||
continue
|
||
try:
|
||
delete_response = await client.delete(f"/api/knowledge/databases/{db_id}", headers=headers)
|
||
if delete_response.status_code not in (200, 404):
|
||
print(f"Warning: Failed to cleanup knowledge database {db_id}: {delete_response.text}")
|
||
except Exception as e:
|
||
print(f"Warning: Exception during cleanup of {db_id}: {e}")
|
||
|
||
try:
|
||
anyio.run(run_cleanup)
|
||
except Exception as e:
|
||
print(f"Warning: Exception during session cleanup startup: {e}")
|
||
yield
|
||
try:
|
||
anyio.run(run_cleanup)
|
||
except Exception as e:
|
||
print(f"Warning: Exception during session cleanup teardown: {e}")
|
||
|
||
|
||
@pytest_asyncio.fixture(scope="function")
|
||
async def standard_user(test_client: httpx.AsyncClient, admin_headers: dict[str, str]) -> dict:
|
||
"""
|
||
Provision a temporary standard user for permission checks.
|
||
|
||
Yields a dictionary with `user`, `password`, and `headers` keys.
|
||
"""
|
||
username = f"pytest_user_{uuid.uuid4().hex[:8]}"
|
||
password = f"Pw!{uuid.uuid4().hex[:8]}"
|
||
|
||
response = await test_client.post(
|
||
"/api/auth/users",
|
||
json={"username": username, "password": password, "role": "user"},
|
||
headers=admin_headers,
|
||
)
|
||
if response.status_code != 200:
|
||
pytest.fail(f"Failed to create standard user (status={response.status_code}): {response.text}")
|
||
|
||
user_payload = response.json()
|
||
login_response = await test_client.post(
|
||
"/api/auth/token",
|
||
data={"username": user_payload["user_id"], "password": password},
|
||
)
|
||
if login_response.status_code != 200:
|
||
pytest.fail(
|
||
f"Failed to authenticate as standard user (status={login_response.status_code}): {login_response.text}"
|
||
)
|
||
|
||
access_token = login_response.json().get("access_token")
|
||
if not access_token:
|
||
pytest.fail("Standard user login succeeded but no access token was returned.")
|
||
|
||
try:
|
||
yield {
|
||
"user": user_payload,
|
||
"password": password,
|
||
"headers": {"Authorization": f"Bearer {access_token}"},
|
||
}
|
||
finally:
|
||
response = await test_client.delete(f"/api/auth/users/{user_payload['id']}", headers=admin_headers)
|
||
assert response.status_code == 200, f"Failed to cleanup test user {user_payload['user_id']}: {response.text}"
|
||
|
||
|
||
@pytest_asyncio.fixture(scope="function")
|
||
async def knowledge_database(test_client: httpx.AsyncClient, admin_headers: dict[str, str]) -> dict:
|
||
"""
|
||
Create a temporary knowledge database for tests that need LightRAG metadata.
|
||
"""
|
||
import time
|
||
|
||
# 使用UUID作为数据库名称的一部分,确保唯一性
|
||
unique_id = uuid.uuid4().hex
|
||
timestamp = int(time.time() * 1000000) # 微秒级时间戳
|
||
db_name = f"pytest_kb_{timestamp}_{unique_id}"
|
||
db_id = None
|
||
|
||
try:
|
||
create_response = await test_client.post(
|
||
"/api/knowledge/databases",
|
||
json={
|
||
"database_name": db_name,
|
||
"description": "Pytest managed knowledge base",
|
||
"embed_model_name": "siliconflow/BAAI/bge-m3",
|
||
"kb_type": "lightrag",
|
||
"additional_params": {},
|
||
},
|
||
headers=admin_headers,
|
||
)
|
||
|
||
if create_response.status_code == 200:
|
||
db_payload = create_response.json()
|
||
db_id = db_payload["db_id"]
|
||
elif create_response.status_code == 409:
|
||
error_detail = create_response.json().get("detail", "")
|
||
pytest.fail(f"Knowledge database name conflict: {error_detail}. Please clean up old test databases first.")
|
||
else:
|
||
pytest.fail(
|
||
f"Failed to create knowledge database (status={create_response.status_code}): {create_response.text}"
|
||
)
|
||
|
||
yield db_payload if db_id else {"db_id": db_id, "name": db_name}
|
||
|
||
finally:
|
||
# 确保清理,即使测试失败
|
||
if db_id:
|
||
try:
|
||
delete_response = await test_client.delete(f"/api/knowledge/databases/{db_id}", headers=admin_headers)
|
||
if delete_response.status_code != 200:
|
||
print(f"Warning: Failed to cleanup knowledge database {db_id}: {delete_response.text}")
|
||
except Exception as e:
|
||
print(f"Warning: Exception during cleanup of {db_id}: {e}")
|
||
|
||
|
||
def pytest_configure(config: pytest.Config) -> None:
|
||
"""Register commonly used custom markers."""
|
||
config.addinivalue_line("markers", "auth: marks tests that require authentication")
|
||
config.addinivalue_line("markers", "integration: marks tests that hit the live API service")
|
||
|
||
|
||
pytest_plugins = ["pytest_asyncio"]
|