167 lines
4.8 KiB
Python
167 lines
4.8 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import hmac
|
|
import hashlib
|
|
import logging
|
|
import time
|
|
|
|
import pytest
|
|
import httpx
|
|
|
|
from sync_state_machine.datasource.api.client import ApiClient
|
|
|
|
|
|
class _FakeResponse:
|
|
status_code = 200
|
|
|
|
def raise_for_status(self) -> None:
|
|
return None
|
|
|
|
def json(self) -> dict:
|
|
return {"code": 200, "data": []}
|
|
|
|
|
|
class _FakeAsyncClient:
|
|
def __init__(self) -> None:
|
|
self.call_times: list[float] = []
|
|
self.closed = False
|
|
self.last_kwargs: dict | None = None
|
|
|
|
async def request(self, **kwargs):
|
|
self.call_times.append(time.monotonic())
|
|
self.last_kwargs = kwargs
|
|
return _FakeResponse()
|
|
|
|
async def aclose(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
class _FailingAsyncClient:
|
|
def __init__(self) -> None:
|
|
self.closed = False
|
|
|
|
async def request(self, **kwargs):
|
|
request = httpx.Request(kwargs["method"], kwargs["url"])
|
|
response = httpx.Response(500, request=request, text="boom")
|
|
raise httpx.HTTPStatusError("server error", request=request, response=response)
|
|
|
|
async def aclose(self) -> None:
|
|
self.closed = True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_client_global_rate_limit_applies_to_concurrent_requests() -> None:
|
|
client = ApiClient(
|
|
base_url="http://example.com",
|
|
uid="uid",
|
|
secret="secret",
|
|
rate_limit_max_requests=2,
|
|
rate_limit_window_seconds=0.2,
|
|
)
|
|
fake_client = _FakeAsyncClient()
|
|
client._client = fake_client
|
|
|
|
await asyncio.gather(
|
|
client.request("GET", "/ping"),
|
|
client.request("GET", "/ping"),
|
|
client.request("GET", "/ping"),
|
|
)
|
|
|
|
assert len(fake_client.call_times) == 3
|
|
third_delay = fake_client.call_times[2] - fake_client.call_times[0]
|
|
assert third_delay >= 0.15
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_client_request_stats_track_success_and_failure(caplog: pytest.LogCaptureFixture) -> None:
|
|
client = ApiClient(
|
|
base_url="http://example.com",
|
|
uid="uid",
|
|
secret="secret",
|
|
)
|
|
logger_name = "sync_state_machine.datasource.api.client"
|
|
success_client = _FakeAsyncClient()
|
|
client._client = success_client
|
|
|
|
with caplog.at_level(logging.INFO, logger=logger_name):
|
|
await client.request("GET", "/ping")
|
|
|
|
failing_client = _FailingAsyncClient()
|
|
client._client = failing_client
|
|
with pytest.raises(httpx.HTTPStatusError):
|
|
await client.request("GET", "/ping")
|
|
|
|
stats = client.get_request_stats()
|
|
assert stats == {"total": 2, "success": 1, "failure": 1}
|
|
assert client.format_request_stats().endswith("total=2, success=1, failure=1")
|
|
|
|
await client.close()
|
|
|
|
assert failing_client.closed is True
|
|
assert "📊 ApiClient request stats: total=2, success=1, failure=1" in caplog.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_client_logs_one_line_request_summary_in_debug_mode(caplog: pytest.LogCaptureFixture) -> None:
|
|
client = ApiClient(
|
|
base_url="http://example.com",
|
|
uid="uid",
|
|
secret="secret",
|
|
debug=True,
|
|
)
|
|
logger_name = "sync_state_machine.datasource.api.client"
|
|
fake_client = _FakeAsyncClient()
|
|
client._client = fake_client
|
|
|
|
with caplog.at_level(logging.INFO, logger=logger_name):
|
|
await client.request("GET", "/ping")
|
|
|
|
assert "🔎 ApiClient request: method=GET endpoint=/ping elapsed=" in caplog.text
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_api_client_get_signature_keeps_list_params_in_signature_source() -> None:
|
|
client = ApiClient(
|
|
base_url="http://example.com",
|
|
uid="uid",
|
|
secret="secret",
|
|
)
|
|
fake_client = _FakeAsyncClient()
|
|
client._client = fake_client
|
|
|
|
await client.request(
|
|
"GET",
|
|
"/ping",
|
|
params={"domains": ["a", "b", "c"], "project_id": "project-1"},
|
|
)
|
|
|
|
assert fake_client.last_kwargs is not None
|
|
sent_params = fake_client.last_kwargs["params"]
|
|
assert sent_params["domains"] == ["a", "b", "c"]
|
|
|
|
sign = sent_params["sign"]
|
|
sign_source = {k: v for k, v in sent_params.items() if k != "sign"}
|
|
items = []
|
|
for key, value in sorted(sign_source.items()):
|
|
if value is None:
|
|
value_str = "null"
|
|
elif isinstance(value, bool):
|
|
value_str = "true" if value else "false"
|
|
elif isinstance(value, str):
|
|
value_str = value
|
|
elif isinstance(value, (int, float)):
|
|
value_str = str(value)
|
|
elif isinstance(value, (list, dict)):
|
|
value_str = __import__("json").dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
else:
|
|
value_str = str(value)
|
|
items.append(f"{key}={value_str}")
|
|
|
|
expected_sign = hmac.new(
|
|
b"secret",
|
|
"&".join(items).encode("utf-8"),
|
|
hashlib.sha256,
|
|
).hexdigest()
|
|
assert sign == expected_sign
|