You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

287 lines
8.7 KiB
Python

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"""
conftest — 模块化 API 接口测试共享 fixture。
提供:
- test_client: FastAPI TestClient 实例 (session 级复用)
- assert_route: 验证接口路由存在 (status_code != 404)
"""
import os
import sys
import tempfile
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager
from pathlib import Path
from typing import Any
from unittest.mock import AsyncMock, patch
sys.path.insert(0, str(Path(__file__).parent.parent))
import pytest
from fastapi.testclient import TestClient
# ============================================================
# 测试环境变量
# ============================================================
_TEST_DB_PATH = tempfile.NamedTemporaryFile(suffix=".db", delete=False).name
os.environ["DATABASE_TYPE"] = "sqlite"
os.environ["DATABASE_NAME"] = _TEST_DB_PATH
os.environ["REDIS_ENABLE"] = "true"
os.environ["POOL_SIZE"] = "1"
os.environ["MAX_OVERFLOW"] = "1"
from app.config.setting import settings
settings.DATABASE_TYPE = "sqlite"
settings.DATABASE_NAME = _TEST_DB_PATH
settings.REDIS_ENABLE = True
settings.POOL_SIZE = 1
settings.MAX_OVERFLOW = 1
settings.CAPTCHA_ENABLE = False # 测试环境关闭验证码
# ============================================================
# Mock Redis — dict 存储,支持 get/set/delete/exists/keys/ttl/expire
# 登录成功后写入的 session 数据可在后续请求中正确读取
# ============================================================
_mock_redis_store: dict[bytes, bytes] = {}
def _redis_get(name: bytes) -> bytes | None:
return _mock_redis_store.get(name)
async def _redis_set(name: bytes, value: bytes, ex: int | None = None, nx: bool = False) -> bool | None:
if nx and name in _mock_redis_store:
return None
_mock_redis_store[name] = value
return True
async def _redis_delete(*names: bytes) -> int:
count = 0
for n in names:
if _mock_redis_store.pop(n, None) is not None:
count += 1
return count
def _redis_keys(pattern: bytes | None = None) -> list[bytes]:
if pattern == b"*" or pattern is None:
return list(_mock_redis_store.keys())
return [k for k in _mock_redis_store if pattern == b"*" or k.startswith(pattern.replace(b"*", b""))]
def _redis_exists(*names: bytes) -> int:
return sum(1 for n in names if n in _mock_redis_store)
def _redis_ttl(name: bytes) -> int:
return 3600 if name in _mock_redis_store else -2
async def _redis_expire(name: bytes, time: int) -> bool:
return name in _mock_redis_store
async def _redis_flushall( asynchronous: bool = False) -> bool:
_mock_redis_store.clear()
return True
async def _redis_flushdb( asynchronous: bool = False) -> bool:
_mock_redis_store.clear()
return True
async def _redis_close() -> None:
pass
async def _redis_aclose() -> None:
pass
async def _redis_hmget(name: bytes, keys: list[bytes]) -> list[bytes | None]:
return [_mock_redis_store.get(name + b":" + k) for k in keys]
async def _redis_hset(name: bytes, key: bytes, value: bytes) -> int:
_mock_redis_store[name + b":" + key] = value
return 1
async def _redis_hgetall(name: bytes) -> dict[bytes, bytes]:
prefix = name + b":"
return {k[len(prefix):]: v for k, v in _mock_redis_store.items() if k.startswith(prefix)}
async def _redis_hdel(name: bytes, *keys: bytes) -> int:
count = 0
for k in keys:
if _mock_redis_store.pop(name + b":" + k, None) is not None:
count += 1
return count
def _redis_info(section: str | None = None) -> dict:
return {}
def _redis_dbsize() -> int:
return len(_mock_redis_store)
_mock_redis = AsyncMock()
_mock_redis.ping = AsyncMock(return_value=True)
_mock_redis.get = AsyncMock(side_effect=_redis_get)
_mock_redis.set = AsyncMock(side_effect=_redis_set)
_mock_redis.delete = AsyncMock(side_effect=_redis_delete)
_mock_redis.keys = AsyncMock(side_effect=_redis_keys)
_mock_redis.exists = AsyncMock(side_effect=_redis_exists)
_mock_redis.ttl = AsyncMock(side_effect=_redis_ttl)
_mock_redis.expire = AsyncMock(side_effect=_redis_expire)
_mock_redis.flushall = AsyncMock(side_effect=_redis_flushall)
_mock_redis.flushdb = AsyncMock(side_effect=_redis_flushdb)
_mock_redis.close = AsyncMock(side_effect=_redis_close)
_mock_redis.aclose = AsyncMock(side_effect=_redis_aclose)
_mock_redis.hmget = AsyncMock(side_effect=_redis_hmget)
_mock_redis.hset = AsyncMock(side_effect=_redis_hset)
_mock_redis.hgetall = AsyncMock(side_effect=_redis_hgetall)
_mock_redis.hdel = AsyncMock(side_effect=_redis_hdel)
_mock_redis.info = AsyncMock(side_effect=_redis_info)
_mock_redis.dbsize = AsyncMock(side_effect=_redis_dbsize)
patch("redis.asyncio.Redis.from_url", return_value=_mock_redis).start()
patch("app.init_app.FastAPILimiter.init", new=AsyncMock()).start()
patch("app.init_app.FastAPILimiter.close", new=AsyncMock()).start()
patch("app.core.ap_scheduler.SchedulerUtil.init_scheduler", new=AsyncMock()).start()
patch("app.core.ap_scheduler.SchedulerUtil.shutdown", new=AsyncMock()).start()
# RateLimiter → no-op需匹配 FastAPI 依赖注入签名)
from fastapi_limiter.depends import RateLimiter, WebSocketRateLimiter
from starlette.requests import Request
from starlette.responses import Response
async def _noop_rate_limit(request: Request, response: Response) -> None:
pass
RateLimiter.__call__ = _noop_rate_limit
WebSocketRateLimiter.__call__ = _noop_rate_limit
# ============================================================
# 精简 lifespan — 仅做数据库初始化
# ============================================================
@asynccontextmanager
async def _test_lifespan(app) -> AsyncGenerator[Any, None]:
from app.scripts.initialize import InitializeData
await InitializeData().init_db()
app.state.redis = _mock_redis
# 将 admin 密码重置为已知密码 "admin123"
from sqlalchemy import update
from app.api.v1.module_system.user.model import UserModel
from app.core.database import async_db_session
from app.utils.hash_bcrpy_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
from main import create_app
_app = create_app()
_app.router.lifespan_context = _test_lifespan
# ============================================================
# Fixtures
# ============================================================
@pytest.fixture(scope="session")
def _api_client() -> TestClient:
"""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路由未注册"
)