mirror of
https://github.com/stack-auth/stack.git
synced 2026-07-20 21:29:36 +08:00
test: add failing tests for sync and async HTTP client
- 22 tests covering construction, headers, URL building, request pipeline - Response processing with x-stack-actual-status and x-stack-known-error - Retry logic with exponential backoff for idempotent methods - 429 rate limit handling with Retry-After header - Context manager protocol for both sync and async clients
This commit is contained in:
parent
a8c18f366b
commit
fbe1c5bfc3
322
sdks/implementations/python/tests/test_client.py
Normal file
322
sdks/implementations/python/tests/test_client.py
Normal file
@ -0,0 +1,322 @@
|
||||
"""Tests for the sync and async HTTP client classes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import respx
|
||||
|
||||
from stack_auth._client import AsyncAPIClient, BaseAPIClient, SyncAPIClient
|
||||
from stack_auth._version import __version__
|
||||
from stack_auth.errors import AuthenticationError, NotFoundError, StackAuthError
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Construction tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClientConstruction:
|
||||
def test_sync_client_constructs(self) -> None:
|
||||
client = SyncAPIClient(project_id="proj", secret_server_key="sk")
|
||||
assert client is not None
|
||||
assert isinstance(client, SyncAPIClient)
|
||||
|
||||
def test_async_client_constructs(self) -> None:
|
||||
client = AsyncAPIClient(project_id="proj", secret_server_key="sk")
|
||||
assert client is not None
|
||||
assert isinstance(client, AsyncAPIClient)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Header tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildHeaders:
|
||||
def setup_method(self) -> None:
|
||||
self.client = SyncAPIClient(project_id="proj_123", secret_server_key="sk_secret")
|
||||
|
||||
def test_project_id_header(self) -> None:
|
||||
headers = self.client._build_headers()
|
||||
assert headers["x-stack-project-id"] == "proj_123"
|
||||
|
||||
def test_access_type_header(self) -> None:
|
||||
headers = self.client._build_headers()
|
||||
assert headers["x-stack-access-type"] == "server"
|
||||
|
||||
def test_secret_server_key_header(self) -> None:
|
||||
headers = self.client._build_headers()
|
||||
assert headers["x-stack-secret-server-key"] == "sk_secret"
|
||||
|
||||
def test_client_version_header(self) -> None:
|
||||
headers = self.client._build_headers()
|
||||
assert headers["x-stack-client-version"] == f"python@{__version__}"
|
||||
|
||||
def test_override_error_status_header(self) -> None:
|
||||
headers = self.client._build_headers()
|
||||
assert headers["x-stack-override-error-status"] == "true"
|
||||
|
||||
def test_random_nonce_header_is_uuid(self) -> None:
|
||||
headers = self.client._build_headers()
|
||||
nonce = headers["x-stack-random-nonce"]
|
||||
# UUID v4 pattern
|
||||
uuid_pattern = re.compile(
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
assert uuid_pattern.match(nonce), f"Nonce {nonce!r} is not a valid UUID v4"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# URL building tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildUrl:
|
||||
def test_default_base_url(self) -> None:
|
||||
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
||||
assert client._build_url("/users") == "https://api.stack-auth.com/api/v1/users"
|
||||
|
||||
def test_custom_base_url(self) -> None:
|
||||
client = SyncAPIClient(project_id="p", secret_server_key="s", base_url="https://custom.host")
|
||||
assert client._build_url("/users") == "https://custom.host/api/v1/users"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Sync request tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSyncRequest:
|
||||
@respx.mock
|
||||
def test_get_returns_json(self) -> None:
|
||||
route = respx.get("https://api.stack-auth.com/api/v1/users").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"id": "user_1"},
|
||||
headers={"x-stack-actual-status": "200"},
|
||||
)
|
||||
)
|
||||
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
||||
result = client.request("GET", "/users")
|
||||
assert result == {"id": "user_1"}
|
||||
|
||||
@respx.mock
|
||||
def test_known_error_raises_not_found(self) -> None:
|
||||
respx.get("https://api.stack-auth.com/api/v1/users/123").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"code": "USER_NOT_FOUND", "message": "User not found"},
|
||||
headers={
|
||||
"x-stack-actual-status": "400",
|
||||
"x-stack-known-error": "USER_NOT_FOUND",
|
||||
},
|
||||
)
|
||||
)
|
||||
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
||||
with pytest.raises(NotFoundError) as exc_info:
|
||||
client.request("GET", "/users/123")
|
||||
assert exc_info.value.code == "USER_NOT_FOUND"
|
||||
|
||||
@respx.mock
|
||||
def test_known_error_raises_authentication_error(self) -> None:
|
||||
respx.get("https://api.stack-auth.com/api/v1/me").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"code": "INVALID_ACCESS_TOKEN", "message": "Bad token"},
|
||||
headers={
|
||||
"x-stack-actual-status": "400",
|
||||
"x-stack-known-error": "INVALID_ACCESS_TOKEN",
|
||||
},
|
||||
)
|
||||
)
|
||||
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
||||
with pytest.raises(AuthenticationError):
|
||||
client.request("GET", "/me")
|
||||
|
||||
@respx.mock
|
||||
def test_http_error_on_unknown_status(self) -> None:
|
||||
respx.get("https://api.stack-auth.com/api/v1/fail").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={},
|
||||
headers={"x-stack-actual-status": "500"},
|
||||
)
|
||||
)
|
||||
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
||||
with pytest.raises(StackAuthError) as exc_info:
|
||||
client.request("GET", "/fail")
|
||||
assert exc_info.value.code == "HTTP_ERROR"
|
||||
|
||||
@respx.mock
|
||||
def test_post_with_none_body_sends_empty_json(self) -> None:
|
||||
route = respx.post("https://api.stack-auth.com/api/v1/items").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"ok": True},
|
||||
headers={"x-stack-actual-status": "200"},
|
||||
)
|
||||
)
|
||||
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
||||
client.request("POST", "/items", body=None)
|
||||
sent_request = route.calls[0].request
|
||||
assert sent_request.content == b"{}"
|
||||
|
||||
@respx.mock
|
||||
def test_get_with_none_body_sends_no_body(self) -> None:
|
||||
route = respx.get("https://api.stack-auth.com/api/v1/items").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"ok": True},
|
||||
headers={"x-stack-actual-status": "200"},
|
||||
)
|
||||
)
|
||||
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
||||
client.request("GET", "/items", body=None)
|
||||
sent_request = route.calls[0].request
|
||||
assert sent_request.content == b""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Async request tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestAsyncRequest:
|
||||
@respx.mock
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_returns_json(self) -> None:
|
||||
respx.get("https://api.stack-auth.com/api/v1/users").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={"id": "user_1"},
|
||||
headers={"x-stack-actual-status": "200"},
|
||||
)
|
||||
)
|
||||
client = AsyncAPIClient(project_id="p", secret_server_key="s")
|
||||
result = await client.request("GET", "/users")
|
||||
assert result == {"id": "user_1"}
|
||||
await client.aclose()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Retry tests (sync)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRetryLogic:
|
||||
@respx.mock
|
||||
@patch("time.sleep")
|
||||
def test_get_retries_on_connect_error(self, mock_sleep: AsyncMock) -> None:
|
||||
url = "https://api.stack-auth.com/api/v1/data"
|
||||
route = respx.get(url).mock(side_effect=httpx.ConnectError("Connection refused"))
|
||||
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
client.request("GET", "/data")
|
||||
# 1 initial + 5 retries = 6 total attempts
|
||||
assert route.call_count == 6
|
||||
|
||||
@respx.mock
|
||||
@patch("time.sleep")
|
||||
def test_post_does_not_retry_on_connect_error(self, mock_sleep: AsyncMock) -> None:
|
||||
url = "https://api.stack-auth.com/api/v1/data"
|
||||
route = respx.post(url).mock(side_effect=httpx.ConnectError("Connection refused"))
|
||||
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
client.request("POST", "/data")
|
||||
assert route.call_count == 1
|
||||
|
||||
@respx.mock
|
||||
@patch("time.sleep")
|
||||
def test_retry_exponential_backoff_delays(self, mock_sleep: AsyncMock) -> None:
|
||||
url = "https://api.stack-auth.com/api/v1/data"
|
||||
respx.get(url).mock(side_effect=httpx.ConnectError("fail"))
|
||||
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
||||
with pytest.raises(httpx.ConnectError):
|
||||
client.request("GET", "/data")
|
||||
delays = [call.args[0] for call in mock_sleep.call_args_list]
|
||||
assert delays == [1.0, 2.0, 4.0, 8.0, 16.0]
|
||||
|
||||
@respx.mock
|
||||
@patch("time.sleep")
|
||||
def test_429_retries_with_retry_after(self, mock_sleep: AsyncMock) -> None:
|
||||
url = "https://api.stack-auth.com/api/v1/data"
|
||||
# First call: 429 with Retry-After, second call: success
|
||||
respx.get(url).mock(
|
||||
side_effect=[
|
||||
httpx.Response(
|
||||
200,
|
||||
json={},
|
||||
headers={"x-stack-actual-status": "429", "Retry-After": "3"},
|
||||
),
|
||||
httpx.Response(
|
||||
200,
|
||||
json={"ok": True},
|
||||
headers={"x-stack-actual-status": "200"},
|
||||
),
|
||||
]
|
||||
)
|
||||
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
||||
result = client.request("GET", "/data")
|
||||
assert result == {"ok": True}
|
||||
mock_sleep.assert_called_once_with(3.0)
|
||||
|
||||
@respx.mock
|
||||
@patch("time.sleep")
|
||||
def test_429_without_retry_after_uses_backoff(self, mock_sleep: AsyncMock) -> None:
|
||||
url = "https://api.stack-auth.com/api/v1/data"
|
||||
respx.get(url).mock(
|
||||
side_effect=[
|
||||
httpx.Response(
|
||||
200,
|
||||
json={},
|
||||
headers={"x-stack-actual-status": "429"},
|
||||
),
|
||||
httpx.Response(
|
||||
200,
|
||||
json={"ok": True},
|
||||
headers={"x-stack-actual-status": "200"},
|
||||
),
|
||||
]
|
||||
)
|
||||
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
||||
result = client.request("GET", "/data")
|
||||
assert result == {"ok": True}
|
||||
# First attempt is attempt=0, so backoff = 1.0 * (2 ** 0) = 1.0
|
||||
mock_sleep.assert_called_once_with(1.0)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context manager tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestContextManager:
|
||||
def test_sync_context_manager(self) -> None:
|
||||
with SyncAPIClient(project_id="p", secret_server_key="s") as client:
|
||||
assert isinstance(client, SyncAPIClient)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_context_manager(self) -> None:
|
||||
async with AsyncAPIClient(project_id="p", secret_server_key="s") as client:
|
||||
assert isinstance(client, AsyncAPIClient)
|
||||
|
||||
def test_sync_close(self) -> None:
|
||||
client = SyncAPIClient(project_id="p", secret_server_key="s")
|
||||
# Force client creation by accessing it
|
||||
_ = client._get_client()
|
||||
client.close()
|
||||
# After close, internal client should be None
|
||||
assert client._client is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_aclose(self) -> None:
|
||||
client = AsyncAPIClient(project_id="p", secret_server_key="s")
|
||||
_ = client._get_client()
|
||||
await client.aclose()
|
||||
assert client._client is None
|
||||
Loading…
Reference in New Issue
Block a user