diff --git a/api/tests/test_import_full_export.py b/api/tests/test_import_full_export.py new file mode 100644 index 0000000..1cd3c44 --- /dev/null +++ b/api/tests/test_import_full_export.py @@ -0,0 +1,104 @@ +"""Полная выгрузка ЕЦП (8400 строк): `dry_run` укладывается в 60 с (spec directory-import, сценарий «Полная выгрузка»). + +Тест идёт на отдельной временной БД с зафиксированным seed (`committed_client`): `dry_run` делает явный +`session.rollback()`, поэтому «таблицы не выросли» осмысленно только вне SAVEPOINT-обёртки `db_session` (см. docstring +`tests/conftest.py`). Файл выгрузки — из `ARM_KA_DOCS` либо соседнего `docs`; нет файла — пропуск с причиной. +""" + +import os +import time +from collections import Counter +from pathlib import Path +from typing import Any + +import pytest +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from src.models.audit_ex import AuditEvent +from src.models.directories import ( + CompanyGroup, + CompanyGroupHistory, + LegalEntity, + LegalEntityHistory, + RegionalOffice, + RelatedGroup, + RelatedGroupMember, +) +from src.models.files import S3Object +from tests.conftest import AuthClientFactory, FakeStorage + +ADMIN = "admin@armka.local" +EXPORT_RELATIVE = Path("source/biq/ecp-export-lagal-entity.xlsx") +XLSX_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" +HTTP_OK = 200 +TIME_LIMIT_SECONDS = 60 +EXPORT_ROWS = 8400 +EXPORT_OFFICES = 53 +EXPORT_GROUPS = 344 # R-15: из 345 ГК файла одна целиком состоит из строк-дублей и не создаётся +MIN_UNSUPPORTED_TYPE_ROWS = 500 +TOUCHED_TABLES = ( + RegionalOffice, + CompanyGroup, + CompanyGroupHistory, + LegalEntity, + LegalEntityHistory, + RelatedGroup, + RelatedGroupMember, + AuditEvent, + S3Object, +) + + +def _export_path() -> Path: + docs = os.environ.get("ARM_KA_DOCS") + return (Path(docs) if docs else Path(__file__).resolve().parents[3] / "docs") / EXPORT_RELATIVE + + +async def _counts(session: AsyncSession) -> dict[str, int]: + return { + model.__tablename__: (await session.execute(select(func.count()).select_from(model))).scalar_one() + for model in TOUCHED_TABLES + } + + +@pytest.mark.integration +@pytest.mark.fixtures +async def test_full_ecp_export_dry_run_within_time_limit( + committed_client: AuthClientFactory, + committed_sessionmaker: async_sessionmaker[AsyncSession], + committed_storage: FakeStorage, +) -> None: + """`dry_run` выгрузки из 8400 строк: ≤ 60 с, ожидаемые счётчики и коды, ни одна таблица справочника не выросла.""" + path = _export_path() + if not path.is_file(): + pytest.skip(f"нет выгрузки ЕЦП: {path} (задайте ARM_KA_DOCS или положите docs рядом с arm-core)") + content = path.read_bytes() + client = await committed_client(ADMIN) + async with committed_sessionmaker() as session: + before = await _counts(session) + + started = time.perf_counter() + response = await client.post( + "/directories/import", + files={"file": (path.name, content, XLSX_TYPE)}, + data={"dry_run": "true", "on_existing": "skip"}, + ) + elapsed = time.perf_counter() - started + + assert response.status_code == HTTP_OK, response.text + assert elapsed <= TIME_LIMIT_SECONDS, f"dry_run занял {elapsed:.1f} с (лимит {TIME_LIMIT_SECONDS} с)" + print(f"\nfull export dry_run: {elapsed:.2f} s") # noqa: T201 - замер времени виден при `pytest -s` + report: dict[str, Any] = response.json() + assert report["rows"] == EXPORT_ROWS + assert report["offices_created"] == EXPORT_OFFICES + assert report["groups_created"] == EXPORT_GROUPS + codes = Counter(line["code"] for line in report["lines"]) + assert codes["RF_CREATED"] == EXPORT_OFFICES + assert codes["UNSUPPORTED_TYPE"] >= MIN_UNSUPPORTED_TYPE_ROWS + assert codes["INVALID_INN"] > 0 + assert codes["DUPLICATE_IN_FILE"] > 0 + + async with committed_sessionmaker() as session: + assert await _counts(session) == before + assert committed_storage.objects == {} diff --git a/docs/superpowers/plans/2026-10-04-slice-1-directories-phase2.md b/docs/superpowers/plans/2026-10-04-slice-1-directories-phase2.md index d44778a..eded6e5 100644 --- a/docs/superpowers/plans/2026-10-04-slice-1-directories-phase2.md +++ b/docs/superpowers/plans/2026-10-04-slice-1-directories-phase2.md @@ -172,9 +172,9 @@ async def run(self, *, filename: str, content: bytes, dry_run: bool, on_existing - Create: `api/tests/test_import_full_export.py` - Modify (при необходимости): `api/src/services/directory_import.py` (пакетные вставки `insert(...).values([...])`, если > 60 с) -- [ ] **Step 1: Тест** `@pytest.mark.integration @pytest.mark.fixtures`: путь `Path(os.environ.get("ARM_KA_DOCS") or Path(__file__).resolve().parents[3] / "docs") / "source/biq/ecp-export-lagal-entity.xlsx"`; нет файла → `pytest.skip("нет выгрузки ЕЦП: …")`. `dry_run=true` под `admin@armka.local`; `time.perf_counter()` ≤ 60 с; `rows == 8400`; `offices_created == 53`; среди кодов есть `UNSUPPORTED_TYPE` (≥ 500 строк), `INVALID_INN`, `DUPLICATE_IN_FILE` (`LE_IN_OTHER_GROUP` на пустой БД невозможен — не проверять); `groups_created == 344` (R-15); таблицы после теста не выросли. -- [ ] **Step 2:** запустить; при превышении 60 с — профилировать (`--durations`), перевести вставки на `session.execute(insert(Model), [dict, ...])` пакетами по 1000, повторить. -- [ ] **Step 3: Commit** `test(import): dry_run полной выгрузки ЕЦП (8400 строк) укладывается в 60 с`. +- [x] **Step 1: Тест** `@pytest.mark.integration @pytest.mark.fixtures`: путь `Path(os.environ.get("ARM_KA_DOCS") or Path(__file__).resolve().parents[3] / "docs") / "source/biq/ecp-export-lagal-entity.xlsx"`; нет файла → `pytest.skip("нет выгрузки ЕЦП: …")`. `dry_run=true` под `admin@armka.local`; `time.perf_counter()` ≤ 60 с; `rows == 8400`; `offices_created == 53`; среди кодов есть `UNSUPPORTED_TYPE` (≥ 500 строк), `INVALID_INN`, `DUPLICATE_IN_FILE` (`LE_IN_OTHER_GROUP` на пустой БД невозможен — не проверять); `groups_created == 344` (R-15); таблицы после теста не выросли. +- [x] **Step 2:** запустить; при превышении 60 с — профилировать (`--durations`), перевести вставки на `session.execute(insert(Model), [dict, ...])` пакетами по 1000, повторить. +- [x] **Step 3: Commit** `test(import): dry_run полной выгрузки ЕЦП (8400 строк) укладывается в 60 с`. --- diff --git a/openspec/changes/slice-1-directories/tasks.md b/openspec/changes/slice-1-directories/tasks.md index 6fdfe28..c7afc76 100644 --- a/openspec/changes/slice-1-directories/tasks.md +++ b/openspec/changes/slice-1-directories/tasks.md @@ -57,7 +57,7 @@ test-integration`). Пути — от корня репозитория. Доп + `s3_object`), `POST /api/v1/directories/import` (multipart, `IMPORT_MAX_BYTES` = 20 МБ → 413, `DIRECTORY_ADMIN`), `schemas/directory_import.py`. Интеграционные тесты: сценарии спеки `directory-import` (срез РБ, идемпотентность, `update`, `LE_IN_OTHER_GROUP`, dry_run без следов, `MISSING_COLUMNS`); S3 — через `app.state.storage` с заглушкой в тестах. -- [ ] 3.3 **Полная выгрузка (fixtures-тест).** Тест с маркером `fixtures`: `ARM_KA_DOCS/source/biq/ecp-export-lagal-entity.xlsx`, +- [x] 3.3 **Полная выгрузка (fixtures-тест).** Тест с маркером `fixtures`: `ARM_KA_DOCS/source/biq/ecp-export-lagal-entity.xlsx`, `dry_run`, `rows = 8400`, `RF_CREATED = 53`, `groups_created = 344`, присутствие кодов, время ≤ 60 с; пропуск с причиной, если файла нет. Профилирование: при превышении — пакетные вставки (`insert().values([...])`).