diff --git a/Makefile b/Makefile index 08ef79a..37a7da3 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ COMPOSE_FILE ?= ../docs/dev/docker-compose.yml # DBML относительно api/: и в основном workspace, и в worktree .wt/<срез>/ это соседний docs/ DBML ?= ../../docs/model/data-model-v5.15.dbml -.PHONY: venv lint test test-integration dbml-check migration-cycle check check-infra docker-build web-check check-full up migrate seed seed-dev +.PHONY: venv lint test test-integration e2e dbml-check migration-cycle check check-infra docker-build web-check check-full up migrate seed seed-dev # Python 3.11 (версия платформы); внутренние пакеты raisa_* вне банка недоступны — пропускаем их venv: @@ -21,8 +21,14 @@ lint: test: cd api && $(BIN)/pytest -m "not integration" +# e2e (профиль services, реальный tabular-parser) идёт отдельной целью `e2e`, поэтому здесь исключён test-integration: - cd api && $(BIN)/pytest -m integration + cd api && $(BIN)/pytest -m "integration and not e2e" + +# Сквозной прогон загрузки ОСВ (Срез 2c): нужны compose-инфраструктура и сервис tabular-parser из профиля `services` +# (README, «Сквозной прогон (e2e)»); без профиля тест пропускается с причиной «профиль services не поднят», код 0 +e2e: + cd api && $(BIN)/pytest -m e2e -rs # Сверка ORM с DBML (spec data-model): пустой вывод и код 0 — совпадение dbml-check: @@ -52,8 +58,9 @@ web-check: # Полная проверка перед PR. Порядок: быстрые без БД (check, dbml-check), затем БД/NATS из compose (test-integration), # затем docker, затем web. Цикл миграций upgrade -> downgrade -> upgrade на чистой БД входит в test-integration -# (tests/test_migrations.py), поэтому отдельным шагом не гоняется: второй запуск только удлинил бы прогон -check-full: check dbml-check check-infra test-integration docker-build web-check +# (tests/test_migrations.py), поэтому отдельным шагом не гоняется: второй запуск только удлинил бы прогон. e2e — после +# test-integration; без профиля `services` пропускается (не падает) +check-full: check dbml-check check-infra test-integration e2e docker-build web-check up: docker compose -f $(COMPOSE_FILE) up -d diff --git a/README.md b/README.md index 62bb0e5..660d3cb 100644 --- a/README.md +++ b/README.md @@ -499,14 +499,15 @@ email без учёта регистра проверяет только сер | `make seed-dev` | то же и dev-пользователи/филиалы (`--dev`) — только для разработки, требует `DEBUG=true` | | `make lint` | `ruff check`, `ruff format --check`, `mypy src scripts` | | `make test` | юнит-тесты: `pytest -m "not integration"`, Docker не нужен | -| `make test-integration` | интеграционные тесты: `pytest -m integration`, нужны PostgreSQL (и NATS) | +| `make test-integration` | интеграционные тесты: `pytest -m "integration and not e2e"`, нужны PostgreSQL (и NATS) | +| `make e2e` | сквозной прогон загрузки ОСВ: `pytest -m e2e`, нужны compose и tabular-parser из профиля `services` (без него — skip) | | `make dbml-check` | сверка ORM с `../docs/model/data-model-v5.15.dbml` | | `make migration-cycle` | миграции на временной БД: `upgrade head` → `downgrade base` → `upgrade head`, пустой autogenerate | | `make check` | быстрая проверка без Docker: `lint` + `test` | | `make docker-build` | сборка образа: `docker build -t arm-core .` | | `make web-check` | фронт: `npm install`, `biome check src`, `tsc --noEmit`, `npm run build` | | `make check-infra` | быстрая проверка, что PostgreSQL и NATS из compose доступны (иначе: `make up`) | -| `make check-full` | полная проверка перед PR: `check` + `dbml-check` + `check-infra` + `test-integration` (в нём цикл миграций) + `docker-build` + `web-check`; нужна поднятая инфраструктура (`make up`) и Docker | +| `make check-full` | полная проверка перед PR: `check` + `dbml-check` + `check-infra` + `test-integration` (в нём цикл миграций) + `e2e` (без профиля `services` пропускается) + `docker-build` + `web-check`; нужна поднятая инфраструктура (`make up`) и Docker | ### Тесты @@ -521,6 +522,28 @@ email без учёта регистра проверяет только сер - **Реальные данные** — репозиторий `../fixtures/` (путь можно задать переменной `ARM_KA_FIXTURES`). Тесты с маркером `fixtures` без каталога пропускаются с понятной причиной. +### Сквозной прогон (e2e) + +Тест `api/tests/e2e/test_osv_ingest_e2e.py` (маркеры `integration`, `e2e`, `fixtures`) проверяет весь путь Среза 2c с +РЕАЛЬНЫМ tabular-parser: `POST /legal-entities/{id}/files` (ОСВ 66 за 2024 из `../fixtures`) → outbox → NATS → +tabular-parser (MinIO) → inbox → маппинг → `GET /files/{id}` = `PROCESSED`, документ 66/2024/Q4 (нарастающий, +`RECOGNIZED`, `counterparty_check = MATCH`) и 9 строк ОСВ. Приложение — настоящий `src.app.app` с lifespan на +отдельной временной БД с seed, NATS и MinIO — из `api/.env.test`; в конце файл удаляется через `DELETE /files/{id}` +(объекту S3 ставится `expires_at`), БД снимается. + +Предусловие — сервис tabular-parser из профиля `services` на `localhost:8001`. Без него тест пропускается +(`профиль services не поднят`), `make check-full` при этом зелёный. Инфраструктура уже поднята (`make up`); поднимайте +только парсер, без зависимостей (`--no-deps`), чтобы не пересоздавать PostgreSQL/NATS/MinIO: + +```bash +docker compose -f ../docs/dev/docker-compose.yml -p arm-ka --profile services up -d --build --no-deps tabular-parser +curl -s localhost:8001/healthcheck # {"uptime": "...", "version": "...", "nats": "ok"} +make e2e # PASSED +docker compose -f ../docs/dev/docker-compose.yml -p arm-ka --profile services stop tabular-parser +``` + +Образ собирается из `../tabular-parser` (main соседнего репозитория workspace). + ### Проверка ORM и DBML Модель данных — `../docs/model/data-model-v5.15.dbml`. ORM обязана совпадать с ней один в один: diff --git a/api/tests/e2e/__init__.py b/api/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/tests/e2e/conftest.py b/api/tests/e2e/conftest.py new file mode 100644 index 0000000..b127cb5 --- /dev/null +++ b/api/tests/e2e/conftest.py @@ -0,0 +1,25 @@ +"""Предусловия сквозных тестов (Срез 2c, tasks 5.1): сервис tabular-parser из профиля `services` compose. + +Без него e2e не имеет смысла (результат распознавания даёт реальный парсер): тест пропускается с причиной, а +`make check-full` без профиля остаётся зелёным. NATS, MinIO и PostgreSQL берутся из `.env.test` (инфраструктура +compose `make up`); профиль поднимается командой из README («Сквозной прогон (e2e)»). +""" + +import httpx +import pytest + +TABULAR_PARSER_HEALTHCHECK = "http://localhost:8001/healthcheck" +HEALTHCHECK_TIMEOUT = 2.0 +SKIP_REASON = "профиль services не поднят" + + +@pytest.fixture(scope="session") +def tabular_parser_ready() -> None: + """`GET localhost:8001/healthcheck` отвечает 200 и `nats=ok`, иначе — skip «профиль services не поднят».""" + try: + response = httpx.get(TABULAR_PARSER_HEALTHCHECK, timeout=HEALTHCHECK_TIMEOUT) + ready = response.status_code == httpx.codes.OK and response.json().get("nats") == "ok" + except (httpx.HTTPError, ValueError): + ready = False + if not ready: + pytest.skip(f"{SKIP_REASON}: {TABULAR_PARSER_HEALTHCHECK} недоступен или без подключения к NATS") diff --git a/api/tests/e2e/test_osv_ingest_e2e.py b/api/tests/e2e/test_osv_ingest_e2e.py new file mode 100644 index 0000000..62d7e78 --- /dev/null +++ b/api/tests/e2e/test_osv_ingest_e2e.py @@ -0,0 +1,158 @@ +"""Сквозной прогон загрузки ОСВ (Срез 2c, tasks 5.1; design D-13) через compose с РЕАЛЬНЫМ tabular-parser. + +Полное приложение `src.app.app` с настоящим lifespan на отдельной временной БД (миграции + seed), реальные NATS и MinIO +из `.env.test`, `NATS_ENABLED=true`. Путь: `POST /legal-entities/{id}/files` → outbox-публикатор → NATS → +tabular-parser (читает файл из MinIO, пишет результат в MinIO) → inbox (ack, результат) → цикл маппинга → файл +`PROCESSED`, документ и строки ОСВ. Предусловие — профиль `services` (`tests/e2e/conftest.py`); иначе тест пропускается. +Очистка: `DELETE /files/{id}` через API и удаление временной БД (фикстура `committed_sessionmaker`). +""" + +import asyncio +import importlib +import time +import uuid +from collections.abc import AsyncIterator +from typing import Any + +import httpx +import pytest +import pytest_asyncio +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker + +from src.core.nats import NatsConnection +from src.models.directories import RegionalOffice +from tests.factories import make_company_group, make_legal_entity + +pytestmark = [pytest.mark.integration, pytest.mark.e2e, pytest.mark.fixtures] + +# src/__init__.py реэкспортирует объект app под именем модуля, поэтому модуль берём через importlib +app_module = importlib.import_module("src.app") +db_module = importlib.import_module("src.db.db") + +KA_RF = "ka_rf@armka.local" # Ростовский филиал, права DOCUMENT_EDIT/DOCUMENT_VIEW +ROSTOV = "Ростовский региональный филиал" +OWNER_INN = "3528052718" +# Как в шапке ОСВ (fixtures/golden/expected_osv_parse/rb/2024-FY/ОСВ_66 за 2024.json): ИНН в шапке нет, поэтому +# counterparty_check = MATCH только по имени владельца файла (D-8) +OWNER_NAME = 'АО "Русский бисквит"' +REAL_OSV_PATH = ("osv", "rb", "2024-FY", "ОСВ_66 за 2024.xls") + +POLL_TIMEOUT = 60.0 +POLL_INTERVAL = 0.5 +LOOP_POLL_SECONDS = 1 # публикатор и цикл маппинга не ждут штатные 5 с между проходами +NATS_READY_TIMEOUT = 10.0 +ENTRY_ROWS = 9 # 10 строк снимка минус TOTAL (D-3: итоговая строка не хранится) +HTTP_CREATED = 201 +HTTP_NO_CONTENT = 204 +HTTP_NOT_FOUND = 404 + + +class _EngineStub: + """Подмена движка БД приложения: lifespan вызывает `dispose()` последним шагом остановки.""" + + async def dispose(self) -> None: + return None + + +@pytest_asyncio.fixture +async def running_app( + committed_sessionmaker: async_sessionmaker[AsyncSession], monkeypatch: pytest.MonkeyPatch +) -> AsyncIterator[Any]: + """Настоящий `src.app.app` внутри lifespan: реальные NATS и MinIO, БД — временная (с seed).""" + monkeypatch.setattr(app_module.settings, "NATS_ENABLED", True) + monkeypatch.setattr(app_module.settings, "OUTBOX_POLL_SECONDS", LOOP_POLL_SECONDS) + monkeypatch.setattr(app_module.settings, "MAPPING_POLL_SECONDS", LOOP_POLL_SECONDS) + monkeypatch.setattr(app_module.settings.OPENBAO.SMB, "SMB_HOST", "") + # фабрика сессий приложения: lifespan/inbox/циклы берут её из app_module, маршруты и аудит входа — из db_module + monkeypatch.setattr(app_module, "async_session_maker", committed_sessionmaker) + monkeypatch.setattr(db_module, "async_session_maker", committed_sessionmaker) + monkeypatch.setattr(app_module, "engine", _EngineStub()) + + async with app_module.lifespan(app_module.app): + application = app_module.app + assert application.state.storage is not None, "MinIO недоступен (.env.test): S3 не подключён" + nats: NatsConnection = application.state.nats + if not await nats.wait_ready(timeout=NATS_READY_TIMEOUT): + pytest.fail("NATS недоступен (.env.test)", pytrace=False) + yield application + + +async def _client(application: Any, email: str) -> httpx.AsyncClient: + transport = httpx.ASGITransport(app=application) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as anonymous: + response = await anonymous.post("/api/v1/dev/token", json={"email": email}) + assert response.status_code == httpx.codes.OK, response.text + return httpx.AsyncClient( + transport=transport, + base_url="http://test/api/v1", + headers={"Authorization": f"Bearer {response.json()['access_token']}"}, + ) + + +async def _wait_processed(client: httpx.AsyncClient, file_id: uuid.UUID) -> dict[str, Any]: + """Опрос карточки файла до `PROCESSED` (до 60 с); `ERROR` — немедленный отказ с причиной из карточки.""" + deadline = time.monotonic() + POLL_TIMEOUT + card: dict[str, Any] = {} + while time.monotonic() < deadline: + response = await client.get(f"/files/{file_id}") + assert response.status_code == httpx.codes.OK, response.text + card = response.json() + if card["status"] == "PROCESSED": + return card + assert card["status"] != "ERROR", f"файл в ERROR: {card}" + await asyncio.sleep(POLL_INTERVAL) + pytest.fail(f"за {POLL_TIMEOUT} с файл не стал PROCESSED; последняя карточка: {card}", pytrace=False) + + +async def test_real_osv_is_parsed_end_to_end( + tabular_parser_ready: None, # noqa: ARG001 - предусловие: профиль services (skip, если не поднят) + fixtures_dir: Any, + running_app: Any, + committed_sessionmaker: async_sessionmaker[AsyncSession], +) -> None: + content = fixtures_dir.joinpath(*REAL_OSV_PATH).read_bytes() + async with committed_sessionmaker() as session, session.begin(): + office = (await session.execute(select(RegionalOffice).where(RegionalOffice.name == ROSTOV))).scalar_one() + group = await make_company_group(session, name="ГК Русский бисквит", office=office) + entity = await make_legal_entity(session, name=OWNER_NAME, inn=OWNER_INN, group=group) + + async with await _client(running_app, KA_RF) as client: + response = await client.post( + f"/legal-entities/{entity.id}/files", + files=[("files", (REAL_OSV_PATH[-1], content, "application/octet-stream"))], + ) + assert response.status_code == HTTP_CREATED, response.text + body = response.json() + assert body["rejected"] == [] + (accepted,) = body["accepted"] + file_id = uuid.UUID(accepted["file_id"]) + try: + card = await _wait_processed(client, file_id) + + assert card["job"]["status"] == "done" + assert card["job"]["mapping_error"] is None + (brief,) = card["documents"] + assert (brief["document_type"], brief["osv_account_code"]) == ("OSV", "66") + assert (brief["period_year"], brief["period_quarter"], brief["is_cumulative"]) == (2024, "Q4", True) + assert brief["counterparty_check"] == "MATCH" + assert brief["status"] == "RECOGNIZED" + + document = (await client.get(f"/documents/{brief['id']}")).json() + assert document["file_id"] == str(file_id) + assert document["legal_entity_id"] == str(entity.id) + assert (document["period_year"], document["period_quarter"]) == (2024, "Q4") + assert (document["counterparty_check"], document["status"]) == ("MATCH", "RECOGNIZED") + + entries = (await client.get(f"/documents/{brief['id']}/entries")).json() + assert entries["total"] == ENTRY_ROWS + assert [row["row_number"] for row in entries["items"]] == sorted( + row["row_number"] for row in entries["items"] + ) + root = entries["items"][0] + assert (root["account_code"], root["level"]) == ("66", 0) + finally: + # очистка: PROCESSED без подтверждения удаляется (204); временная БД снимается фикстурой + deleted = await client.delete(f"/files/{file_id}") + assert deleted.status_code == HTTP_NO_CONTENT, deleted.text + assert (await client.get(f"/files/{file_id}")).status_code == HTTP_NOT_FOUND diff --git a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md index 0bd0873..fca25ee 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md +++ b/docs/superpowers/plans/2026-10-05-slice-2c-osv-ingest.md @@ -305,8 +305,8 @@ async def delete(self, file_id) -> None # get_file(scope) → 404; есть do **Interfaces:** тест (маркеры `integration`, `e2e`, `fixtures`): предусловия — `curl localhost:8001/healthcheck` tabular-parser из профиля `services` (`docker compose -f ../docs/dev/docker-compose.yml --profile services up -d --build tabular-parser`) и `NATS_ENABLED=true` у тестового приложения; иначе `pytest.skip("профиль services не поднят")`. Шаги: полное приложение `src.app.app` (lifespan с реальными NATS/MinIO из `.env.test`, `NATS_ENABLED=true` через monkeypatch settings) на отдельной БД (`committed_sessionmaker` + seed) → `POST /legal-entities/{id}/files` для ЮЛ, созданного фабрикой с ИНН `3528052718` (АО «Русский бисквит», владелец ОСВ 66) с `rb/2024-FY/ОСВ_66 за 2024.xls` → ждать до 60 с `GET /files/{id}` → `PROCESSED` → документ 66/2024/Q4, 9 строк (10 строк снимка минус TOTAL), `counterparty_check = MATCH`, `status = RECOGNIZED`; очистка: удалить файл через API. -- [ ] **Step 1:** тест; **Step 2:** поднять профиль (`make up` + `--profile services` для `tabular-parser`), прогнать `make e2e` → PASS; без профиля → skip; `make check-full` зелёный. -- [ ] **Step 3: Commit** `test(e2e): сквозной прогон загрузки ОСВ через compose с реальным tabular-parser`. +- [x] **Step 1:** тест; **Step 2:** поднять профиль (`make up` + `--profile services` для `tabular-parser`), прогнать `make e2e` → PASS; без профиля → skip; `make check-full` зелёный. +- [x] **Step 3: Commit** `test(e2e): сквозной прогон загрузки ОСВ через compose с реальным tabular-parser`. --- diff --git a/openspec/changes/slice-2c-osv-ingest/design.md b/openspec/changes/slice-2c-osv-ingest/design.md index 7c964e9..5d776f0 100644 --- a/openspec/changes/slice-2c-osv-ingest/design.md +++ b/openspec/changes/slice-2c-osv-ingest/design.md @@ -194,3 +194,6 @@ rows без TOTAL, у каждого не-корня есть родитель, | 34 | 2026-10-05 | R-21 (Task 9): `confirm` берёт блокировки «файл → документ» и перечитывает оба под `FOR UPDATE`; чтение файлов — методы `PrimaryFilesService`, документов — `DocumentService`; задание для `DocumentOut` — текущее задание файла; список файлов `ORDER BY uploaded_at DESC NULLS LAST, id`; у моделей нет `relationship()`, поэтому job/документы грузятся пачками (число запросов не зависит от числа файлов, проверено тестом) | согласовано с порядком блокировок автозамены; Review Focus 5 | переупорядочить блокировки | | 35 | 2026-10-05 | R-22 (Task 9): `test_openapi_routes` отвергает лишние маршруты под `/legal-entities`, `/files`, `/documents` (Task 10 обязан добавить DELETE в `EXPECTED_ROUTES`) | новый маршрут сначала должен попасть в спеку | ослабить тест | | 36 | 2026-10-05 | R-23 (Task 10): удаление файла блокирует все его задания (по id) → файл → документы, 409 проверяются после блокировок; файл исчез за время ожидания → 404; `expires_at = now` безусловно у `s3_object` файла и объектов результатов (объекты не разделяются между файлами); FK на `document` из `financial_statement_meta`/`consolidation_document`/`ex_snapshot_item` не чистятся (в Срезе 2c недостижимы; `CONFIRMED`-запрет закрывает консолидации; ББ/ОФР — очищать `financial_statement_meta` в Срезе 3); `FILE_HAS_CONFIRMED_DOCUMENTS` проверяется раньше `FILE_SUPERSEDES_OTHERS` | порядок блокировок задание → файл общий для обмена/маппинга | IntegrityError 500 в Срезе 3 без очистки | +| 37 | 2026-10-05 | Task 11: `make test-integration` = `pytest -m "integration and not e2e"`, e2e идёт целью `make e2e` и отдельным шагом `check-full` после `test-integration` | e2e несёт маркер `integration` (требование брифа); без исключения он запускался бы в `check-full` дважды | двойной прогон e2e при поднятом профиле (секунды); без профиля — два skip | +| 38 | 2026-10-05 | Task 11: профиль `services` для парсера поднимается как `-p arm-ka … up -d --build --no-deps tabular-parser` (README); предусловие теста — `GET localhost:8001/healthcheck` = 200 и `nats=ok`, иначе skip «профиль services не поднят» | проект `arm-ka` поднят из другого файла compose (worktree среза 1): без `--no-deps` compose мог пересоздать postgres/nats/minio при расхождении конфигурации | пересоздание инфраструктуры и потеря dev-данных | +| 39 | 2026-10-05 | Task 11: e2e подменяет `async_session_maker` в `src.app` и `src.db.db` и `engine` в `src.app` (как `test_exchange_lifespan_nats`), `MAPPING_POLL_SECONDS`/`OUTBOX_POLL_SECONDS` = 1; ЮЛ создаётся с именем `АО "Русский бисквит"`, ИНН `3528052718` — в шапке ОСВ ИНН нет, MATCH честный по имени (D-8) | нужна временная БД вместо рабочей и быстрый прогон; шапка снимка: `organization_name = АО "Русский бисквит"`, `organization_inn = null` | e2e читает/пишет не ту БД (рабочая `armka`) либо ложный MISMATCH | diff --git a/openspec/changes/slice-2c-osv-ingest/tasks.md b/openspec/changes/slice-2c-osv-ingest/tasks.md index 02eaae3..1b77029 100644 --- a/openspec/changes/slice-2c-osv-ingest/tasks.md +++ b/openspec/changes/slice-2c-osv-ingest/tasks.md @@ -58,7 +58,7 @@ ## 5. Сквозной прогон и документы -- [ ] 5.1 **E2E через compose.** `tests/e2e/test_osv_ingest_e2e.py` (маркеры `integration`, `e2e`): при поднятом профиле +- [x] 5.1 **E2E через compose.** `tests/e2e/test_osv_ingest_e2e.py` (маркеры `integration`, `e2e`): при поднятом профиле `services` (реальный `tabular-parser` против общего NATS/MinIO) — загрузка `rb/2024-FY/ОСВ_66 за 2024.xls` через API ядра, ожидание `PROCESSED` (≤ 60 с), проверка документа и 9 строк (10 строк снимка минус TOTAL); без профиля — skip с причиной. `make e2e` цель; `check-full` его включает (skip допустим).