Add tests/test_api.py
This commit is contained in:
@@ -0,0 +1,296 @@
|
|||||||
|
"""Unit-тесты API-клиентов с моками httpx.MockTransport."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from selectel_ml_tui.api.billing import (
|
||||||
|
BillingApiError,
|
||||||
|
BillingClient,
|
||||||
|
normalize_dt,
|
||||||
|
)
|
||||||
|
from selectel_ml_tui.api.client import SelectelApiError
|
||||||
|
from selectel_ml_tui.api.gateway import GatewayApiError, GatewayClient
|
||||||
|
|
||||||
|
|
||||||
|
class NoopSleep:
|
||||||
|
"""Пауза-заглушка для ретраев (не ждём в тестах)."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.calls: list[float] = []
|
||||||
|
|
||||||
|
async def __call__(self, seconds: float) -> None:
|
||||||
|
self.calls.append(seconds)
|
||||||
|
|
||||||
|
|
||||||
|
def _transport(handler: Any) -> httpx.MockTransport:
|
||||||
|
return httpx.MockTransport(handler)
|
||||||
|
|
||||||
|
|
||||||
|
def _billing_client(
|
||||||
|
handler: Any, max_retries: int = 2, sleep: NoopSleep | None = None
|
||||||
|
) -> BillingClient:
|
||||||
|
return BillingClient(
|
||||||
|
api_base_url="https://api.selectel.ru",
|
||||||
|
static_token="static-token",
|
||||||
|
max_retries=max_retries,
|
||||||
|
transport=_transport(handler),
|
||||||
|
sleep=sleep or NoopSleep(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ok_consumption() -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"status": "success",
|
||||||
|
"data": [
|
||||||
|
{
|
||||||
|
"account_id": "239633",
|
||||||
|
"period": "2026-08-01",
|
||||||
|
"provider_key": "aig",
|
||||||
|
"metric": {
|
||||||
|
"id": "air_tokens_cost",
|
||||||
|
"name": "Стоимость токенов",
|
||||||
|
"unit": "nanorub",
|
||||||
|
"quantity": "85483000000",
|
||||||
|
},
|
||||||
|
"value": 854.83,
|
||||||
|
"project": "7bf2d2a328494e25b13d05a1926ca75a",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_billing_normalize_dt() -> None:
|
||||||
|
assert normalize_dt("2026-08-01") == "2026-08-01T00:00:00.000"
|
||||||
|
assert normalize_dt("2026-08-01T10:30:00") == "2026-08-01T10:30:00"
|
||||||
|
assert normalize_dt("2026-08-01T00:00:00.000") == "2026-08-01T00:00:00.000"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_billing_gets_normalized_dates() -> None:
|
||||||
|
seen: dict[str, str] = {}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
seen.update(dict(request.url.params))
|
||||||
|
return httpx.Response(200, json=_ok_consumption())
|
||||||
|
|
||||||
|
client = _billing_client(handler)
|
||||||
|
await client.get_consumption("2026-08-01", "2026-08-19")
|
||||||
|
assert seen["start"] == "2026-08-01T00:00:00.000"
|
||||||
|
assert seen["end"] == "2026-08-19T00:00:00.000"
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_billing_get_consumption() -> None:
|
||||||
|
client = _billing_client(
|
||||||
|
lambda request: httpx.Response(200, json=_ok_consumption())
|
||||||
|
)
|
||||||
|
items = await client.get_consumption("2026-08-01", "2026-08-19")
|
||||||
|
assert len(items) == 1
|
||||||
|
assert items[0].value == Decimal("854.83")
|
||||||
|
assert items[0].metric is not None
|
||||||
|
assert items[0].metric.id == "air_tokens_cost"
|
||||||
|
assert items[0].project_id == "7bf2d2a328494e25b13d05a1926ca75a"
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_billing_get_summary_sums_values() -> None:
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
assert "provider_keys=aig" in request.url.query.decode()
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"status": "success",
|
||||||
|
"data": [
|
||||||
|
{"value": "100.25"},
|
||||||
|
{"value": 42.5},
|
||||||
|
{"value": 0},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
client = _billing_client(handler)
|
||||||
|
total = await client.get_summary("2026-08-01", "2026-08-19")
|
||||||
|
assert total == Decimal("142.75")
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_billing_5xx_retries_then_success() -> None:
|
||||||
|
sleep = NoopSleep()
|
||||||
|
attempts = {"count": 0}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
attempts["count"] += 1
|
||||||
|
if attempts["count"] == 1:
|
||||||
|
return httpx.Response(500, text="internal error")
|
||||||
|
return httpx.Response(200, json=_ok_consumption())
|
||||||
|
|
||||||
|
client = _billing_client(handler, sleep=sleep)
|
||||||
|
items = await client.get_consumption("2026-08-01", "2026-08-19")
|
||||||
|
assert attempts["count"] == 2
|
||||||
|
assert len(items) == 1
|
||||||
|
assert sleep.calls == [0.5]
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_billing_429_retries_then_raises() -> None:
|
||||||
|
sleep = NoopSleep()
|
||||||
|
attempts = {"count": 0}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
attempts["count"] += 1
|
||||||
|
return httpx.Response(429, text="rate limit")
|
||||||
|
|
||||||
|
client = _billing_client(handler, max_retries=2, sleep=sleep)
|
||||||
|
with pytest.raises(SelectelApiError) as exc_info:
|
||||||
|
await client.get_consumption("2026-08-01", "2026-08-19")
|
||||||
|
assert exc_info.value.status_code == 429
|
||||||
|
assert attempts["count"] == 3
|
||||||
|
assert sleep.calls == [0.5, 1.0]
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_billing_4xx_no_retry() -> None:
|
||||||
|
sleep = NoopSleep()
|
||||||
|
attempts = {"count": 0}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
attempts["count"] += 1
|
||||||
|
return httpx.Response(400, text="bad request")
|
||||||
|
|
||||||
|
client = _billing_client(handler, sleep=sleep)
|
||||||
|
with pytest.raises(BillingApiError) as exc_info:
|
||||||
|
await client.get_consumption("2026-08-01", "2026-08-19")
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
assert "HTTP 400" in str(exc_info.value)
|
||||||
|
assert attempts["count"] == 1
|
||||||
|
assert sleep.calls == []
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_billing_network_error_retries() -> None:
|
||||||
|
sleep = NoopSleep()
|
||||||
|
attempts = {"count": 0}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
attempts["count"] += 1
|
||||||
|
if attempts["count"] == 1:
|
||||||
|
raise httpx.ConnectError("connection refused")
|
||||||
|
return httpx.Response(200, json=_ok_consumption())
|
||||||
|
|
||||||
|
client = _billing_client(handler, sleep=sleep)
|
||||||
|
items = await client.get_consumption("2026-08-01", "2026-08-19")
|
||||||
|
assert attempts["count"] == 2
|
||||||
|
assert len(items) == 1
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_billing_timeout_retries_then_raises() -> None:
|
||||||
|
sleep = NoopSleep()
|
||||||
|
attempts = {"count": 0}
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
attempts["count"] += 1
|
||||||
|
raise httpx.ReadTimeout("timed out")
|
||||||
|
|
||||||
|
client = _billing_client(handler, max_retries=2, sleep=sleep)
|
||||||
|
with pytest.raises(SelectelApiError) as exc_info:
|
||||||
|
await client.get_consumption("2026-08-01", "2026-08-19")
|
||||||
|
assert exc_info.value.status_code is None
|
||||||
|
assert "Ошибка сети" in str(exc_info.value)
|
||||||
|
assert attempts["count"] == 3
|
||||||
|
assert sleep.calls == [0.5, 1.0]
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_billing_non_success_status_raises() -> None:
|
||||||
|
client = _billing_client(
|
||||||
|
lambda request: httpx.Response(200, json={"status": "error", "data": []})
|
||||||
|
)
|
||||||
|
with pytest.raises(BillingApiError):
|
||||||
|
await client.get_consumption("2026-08-01", "2026-08-19")
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
class FakeTokenProvider:
|
||||||
|
"""Токен-провайдер для GatewayClient в тестах."""
|
||||||
|
|
||||||
|
def __init__(self, tokens: list[str] | None = None) -> None:
|
||||||
|
self._tokens = tokens or ["token-1"]
|
||||||
|
self._index = 0
|
||||||
|
self.calls = 0
|
||||||
|
|
||||||
|
async def get_token(self) -> str:
|
||||||
|
self.calls += 1
|
||||||
|
token = self._tokens[min(self._index, len(self._tokens) - 1)]
|
||||||
|
self._index += 1
|
||||||
|
return token
|
||||||
|
|
||||||
|
def invalidate(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def test_gateway_list_gateways() -> None:
|
||||||
|
provider = FakeTokenProvider()
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
assert request.headers["X-Auth-Token"] == "token-1"
|
||||||
|
assert request.headers["X-Project-Id"] == "proj-1"
|
||||||
|
assert request.headers["X-Roles"] == "reader"
|
||||||
|
return httpx.Response(200, json={"data": [{"id": "gw-1", "name": "Router"}]})
|
||||||
|
|
||||||
|
client = GatewayClient(
|
||||||
|
api_base_url="https://api.selectel.ru",
|
||||||
|
token_provider=provider,
|
||||||
|
project_id="proj-1",
|
||||||
|
domain_id="239633",
|
||||||
|
transport=_transport(handler),
|
||||||
|
)
|
||||||
|
gateways = await client.list_gateways()
|
||||||
|
assert gateways == [{"id": "gw-1", "name": "Router"}]
|
||||||
|
assert provider.calls == 1
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_gateway_401_refreshes_token() -> None:
|
||||||
|
provider = FakeTokenProvider(tokens=["token-old", "token-new"])
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
if request.headers["X-Auth-Token"] == "token-old":
|
||||||
|
return httpx.Response(401, text="unauthorized")
|
||||||
|
return httpx.Response(200, json={"data": [{"id": "gw-2"}]})
|
||||||
|
|
||||||
|
client = GatewayClient(
|
||||||
|
api_base_url="https://api.selectel.ru",
|
||||||
|
token_provider=provider,
|
||||||
|
project_id="proj-1",
|
||||||
|
domain_id="239633",
|
||||||
|
transport=_transport(handler),
|
||||||
|
)
|
||||||
|
gateways = await client.list_gateways()
|
||||||
|
assert gateways == [{"id": "gw-2"}]
|
||||||
|
assert provider.calls == 2
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_gateway_401_without_refresh_raises() -> None:
|
||||||
|
provider = FakeTokenProvider(tokens=["token-only"])
|
||||||
|
|
||||||
|
def handler(request: httpx.Request) -> httpx.Response:
|
||||||
|
return httpx.Response(401, text="unauthorized")
|
||||||
|
|
||||||
|
client = GatewayClient(
|
||||||
|
api_base_url="https://api.selectel.ru",
|
||||||
|
token_provider=provider,
|
||||||
|
project_id="proj-1",
|
||||||
|
domain_id="239633",
|
||||||
|
max_retries=0,
|
||||||
|
transport=_transport(handler),
|
||||||
|
)
|
||||||
|
with pytest.raises(GatewayApiError) as exc_info:
|
||||||
|
await client.list_gateways()
|
||||||
|
assert exc_info.value.status_code == 401
|
||||||
|
await client.aclose()
|
||||||
Reference in New Issue
Block a user