- invalid_subject параметризован по трём ключам, equality покрывает три пары - кейс утечки JWT/seed при невалидном subject - /healthcheck с нестандартными subject'ами и проверкой отсутствия JWT - subscribe с настроенным subject'ом, лог старта с subject'ами - tasks.md: отмечены 1.1, 1.2, 2.x, 3.1 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
411 lines
15 KiB
Python
411 lines
15 KiB
Python
"""Быстрые тесты `src/core/nats.py` без сервера NATS: аутентификация, supervisor первого подключения, publish.
|
||
|
||
Клиент nats-py подменяется фабрикой (`client_factory`), паузы backoff — инжектируемым `sleep`
|
||
(spec recognition-job-exchange, «Соединение с NATS по шаблону, без JetStream»; design D-6).
|
||
"""
|
||
|
||
import ast
|
||
import asyncio
|
||
import os
|
||
from collections.abc import Iterator
|
||
from pathlib import Path
|
||
from types import SimpleNamespace
|
||
from typing import Any
|
||
|
||
import nkeys
|
||
import pytest
|
||
from loguru import logger
|
||
|
||
from src.contract.subjects import NATS_MSG_ID_HEADER, RESULT_SUBJECT
|
||
from src.core.config import NATSSettings
|
||
from src.core.nats import (
|
||
RESULT_QUEUE_GROUP,
|
||
NatsConfigError,
|
||
NatsConnection,
|
||
NatsNotConnectedError,
|
||
NatsPublishError,
|
||
build_nats_auth,
|
||
used_subjects,
|
||
)
|
||
|
||
SRC_DIR = Path(__file__).resolve().parent.parent / "src"
|
||
|
||
|
||
def _nats_settings(seed: str = "", jwt: str = "") -> NATSSettings:
|
||
return NATSSettings(NATS_URL="nats://localhost:4222", NATS_SEED=seed, NATS_JWT=jwt)
|
||
|
||
|
||
def _valid_seed() -> str:
|
||
return nkeys.encode_seed(os.urandom(32), nkeys.PREFIX_BYTE_USER).decode("utf-8")
|
||
|
||
|
||
@pytest.fixture
|
||
def log_records() -> Iterator[list[Any]]:
|
||
"""Записи loguru, выпущенные за время теста."""
|
||
records: list[Any] = []
|
||
handler_id = logger.add(lambda message: records.append(message.record), level="DEBUG")
|
||
yield records
|
||
logger.remove(handler_id)
|
||
|
||
|
||
# --- build_nats_auth ----------------------------------------------------------------------------
|
||
def test_build_nats_auth_empty_credentials_gives_no_kwargs():
|
||
assert build_nats_auth(_nats_settings()) == {}
|
||
|
||
|
||
@pytest.mark.parametrize(("seed", "jwt"), [("SU-only-seed", ""), ("", "only-jwt")])
|
||
def test_build_nats_auth_partial_credentials_is_config_error(seed: str, jwt: str):
|
||
with pytest.raises(NatsConfigError, match="NATS_SEED"):
|
||
build_nats_auth(_nats_settings(seed, jwt))
|
||
|
||
|
||
def test_build_nats_auth_invalid_seed_is_config_error():
|
||
with pytest.raises(NatsConfigError, match="NATS_SEED"):
|
||
build_nats_auth(_nats_settings("not-a-seed", "header.payload.signature"))
|
||
|
||
|
||
def test_nats_config_error_is_runtime_error():
|
||
assert issubclass(NatsConfigError, RuntimeError)
|
||
|
||
|
||
def test_build_nats_auth_valid_credentials_give_callbacks():
|
||
auth = build_nats_auth(_nats_settings(_valid_seed(), "header.payload.signature"))
|
||
|
||
assert set(auth) == {"user_jwt_cb", "signature_cb"}
|
||
assert auth["user_jwt_cb"]() == b"header.payload.signature"
|
||
signature = auth["signature_cb"]("nonce")
|
||
assert isinstance(signature, bytes)
|
||
assert signature
|
||
|
||
|
||
# --- поддельный клиент nats-py ------------------------------------------------------------------
|
||
class _FakeClient:
|
||
"""Минимальный двойник `nats.aio.client.Client`: подключение, подписка, публикация, закрытие."""
|
||
|
||
def __init__(self, world: "_FakeWorld") -> None:
|
||
self.world = world
|
||
self.is_connected = False
|
||
self.is_closed = False
|
||
self.connect_options: dict[str, Any] = {}
|
||
self.subscriptions: list[dict[str, Any]] = []
|
||
self.published: list[dict[str, Any]] = []
|
||
self.flush_error: Exception | None = None
|
||
self.drained = False
|
||
|
||
async def connect(self, servers: str, **options: Any) -> None:
|
||
self.world.connect_calls += 1
|
||
self.connect_options = {"servers": servers, **options}
|
||
if self.world.connect_calls <= self.world.fail_times:
|
||
raise OSError("Connection refused")
|
||
self.is_connected = True
|
||
|
||
async def subscribe(self, subject: str, queue: str = "", cb: Any = None) -> SimpleNamespace:
|
||
self.subscriptions.append({"subject": subject, "queue": queue, "cb": cb})
|
||
return SimpleNamespace(subject=subject, queue=queue)
|
||
|
||
async def publish(self, subject: str, payload: bytes = b"", headers: dict[str, str] | None = None) -> None:
|
||
self.published.append({"subject": subject, "payload": payload, "headers": headers})
|
||
|
||
async def flush(self, timeout: int = 10) -> None:
|
||
if self.flush_error is not None:
|
||
raise self.flush_error
|
||
|
||
async def drain(self) -> None:
|
||
self.drained = True
|
||
await self.close()
|
||
|
||
async def close(self) -> None:
|
||
self.is_connected = False
|
||
self.is_closed = True
|
||
|
||
|
||
class _FakeWorld:
|
||
"""Фабрика клиентов: первые `fail_times` подключений падают, затем успех."""
|
||
|
||
def __init__(self, fail_times: int = 0) -> None:
|
||
self.fail_times = fail_times
|
||
self.connect_calls = 0
|
||
self.clients: list[_FakeClient] = []
|
||
|
||
def __call__(self) -> _FakeClient:
|
||
client = _FakeClient(self)
|
||
self.clients.append(client)
|
||
return client
|
||
|
||
|
||
class _RecordingSleep:
|
||
"""Подмена `asyncio.sleep`: запоминает интервалы backoff и не ждёт."""
|
||
|
||
def __init__(self) -> None:
|
||
self.delays: list[float] = []
|
||
|
||
async def __call__(self, delay: float) -> None:
|
||
self.delays.append(delay)
|
||
await asyncio.sleep(0)
|
||
|
||
|
||
async def _handler(msg: Any) -> None:
|
||
return None
|
||
|
||
|
||
# --- supervisor первого подключения -------------------------------------------------------------
|
||
async def test_supervisor_retries_with_backoff_then_connects_and_subscribes_once(log_records: list[Any]):
|
||
world = _FakeWorld(fail_times=7)
|
||
sleep = _RecordingSleep()
|
||
subscribed: list[NatsConnection] = []
|
||
|
||
async def on_connected(conn: NatsConnection) -> None:
|
||
subscribed.append(conn)
|
||
await conn.subscribe_results(_handler)
|
||
|
||
conn = NatsConnection(_nats_settings(), on_connected=on_connected, client_factory=world, sleep=sleep)
|
||
assert not conn.is_connected
|
||
|
||
await conn.start()
|
||
assert await conn.wait_ready(timeout=2)
|
||
|
||
assert conn.is_connected
|
||
assert world.connect_calls == 8 # noqa: PLR2004
|
||
assert sleep.delays == [1, 2, 4, 8, 16, 30, 30]
|
||
assert subscribed == [conn]
|
||
connected_client = world.clients[-1]
|
||
assert [(s["subject"], s["queue"]) for s in connected_client.subscriptions] == [
|
||
(RESULT_SUBJECT, RESULT_QUEUE_GROUP)
|
||
]
|
||
options = connected_client.connect_options
|
||
assert options["servers"] == "nats://localhost:4222"
|
||
assert options["max_reconnect_attempts"] == -1
|
||
assert options["reconnect_time_wait"] == 2 # noqa: PLR2004
|
||
for callback in ("error_cb", "disconnected_cb", "reconnected_cb", "closed_cb"):
|
||
assert callable(options[callback])
|
||
|
||
# Первая ошибка — warning с причиной, остальные в пределах минуты подавлены
|
||
warnings = [r for r in log_records if r["level"].name == "WARNING"]
|
||
assert len(warnings) == 1
|
||
assert "error_category=nats_error" in warnings[0]["message"]
|
||
assert "Connection refused" in warnings[0]["message"]
|
||
|
||
await conn.stop()
|
||
assert connected_client.is_closed
|
||
|
||
|
||
async def test_supervisor_connects_first_time_without_sleep():
|
||
world = _FakeWorld()
|
||
sleep = _RecordingSleep()
|
||
conn = NatsConnection(_nats_settings(), client_factory=world, sleep=sleep)
|
||
|
||
await conn.start()
|
||
assert await conn.wait_ready(timeout=2)
|
||
|
||
assert sleep.delays == []
|
||
assert world.connect_calls == 1
|
||
await conn.stop()
|
||
|
||
|
||
async def test_start_does_not_block_and_stop_cancels_supervisor():
|
||
world = _FakeWorld(fail_times=10**6)
|
||
never = asyncio.Event()
|
||
|
||
async def blocking_sleep(delay: float) -> None:
|
||
await never.wait()
|
||
|
||
conn = NatsConnection(_nats_settings(), client_factory=world, sleep=blocking_sleep)
|
||
await conn.start()
|
||
await asyncio.sleep(0.01)
|
||
|
||
assert not conn.is_connected
|
||
assert world.connect_calls == 1
|
||
|
||
await conn.stop() # без исключений наружу
|
||
assert not conn.is_connected
|
||
assert not await conn.wait_ready(timeout=0.01)
|
||
|
||
|
||
async def test_config_error_logged_as_config_error_and_not_retried(log_records: list[Any]):
|
||
world = _FakeWorld()
|
||
sleep = _RecordingSleep()
|
||
conn = NatsConnection(_nats_settings(seed="SU-only-seed"), client_factory=world, sleep=sleep)
|
||
|
||
await conn.start()
|
||
assert not await conn.wait_ready(timeout=0.05)
|
||
|
||
assert world.connect_calls == 0
|
||
assert sleep.delays == []
|
||
assert not conn.is_connected
|
||
errors = [r for r in log_records if r["level"].name == "ERROR"]
|
||
assert len(errors) == 1
|
||
assert "error_category=config_error" in errors[0]["message"]
|
||
assert "nats_error" not in errors[0]["message"]
|
||
await conn.stop()
|
||
|
||
|
||
async def test_on_connected_failure_is_retried(log_records: list[Any]):
|
||
world = _FakeWorld()
|
||
sleep = _RecordingSleep()
|
||
calls = 0
|
||
|
||
async def flaky_on_connected(conn: NatsConnection) -> None:
|
||
nonlocal calls
|
||
calls += 1
|
||
if calls < 3: # noqa: PLR2004
|
||
raise RuntimeError("подписка не удалась")
|
||
|
||
conn = NatsConnection(_nats_settings(), on_connected=flaky_on_connected, client_factory=world, sleep=sleep)
|
||
await conn.start()
|
||
assert await conn.wait_ready(timeout=2)
|
||
|
||
assert calls == 3 # noqa: PLR2004
|
||
assert sleep.delays == [1, 2]
|
||
await conn.stop()
|
||
|
||
|
||
async def test_stop_without_start_is_noop():
|
||
conn = NatsConnection(_nats_settings(), client_factory=_FakeWorld())
|
||
await conn.stop()
|
||
assert not conn.is_connected
|
||
|
||
|
||
async def test_error_callback_logging_is_throttled_and_reset_on_reconnect(log_records: list[Any]):
|
||
world = _FakeWorld()
|
||
conn = NatsConnection(_nats_settings(), client_factory=world, sleep=_RecordingSleep())
|
||
await conn.start()
|
||
assert await conn.wait_ready(timeout=2)
|
||
options = world.clients[-1].connect_options
|
||
|
||
await options["disconnected_cb"]()
|
||
for _ in range(5):
|
||
await options["error_cb"](OSError("Connection refused"))
|
||
await options["reconnected_cb"]()
|
||
await options["error_cb"](OSError("Connection reset"))
|
||
|
||
warnings = [r["message"] for r in log_records if r["level"].name == "WARNING"]
|
||
errors_logged = [m for m in warnings if "error_category=nats_error" in m]
|
||
assert len(errors_logged) == 2 # noqa: PLR2004 - первая ошибка серии и первая после переподключения
|
||
assert any("Connection reset" in m for m in errors_logged)
|
||
await conn.stop()
|
||
|
||
|
||
# --- publish / subscribe ------------------------------------------------------------------------
|
||
async def test_publish_without_connection_raises_publish_error():
|
||
conn = NatsConnection(_nats_settings(), client_factory=_FakeWorld())
|
||
|
||
with pytest.raises(NatsPublishError, match="NATS"):
|
||
await conn.publish("files.tabular_uploaded", b"{}", "job:1")
|
||
|
||
|
||
async def test_publish_sends_msg_id_header_and_flushes():
|
||
world = _FakeWorld()
|
||
conn = NatsConnection(_nats_settings(), client_factory=world, sleep=_RecordingSleep())
|
||
await conn.start()
|
||
assert await conn.wait_ready(timeout=2)
|
||
|
||
await conn.publish("files.tabular_uploaded", b'{"a": 1}', "job:1")
|
||
|
||
assert world.clients[-1].published == [
|
||
{"subject": "files.tabular_uploaded", "payload": b'{"a": 1}', "headers": {NATS_MSG_ID_HEADER: "job:1"}}
|
||
]
|
||
await conn.stop()
|
||
|
||
|
||
async def test_publish_flush_failure_raises_publish_error():
|
||
world = _FakeWorld()
|
||
conn = NatsConnection(_nats_settings(), client_factory=world, sleep=_RecordingSleep())
|
||
await conn.start()
|
||
assert await conn.wait_ready(timeout=2)
|
||
world.clients[-1].flush_error = TimeoutError()
|
||
|
||
with pytest.raises(NatsPublishError):
|
||
await conn.publish("files.tabular_uploaded", b"{}", "job:1")
|
||
await conn.stop()
|
||
|
||
|
||
async def test_subscribe_results_handler_errors_are_logged_not_raised(log_records: list[Any]):
|
||
world = _FakeWorld()
|
||
conn = NatsConnection(
|
||
_nats_settings(), client_factory=world, sleep=_RecordingSleep(), result_subject="test.subject", queue="g"
|
||
)
|
||
await conn.start()
|
||
assert await conn.wait_ready(timeout=2)
|
||
|
||
async def failing_handler(msg: Any) -> None:
|
||
raise ValueError("сломанный обработчик")
|
||
|
||
await conn.subscribe_results(failing_handler)
|
||
subscription = world.clients[-1].subscriptions[0]
|
||
assert (subscription["subject"], subscription["queue"]) == ("test.subject", "g")
|
||
|
||
await subscription["cb"](SimpleNamespace(subject="test.subject", data=b"xyz", headers={}))
|
||
|
||
errors = [r for r in log_records if r["level"].name == "ERROR"]
|
||
assert len(errors) == 1
|
||
assert "error_category=handler_error" in errors[0]["message"]
|
||
await conn.stop()
|
||
|
||
|
||
async def test_subscribe_results_without_connection_raises():
|
||
conn = NatsConnection(_nats_settings(), client_factory=_FakeWorld())
|
||
with pytest.raises(NatsNotConnectedError):
|
||
await conn.subscribe_results(_handler)
|
||
|
||
|
||
async def test_stop_drains_connected_client():
|
||
world = _FakeWorld()
|
||
conn = NatsConnection(_nats_settings(), client_factory=world, sleep=_RecordingSleep())
|
||
await conn.start()
|
||
assert await conn.wait_ready(timeout=2)
|
||
|
||
await conn.stop()
|
||
|
||
assert world.clients[-1].drained
|
||
assert not conn.is_connected
|
||
|
||
|
||
# --- без JetStream -------------------------------------------------------------------------------
|
||
def test_src_does_not_use_jetstream():
|
||
"""JetStream на платформе нет: в src/ нет вызовов `.jetstream()` и импортов `nats.js`."""
|
||
offenders: list[str] = []
|
||
for path in SRC_DIR.rglob("*.py"):
|
||
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
||
for node in ast.walk(tree):
|
||
if isinstance(node, ast.Attribute) and node.attr.lower() == "jetstream":
|
||
offenders.append(f"{path}:{node.lineno} .{node.attr}")
|
||
elif isinstance(node, ast.ImportFrom) and (node.module or "").startswith("nats.js"):
|
||
offenders.append(f"{path}:{node.lineno} from {node.module}")
|
||
elif isinstance(node, ast.Import):
|
||
offenders.extend(
|
||
f"{path}:{node.lineno} import {alias.name}"
|
||
for alias in node.names
|
||
if alias.name.startswith("nats.js")
|
||
)
|
||
assert offenders == []
|
||
|
||
|
||
def test_used_subjects_core() -> None:
|
||
nats = NATSSettings(NATS_URL="nats://x:4222", NATS_RESULT_SUBJECT="test.files.tabular_parsed")
|
||
assert used_subjects(nats) == {
|
||
"tabular_request": "files.tabular_uploaded",
|
||
"image_request": "files.img_uploaded",
|
||
"result": "test.files.tabular_parsed",
|
||
}
|
||
|
||
|
||
def test_connection_subscribes_to_configured_result_subject() -> None:
|
||
nats = NATSSettings(NATS_URL="nats://x:4222", NATS_RESULT_SUBJECT="test.files.tabular_parsed")
|
||
assert NatsConnection(nats).result_subject == "test.files.tabular_parsed"
|
||
assert NatsConnection(nats, result_subject="override").result_subject == "override"
|
||
|
||
|
||
async def test_connection_subscribes_with_configured_result_subject() -> None:
|
||
"""T3.2: subscribe вызывается с subject'ом из настроек (без override) и группой очереди."""
|
||
nats = NATSSettings(NATS_URL="nats://x:4222", NATS_RESULT_SUBJECT="test.files.tabular_parsed")
|
||
world = _FakeWorld()
|
||
conn = NatsConnection(nats, client_factory=world, sleep=_RecordingSleep(), queue="g")
|
||
await conn.start()
|
||
assert await conn.wait_ready(timeout=2)
|
||
|
||
await conn.subscribe_results(_handler)
|
||
|
||
subscription = world.clients[-1].subscriptions[0]
|
||
assert (subscription["subject"], subscription["queue"]) == ("test.files.tabular_parsed", "g")
|
||
await conn.stop()
|