Files
wehub-resource-sync 1443d3fdf9
Ruff Format Check / Ruff Format & Lint (push) Failing after 7m39s
Deploy VitePress site to Pages / build (push) Failing after 9m11s
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:26 +08:00

320 lines
11 KiB
Python

"""
Shared pytest fixtures for integration tests that exercise the live API service.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import uuid
from collections.abc import AsyncGenerator
from pathlib import Path
import anyio
import httpx
import pytest
import pytest_asyncio
from dotenv import load_dotenv
PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
load_dotenv(PROJECT_ROOT / ".env", override=False)
load_dotenv(PROJECT_ROOT / "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")
_ADMIN_TOKEN_CACHE: str | None = None
HTTP_TIMEOUT = httpx.Timeout(60.0, connect=5.0)
SANDBOX_CONTAINER_PREFIX = os.getenv("YUXI_SANDBOX_CONTAINER_PREFIX", "yuxi-sandbox")
@pytest.fixture(scope="session", autouse=True)
def ensure_live_api_schema():
if not ADMIN_LOGIN or not ADMIN_PASSWORD:
return
async def run_schema_setup() -> None:
from yuxi.storage.postgres.manager import pg_manager
pg_manager.initialize()
await pg_manager.create_tables()
await pg_manager.ensure_business_schema()
await pg_manager.ensure_knowledge_schema()
anyio.run(run_schema_setup)
def _require_admin_credentials() -> tuple[str, str]:
if not ADMIN_LOGIN or not ADMIN_PASSWORD:
pytest.skip("Integration credentials are not configured via TEST_USERNAME / TEST_PASSWORD.")
return ADMIN_LOGIN, ADMIN_PASSWORD
@pytest_asyncio.fixture(scope="function")
async def test_client() -> AsyncGenerator[httpx.AsyncClient, None]:
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:
global _ADMIN_TOKEN_CACHE
if _ADMIN_TOKEN_CACHE:
return _ADMIN_TOKEN_CACHE
username, password = _require_admin_credentials()
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": username, "password": 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. Complete `/api/auth/initialize` before "
"running integration 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]:
return {"Authorization": f"Bearer {admin_token}"}
@pytest.fixture(scope="session", autouse=True)
def cleanup_test_knowledge_databases():
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 exc:
print(f"Warning: Failed to list knowledge databases for cleanup: {exc}")
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 ""
slug = entry.get("slug")
if not slug or not isinstance(name, str) or not name.startswith(prefixes):
continue
try:
delete_response = await client.delete(f"/api/knowledge/databases/{slug}", headers=headers)
if delete_response.status_code not in (200, 404):
print(f"Warning: Failed to cleanup knowledge database {slug}: {delete_response.text}")
except Exception as exc:
print(f"Warning: Exception during cleanup of {slug}: {exc}")
try:
anyio.run(run_cleanup)
except Exception as exc:
print(f"Warning: Exception during session cleanup startup: {exc}")
yield
try:
anyio.run(run_cleanup)
except Exception as exc:
print(f"Warning: Exception during session cleanup teardown: {exc}")
def _docker_api_request(method: str, path: str) -> list[dict] | dict:
cmd = [
"curl",
"-sS",
"--unix-socket",
os.getenv("YUXI_DOCKER_API_SOCKET", "/var/run/docker.sock"),
"-X",
method,
f"{os.getenv('YUXI_DOCKER_API_BASE', 'http://localhost').rstrip('/')}{path}",
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=15, check=False)
if result.returncode != 0:
raise RuntimeError(result.stderr.strip() or "Docker API request failed")
text = (result.stdout or "").strip()
if not text:
return {}
return json.loads(text)
def _cleanup_sandbox_containers() -> None:
try:
containers = _docker_api_request("GET", "/containers/json?all=true")
except Exception as exc:
print(f"Warning: Failed to list sandbox containers for cleanup: {exc}")
return
for container in containers if isinstance(containers, list) else []:
names = container.get("Names") or []
if not any(name.lstrip("/").startswith(f"{SANDBOX_CONTAINER_PREFIX}-") for name in names):
continue
container_id = container.get("Id")
if not container_id:
continue
try:
_docker_api_request("POST", f"/containers/{container_id}/stop?t=2")
except Exception:
pass
try:
_docker_api_request("DELETE", f"/containers/{container_id}?force=true")
except Exception as exc:
print(f"Warning: Failed to cleanup sandbox container {container_id[:12]}: {exc}")
@pytest.fixture(scope="session", autouse=True)
def cleanup_test_sandboxes():
_cleanup_sandbox_containers()
yield
_cleanup_sandbox_containers()
@pytest_asyncio.fixture(scope="function")
async def standard_user(test_client: httpx.AsyncClient, admin_headers: dict[str, str]) -> AsyncGenerator[dict, None]:
username = f"pytest_user_{uuid.uuid4().hex[:8]}"
password = f"Pw!{uuid.uuid4().hex[:8]}"
# 用户隔离重构后所有登录用户必须绑定部门,创建时显式指定一个已存在部门
dept_response = await test_client.get("/api/departments", headers=admin_headers)
if dept_response.status_code != 200 or not dept_response.json():
pytest.fail(f"No department available to bind standard user: {dept_response.text}")
department_id = dept_response.json()[0]["id"]
response = await test_client.post(
"/api/auth/users",
json={"username": username, "password": password, "role": "user", "department_id": department_id},
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["uid"], "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:
cleanup_error = None
for _ in range(3):
response = await test_client.delete(f"/api/auth/users/{user_payload['id']}", headers=admin_headers)
if response.status_code in (200, 404):
cleanup_error = None
break
cleanup_error = response
await anyio.sleep(0.3)
if cleanup_error is not None:
assert cleanup_error.status_code == 200, (
f"Failed to cleanup test user {user_payload['uid']}: {cleanup_error.text}"
)
@pytest_asyncio.fixture(scope="function")
async def knowledge_database(
test_client: httpx.AsyncClient,
admin_headers: dict[str, str],
) -> AsyncGenerator[dict, None]:
import time
unique_id = uuid.uuid4().hex
timestamp = int(time.time() * 1000000)
db_name = f"pytest_kb_{timestamp}_{unique_id}"
kb_id = None
try:
create_response = await test_client.post(
"/api/knowledge/databases",
json={
"database_name": db_name,
"description": "Pytest managed knowledge base",
"embedding_model_spec": "siliconflow-cn:Pro/BAAI/bge-m3",
"kb_type": "milvus",
"additional_params": {},
},
headers=admin_headers,
)
if create_response.status_code == 200:
db_payload = create_response.json()
kb_id = db_payload["kb_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
finally:
if kb_id:
try:
delete_response = await test_client.delete(f"/api/knowledge/databases/{kb_id}", headers=admin_headers)
if delete_response.status_code != 200:
print(f"Warning: Failed to cleanup knowledge database {kb_id}: {delete_response.text}")
except Exception as exc:
print(f"Warning: Exception during cleanup of {kb_id}: {exc}")