diff --git a/api/tests/conftest.py b/api/tests/conftest.py index e69de29..e5eeb18 100644 --- a/api/tests/conftest.py +++ b/api/tests/conftest.py @@ -0,0 +1,3 @@ +"""Общие фикстуры тестов.""" + +pytest_plugins = ["tests.pg_helpers"] diff --git a/api/tests/pg_helpers.py b/api/tests/pg_helpers.py new file mode 100644 index 0000000..a924716 --- /dev/null +++ b/api/tests/pg_helpers.py @@ -0,0 +1,54 @@ +"""Помощники интеграционных тестов с PostgreSQL: временная БД внутри того же сервера, что и `OPENBAO__DATABASE`. + +Фикстура `scratch_db_url` создаёт пустую БД `armcore_scratch_`, отдаёт её SQLAlchemy-URL (драйвер asyncpg) и +в конце удаляет. Рабочую БД (`armka`) тесты не трогают. Подключается плагином в `conftest.py`. +""" + +import uuid +from collections.abc import AsyncIterator + +import asyncpg +import pytest_asyncio +from sqlalchemy.engine import URL, make_url + +from src.core.config import settings + + +def base_url() -> URL: + """SQLAlchemy-URL рабочей БД из настроек (`.env.test`).""" + return make_url(settings.OPENBAO.DATABASE.DATABASE_URL) + + +def _asyncpg_dsn(url: URL) -> str: + return url.set(drivername="postgresql").render_as_string(hide_password=False) + + +async def create_scratch_db(name: str) -> URL: + """Создать пустую БД `name` на сервере рабочей БД; вернуть SQLAlchemy-URL на неё.""" + url = base_url() + conn = await asyncpg.connect(_asyncpg_dsn(url)) + try: + await conn.execute(f'CREATE DATABASE "{name}"') + finally: + await conn.close() + return url.set(database=name) + + +async def drop_scratch_db(name: str) -> None: + """Удалить БД `name` (закрывая оставшиеся подключения).""" + conn = await asyncpg.connect(_asyncpg_dsn(base_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) diff --git a/api/tests/test_models_part_01_03.py b/api/tests/test_models_part_01_03.py new file mode 100644 index 0000000..00e8f75 --- /dev/null +++ b/api/tests/test_models_part_01_03.py @@ -0,0 +1,210 @@ +"""ORM частей 01–03 DBML v5.13 (справочники, доступ, учёт): чётность с DBML без БД и create_all на PostgreSQL.""" + +import datetime +import uuid + +import pytest +from pydbml.classes import Table as DbmlTable +from sqlalchemy import Table, inspect, text +from sqlalchemy.dialects import postgresql +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy.schema import CreateIndex, CreateTable + +from src.models import Base +from tests.dbml_helpers import load_dbml + +PART_01_03_TABLES = { + # directories + "regional_office", + "company_group", + "company_group_history", + "legal_entity", + "legal_entity_history", + "legal_entity_alias", + "related_group", + "related_group_member", + # access + "app_user", + "role", + "permission", + "role_permission", + "user_role", + # accounting + "chart_of_account", + "elimination_rule", + "elimination_rule_account", +} +PG_IDENTIFIER_LIMIT = 63 +EXPECTED_TABLE_COUNT = 16 + + +def orm_tables() -> dict[str, Table]: + return {t.name: t for t in Base.metadata.sorted_tables} + + +def dbml_tables() -> dict[str, DbmlTable]: + return {t.name: t for t in load_dbml().tables if t.name in PART_01_03_TABLES} + + +def test_part_01_03_tables_registered_in_metadata() -> None: + assert set(orm_tables()) == PART_01_03_TABLES + assert len(Base.metadata.sorted_tables) == EXPECTED_TABLE_COUNT # на этом этапе (задачи 7–8 добавят остальные) + assert all(t.schema == "dkb" for t in Base.metadata.sorted_tables) + + +def test_every_table_and_index_compiles_for_postgresql() -> None: + dialect = postgresql.dialect() + for table in Base.metadata.sorted_tables: + assert str(CreateTable(table).compile(dialect=dialect)).strip() + for index in table.indexes: + assert str(CreateIndex(index).compile(dialect=dialect)).strip() + + +def test_foreign_keys_resolve_to_registered_tables() -> None: + for table in Base.metadata.sorted_tables: + for fk in table.foreign_keys: + assert fk.column.table.key in Base.metadata.tables, f"{table.name}.{fk.parent.name}" + assert fk.column.table.schema == "dkb" + + +def test_constraint_names_are_explicit_and_fit_postgres_limit() -> None: + """Все имена ограничений заданы (конвенцией/явно), без хеш-усечения, и не длиннее 63 символов.""" + for table in Base.metadata.sorted_tables: + names = [c.name for c in table.constraints] + [i.name for i in table.indexes] + for name in names: + assert name, f"{table.name}: безымянное ограничение" + assert len(str(name)) <= PG_IDENTIFIER_LIMIT, f"{table.name}: {name} длиннее {PG_IDENTIFIER_LIMIT}" + + +def test_columns_match_dbml() -> None: + """Имена колонок, nullability, PK и FK-цели — как в DBML.""" + orm, dbml = orm_tables(), dbml_tables() + refs = [r for r in load_dbml().refs if r.table1.name in PART_01_03_TABLES] + expected_fks = {(r.table1.name, r.col1[0].name, r.table2.name, r.col2[0].name) for r in refs} + actual_fks = { + (t.name, fk.parent.name, fk.column.table.name, fk.column.name) for t in orm.values() for fk in t.foreign_keys + } + assert actual_fks == expected_fks + for name, dbml_table in dbml.items(): + orm_table = orm[name] + dbml_cols = {c.name: c for c in dbml_table.columns} + assert set(orm_table.columns.keys()) == set(dbml_cols), name + composite_pk = {c.name for i in dbml_table.indexes if i.pk for c in i.subjects} + for col_name, dbml_col in dbml_cols.items(): + orm_col = orm_table.columns[col_name] + is_pk = dbml_col.pk or col_name in composite_pk + assert orm_col.primary_key == is_pk, f"{name}.{col_name}: pk" + assert orm_col.nullable == (not (dbml_col.not_null or is_pk)), f"{name}.{col_name}: nullable" + assert bool(orm_col.unique) == bool(dbml_col.unique), f"{name}.{col_name}: unique" + if dbml_col.default is None: + assert orm_col.server_default is None, f"{name}.{col_name}: лишний server_default" + else: + assert orm_col.server_default is not None, f"{name}.{col_name}: нет server_default" + assert str(orm_col.server_default.arg).lower() == str(dbml_col.default).lower() + + +def test_column_order_follows_dbml() -> None: + """Порядок колонок как в DBML (в частности `id` первой) — миграция 0001 читается рядом с DBML.""" + orm = orm_tables() + for name, dbml_table in dbml_tables().items(): + assert [c.name for c in orm[name].columns] == [c.name for c in dbml_table.columns], name + + +def test_indexes_match_dbml() -> None: + """Состав индексов (кроме PK): колонки, уникальность, partial-условие.""" + orm = orm_tables() + for name, dbml_table in dbml_tables().items(): + expected = sorted( + (tuple(c.name for c in i.subjects), i.unique or "partial UNIQUE" in str(i.note)) + for i in dbml_table.indexes + if not i.pk + ) + actual = sorted((tuple(c.name for c in i.columns), bool(i.unique)) for i in orm[name].indexes) + assert actual == expected, name + partial = {i.name: str(i.dialect_options["postgresql"]["where"]) for i in orm["related_group_member"].indexes} + assert partial == { + "uq_related_group_member_group_entity_current": "is_current", + "uq_related_group_member_entity_current": "is_current", + } + + +@pytest.mark.integration +async def test_create_all_on_scratch_db(scratch_db_url: str) -> None: + """`create_all` на пустой БД: 16 таблиц в схеме dkb, enum-типы, partial unique работают.""" + engine = create_async_engine(scratch_db_url) + try: + async with engine.begin() as conn: + await conn.execute(text("CREATE SCHEMA IF NOT EXISTS dkb")) + await conn.run_sync(Base.metadata.create_all) + + async with engine.connect() as conn: + tables = await conn.run_sync(lambda c: set(inspect(c).get_table_names(schema="dkb"))) + assert tables == PART_01_03_TABLES + enums = { + row[0] + for row in await conn.execute( + text( + "SELECT t.typname FROM pg_type t JOIN pg_namespace n ON n.oid = t.typnamespace " + "WHERE n.nspname = 'dkb' AND t.typtype = 'e'" + ) + ) + } + assert enums == { + "entity_type", "related_group_kind", "account_kind", "account_row_type", "business_segment", + "statement_type", "le_history_event", "permission_object", "permission_action", + } # fmt: skip + indexdefs = { + row[0]: row[1] + for row in await conn.execute( + text( + "SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'dkb' " + "AND tablename = 'related_group_member'" + ) + ) + } + for name in ("uq_related_group_member_group_entity_current", "uq_related_group_member_entity_current"): + assert "UNIQUE" in indexdefs[name] + assert "WHERE is_current" in indexdefs[name] + pk = await conn.run_sync(lambda c: inspect(c).get_pk_constraint("role_permission", schema="dkb")) + assert pk["name"] == "pk_role_permission" + assert pk["constrained_columns"] == ["role_id", "permission_id"] + + await _check_related_group_member_uniqueness(engine) + finally: + await engine.dispose() + + +async def _check_related_group_member_uniqueness(engine) -> None: # type: ignore[no-untyped-def] + """ЮЛ — только в одной ГСК/ГСЗ среди текущих; закрытая версия не мешает (ТЗ 6.1, ANL-030).""" + now = datetime.datetime.now(datetime.UTC) + ids = {k: uuid.uuid4() for k in ("ro", "cg", "le", "g1", "g2", "m1", "m2", "m3")} + async with engine.begin() as conn: + await conn.execute(text("INSERT INTO dkb.regional_office (id, name) VALUES (:ro, 'ГО')"), ids) + await conn.execute( + text("INSERT INTO dkb.company_group (id, code, name, valid_from) VALUES (:cg, 'G1', 'ГК', :now)"), + {**ids, "now": now}, + ) + await conn.execute( + text("INSERT INTO dkb.legal_entity (id, inn, name, valid_from) VALUES (:le, '1', 'ЮЛ', :now)"), + {**ids, "now": now}, + ) + for g, code in (("g1", "GSK-1"), ("g2", "GSK-2")): + await conn.execute( + text( + "INSERT INTO dkb.related_group (id, code, company_group_id, kind, name) " + "VALUES (:g, :code, :cg, 'gsk', 'ГСК')" + ), + {"g": ids[g], "code": code, "cg": ids["cg"]}, + ) + insert = text( + "INSERT INTO dkb.related_group_member (id, related_group_id, legal_entity_id, valid_from, is_current) " + "VALUES (:id, :g, :le, :now, :cur)" + ) + async with engine.begin() as conn: + await conn.execute(insert, {"id": ids["m1"], "g": ids["g1"], "le": ids["le"], "now": now, "cur": True}) + # закрытая версия в другой группе — допустима + await conn.execute(insert, {"id": ids["m2"], "g": ids["g2"], "le": ids["le"], "now": now, "cur": False}) + with pytest.raises(IntegrityError): + async with engine.begin() as conn: # второе текущее членство того же ЮЛ — нарушение + await conn.execute(insert, {"id": ids["m3"], "g": ids["g2"], "le": ids["le"], "now": now, "cur": True})