"""Tests for sageapi.utils.http_client""" import hashlib import hmac import json import time from unittest.mock import AsyncMock, MagicMock, patch import aiohttp import pytest from sageapi.utils.http_client import ( DAPISigner, SageHttpClient, RetryConfig, compute_dapi_signature, ) # --------------------------------------------------------------------------- # compute_dapi_signature tests (also imported from middleware) # --------------------------------------------------------------------------- class TestComputeDapiSignature: def test_basic(self): sig = compute_dapi_signature("GET", "/api/test", "1700000000.0", "secret") assert len(sig) == 64 def test_with_body(self): body = b'{"msg":"hi"}' sig = compute_dapi_signature("POST", "/api/test", "1700000000.0", "secret", body) assert len(sig) == 64 def test_deterministic(self): sig1 = compute_dapi_signature("GET", "/path", "123.0", "secret") sig2 = compute_dapi_signature("GET", "/path", "123.0", "secret") assert sig1 == sig2 # --------------------------------------------------------------------------- # DAPISigner tests # --------------------------------------------------------------------------- class TestDAPISigner: def test_sign_request(self): signer = DAPISigner(api_key="my-key", api_secret="my-secret") headers = signer.sign_request("GET", "/test") assert "X-DAPI-Key" in headers assert "X-DAPI-Timestamp" in headers assert "X-DAPI-Signature" in headers assert headers["X-DAPI-Key"] == "my-key" assert headers["X-DAPI-Timestamp"] # should be a valid timestamp string def test_sign_request_with_body(self): signer = DAPISigner(api_key="k", api_secret="s") body = b'{"test": true}' headers = signer.sign_request("POST", "/v1/chat", body) assert headers["X-DAPI-Key"] == "k" # Verify timestamp is recent ts = float(headers["X-DAPI-Timestamp"]) assert abs(time.time() - ts) < 2 def test_sign_request_produces_valid_signature(self): signer = DAPISigner(api_key="k", api_secret="secret") body = b'hello' headers = signer.sign_request("POST", "/path", body) expected = compute_dapi_signature("POST", "/path", headers["X-DAPI-Timestamp"], "secret", body) assert headers["X-DAPI-Signature"] == expected def test_sign_request_uppercases_method(self): signer = DAPISigner(api_key="k", api_secret="s") headers = signer.sign_request("get", "/path") expected = compute_dapi_signature("GET", "/path", headers["X-DAPI-Timestamp"], "s") assert headers["X-DAPI-Signature"] == expected # --------------------------------------------------------------------------- # RetryConfig tests # --------------------------------------------------------------------------- class TestRetryConfig: def test_defaults(self): config = RetryConfig() assert config.max_retries == 3 assert config.backoff_factor == 0.5 assert 429 in config.retry_on_status assert 500 in config.retry_on_status def test_custom(self): config = RetryConfig(max_retries=5, backoff_factor=1.0) assert config.max_retries == 5 assert config.backoff_factor == 1.0 # --------------------------------------------------------------------------- # SageHttpClient tests # --------------------------------------------------------------------------- class TestSageHttpClient: def test_init_defaults(self): client = SageHttpClient( base_url="https://api.example.com", api_key="key", api_secret="secret", ) assert client.base_url == "https://api.example.com" assert client.signer.api_key == "key" assert client.signer.api_secret == "secret" assert client.auto_sign is True def test_init_with_custom_signer(self): signer = DAPISigner(api_key="k", api_secret="s") client = SageHttpClient(base_url="https://api.example.com", signer=signer) assert client.signer is signer def test_init_without_auto_sign(self): client = SageHttpClient( base_url="https://api.example.com", auto_sign=False, ) assert client.auto_sign is False def test_build_url(self): client = SageHttpClient(base_url="https://api.example.com") assert client._build_url("/v1/test") == "https://api.example.com/v1/test" assert client._build_url("v1/test") == "https://api.example.com/v1/test" # Full URL should pass through assert client._build_url("https://other.com/path") == "https://other.com/path" def test_get_relative_path(self): client = SageHttpClient(base_url="https://api.example.com") assert client._get_relative_path("/v1/chat") == "/v1/chat" assert client._get_relative_path("https://api.example.com/v1/chat?page=1") == "/v1/chat" def test_sign_and_prepare_adds_dapi_headers(self): client = SageHttpClient( base_url="https://api.example.com", api_key="k", api_secret="s", ) import asyncio url, headers, body = asyncio.get_event_loop().run_until_complete( client._sign_and_prepare("GET", "/test") ) assert "X-DAPI-Key" in headers assert "X-DAPI-Timestamp" in headers assert "X-DAPI-Signature" in headers assert headers["X-DAPI-Key"] == "k" def test_sign_and_prepare_with_json_body(self): client = SageHttpClient( base_url="https://api.example.com", api_key="k", api_secret="s", ) import asyncio url, headers, body = asyncio.get_event_loop().run_until_complete( client._sign_and_prepare("POST", "/test", json_body={"msg": "hi"}) ) assert body is not None assert json.loads(body) == {"msg": "hi"} assert headers.get("Content-Type") == "application/json" @pytest.mark.asyncio async def test_context_manager(self): client = SageHttpClient( base_url="https://httpbin.org", api_key="k", api_secret="s", max_retries=0, ) async with client as c: assert c is client assert client._closed is True @pytest.mark.asyncio async def test_close(self): client = SageHttpClient( base_url="https://httpbin.org", api_key="k", api_secret="s", max_retries=0, ) await client._get_session() # create session await client.close() assert client._closed is True @pytest.mark.asyncio async def test_request_with_retry_on_502(self): """Test that 502 responses trigger retries and raise after exhaustion.""" client = SageHttpClient( base_url="", api_key="k", api_secret="s", max_retries=2, backoff_factor=0.01, # fast for tests auto_sign=False, ) mock_response = AsyncMock() mock_response.status = 502 mock_response.request_info = MagicMock() mock_response.history = [] mock_response.release = MagicMock() mock_session = AsyncMock() mock_session.request = AsyncMock(return_value=mock_response) mock_session.closed = False client._session = mock_session with pytest.raises(aiohttp.ClientResponseError) as exc_info: await client.request("GET", "/test") assert exc_info.value.status == 502 # Should have been called 3 times (1 initial + 2 retries) assert mock_session.request.call_count == 3 class TestSageHttpClientIntegration: """Integration tests against httpbin.org (requires network).""" @pytest.mark.asyncio async def test_get_request(self): client = SageHttpClient( base_url="https://httpbin.org", auto_sign=False, max_retries=0, timeout=10.0, ) async with client: resp = await client.get("/get") assert resp.status == 200 data = await resp.json() assert "url" in data @pytest.mark.asyncio async def test_post_request_with_json(self): client = SageHttpClient( base_url="https://httpbin.org", auto_sign=False, max_retries=0, timeout=10.0, ) async with client: resp = await client.post("/post", json={"key": "value"}) assert resp.status == 200 data = await resp.json() assert data["json"] == {"key": "value"} @pytest.mark.asyncio async def test_headers_sent(self): client = SageHttpClient( base_url="https://httpbin.org", headers={"X-Custom-Header": "test-value"}, auto_sign=False, max_retries=0, timeout=10.0, ) async with client: resp = await client.get("/headers") assert resp.status == 200 data = await resp.json() assert data["headers"].get("X-Custom-Header") == "test-value" @pytest.mark.asyncio async def test_query_params(self): client = SageHttpClient( base_url="https://httpbin.org", auto_sign=False, max_retries=0, timeout=10.0, ) async with client: resp = await client.get("/get", params={"foo": "bar", "baz": "qux"}) assert resp.status == 200 data = await resp.json() assert data["args"]["foo"] == "bar" assert data["args"]["baz"] == "qux"