"""测试夹具。 约定: - 一律走真实 HTTP 栈(ASGITransport),不做 handler 级 mock —— 中间件/依赖/RLS 都是被测对象。 - 每个用例前清库,避免串味;清库走**迁移角色**(app 角色没有 TRUNCATE 权限,这是设计使然)。 """ from __future__ import annotations import os os.environ["ENV"] = "test" os.environ.setdefault("SMS_DEV_CODE", "123456") # 测试里关掉发码节流,否则"同一手机号再登录一次"要等 60 秒 os.environ.setdefault("SMS_THROTTLE_SECONDS", "0") import uuid from collections.abc import AsyncIterator from typing import Any import pytest from httpx import ASGITransport, AsyncClient from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.pool import NullPool from alembic import command from alembic.config import Config from app.config import get_settings from app.db import engine from app.main import app from app.stores import reset_store_for_tests settings = get_settings() DEV_CODE = settings.sms_dev_code _TABLES = "audit_log, invitation, member, workspace, app_user" def _run_migrations() -> None: cfg = Config(str(os.path.join(os.path.dirname(__file__), "..", "alembic.ini"))) command.upgrade(cfg, "head") @pytest.fixture(scope="session", autouse=True) def migrated_database() -> None: _run_migrations() @pytest.fixture(autouse=True) async def clean_database(migrated_database: None) -> AsyncIterator[None]: owner_engine = create_async_engine(settings.migration_database_url, poolclass=NullPool) async with owner_engine.begin() as conn: await conn.execute(text(f"TRUNCATE {_TABLES} CASCADE")) await owner_engine.dispose() reset_store_for_tests() yield @pytest.fixture(autouse=True) async def dispose_app_engine() -> AsyncIterator[None]: yield await engine.dispose() @pytest.fixture async def client() -> AsyncIterator[AsyncClient]: transport = ASGITransport(app=app) async with AsyncClient(transport=transport, base_url="http://test") as ac: yield ac def auth(token: str) -> dict[str, str]: return {"Authorization": f"Bearer {token}"} def unwrap(response: Any) -> Any: body = response.json() assert body.get("code") == 0, f"期望成功,实际 {body}" return body["data"] async def login(client: AsyncClient, phone: str | None = None) -> dict[str, Any]: """发码 + 登录,返回 {token, refresh_token, user_id, phone}。""" phone = phone or f"138{uuid.uuid4().int % 100_000_000:08d}" await client.post("/api/v1/auth/sms/send", json={"phone": phone}) data = unwrap(await client.post("/api/v1/auth/login", json={"phone": phone, "code": DEV_CODE})) return { "token": data["tokens"]["access_token"], "refresh_token": data["tokens"]["refresh_token"], "user_id": data["user"]["id"], "phone": phone, "headers": auth(data["tokens"]["access_token"]), } async def create_workspace(client: AsyncClient, user: dict[str, Any], name: str) -> dict[str, Any]: return unwrap( await client.post("/api/v1/workspaces", json={"name": name}, headers=user["headers"]) )