arm-core/api/tests/test_nats_integration.py
Andrey Epifancev 4e5e1a7b5b feat: src/core/nats.py — фоновое подключение к core NATS, publish с Nats-Msg-Id, подписка recognition.result в queue group arm-core
- NatsConnection: supervisor первого подключения в фоне (старт приложения не ждёт NATS), nats-py
  переподключается сам (max_reconnect_attempts=-1, reconnect_time_wait=2) и восстанавливает подписки;
  экспоненциальный backoff 1→30 с, если connect завершился исключением; логирование ошибок без шума
  (первая ошибка серии — warning, дальше не чаще раза в минуту).
- publish(subject, payload, msg_id): заголовок Nats-Msg-Id + flush; нет соединения или нет подтверждения —
  NatsPublishError. subscribe_results(handler): queue group arm-core, исключения обработчика логируются.
- build_nats_auth перенесён из app.py; ошибка учётных данных — NatsConfigError, категория config_error,
  без повторов (T4.1). Первое подключение повторяется (T4.2): прежний await connect в lifespan при
  недоступном NATS висел бесконечно (nats-py с max_reconnect_attempts=-1 повторяет первое подключение сам).
- app.py: подключение переехало в core/nats.py (R-3), lifespan запускает/останавливает NatsConnection,
  подписка — заглушка services/recognition_exchange.handle_message (полная обработка — задача 24).
- Тесты: юнит (поддельный клиент, инжектируемый sleep, отсутствие JetStream в src/) и интеграционные
  против NATS без JetStream (заголовок, queue group без дублей, недоступный сервер).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-03 23:47:39 +04:00

170 lines
6.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Интеграционные тесты `src/core/nats.py` против NATS без JetStream из `.env.test` (spec recognition-job-exchange,
«Соединение с NATS по шаблону, без JetStream»; design D-6).
Subject'ы — уникальные на тест (`test.<uuid>`): общий `recognition.result` не трогаем, чтобы не пересекаться
с другими запущенными инстансами ядра.
"""
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 RESULT_QUEUE_GROUP, NatsConnection, NatsPublishError
pytestmark = pytest.mark.integration
READY_TIMEOUT = 5.0
START_MAX_SECONDS = 0.5 # start() не ждёт подключения
STOP_MAX_SECONDS = 2.0 # stop() не висит в цикле подключения nats-py
def _nats_settings() -> NATSSettings:
return get_settings().OPENBAO.NATS
def _unique_subject() -> str:
return f"test.{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)", pytrace=False)
return conn
async def test_publish_delivers_payload_and_msg_id_header():
subject = _unique_subject()
received: list[Msg] = []
async def handler(msg: Msg) -> None:
received.append(msg)
async def subscribe(conn: NatsConnection) -> None:
await conn.subscribe_results(handler)
conn = await _start(NatsConnection(_nats_settings(), on_connected=subscribe, result_subject=subject))
try:
assert conn.is_connected
await conn.publish(subject, b'{"job": 1}', "job-1:1")
await _wait_until(lambda: len(received) == 1)
finally:
await conn.stop()
msg = received[0]
assert msg.subject == subject
assert msg.data == b'{"job": 1}'
assert msg.headers == {NATS_MSG_ID_HEADER: "job-1:1"}
async def test_queue_group_delivers_each_message_to_exactly_one_instance():
"""Два инстанса ядра в queue group `arm-core`: каждое сообщение обработано ровно одним (без дублей)."""
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_results(handler)
return on_connected
instance_a = await _start(
NatsConnection(_nats_settings(), on_connected=make_on_connected("a"), result_subject=subject)
)
instance_b = await _start(
NatsConnection(_nats_settings(), on_connected=make_on_connected("b"), result_subject=subject)
)
assert instance_a.queue == instance_b.queue == RESULT_QUEUE_GROUP
total = 20
try:
for i in range(total):
await instance_a.publish(subject, f"result {i}".encode(), f"result: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()
all_ids = received["a"] + received["b"]
assert len(all_ids) == total
assert Counter(all_ids) == Counter(f"result:job-{i}:1" for i in range(total))
async def test_subscription_survives_handler_error():
subject = _unique_subject()
received: list[bytes] = []
async def handler(msg: Msg) -> None:
if msg.data == b"boom":
raise ValueError("сломанное сообщение")
received.append(msg.data)
async def subscribe(conn: NatsConnection) -> None:
await conn.subscribe_results(handler)
conn = await _start(NatsConnection(_nats_settings(), on_connected=subscribe, result_subject=subject))
try:
await conn.publish(subject, b"boom", "m:1")
await conn.publish(subject, b"ok", "m:2")
await _wait_until(lambda: received == [b"ok"])
finally:
await conn.stop()
async def test_start_against_unreachable_server_does_not_block_and_stops_cleanly():
"""Регрессия T4.2: недоступный NATS при старте — start() не ждёт, is_connected=False, stop() быстрый."""
settings = NATSSettings(NATS_URL="nats://127.0.0.1:54999")
conn = NatsConnection(settings)
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.unreachable", b"{}", "m:1")
started = time.monotonic()
await conn.stop()
assert time.monotonic() - started < STOP_MAX_SECONDS
assert not conn.is_connected
async def test_plain_client_receives_publish_from_connection():
"""Сторонний подписчик (как сервис распознавания) видит заголовок `Nats-Msg-Id` от `NatsConnection.publish`."""
subject = _unique_subject()
nc = NATS()
await nc.connect(_nats_settings().NATS_URL, connect_timeout=2, max_reconnect_attempts=1)
subscription = await nc.subscribe(subject)
conn = await _start(NatsConnection(_nats_settings()))
try:
await conn.publish(subject, b"payload", "job-x:2")
msg = await subscription.next_msg(timeout=5)
finally:
await conn.stop()
await nc.close()
assert msg.data == b"payload"
assert msg.headers == {NATS_MSG_ID_HEADER: "job-x:2"}