feat: add DeFi data via DefiLlama API (TDD)
7 new endpoints under /api/v1/defi/ (all free, no API key):
- GET /defi/tvl/protocols - top DeFi protocols by TVL
- GET /defi/tvl/chains - chain TVL rankings
- GET /defi/tvl/{protocol} - single protocol TVL
- GET /defi/yields - top yield pools (filter by chain/project)
- GET /defi/stablecoins - stablecoin market data
- GET /defi/volumes/dexs - DEX volume overview
- GET /defi/fees - protocol fees/revenue overview
Data source: DefiLlama API (free, no key needed)
58 new tests (33 service + 25 route). All 561 tests passing.
This commit is contained in:
656
tests/test_defi_service.py
Normal file
656
tests/test_defi_service.py
Normal file
@@ -0,0 +1,656 @@
|
||||
"""Tests for defi_service.py - DefiLlama API integration.
|
||||
|
||||
TDD: these tests are written before implementation.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import defi_service
|
||||
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
|
||||
363
tests/test_routes_defi.py
Normal file
363
tests/test_routes_defi.py
Normal file
@@ -0,0 +1,363 @@
|
||||
"""Tests for routes_defi.py - DeFi API routes.
|
||||
|
||||
TDD: these tests are written before implementation.
|
||||
"""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from httpx import ASGITransport, AsyncClient
|
||||
|
||||
from main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def client():
|
||||
transport = ASGITransport(app=app)
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as c:
|
||||
yield c
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/v1/defi/tvl/protocols
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_top_protocols", new_callable=AsyncMock)
|
||||
async def test_tvl_protocols_happy_path(mock_fn, client):
|
||||
mock_fn.return_value = [
|
||||
{
|
||||
"name": "Aave",
|
||||
"symbol": "AAVE",
|
||||
"tvl": 10_000_000_000.0,
|
||||
"chain": "Ethereum",
|
||||
"chains": ["Ethereum"],
|
||||
"category": "Lending",
|
||||
"change_1d": 0.5,
|
||||
"change_7d": -1.2,
|
||||
}
|
||||
]
|
||||
resp = await client.get("/api/v1/defi/tvl/protocols")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
assert len(data["data"]) == 1
|
||||
assert data["data"][0]["name"] == "Aave"
|
||||
assert data["data"][0]["tvl"] == 10_000_000_000.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_top_protocols", new_callable=AsyncMock)
|
||||
async def test_tvl_protocols_empty(mock_fn, client):
|
||||
mock_fn.return_value = []
|
||||
resp = await client.get("/api/v1/defi/tvl/protocols")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_top_protocols", new_callable=AsyncMock)
|
||||
async def test_tvl_protocols_service_error_returns_502(mock_fn, client):
|
||||
mock_fn.side_effect = RuntimeError("DefiLlama unavailable")
|
||||
resp = await client.get("/api/v1/defi/tvl/protocols")
|
||||
|
||||
assert resp.status_code == 502
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/v1/defi/tvl/chains
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_chain_tvls", new_callable=AsyncMock)
|
||||
async def test_tvl_chains_happy_path(mock_fn, client):
|
||||
mock_fn.return_value = [
|
||||
{"name": "Ethereum", "tvl": 50_000_000_000.0, "tokenSymbol": "ETH"},
|
||||
{"name": "BSC", "tvl": 5_000_000_000.0, "tokenSymbol": "BNB"},
|
||||
]
|
||||
resp = await client.get("/api/v1/defi/tvl/chains")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
assert len(data["data"]) == 2
|
||||
assert data["data"][0]["name"] == "Ethereum"
|
||||
assert data["data"][0]["tokenSymbol"] == "ETH"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_chain_tvls", new_callable=AsyncMock)
|
||||
async def test_tvl_chains_empty(mock_fn, client):
|
||||
mock_fn.return_value = []
|
||||
resp = await client.get("/api/v1/defi/tvl/chains")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_chain_tvls", new_callable=AsyncMock)
|
||||
async def test_tvl_chains_service_error_returns_502(mock_fn, client):
|
||||
mock_fn.side_effect = RuntimeError("upstream error")
|
||||
resp = await client.get("/api/v1/defi/tvl/chains")
|
||||
|
||||
assert resp.status_code == 502
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/v1/defi/tvl/{protocol}
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_protocol_tvl", new_callable=AsyncMock)
|
||||
async def test_protocol_tvl_happy_path(mock_fn, client):
|
||||
mock_fn.return_value = 10_000_000_000.0
|
||||
resp = await client.get("/api/v1/defi/tvl/aave")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
assert data["data"]["protocol"] == "aave"
|
||||
assert data["data"]["tvl"] == 10_000_000_000.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_protocol_tvl", new_callable=AsyncMock)
|
||||
async def test_protocol_tvl_not_found_returns_404(mock_fn, client):
|
||||
mock_fn.return_value = None
|
||||
resp = await client.get("/api/v1/defi/tvl/nonexistent-protocol")
|
||||
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_protocol_tvl", new_callable=AsyncMock)
|
||||
async def test_protocol_tvl_service_error_returns_502(mock_fn, client):
|
||||
mock_fn.side_effect = RuntimeError("HTTP error")
|
||||
resp = await client.get("/api/v1/defi/tvl/aave")
|
||||
|
||||
assert resp.status_code == 502
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_protocol_tvl", new_callable=AsyncMock)
|
||||
async def test_protocol_tvl_passes_slug_to_service(mock_fn, client):
|
||||
mock_fn.return_value = 5_000_000_000.0
|
||||
await client.get("/api/v1/defi/tvl/uniswap-v3")
|
||||
|
||||
mock_fn.assert_called_once_with("uniswap-v3")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/v1/defi/yields
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_yield_pools", new_callable=AsyncMock)
|
||||
async def test_yields_happy_path(mock_fn, client):
|
||||
mock_fn.return_value = [
|
||||
{
|
||||
"pool": "0xabcd",
|
||||
"chain": "Ethereum",
|
||||
"project": "aave-v3",
|
||||
"symbol": "USDC",
|
||||
"tvlUsd": 1_000_000_000.0,
|
||||
"apy": 3.5,
|
||||
"apyBase": 3.0,
|
||||
"apyReward": 0.5,
|
||||
}
|
||||
]
|
||||
resp = await client.get("/api/v1/defi/yields")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
assert len(data["data"]) == 1
|
||||
assert data["data"][0]["pool"] == "0xabcd"
|
||||
assert data["data"][0]["apy"] == 3.5
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_yield_pools", new_callable=AsyncMock)
|
||||
async def test_yields_with_chain_filter(mock_fn, client):
|
||||
mock_fn.return_value = []
|
||||
resp = await client.get("/api/v1/defi/yields?chain=Ethereum")
|
||||
|
||||
assert resp.status_code == 200
|
||||
mock_fn.assert_called_once_with(chain="Ethereum", project=None)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_yield_pools", new_callable=AsyncMock)
|
||||
async def test_yields_with_project_filter(mock_fn, client):
|
||||
mock_fn.return_value = []
|
||||
resp = await client.get("/api/v1/defi/yields?project=aave-v3")
|
||||
|
||||
assert resp.status_code == 200
|
||||
mock_fn.assert_called_once_with(chain=None, project="aave-v3")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_yield_pools", new_callable=AsyncMock)
|
||||
async def test_yields_with_chain_and_project_filter(mock_fn, client):
|
||||
mock_fn.return_value = []
|
||||
resp = await client.get("/api/v1/defi/yields?chain=Polygon&project=curve")
|
||||
|
||||
assert resp.status_code == 200
|
||||
mock_fn.assert_called_once_with(chain="Polygon", project="curve")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_yield_pools", new_callable=AsyncMock)
|
||||
async def test_yields_empty(mock_fn, client):
|
||||
mock_fn.return_value = []
|
||||
resp = await client.get("/api/v1/defi/yields")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_yield_pools", new_callable=AsyncMock)
|
||||
async def test_yields_service_error_returns_502(mock_fn, client):
|
||||
mock_fn.side_effect = RuntimeError("yields API down")
|
||||
resp = await client.get("/api/v1/defi/yields")
|
||||
|
||||
assert resp.status_code == 502
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/v1/defi/stablecoins
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_stablecoins", new_callable=AsyncMock)
|
||||
async def test_stablecoins_happy_path(mock_fn, client):
|
||||
mock_fn.return_value = [
|
||||
{
|
||||
"name": "Tether",
|
||||
"symbol": "USDT",
|
||||
"pegType": "peggedUSD",
|
||||
"circulating": 100_000_000_000.0,
|
||||
"price": 1.0,
|
||||
}
|
||||
]
|
||||
resp = await client.get("/api/v1/defi/stablecoins")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
assert len(data["data"]) == 1
|
||||
assert data["data"][0]["symbol"] == "USDT"
|
||||
assert data["data"][0]["circulating"] == 100_000_000_000.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_stablecoins", new_callable=AsyncMock)
|
||||
async def test_stablecoins_empty(mock_fn, client):
|
||||
mock_fn.return_value = []
|
||||
resp = await client.get("/api/v1/defi/stablecoins")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_stablecoins", new_callable=AsyncMock)
|
||||
async def test_stablecoins_service_error_returns_502(mock_fn, client):
|
||||
mock_fn.side_effect = RuntimeError("stables API error")
|
||||
resp = await client.get("/api/v1/defi/stablecoins")
|
||||
|
||||
assert resp.status_code == 502
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/v1/defi/volumes/dexs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_dex_volumes", new_callable=AsyncMock)
|
||||
async def test_dex_volumes_happy_path(mock_fn, client):
|
||||
mock_fn.return_value = {
|
||||
"totalVolume24h": 5_000_000_000.0,
|
||||
"totalVolume7d": 30_000_000_000.0,
|
||||
"protocols": [
|
||||
{"name": "Uniswap", "volume24h": 2_000_000_000.0},
|
||||
],
|
||||
}
|
||||
resp = await client.get("/api/v1/defi/volumes/dexs")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
assert data["data"]["totalVolume24h"] == 5_000_000_000.0
|
||||
assert data["data"]["totalVolume7d"] == 30_000_000_000.0
|
||||
assert len(data["data"]["protocols"]) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_dex_volumes", new_callable=AsyncMock)
|
||||
async def test_dex_volumes_returns_502_when_service_returns_none(mock_fn, client):
|
||||
mock_fn.return_value = None
|
||||
resp = await client.get("/api/v1/defi/volumes/dexs")
|
||||
|
||||
assert resp.status_code == 502
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_dex_volumes", new_callable=AsyncMock)
|
||||
async def test_dex_volumes_service_error_returns_502(mock_fn, client):
|
||||
mock_fn.side_effect = RuntimeError("volume API error")
|
||||
resp = await client.get("/api/v1/defi/volumes/dexs")
|
||||
|
||||
assert resp.status_code == 502
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /api/v1/defi/fees
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_protocol_fees", new_callable=AsyncMock)
|
||||
async def test_fees_happy_path(mock_fn, client):
|
||||
mock_fn.return_value = [
|
||||
{"name": "Uniswap", "fees24h": 1_000_000.0, "revenue24h": 500_000.0},
|
||||
{"name": "Aave", "fees24h": 800_000.0, "revenue24h": 800_000.0},
|
||||
]
|
||||
resp = await client.get("/api/v1/defi/fees")
|
||||
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
assert len(data["data"]) == 2
|
||||
assert data["data"][0]["name"] == "Uniswap"
|
||||
assert data["data"][0]["fees24h"] == 1_000_000.0
|
||||
assert data["data"][0]["revenue24h"] == 500_000.0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_protocol_fees", new_callable=AsyncMock)
|
||||
async def test_fees_empty(mock_fn, client):
|
||||
mock_fn.return_value = []
|
||||
resp = await client.get("/api/v1/defi/fees")
|
||||
|
||||
assert resp.status_code == 200
|
||||
assert resp.json()["data"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("routes_defi.defi_service.get_protocol_fees", new_callable=AsyncMock)
|
||||
async def test_fees_service_error_returns_502(mock_fn, client):
|
||||
mock_fn.side_effect = RuntimeError("fees API error")
|
||||
resp = await client.get("/api/v1/defi/fees")
|
||||
|
||||
assert resp.status_code == 502
|
||||
Reference in New Issue
Block a user