From b7d4f0c17a03906ba18bc549c65e896e22abbe42 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Sun, 4 Oct 2026 03:18:09 +0400 Subject: [PATCH] =?UTF-8?q?test,=20docs:=20=D0=BF=D0=BE=D1=80=D1=8F=D0=B4?= =?UTF-8?q?=D0=BE=D0=BA=20=D1=81=D1=82=D0=B0=D1=80=D1=82=D0=B0=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20=D0=B1=D0=B0?= =?UTF-8?q?=D0=BA=D0=B5=D1=82=D0=B0=20=D0=B2=20=D1=82=D0=B5=D1=81=D1=82?= =?UTF-8?q?=D0=B0=D1=85=20=D0=BF=D1=80=D0=B8=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD?= =?UTF-8?q?=D0=B8=D1=8F;=20README/CLAUDE.md=20=D0=B1=D0=B5=D0=B7=20=D1=83?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=80=D0=B5=D0=B2=D1=88=D0=B8=D1=85=20=C2=AB?= =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D1=8F=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D0=B7=D0=B0=D0=B4=D0=B0=D1=87=D0=B5=D0=B9=C2=BB,=20sp?= =?UTF-8?q?ec=20=D0=B1=D0=B5=D0=B7=20=D0=BD=D0=BE=D0=BC=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=20=D1=80=D1=83=D0=BB=D0=B8=D0=BD=D0=B3=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit T9.2: события старта worker.start → nats.start; _connect_s3 — info/warning/error по ответу проверки бакета, клиент остаётся. T9.3: spec без «R-17». T9.4: README и CLAUDE.md описывают уже существующие файлы без отсылок к будущим задачам. Co-Authored-By: Claude Fable 5.1 --- CLAUDE.md | 7 +-- README.md | 18 +++--- api/tests/unit/test_app.py | 61 ++++++++++++++++++- .../specs/tabular-recognition-service/spec.md | 2 +- 4 files changed, 72 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index cb33e8e..683eff3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -37,11 +37,10 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote `requirements.txt` — `asyncpg`, `SQLAlchemy`, `alembic`, `smbprotocol`, `argon2-cffi`, `greenlet`, `Mako`, `raisa_fastapi_protected_api`; шаблонный `README.md` заменён описанием сервиса. -**Добавлено** (целевое состояние среза; каждый пункт появляется своей задачей `openspec/changes/slice-0-skeleton/tasks.md`, -до этого файла нет): `api/src/parser/` (интерфейс `Parser` и заглушка `NullParser`; разбор ОСВ §1 и форм §12 — чистые +**Добавлено** (в скобках — задача `openspec/changes/slice-0-skeleton/tasks.md`): `api/src/parser/` (интерфейс `Parser` и заглушка `NullParser`; разбор ОСВ §1 и форм §12 — чистые функции над книгой Excel, Срезы 2+), `api/src/contract/` (pydantic-модели `recognition-result` v1.0) и `api/contracts/` -(копии схем; идентичность с `../docs/contracts` проверяется в `make check`) — задача 3.1, `api/rules/*.yaml` (словари -синонимов — в документах это `rules/*.yaml` относительно `api/`) — 4.1, `api/src/core/nats.py` и `api/src/core/worker.py` +(копии схем; идентичность с `../docs/contracts` проверяется в `make check`) — задача 3.1, `api/rules/` (README о назначении; +сами словари `*.yaml` — в документах это `rules/*.yaml` относительно `api/` — появятся в Срезе 2) — 4.1, `api/src/core/nats.py` и `api/src/core/worker.py` (подписка queue group `tabular-parser` на `recognition.tabular.request` → ack → S3 `uploads/` → парсер → S3 `results/` → `recognition.result`) — 4.2–4.3, `api/pyproject.toml` (ruff, mypy) и `Makefile` — 2.1, `Dockerfile`, `.env.example`, `.env.test` — 2.2. Проверки: `make check` (`pytest`, `ruff check . && ruff format --check .`, `mypy src`), diff --git a/README.md b/README.md index c070aeb..875d934 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Stateless-сервис распознавания табличных файло `arm-core`. Обмен с `arm-core` идёт по контракту **v1.0**: схемы — [`../docs/contracts/`](../docs/contracts/), копия — в -`api/contracts/` (добавляется задачей 3.1 среза и проверяется на идентичность в `make check`). +`api/contracts/` (идентичность с источником проверяется в `make check`). **Срез 0 — скелет.** Транспорт работает полностью (подписка, ack, чтение S3, публикация результата, ошибки транспорта), а сам разбор заменён заглушкой: на любой корректный запрос сервис отвечает конвертом `status = error` с @@ -51,15 +51,15 @@ api/ бэкенд (все команды разработки src/app.py FastAPI: lifespan (MinIO, NATS, воркер), GET /healthcheck, middleware, обработчики ошибок src/core/ config.py (настройки), clients.py (S3), nats.py (соединение NATS), worker.py (воркер распознавания), logger.py, exception_handlers.py - src/parser/ интерфейс Parser и реализации (добавляется задачей 4.1) - src/contract/ pydantic-модели контракта v1.0 (добавляется задачей 3.1) - contracts/ копии JSON-схем контракта (добавляется задачей 3.1) - rules/ словари синонимов *.yaml для парсера (добавляется задачей 4.1) + src/parser/ интерфейс Parser и заглушка NullParser (разбор — Срез 2) + src/contract/ pydantic-модели контракта v1.0 + contracts/ копии JSON-схем контракта и примеры + rules/ словари синонимов *.yaml для парсера (пока только README; словари — Срез 2) tests/ pytest: unit и integration (маркер `integration`) setup/ pip.conf и setup.sh (создание venv без uv) raisa_config.yml конфигурация платформы РАИСА -Makefile команды разработки (добавляется задачей 2.1) -Dockerfile образ сервиса, python:3.11-slim (добавляется задачей 2.2) +Makefile команды разработки +Dockerfile образ сервиса, python:3.11-slim openspec/ требования и дельта текущего среза (openspec/changes/slice-0-skeleton/) ``` @@ -87,8 +87,6 @@ openspec/ требования и дельта текущего ср | `make check-full` | `make check` + `make test-integration` + `docker build`; запускать перед PR | | `make up` | `docker compose -f ../docs/dev/docker-compose.yml up -d --wait postgres nats minio` | -Все цели `Makefile` (включая `check-full`) создаются задачей 2.1; `docker build` в `check-full` заработает после задачи 2.2 (`Dockerfile`). - ## Запуск локально против compose ```bash @@ -100,7 +98,7 @@ cd api && .venv/bin/python run_server.py ``` Значения из `api/.env.example` уже совпадают с `../docs/dev/.env` (стандартные порты и учётные данные compose); править -`api/.env` нужно, только если вы меняли порты или пароли compose. Файл `api/.env.example` создаётся задачей 2.2. +`api/.env` нужно, только если вы меняли порты или пароли compose. Подробности про compose, смену портов и сброс — [`../docs/dev/README.md`](../docs/dev/README.md). Сервис слушает `0.0.0.0:8000` (`SERVER__HOST`, `SERVER__PORT`). Проверка: diff --git a/api/tests/unit/test_app.py b/api/tests/unit/test_app.py index f89ff75..48e34c3 100644 --- a/api/tests/unit/test_app.py +++ b/api/tests/unit/test_app.py @@ -15,8 +15,10 @@ from typing import Any import httpx import pytest +from loguru import logger from src.contract import NATS_MSG_ID_HEADER, RESULT_SUBJECT +from src.core.clients import S3Storage from src.core.worker import RecognitionWorker EXAMPLES_DIR = Path(__file__).resolve().parents[2] / "contracts" / "examples" @@ -108,6 +110,10 @@ def patched_app(monkeypatch: pytest.MonkeyPatch, events: list[str]) -> Iterator[ return storage class RecordingWorker(RecognitionWorker): + def start(self) -> None: + events.append("worker.start") + super().start() + async def stop(self, timeout: float = 10.0) -> None: events.append("worker.stop") await super().stop(timeout) @@ -164,8 +170,9 @@ async def test_nats_enabled_subscribes_worker_and_reports_ok( ] assert connection.published[1][1]["error"]["code"] == "NOT_IMPLEMENTED" assert connection.published[1][1]["service"]["name"] == "tabular-parser" + # Старт: воркер готов до подключения (on_connected сразу подписывает worker.on_request). # Остановка: воркер дорабатывает очередь, пока NATS ещё жив, затем NATS, затем S3 - assert events == ["nats.start", "worker.stop", "nats.stop", "s3.close"] + assert events == ["worker.start", "nats.start", "worker.stop", "nats.stop", "s3.close"] async def test_nats_enabled_but_not_connected_reports_down(patched_app: Any, monkeypatch: pytest.MonkeyPatch) -> None: @@ -177,3 +184,55 @@ async def test_nats_enabled_but_not_connected_reports_down(patched_app: Any, mon (connection,) = FakeNatsConnection.instances assert connection.handlers == [] # подписка — только после подключения assert patched_app.app.state.worker is not None + + +# --- проверка бакета при старте (_connect_s3) ------------------------------------------------------------------------ +@pytest.fixture +def log_records() -> Iterator[list[Any]]: + records: list[Any] = [] + handler_id = logger.add(lambda message: records.append(message.record), level="DEBUG") + yield records + logger.remove(handler_id) + + +class FakeMinio: + """Подмена `miniopy_async.Minio`: `bucket_exists` отдаёт заданный ответ или бросает исключение.""" + + answer: bool | Exception = True + closed = 0 + + def __init__(self, *_args: Any, **_kwargs: Any) -> None: + pass + + async def bucket_exists(self, bucket: str) -> bool: + if isinstance(FakeMinio.answer, Exception): + raise FakeMinio.answer + return FakeMinio.answer + + async def close_session(self) -> None: + FakeMinio.closed += 1 + + +@pytest.mark.parametrize( + ("answer", "level", "fragment"), + [ + pytest.param(True, "INFO", "Бакет проверен", id="bucket-exists"), + pytest.param(False, "WARNING", "Бакет не найден", id="bucket-missing"), + pytest.param(ConnectionError("MinIO недоступен"), "ERROR", "Не удалось проверить", id="minio-down"), + ], +) +async def test_connect_s3_logs_bucket_check_and_keeps_client( + monkeypatch: pytest.MonkeyPatch, log_records: list[Any], answer: Any, level: str, fragment: str +) -> None: + monkeypatch.setattr(FakeMinio, "answer", answer) + monkeypatch.setattr(FakeMinio, "closed", 0) + monkeypatch.setattr(app_module, "Minio", FakeMinio) + + storage = await app_module._connect_s3() + + # Клиент остаётся в любом случае: MinIO может подняться позже, воркер прочитает файл без рестарта + assert isinstance(storage, S3Storage) + assert storage.bucket == app_module.settings.OPENBAO.MINIO.MINIO_BUCKET + assert FakeMinio.closed == 0 + messages = [record["message"] for record in log_records if record["level"].name == level] + assert any(fragment in message for message in messages), messages diff --git a/openspec/changes/slice-0-skeleton/specs/tabular-recognition-service/spec.md b/openspec/changes/slice-0-skeleton/specs/tabular-recognition-service/spec.md index f3b8f8f..55fca3c 100644 --- a/openspec/changes/slice-0-skeleton/specs/tabular-recognition-service/spec.md +++ b/openspec/changes/slice-0-skeleton/specs/tabular-recognition-service/spec.md @@ -81,7 +81,7 @@ NATS и S3; воркер MUST NOT зависеть от конкретной р При пустых `NATS_SEED`/`NATS_JWT` сервис SHALL подключаться без учётных данных, иначе — с nkeys/JWT. Сервис MUST NOT требовать stream'ов или consumer'ов JetStream; при разрыве соединения SHALL переподключаться автоматически и восстанавливать подписку; `/healthcheck` SHALL отражать состояние (`nats = ok | down`; `off` — NATS выключен флагом -`NATS_ENABLED=false`, R-17). +`NATS_ENABLED=false`). #### Scenario: NATS перезапущен - **WHEN** контейнер NATS перезапущен во время работы сервиса