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:
Yaojia Wang
2026-03-19 23:03:01 +01:00
parent 4915f1bae4
commit 37c46e76ae
5 changed files with 1291 additions and 0 deletions

363
tests/test_routes_defi.py Normal file
View 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