2026-08-07 09:06:19 +08:00
|
|
|
|
"""conftest — 模块化 API 接口测试共享 fixture(真实 PostgreSQL + Redis)。
|
|
|
|
|
|
|
|
|
|
|
|
A2 架构治理:冒烟层不再用 SQLite + mock Redis,改为连真实 PostgreSQL16(专用
|
|
|
|
|
|
测试库 dpb_test,number_gen 的 pg_advisory_xact_lock 路径真被执行,不再静默降级)
|
|
|
|
|
|
+ 真实 Redis,与统计引擎 tc 套件同一套基础设施(可被 TC_CI_* 环境变量覆盖,供 CI
|
|
|
|
|
|
容器指定宿主)。
|
2026-08-06 00:17:49 +08:00
|
|
|
|
|
|
|
|
|
|
提供:
|
|
|
|
|
|
- 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
|
2026-08-07 09:06:19 +08:00
|
|
|
|
排除,避免 pytest 收集时 import 它们。
|
2026-08-06 00:17:49 +08:00
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
import os
|
|
|
|
|
|
|
|
|
|
|
|
collect_ignore_glob = ["../scripts/test_*.py"]
|
2026-08-07 09:06:19 +08:00
|
|
|
|
import asyncio
|
2026-08-06 00:17:49 +08:00
|
|
|
|
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
|
|
|
|
|
|
|
2026-08-07 09:06:19 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 测试环境变量 —— 必须在 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()
|
|
|
|
|
|
|
2026-08-06 00:17:49 +08:00
|
|
|
|
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 # 测试环境关闭验证码
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================
|
2026-08-07 09:06:19 +08:00
|
|
|
|
# 跨事件循环安全 —— TestClient 的 lifespan 跑在 portal 线程 loop,而 pytest-asyncio
|
|
|
|
|
|
# 的 async 测试跑在函数级新 loop;asyncpg 连接 loop 绑定,复用池化连接会
|
|
|
|
|
|
# RuntimeError。改用 NullPool:每个 session 在当前 loop 新开连接、同 loop 关闭,
|
|
|
|
|
|
# 永不跨 loop 复用(冒烟层 session 级仅一处 async 直连用例,代价可忽略)。
|
2026-08-06 00:17:49 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
|
2026-08-07 09:06:19 +08:00
|
|
|
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
|
|
|
|
|
from sqlalchemy.pool import NullPool
|
2026-08-06 00:17:49 +08:00
|
|
|
|
|
2026-08-07 09:06:19 +08:00
|
|
|
|
import app.core.database as _database
|
2026-08-06 00:17:49 +08:00
|
|
|
|
|
2026-08-07 09:06:19 +08:00
|
|
|
|
_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
|
|
|
|
|
|
)
|
2026-08-06 00:17:49 +08:00
|
|
|
|
|
2026-08-07 09:06:19 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
# 调度器 mock —— 冒烟层不启动后台调度线程(真实调度由 tc 套件经真实 lifespan 覆盖)
|
|
|
|
|
|
# ============================================================
|
2026-08-06 00:17:49 +08:00
|
|
|
|
|
|
|
|
|
|
patch("app.core.ap_scheduler.SchedulerUtil.init_scheduler", new=AsyncMock()).start()
|
|
|
|
|
|
patch("app.core.ap_scheduler.SchedulerUtil.shutdown", new=AsyncMock()).start()
|
|
|
|
|
|
|
|
|
|
|
|
# ============================================================
|
2026-08-07 09:06:19 +08:00
|
|
|
|
# 精简 lifespan — 真实 PG + Redis,仅跳过调度器
|
2026-08-06 00:17:49 +08:00
|
|
|
|
# ============================================================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@asynccontextmanager
|
|
|
|
|
|
async def _test_lifespan(app) -> AsyncGenerator[Any, None]:
|
2026-08-07 09:06:19 +08:00
|
|
|
|
from app.api.v1.module_system.dict.service import DictDataService
|
|
|
|
|
|
from app.api.v1.module_system.params.service import ParamsService
|
2026-08-06 00:17:49 +08:00
|
|
|
|
from app.scripts.initialize import InitializeData
|
2026-08-07 09:06:19 +08:00
|
|
|
|
from app.core.database import async_db_session, redis_connect
|
2026-08-06 00:17:49 +08:00
|
|
|
|
|
|
|
|
|
|
await InitializeData().init_db()
|
2026-08-07 09:06:19 +08:00
|
|
|
|
await redis_connect(app, status=True)
|
|
|
|
|
|
await ParamsService.init_cache(redis=app.state.redis)
|
|
|
|
|
|
await DictDataService.init_cache(redis=app.state.redis)
|
2026-08-06 00:17:49 +08:00
|
|
|
|
|
|
|
|
|
|
# 将 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
|
|
|
|
|
|
|
2026-08-07 09:06:19 +08:00
|
|
|
|
await redis_connect(app, status=False)
|
|
|
|
|
|
|
2026-08-06 00:17:49 +08:00
|
|
|
|
|
|
|
|
|
|
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: 认证 headers(dict),传入则合并到请求头。
|
|
|
|
|
|
**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,路由未注册"
|