Files
openbb-invest-api/tests/test_defi_service.py
Yaojia Wang ec005c91a9
All checks were successful
continuous-integration/drone/push Build is passing
chore: fix all ruff lint warnings
- Remove unused datetime imports from openbb_service, market_service,
  quantitative_service (now using obb_utils.days_ago)
- Remove unused variable 'maintains' in routes_sentiment
- Remove unused imports in test files
- Fix forward reference annotation in test helper
2026-03-19 23:19:08 +01:00

656 lines
20 KiB
Python

"""Tests for defi_service.py - DefiLlama API integration.
TDD: these tests are written before implementation.
"""
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from defi_service import (
get_chain_tvls,
get_dex_volumes,
get_protocol_fees,
get_protocol_tvl,
get_stablecoins,
get_top_protocols,
get_yield_pools,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
SAMPLE_PROTOCOLS = [
{
"name": "Aave",
"symbol": "AAVE",
"tvl": 10_000_000_000.0,
"chain": "Ethereum",
"chains": ["Ethereum", "Polygon"],
"category": "Lending",
"change_1d": 0.5,
"change_7d": -1.2,
},
{
"name": "Uniswap",
"symbol": "UNI",
"tvl": 8_000_000_000.0,
"chain": "Ethereum",
"chains": ["Ethereum"],
"category": "DEX",
"change_1d": 1.0,
"change_7d": 2.0,
},
]
SAMPLE_CHAINS = [
{"name": "Ethereum", "tvl": 50_000_000_000.0, "tokenSymbol": "ETH"},
{"name": "BSC", "tvl": 5_000_000_000.0, "tokenSymbol": "BNB"},
]
SAMPLE_POOLS = [
{
"pool": "0xabcd",
"chain": "Ethereum",
"project": "aave-v3",
"symbol": "USDC",
"tvlUsd": 1_000_000_000.0,
"apy": 3.5,
"apyBase": 3.0,
"apyReward": 0.5,
},
{
"pool": "0x1234",
"chain": "Polygon",
"project": "curve",
"symbol": "DAI",
"tvlUsd": 500_000_000.0,
"apy": 4.2,
"apyBase": 2.5,
"apyReward": 1.7,
},
]
SAMPLE_STABLECOINS_RESPONSE = {
"peggedAssets": [
{
"name": "Tether",
"symbol": "USDT",
"pegType": "peggedUSD",
"circulating": {"peggedUSD": 100_000_000_000.0},
"price": 1.0,
},
{
"name": "USD Coin",
"symbol": "USDC",
"pegType": "peggedUSD",
"circulating": {"peggedUSD": 40_000_000_000.0},
"price": 1.0,
},
]
}
SAMPLE_DEX_RESPONSE = {
"total24h": 5_000_000_000.0,
"total7d": 30_000_000_000.0,
"protocols": [
{"name": "Uniswap", "total24h": 2_000_000_000.0},
{"name": "Curve", "total24h": 500_000_000.0},
],
}
SAMPLE_FEES_RESPONSE = {
"protocols": [
{"name": "Uniswap", "total24h": 1_000_000.0, "revenue24h": 500_000.0},
{"name": "Aave", "total24h": 800_000.0, "revenue24h": 800_000.0},
],
}
def _make_mock_client(json_return):
"""Build a fully configured AsyncMock for httpx.AsyncClient context manager.
Uses MagicMock for the response object so that resp.json() (a sync method
in httpx) returns the value directly rather than a coroutine.
"""
mock_resp = MagicMock()
mock_resp.status_code = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json.return_value = json_return
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
return mock_client
# ---------------------------------------------------------------------------
# get_top_protocols
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_top_protocols_returns_top_20(mock_client_cls):
raw = [
{
"name": f"Protocol{i}",
"symbol": f"P{i}",
"tvl": float(100 - i),
"chain": "Ethereum",
"chains": ["Ethereum"],
"category": "Lending",
"change_1d": 0.1,
"change_7d": 0.2,
}
for i in range(30)
]
mock_client_cls.return_value = _make_mock_client(raw)
result = await get_top_protocols()
assert len(result) == 20
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_top_protocols_returns_correct_fields(mock_client_cls):
mock_client_cls.return_value = _make_mock_client(SAMPLE_PROTOCOLS)
result = await get_top_protocols()
assert len(result) == 2
first = result[0]
assert first["name"] == "Aave"
assert first["symbol"] == "AAVE"
assert first["tvl"] == 10_000_000_000.0
assert first["chain"] == "Ethereum"
assert first["chains"] == ["Ethereum", "Polygon"]
assert first["category"] == "Lending"
assert first["change_1d"] == 0.5
assert first["change_7d"] == -1.2
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_top_protocols_respects_custom_limit(mock_client_cls):
raw = [
{"name": f"P{i}", "symbol": f"S{i}", "tvl": float(i), "chain": "ETH",
"chains": [], "category": "DEX", "change_1d": 0.0, "change_7d": 0.0}
for i in range(25)
]
mock_client_cls.return_value = _make_mock_client(raw)
result = await get_top_protocols(limit=5)
assert len(result) == 5
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_top_protocols_handles_missing_fields(mock_client_cls):
raw = [{"name": "Sparse"}] # missing all optional fields
mock_client_cls.return_value = _make_mock_client(raw)
result = await get_top_protocols()
assert len(result) == 1
assert result[0]["name"] == "Sparse"
assert result[0]["tvl"] is None
assert result[0]["symbol"] is None
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_top_protocols_returns_empty_on_http_error(mock_client_cls):
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = Exception("HTTP 500")
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client_cls.return_value = mock_client
result = await get_top_protocols()
assert result == []
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_top_protocols_calls_correct_url(mock_client_cls):
mock_client = _make_mock_client([])
mock_client_cls.return_value = mock_client
await get_top_protocols()
mock_client.get.assert_called_once_with("https://api.llama.fi/protocols")
# ---------------------------------------------------------------------------
# get_chain_tvls
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_chain_tvls_returns_all_chains(mock_client_cls):
mock_client_cls.return_value = _make_mock_client(SAMPLE_CHAINS)
result = await get_chain_tvls()
assert len(result) == 2
assert result[0]["name"] == "Ethereum"
assert result[0]["tvl"] == 50_000_000_000.0
assert result[0]["tokenSymbol"] == "ETH"
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_chain_tvls_handles_missing_token_symbol(mock_client_cls):
raw = [{"name": "SomeChain", "tvl": 100.0}]
mock_client_cls.return_value = _make_mock_client(raw)
result = await get_chain_tvls()
assert result[0]["tokenSymbol"] is None
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_chain_tvls_returns_empty_on_error(mock_client_cls):
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = Exception("timeout")
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client_cls.return_value = mock_client
result = await get_chain_tvls()
assert result == []
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_chain_tvls_calls_correct_url(mock_client_cls):
mock_client = _make_mock_client([])
mock_client_cls.return_value = mock_client
await get_chain_tvls()
mock_client.get.assert_called_once_with("https://api.llama.fi/v2/chains")
# ---------------------------------------------------------------------------
# get_protocol_tvl
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_protocol_tvl_returns_numeric_value(mock_client_cls):
mock_client_cls.return_value = _make_mock_client(10_000_000_000.0)
result = await get_protocol_tvl("aave")
assert result == 10_000_000_000.0
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_protocol_tvl_calls_correct_url(mock_client_cls):
mock_client = _make_mock_client(1234.0)
mock_client_cls.return_value = mock_client
await get_protocol_tvl("uniswap")
mock_client.get.assert_called_once_with("https://api.llama.fi/tvl/uniswap")
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_protocol_tvl_returns_none_on_error(mock_client_cls):
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = Exception("404 not found")
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client_cls.return_value = mock_client
result = await get_protocol_tvl("nonexistent")
assert result is None
# ---------------------------------------------------------------------------
# get_yield_pools
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_yield_pools_returns_top_20_by_tvl(mock_client_cls):
# Create 30 pools with decreasing TVL
raw_data = {
"data": [
{
"pool": f"pool{i}",
"chain": "Ethereum",
"project": "aave",
"symbol": "USDC",
"tvlUsd": float(1000 - i),
"apy": 3.0,
"apyBase": 2.5,
"apyReward": 0.5,
}
for i in range(30)
]
}
mock_client_cls.return_value = _make_mock_client(raw_data)
result = await get_yield_pools()
assert len(result) == 20
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_yield_pools_returns_correct_fields(mock_client_cls):
mock_client_cls.return_value = _make_mock_client({"data": SAMPLE_POOLS})
result = await get_yield_pools()
assert len(result) == 2
first = result[0]
assert first["pool"] == "0xabcd"
assert first["chain"] == "Ethereum"
assert first["project"] == "aave-v3"
assert first["symbol"] == "USDC"
assert first["tvlUsd"] == 1_000_000_000.0
assert first["apy"] == 3.5
assert first["apyBase"] == 3.0
assert first["apyReward"] == 0.5
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_yield_pools_filters_by_chain(mock_client_cls):
mock_client_cls.return_value = _make_mock_client({"data": SAMPLE_POOLS})
result = await get_yield_pools(chain="Ethereum")
assert len(result) == 1
assert result[0]["chain"] == "Ethereum"
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_yield_pools_filters_by_project(mock_client_cls):
mock_client_cls.return_value = _make_mock_client({"data": SAMPLE_POOLS})
result = await get_yield_pools(project="curve")
assert len(result) == 1
assert result[0]["project"] == "curve"
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_yield_pools_filters_by_chain_and_project(mock_client_cls):
pools = SAMPLE_POOLS + [
{
"pool": "0xzzzz",
"chain": "Ethereum",
"project": "curve",
"symbol": "USDT",
"tvlUsd": 200_000_000.0,
"apy": 2.0,
"apyBase": 2.0,
"apyReward": 0.0,
}
]
mock_client_cls.return_value = _make_mock_client({"data": pools})
result = await get_yield_pools(chain="Ethereum", project="curve")
assert len(result) == 1
assert result[0]["pool"] == "0xzzzz"
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_yield_pools_returns_empty_on_error(mock_client_cls):
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = Exception("Connection error")
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client_cls.return_value = mock_client
result = await get_yield_pools()
assert result == []
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_yield_pools_calls_correct_url(mock_client_cls):
mock_client = _make_mock_client({"data": []})
mock_client_cls.return_value = mock_client
await get_yield_pools()
mock_client.get.assert_called_once_with("https://yields.llama.fi/pools")
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_yield_pools_sorts_by_tvl_descending(mock_client_cls):
unsorted_pools = [
{
"pool": "low", "chain": "Ethereum", "project": "aave",
"symbol": "DAI", "tvlUsd": 100.0, "apy": 1.0,
"apyBase": 1.0, "apyReward": 0.0,
},
{
"pool": "high", "chain": "Ethereum", "project": "aave",
"symbol": "USDC", "tvlUsd": 9000.0, "apy": 2.0,
"apyBase": 2.0, "apyReward": 0.0,
},
]
mock_client_cls.return_value = _make_mock_client({"data": unsorted_pools})
result = await get_yield_pools()
assert result[0]["pool"] == "high"
assert result[1]["pool"] == "low"
# ---------------------------------------------------------------------------
# get_stablecoins
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_stablecoins_returns_correct_fields(mock_client_cls):
mock_client_cls.return_value = _make_mock_client(SAMPLE_STABLECOINS_RESPONSE)
result = await get_stablecoins()
assert len(result) == 2
first = result[0]
assert first["name"] == "Tether"
assert first["symbol"] == "USDT"
assert first["pegType"] == "peggedUSD"
assert first["circulating"] == 100_000_000_000.0
assert first["price"] == 1.0
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_stablecoins_returns_top_20(mock_client_cls):
assets = [
{
"name": f"Stable{i}",
"symbol": f"S{i}",
"pegType": "peggedUSD",
"circulating": {"peggedUSD": float(1000 - i)},
"price": 1.0,
}
for i in range(25)
]
mock_client_cls.return_value = _make_mock_client({"peggedAssets": assets})
result = await get_stablecoins()
assert len(result) == 20
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_stablecoins_handles_missing_circulating(mock_client_cls):
raw = {"peggedAssets": [{"name": "NoCirc", "symbol": "NC", "pegType": "peggedUSD", "price": 1.0}]}
mock_client_cls.return_value = _make_mock_client(raw)
result = await get_stablecoins()
assert result[0]["circulating"] is None
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_stablecoins_returns_empty_on_error(mock_client_cls):
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = Exception("timeout")
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client_cls.return_value = mock_client
result = await get_stablecoins()
assert result == []
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_stablecoins_calls_correct_url(mock_client_cls):
mock_client = _make_mock_client({"peggedAssets": []})
mock_client_cls.return_value = mock_client
await get_stablecoins()
mock_client.get.assert_called_once_with("https://stablecoins.llama.fi/stablecoins")
# ---------------------------------------------------------------------------
# get_dex_volumes
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_dex_volumes_returns_correct_structure(mock_client_cls):
mock_client_cls.return_value = _make_mock_client(SAMPLE_DEX_RESPONSE)
result = await get_dex_volumes()
assert result["totalVolume24h"] == 5_000_000_000.0
assert result["totalVolume7d"] == 30_000_000_000.0
assert len(result["protocols"]) == 2
assert result["protocols"][0]["name"] == "Uniswap"
assert result["protocols"][0]["volume24h"] == 2_000_000_000.0
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_dex_volumes_returns_none_on_error(mock_client_cls):
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = Exception("server error")
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client_cls.return_value = mock_client
result = await get_dex_volumes()
assert result is None
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_dex_volumes_calls_correct_url(mock_client_cls):
mock_client = _make_mock_client({"total24h": 0, "total7d": 0, "protocols": []})
mock_client_cls.return_value = mock_client
await get_dex_volumes()
mock_client.get.assert_called_once_with("https://api.llama.fi/overview/dexs")
# ---------------------------------------------------------------------------
# get_protocol_fees
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_protocol_fees_returns_correct_structure(mock_client_cls):
mock_client_cls.return_value = _make_mock_client(SAMPLE_FEES_RESPONSE)
result = await get_protocol_fees()
assert len(result) == 2
first = result[0]
assert first["name"] == "Uniswap"
assert first["fees24h"] == 1_000_000.0
assert first["revenue24h"] == 500_000.0
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_protocol_fees_returns_empty_on_error(mock_client_cls):
mock_resp = MagicMock()
mock_resp.raise_for_status.side_effect = Exception("connection reset")
mock_client = AsyncMock()
mock_client.get = AsyncMock(return_value=mock_resp)
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
mock_client.__aexit__ = AsyncMock(return_value=None)
mock_client_cls.return_value = mock_client
result = await get_protocol_fees()
assert result == []
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_protocol_fees_calls_correct_url(mock_client_cls):
mock_client = _make_mock_client({"protocols": []})
mock_client_cls.return_value = mock_client
await get_protocol_fees()
mock_client.get.assert_called_once_with("https://api.llama.fi/overview/fees")
@pytest.mark.asyncio
@patch("defi_service.httpx.AsyncClient")
async def test_get_protocol_fees_handles_missing_revenue(mock_client_cls):
raw = {"protocols": [{"name": "SomeProtocol", "total24h": 500_000.0}]}
mock_client_cls.return_value = _make_mock_client(raw)
result = await get_protocol_fees()
assert result[0]["revenue24h"] is None