"""Общая обвязка unit-тестов воркера шлюза (`test_worker.py`, `test_worker_concurrency.py`); фикстура `make_env` — в `tests/unit/conftest.py`. Без Docker и сети: S3 — словарь в памяти (ошибки — классы `src.core.clients`), NATS — настоящий `NatsGateway` поверх фейкового клиента nats-py (тело, `Nats-Msg-Id`, повторы и `PublishFailed` — как в проде), горячая папка — настоящий `LocalHotFolder` на `tmp_path` под шпионом, ИС Сканирование — `process_once` эмулятора (задержка 0) в фоновой задаче. Общий журнал событий (`events`) упорядочивает публикации, чтения S3 и запись во входящий каталог. Каждый опубликованный конверт проверяется JSON Schema `recognition-result-message.schema.json`. """ import asyncio import json import uuid from collections.abc import AsyncIterator, Awaitable, Callable from contextlib import asynccontextmanager, suppress from dataclasses import dataclass, field from datetime import UTC, datetime, timedelta from pathlib import Path from typing import Any from jsonschema import Draft202012Validator, FormatChecker from nats import errors as nats_errors from src.contract import NATS_MSG_ID_HEADER, RESULT_SUBJECT from src.core.clients import JSON_CONTENT_TYPE, OCTET_STREAM, S3ObjectNotFoundError, S3WriteError from src.core.config import Settings from src.core.nats import NatsGateway from src.core.worker import Worker from src.gateway.hotfolder import LocalHotFolder from src.scan_emulator import process_once API_DIR = Path(__file__).resolve().parents[1] CONTRACTS_DIR = API_DIR / "contracts" BUCKET = "arm-ka" PDF_BYTES = b"%PDF-1.4\n% scan-gateway test\n\x00\x01\x02binary" MESSAGE_VALIDATOR = Draft202012Validator( json.loads((CONTRACTS_DIR / "recognition-result-message.schema.json").read_text(encoding="utf-8")), format_checker=FormatChecker(), ) # Синтетический успешный ответ ИС (R-10): форма `fixtures/ocr/aep_5.json`, `error = null`, непустой `data_ocr` SUCCESS_ANSWER: dict[str, Any] = { "file_uid": "ok_doc", "error": None, "data_ocr": { "label": {"form_name": "Бухгалтерский баланс", "confidence": 98.0}, "organization": {"form_name": "ООО Ромашка", "confidence": 97.5}, "inn": {"form_name": "7700000000", "confidence": 99.0}, "units": {"form_name": "тыс. руб.", "confidence": 95.0}, "date": {"form_name": "30.09.2025", "confidence": 96.0}, "table_structure": [{"line_code": "1600", "line_name": "БАЛАНС", "values": ["5678", "-12.3"]}], }, } # --- фейки -------------------------------------------------------------------------------------------------------- class FakeStorage: """S3 в памяти с интерфейсом `S3Storage`: get_bytes / put_bytes / put_json и ошибками `src.core.clients`.""" def __init__(self, events: list[tuple[str, str]], bucket: str = BUCKET) -> None: self.events = events self.bucket = bucket self.objects: dict[tuple[str, str], bytes] = {} self.content_types: dict[tuple[str, str], str] = {} self.get_calls: list[tuple[str, str]] = [] self.fail_put_prefixes: tuple[str, ...] = () async def get_bytes(self, key: str, *, bucket: str | None = None) -> bytes: bucket_name = bucket or self.bucket self.get_calls.append((bucket_name, key)) self.events.append(("s3_get", key)) try: return self.objects[(bucket_name, key)] except KeyError: raise S3ObjectNotFoundError( f"S3: объект {bucket_name}/{key} не прочитан (NoSuchKey)", bucket=bucket_name, key=key ) from None async def put_bytes(self, key: str, data: bytes, content_type: str = OCTET_STREAM) -> None: if self.fail_put_prefixes and key.startswith(self.fail_put_prefixes): raise S3WriteError( f"S3: объект {self.bucket}/{key} не записан (ConnectionError)", bucket=self.bucket, key=key ) self.objects[(self.bucket, key)] = data self.content_types[(self.bucket, key)] = content_type self.events.append(("s3_put", key)) async def put_json(self, key: str, obj: Any) -> None: await self.put_bytes(key, json.dumps(obj, ensure_ascii=False).encode("utf-8"), JSON_CONTENT_TYPE) class FakeNatsClient: """Подмена `nats.aio.client.Client` для настоящего `NatsGateway`: записывает публикации в общий журнал.""" def __init__(self, events: list[tuple[str, str]]) -> None: self.events = events self.is_connected = False self.is_closed = False self.published: list[tuple[str, bytes, dict[str, str]]] = [] self.fail = False self.block: asyncio.Event | None = None async def connect(self, *args: Any, **kwargs: Any) -> None: self.is_connected = True async def publish(self, subject: str, payload: bytes = b"", headers: dict[str, str] | None = None) -> None: if self.block is not None: await self.block.wait() if self.fail: raise nats_errors.ConnectionClosedError assert headers is not None self.published.append((subject, payload, headers)) self.events.append(("publish", headers[NATS_MSG_ID_HEADER])) async def flush(self, timeout: float = 10) -> None: return None async def drain(self) -> None: self.is_connected = False self.is_closed = True async def close(self) -> None: self.is_connected = False self.is_closed = True class SpyHotFolder: """`LocalHotFolder` под шпионом: журнал записей во входящий каталог, сценарии сбоев `poll`.""" def __init__(self, inner: LocalHotFolder, events: list[tuple[str, str]]) -> None: self.inner = inner self.events = events self.puts: list[str] = [] self.polls = 0 self.poll_errors: list[BaseException] = [] self.poll_always: BaseException | None = None self.poll_script: list[bytes | None] = [] def put(self, name: str, data: bytes) -> None: self.inner.put(name, data) self.puts.append(name) self.events.append(("put", name)) def poll(self, job_id: str) -> bytes | None: self.polls += 1 if self.poll_always is not None: raise self.poll_always if self.poll_errors: raise self.poll_errors.pop(0) if self.poll_script: return self.poll_script.pop(0) return self.inner.poll(job_id) def healthy(self) -> bool: return self.inner.healthy() # --- окружение теста ---------------------------------------------------------------------------------------------- @dataclass class Env: settings: Settings root: Path fixtures: Path events: list[tuple[str, str]] storage: FakeStorage client: FakeNatsClient gateway: NatsGateway hotfolder: SpyHotFolder worker: Worker local: LocalHotFolder = field(init=False) def __post_init__(self) -> None: self.local = self.hotfolder.inner # запрос ------------------------------------------------------------------------------------------------------- def request( # noqa: PLR0913 - параметры запроса для сценариев self, *, original_name: str = "ok_doc.pdf", deadline_s: float = 60, attempt: int = 1, bucket: str = BUCKET, data: bytes | None = PDF_BYTES, job_id: str | None = None, ) -> dict[str, Any]: job = job_id or str(uuid.uuid4()) file_id = str(uuid.uuid4()) key = f"uploads/{file_id}/{job}/source.bin" if data is not None: self.storage.objects[(bucket, key)] = data deadline = datetime.now(UTC) + timedelta(seconds=deadline_s) return { "schema_version": "1.0", "job_id": job, "file_id": file_id, "pipeline": "image", "file_type": "PDF", "original_name": original_name, "s3": {"bucket": bucket, "key": key}, "reply_deadline": deadline.isoformat().replace("+00:00", "Z"), "attempt": attempt, } async def deliver(self, body: dict[str, Any] | bytes) -> None: data = body if isinstance(body, bytes) else json.dumps(body, ensure_ascii=False).encode("utf-8") await self.worker.on_request(FakeMsg(data)) async def run(self, body: dict[str, Any], timeout: float = 5) -> dict[str, Any]: """Доставить запрос, дождаться окончания всех заданий, вернуть единственный итоговый конверт задания.""" await self.deliver(body) await asyncio.wait_for(self.worker.wait_idle(), timeout) results = self.results(body["job_id"]) assert len(results) == 1, results return results[0][1] # публикации --------------------------------------------------------------------------------------------------- def published(self) -> list[tuple[str, dict[str, Any]]]: """(Nats-Msg-Id, тело) всех публикаций; каждое тело проверено схемой конверта.""" out = [] for subject, payload, headers in self.client.published: assert subject == RESULT_SUBJECT body = json.loads(payload.decode("utf-8")) MESSAGE_VALIDATOR.validate(body) assert body["service"]["name"] == "scan-gateway" assert body["schema_version"] == "1.0" out.append((headers[NATS_MSG_ID_HEADER], body)) return out def acks(self, job_id: str) -> list[str]: return [mid for mid, body in self.published() if body["job_id"] == job_id and body["status"] == "processing"] def results(self, job_id: str) -> list[tuple[str, dict[str, Any]]]: return [ (mid, body) for mid, body in self.published() if body["job_id"] == job_id and body["status"] != "processing" ] def inbox(self) -> list[str]: if not self.local.input_dir.is_dir(): return [] return sorted(p.name for p in self.local.input_dir.iterdir() if not p.name.startswith(".")) def answer(self, job_id: str, content: bytes) -> None: path = self.local.answer_path(job_id) path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(content) class FakeMsg: def __init__(self, data: bytes) -> None: self.data = data self.subject = "recognition.image.request" self.headers: dict[str, str] | None = None EnvFactory = Callable[..., Awaitable[Env]] @asynccontextmanager async def emulator(env: Env, fixtures: Path | None = None) -> AsyncIterator[None]: """ИС Сканирование: `process_once` (задержка 0) каждые 5 мс, пока открыт контекст.""" async def loop() -> None: while True: process_once(env.root, fixtures or env.fixtures, 0) await asyncio.sleep(0.005) task = asyncio.create_task(loop()) try: yield finally: task.cancel() with suppress(asyncio.CancelledError): await task async def eventually(predicate: Callable[[], bool], timeout: float = 3.0) -> None: deadline = asyncio.get_running_loop().time() + timeout while not predicate(): if asyncio.get_running_loop().time() > deadline: raise AssertionError("условие не выполнилось вовремя") await asyncio.sleep(0.005)