Files
DadaoTask/backend/tests/conftest.py
T
Steven 2b493bd9f8
CI / backend (push) Canceled after 0s
fix(m0): 迁移可执行 + 测试全绿(33 条)
跑通迁移与测试后修掉的真问题:

- alembic 0001:asyncpg 走预备语句,一次 execute 只能一条 SQL。
  CREATE FUNCTION / CREATE POLICY / GRANT 全部拆成单语句逐条执行,
  否则报 "cannot insert multiple commands into a prepared statement"。
- 创建工作区:ORM flush 只按 relationship 排序,裸外键不算数,
  member 可能先于 workspace 落地撞 FK。改为先 flush workspace 再插 member。
- datetime.UTC 是 3.11+ 才有的,本机 3.10 直接 ImportError。改用 timezone.utc。
- 幂等中间件:原来会缓存 4xx。校验失败被缓存后,用户改对参数仍拿到同一个 422。
  改为只缓存 <400。
- 发码节流窗口改为可配置 SMS_THROTTLE_SECONDS(测试置 0),
  节流用例自己显式打开,避免"整库关掉节流"导致节流从未被验证。
- 租户隔离用例:pytest.raises(Exception) 改为精确的 ProgrammingError,
  否则任何异常都会让"应用角色不能建表"这条断言假通过。

验收:pytest 33 passed;ruff check / format 通过;真机跑 uvicorn,
/health、/version、登录、建工作区、列表、/auth/me 全通。
2026-10-28 03:22:59 +08:00

101 lines
3.1 KiB
Python

"""测试夹具。
约定:
- 一律走真实 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"])
)