From ef55485dfcae1c89ac38a2c1fccd739e4ec81294 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sat, 3 Oct 2026 19:32:48 +0400 Subject: [PATCH] =?UTF-8?q?test:=20=D1=87=D1=91=D1=82=D0=BD=D0=BE=D1=81?= =?UTF-8?q?=D1=82=D1=8C=20=D1=87=D0=B0=D1=81=D1=82=D0=B5=D0=B9=2007?= =?UTF-8?q?=E2=80=9309=20=D1=81=20DBML,=20=D0=BF=D0=BE=D0=BB=D0=BD=D0=BE?= =?UTF-8?q?=D1=82=D0=B0=2043=20=D1=82=D0=B0=D0=B1=D0=BB=D0=B8=D1=86=20?= =?UTF-8?q?=D0=B8=2027=20enum,=20partial=20unique=20consolidation=5Flock?= =?UTF-8?q?=20=D0=BD=D0=B0=20PG16?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- api/tests/test_models_part_04_06.py | 14 +- api/tests/test_models_part_07_09.py | 241 ++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+), 7 deletions(-) create mode 100644 api/tests/test_models_part_07_09.py diff --git a/api/tests/test_models_part_04_06.py b/api/tests/test_models_part_04_06.py index e1f8440..9126293 100644 --- a/api/tests/test_models_part_04_06.py +++ b/api/tests/test_models_part_04_06.py @@ -3,6 +3,7 @@ import datetime import decimal import uuid +import warnings from dataclasses import dataclass import pytest @@ -40,7 +41,6 @@ PART_04_06_TABLES = { "financial_statement_line", "financial_statement_value", } -EXPECTED_TABLE_COUNT = 25 # на этом этапе: 16 (части 01–03) + 9 (части 04–06); задача 8 добавит остальные def orm_tables() -> dict[str, Table]: @@ -48,8 +48,7 @@ def orm_tables() -> dict[str, Table]: def test_part_04_06_tables_registered_in_metadata() -> None: - assert set(orm_tables()) == PART_01_03_TABLES | PART_04_06_TABLES - assert len(Base.metadata.sorted_tables) == EXPECTED_TABLE_COUNT + assert PART_01_03_TABLES | PART_04_06_TABLES <= set(orm_tables()) # полный состав и счёт — в тесте части 07–09 assert all(t.schema == "dkb" for t in Base.metadata.sorted_tables) @@ -78,7 +77,9 @@ def test_fk_cycle_is_broken_by_use_alter_without_warning() -> None: job_fk = next(f for f in orm_tables()["recognition_job"].foreign_keys if f.parent.name == "file_id") assert job_fk.constraint is not None assert job_fk.constraint.use_alter is False - names = [t.name for t in Base.metadata.sorted_tables] # pytest -W error: SAWarning о цикле стал бы ошибкой + with warnings.catch_warnings(): + warnings.simplefilter("error") # SAWarning о неразрешённом цикле зависимостей стал бы ошибкой + names = [t.name for t in Base.metadata.sorted_tables] assert names.index("stored_file") < names.index("recognition_job") @@ -90,7 +91,7 @@ def test_document_unique_index_is_nulls_not_distinct() -> None: @pytest.mark.integration async def test_create_all_and_behavior_on_scratch_db(scratch_db_url: str) -> None: - """`create_all` частей 01–06 (25 таблиц); NULLS NOT DISTINCT и Decimal работают на реальной PostgreSQL 16.""" + """`create_all`; таблицы частей 01–06 созданы; NULLS NOT DISTINCT и Decimal работают на реальной PostgreSQL 16.""" engine = create_async_engine(scratch_db_url) try: async with engine.begin() as conn: @@ -99,8 +100,7 @@ async def test_create_all_and_behavior_on_scratch_db(scratch_db_url: str) -> Non 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 | PART_04_06_TABLES - assert len(tables) == EXPECTED_TABLE_COUNT + assert PART_01_03_TABLES | PART_04_06_TABLES <= tables fk_names = { row[0] for row in await conn.execute( diff --git a/api/tests/test_models_part_07_09.py b/api/tests/test_models_part_07_09.py new file mode 100644 index 0000000..97cccaa --- /dev/null +++ b/api/tests/test_models_part_07_09.py @@ -0,0 +1,241 @@ +"""ORM частей 07–09 DBML v5.13 (консолидация, КФО, аудит/ЕХ/прочее) и ИТОГОВАЯ полнота всех 43 таблиц.""" + +import datetime + +import pytest +from sqlalchemy import inspect, text +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine + +from src.models import Base +from src.models.access import AppUser +from src.models.consolidation import Consolidation, ConsolidationLock, ConsolidationVersion +from src.models.directories import CompanyGroup, RegionalOffice +from src.models.enums import ( + ENUMS, + ConsolidationApproach, + ConsolidationScenario, + ConsolidationStatus, + PeriodQuarter, +) +from tests.dbml_helpers import load_dbml +from tests.dbml_parity import find_problems +from tests.test_models_part_01_03 import PART_01_03_TABLES, PG_IDENTIFIER_LIMIT +from tests.test_models_part_04_06 import PART_04_06_TABLES + +PART_07_09_TABLES = { + # consolidation + "consolidation", + "consolidation_version", + "consolidation_status_history", + "consolidation_lock", + "consolidation_member", + "consolidation_document", + # kfo + "correction", + "goodwill", + "kfo_result", + "kfo_result_line", + "kfo_result_line_company", + "client_kfo_line", + # audit_ex (+ required_document_rule по рулингу R-4) + "comment", + "audit_event", + "ex_snapshot", + "ex_snapshot_member", + "ex_snapshot_item", + "required_document_rule", +} +ALL_TABLES = PART_01_03_TABLES | PART_04_06_TABLES | PART_07_09_TABLES +EXPECTED_TABLE_COUNT = 43 +EXPECTED_ENUM_COUNT = 27 + + +def test_part_07_09_tables_are_new() -> None: + assert PART_07_09_TABLES.isdisjoint(PART_01_03_TABLES | PART_04_06_TABLES) + + +def test_orm_matches_dbml_part_07_09() -> None: + """Колонки (имя, порядок, тип, nullable, PK, unique, default), FK и индексы (вкл. partial WHERE) — как в DBML.""" + assert find_problems(Base.metadata, PART_07_09_TABLES) == [] + + +def test_all_constraint_names_fit_postgres_limit() -> None: + """Имена всех ограничений и индексов всех таблиц metadata заданы и не длиннее 63 символов.""" + for table in Base.metadata.sorted_tables: + for constraint in [*table.constraints, *table.indexes]: + assert constraint.name, f"{table.name}: безымянное ограничение" + assert len(str(constraint.name)) <= PG_IDENTIFIER_LIMIT, f"{table.name}: {constraint.name}" + + +def test_consolidation_lock_index_is_partial_unique_on_version() -> None: + table = Base.metadata.tables["dkb.consolidation_lock"] + index = next(i for i in table.indexes if i.name == "uq_consolidation_lock_version_active") + assert index.unique is True + assert [c.name for c in index.columns] == ["consolidation_version_id"] + assert str(index.dialect_options["postgresql"]["where"]) == "released_at IS NULL" + + +def test_composite_primary_keys() -> None: + tables = Base.metadata.tables + assert [c.name for c in tables["dkb.kfo_result_line_company"].primary_key] == [ + "kfo_result_line_id", + "legal_entity_id", + ] + assert [c.name for c in tables["dkb.ex_snapshot_member"].primary_key] == ["ex_snapshot_id", "legal_entity_id"] + + +def test_ex_snapshot_has_no_fk_to_consolidation() -> None: + """v5.12: срез ЕХ самодостаточен — ни одна таблица ex_snapshot* не ссылается на консолидацию/КФО.""" + consolidation_side = { + "consolidation", + "consolidation_version", + "consolidation_status_history", + "consolidation_lock", + "consolidation_member", + "consolidation_document", + "correction", + "goodwill", + "kfo_result", + "kfo_result_line", + "kfo_result_line_company", + "client_kfo_line", + } + for name in ("ex_snapshot", "ex_snapshot_member", "ex_snapshot_item"): + for fk in Base.metadata.tables[f"dkb.{name}"].foreign_keys: + assert fk.column.table.name not in consolidation_side, f"{name}.{fk.parent.name}" + assert not Base.metadata.tables["dkb.ex_snapshot"].c.source_version_id.foreign_keys + + +# ---------------------------------------------------------------- итоговая полнота моделей (43 таблицы) + + +def test_all_43_tables_registered_and_match_dbml_names() -> None: + """Состав metadata == таблицы DBML v5.13 (имена буквально), всего 43, все в схеме dkb.""" + tables = list(Base.metadata.sorted_tables) + assert len(tables) == EXPECTED_TABLE_COUNT + assert all(t.schema == "dkb" for t in tables) + dbml_names = {t.name for t in load_dbml().tables} + assert len(dbml_names) == EXPECTED_TABLE_COUNT + assert {t.name for t in tables} == dbml_names + assert ALL_TABLES == dbml_names # разбиение по частям покрывает модель без пропусков и дублей + + +def test_all_27_enums_defined() -> None: + assert len(ENUMS) == EXPECTED_ENUM_COUNT + assert set(ENUMS) == {e.name for e in load_dbml().enums} + + +def test_orm_matches_dbml_for_all_43_tables() -> None: + """Сквозная сверка всех 43 таблиц одним вызовом компаратора.""" + assert find_problems(Base.metadata, ALL_TABLES) == [] + + +# ---------------------------------------------------------------- интеграция: PostgreSQL 16 + + +@pytest.mark.integration +async def test_create_all_43_tables_on_scratch_db(scratch_db_url: str) -> None: + """`create_all` всех 43 таблиц; 27 enum-типов в dkb; partial unique `consolidation_lock` работает поведением.""" + 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 len(tables) == EXPECTED_TABLE_COUNT + assert tables == ALL_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 len(enums) == EXPECTED_ENUM_COUNT + assert enums == set(ENUMS) + indexdef = ( + await conn.execute( + text( + "SELECT indexdef FROM pg_indexes " + "WHERE schemaname = 'dkb' AND indexname = 'uq_consolidation_lock_version_active'" + ) + ) + ).scalar_one() + assert "UNIQUE" in indexdef + assert "WHERE (released_at IS NULL)" in indexdef + # у ex_snapshot.source_version_id нет FK; составные PK созданы + fk_columns = { + row[0] + for row in await conn.execute( + text( + "SELECT a.attname FROM pg_constraint c " + "JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey) " + "WHERE c.contype = 'f' AND c.conrelid = 'dkb.ex_snapshot'::regclass" + ) + ) + } + assert "source_version_id" not in fk_columns + pk = await conn.run_sync(lambda c: inspect(c).get_pk_constraint("ex_snapshot_member", schema="dkb")) + assert pk["constrained_columns"] == ["ex_snapshot_id", "legal_entity_id"] + + async with AsyncSession(engine, expire_on_commit=False) as session: + await _check_active_lock_is_unique(session) + finally: + await engine.dispose() + + +async def _check_active_lock_is_unique(session: AsyncSession) -> None: + """Два активных lock на одну версию -> IntegrityError; после `released_at` новый lock допустим.""" + now = datetime.datetime.now(datetime.UTC) + office = RegionalOffice(name="ГО") + session.add(office) + await session.flush() + user = AppUser(full_name="Аналитик", regional_office_id=office.id) + group = CompanyGroup(code="G1", name="ГК", valid_from=now) + session.add_all([user, group]) + await session.flush() + consolidation = Consolidation( + company_group_id=group.id, year=2025, quarter=PeriodQuarter.Q1, reporting_date=datetime.date(2025, 4, 1) + ) + session.add(consolidation) + await session.flush() + version = ConsolidationVersion( + consolidation_id=consolidation.id, + version_no=1, + status=ConsolidationStatus.INITIATED, + scenario=ConsolidationScenario.CREATE, + initiator_id=user.id, + initiator_office_id=office.id, + ) + session.add(version) + await session.flush() + await session.refresh(version) + assert version.approach == ConsolidationApproach.BASIC # server_default 'BASIC' + assert version.goodwill_enabled is False + assert version.is_in_work is True + version_id, user_id = version.id, user.id + await session.commit() + + def lock() -> ConsolidationLock: + return ConsolidationLock(consolidation_version_id=version_id, locked_by=user_id, locked_at=now) + + first = lock() + session.add(first) + await session.flush() + first_id = first.id + session.add(lock()) + with pytest.raises(IntegrityError): + await session.flush() # второй активный lock на ту же версию + await session.rollback() + + await session.execute( + text("UPDATE dkb.consolidation_lock SET released_at = :now WHERE id = :id"), {"now": now, "id": first_id} + ) + session.add(lock()) # после снятия первой блокировки — можно + await session.flush() + await session.rollback()