feat(m0): 后端骨架 + Web/安卓前端骨架 + 设计文档
- backend: FastAPI 应用工厂、统一错误信封、JWT、短信登录、工作区/成员/邀请、幂等中间件 - alembic 0001: workspace/member/invitation/audit_log/app_user + RLS 策略 + 双角色 GRANT - tests: 认证/工作区/租户隔离/幂等 四组用例(尚未在本机执行) - web: React18+Vite 骨架(登录、工作区、邀请落地页) - android: Compose 骨架(登录、工作区列表) - 2.Design: PRODUCT-PLAN / ROADMAP-M0-M1 / NAMING / DEVELOPMENT
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
"""测试夹具。
|
||||
|
||||
约定:
|
||||
- 一律走真实 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"]))
|
||||
Reference in New Issue
Block a user