Решение владельца 08.10.2026: recognition.tabular.request → files.tabular_uploaded, recognition.image.request → files.img_uploaded, recognition.result → files.tabular_parsed (общий subject ack и результатов; files.img_parsed не используется). Тела сообщений, Nats-Msg-Id, queue group'ы и schema_version не меняются. Константы api/src/contract/subjects.py, докстринги, тесты, README, CLAUDE.md; копии схем api/contracts/ из docs/contracts/ побайтно; change nats-files-subjects с дельтой спеки. Сквозной change — RSHB/arm-ka-docs#74. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
170 lines
6.3 KiB
Python
170 lines
6.3 KiB
Python
"""Интеграционные тесты `src/core/nats.py` против NATS без JetStream из `.env.test` (spec recognition-job-exchange,
|
||
«Соединение с NATS по шаблону, без JetStream»; design D-6).
|
||
|
||
Subject'ы — уникальные на тест (`test.<uuid>`): общий `files.tabular_parsed` не трогаем, чтобы не пересекаться
|
||
с другими запущенными инстансами ядра.
|
||
"""
|
||
|
||
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"}
|