First product
This commit is contained in:
parent
778da9e2fc
commit
4433d7d2c4
157 changed files with 23975 additions and 0 deletions
171
tests/conftest.py
Normal file
171
tests/conftest.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
|
||||
import pytest
|
||||
from alembic import command
|
||||
from alembic.config import Config
|
||||
from sqlalchemy.engine import make_url
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from app.auth.deps import get_login_rate_limiter
|
||||
from app.db.session import close_engine
|
||||
from app.settings import settings
|
||||
|
||||
RESET_SQL = text(
|
||||
"""
|
||||
TRUNCATE TABLE
|
||||
ingestion_queue_items,
|
||||
scholar_publications,
|
||||
crawl_runs,
|
||||
scholar_profiles,
|
||||
publications,
|
||||
user_settings,
|
||||
users
|
||||
RESTART IDENTITY CASCADE
|
||||
"""
|
||||
)
|
||||
|
||||
DB_NAME_SAFE_RE = re.compile(r"^[A-Za-z0-9_]+$")
|
||||
|
||||
|
||||
def _resolve_test_database_url() -> str | None:
|
||||
explicit = (os.getenv("TEST_DATABASE_URL") or "").strip()
|
||||
if explicit:
|
||||
return explicit
|
||||
|
||||
base = (os.getenv("DATABASE_URL") or "").strip()
|
||||
if not base:
|
||||
return None
|
||||
|
||||
parsed = make_url(base)
|
||||
if not parsed.database:
|
||||
return None
|
||||
derived_database = (
|
||||
parsed.database if parsed.database.endswith("_test") else f"{parsed.database}_test"
|
||||
)
|
||||
return parsed.set(database=derived_database).render_as_string(hide_password=False)
|
||||
|
||||
|
||||
def pytest_runtest_setup(item: pytest.Item) -> None:
|
||||
if "integration" in item.keywords and not _resolve_test_database_url():
|
||||
pytest.skip("DATABASE_URL (or TEST_DATABASE_URL) is required for integration tests")
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def database_url() -> str:
|
||||
value = _resolve_test_database_url()
|
||||
if not value:
|
||||
pytest.skip("DATABASE_URL (or TEST_DATABASE_URL) is required for database tests")
|
||||
return value
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def alembic_config(database_url: str) -> Config:
|
||||
config = Config("alembic.ini")
|
||||
config.set_main_option("sqlalchemy.url", database_url)
|
||||
return config
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def ensure_test_database_exists(database_url: str) -> Iterator[None]:
|
||||
parsed = make_url(database_url)
|
||||
database_name = parsed.database
|
||||
if not database_name:
|
||||
raise RuntimeError("TEST_DATABASE_URL must include a database name.")
|
||||
if not DB_NAME_SAFE_RE.fullmatch(database_name):
|
||||
raise RuntimeError(
|
||||
"TEST_DATABASE_URL database name must match [A-Za-z0-9_]+ for safe auto-provisioning."
|
||||
)
|
||||
|
||||
admin_name = "postgres" if database_name != "postgres" else "template1"
|
||||
admin_url = parsed.set(database=admin_name)
|
||||
admin_url_rendered = admin_url.render_as_string(hide_password=False)
|
||||
|
||||
async def _ensure_database() -> None:
|
||||
engine = create_async_engine(
|
||||
admin_url_rendered,
|
||||
pool_pre_ping=True,
|
||||
isolation_level="AUTOCOMMIT",
|
||||
)
|
||||
try:
|
||||
async with engine.connect() as connection:
|
||||
exists_result = await connection.execute(
|
||||
text("SELECT 1 FROM pg_database WHERE datname = :database_name"),
|
||||
{"database_name": database_name},
|
||||
)
|
||||
if exists_result.scalar_one_or_none() == 1:
|
||||
return
|
||||
try:
|
||||
await connection.execute(text(f'CREATE DATABASE "{database_name}"'))
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
"Unable to auto-create test database. "
|
||||
"Create it manually or grant CREATEDB to the database user."
|
||||
) from exc
|
||||
finally:
|
||||
await engine.dispose()
|
||||
|
||||
asyncio.run(_ensure_database())
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def migrated_database(
|
||||
alembic_config: Config,
|
||||
ensure_test_database_exists: None,
|
||||
database_url: str,
|
||||
) -> Iterator[None]:
|
||||
previous_env_database_url = os.getenv("DATABASE_URL")
|
||||
previous_settings_database_url = settings.database_url
|
||||
|
||||
os.environ["DATABASE_URL"] = database_url
|
||||
object.__setattr__(settings, "database_url", database_url)
|
||||
asyncio.run(close_engine())
|
||||
command.upgrade(alembic_config, "head")
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if previous_env_database_url is None:
|
||||
os.environ.pop("DATABASE_URL", None)
|
||||
else:
|
||||
os.environ["DATABASE_URL"] = previous_env_database_url
|
||||
object.__setattr__(settings, "database_url", previous_settings_database_url)
|
||||
asyncio.run(close_engine())
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def db_session(
|
||||
migrated_database: None,
|
||||
database_url: str,
|
||||
) -> AsyncIterator[AsyncSession]:
|
||||
engine = create_async_engine(database_url, pool_pre_ping=True)
|
||||
async with engine.begin() as connection:
|
||||
await connection.execute(RESET_SQL)
|
||||
|
||||
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||
async with session_factory() as session:
|
||||
yield session
|
||||
await session.rollback()
|
||||
|
||||
await engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_rate_limiter_state() -> Iterator[None]:
|
||||
limiter = get_login_rate_limiter()
|
||||
limiter.clear_all()
|
||||
yield
|
||||
limiter.clear_all()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
async def reset_app_engine() -> AsyncIterator[None]:
|
||||
await close_engine()
|
||||
yield
|
||||
await close_engine()
|
||||
Loading…
Add table
Add a link
Reference in a new issue