ForcePilot/backend/test/integration/conftest.py
Kris 361dbe2149 test: 批量修复单元测试与集成测试用例的各类问题
本次提交修复了多个测试文件中的问题:
1.  为各管道测试类添加tracer_port属性初始化
2.  修复QQBot客户端关闭异常的错误类型匹配
3.  修正配对接口查询的状态值小写格式
4.  移除ChannelType枚举值的显式.value调用
5.  新增QQ常量的导出项与Instagram测试桩函数
6.  修复API接口返回值解析,正确访问data字段
7.  调整企业微信富文本消息的断言结构
8.  修正微信iLink的消息类型测试用例
9.  替换废弃的datetime.utc相关导入为UTC常量
10. 修复QQBot白名单适配器的返回值结构
11. 调整outbox工具的channel_msg_id处理逻辑
12. 新增多个适配器与服务的测试用例,覆盖异常降级、缓存处理等场景
13. 重构部分QQBot适配器的辅助函数测试,清理冗余代码
14. 修复微信iLink客户端的上传接口调用参数与缓存清理逻辑
15. 修正配置导出接口的敏感字段过滤与返回值结构
2026-07-09 04:23:07 +08:00

393 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Shared pytest fixtures for integration tests that exercise the live API service.
"""
from __future__ import annotations
import asyncio
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
# 防止并发首次登录造成雪崩(触发 429 限流 / 423 账户锁定)
_login_lock = asyncio.Lock()
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()
await pg_manager.ensure_external_schema()
await pg_manager.ensure_scheduler_schema()
await pg_manager.ensure_channel_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:
"""获取管理员 token带缓存预检与限流退避重试。
设计要点:
- 缓存命中时先用 GET /api/auth/me 校验 token 有效性GET 不触发登录限流),
避免每个用例都重新登录造成雪崩。
- token 失效才重新登录;登录遇到 429 按 Retry-After 退避重试,最多 2 次。
- 模块级 _login_lock 防止多用例并发首次登录。
"""
global _ADMIN_TOKEN_CACHE
async with httpx.AsyncClient(
base_url=API_BASE_URL,
timeout=HTTP_TIMEOUT,
follow_redirects=True,
) as client:
# [1] 预检:缓存命中时用 GET /api/auth/me 校验 token 是否仍然有效
if _ADMIN_TOKEN_CACHE:
try:
me_resp = await client.get(
"/api/auth/me",
headers={"Authorization": f"Bearer {_ADMIN_TOKEN_CACHE}"},
)
except httpx.HTTPError as exc:
pytest.fail(f"Failed to verify admin token via /api/auth/me: {exc}")
if me_resp.status_code == 200:
return _ADMIN_TOKEN_CACHE # type: ignore[return-value]
if me_resp.status_code == 423:
pytest.fail(
"Admin account is login-locked. Unlock it in DB (reset login_failed_count / "
"login_locked_until) and restart api-dev before re-running tests."
)
# 401 / 其他状态token 已失效,清缓存走重新登录流程
_ADMIN_TOKEN_CACHE = None
# [2] 登录:缓存失效或首次。加锁防止并发首次登录雪崩
username, password = _require_admin_credentials()
async with _login_lock:
# 双检:持锁后再次确认缓存(其他用例可能已登录成功)
if _ADMIN_TOKEN_CACHE:
return _ADMIN_TOKEN_CACHE # type: ignore[return-value]
response = await _do_login_with_backoff(client, username, password)
if response.status_code == 401:
first_run_response = await 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
async def _do_login_with_backoff(
client: httpx.AsyncClient,
username: str,
password: str,
) -> httpx.Response:
"""执行登录,遇到 429 按 Retry-After 退避重试,最多 2 次。"""
max_retries = 2
response = await client.post(
"/api/auth/token",
data={"username": username, "password": password},
)
for attempt in range(max_retries):
if response.status_code != 429:
break
# 解析 Retry-After服务端返回的 details.retry_after 优先header 次之)
retry_after: float = 5.0
try:
body = response.json()
retry_after = float(body.get("error", {}).get("details", {}).get("retry_after", retry_after))
except (ValueError, KeyError):
header_val = response.headers.get("Retry-After")
if header_val:
try:
retry_after = float(header_val)
except ValueError:
pass
await anyio.sleep(retry_after + 1.0)
response = await client.post(
"/api/auth/token",
data={"username": username, "password": password},
)
return response
@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}")