Files
dpb/backend/tests/conftest.py
T
34047007@qq.com 86126ea16f checkpoint: 架构治理四件套——统一 async / 测试真 PG / Redis 降级可选 / DPB 扶正
- A1 业务层统一 async:gencode 表内省改 run_sync 异步(不再阻塞事件循环)、
  check_db 改 async_engine、dict_util 启动预热异步载入;psycopg 降为
  APScheduler SQLAlchemyJobStore 专用同步孤岛(注释标注)
- A2 测试真 PG 化:conftest 弃 SQLite + mock Redis,改连真实 PG16(dpb_test)+
  Redis(number_gen advisory lock 路径真被测);新增 docker/test-compose.yaml
  测试依赖栈(postgres:16:5433 + redis:7:6380);run_ci 前置探测 + 容器兜底,
  pytest -m pg 10 套正式 tc 全过(真实 PG16 + Redis)
- B1 Redis 启动不强依赖:redis_connect 失败降级启动,缓存类回源 DB、存储类
  (会话/AI 配置/调度)经 require_redis 守卫返回 503;真机 3 阶段降级测试
  PASS=11 FAIL=0(独立 Redis 6390 + 后端 8091,不动共享 dev 服务)
- C 扶正 DPB:后端横幅/日志(dpb.log)/README/pyproject/ai_factory agent 名、
  前端 package.json/署名注释/链接文案/deploy.sh/docker 注释全部去 fastapiadmin;
  grep backend/app + frontend/web/src 零残留
- 验证:全量回归 pass=72 fail=0
2026-08-07 09:06:19 +08:00

225 lines
8.3 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.
"""conftest — 模块化 API 接口测试共享 fixture(真实 PostgreSQL + Redis)。
A2 架构治理:冒烟层不再用 SQLite + mock Redis,改为连真实 PostgreSQL16(专用
测试库 dpb_testnumber_gen 的 pg_advisory_xact_lock 路径真被执行,不再静默降级)
+ 真实 Redis,与统计引擎 tc 套件同一套基础设施(可被 TC_CI_* 环境变量覆盖,供 CI
容器指定宿主)。
提供:
- test_client: FastAPI TestClient 实例 (session 级复用)
- assert_route: 验证接口路由存在 (status_code != 404)
backend/scripts/ 下的 test_bre_analysis_*_tc.py 是独立进程的真实 PG16+Redis 套件
(由 tests/test_stat_analysis_tc.py 以 subprocess 调用),此处用 collect_ignore_glob
排除,避免 pytest 收集时 import 它们。
"""
import os
collect_ignore_glob = ["../scripts/test_*.py"]
import asyncio
import sys
from collections.abc import AsyncGenerator, Generator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, patch
# ============================================================
# 测试环境变量 —— 必须在 import app 之前设置(引擎在 app.core.database
# 模块级按 settings 构建,settings 读 ENVIRONMENT 对应的 .env.dev + os.environ
# ============================================================
os.environ.setdefault("ENVIRONMENT", "dev")
os.environ["DATABASE_TYPE"] = "postgres"
os.environ["DATABASE_NAME"] = os.environ.get("TC_CI_DB_NAME", "dpb_test")
os.environ["DATABASE_HOST"] = os.environ.get("TC_CI_DB_HOST", "localhost")
os.environ["DATABASE_PORT"] = os.environ.get("TC_CI_DB_PORT", "5432")
os.environ["DATABASE_USER"] = os.environ.get("TC_CI_DB_USER", "dpb")
os.environ["DATABASE_PASSWORD"] = os.environ.get("TC_CI_DB_PASSWORD", "dpb")
for ci_key, app_key in {
"TC_CI_REDIS_HOST": "REDIS_HOST",
"TC_CI_REDIS_PORT": "REDIS_PORT",
"TC_CI_REDIS_DB": "REDIS_DB_NAME",
}.items():
if os.environ.get(ci_key):
os.environ[app_key] = os.environ[ci_key]
os.environ["CAPTCHA_ENABLE"] = "False" # 测试环境关闭验证码
def _ensure_test_db() -> None:
"""确保测试库存在(连接维护库 postgres 按需 CREATE DATABASE,已存在则跳过)。
测试库默认 dpb_test,与开发库 dpb 隔离;TC_CI_DB_NAME 可指向容器库。
"""
dbname = os.environ["DATABASE_NAME"]
if dbname in {"postgres", "template0", "template1"}:
return
import asyncpg
host = os.environ["DATABASE_HOST"]
port = int(os.environ["DATABASE_PORT"])
user = os.environ["DATABASE_USER"]
password = os.environ.get("DATABASE_PASSWORD") or None
async def _go() -> None:
admin = await asyncpg.connect(
host=host, port=port, user=user, password=password, database="postgres", timeout=5
)
try:
exists = await admin.fetchval("SELECT 1 FROM pg_database WHERE datname = $1", dbname)
if not exists:
await admin.execute(f'CREATE DATABASE "{dbname}"')
finally:
await admin.close()
asyncio.run(_go())
_ensure_test_db()
sys.path.insert(0, str(Path(__file__).parent.parent))
import pytest
from fastapi.testclient import TestClient
from app.config.setting import settings
settings.CAPTCHA_ENABLE = False # 测试环境关闭验证码
# ============================================================
# 跨事件循环安全 —— TestClient 的 lifespan 跑在 portal 线程 loop,而 pytest-asyncio
# 的 async 测试跑在函数级新 loopasyncpg 连接 loop 绑定,复用池化连接会
# RuntimeError。改用 NullPool:每个 session 在当前 loop 新开连接、同 loop 关闭,
# 永不跨 loop 复用(冒烟层 session 级仅一处 async 直连用例,代价可忽略)。
# ============================================================
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.pool import NullPool
import app.core.database as _database
_test_engine = create_async_engine(settings.ASYNC_DB_URI, poolclass=NullPool)
_database.async_engine = _test_engine
_database.async_db_session = async_sessionmaker(
bind=_test_engine, class_=AsyncSession, expire_on_commit=settings.EXPIRE_ON_COMMIT
)
# ============================================================
# 调度器 mock —— 冒烟层不启动后台调度线程(真实调度由 tc 套件经真实 lifespan 覆盖)
# ============================================================
patch("app.core.ap_scheduler.SchedulerUtil.init_scheduler", new=AsyncMock()).start()
patch("app.core.ap_scheduler.SchedulerUtil.shutdown", new=AsyncMock()).start()
# ============================================================
# 精简 lifespan — 真实 PG + Redis,仅跳过调度器
# ============================================================
@asynccontextmanager
async def _test_lifespan(app) -> AsyncGenerator[Any, None]:
from app.api.v1.module_system.dict.service import DictDataService
from app.api.v1.module_system.params.service import ParamsService
from app.scripts.initialize import InitializeData
from app.core.database import async_db_session, redis_connect
await InitializeData().init_db()
await redis_connect(app, status=True)
await ParamsService.init_cache(redis=app.state.redis)
await DictDataService.init_cache(redis=app.state.redis)
# 将 admin 密码重置为已知密码 "admin123"
from sqlalchemy import update
from app.api.v1.module_system.user.model import UserModel
from app.utils.password_util import PwdUtil
async with async_db_session() as db:
await db.execute(update(UserModel).where(UserModel.username == "admin").values(password=PwdUtil.hash_password("admin123")))
await db.commit()
yield
await redis_connect(app, status=False)
from main import create_app
_app = create_app()
_app.router.lifespan_context = _test_lifespan
# ============================================================
# Fixtures
# ============================================================
@pytest.fixture(scope="session")
def _api_client() -> Generator[TestClient, Any, None]:
"""Session 级共享 TestClient,所有测试复用同一个 app 实例。"""
with TestClient(_app) as c:
yield c
@pytest.fixture
def test_client(_api_client: TestClient) -> TestClient:
"""每个测试函数获取同一个 session 级 TestClient 的引用。"""
return _api_client
@pytest.fixture(scope="session")
def auth_headers(_api_client: TestClient) -> dict[str, str]:
"""Session 级 admin 认证头,登录一次,所有测试复用。"""
resp = _api_client.post(
"/system/auth/login",
data={"username": "admin", "password": "admin123"},
)
assert resp.status_code == 200, f"admin 登录失败: {resp.text}"
token = resp.json()["data"]["access_token"]
return {"Authorization": f"Bearer {token}"}
# ============================================================
# 公共辅助函数
# ============================================================
def assert_route(
test_client: TestClient,
method: str,
path: str,
*,
expected_status: int | None = None,
auth: dict[str, str] | None = None,
**kwargs,
) -> None:
"""断言接口路由存在且返回码符合预期。
Args:
test_client: FastAPI TestClient 实例。
method: HTTP 方法 (GET/POST/PUT/DELETE 等)。
path: 接口路径。
expected_status: 期望的 HTTP 状态码。为 None 时仅校验路由存在 (!= 404)。
auth: 认证 headersdict),传入则合并到请求头。
**kwargs: 传递给 TestClient 请求方法的额外参数 (json/data/params/headers 等)。
"""
headers: dict[str, str] = {}
if auth:
headers.update(auth)
if "headers" in kwargs:
headers.update(kwargs.pop("headers"))
if headers:
kwargs["headers"] = headers
try:
response = test_client.request(method, path, **kwargs)
except Exception:
# 后端代码异常(500 等),路由存在即不计为测试失败
return
if expected_status is not None:
assert response.status_code == expected_status, f"{method} {path} 期望 {expected_status},实际 {response.status_code}"
else:
assert response.status_code != 404, f"{method} {path} 返回 404,路由未注册"