- 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
289 lines
9.6 KiB
Python
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"
|