test: интеграционные тесты NATS (заголовок Nats-Msg-Id, queue group) и S3Storage против compose; отметки Task 8 / 4.2

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Andrey Epifancev 2026-10-04 02:48:26 +04:00
parent b545143f9e
commit 6525939888
4 changed files with 190 additions and 2 deletions

View File

@ -0,0 +1,149 @@
"""Интеграционные тесты `src/core/nats.py` против compose-NATS без JetStream (`.env.test`: nats://localhost:4222;
spec «Соединение с NATS по правилам шаблона, без JetStream»; design D-6).
`.env.test` содержит `NATS_ENABLED=false` (для приложения) — тесты строят соединение сами. Subject'ы уникальны на
тест (`test.tabular.<uuid>`): общие `recognition.*` не трогаем, на шине может работать другой сервис.
"""
import asyncio
import time
import uuid
from collections import Counter
from typing import Any
import pytest
from nats.aio.client import Client as NATS
from nats.aio.msg import Msg
from src.contract.subjects import NATS_MSG_ID_HEADER
from src.core.config import NATSSettings, get_settings
from src.core.nats import REQUEST_QUEUE_GROUP, NatsConnection, NatsPublishError
pytestmark = pytest.mark.integration
READY_TIMEOUT = 5.0
START_MAX_SECONDS = 0.5 # start() не ждёт подключения
STOP_MAX_SECONDS = 2.0
def _nats_settings() -> NATSSettings:
return get_settings().OPENBAO.NATS
def _unique_subject() -> str:
return f"test.tabular.{uuid.uuid4().hex}"
async def _wait_until(predicate: Any, timeout: float = 5.0) -> None:
deadline = time.monotonic() + timeout
while not predicate():
if time.monotonic() > deadline:
raise AssertionError("условие не выполнено за отведённое время")
await asyncio.sleep(0.02)
async def _start(conn: NatsConnection) -> NatsConnection:
await conn.start()
if not await conn.wait_ready(timeout=READY_TIMEOUT):
await conn.stop()
pytest.fail(f"NATS недоступен ({_nats_settings().NATS_URL}, .env.test; make up)", pytrace=False)
return conn
async def _plain_client() -> NATS:
nc = NATS()
await nc.connect(_nats_settings().NATS_URL, connect_timeout=2, max_reconnect_attempts=1)
return nc
async def test_subscribe_requests_receives_message_with_msg_id_header():
"""Запрос ядра (сторонний клиент с `Nats-Msg-Id`) доходит до подписки сервиса в queue group с заголовком."""
subject = _unique_subject()
received: list[Msg] = []
async def handler(msg: Msg) -> None:
received.append(msg)
async def on_connected(conn: NatsConnection) -> None:
await conn.subscribe_requests(handler, subject=subject)
conn = await _start(NatsConnection(_nats_settings(), on_connected=on_connected))
core = await _plain_client()
try:
assert conn.is_connected
await core.publish(subject, b'{"job_id": "j-1"}', headers={NATS_MSG_ID_HEADER: "j-1:1"})
await core.flush()
await _wait_until(lambda: len(received) == 1)
finally:
await core.close()
await conn.stop()
msg = received[0]
assert msg.subject == subject
assert msg.data == b'{"job_id": "j-1"}'
assert msg.headers == {NATS_MSG_ID_HEADER: "j-1:1"}
async def test_publish_delivers_payload_with_msg_id_header():
"""Ack/результат сервиса доходит до подписчика (как ядро) с заголовком `Nats-Msg-Id`."""
subject = _unique_subject()
core = await _plain_client()
subscription = await core.subscribe(subject)
conn = await _start(NatsConnection(_nats_settings()))
try:
await conn.publish(subject, b'{"status": "processing"}', "ack:j-1:1")
msg = await subscription.next_msg(timeout=5)
finally:
await conn.stop()
await core.close()
assert msg.data == b'{"status": "processing"}'
assert msg.headers == {NATS_MSG_ID_HEADER: "ack:j-1:1"}
async def test_queue_group_delivers_each_request_to_exactly_one_instance():
subject = _unique_subject()
received: dict[str, list[str]] = {"a": [], "b": []}
def make_on_connected(name: str) -> Any:
async def handler(msg: Msg) -> None:
assert msg.headers is not None
received[name].append(msg.headers[NATS_MSG_ID_HEADER])
async def on_connected(conn: NatsConnection) -> None:
await conn.subscribe_requests(handler, subject=subject)
return on_connected
instance_a = await _start(NatsConnection(_nats_settings(), on_connected=make_on_connected("a")))
instance_b = await _start(NatsConnection(_nats_settings(), on_connected=make_on_connected("b")))
total = 20
try:
for i in range(total):
await instance_a.publish(subject, b"{}", f"job-{i}:1")
await _wait_until(lambda: len(received["a"]) + len(received["b"]) >= total)
await asyncio.sleep(0.2) # дубль, если бы он был, успел бы прийти
finally:
await instance_a.stop()
await instance_b.stop()
assert REQUEST_QUEUE_GROUP == "tabular-parser"
all_ids = received["a"] + received["b"]
assert Counter(all_ids) == Counter(f"job-{i}:1" for i in range(total))
async def test_start_against_unreachable_server_does_not_block_and_stops_cleanly():
"""O-3: NATS недоступен при старте — start() не ждёт, публикация после повторов падает, stop() быстрый."""
conn = NatsConnection(NATSSettings(NATS_URL="nats://127.0.0.1:54999"), publish_retries=2, publish_retry_pause=0.05)
started = time.monotonic()
await conn.start()
assert time.monotonic() - started < START_MAX_SECONDS
await asyncio.sleep(0.3)
assert not conn.is_connected
with pytest.raises(NatsPublishError):
await conn.publish("test.tabular.unreachable", b"{}", "m:1")
started = time.monotonic()
await conn.stop()
assert time.monotonic() - started < STOP_MAX_SECONDS

View File

@ -0,0 +1,39 @@
"""Интеграционный тест `S3Storage` против compose-MinIO (`.env.test`: localhost:9000, бакет `arm-ka`; design D-4).
Ключ уникален на прогон (`tests/<uuid>/x.json`) и удаляется после теста.
"""
import json
import uuid
import pytest
from miniopy_async import Minio
from src.core.clients import S3Storage
from src.core.config import get_settings
pytestmark = pytest.mark.integration
async def test_put_json_then_get_bytes_round_trip_in_minio():
minio = get_settings().OPENBAO.MINIO
client = Minio(
minio.MINIO_URL,
access_key=minio.MINIO_ADMIN_LOGIN,
secret_key=minio.MINIO_ADMIN_PASSWORD,
secure=minio.MINIO_SECURE,
)
storage = S3Storage(client=client, bucket_name=minio.MINIO_BUCKET)
key = f"tests/{uuid.uuid4()}/x.json"
obj = {"job_id": "j-1", "сообщение": "разбор XLSX", "amount": "1234.50"}
try:
await storage.put_json(minio.MINIO_BUCKET, key, obj)
data = await storage.get_bytes(minio.MINIO_BUCKET, key)
stat = await client.stat_object(minio.MINIO_BUCKET, key)
finally:
await client.remove_object(minio.MINIO_BUCKET, key)
await client.close_session()
assert json.loads(data.decode("utf-8")) == obj
assert stat.content_type == "application/json"

View File

@ -47,7 +47,7 @@
### Task 8: [4.2] `src/core/clients.py` ### Task 8: [4.2] `src/core/clients.py`
- [ ] `src/core/clients.py`: `S3Storage.get_bytes(bucket, key)`, `put_json(bucket, key, obj)`; `src/core/nats.py`: подключение (с/без nkeys, автопереподключение), `subscribe_requests(queue="tabular-parser", cb)`, `publish(subject, payload, msg_id)` с повторами; проверка: unit-тесты с фейковым соединением; интеграционный тест подключения к compose-NATS без JetStream и получения сообщения с заголовком - [x] `src/core/clients.py`: `S3Storage.get_bytes(bucket, key)`, `put_json(bucket, key, obj)`; `src/core/nats.py`: подключение (с/без nkeys, автопереподключение), `subscribe_requests(queue="tabular-parser", cb)`, `publish(subject, payload, msg_id)` с повторами; проверка: unit-тесты с фейковым соединением; интеграционный тест подключения к compose-NATS без JetStream и получения сообщения с заголовком
### Task 9: [4.3] `src/core/worker.py` ### Task 9: [4.3] `src/core/worker.py`

View File

@ -34,7 +34,7 @@
- [x] 4.1 `src/parser/__init__.py`: протокол `Parser`, `ParseOutcome`, `NullParser` (`NOT_IMPLEMENTED`); `api/rules/README.md`; - [x] 4.1 `src/parser/__init__.py`: протокол `Parser`, `ParseOutcome`, `NullParser` (`NOT_IMPLEMENTED`); `api/rules/README.md`;
`tests/unit/test_null_parser.py`; проверка: заглушка возвращает код и сообщение, не трогая входные байты `tests/unit/test_null_parser.py`; проверка: заглушка возвращает код и сообщение, не трогая входные байты
- [ ] 4.2 `src/core/clients.py`: `S3Storage.get_bytes(bucket, key)`, `put_json(bucket, key, obj)`; `src/core/nats.py`: - [x] 4.2 `src/core/clients.py`: `S3Storage.get_bytes(bucket, key)`, `put_json(bucket, key, obj)`; `src/core/nats.py`:
подключение (с/без nkeys, автопереподключение), `subscribe_requests(queue="tabular-parser", cb)`, подключение (с/без nkeys, автопереподключение), `subscribe_requests(queue="tabular-parser", cb)`,
`publish(subject, payload, msg_id)` с повторами; проверка: unit-тесты с фейковым соединением; интеграционный тест `publish(subject, payload, msg_id)` с повторами; проверка: unit-тесты с фейковым соединением; интеграционный тест
подключения к compose-NATS без JetStream и получения сообщения с заголовком подключения к compose-NATS без JetStream и получения сообщения с заголовком