CI / backend (push) Canceled after 0s
fnsvr-01 的 PostgreSQL 在 8432 端口(不是 5432),实测 PostgreSQL 17.6 / Alpine。 已在该实例建好独立库与角色: - dadaotask(开发)/ dadaotask_test(测试),owner 都是 dadao_owner - dadao_owner 迁移用;dadao_app 应用用(NOSUPERUSER NOBYPASSRLS) - 两个库都已 ALTER SCHEMA public OWNER TO dadao_owner + REVOKE CREATE FROM PUBLIC + GRANT 给 dadao_app 代码改动: - config 增加 test_database_url / test_migration_database_url,并给出 resolved_database_url() / resolved_migration_database_url():ENV=test 且有测试库时优先用测试库, 否则退回开发库(CI 里直接给 DATABASE_URL 即可,行为不变)。 - db.py / alembic/env.py / tests/conftest.py 统一走这两个解析方法。 原先 conftest 清表用的是 settings.migration_database_url,而应用连的是测试库, 一旦测试库与开发库不同,TRUNCATE 就清到另一个库上——表现为"测试库脏数据越攒越多、 列表类断言随机失败"。这是本次真踩到的坑,不是假想。 - tests/conftest.py 保留 E402 豁免(环境变量必须在 import app 模块前写好)。 验证: - 远程 dadaotask_test 上 pytest 33 passed,跑完表内 0 行(确认清表打在同一个库) - 远程 dadaotask 上 alembic upgrade head 成功,/health 与 /version 正常,db_role 为 dadao_app 且三项全 false - 本机便携 PG 16.4 上同样 33 passed(两个 PG 大版本都过) - ruff check / format 干净 已知限制:该实例没有 pgvector(只有 pg_trgm / pgcrypto),M3 向量检索前必须先定扩展来源, 已写进 2.Design/DEVELOPMENT.md。
105 lines
3.3 KiB
Python
105 lines
3.3 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")
|
|
# 测试库由 config 的 test_database_url / test_migration_database_url 决定(见 backend/.env),
|
|
# 这里不再改 os.environ —— 配置对象自己会读 .env。
|
|
|
|
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.resolved_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"])
|
|
)
|