test: conftest — БД сессии с миграциями, транзакция с откатом на тест, маркер fixtures, тест чтения Decimal
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
78e4a95327
commit
c3cf307866
@ -1,3 +1,87 @@
|
|||||||
"""Общие фикстуры тестов."""
|
"""Общие фикстуры тестов (spec data-model; design D-7, рулинг R-16).
|
||||||
|
|
||||||
pytest_plugins = ["tests.pg_helpers"]
|
БД-фикстуры — только для тестов с маркером `integration` и ленивые: пока тест их не запросил, PostgreSQL не
|
||||||
|
трогается (`make check` не требует БД). Тестовая БД сессии — отдельная свежая `armcore_pytest_<hex>` на сервере из
|
||||||
|
`.env.test`: мигрируется alembic один раз на сессию и удаляется в конце; рабочая БД `armka` не используется.
|
||||||
|
Изоляция тестов — внешняя транзакция с SAVEPOINT: `commit()` внутри теста ничего не фиксирует.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from collections.abc import AsyncIterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, create_async_engine
|
||||||
|
|
||||||
|
from tests.fixtures_helpers import fixtures_skip_reason, resolve_fixtures_dir
|
||||||
|
from tests.pg_helpers import ( # scratch_db_url — реэкспорт фикстуры, чтобы pytest её нашёл
|
||||||
|
alembic_async,
|
||||||
|
create_scratch_db,
|
||||||
|
drop_scratch_db,
|
||||||
|
scratch_db_url, # noqa: F401
|
||||||
|
)
|
||||||
|
|
||||||
|
PG_UNAVAILABLE = "PostgreSQL недоступен (make up / .env.test): {error}"
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_collection_modifyitems(config: pytest.Config, items: list[pytest.Item]) -> None:
|
||||||
|
"""Тесты с маркером `fixtures` пропускаются с явной причиной, если каталог fixtures недоступен."""
|
||||||
|
reason = fixtures_skip_reason()
|
||||||
|
if reason is None:
|
||||||
|
return
|
||||||
|
skip = pytest.mark.skip(reason=reason)
|
||||||
|
for item in items:
|
||||||
|
if "fixtures" in item.keywords:
|
||||||
|
item.add_marker(skip)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def fixtures_dir() -> Path:
|
||||||
|
"""Каталог реальных данных (`ARM_KA_FIXTURES` или соседний `fixtures`); без него тест пропускается."""
|
||||||
|
reason = fixtures_skip_reason()
|
||||||
|
if reason is not None:
|
||||||
|
pytest.skip(reason)
|
||||||
|
return resolve_fixtures_dir()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="session")
|
||||||
|
async def migrated_db_url() -> AsyncIterator[str]:
|
||||||
|
"""URL свежей БД `armcore_pytest_<hex>` с применённой миграцией `alembic upgrade head`; удаляется в конце сессии."""
|
||||||
|
name = f"armcore_pytest_{uuid.uuid4().hex[:12]}"
|
||||||
|
try:
|
||||||
|
url = await create_scratch_db(name)
|
||||||
|
except (OSError, ConnectionError) as error:
|
||||||
|
pytest.fail(PG_UNAVAILABLE.format(error=error), pytrace=False)
|
||||||
|
except Exception as error: # asyncpg: PostgresError/InterfaceError и т.п. при недоступном сервере
|
||||||
|
pytest.fail(PG_UNAVAILABLE.format(error=f"{type(error).__name__}: {error}"), pytrace=False)
|
||||||
|
try:
|
||||||
|
rendered = url.render_as_string(hide_password=False)
|
||||||
|
await alembic_async(rendered, "upgrade", "head")
|
||||||
|
yield rendered
|
||||||
|
finally:
|
||||||
|
await drop_scratch_db(name)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture(scope="session")
|
||||||
|
async def db_engine(migrated_db_url: str) -> AsyncIterator[AsyncEngine]:
|
||||||
|
"""Async-движок на мигрированную БД сессии; `search_path = dkb`."""
|
||||||
|
engine = create_async_engine(migrated_db_url, connect_args={"server_settings": {"search_path": "dkb"}})
|
||||||
|
try:
|
||||||
|
yield engine
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def db_session(db_engine: AsyncEngine) -> AsyncIterator[AsyncSession]:
|
||||||
|
"""Сессия в внешней транзакции с откатом: `commit()` в тесте освобождает лишь SAVEPOINT, данные не остаются."""
|
||||||
|
connection: AsyncConnection = await db_engine.connect()
|
||||||
|
transaction = await connection.begin()
|
||||||
|
session = AsyncSession(bind=connection, join_transaction_mode="create_savepoint", expire_on_commit=False)
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
finally:
|
||||||
|
await session.close()
|
||||||
|
await transaction.rollback()
|
||||||
|
await connection.close()
|
||||||
|
|||||||
69
api/tests/factories.py
Normal file
69
api/tests/factories.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
"""Фабрики тестовых данных для БД-тестов: минимальные цепочки FK поверх сессии (`flush`, без `commit`)."""
|
||||||
|
|
||||||
|
import datetime
|
||||||
|
import decimal
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.directories import LegalEntity
|
||||||
|
from src.models.enums import (
|
||||||
|
DocumentStatus,
|
||||||
|
DocumentType,
|
||||||
|
FileStatus,
|
||||||
|
FileType,
|
||||||
|
RecognitionPipeline,
|
||||||
|
RecognitionStatus,
|
||||||
|
S3BucketRole,
|
||||||
|
)
|
||||||
|
from src.models.files import Document, RecognitionJob, S3Object, StoredFile
|
||||||
|
from src.models.osv import OsvEntry
|
||||||
|
|
||||||
|
|
||||||
|
async def make_osv_entry(session: AsyncSession, **overrides: Any) -> OsvEntry:
|
||||||
|
"""Строка ОСВ с минимальной цепочкой FK (ЮЛ, s3_object, stored_file, recognition_job, document); `flush`.
|
||||||
|
|
||||||
|
`overrides` перекрывают поля `OsvEntry` (например, `closing_debit=Decimal("1.00")`). Данные остаются в
|
||||||
|
транзакции сессии: фикстура `db_session` откатывает их после теста.
|
||||||
|
"""
|
||||||
|
now = datetime.datetime.now(datetime.UTC)
|
||||||
|
entity = LegalEntity(inn="7700000001", name="ООО Тест", valid_from=now)
|
||||||
|
s3 = S3Object(bucket_role=S3BucketRole.uploads, bucket="uploads", key=f"{uuid.uuid4()}/osv.xlsx", created_at=now)
|
||||||
|
session.add_all([entity, s3])
|
||||||
|
await session.flush()
|
||||||
|
file = StoredFile(
|
||||||
|
legal_entity_id=entity.id, file_type=FileType.XLSX, s3_object_id=s3.id, status=FileStatus.UPLOADED
|
||||||
|
)
|
||||||
|
session.add(file)
|
||||||
|
await session.flush()
|
||||||
|
job = RecognitionJob(
|
||||||
|
file_id=file.id,
|
||||||
|
pipeline=RecognitionPipeline.tabular,
|
||||||
|
request_subject="recognition.tabular.request",
|
||||||
|
schema_version="1",
|
||||||
|
status=RecognitionStatus.queued,
|
||||||
|
)
|
||||||
|
session.add(job)
|
||||||
|
await session.flush()
|
||||||
|
document = Document(
|
||||||
|
stored_file_id=file.id,
|
||||||
|
legal_entity_id=entity.id,
|
||||||
|
document_type=DocumentType.OSV,
|
||||||
|
osv_account_code="62",
|
||||||
|
status=DocumentStatus.RECOGNIZED,
|
||||||
|
)
|
||||||
|
session.add(document)
|
||||||
|
await session.flush()
|
||||||
|
fields: dict[str, Any] = {
|
||||||
|
"recognition_job_id": job.id,
|
||||||
|
"document_id": document.id,
|
||||||
|
"owner_legal_entity_id": entity.id,
|
||||||
|
"row_number": 1,
|
||||||
|
"closing_debit": decimal.Decimal("0.00"),
|
||||||
|
}
|
||||||
|
fields.update(overrides)
|
||||||
|
entry = OsvEntry(**fields)
|
||||||
|
session.add(entry)
|
||||||
|
await session.flush()
|
||||||
|
return entry
|
||||||
28
api/tests/fixtures_helpers.py
Normal file
28
api/tests/fixtures_helpers.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
"""Поиск каталога реальных данных `fixtures` (соседний репозиторий workspace) для тестов с маркером `fixtures`."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
FIXTURES_ENV = "ARM_KA_FIXTURES"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_fixtures_dir() -> Path:
|
||||||
|
"""Каталог fixtures: `ARM_KA_FIXTURES`, иначе соседний `../../fixtures` относительно `arm-core/api`.
|
||||||
|
|
||||||
|
Не проверяет существование: это делает вызывающий код (пропуск теста с причиной).
|
||||||
|
"""
|
||||||
|
explicit = os.environ.get(FIXTURES_ENV)
|
||||||
|
if explicit:
|
||||||
|
return Path(explicit)
|
||||||
|
return Path(__file__).resolve().parents[3] / "fixtures"
|
||||||
|
|
||||||
|
|
||||||
|
def fixtures_skip_reason() -> str | None:
|
||||||
|
"""Причина пропуска тестов с маркером `fixtures` или `None`, если каталог доступен."""
|
||||||
|
path = resolve_fixtures_dir()
|
||||||
|
if path.is_dir():
|
||||||
|
return None
|
||||||
|
return (
|
||||||
|
f"ARM_KA_FIXTURES недоступен: нет каталога {path} "
|
||||||
|
f"(задайте {FIXTURES_ENV} или положите fixtures рядом с arm-core)"
|
||||||
|
)
|
||||||
@ -4,8 +4,12 @@
|
|||||||
в конце удаляет. Рабочую БД (`armka`) тесты не трогают. Подключается плагином в `conftest.py`.
|
в конце удаляет. Рабочую БД (`armka`) тесты не трогают. Подключается плагином в `conftest.py`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
import uuid
|
import uuid
|
||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
import pytest_asyncio
|
import pytest_asyncio
|
||||||
@ -13,6 +17,8 @@ from sqlalchemy.engine import URL, make_url
|
|||||||
|
|
||||||
from src.core.config import settings
|
from src.core.config import settings
|
||||||
|
|
||||||
|
API_DIR = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
def base_url() -> URL:
|
def base_url() -> URL:
|
||||||
"""SQLAlchemy-URL рабочей БД из настроек (`.env.test`)."""
|
"""SQLAlchemy-URL рабочей БД из настроек (`.env.test`)."""
|
||||||
@ -23,6 +29,29 @@ def _asyncpg_dsn(url: URL) -> str:
|
|||||||
return url.set(drivername="postgresql").render_as_string(hide_password=False)
|
return url.set(drivername="postgresql").render_as_string(hide_password=False)
|
||||||
|
|
||||||
|
|
||||||
|
def run_alembic(url: str, *args: str) -> str:
|
||||||
|
"""`alembic -x dburl=<url> <args>` из каталога api/ отдельным процессом; вернуть stdout+stderr.
|
||||||
|
|
||||||
|
`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,
|
||||||
|
)
|
||||||
|
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) -> str:
|
||||||
|
"""`run_alembic` в потоке, чтобы не блокировать event loop."""
|
||||||
|
return await asyncio.to_thread(run_alembic, url, *args)
|
||||||
|
|
||||||
|
|
||||||
async def create_scratch_db(name: str) -> URL:
|
async def create_scratch_db(name: str) -> URL:
|
||||||
"""Создать пустую БД `name` на сервере рабочей БД; вернуть SQLAlchemy-URL на неё."""
|
"""Создать пустую БД `name` на сервере рабочей БД; вернуть SQLAlchemy-URL на неё."""
|
||||||
url = base_url()
|
url = base_url()
|
||||||
|
|||||||
36
api/tests/test_fixtures_marker.py
Normal file
36
api/tests/test_fixtures_marker.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""Маркер `fixtures`: путь к реальным данным и пропуск с причиной (spec quality-checks, «Фикстуры … опциональны»)."""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from tests.fixtures_helpers import FIXTURES_ENV, fixtures_skip_reason, resolve_fixtures_dir
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.fixtures
|
||||||
|
def test_fixtures_dir_contains_readme(fixtures_dir: Path) -> None:
|
||||||
|
"""При доступных fixtures каталог существует и содержит README.md; иначе тест пропускается с причиной."""
|
||||||
|
assert fixtures_dir.is_dir()
|
||||||
|
assert (fixtures_dir / "README.md").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_prefers_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
monkeypatch.setenv(FIXTURES_ENV, str(tmp_path))
|
||||||
|
assert resolve_fixtures_dir() == tmp_path
|
||||||
|
assert fixtures_skip_reason() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_falls_back_to_sibling_dir(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.delenv(FIXTURES_ENV, raising=False)
|
||||||
|
path = resolve_fixtures_dir()
|
||||||
|
assert path.name == "fixtures"
|
||||||
|
assert path.parent == Path(__file__).resolve().parents[3]
|
||||||
|
|
||||||
|
|
||||||
|
def test_skip_reason_names_variable_and_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
||||||
|
missing = tmp_path / "нет-такого"
|
||||||
|
monkeypatch.setenv(FIXTURES_ENV, str(missing))
|
||||||
|
reason = fixtures_skip_reason()
|
||||||
|
assert reason is not None
|
||||||
|
assert "ARM_KA_FIXTURES недоступен" in reason
|
||||||
|
assert str(missing) in reason
|
||||||
@ -6,11 +6,7 @@ CLI: `env.py` вызывает `asyncio.run(...)`, а внутри event loop py
|
|||||||
перенастраивает логирование тестов через `fileConfig`. Каждая проверка идёт на своей временной БД (`scratch_db_url`).
|
перенастраивает логирование тестов через `fileConfig`. Каждая проверка идёт на своей временной БД (`scratch_db_url`).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import uuid
|
import uuid
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@ -21,7 +17,8 @@ from sqlalchemy.ext.asyncio import AsyncConnection, AsyncEngine, AsyncSession, c
|
|||||||
|
|
||||||
from src.models import Base
|
from src.models import Base
|
||||||
from src.models.enums import ENUMS
|
from src.models.enums import ENUMS
|
||||||
from tests.pg_helpers import base_url, create_scratch_db, drop_scratch_db
|
from tests.pg_helpers import alembic_async as alembic
|
||||||
|
from tests.pg_helpers import base_url, create_scratch_db, drop_scratch_db, run_alembic
|
||||||
from tests.test_models_part_01_03 import _check_related_group_member_uniqueness
|
from tests.test_models_part_01_03 import _check_related_group_member_uniqueness
|
||||||
from tests.test_models_part_04_06 import _check_nulls_not_distinct, _make_file_with_job
|
from tests.test_models_part_04_06 import _check_nulls_not_distinct, _make_file_with_job
|
||||||
from tests.test_models_part_07_09 import (
|
from tests.test_models_part_07_09 import (
|
||||||
@ -31,30 +28,11 @@ from tests.test_models_part_07_09 import (
|
|||||||
_check_active_lock_is_unique,
|
_check_active_lock_is_unique,
|
||||||
)
|
)
|
||||||
|
|
||||||
API_DIR = Path(__file__).resolve().parents[1]
|
|
||||||
HEAD = "0001_dbml_v5_13"
|
HEAD = "0001_dbml_v5_13"
|
||||||
VERSION_TABLE = "alembic_version"
|
VERSION_TABLE = "alembic_version"
|
||||||
FK_CYCLE = "fk_stored_file_current_recognition_job_id_recognition_job"
|
FK_CYCLE = "fk_stored_file_current_recognition_job_id_recognition_job"
|
||||||
|
|
||||||
|
|
||||||
def _run_alembic(url: str, *args: str) -> str:
|
|
||||||
"""`alembic -x dburl=<url> <args>` из каталога api/; вернуть stdout+stderr, упасть с выводом при ошибке."""
|
|
||||||
result = subprocess.run(
|
|
||||||
[sys.executable, "-m", "alembic", "-x", f"dburl={url}", *args],
|
|
||||||
cwd=API_DIR,
|
|
||||||
capture_output=True,
|
|
||||||
text=True,
|
|
||||||
check=False,
|
|
||||||
)
|
|
||||||
output = result.stdout + result.stderr
|
|
||||||
assert result.returncode == 0, f"alembic {' '.join(args)} завершился с кодом {result.returncode}:\n{output}"
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
async def alembic(url: str, *args: str) -> str:
|
|
||||||
return await asyncio.to_thread(_run_alembic, url, *args)
|
|
||||||
|
|
||||||
|
|
||||||
async def _scalars(conn: AsyncConnection, sql: str) -> list[Any]:
|
async def _scalars(conn: AsyncConnection, sql: str) -> list[Any]:
|
||||||
return list((await conn.execute(text(sql))).scalars())
|
return list((await conn.execute(text(sql))).scalars())
|
||||||
|
|
||||||
@ -321,7 +299,7 @@ async def test_upgrade_into_preprovisioned_schema_without_database_create(scratc
|
|||||||
|
|
||||||
def test_offline_sql_creates_schema_before_version_table() -> None:
|
def test_offline_sql_creates_schema_before_version_table() -> None:
|
||||||
"""Офлайн-режим (`upgrade head --sql`): условный CREATE SCHEMA dkb идёт до таблицы версий и до enum-типов."""
|
"""Офлайн-режим (`upgrade head --sql`): условный CREATE SCHEMA dkb идёт до таблицы версий и до enum-типов."""
|
||||||
output = _run_alembic("postgresql+asyncpg://user:pass@localhost/offline", "upgrade", "head", "--sql")
|
output = run_alembic("postgresql+asyncpg://user:pass@localhost/offline", "upgrade", "head", "--sql")
|
||||||
schema_at = output.index(
|
schema_at = output.index(
|
||||||
"DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'dkb') "
|
"DO $$ BEGIN IF NOT EXISTS (SELECT 1 FROM pg_namespace WHERE nspname = 'dkb') "
|
||||||
"THEN CREATE SCHEMA dkb; END IF; END $$"
|
"THEN CREATE SCHEMA dkb; END IF; END $$"
|
||||||
|
|||||||
43
api/tests/test_money_types.py
Normal file
43
api/tests/test_money_types.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"""Деньги — точные типы (spec data-model, «Деньги и проценты — точные типы») и изоляция БД-фикстур.
|
||||||
|
|
||||||
|
Сценарий «Чтение денежной колонки»: `osv_entry.closing_debit = 106311116.46` читается как `Decimal("106311116.46")`.
|
||||||
|
Тесты идут на БД сессии (`migrated_db_url`, миграции alembic) в транзакции с откатом (`db_session`, design D-7, R-16).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.models.osv import OsvEntry
|
||||||
|
from tests.factories import make_osv_entry
|
||||||
|
|
||||||
|
AMOUNT = decimal.Decimal("106311116.46")
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.integration
|
||||||
|
|
||||||
|
|
||||||
|
async def test_closing_debit_is_read_back_as_decimal(db_session: AsyncSession) -> None:
|
||||||
|
"""Запись из БД, а не из identity map: тип `Decimal`, значение без потери точности."""
|
||||||
|
entry = await make_osv_entry(db_session, closing_debit=AMOUNT)
|
||||||
|
entry_id = entry.id
|
||||||
|
db_session.expire_all() # забыть объект: значение читается только запросом
|
||||||
|
stored = (await db_session.execute(select(OsvEntry.closing_debit).where(OsvEntry.id == entry_id))).scalar_one()
|
||||||
|
assert type(stored) is decimal.Decimal
|
||||||
|
assert stored == AMOUNT
|
||||||
|
assert str(stored) == "106311116.46"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rollback_isolation_writes_and_commits(db_session: AsyncSession) -> None:
|
||||||
|
"""Первая половина проверки изоляции: запись и `commit` внутри теста (SAVEPOINT, не настоящая фиксация)."""
|
||||||
|
await make_osv_entry(db_session, closing_debit=AMOUNT)
|
||||||
|
await db_session.commit()
|
||||||
|
count = (await db_session.execute(select(func.count()).select_from(OsvEntry))).scalar_one()
|
||||||
|
assert count == 1
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rollback_isolation_next_test_sees_nothing(db_session: AsyncSession) -> None:
|
||||||
|
"""Вторая половина (порядок — по именам внутри модуля): данные предыдущего теста откатились."""
|
||||||
|
count = (await db_session.execute(select(func.count()).select_from(OsvEntry))).scalar_one()
|
||||||
|
assert count == 0
|
||||||
Loading…
x
Reference in New Issue
Block a user