From 6552ebe345c388f5d1da092e95100ecae12fbe03 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 04:23:10 +0400 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=80=D0=B5=D0=B7=200=20[6.1]:=20=D1=81?= =?UTF-8?q?=D0=BA=D0=B2=D0=BE=D0=B7=D0=BD=D0=BE=D0=B9=20=D0=B8=D0=BD=D1=82?= =?UTF-8?q?=D0=B5=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=BE=D0=BD=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D1=82=D0=B5=D1=81=D1=82=20test=5Froundtrip.py,=20?= =?UTF-8?q?=D0=BE=D1=82=D0=BC=D0=B5=D1=82=D0=BA=D0=B0=20=D0=B7=D0=B0=D0=B4?= =?UTF-8?q?=D0=B0=D1=87=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lifespan приложения и эмулятор ИС в процессе теста (R-7) против compose NATS/MinIO, горячая папка в tmp-каталоге. Три сценария (R-10): aep_5.pdf (реальная фикстура) — INVALID_DOCUMENT от ИС, повтор во время ожидания и после результата даёт ещё один ack без второго файла; ok_doc.pdf (синтетический успех) — NOT_IMPLEMENTED; unknown.pdf — INVALID_DOCUMENT эмулятора. Проверяются схема конверта, service.name, Nats-Msg-Id, raw/.json байт в байт, входящий файл против объекта S3, /healthcheck, остановка без незавершённых задач и ошибок в логе. Co-Authored-By: Claude Fable 5.1 --- api/tests/integration/test_roundtrip.py | 419 ++++++++++++++++++ .../plans/2026-10-03-slice-0-skeleton.md | 2 +- openspec/changes/slice-0-skeleton/tasks.md | 2 +- 3 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 api/tests/integration/test_roundtrip.py diff --git a/api/tests/integration/test_roundtrip.py b/api/tests/integration/test_roundtrip.py new file mode 100644 index 0000000..9edb929 --- /dev/null +++ b/api/tests/integration/test_roundtrip.py @@ -0,0 +1,419 @@ +"""Сквозной сценарий scan-gateway против compose-инфраструктуры (задача 6.1, R-7, R-10). + +Настоящий lifespan приложения (`create_app` → S3, NATS, воркер, `NullNormalizer`) и эмулятор ИС Сканирование +(`src.scan_emulator.run` в потоке) работают в процессе теста; NATS 127.0.0.1:4222 и MinIO 127.0.0.1:9000 — из +`api/.env.test` (`make up`), горячая папка — tmp-каталог. + +Сценарии (R-10: обе реальные фикстуры `fixtures/ocr/*.json` — ответы ИС с ошибкой `INVALID_DOCUMENT`): +1. `aep_5.pdf` (реальная фикстура) → ack `processing`, затем `INVALID_DOCUMENT` с сообщением ИС и `raw_s3_key`; + повтор того же запроса во время ожидания ответа ИС и после результата — ещё один ack, второго файла нет; +2. `ok_doc.pdf` (синтетический успешный ответ ИС) → ack, затем `NOT_IMPLEMENTED` с `raw_s3_key`; +3. `unknown.pdf` (нет фикстуры) → ack, затем `INVALID_DOCUMENT` эмулятора. +Каждый конверт проверяется схемой `recognition-result-message`, `service.name` и `Nats-Msg-Id`; сырой ответ в MinIO +совпадает с ответом эмулятора байт в байт; входящий файл совпадает с объектом S3. + +Общая инфраструктура: NATS и MinIO используют и другие сервисы workspace. Чужие конверты на `recognition.result` +отсекаются по `job_id` теста; ключи MinIO уникальны (`uploads/itest-/…`, `raw/.json`) и удаляются. +Подписка шлюза идёт в 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 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 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.normalizer import NOT_IMPLEMENTED_MESSAGE +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" + +CONNECT_WAIT = 10.0 # секунд на подключение к NATS (тест и фоновая задача приложения) +SCENARIO_WAIT = 20.0 # секунд на ожидание конвертов одного сценария +QUIET_PERIOD = 1.0 # секунд тишины после повторного запроса: второго результата быть не должно +EMULATOR_DELAY = 0.5 # секунд «распознавания»: окно, в котором повторный запрос приходит во время ожидания ИС +POLL_SECONDS = 0.2 # SCAN_POLL_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(), +) +REQUEST_EXAMPLE: dict[str, Any] = json.loads( + (CONTRACTS_DIR / "examples" / "request-image.json").read_text(encoding="utf-8") +) + +# Синтетический успешный ответ ИС (R-10): копия `aep_5.json` с `error = null` и непустым `data_ocr` по форме +# docs/context/05-domain-rules.md §11 (документы с label/organization/inn/units/date/table_structure.rows) +SUCCESS_DATA_OCR: list[dict[str, Any]] = [ + { + "pages": [1], + "label": {"form_name": "Бухгалтерский баланс", "confidence": 98.4}, + "organization": {"full_name": "ООО «Русский бисквит»", "confidence": 97.1}, + "inn": {"inn": "7701234567", "confidence": 99.2}, + "units": {"measurement": "тыс. руб.", "confidence": 96.0}, + "date": {"date": "30.09.2025", "confidence": 95.5}, + "table_structure": { + "rows": [ + { + "row_index": 0, + "cells": [ + {"column_index": 0, "value": "Наименование показателя", "confidence": 99.0}, + {"column_index": 1, "value": "Код", "confidence": 99.0}, + {"column_index": 2, "value": "На 30 сентября 2025 г.", "confidence": 97.0}, + {"column_index": 3, "value": "На 31 декабря 2024 г.", "confidence": 97.0}, + ], + }, + { + "row_index": 1, + "cells": [ + {"column_index": 0, "value": "БАЛАНС", "confidence": 98.0}, + {"column_index": 1, "value": "1600", "confidence": 99.0}, + {"column_index": 2, "value": "1 234 567", "confidence": 96.5}, + {"column_index": 3, "value": "(12 345)", "confidence": 95.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 + + def answer_path(self, job_id: str) -> Path: + return self.root / self.settings.SCAN_OUTPUT_DIR / job_id / f"{job_id}.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"] + 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 = :`.""" + 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 + + # --- проверки ------------------------------------------------------------------------------------------------- + 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)) + + async def run(self, original_name: str, data: bytes) -> 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) + return body, envelopes[1] + + async def check_files(self, body: dict[str, Any], result: Envelope, data: bytes) -> None: + """Итог `error` с `raw_s3_key`; сырой ответ в MinIO = ответ эмулятора; входящий файл = объект S3.""" + job_id = body["job_id"] + assert result.body["status"] == "error" + assert result.body["file_id"] == body["file_id"] + assert result.body["raw_s3_key"] == f"raw/{job_id}.json" + raw = await self.s3.get_bytes(f"raw/{job_id}.json") + assert raw == self.answer_path(job_id).read_bytes() # как есть, байт в байт + assert json.loads(raw)["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 + # результат-файл пишется только для done/needs_check + with pytest.raises(S3Error): + await self.s3.client.stat_object(self.s3.bucket, f"results/{body['file_id']}/{job_id}/result.json") + name = f"{job_id}_{body['original_name']}.done" + assert self.inbox(job_id) == [name] + assert (self.input_dir / name).read_bytes() == await self.s3.get_bytes(body["s3"]["key"]) == data + + +def _write_fixtures(fixtures: Path) -> None: + """Каталог фикстур эмулятора: копия реальной `aep_5.json` и синтетическая успешная `ok_doc.json` (R-10).""" + fixtures.mkdir() + template: dict[str, Any] = {"file_uid": "aep_5"} + if REAL_FIXTURES.is_dir(): + shutil.copy(_real_fixture("aep_5", ".json"), fixtures / "aep_5.json") + template = json.loads((fixtures / "aep_5.json").read_text(encoding="utf-8")) + success = {**template, "file_uid": "ok_doc", "error": None, "data_ocr": SUCCESS_DATA_OCR} + (fixtures / "ok_doc.json").write_text(json.dumps(success, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _settings(monkeypatch: pytest.MonkeyPatch, root: Path) -> 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_TIMEOUT_SECONDS", str(TIMEOUT_SECONDS)) + 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, + fixtures_dir=fixtures, + delay=EMULATOR_DELAY, + poll_interval=0.05, + ) + 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) + + +@pytest.fixture +async def roundtrip(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, log_lines: list[str]) -> AsyncIterator[Roundtrip]: + root = tmp_path / "hotfolder" + root.mkdir() + _write_fixtures(tmp_path / "fixtures") + settings = _settings(monkeypatch, root) + 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.mark.fixtures +async def test_real_fixture_aep_5_passes_is_error_through_and_repeat_gets_ack(roundtrip: Roundtrip) -> None: + data = _real_fixture("aep_5", ".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("aep_5.pdf", data) + job_id = body["job_id"] + await roundtrip.publish(body) + inbox_file = roundtrip.input_dir / f"{job_id}_aep_5.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) + raw = json.loads(await roundtrip.s3.get_bytes(f"raw/{job_id}.json")) + assert raw["error"] == expected_error + + # повтор ядра после результата (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}_aep_5.pdf.done"] + + +@pytest.mark.fixtures +async def test_success_answer_gives_not_implemented(roundtrip: Roundtrip) -> None: + data = _real_fixture("aep_5", ".pdf").read_bytes() # байты реального скана под именем ok_doc.pdf + body, result = await roundtrip.run("ok_doc.pdf", data) + job_id = body["job_id"] + assert result.body["error"] == {"code": "NOT_IMPLEMENTED", "message": NOT_IMPLEMENTED_MESSAGE} + raw = json.loads(await roundtrip.s3.get_bytes(f"raw/{job_id}.json")) + assert raw["error"] is None + assert raw["data_ocr"] == SUCCESS_DATA_OCR + + +async def test_unknown_name_gets_emulator_invalid_document(roundtrip: Roundtrip) -> None: + data = b"%PDF-1.4\n% scan-gateway itest unknown.pdf\n\x00\x01\x02" + body, result = await roundtrip.run("unknown.pdf", data) + job_id = body["job_id"] + error = {"code": "INVALID_DOCUMENT", "message": "unknown.pdf: нет эталонного ответа в фикстурах"} + assert result.body["error"] == error + assert json.loads(await roundtrip.s3.get_bytes(f"raw/{job_id}.json")) == { + "file_uid": job_id, + "error": error, + "data_ocr": [], + } diff --git a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md index fa7fa90..98bfe80 100644 --- a/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md +++ b/docs/superpowers/plans/2026-10-03-slice-0-skeleton.md @@ -72,7 +72,7 @@ ### Task 14: [6.1] `tests/integration/test_roundtrip.py` (маркер `integration`) -- [ ] `tests/integration/test_roundtrip.py` (маркер `integration`): compose с `scan-emulator` — положить `aep_5.pdf` в MinIO `uploads/…`, опубликовать `request-image.json`, собрать `recognition.result`: `processing`, затем `NOT_IMPLEMENTED` с `raw_s3_key`; проверить объект `raw/.json` в MinIO; второй запрос с `unknown.pdf` → `INVALID_DOCUMENT`; проверка: зелёный +- [x] `tests/integration/test_roundtrip.py` (маркер `integration`): compose с `scan-emulator` — положить `aep_5.pdf` в MinIO `uploads/…`, опубликовать `request-image.json`, собрать `recognition.result`: `processing`, затем `NOT_IMPLEMENTED` с `raw_s3_key`; проверить объект `raw/.json` в MinIO; второй запрос с `unknown.pdf` → `INVALID_DOCUMENT`; проверка: зелёный ### Task 15: [6.2] `make check-full` diff --git a/openspec/changes/slice-0-skeleton/tasks.md b/openspec/changes/slice-0-skeleton/tasks.md index dc359de..8fc894f 100644 --- a/openspec/changes/slice-0-skeleton/tasks.md +++ b/openspec/changes/slice-0-skeleton/tasks.md @@ -61,7 +61,7 @@ ## 6. Интеграция, проверки, завершение -- [ ] 6.1 `tests/integration/test_roundtrip.py` (маркер `integration`): compose с `scan-emulator` — положить `aep_5.pdf` в +- [x] 6.1 `tests/integration/test_roundtrip.py` (маркер `integration`): compose с `scan-emulator` — положить `aep_5.pdf` в MinIO `uploads/…`, опубликовать `request-image.json`, собрать `recognition.result`: `processing`, затем `NOT_IMPLEMENTED` с `raw_s3_key`; проверить объект `raw/.json` в MinIO; второй запрос с `unknown.pdf` → `INVALID_DOCUMENT`; проверка: зелёный