sageapi/tests/test_http_client.py
Hermes Agent 5936a2f328 feat: implement sync engine, API handlers, DAPI auth, HTTP client
- Sync engine: BaseSync abstract class + 4 sync modules (users/pricing/uapi/llmage)
  - Checkpoint management via sync_state table
  - Batch processing with retry and exponential backoff
  - Incremental fetch from Sage DB via sqlor
  - UPSERT to local cache tables
- API handlers: balance/accounting/users/pricing/health
  - Balance: cache lookup + Sage fallback
  - Accounting: create with idempotency, query with filters/pagination
  - Users: keyword search, org filter
  - Pricing: filter by ppid/llmid/type/status
  - Health: basic + readiness checks (DB connectivity)
- DAPI auth: middleware + authenticate_request function
  - HMAC-SHA256 signature verification
  - Timestamp window validation
  - Sage downapikey table lookup
- HTTP client: SageHttpClient with aiohttp
  - Auto DAPI signature injection
  - Connection pooling, retry, timeout
- Router: 12 routes registered
- Module init: load_sageapi() wires everything to ServerEnv
2026-05-20 18:22:23 +08:00

289 lines
9.6 KiB
Python

"""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"