DRRK/tests/unit/test_gateway.py

274 lines
8.5 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.

from __future__ import annotations
import logging
from pathlib import Path
import pytest
import requests
from pydantic import SecretStr
from app.integrations.gateway import (
API_KEY_HEADER,
GatewayAuthError,
GatewayClient,
GatewayError,
GatewayNotFoundError,
GatewayUnavailableError,
UpstreamNotFoundError,
)
TX_HEADER = "X-Gravitee-Transaction-Id"
SECRET = "super-secret-moex-key-7f3a"
class FakeResponse:
def __init__(self, status_code: int, text: str = "", headers=None) -> None:
self.status_code = status_code
self.text = text
self.content = text.encode("utf-8")
self.headers = headers or {}
class FakeSession:
"""Подменённая requests.Session: отдаёт ответы/исключения по очереди."""
def __init__(self, *outcomes) -> None: # noqa: ANN002
self._outcomes = list(outcomes)
self.calls: list[dict] = []
def get(self, url, **kwargs): # noqa: ANN001, ANN003
self.calls.append({"url": url, **kwargs})
outcome = self._outcomes.pop(0)
if isinstance(outcome, Exception):
raise outcome
return outcome
def _client(session: FakeSession, *, retries: int = 3, key: str = SECRET):
sleeps: list[float] = []
client = GatewayClient(
source="moex",
base_url="https://gateway.test",
path_prefix="/external/moex-iss",
api_key=SecretStr(key),
retries=retries,
session=session,
sleep=sleeps.append,
)
return client, sleeps
def test_success_sends_key_header_and_records_request() -> None:
session = FakeSession(FakeResponse(200, "{}"))
client, sleeps = _client(session)
response = client.get("/iss/securities.json", {"q": "RU000A106169"})
assert response.status_code == 200
assert session.calls[0]["url"] == (
"https://gateway.test/external/moex-iss/iss/securities.json"
)
assert session.calls[0]["headers"] == {API_KEY_HEADER: SECRET}
assert session.calls[0]["timeout"] == 15
assert sleeps == []
[record] = client.drain_records()
assert record.ok is True
assert record.http_status == 200
assert record.endpoint == "/external/moex-iss/iss/securities.json"
assert record.params == {"q": "RU000A106169"}
assert client.drain_records() == []
def test_502_then_200_is_retried() -> None:
session = FakeSession(FakeResponse(502, "Bad Gateway"), FakeResponse(200, "{}"))
client, sleeps = _client(session)
response = client.get("/iss/securities.json")
assert response.status_code == 200
assert len(session.calls) == 2
assert sleeps == [1.0]
[record] = client.drain_records()
assert record.ok is True
@pytest.mark.parametrize("status", [429, 502, 503, 504])
def test_retryable_statuses_exhaust_into_unavailable(status: int) -> None:
session = FakeSession(*[FakeResponse(status) for _ in range(4)])
client, sleeps = _client(session, retries=3)
with pytest.raises(GatewayUnavailableError) as exc_info:
client.get("/iss/securities.json")
assert len(session.calls) == 4 # первая попытка + 3 повтора
assert sleeps == [1.0, 2.0, 4.0]
assert exc_info.value.http_status == status
[record] = client.drain_records()
assert record.ok is False
assert record.http_status == status
def test_429_respects_retry_after() -> None:
session = FakeSession(
FakeResponse(429, headers={"Retry-After": "7"}), FakeResponse(200, "{}")
)
client, sleeps = _client(session)
client.get("/iss/securities.json")
assert sleeps == [7.0]
def test_network_timeout_is_retried() -> None:
session = FakeSession(
requests.ConnectTimeout("connect timeout"),
requests.ConnectionError("reset"),
FakeResponse(200, "{}"),
)
client, _ = _client(session)
assert client.get("/iss/securities.json").status_code == 200
assert len(session.calls) == 3
def test_network_errors_exhaust_into_unavailable() -> None:
session = FakeSession(requests.ReadTimeout("read timeout"))
client, _ = _client(session, retries=0)
with pytest.raises(GatewayUnavailableError, match="таймаут"):
client.get("/iss/securities.json")
@pytest.mark.parametrize("status", [401, 403])
def test_auth_errors_are_not_retried(status: int, fixtures_dir: Path) -> None:
body = (fixtures_dir / "gateway_401_unauthorized.txt").read_text("utf-8")
session = FakeSession(FakeResponse(status, body))
client, sleeps = _client(session)
with pytest.raises(GatewayAuthError) as exc_info:
client.get("/iss/securities.json")
assert len(session.calls) == 1
assert sleeps == []
assert "MOEX_API_KEY" in str(exc_info.value)
assert exc_info.value.http_status == status
def test_404_no_context_path_is_gateway_not_found() -> None:
body = (
'{"message":"No context-path matches the request URI.","http_status_code":404}'
)
session = FakeSession(FakeResponse(404, body))
client, sleeps = _client(session)
with pytest.raises(GatewayNotFoundError) as exc_info:
client.get("/iss/securities.json")
assert len(session.calls) == 1
assert sleeps == []
assert "MOEX_PATH" in str(exc_info.value)
def test_plain_404_is_upstream_not_found_not_gateway_path() -> None:
session = FakeSession(FakeResponse(404, "Not Found"))
client, _ = _client(session)
with pytest.raises(UpstreamNotFoundError) as exc_info:
client.get("/iss/securities.json")
assert not isinstance(exc_info.value, GatewayNotFoundError)
assert len(session.calls) == 1
def test_500_is_not_retried() -> None:
session = FakeSession(FakeResponse(500, "Internal error"))
client, _ = _client(session)
with pytest.raises(GatewayError) as exc_info:
client.get("/iss/securities.json")
assert type(exc_info.value) is GatewayError
assert len(session.calls) == 1
def test_missing_key_fails_without_request() -> None:
session = FakeSession()
client, _ = _client(session, key="")
with pytest.raises(GatewayAuthError, match="Не задан ключ"):
client.get("/iss/securities.json")
assert session.calls == []
def test_key_never_appears_in_logs_errors_or_records(caplog) -> None:
caplog.set_level(logging.DEBUG)
session = FakeSession(
FakeResponse(200, "{}"),
FakeResponse(502),
FakeResponse(401, "Unauthorized"),
requests.ConnectionError("connection refused"),
requests.ConnectionError("connection refused"),
)
client, _ = _client(session, retries=1)
client.get("/iss/securities.json", {"q": "RU000A106169"})
errors: list[str] = []
for _ in range(2):
with pytest.raises(GatewayError) as exc_info:
client.get("/iss/securities.json")
errors.append(str(exc_info.value))
assert caplog.records, "клиент должен логировать запросы"
assert SECRET not in caplog.text
assert API_KEY_HEADER not in caplog.text
assert all(SECRET not in message for message in errors)
assert all(SECRET not in repr(record) for record in client.drain_records())
assert SECRET not in repr(client)
def test_gravitee_transaction_id_is_recorded_on_success() -> None:
session = FakeSession(FakeResponse(200, "{}", headers={TX_HEADER: "tx-ok-1"}))
client, _ = _client(session)
client.get("/iss/securities.json")
[record] = client.drain_records()
assert record.transaction_id == "tx-ok-1"
def test_gravitee_transaction_id_of_last_attempt_on_exhausted_retries() -> None:
session = FakeSession(
FakeResponse(502, headers={TX_HEADER: "tx-1"}),
FakeResponse(502, headers={TX_HEADER: "tx-2"}),
)
client, _ = _client(session, retries=1)
with pytest.raises(GatewayUnavailableError) as exc_info:
client.get("/iss/securities.json")
assert exc_info.value.transaction_id == "tx-2"
[record] = client.drain_records()
assert record.transaction_id == "tx-2"
def test_gravitee_transaction_id_on_auth_error_and_absent_on_network_error(
caplog,
) -> None:
caplog.set_level(logging.INFO)
session = FakeSession(
FakeResponse(401, "Unauthorized", headers={TX_HEADER: "tx-401"}),
requests.ConnectionError("connection refused"),
)
client, _ = _client(session, retries=0)
for _ in range(2):
with pytest.raises(GatewayError):
client.get("/iss/securities.json")
auth, network = client.drain_records()
assert auth.transaction_id == "tx-401"
assert network.transaction_id is None
assert "tx=tx-401" in caplog.text