arm-core/api/tests/pg_helpers.py
Andrey Epifancev 9e53fc64be Схема БД конфигурируемая: настройка DB_SCHEMA вместо зашитой dkb (#38)
DB_SCHEMA (по умолчанию dkb, валидация идентификатора) читают models/base.py (SCHEMA), db/db.py (search_path),
alembic/env.py и миграции 0001-0005 (SCHEMA и строковые FK f"{SCHEMA}.x.id" вместо литералов). В контуре -
OPENBAO__APP__DB_SCHEMA. Офлайн-SQL `alembic upgrade head --sql` при значении по умолчанию совпадает побайтно.
Тесты: валидация настройки, миграции и сессия приложения в схеме dkb_alt; .env.example/.env.test/README/CLAUDE.md.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3
2026-10-07 19:26:37 +04:00

98 lines
4.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Помощники интеграционных тестов с PostgreSQL: временная БД внутри того же сервера, что и `OPENBAO__DATABASE`.
Фикстура `scratch_db_url` создаёт пустую БД `armcore_scratch_<hex>`, отдаёт её SQLAlchemy-URL (драйвер asyncpg) и
в конце удаляет. Рабочую БД (`armka`) тесты не трогают и от её существования не зависят: `CREATE/DROP DATABASE` и
прочие команды уровня кластера идут через служебную БД `postgres` того же сервера (`service_url`). Подключается
плагином в `conftest.py`.
"""
import asyncio
import os
import subprocess
import sys
import uuid
from collections.abc import AsyncIterator
from functools import partial
from pathlib import Path
import asyncpg
import pytest_asyncio
from sqlalchemy.engine import URL, make_url
from src.core.config import settings
API_DIR = Path(__file__).resolve().parents[1]
def base_url() -> URL:
"""SQLAlchemy-URL рабочей БД из настроек (`.env.test`)."""
return make_url(settings.OPENBAO.DATABASE.DATABASE_URL)
#: Служебная БД PostgreSQL, которая есть на любом сервере: к ней подключаются для команд уровня кластера.
SERVICE_DATABASE = "postgres"
def service_url() -> URL:
"""SQLAlchemy-URL служебной БД `postgres` на сервере из настроек (те же хост, порт и учётные данные)."""
return base_url().set(database=SERVICE_DATABASE)
def _asyncpg_dsn(url: URL) -> str:
return url.set(drivername="postgresql").render_as_string(hide_password=False)
def run_alembic(url: str, *args: str, env: dict[str, str] | None = None) -> str:
"""`alembic -x dburl=<url> <args>` из каталога api/ отдельным процессом; вернуть stdout+stderr.
`env` — дополнительные переменные окружения процесса (например `DB_SCHEMA`).
`env.py` вызывает `asyncio.run(...)` — внутри event loop pytest-asyncio это невозможно. При ошибке —
`AssertionError` с выводом процесса (`ENV_FILE`/`APP_ENV` наследуются из окружения pytest-env).
"""
result = subprocess.run(
[sys.executable, "-m", "alembic", "-x", f"dburl={url}", *args],
cwd=API_DIR,
capture_output=True,
text=True,
check=False,
env={**os.environ, **(env or {})},
)
output = result.stdout + result.stderr
assert result.returncode == 0, f"alembic {' '.join(args)} завершился с кодом {result.returncode}:\n{output}"
return output
async def alembic_async(url: str, *args: str, env: dict[str, str] | None = None) -> str:
"""`run_alembic` в потоке, чтобы не блокировать event loop."""
return await asyncio.to_thread(partial(run_alembic, url, *args, env=env))
async def create_scratch_db(name: str) -> URL:
"""Создать пустую БД `name` на сервере рабочей БД; вернуть SQLAlchemy-URL на неё."""
conn = await asyncpg.connect(_asyncpg_dsn(service_url()))
try:
await conn.execute(f'CREATE DATABASE "{name}"')
finally:
await conn.close()
return base_url().set(database=name)
async def drop_scratch_db(name: str) -> None:
"""Удалить БД `name` (закрывая оставшиеся подключения)."""
conn = await asyncpg.connect(_asyncpg_dsn(service_url()))
try:
await conn.execute(f'DROP DATABASE IF EXISTS "{name}" WITH (FORCE)')
finally:
await conn.close()
@pytest_asyncio.fixture
async def scratch_db_url() -> AsyncIterator[str]:
"""URL пустой временной БД (asyncpg); БД удаляется после теста."""
name = f"armcore_scratch_{uuid.uuid4().hex[:12]}"
url = await create_scratch_db(name)
try:
yield url.render_as_string(hide_password=False)
finally:
await drop_scratch_db(name)