From 686acb4423fe17f6b050775006985977bf0aa249 Mon Sep 17 00:00:00 2001 From: andrew Date: Wed, 2 Sep 2026 16:45:25 +0300 Subject: [PATCH] Add tests/test_api.py --- tests/test_api.py | 296 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 tests/test_api.py diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..59672c4 --- /dev/null +++ b/tests/test_api.py @@ -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()