- C-1 (№30): сумма с более чем двумя знаками после запятой не принимается (amount=null, string_value, is_recognized=false); confidence ячеек и шапки приводится к [0, 100], NaN и бесконечность дают 0 - F-A (№28): ошибочный файл набора при успешном понижает статус до needs_check - F-B: «ИНН не распознан» только когда ИНН не извлечён, иначе «ИНН распознан неуверенно» - №29: предупреждения «строк с нечисловым кодом пропущено: N» и «строк без кода с суммами пропущено: N» - I-3 (№31): тексты дат длиннее 200 символов не разбираются и не проходят fix_lookalikes - T1.3 (№33): загрузчик yaml отклоняет повторяющиеся ключи; тавтологичный тест дублей заменён настоящим - гигиена: докстринг amounts.py описывает фактическое поведение (все точки удаляются), мёртвые noqa в ocr/__init__.py Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
654 lines
40 KiB
Python
654 lines
40 KiB
Python
"""Сквозной сценарий scan-gateway против compose-инфраструктуры (задача 6.1, R-7, R-10; Срез 3a).
|
||
|
||
Настоящий lifespan приложения (`create_app` → S3, NATS, воркер, `OcrNormalizer` со справочниками `api/rules/`) и
|
||
эмулятор ИС Сканирование (`src.scan_emulator.run` в потоке, `SCAN_EMULATOR_DEFAULT` пуст) работают в процессе
|
||
теста; NATS 127.0.0.1:4222 и MinIO 127.0.0.1:9000 — из `api/.env.test` (`make up`), горячая папка — tmp-каталог.
|
||
Входящий файл — `<job_id>.pdf` (расширение `original_name` в нижнем регистре, `.part` живёт в `Tmp`), пару фикстур
|
||
эмулятор находит по sha256 содержимого: в его каталоге фикстур лежат PDF и ответы (реальные `aep_*` из
|
||
`fixtures/ocr` и синтетический `ok_doc`).
|
||
|
||
Сценарии (реальные фикстуры `fixtures/ocr`: OQ-76/77/78; эмулятор кладёт в `Output/<job_id>/` весь набор ответа):
|
||
1. `aep_5.PDF` (ответ-ошибка `INVALID_DOCUMENT`) → ack `processing`, затем `INVALID_DOCUMENT` с сообщением ИС и
|
||
`raw_s3_key`; повтор запроса во время ожидания ответа ИС и после результата — ещё один ack, второго файла нет;
|
||
2. `aep_3.pdf` (набор из двух файлов: `<job_id>.json` — успех, `<job_id>_ofr.json` — `INVALID_DOCUMENT`) → ack, затем
|
||
`needs_check` с документами (баланс и ОФР) в файле результата и `metrics.warnings`, в том числе про `_ofr.json`;
|
||
`raw/{job_id}.json` — оба файла (склейка, D-4);
|
||
3. `aep_6.pdf` (один успешный файл) → `needs_check`, `result_s3` с двумя документами (`BALANCE`, `INCOME`);
|
||
4. `ok_doc.pdf` (синтетический успешный ответ в форме реальных) → `needs_check` (редакция `fsbu4` пуста) с `result_s3`;
|
||
5. `unknown.pdf` (содержимого нет в фикстурах) → ack, затем `INVALID_DOCUMENT` эмулятора;
|
||
6. `aep_6.pdf` при `SCAN_CLEANUP=true` → после результата `Input/` и `Output/<job_id>/` пусты, `Tmp/` без `.part`.
|
||
Каждый конверт проверяется схемой `recognition-result-message`, `service.name` и `Nats-Msg-Id`; файл результата
|
||
из MinIO — схемой `recognition-result`; склеенный сырой ответ в MinIO содержит ровно те файлы, что лежат в каталоге
|
||
ответа, в порядке возрастания имени, с телами байт в байт; входящий файл совпадает с объектом S3. Готовность набора
|
||
— по settle (`SETTLE_SECONDS`, 0.2 с).
|
||
|
||
Общая инфраструктура: NATS и MinIO используют и другие сервисы workspace. Чужие конверты на `recognition.result`
|
||
отсекаются по `job_id` теста; ключи MinIO уникальны (`uploads/itest-<uuid>/…`, `raw/<job_id>.json`, `results/…`) и
|
||
удаляются.
|
||
Подписка шлюза идёт в queue group `scan-gateway`: если в этом NATS живёт другой экземпляр scan-gateway, он может
|
||
забрать запрос теста (тест тогда упадёт по таймауту ожидания ack). Перед запуском занятость группы можно проверить
|
||
мониторингом NATS: `curl -s 'http://127.0.0.1:8222/subsz?subs=1'` — подписчиков `recognition.image.request` быть
|
||
не должно. Автоматической защиты в тесте нет намеренно (она была бы гонкой). Недоступная инфраструктура — падение
|
||
теста с понятным сообщением, не skip; нет репозитория fixtures — skip сценариев на реальных данных (маркер `fixtures`).
|
||
"""
|
||
|
||
import asyncio
|
||
import hashlib
|
||
import json
|
||
import shutil
|
||
import socket
|
||
import threading
|
||
import uuid
|
||
from collections.abc import AsyncIterator
|
||
from contextlib import AsyncExitStack, suppress
|
||
from dataclasses import dataclass, field
|
||
from datetime import UTC, datetime, timedelta
|
||
from pathlib import Path
|
||
from typing import Any
|
||
from urllib.parse import urlparse
|
||
|
||
import httpx
|
||
import pytest
|
||
from fastapi import FastAPI
|
||
from jsonschema import Draft202012Validator, FormatChecker
|
||
from miniopy_async.error import S3Error
|
||
from nats.aio.client import Client as NATS
|
||
from nats.aio.msg import Msg
|
||
|
||
from src.app import create_app
|
||
from src.contract import CONTRACT_VERSION, IMAGE_REQUEST_SUBJECT, NATS_MSG_ID_HEADER, RESULT_SUBJECT, request_msg_id
|
||
from src.core.clients import JSON_CONTENT_TYPE, S3Storage
|
||
from src.core.config import Settings, get_settings
|
||
from src.scan_emulator import EmulatorSettings, run
|
||
|
||
pytestmark = [
|
||
pytest.mark.integration,
|
||
pytest.mark.usefixtures("log_lines"),
|
||
# miniopy-async отдаёт aiohttp тело объекта байтами; на теле > 1 МиБ (реальный скан aep_5.PDF) aiohttp пишет
|
||
# ResourceWarning «Sending a large body directly…». Предупреждение библиотеки, по умолчанию Python его не
|
||
# показывает; здесь отключено точечно, чтобы прогон с `-W error` проверял остальные предупреждения
|
||
pytest.mark.filterwarnings("ignore:Sending a large body directly with raw bytes:ResourceWarning"),
|
||
]
|
||
|
||
API_DIR = Path(__file__).resolve().parents[2]
|
||
CONTRACTS_DIR = API_DIR / "contracts"
|
||
# `<корень scan-gateway>/../fixtures/ocr` (workspace и worktree `.wt/<срез>/`), как в unit-тестах
|
||
REAL_FIXTURES = API_DIR.parent.parent / "fixtures" / "ocr"
|
||
|
||
GOLDEN_JOB_ID = "3f1c2b7a-0000-4000-8000-000000000001" # условный job_id эталонов (`tests/golden_update.py`)
|
||
CONNECT_WAIT = 10.0 # секунд на подключение к NATS (тест и фоновая задача приложения)
|
||
SCENARIO_WAIT = 20.0 # секунд на ожидание конвертов одного сценария
|
||
QUIET_PERIOD = 1.0 # секунд тишины после повторного запроса: второго результата быть не должно
|
||
EMULATOR_DELAY = 0.5 # секунд «распознавания»: окно, в котором повторный запрос приходит во время ожидания ИС
|
||
POLL_SECONDS = 0.1 # SCAN_POLL_SECONDS шлюза
|
||
SETTLE_SECONDS = 0.2 # SCAN_SETTLE_SECONDS шлюза: набор ответа готов, когда не менялся столько секунд
|
||
TIMEOUT_SECONDS = 15 # SCAN_TIMEOUT_SECONDS: зависший эмулятор даёт TIMEOUT раньше, чем истечёт SCENARIO_WAIT
|
||
|
||
MESSAGE_VALIDATOR = Draft202012Validator(
|
||
json.loads((CONTRACTS_DIR / "recognition-result-message.schema.json").read_text(encoding="utf-8")),
|
||
format_checker=FormatChecker(),
|
||
)
|
||
RESULT_VALIDATOR = Draft202012Validator(
|
||
json.loads((CONTRACTS_DIR / "recognition-result.schema.json").read_text(encoding="utf-8")),
|
||
format_checker=FormatChecker(),
|
||
)
|
||
REQUEST_EXAMPLE: dict[str, Any] = json.loads(
|
||
(CONTRACTS_DIR / "examples" / "request-image.json").read_text(encoding="utf-8")
|
||
)
|
||
|
||
# Синтетический успешный ответ ИС (R-10) в форме реальных ответов (`aep_6.json`): `data_ocr` — объект с полями
|
||
# `label/organization/inn/units/date` (`form_name` + `confidence`) и `table_structure[{label, rows}]`. Все
|
||
# `confidence` = 100, шапка есть, даты колонок и отчёта разобраны, но дата 2025 года попадает в редакцию `fsbu4`,
|
||
# справочник которой пуст: нормализатор даёт `needs_check` (предупреждение «редакция fsbu4 не заполнена»)
|
||
OK_DOC_PDF = b"%PDF-1.4\n% scan-gateway itest ok_doc.pdf\n\x00\x01\x02"
|
||
UNKNOWN_PDF = b"%PDF-1.4\n% scan-gateway itest unknown.pdf\n\x00\x01\x02"
|
||
OK_DOC_ORGANIZATION = "ООО «Русский бисквит»"
|
||
SUCCESS_DATA_OCR: dict[str, Any] = {
|
||
"label": {"form_name": "Бухгалтерская отчетность", "confidence": 100.0},
|
||
"organization": {"form_name": OK_DOC_ORGANIZATION, "confidence": 100.0},
|
||
"inn": {"form_name": "7701234567", "confidence": 100.0},
|
||
"units": {"form_name": "тыс. руб.", "confidence": 100.0},
|
||
"date": {"form_name": "30.09.2025", "confidence": 100.0},
|
||
"table_structure": [
|
||
{
|
||
"label": {"form_name": "Бухгалтерский баланс", "confidence": 100.0},
|
||
"rows": [
|
||
{
|
||
"row_index": 0,
|
||
"cells": [
|
||
{"column_index": 0, "value": "Наименование показателя", "confidence": 100.0},
|
||
{"column_index": 1, "value": "Код", "confidence": 100.0},
|
||
{"column_index": 2, "value": "На 30 сентября 2025 г.", "confidence": 100.0},
|
||
{"column_index": 3, "value": "На 31 декабря 2024 г.", "confidence": 100.0},
|
||
],
|
||
},
|
||
{
|
||
"row_index": 1,
|
||
"cells": [
|
||
{"column_index": 0, "value": "БАЛАНС", "confidence": 100.0},
|
||
{"column_index": 1, "value": "1600", "confidence": 100.0},
|
||
{"column_index": 2, "value": "1 234 567", "confidence": 100.0},
|
||
{"column_index": 3, "value": "(12 345)", "confidence": 100.0},
|
||
],
|
||
},
|
||
],
|
||
}
|
||
],
|
||
}
|
||
|
||
|
||
def _require_port(host: str, port: int, what: str) -> None:
|
||
try:
|
||
with socket.create_connection((host, port), timeout=2):
|
||
return
|
||
except OSError as exc:
|
||
pytest.fail(f"{what} недоступен на {host}:{port} ({exc!r}): поднимите инфраструктуру `make up`")
|
||
|
||
|
||
def _real_fixture(stem: str, suffix: str) -> Path:
|
||
"""Файл реальной фикстуры без учёта регистра расширения (`aep_5.PDF`); нет репозитория fixtures — skip."""
|
||
if not REAL_FIXTURES.is_dir():
|
||
pytest.skip("fixtures недоступен: положите репозиторий fixtures рядом с workspace")
|
||
for path in sorted(REAL_FIXTURES.iterdir()):
|
||
if path.stem == stem and path.suffix.lower() == suffix:
|
||
return path
|
||
pytest.fail(f"в {REAL_FIXTURES} нет {stem}{suffix} (без учёта регистра расширения)")
|
||
|
||
|
||
@dataclass
|
||
class Envelope:
|
||
msg_id: str
|
||
body: dict[str, Any]
|
||
|
||
|
||
@dataclass
|
||
class Roundtrip:
|
||
"""Запущенный шлюз (lifespan приложения), эмулятор и слушатель `recognition.result`."""
|
||
|
||
settings: Settings
|
||
app: FastAPI
|
||
root: Path
|
||
s3: S3Storage
|
||
publisher: NATS
|
||
received: list[Msg] = field(default_factory=list)
|
||
jobs: set[str] = field(default_factory=set)
|
||
keys: list[str] = field(default_factory=list)
|
||
|
||
@property
|
||
def input_dir(self) -> Path:
|
||
return self.root / self.settings.SCAN_INPUT_DIR
|
||
|
||
@property
|
||
def tmp_dir(self) -> Path:
|
||
return self.root / self.settings.SCAN_TMP_DIR
|
||
|
||
def answer_dir(self, job_id: str) -> Path:
|
||
return self.root / self.settings.SCAN_OUTPUT_DIR / job_id
|
||
|
||
def answer_names(self, job_id: str) -> list[str]:
|
||
"""Имена всех `*.json` каталога ответа по возрастанию (каталог шлюз не удаляет)."""
|
||
return sorted(p.name for p in self.answer_dir(job_id).glob("*.json"))
|
||
|
||
# --- запрос ---------------------------------------------------------------------------------------------------
|
||
async def request(self, original_name: str, data: bytes) -> dict[str, Any]:
|
||
"""Кладёт исходник в MinIO под уникальным ключом и строит запрос по `examples/request-image.json`."""
|
||
job_id, file_id = str(uuid.uuid4()), str(uuid.uuid4())
|
||
key = f"uploads/itest-{uuid.uuid4()}/{file_id}/{job_id}/{original_name}"
|
||
self.jobs.add(job_id)
|
||
self.keys += [key, f"raw/{job_id}.json", f"results/{file_id}/{job_id}/result.json"]
|
||
await self.s3.put_bytes(key, data, content_type="application/pdf")
|
||
deadline = datetime.now(UTC) + timedelta(seconds=60)
|
||
return {
|
||
**REQUEST_EXAMPLE,
|
||
"job_id": job_id,
|
||
"file_id": file_id,
|
||
"original_name": original_name,
|
||
"s3": {"bucket": self.s3.bucket, "key": key},
|
||
"reply_deadline": deadline.isoformat().replace("+00:00", "Z"),
|
||
"attempt": 1,
|
||
}
|
||
|
||
async def publish(self, body: dict[str, Any]) -> None:
|
||
"""Публикация как в ядре: `recognition.image.request`, `Nats-Msg-Id = <job_id>:<attempt>`."""
|
||
payload = json.dumps(body, ensure_ascii=False).encode("utf-8")
|
||
headers = {NATS_MSG_ID_HEADER: request_msg_id(body["job_id"], body["attempt"])}
|
||
await self.publisher.publish(IMAGE_REQUEST_SUBJECT, payload, headers=headers)
|
||
await self.publisher.flush()
|
||
|
||
# --- конверты -------------------------------------------------------------------------------------------------
|
||
def envelopes(self, job_id: str) -> list[Envelope]:
|
||
"""Конверты задания в порядке получения; каждый проверен схемой и `service.name`."""
|
||
out = []
|
||
for msg in self.received:
|
||
body = json.loads(msg.data)
|
||
if body.get("job_id") != job_id:
|
||
continue
|
||
MESSAGE_VALIDATOR.validate(body)
|
||
assert body["service"]["name"] == "scan-gateway", body
|
||
assert body["schema_version"] == "1.0", body
|
||
assert msg.headers is not None, body
|
||
out.append(Envelope(msg.headers[NATS_MSG_ID_HEADER], body))
|
||
return out
|
||
|
||
def acks(self, job_id: str) -> list[str]:
|
||
return [e.msg_id for e in self.envelopes(job_id) if e.body["status"] == "processing"]
|
||
|
||
def results(self, job_id: str) -> list[Envelope]:
|
||
return [e for e in self.envelopes(job_id) if e.body["status"] != "processing"]
|
||
|
||
async def wait_for(self, what: str, predicate: Any, timeout: float = SCENARIO_WAIT) -> None:
|
||
loop = asyncio.get_running_loop()
|
||
deadline = loop.time() + timeout
|
||
while not predicate():
|
||
if loop.time() > deadline:
|
||
raise AssertionError(f"за {timeout} с не дождались: {what}")
|
||
await asyncio.sleep(0.01)
|
||
|
||
async def health(self) -> dict[str, Any]:
|
||
transport = httpx.ASGITransport(app=self.app)
|
||
async with httpx.AsyncClient(transport=transport, base_url="http://scan-gateway") as client:
|
||
response = await client.get("/healthcheck")
|
||
assert response.status_code == 200
|
||
body: dict[str, Any] = response.json()
|
||
return body
|
||
|
||
# --- проверки -------------------------------------------------------------------------------------------------
|
||
async def raw(self, job_id: str) -> dict[str, Any]:
|
||
raw: dict[str, Any] = json.loads(await self.s3.get_bytes(f"raw/{job_id}.json"))
|
||
return raw
|
||
|
||
def inbox(self, job_id: str) -> list[str]:
|
||
return sorted(p.name for p in self.input_dir.iterdir() if p.name.startswith(job_id))
|
||
|
||
def stray_parts(self) -> list[str]:
|
||
"""Временные `.part` записи во входящий каталог: после успешной публикации их в `Tmp` нет (решение №26)."""
|
||
return (
|
||
sorted(p.name for p in self.tmp_dir.iterdir() if p.name.endswith(".part")) if self.tmp_dir.is_dir() else []
|
||
)
|
||
|
||
async def run(self, original_name: str, data: bytes, *, cleanup: bool = False) -> tuple[dict[str, Any], Envelope]:
|
||
"""Запрос → ack → итоговый конверт; общие проверки сквозного сценария. Возвращает запрос и итог."""
|
||
body = await self.request(original_name, data)
|
||
job_id = body["job_id"]
|
||
await self.publish(body)
|
||
await self.wait_for(f"итоговый конверт {job_id}", lambda: bool(self.results(job_id)))
|
||
envelopes = self.envelopes(job_id)
|
||
assert [e.msg_id for e in envelopes] == [f"ack:{job_id}:1", f"result:{job_id}:1"] # ack раньше итога
|
||
assert envelopes[0].body["status"] == "processing"
|
||
assert envelopes[0].body["file_id"] == body["file_id"]
|
||
await self.check_files(body, envelopes[1], data, cleanup=cleanup)
|
||
return body, envelopes[1]
|
||
|
||
async def check_files(self, body: dict[str, Any], result: Envelope, data: bytes, *, cleanup: bool = False) -> None:
|
||
"""Сырой ответ в MinIO — склейка ответа эмулятора (D-4); входящий файл `<job_id>.pdf` = объект S3.
|
||
|
||
Без очистки (`SCAN_CLEANUP=false`) каталог ответа и входящий файл (с суффиксом `.done` эмулятора) остаются; при
|
||
очистке (`SCAN_CLEANUP=true`) во входящем каталоге пусто, каталога `Output/<job_id>/` нет. `.part` в `Tmp` нет
|
||
в обоих режимах.
|
||
"""
|
||
job_id = body["job_id"]
|
||
assert result.body["file_id"] == body["file_id"]
|
||
assert result.body["raw_s3_key"] == f"raw/{job_id}.json"
|
||
raw = await self.raw(job_id)
|
||
assert raw["job_id"] == job_id
|
||
assert raw["files"], "в склейке нет ни одного файла"
|
||
for entry in raw["files"]:
|
||
assert entry["parse_error"] is None
|
||
assert entry["response"]["file_uid"] == job_id
|
||
stat = await self.s3.client.stat_object(self.s3.bucket, f"raw/{job_id}.json")
|
||
assert stat.content_type == JSON_CONTENT_TYPE
|
||
assert self.stray_parts() == []
|
||
if cleanup:
|
||
assert list(self.input_dir.iterdir()) == []
|
||
assert not self.answer_dir(job_id).exists()
|
||
return
|
||
names = self.answer_names(job_id)
|
||
assert names, "эмулятор не положил ни одного json"
|
||
# склейка (D-4): все json каталога ответа, по возрастанию имени, тела совпадают с файлами
|
||
assert [entry["name"] for entry in raw["files"]] == names
|
||
for entry in raw["files"]:
|
||
assert entry["response"] == json.loads((self.answer_dir(job_id) / entry["name"]).read_bytes())
|
||
# входящий файл — `<job_id>.<ext>` в нижнем регистре; эмулятор переименовал его в `.done`
|
||
name = f"{job_id}.pdf.done"
|
||
assert self.inbox(job_id) == [name]
|
||
assert (self.input_dir / name).read_bytes() == await self.s3.get_bytes(body["s3"]["key"]) == data
|
||
|
||
async def check_error(self, body: dict[str, Any], result: Envelope) -> None:
|
||
"""Итог `error`: файла результата в MinIO нет (он пишется только для done/needs_check)."""
|
||
assert result.body["status"] == "error"
|
||
assert "result_s3" not in result.body
|
||
with pytest.raises(S3Error):
|
||
await self.s3.client.stat_object(self.s3.bucket, f"results/{body['file_id']}/{body['job_id']}/result.json")
|
||
|
||
async def check_needs_check(self, body: dict[str, Any], result: Envelope) -> dict[str, Any]:
|
||
"""Итог `needs_check` с `result_s3`; файл результата из MinIO проходит схему `recognition-result`.
|
||
|
||
Возвращает файл результата (документы лежат только в нём: конверт несёт ссылку `result_s3`).
|
||
"""
|
||
job_id, file_id = body["job_id"], body["file_id"]
|
||
assert result.body["status"] == "needs_check"
|
||
assert "error" not in result.body
|
||
key = f"results/{file_id}/{job_id}/result.json"
|
||
assert result.body["result_s3"] == {"bucket": self.s3.bucket, "key": key}
|
||
stat = await self.s3.client.stat_object(self.s3.bucket, key)
|
||
assert stat.content_type == JSON_CONTENT_TYPE
|
||
payload: dict[str, Any] = json.loads(await self.s3.get_bytes(key))
|
||
RESULT_VALIDATOR.validate(payload)
|
||
assert payload["schema_version"] == CONTRACT_VERSION
|
||
assert (payload["job_id"], payload["file_id"], payload["status"]) == (job_id, file_id, "needs_check")
|
||
assert payload["service"]["name"] == "scan-gateway"
|
||
assert payload["raw_s3_key"] == f"raw/{job_id}.json"
|
||
# итог в конверте и в файле результата — одни и те же метрики
|
||
assert payload["metrics"] == result.body["metrics"]
|
||
return payload
|
||
|
||
|
||
def _write_fixtures(fixtures: Path) -> None:
|
||
"""Каталог фикстур эмулятора: пары PDF+JSON по содержимому (sha256). Реальные — копии `aep_3` (`aep_3.json` +
|
||
`aep_3_ofr.json`), `aep_5`, `aep_6` из `fixtures/ocr`; синтетическая — `ok_doc.pdf` + `ok_doc.json` (R-10), её байты
|
||
сценарий 4 отправляет в S3. Без репозитория fixtures — только синтетическая пара."""
|
||
fixtures.mkdir()
|
||
template: dict[str, Any] = {"file_uid": "aep_5"}
|
||
if REAL_FIXTURES.is_dir():
|
||
for path in sorted(REAL_FIXTURES.iterdir()):
|
||
if path.stem in ("aep_3", "aep_3_ofr", "aep_5", "aep_6") and path.suffix.lower() in (".json", ".pdf"):
|
||
shutil.copy(path, fixtures / path.name)
|
||
template = json.loads((fixtures / "aep_5.json").read_text(encoding="utf-8"))
|
||
# успех — в форме реальных ответов ИС: `error = {"code": "", "message": ""}`, а не `null` (OQ-77)
|
||
success = {**template, "file_uid": "ok_doc", "error": {"code": "", "message": ""}, "data_ocr": SUCCESS_DATA_OCR}
|
||
(fixtures / "ok_doc.pdf").write_bytes(OK_DOC_PDF)
|
||
(fixtures / "ok_doc.json").write_text(json.dumps(success, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
|
||
def _settings(monkeypatch: pytest.MonkeyPatch, root: Path, *, cleanup: bool) -> Settings:
|
||
"""Настройки шлюза из `.env.test` и окружения (как в проде) с tmp-корнем; инфраструктура недоступна — fail."""
|
||
monkeypatch.setenv("SCAN_HOTFOLDER_MODE", "local")
|
||
monkeypatch.setenv("SCAN_HOTFOLDER_PATH", str(root))
|
||
monkeypatch.setenv("SCAN_POLL_SECONDS", str(POLL_SECONDS))
|
||
monkeypatch.setenv("SCAN_SETTLE_SECONDS", str(SETTLE_SECONDS))
|
||
monkeypatch.setenv("SCAN_TIMEOUT_SECONDS", str(TIMEOUT_SECONDS))
|
||
monkeypatch.setenv("SCAN_CLEANUP", "true" if cleanup else "false")
|
||
monkeypatch.setenv("NATS_ENABLED", "true")
|
||
settings = get_settings()
|
||
parsed = urlparse(settings.OPENBAO.NATS.NATS_URL)
|
||
_require_port(parsed.hostname or "127.0.0.1", parsed.port or 4222, "NATS")
|
||
host, _, port = settings.OPENBAO.MINIO.MINIO_URL.partition(":")
|
||
_require_port(host, int(port or 9000), "MinIO")
|
||
return settings
|
||
|
||
|
||
async def _start_gateway(stack: AsyncExitStack, app: FastAPI) -> None:
|
||
"""Lifespan приложения; возврат — когда шлюз подписан на `recognition.image.request`."""
|
||
await stack.enter_async_context(app.router.lifespan_context(app))
|
||
[connect_task] = [t for t in asyncio.all_tasks() if t.get_name() == "nats-connect"]
|
||
# задача завершается после SUB с flush: дальше подписка шлюза действует на сервере
|
||
await asyncio.wait_for(asyncio.shield(connect_task), CONNECT_WAIT)
|
||
assert app.state.nats.is_connected, "шлюз не подписался на запросы (см. лог ERROR)"
|
||
|
||
|
||
def _start_emulator(stack: AsyncExitStack, settings: Settings, root: Path, fixtures: Path) -> None:
|
||
"""Эмулятор ИС (`src.scan_emulator.run`) в потоке; при закрытии стека — штатная остановка и ожидание потока."""
|
||
stop = threading.Event()
|
||
emulator_settings = EmulatorSettings(
|
||
hotfolder_path=root,
|
||
input_dir=settings.SCAN_INPUT_DIR,
|
||
output_dir=settings.SCAN_OUTPUT_DIR,
|
||
tmp_dir=settings.SCAN_TMP_DIR,
|
||
fixtures_dir=fixtures,
|
||
delay=EMULATOR_DELAY,
|
||
poll_interval=0.05,
|
||
default="", # `SCAN_EMULATOR_DEFAULT` пуст: промах по хешу даёт ответ-ошибку, а не набор по умолчанию
|
||
)
|
||
emulator = asyncio.create_task(asyncio.to_thread(run, emulator_settings, stop), name="scan-emulator")
|
||
|
||
async def stop_emulator() -> None:
|
||
stop.set()
|
||
await emulator
|
||
|
||
stack.push_async_callback(stop_emulator)
|
||
|
||
|
||
async def _roundtrip(
|
||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, log_lines: list[str], *, cleanup: bool
|
||
) -> AsyncIterator[Roundtrip]:
|
||
"""Запущенные шлюз и эмулятор; `cleanup` — значение `SCAN_CLEANUP` шлюза."""
|
||
root = tmp_path / "hotfolder"
|
||
root.mkdir()
|
||
_write_fixtures(tmp_path / "fixtures")
|
||
settings = _settings(monkeypatch, root, cleanup=cleanup)
|
||
assert settings.SCAN_CLEANUP is cleanup
|
||
nats_url = settings.OPENBAO.NATS.NATS_URL
|
||
|
||
async with AsyncExitStack() as stack:
|
||
s3 = S3Storage.from_settings(settings.OPENBAO.MINIO)
|
||
stack.push_async_callback(s3.close)
|
||
client = NATS()
|
||
try:
|
||
await asyncio.wait_for(client.connect(nats_url, max_reconnect_attempts=0), CONNECT_WAIT)
|
||
except Exception as exc:
|
||
pytest.fail(f"нет подключения к NATS {nats_url} ({exc!r}): `make up`")
|
||
stack.push_async_callback(client.drain)
|
||
rt = Roundtrip(settings=settings, app=create_app(settings), root=root, s3=s3, publisher=client)
|
||
|
||
async def remove_objects() -> None:
|
||
for key in rt.keys:
|
||
with suppress(S3Error):
|
||
await s3.client.remove_object(s3.bucket, key)
|
||
|
||
stack.push_async_callback(remove_objects)
|
||
|
||
async def on_result(msg: Msg) -> None:
|
||
with suppress(ValueError): # чужие сообщения на общем subject могут быть чем угодно
|
||
body = json.loads(msg.data)
|
||
if isinstance(body, dict) and body.get("job_id") in rt.jobs:
|
||
rt.received.append(msg)
|
||
|
||
# core NATS без JetStream не хранит сообщения: слушатель подписан до первой публикации запроса
|
||
await client.subscribe(RESULT_SUBJECT, cb=on_result)
|
||
await client.flush()
|
||
await _start_gateway(stack, rt.app)
|
||
_start_emulator(stack, settings, root, tmp_path / "fixtures")
|
||
yield rt
|
||
|
||
# lifespan остановлен: заданий и фоновых задач приложения не осталось, ошибок в логе нет
|
||
assert rt.app.state.worker.active_jobs == 0
|
||
leaked = [t.get_name() for t in asyncio.all_tasks() if t is not asyncio.current_task() and not t.done()]
|
||
assert leaked == [], f"незавершённые задачи после остановки: {leaked}"
|
||
errors = [line for line in log_lines if line.startswith(("ERROR|", "CRITICAL|"))]
|
||
assert errors == [], errors
|
||
|
||
|
||
@pytest.fixture
|
||
async def roundtrip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, log_lines: list[str]) -> AsyncIterator[Roundtrip]:
|
||
"""Шлюз с `SCAN_CLEANUP=false` (по умолчанию)."""
|
||
async for rt in _roundtrip(tmp_path, monkeypatch, log_lines, cleanup=False):
|
||
yield rt
|
||
|
||
|
||
@pytest.fixture
|
||
async def roundtrip_cleanup(
|
||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, log_lines: list[str]
|
||
) -> AsyncIterator[Roundtrip]:
|
||
"""Шлюз с `SCAN_CLEANUP=true`."""
|
||
async for rt in _roundtrip(tmp_path, monkeypatch, log_lines, cleanup=True):
|
||
yield rt
|
||
|
||
|
||
def _drop_none(value: Any) -> Any:
|
||
"""Без полей `null`: эталон записан с `exclude_none`, файл результата хранит явные `null` (`exclude_unset`)."""
|
||
if isinstance(value, dict):
|
||
return {key: _drop_none(item) for key, item in value.items() if item is not None}
|
||
if isinstance(value, list):
|
||
return [_drop_none(item) for item in value]
|
||
return value
|
||
|
||
|
||
def _golden(name: str, job_id: str) -> dict[str, Any] | None:
|
||
"""Эталон `fixtures/ocr/expected_normalize/<name>.json` с подставленным `job_id`; нет эталона — `None`.
|
||
|
||
Эталон строится на условном `job_id` (`tests/golden_update.py`): он встречается в `locator` и в предупреждениях.
|
||
"""
|
||
path = REAL_FIXTURES / "expected_normalize" / f"{name}.json"
|
||
if not path.is_file():
|
||
return None
|
||
expected: dict[str, Any] = json.loads(path.read_text(encoding="utf-8").replace(GOLDEN_JOB_ID, job_id))
|
||
return expected
|
||
|
||
|
||
@pytest.mark.fixtures
|
||
async def test_real_fixture_aep_5_passes_is_error_through_and_repeat_gets_ack(roundtrip: Roundtrip) -> None:
|
||
pdf = _real_fixture("aep_5", ".pdf") # реальное имя `aep_5.PDF`: расширение в верхнем регистре
|
||
data = pdf.read_bytes()
|
||
expected_error = json.loads(_real_fixture("aep_5", ".json").read_text(encoding="utf-8"))["error"]
|
||
health = await roundtrip.health()
|
||
assert (health["nats"], health["hotfolder"]) == ("ok", "ok"), health
|
||
|
||
body = await roundtrip.request(pdf.name, data)
|
||
job_id = body["job_id"]
|
||
await roundtrip.publish(body)
|
||
# расширение входящего файла — в нижнем регистре, имя файла = job_id (раскладка FileWorker)
|
||
inbox_file = roundtrip.input_dir / f"{job_id}.pdf"
|
||
await roundtrip.wait_for(f"файл {inbox_file.name} во входящем каталоге", inbox_file.exists)
|
||
# повтор того же запроса во время ожидания ответа ИС (эмулятор «распознаёт» EMULATOR_DELAY с)
|
||
await roundtrip.publish(body)
|
||
await roundtrip.wait_for(f"итоговый конверт {job_id}", lambda: bool(roundtrip.results(job_id)))
|
||
await asyncio.sleep(QUIET_PERIOD) # второй (лишний) результат или ack успел бы прийти
|
||
|
||
envelopes = roundtrip.envelopes(job_id)
|
||
assert [e.msg_id for e in envelopes] == [f"ack:{job_id}:1", f"ack:{job_id}:1", f"result:{job_id}:1"]
|
||
[result] = roundtrip.results(job_id)
|
||
assert result.body["error"] == {"code": "INVALID_DOCUMENT", "message": expected_error["message"]}
|
||
assert result.body["error"]["message"] == "Не удалось определить тип документа."
|
||
await roundtrip.check_files(body, result, data)
|
||
await roundtrip.check_error(body, result)
|
||
raw = await roundtrip.raw(job_id)
|
||
assert [entry["response"]["error"] for entry in raw["files"]] == [expected_error]
|
||
# все файлы набора ошибочные: итог — ошибка первого файла, а warnings перечисляют каждый ошибочный файл (D-5)
|
||
assert result.body["metrics"]["warnings"] == [f"{job_id}.json: INVALID_DOCUMENT: {expected_error['message']}"]
|
||
|
||
# повтор ядра после результата (attempt 2): ещё один ack, без второго файла и второго результата
|
||
await roundtrip.publish({**body, "attempt": 2})
|
||
await roundtrip.wait_for(f"ack:{job_id}:2", lambda: f"ack:{job_id}:2" in roundtrip.acks(job_id))
|
||
await asyncio.sleep(QUIET_PERIOD)
|
||
assert roundtrip.acks(job_id) == [f"ack:{job_id}:1", f"ack:{job_id}:1", f"ack:{job_id}:2"]
|
||
assert [e.msg_id for e in roundtrip.results(job_id)] == [f"result:{job_id}:1"]
|
||
assert roundtrip.inbox(job_id) == [f"{job_id}.pdf.done"]
|
||
|
||
|
||
@pytest.mark.fixtures
|
||
async def test_real_fixture_aep_3_two_files_merged_needs_check_with_documents(roundtrip: Roundtrip) -> None:
|
||
"""Набор из двух файлов: успешный `<job_id>.json` и `<job_id>_ofr.json` с `INVALID_DOCUMENT` (OQ-76, OQ-78)."""
|
||
data = _real_fixture("aep_3", ".pdf").read_bytes()
|
||
ofr_error = json.loads(_real_fixture("aep_3_ofr", ".json").read_text(encoding="utf-8"))["error"]
|
||
assert ofr_error["code"] == "INVALID_DOCUMENT"
|
||
|
||
body, result = await roundtrip.run("aep_3.pdf", data)
|
||
job_id = body["job_id"]
|
||
payload = await roundtrip.check_needs_check(body, result)
|
||
# документы собраны из успешного файла: баланс и ОФР, по одному на таблицу `table_structure`
|
||
assert [d["document_type"] for d in payload["documents"]] == ["BALANCE", "INCOME"]
|
||
assert [d["locator"] for d in payload["documents"]] == [
|
||
f"file:{job_id}.json;table:0",
|
||
f"file:{job_id}.json;table:1",
|
||
]
|
||
assert payload["metrics"]["rows"] == sum(len(d["lines"]) for d in payload["documents"])
|
||
# ошибочный файл набора — в warnings одной строкой `<имя>: <код>: <сообщение>` (последней: файлы по имени);
|
||
# успешный файл даёт предупреждения нормализации
|
||
warnings = result.body["metrics"]["warnings"]
|
||
assert warnings[-1] == f"{job_id}_ofr.json: INVALID_DOCUMENT: {ofr_error['message']}"
|
||
assert f"{job_id}.json: ИНН распознан неуверенно" in warnings # в aep_3 ИНН извлечён, но confidence < 100
|
||
assert f"{job_id}.json: table:0: шапка синтезирована: колонки без дат" in warnings
|
||
expected = _golden("aep_3", job_id)
|
||
if expected is not None: # эталон нормализатора (`make golden-update`) совпадает со сквозным результатом
|
||
assert _drop_none(payload["documents"]) == expected["documents"]
|
||
assert warnings == expected["warnings"]
|
||
|
||
raw = await roundtrip.raw(job_id)
|
||
assert [entry["name"] for entry in raw["files"]] == [f"{job_id}.json", f"{job_id}_ofr.json"]
|
||
first, second = raw["files"]
|
||
assert first["response"]["error"] == {"code": "", "message": ""} # успех — не `null`
|
||
assert [t["label"]["form_name"] for t in first["response"]["data_ocr"]["table_structure"]] == [
|
||
"Бухгалтерский баланс",
|
||
"Отчет о финансовых результатах",
|
||
]
|
||
assert second["response"]["error"] == ofr_error
|
||
assert (first["parse_error"], second["parse_error"]) == (None, None)
|
||
|
||
|
||
@pytest.mark.fixtures
|
||
async def test_real_fixture_aep_6_needs_check_with_two_documents(roundtrip: Roundtrip) -> None:
|
||
data = _real_fixture("aep_6", ".pdf").read_bytes()
|
||
body, result = await roundtrip.run("aep_6.pdf", data)
|
||
job_id = body["job_id"]
|
||
payload = await roundtrip.check_needs_check(body, result)
|
||
assert [d["document_type"] for d in payload["documents"]] == ["BALANCE", "INCOME"]
|
||
# отчётность за 2025 год попадает в редакцию `fsbu4`, справочник которой пуст (по одному предупреждению на таблицу)
|
||
warnings = result.body["metrics"]["warnings"]
|
||
assert f"{job_id}.json: table:0: редакция fsbu4 не заполнена" in warnings
|
||
assert f"{job_id}.json: table:1: редакция fsbu4 не заполнена" in warnings
|
||
assert not any("INVALID_DOCUMENT" in warning for warning in warnings) # ошибочных файлов в наборе нет
|
||
expected = _golden("aep_6", job_id)
|
||
if expected is not None:
|
||
assert _drop_none(payload["documents"]) == expected["documents"]
|
||
assert warnings == expected["warnings"]
|
||
raw = await roundtrip.raw(job_id)
|
||
[entry] = raw["files"]
|
||
assert entry["name"] == f"{job_id}.json"
|
||
assert entry["response"]["error"] == {"code": "", "message": ""}
|
||
|
||
|
||
async def test_synthetic_success_answer_gives_needs_check(roundtrip: Roundtrip) -> None:
|
||
"""`ok_doc.pdf` лежит в фикстурах эмулятора теми же байтами, что отправляются в S3 (пара по sha256, R-10)."""
|
||
body, result = await roundtrip.run("ok_doc.pdf", OK_DOC_PDF)
|
||
job_id = body["job_id"]
|
||
payload = await roundtrip.check_needs_check(body, result)
|
||
[document] = payload["documents"]
|
||
assert document["document_type"] == "BALANCE"
|
||
assert document["locator"] == f"file:{job_id}.json;table:0"
|
||
assert document["header"]["organization_name"] == OK_DOC_ORGANIZATION
|
||
assert document["header"]["inn"] == "7701234567"
|
||
assert document["header"]["report_date"] == "2025-09-30"
|
||
assert [c["period_date"] for c in document["columns"]] == ["2025-09-30", "2024-12-31"]
|
||
[line] = document["lines"]
|
||
assert (line["line_code"], line["line_name"]) == ("1600", "БАЛАНС") # имя из OCR: редакция fsbu4 пуста
|
||
assert [v["amount"] for v in line["values"]] == ["1234567", "-12345"] # деньги в файле результата — строки
|
||
# всё распознано, кроме пустой редакции: единственное предупреждение
|
||
assert result.body["metrics"]["warnings"] == [f"{job_id}.json: table:0: редакция fsbu4 не заполнена"]
|
||
raw = await roundtrip.raw(job_id)
|
||
[entry] = raw["files"]
|
||
assert entry["response"]["error"] == {"code": "", "message": ""}
|
||
assert entry["response"]["data_ocr"] == SUCCESS_DATA_OCR
|
||
|
||
|
||
async def test_unknown_content_gets_emulator_invalid_document(roundtrip: Roundtrip) -> None:
|
||
"""Содержимого нет среди фикстур, `SCAN_EMULATOR_DEFAULT` пуст: ответ-ошибка эмулятора (D-3)."""
|
||
body, result = await roundtrip.run("unknown.pdf", UNKNOWN_PDF)
|
||
job_id = body["job_id"]
|
||
digest = hashlib.sha256(UNKNOWN_PDF).hexdigest()
|
||
error = {"code": "INVALID_DOCUMENT", "message": f"{digest[:12]}: нет пары по содержимому в фикстурах"}
|
||
await roundtrip.check_error(body, result)
|
||
assert result.body["error"] == error
|
||
raw = await roundtrip.raw(job_id)
|
||
[entry] = raw["files"]
|
||
assert entry["name"] == f"{job_id}.json"
|
||
assert entry["parse_error"] is None
|
||
# ответ-заглушка эмулятора в форме реальных ответов ИС (R-28): `data_ocr` — объект без таблиц
|
||
assert entry["response"]["file_uid"] == job_id
|
||
assert entry["response"]["error"] == error
|
||
assert entry["response"]["data_ocr"]["table_structure"] == []
|
||
|
||
|
||
@pytest.mark.fixtures
|
||
async def test_cleanup_enabled_leaves_input_and_answer_empty(roundtrip_cleanup: Roundtrip) -> None:
|
||
"""`SCAN_CLEANUP=true`: после результата во входящем каталоге пусто, каталога `Output/<job_id>/` нет (D-2)."""
|
||
data = _real_fixture("aep_6", ".pdf").read_bytes()
|
||
body, result = await roundtrip_cleanup.run("aep_6.pdf", data, cleanup=True)
|
||
job_id = body["job_id"]
|
||
await roundtrip_cleanup.check_needs_check(body, result)
|
||
# очистка идёт после сохранения сырого ответа: он и файл результата на месте, горячая папка чиста
|
||
raw = await roundtrip_cleanup.raw(job_id)
|
||
assert [entry["name"] for entry in raw["files"]] == [f"{job_id}.json"]
|
||
assert list(roundtrip_cleanup.input_dir.iterdir()) == []
|
||
assert not roundtrip_cleanup.answer_dir(job_id).exists()
|
||
assert roundtrip_cleanup.stray_parts() == []
|