test(e2e): сквозной прогон загрузки ОСВ через compose с реальным tabular-parser

Тест tests/e2e (маркеры integration, e2e, fixtures): полное приложение src.app.app с lifespan на временной БД,
реальные NATS и MinIO, tabular-parser из профиля services; ОСВ 66 за 2024 -> PROCESSED, документ 66/2024/Q4,
9 строк, counterparty_check = MATCH, status = RECOGNIZED; без профиля — skip. Makefile: цель e2e, check-full
включает e2e, test-integration исключает e2e. README: раздел «Сквозной прогон (e2e)».

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 23:55:23 +04:00
parent ea2423dec9
commit f361fae79b
8 changed files with 225 additions and 9 deletions

View File

@ -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

View File

@ -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 обязана совпадать с ней один в один:

View File

25
api/tests/e2e/conftest.py Normal file
View File

@ -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")

View File

@ -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

View File

@ -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`.
---

View File

@ -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 |

View File

@ -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 допустим).