Files
polymarket-insider-tracker/tests/ingestor/test_clob_client.py
T
Patrick Selamy 31c675bdec fix: resolve flaky tests in clob_client and websocket (#49)
## Changes

### test_get_market_not_found
- Updated test to expect `RetryError` instead of `ClobClientError`
- The `get_market` method uses the `@with_retry()` decorator, so when
  the underlying API call fails repeatedly, it raises `RetryError`
  wrapping the original exception
- Removed unused `ClobClientError` import

### test_start_and_receive_trades
- Replaced `MagicMock` with a proper `MockWebSocket` class that
  implements the async iterator protocol correctly
- `MagicMock` was passing `self` as an extra argument when calling
  `__aiter__`, causing "takes 0 positional arguments but 1 was given"
- Removed unused `MagicMock` import

Closes #49

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 19:04:32 -05:00

361 lines
12 KiB
Python

"""Tests for ClobClient wrapper."""
import time
from unittest.mock import MagicMock, patch
import pytest
from polymarket_insider_tracker.ingestor.clob_client import (
ClobClient,
RateLimiter,
RetryError,
with_retry,
)
from polymarket_insider_tracker.ingestor.models import Market, Orderbook
class TestRateLimiter:
"""Tests for RateLimiter."""
def test_acquire_sync_no_wait_first_call(self) -> None:
"""First call should not wait."""
limiter = RateLimiter(max_requests_per_second=10)
start = time.monotonic()
limiter.acquire_sync()
elapsed = time.monotonic() - start
# Should be nearly instant
assert elapsed < 0.05
def test_acquire_sync_enforces_rate(self) -> None:
"""Subsequent calls should be rate limited."""
limiter = RateLimiter(max_requests_per_second=10) # 100ms between calls
# First call
limiter.acquire_sync()
# Second call should wait
start = time.monotonic()
limiter.acquire_sync()
elapsed = time.monotonic() - start
# Should wait at least 90ms (allowing some tolerance)
assert elapsed >= 0.08
class TestWithRetry:
"""Tests for retry decorator."""
def test_success_first_try(self) -> None:
"""Function succeeds on first try."""
call_count = 0
@with_retry(max_retries=3)
def succeed() -> str:
nonlocal call_count
call_count += 1
return "success"
result = succeed()
assert result == "success"
assert call_count == 1
def test_success_after_retries(self) -> None:
"""Function succeeds after some retries."""
call_count = 0
@with_retry(max_retries=3, base_delay=0.01)
def succeed_eventually() -> str:
nonlocal call_count
call_count += 1
if call_count < 3:
raise ValueError("Not yet")
return "success"
result = succeed_eventually()
assert result == "success"
assert call_count == 3
def test_exhausted_retries(self) -> None:
"""Raises RetryError after exhausting retries."""
@with_retry(max_retries=2, base_delay=0.01)
def always_fails() -> str:
raise ValueError("Always fails")
with pytest.raises(RetryError) as exc_info:
always_fails()
assert "3 attempts failed" in str(exc_info.value)
assert isinstance(exc_info.value.last_exception, ValueError)
def test_specific_exception_types(self) -> None:
"""Only retries on specified exception types."""
call_count = 0
@with_retry(max_retries=3, base_delay=0.01, retry_on=(ValueError,))
def raise_type_error() -> str:
nonlocal call_count
call_count += 1
raise TypeError("Not retried")
with pytest.raises(TypeError):
raise_type_error()
# Should only be called once since TypeError is not in retry_on
assert call_count == 1
class TestClobClient:
"""Tests for ClobClient wrapper."""
@pytest.fixture
def mock_base_client(self) -> MagicMock:
"""Create a mock base CLOB client."""
with patch("polymarket_insider_tracker.ingestor.clob_client.BaseClobClient") as mock:
yield mock.return_value
def test_init_defaults(self, mock_base_client: MagicMock) -> None: # noqa: ARG002
"""Test client initialization with defaults."""
client = ClobClient()
assert client._host == "https://clob.polymarket.com"
assert client._max_retries == 3
def test_init_with_env_api_key(self, mock_base_client: MagicMock) -> None: # noqa: ARG002
"""Test client reads API key from environment."""
with patch.dict("os.environ", {"POLYMARKET_API_KEY": "test-key"}):
client = ClobClient()
assert client._api_key == "test-key"
def test_init_with_explicit_api_key(self, mock_base_client: MagicMock) -> None: # noqa: ARG002
"""Test client uses explicitly provided API key."""
client = ClobClient(api_key="explicit-key")
assert client._api_key == "explicit-key"
def test_health_check_success(self, mock_base_client: MagicMock) -> None:
"""Test health check returns True when API responds OK."""
mock_base_client.get_ok.return_value = "OK"
client = ClobClient()
result = client.health_check()
assert result is True
mock_base_client.get_ok.assert_called_once()
def test_health_check_failure(self, mock_base_client: MagicMock) -> None:
"""Test health check returns False on error."""
mock_base_client.get_ok.side_effect = Exception("Connection failed")
client = ClobClient()
result = client.health_check()
assert result is False
def test_get_server_time(self, mock_base_client: MagicMock) -> None:
"""Test getting server time."""
mock_base_client.get_server_time.return_value = 1704067200000
client = ClobClient()
result = client.get_server_time()
assert result == 1704067200000
def test_get_markets(self, mock_base_client: MagicMock) -> None:
"""Test fetching markets."""
mock_base_client.get_simplified_markets.return_value = {
"data": [
{
"condition_id": "0x123",
"question": "Test market?",
"tokens": [],
"closed": False,
},
],
"next_cursor": "LTE=",
}
client = ClobClient()
markets = client.get_markets()
assert len(markets) == 1
assert isinstance(markets[0], Market)
assert markets[0].condition_id == "0x123"
def test_get_markets_filters_closed(self, mock_base_client: MagicMock) -> None:
"""Test that closed markets are filtered when active_only=True."""
mock_base_client.get_simplified_markets.return_value = {
"data": [
{"condition_id": "0x1", "closed": False},
{"condition_id": "0x2", "closed": True},
],
"next_cursor": "LTE=",
}
client = ClobClient()
markets = client.get_markets(active_only=True)
assert len(markets) == 1
assert markets[0].condition_id == "0x1"
def test_get_markets_includes_closed(self, mock_base_client: MagicMock) -> None:
"""Test that closed markets are included when active_only=False."""
mock_base_client.get_simplified_markets.return_value = {
"data": [
{"condition_id": "0x1", "closed": False},
{"condition_id": "0x2", "closed": True},
],
"next_cursor": "LTE=",
}
client = ClobClient()
markets = client.get_markets(active_only=False)
assert len(markets) == 2
def test_get_markets_pagination(self, mock_base_client: MagicMock) -> None:
"""Test that pagination is handled correctly."""
mock_base_client.get_simplified_markets.side_effect = [
{
"data": [{"condition_id": "0x1"}],
"next_cursor": "cursor2",
},
{
"data": [{"condition_id": "0x2"}],
"next_cursor": "LTE=",
},
]
client = ClobClient()
markets = client.get_markets()
assert len(markets) == 2
assert mock_base_client.get_simplified_markets.call_count == 2
def test_get_market(self, mock_base_client: MagicMock) -> None:
"""Test fetching a single market."""
mock_base_client.get_market.return_value = {
"condition_id": "0xabc",
"question": "Will it happen?",
"tokens": [
{"token_id": "t1", "outcome": "Yes"},
{"token_id": "t2", "outcome": "No"},
],
}
client = ClobClient()
market = client.get_market("0xabc")
assert isinstance(market, Market)
assert market.condition_id == "0xabc"
assert len(market.tokens) == 2
def test_get_market_not_found(self, mock_base_client: MagicMock) -> None:
"""Test error handling when market not found.
When the underlying API call fails, the @with_retry decorator will
retry the operation. After all retries are exhausted, it raises
RetryError wrapping the original exception.
"""
mock_base_client.get_market.side_effect = Exception("Not found")
client = ClobClient()
with pytest.raises(RetryError) as exc_info:
client.get_market("0xnotfound")
# The RetryError wraps the original exception
assert "get_market" in str(exc_info.value)
assert exc_info.value.last_exception is not None
def test_get_orderbook(self, mock_base_client: MagicMock) -> None:
"""Test fetching an orderbook."""
mock_bid = MagicMock()
mock_bid.price = "0.50"
mock_bid.size = "100"
mock_ask = MagicMock()
mock_ask.price = "0.52"
mock_ask.size = "150"
mock_orderbook = MagicMock()
mock_orderbook.market = "0xmarket"
mock_orderbook.asset_id = "token123"
mock_orderbook.tick_size = "0.01"
mock_orderbook.bids = [mock_bid]
mock_orderbook.asks = [mock_ask]
mock_base_client.get_order_book.return_value = mock_orderbook
client = ClobClient()
orderbook = client.get_orderbook("token123")
assert isinstance(orderbook, Orderbook)
assert orderbook.asset_id == "token123"
assert len(orderbook.bids) == 1
assert len(orderbook.asks) == 1
def test_get_orderbooks(self, mock_base_client: MagicMock) -> None:
"""Test fetching multiple orderbooks."""
mock_ob1 = MagicMock()
mock_ob1.market = "m1"
mock_ob1.asset_id = "t1"
mock_ob1.tick_size = "0.01"
mock_ob1.bids = []
mock_ob1.asks = []
mock_ob2 = MagicMock()
mock_ob2.market = "m2"
mock_ob2.asset_id = "t2"
mock_ob2.tick_size = "0.01"
mock_ob2.bids = []
mock_ob2.asks = []
mock_base_client.get_order_books.return_value = [mock_ob1, mock_ob2]
client = ClobClient()
orderbooks = client.get_orderbooks(["t1", "t2"])
assert len(orderbooks) == 2
assert all(isinstance(ob, Orderbook) for ob in orderbooks)
def test_get_midpoint(self, mock_base_client: MagicMock) -> None:
"""Test fetching midpoint price."""
mock_base_client.get_midpoint.return_value = {"mid": "0.55"}
client = ClobClient()
result = client.get_midpoint("token123")
assert result == "0.55"
def test_get_midpoint_error(self, mock_base_client: MagicMock) -> None:
"""Test midpoint returns None on error."""
mock_base_client.get_midpoint.side_effect = Exception("API error")
client = ClobClient()
result = client.get_midpoint("token123")
assert result is None
def test_get_price_buy(self, mock_base_client: MagicMock) -> None:
"""Test fetching buy price."""
mock_base_client.get_price.return_value = {"price": "0.53"}
client = ClobClient()
result = client.get_price("token123", side="BUY")
assert result == "0.53"
mock_base_client.get_price.assert_called_with("token123", side="BUY")
def test_get_price_sell(self, mock_base_client: MagicMock) -> None:
"""Test fetching sell price."""
mock_base_client.get_price.return_value = {"price": "0.51"}
client = ClobClient()
result = client.get_price("token123", side="SELL")
assert result == "0.51"
mock_base_client.get_price.assert_called_with("token123", side="SELL")