"""测试夹具。 约定: - 一律走真实 HTTP 栈(ASGITransport),不做 handler 级 mock —— 中间件/依赖/RLS 都是被测对象。 - 每个用例前清库,避免串味;清库走**迁移角色**(app 角色没有 TRUNCATE 权限,这是设计使然)。 """ from __future__ import annotations import os os.environ["ENV"] = "test" os.environ.setdefault("SMS_DEV_CODE", "123456") import uuid from collections.abc import AsyncIterator from typing import Any import pytest from alembic import command from alembic.config import Config from httpx import ASGITransport, AsyncClient from sqlalchemy import text from sqlalchemy.ext.asyncio import create_async_engine from sqlalchemy.pool import NullPool 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"]))