"""Слой NATS (`src.core.nats`): подключение с/без nkeys, колбэки переподключения, подписка queue group, публикация с `Nats-Msg-Id` и повторами. Клиент nats-py подменяется фейком — сеть не нужна. Переподключение к живому серверу не проверяется (общий контейнер NATS не перезапускаем): здесь — только колбэки и восстановление подписки клиентом. """ import base64 import json import uuid from typing import Any import nkeys import pytest from nats import errors as nats_errors from src.contract import ( IMAGE_REQUEST_SUBJECT, NATS_MSG_ID_HEADER, RESULT_SUBJECT, ErrorCode, ServiceInfo, build_ack_message, build_error_message, ) from src.core.config import NATSSettings, Settings, get_settings from src.core.nats import ( REQUEST_QUEUE_GROUP, NatsConfigError, NatsGateway, NatsNotConnectedError, PublishFailed, build_nats_auth, used_subjects, ) class FakeMsg: def __init__(self, data: bytes = b"{}", headers: dict[str, str] | None = None) -> None: self.subject = IMAGE_REQUEST_SUBJECT self.data = data self.headers = headers class FakeNatsClient: """Минимальная подмена `nats.aio.client.Client`: запоминает вызовы, ошибки публикации задаются очередью.""" def __init__(self) -> None: self.connect_args: tuple[Any, ...] = () self.connect_kwargs: dict[str, Any] = {} self.is_connected = False self.is_closed = False self.published: list[tuple[str, bytes, dict[str, str] | None]] = [] self.publish_errors: list[BaseException] = [] self.flush_calls = 0 self.subscriptions: list[dict[str, Any]] = [] self.drained = False self.closed = False self.jetstream_calls = 0 async def connect(self, *args: Any, **kwargs: Any) -> None: self.connect_args = args self.connect_kwargs = kwargs self.is_connected = True async def publish(self, subject: str, payload: bytes = b"", headers: dict[str, str] | None = None) -> None: self.published.append((subject, payload, headers)) if self.publish_errors: raise self.publish_errors.pop(0) async def flush(self, timeout: float = 10) -> None: self.flush_calls += 1 async def subscribe(self, subject: str, queue: str = "", cb: Any = None) -> str: self.subscriptions.append({"subject": subject, "queue": queue, "cb": cb}) return f"sub-{len(self.subscriptions)}" async def drain(self) -> None: self.drained = True self.is_connected = False self.is_closed = True async def close(self) -> None: self.closed = True self.is_connected = False self.is_closed = True def jetstream(self, **opts: Any) -> None: # pragma: no cover - вызов и есть ошибка self.jetstream_calls += 1 raise AssertionError("JetStream не используется (D-8)") def make_settings(monkeypatch: pytest.MonkeyPatch, **env: str) -> Settings: for name, value in env.items(): monkeypatch.setenv(name, value) get_settings.cache_clear() return get_settings() @pytest.fixture def settings(monkeypatch: pytest.MonkeyPatch) -> Settings: return make_settings(monkeypatch) @pytest.fixture def fake() -> FakeNatsClient: return FakeNatsClient() @pytest.fixture def sleeps() -> list[float]: return [] @pytest.fixture def gateway(settings: Settings, fake: FakeNatsClient, sleeps: list[float]) -> NatsGateway: async def fake_sleep(delay: float) -> None: sleeps.append(delay) return NatsGateway(settings, client_factory=lambda: fake, retry_delay=0, sleep=fake_sleep) # --- подключение ------------------------------------------------------------------------------------------------ async def test_connect_without_credentials_when_seed_and_jwt_empty(gateway: NatsGateway, fake: FakeNatsClient): assert await gateway.connect() is True assert fake.connect_args == ("nats://127.0.0.1:4222",) kwargs = fake.connect_kwargs assert kwargs["max_reconnect_attempts"] == -1 assert kwargs["name"] == "scan-gateway" for callback in ("error_cb", "disconnected_cb", "reconnected_cb", "closed_cb"): assert callable(kwargs[callback]) for credential in ("user_jwt_cb", "signature_cb", "nkeys_seed", "nkeys_seed_str", "user_credentials", "token"): assert credential not in kwargs assert fake.jetstream_calls == 0 assert gateway.is_connected is True async def test_connect_with_nkeys_and_jwt_when_both_set(monkeypatch: pytest.MonkeyPatch, fake: FakeNatsClient): # Фиктивная пара ключей создаётся в тесте: настоящих секретов в репозитории нет keypair = nkeys.from_seed(nkeys.encode_seed(b"\x01" * 32, nkeys.PREFIX_BYTE_USER)) seed = keypair.seed.decode() settings = make_settings(monkeypatch, OPENBAO__NATS__NATS_SEED=seed, OPENBAO__NATS__NATS_JWT="dummy.jwt.token") gateway = NatsGateway(settings, client_factory=lambda: fake) await gateway.connect() kwargs = fake.connect_kwargs assert kwargs["user_jwt_cb"]() == b"dummy.jwt.token" nonce = "nonce-123" signature = base64.b64decode(kwargs["signature_cb"](nonce)) assert keypair.verify(nonce.encode(), signature) is True assert fake.jetstream_calls == 0 def test_build_nats_auth_strips_whitespace_around_seed_and_jwt(monkeypatch: pytest.MonkeyPatch): """Пробелы и перевод строки по краям (типично для значений из OpenBao) срезаются: seed читается, JWT чистый.""" keypair = nkeys.from_seed(nkeys.encode_seed(b"\x02" * 32, nkeys.PREFIX_BYTE_USER)) settings = make_settings( monkeypatch, OPENBAO__NATS__NATS_SEED=f" {keypair.seed.decode()}\n", OPENBAO__NATS__NATS_JWT="\n dummy.jwt.token \n", ) kwargs = build_nats_auth(settings.OPENBAO.NATS) assert kwargs["user_jwt_cb"]() == b"dummy.jwt.token" signature = base64.b64decode(kwargs["signature_cb"]("nonce-1")) assert keypair.verify(b"nonce-1", signature) is True @pytest.mark.parametrize( "env", [ {"OPENBAO__NATS__NATS_SEED": "SUAxxx"}, {"OPENBAO__NATS__NATS_JWT": "dummy.jwt.token"}, ], ) async def test_connect_with_only_one_credential_is_config_error( monkeypatch: pytest.MonkeyPatch, fake: FakeNatsClient, env: dict[str, str] ): settings = make_settings(monkeypatch, **env) gateway = NatsGateway(settings, client_factory=lambda: fake) with pytest.raises(NatsConfigError, match="NATS_SEED и NATS_JWT"): await gateway.connect() assert fake.connect_kwargs == {} async def test_connect_with_unreadable_seed_is_config_error(monkeypatch: pytest.MonkeyPatch, fake: FakeNatsClient): settings = make_settings(monkeypatch, OPENBAO__NATS__NATS_SEED="не-seed", OPENBAO__NATS__NATS_JWT="dummy.jwt") gateway = NatsGateway(settings, client_factory=lambda: fake) with pytest.raises(NatsConfigError, match="NATS_SEED"): await gateway.connect() async def test_nats_disabled_does_not_connect(monkeypatch: pytest.MonkeyPatch, fake: FakeNatsClient): settings = make_settings(monkeypatch, NATS_ENABLED="false") created: list[FakeNatsClient] = [] def factory() -> FakeNatsClient: created.append(fake) return fake gateway = NatsGateway(settings, client_factory=factory) assert await gateway.connect() is False assert created == [] assert gateway.is_connected is False async def test_is_connected_reflects_client_state(gateway: NatsGateway, fake: FakeNatsClient): assert gateway.is_connected is False # до connect клиента нет await gateway.connect() assert gateway.is_connected is True fake.is_connected = False # nats-py переподключается assert gateway.is_connected is False async def test_reconnect_callbacks_log_and_keep_state(gateway: NatsGateway, fake: FakeNatsClient): await gateway.connect() kwargs = fake.connect_kwargs fake.is_connected = False await kwargs["disconnected_cb"]() await kwargs["error_cb"](nats_errors.StaleConnectionError()) assert gateway.is_connected is False fake.is_connected = True await kwargs["reconnected_cb"]() assert gateway.is_connected is True await kwargs["closed_cb"]() async def test_close_drains_connected_client(gateway: NatsGateway, fake: FakeNatsClient): await gateway.connect() await gateway.close() assert fake.drained is True assert gateway.is_connected is False async def test_close_without_connection_is_noop(gateway: NatsGateway, fake: FakeNatsClient): await gateway.close() assert fake.drained is False assert fake.closed is False async def test_close_falls_back_to_close_when_drain_fails(gateway: NatsGateway, fake: FakeNatsClient): await gateway.connect() async def failing_drain() -> None: raise nats_errors.ConnectionReconnectingError() fake.drain = failing_drain # type: ignore[method-assign] await gateway.close() assert fake.closed is True # --- подписка --------------------------------------------------------------------------------------------------- async def test_subscribe_requests_uses_image_subject_and_queue_group(gateway: NatsGateway, fake: FakeNatsClient): received: list[FakeMsg] = [] async def on_request(msg: Any) -> None: received.append(msg) await gateway.connect() await gateway.subscribe_requests(on_request) assert REQUEST_QUEUE_GROUP == "scan-gateway" [subscription] = fake.subscriptions assert subscription["subject"] == IMAGE_REQUEST_SUBJECT == "files.img_uploaded" assert subscription["queue"] == "scan-gateway" assert fake.flush_calls == 1 # подписка подтверждена сервером до возврата msg = FakeMsg(b'{"job_id": "x"}', {NATS_MSG_ID_HEADER: "x:1"}) await subscription["cb"](msg) assert received == [msg] # обработчик получает исходное сообщение nats-py (data, headers) async def test_subscribe_requests_handler_exception_is_logged_not_raised(gateway: NatsGateway, fake: FakeNatsClient): async def broken(msg: Any) -> None: raise RuntimeError("сбой обработчика") await gateway.connect() await gateway.subscribe_requests(broken) await fake.subscriptions[0]["cb"](FakeMsg()) # не бросает: подписка живёт async def test_subscribe_requests_requires_connection(gateway: NatsGateway): async def on_request(msg: Any) -> None: return None with pytest.raises(NatsNotConnectedError): await gateway.subscribe_requests(on_request) # --- публикация ------------------------------------------------------------------------------------------------- async def test_publish_sets_msg_id_header_and_flushes(gateway: NatsGateway, fake: FakeNatsClient): await gateway.connect() await gateway.publish(RESULT_SUBJECT, b"payload", "result:job:1") assert fake.published == [(RESULT_SUBJECT, b"payload", {"Nats-Msg-Id": "result:job:1"})] assert fake.flush_calls == 1 async def test_publish_retries_transient_errors_until_success( gateway: NatsGateway, fake: FakeNatsClient, sleeps: list[float] ): await gateway.connect() fake.publish_errors = [nats_errors.ConnectionReconnectingError(), nats_errors.FlushTimeoutError()] await gateway.publish(RESULT_SUBJECT, b"payload", "ack:job:1") assert len(fake.published) == 3 # первая попытка + два повтора из PUBLISH_RETRIES=3 assert all(headers == {"Nats-Msg-Id": "ack:job:1"} for _, _, headers in fake.published) assert sleeps == [0, 0] # пауза перед каждым повтором async def test_publish_succeeds_on_last_retry(gateway: NatsGateway, fake: FakeNatsClient, sleeps: list[float]): await gateway.connect() fake.publish_errors = [nats_errors.ConnectionClosedError()] * 3 await gateway.publish(RESULT_SUBJECT, b"payload", "ack:job:1") assert len(fake.published) == 4 # 1 + 3 повтора: последний повтор успешен assert sleeps == [0, 0, 0] @pytest.mark.parametrize( "error", [ nats_errors.ConnectionClosedError(), nats_errors.ConnectionReconnectingError(), nats_errors.OutboundBufferLimitError(), nats_errors.StaleConnectionError(), nats_errors.TimeoutError(), TimeoutError(), ConnectionResetError(), ], ) async def test_publish_raises_publish_failed_after_initial_attempt_and_all_retries( gateway: NatsGateway, fake: FakeNatsClient, error: BaseException ): await gateway.connect() fake.publish_errors = [error] * 10 with pytest.raises(PublishFailed) as exc_info: await gateway.publish(RESULT_SUBJECT, b"payload", "result:job:1") assert len(fake.published) == 4 # PUBLISH_RETRIES=3: первая попытка + 3 повтора assert exc_info.value.attempts == 4 assert exc_info.value.msg_id == "result:job:1" assert exc_info.value.__cause__ is error async def test_publish_retries_parameter_overrides_settings(gateway: NatsGateway, fake: FakeNatsClient): await gateway.connect() fake.publish_errors = [nats_errors.ConnectionClosedError()] * 10 with pytest.raises(PublishFailed): await gateway.publish(RESULT_SUBJECT, b"payload", "result:job:1", retries=5) assert len(fake.published) == 6 async def test_publish_zero_retries_makes_exactly_one_attempt( gateway: NatsGateway, fake: FakeNatsClient, sleeps: list[float] ): await gateway.connect() fake.publish_errors = [nats_errors.ConnectionClosedError()] * 10 with pytest.raises(PublishFailed) as exc_info: await gateway.publish(RESULT_SUBJECT, b"payload", "result:job:1", retries=0) assert len(fake.published) == 1 assert exc_info.value.attempts == 1 assert sleeps == [] async def test_publish_uses_publish_retries_from_settings(monkeypatch: pytest.MonkeyPatch, fake: FakeNatsClient): settings = make_settings(monkeypatch, PUBLISH_RETRIES="2") gateway = NatsGateway(settings, client_factory=lambda: fake, retry_delay=0) await gateway.connect() fake.publish_errors = [nats_errors.ConnectionClosedError()] * 10 with pytest.raises(PublishFailed): await gateway.publish(RESULT_SUBJECT, b"payload", "result:job:1") assert len(fake.published) == 3 # PUBLISH_RETRIES=2: 1 + 2 повтора @pytest.mark.parametrize("error", [nats_errors.MaxPayloadError(), nats_errors.BadSubjectError(), ValueError("x")]) async def test_publish_does_not_retry_non_transient_errors( gateway: NatsGateway, fake: FakeNatsClient, error: BaseException ): await gateway.connect() fake.publish_errors = [error] * 10 with pytest.raises(PublishFailed) as exc_info: await gateway.publish(RESULT_SUBJECT, b"payload", "result:job:1") assert len(fake.published) == 1 assert exc_info.value.attempts == 1 assert exc_info.value.__cause__ is error async def test_publish_without_connection_fails_without_attempts(gateway: NatsGateway, fake: FakeNatsClient): with pytest.raises(PublishFailed) as exc_info: await gateway.publish(RESULT_SUBJECT, b"payload", "result:job:1") assert exc_info.value.attempts == 0 assert fake.published == [] async def test_publish_rejects_negative_retries(gateway: NatsGateway): await gateway.connect() with pytest.raises(ValueError, match="retries"): await gateway.publish(RESULT_SUBJECT, b"payload", "result:job:1", retries=-1) # --- публикация конверта ---------------------------------------------------------------------------------------- SERVICE = ServiceInfo(name="scan-gateway", version="1.0.0") async def test_publish_result_ack_uses_ack_msg_id(gateway: NatsGateway, fake: FakeNatsClient): job_id, file_id = uuid.uuid4(), uuid.uuid4() message = build_ack_message(job_id, file_id, SERVICE) await gateway.connect() await gateway.publish_result(message, attempt=2) [(subject, payload, headers)] = fake.published assert subject == RESULT_SUBJECT assert headers == {"Nats-Msg-Id": f"ack:{job_id}:2"} assert json.loads(payload) == message.to_wire() async def test_publish_result_final_uses_result_msg_id_and_utf8(gateway: NatsGateway, fake: FakeNatsClient): job_id, file_id = uuid.uuid4(), uuid.uuid4() message = build_error_message(job_id, file_id, SERVICE, ErrorCode.TIMEOUT, "ИС не ответила") await gateway.connect() await gateway.publish_result(message, attempt=1) [(_, payload, headers)] = fake.published assert headers == {"Nats-Msg-Id": f"result:{job_id}:1"} assert "ИС не ответила".encode() in payload # UTF-8 без \u-экранирования assert json.loads(payload) == message.to_wire() # --- subject'ы из настроек (#32) --------------------------------------------------------------------------------- CUSTOM_ENV = { "OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT": "test.files.img_uploaded", "OPENBAO__NATS__NATS_RESULT_SUBJECT": "test.files.tabular_parsed", } def test_used_subjects_scan_gateway() -> None: nats = NATSSettings(NATS_URL="nats://x:4222", NATS_IMAGE_REQUEST_SUBJECT="test.files.img_uploaded") assert used_subjects(nats) == {"image_request": "test.files.img_uploaded", "result": "files.tabular_parsed"} async def test_subscribe_requests_uses_subject_from_settings(monkeypatch: pytest.MonkeyPatch, fake: FakeNatsClient): gw = NatsGateway(make_settings(monkeypatch, **CUSTOM_ENV), client_factory=lambda: fake) async def on_request(msg: Any) -> None: return None await gw.connect() await gw.subscribe_requests(on_request) [subscription] = fake.subscriptions assert subscription["subject"] == "test.files.img_uploaded" assert subscription["queue"] == "scan-gateway" async def test_subscribe_requests_error_names_configured_subject(monkeypatch: pytest.MonkeyPatch): gw = NatsGateway(make_settings(monkeypatch, **CUSTOM_ENV)) async def on_request(msg: Any) -> None: return None with pytest.raises(NatsNotConnectedError, match=r"test\.files\.img_uploaded"): await gw.subscribe_requests(on_request) async def test_publish_result_uses_subject_from_settings(monkeypatch: pytest.MonkeyPatch, fake: FakeNatsClient): gw = NatsGateway(make_settings(monkeypatch, **CUSTOM_ENV), client_factory=lambda: fake) message = build_ack_message(uuid.uuid4(), uuid.uuid4(), SERVICE) await gw.connect() await gw.publish_result(message, attempt=1) [(subject, _, _)] = fake.published assert subject == "test.files.tabular_parsed"