""" 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: 认证 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,路由未注册" )