from __future__ import annotations import re from unittest.mock import AsyncMock, MagicMock import pytest from cerbero_mcp.exchanges.ibkr.client import IBKRClient, IBKRError from pytest_httpx import HTTPXMock @pytest.fixture def fake_signer(): s = MagicMock() s.consumer_key = "CK" s.access_token = "AT" s.get_live_session_token = AsyncMock(return_value="LSTBASE64==") s.sign_with_lst = MagicMock(return_value="SIG==") s.make_oauth_params = MagicMock(return_value={ "oauth_consumer_key": "CK", "oauth_token": "AT", "oauth_nonce": "n", "oauth_timestamp": "1", "oauth_signature_method": "HMAC-SHA256", "oauth_version": "1.0", }) return s @pytest.fixture def client(fake_signer): return IBKRClient( signer=fake_signer, account_id="DU1234", paper=True, base_url="https://api.ibkr.com/v1/api", ) @pytest.mark.asyncio async def test_health_no_network(client): info = await client.health() assert info["status"] == "ok" assert info["paper"] is True @pytest.mark.asyncio async def test_get_account_summary(httpx_mock: HTTPXMock, client): httpx_mock.add_response( url=re.compile(r".*/portfolio/DU1234/summary"), json={"netliquidation": {"amount": 10000, "currency": "USD"}, "totalcashvalue": {"amount": 8000}}, ) httpx_mock.add_response( url=re.compile(r".*/tickle"), json={"session": "abc"}, ) data = await client.get_account() assert "netliquidation" in data @pytest.mark.asyncio async def test_request_retries_once_on_401(httpx_mock: HTTPXMock, client): httpx_mock.add_response(url=re.compile(r".*/tickle"), json={}) # First call returns 401 httpx_mock.add_response( url=re.compile(r".*/portfolio/DU1234/summary"), status_code=401, text="session expired", ) # Second call (after LST refresh) succeeds httpx_mock.add_response( url=re.compile(r".*/portfolio/DU1234/summary"), json={"netliquidation": {"amount": 5000}}, ) data = await client.get_account() assert data["netliquidation"]["amount"] == 5000 @pytest.mark.asyncio async def test_request_raises_on_persistent_401(httpx_mock: HTTPXMock, client): from cerbero_mcp.exchanges.ibkr.oauth import IBKRAuthError httpx_mock.add_response(url=re.compile(r".*/tickle"), json={}) # Both attempts return 401 httpx_mock.add_response( url=re.compile(r".*/portfolio/DU1234/summary"), status_code=401, text="bad creds", ) httpx_mock.add_response( url=re.compile(r".*/portfolio/DU1234/summary"), status_code=401, text="bad creds", ) with pytest.raises(IBKRAuthError, match="after retry"): await client.get_account() @pytest.mark.asyncio async def test_resolve_conid_caches(httpx_mock: HTTPXMock, client): httpx_mock.add_response( url=re.compile(r".*/tickle"), json={"session": "x"}, ) httpx_mock.add_response( url=re.compile(r".*/trsrv/secdef/search.*symbol=AAPL"), json=[{"conid": 265598, "symbol": "AAPL", "secType": "STK"}], ) cid = await client.resolve_conid("AAPL", "STK") assert cid == 265598 cid2 = await client.resolve_conid("AAPL", "STK") assert cid2 == 265598 @pytest.mark.asyncio async def test_get_positions(httpx_mock: HTTPXMock, client): httpx_mock.add_response(url=re.compile(r".*/tickle"), json={}) httpx_mock.add_response( url=re.compile(r".*/portfolio/DU1234/positions/0"), json=[{"conid": 265598, "position": 10, "mktPrice": 150}], ) res = await client.get_positions() assert isinstance(res, list) assert res[0]["position"] == 10 @pytest.mark.asyncio async def test_get_ticker_resolves_and_fetches(httpx_mock: HTTPXMock, client): httpx_mock.add_response(url=re.compile(r".*/tickle"), json={}) httpx_mock.add_response( url=re.compile(r".*/trsrv/secdef/search.*symbol=AAPL"), json=[{"conid": 265598, "symbol": "AAPL", "secType": "STK"}], ) httpx_mock.add_response( url=re.compile(r".*/iserver/marketdata/snapshot"), json=[{"31": "150.5", "84": "150.4", "86": "150.6", "conid": 265598}], ) snap = await client.get_ticker("AAPL", "stocks") assert snap["last_price"] == 150.5 assert snap["bid"] == 150.4 assert snap["ask"] == 150.6 @pytest.mark.asyncio async def test_resolve_conid_empty_response_raises(httpx_mock: HTTPXMock, client): httpx_mock.add_response(url=re.compile(r".*/tickle"), json={}) httpx_mock.add_response( url=re.compile(r".*/trsrv/secdef/search.*symbol=NOPE"), json=[], ) with pytest.raises(IBKRError, match="IBKR_CONID_NOT_FOUND"): await client.resolve_conid("NOPE", "STK") @pytest.mark.asyncio async def test_resolve_conid_malformed_response_raises(httpx_mock: HTTPXMock, client): httpx_mock.add_response(url=re.compile(r".*/tickle"), json={}) httpx_mock.add_response( url=re.compile(r".*/trsrv/secdef/search.*symbol=BAD"), json=[{"symbol": "BAD"}], # missing conid key ) with pytest.raises(IBKRError, match="malformed"): await client.resolve_conid("BAD", "STK")