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:
195
defi_service.py
Normal file
195
defi_service.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
"""DeFi data service via DefiLlama API (no API key required)."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
LLAMA_BASE = "https://api.llama.fi"
|
||||||
|
STABLES_BASE = "https://stablecoins.llama.fi"
|
||||||
|
YIELDS_BASE = "https://yields.llama.fi"
|
||||||
|
TIMEOUT = 15.0
|
||||||
|
|
||||||
|
|
||||||
|
async def get_top_protocols(limit: int = 20) -> list[dict[str, Any]]:
|
||||||
|
"""Fetch top DeFi protocols ranked by TVL from DefiLlama."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
||||||
|
resp = await client.get(f"{LLAMA_BASE}/protocols")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": p.get("name"),
|
||||||
|
"symbol": p.get("symbol"),
|
||||||
|
"tvl": p.get("tvl"),
|
||||||
|
"chain": p.get("chain"),
|
||||||
|
"chains": p.get("chains", []),
|
||||||
|
"category": p.get("category"),
|
||||||
|
"change_1d": p.get("change_1d"),
|
||||||
|
"change_7d": p.get("change_7d"),
|
||||||
|
}
|
||||||
|
for p in data[:limit]
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to fetch top protocols from DefiLlama")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def get_chain_tvls() -> list[dict[str, Any]]:
|
||||||
|
"""Fetch TVL rankings for all chains from DefiLlama."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
||||||
|
resp = await client.get(f"{LLAMA_BASE}/v2/chains")
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": c.get("name"),
|
||||||
|
"tvl": c.get("tvl"),
|
||||||
|
"tokenSymbol": c.get("tokenSymbol"),
|
||||||
|
}
|
||||||
|
for c in data
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to fetch chain TVLs from DefiLlama")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def get_protocol_tvl(protocol: str) -> float | None:
|
||||||
|
"""Fetch current TVL for a specific protocol slug."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
||||||
|
resp = await client.get(f"{LLAMA_BASE}/tvl/{protocol}")
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to fetch TVL for protocol %s", protocol)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_yield_pools(
|
||||||
|
chain: str | None = None,
|
||||||
|
project: str | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Fetch yield pools from DefiLlama, optionally filtered by chain and/or project.
|
||||||
|
|
||||||
|
Returns top 20 by TVL descending.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
||||||
|
resp = await client.get(f"{YIELDS_BASE}/pools")
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
pools: list[dict[str, Any]] = payload.get("data", [])
|
||||||
|
|
||||||
|
if chain is not None:
|
||||||
|
pools = [p for p in pools if p.get("chain") == chain]
|
||||||
|
if project is not None:
|
||||||
|
pools = [p for p in pools if p.get("project") == project]
|
||||||
|
|
||||||
|
pools = sorted(pools, key=lambda p: p.get("tvlUsd") or 0, reverse=True)[:20]
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"pool": p.get("pool"),
|
||||||
|
"chain": p.get("chain"),
|
||||||
|
"project": p.get("project"),
|
||||||
|
"symbol": p.get("symbol"),
|
||||||
|
"tvlUsd": p.get("tvlUsd"),
|
||||||
|
"apy": p.get("apy"),
|
||||||
|
"apyBase": p.get("apyBase"),
|
||||||
|
"apyReward": p.get("apyReward"),
|
||||||
|
}
|
||||||
|
for p in pools
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to fetch yield pools from DefiLlama")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_circulating(asset: dict[str, Any]) -> float | None:
|
||||||
|
"""Extract the primary circulating supply value from a stablecoin asset dict."""
|
||||||
|
raw = asset.get("circulating")
|
||||||
|
if raw is None:
|
||||||
|
return None
|
||||||
|
if isinstance(raw, (int, float)):
|
||||||
|
return float(raw)
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
# DefiLlama returns {"peggedUSD": <amount>, ...}
|
||||||
|
values = [v for v in raw.values() if isinstance(v, (int, float))]
|
||||||
|
return values[0] if values else None
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_stablecoins(limit: int = 20) -> list[dict[str, Any]]:
|
||||||
|
"""Fetch top stablecoins by circulating supply from DefiLlama."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
||||||
|
resp = await client.get(f"{STABLES_BASE}/stablecoins")
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
assets: list[dict[str, Any]] = payload.get("peggedAssets", [])
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": a.get("name"),
|
||||||
|
"symbol": a.get("symbol"),
|
||||||
|
"pegType": a.get("pegType"),
|
||||||
|
"circulating": _extract_circulating(a),
|
||||||
|
"price": a.get("price"),
|
||||||
|
}
|
||||||
|
for a in assets[:limit]
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to fetch stablecoins from DefiLlama")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def get_dex_volumes() -> dict[str, Any] | None:
|
||||||
|
"""Fetch DEX volume overview from DefiLlama."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
||||||
|
resp = await client.get(f"{LLAMA_BASE}/overview/dexs")
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
|
||||||
|
protocols = [
|
||||||
|
{
|
||||||
|
"name": p.get("name"),
|
||||||
|
"volume24h": p.get("total24h"),
|
||||||
|
}
|
||||||
|
for p in payload.get("protocols", [])
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"totalVolume24h": payload.get("total24h"),
|
||||||
|
"totalVolume7d": payload.get("total7d"),
|
||||||
|
"protocols": protocols,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to fetch DEX volumes from DefiLlama")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_protocol_fees() -> list[dict[str, Any]]:
|
||||||
|
"""Fetch protocol fees and revenue overview from DefiLlama."""
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=TIMEOUT) as client:
|
||||||
|
resp = await client.get(f"{LLAMA_BASE}/overview/fees")
|
||||||
|
resp.raise_for_status()
|
||||||
|
payload = resp.json()
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": p.get("name"),
|
||||||
|
"fees24h": p.get("total24h"),
|
||||||
|
"revenue24h": p.get("revenue24h"),
|
||||||
|
}
|
||||||
|
for p in payload.get("protocols", [])
|
||||||
|
]
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to fetch protocol fees from DefiLlama")
|
||||||
|
return []
|
||||||
2
main.py
2
main.py
@@ -40,6 +40,7 @@ from routes_technical import router as technical_router # noqa: E402
|
|||||||
from routes_portfolio import router as portfolio_router # noqa: E402
|
from routes_portfolio import router as portfolio_router # noqa: E402
|
||||||
from routes_backtest import router as backtest_router # noqa: E402
|
from routes_backtest import router as backtest_router # noqa: E402
|
||||||
from routes_cn import router as cn_router # noqa: E402
|
from routes_cn import router as cn_router # noqa: E402
|
||||||
|
from routes_defi import router as defi_router # noqa: E402
|
||||||
|
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
level=settings.log_level.upper(),
|
level=settings.log_level.upper(),
|
||||||
@@ -87,6 +88,7 @@ app.include_router(regulators_router)
|
|||||||
app.include_router(portfolio_router)
|
app.include_router(portfolio_router)
|
||||||
app.include_router(backtest_router)
|
app.include_router(backtest_router)
|
||||||
app.include_router(cn_router)
|
app.include_router(cn_router)
|
||||||
|
app.include_router(defi_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/health", response_model=dict[str, str])
|
@app.get("/health", response_model=dict[str, str])
|
||||||
|
|||||||
75
routes_defi.py
Normal file
75
routes_defi.py
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
"""DeFi data routes via DefiLlama API."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
|
||||||
|
import defi_service
|
||||||
|
from models import ApiResponse
|
||||||
|
from route_utils import safe
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/v1/defi")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tvl/protocols", response_model=ApiResponse)
|
||||||
|
@safe
|
||||||
|
async def tvl_protocols() -> ApiResponse:
|
||||||
|
"""Get top DeFi protocols ranked by TVL."""
|
||||||
|
data = await defi_service.get_top_protocols()
|
||||||
|
return ApiResponse(data=data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tvl/chains", response_model=ApiResponse)
|
||||||
|
@safe
|
||||||
|
async def tvl_chains() -> ApiResponse:
|
||||||
|
"""Get TVL rankings for all chains."""
|
||||||
|
data = await defi_service.get_chain_tvls()
|
||||||
|
return ApiResponse(data=data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tvl/{protocol}", response_model=ApiResponse)
|
||||||
|
@safe
|
||||||
|
async def protocol_tvl(protocol: str) -> ApiResponse:
|
||||||
|
"""Get current TVL for a specific protocol slug."""
|
||||||
|
tvl = await defi_service.get_protocol_tvl(protocol)
|
||||||
|
if tvl is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Protocol '{protocol}' not found")
|
||||||
|
return ApiResponse(data={"protocol": protocol, "tvl": tvl})
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/yields", response_model=ApiResponse)
|
||||||
|
@safe
|
||||||
|
async def yield_pools(
|
||||||
|
chain: str | None = Query(default=None, description="Filter by chain name"),
|
||||||
|
project: str | None = Query(default=None, description="Filter by project name"),
|
||||||
|
) -> ApiResponse:
|
||||||
|
"""Get top yield pools, optionally filtered by chain and/or project."""
|
||||||
|
data = await defi_service.get_yield_pools(chain=chain, project=project)
|
||||||
|
return ApiResponse(data=data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/stablecoins", response_model=ApiResponse)
|
||||||
|
@safe
|
||||||
|
async def stablecoins() -> ApiResponse:
|
||||||
|
"""Get top stablecoins by circulating supply."""
|
||||||
|
data = await defi_service.get_stablecoins()
|
||||||
|
return ApiResponse(data=data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/volumes/dexs", response_model=ApiResponse)
|
||||||
|
@safe
|
||||||
|
async def dex_volumes() -> ApiResponse:
|
||||||
|
"""Get DEX volume overview including top protocols."""
|
||||||
|
data = await defi_service.get_dex_volumes()
|
||||||
|
if data is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail="Failed to fetch DEX volume data from DefiLlama",
|
||||||
|
)
|
||||||
|
return ApiResponse(data=data)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/fees", response_model=ApiResponse)
|
||||||
|
@safe
|
||||||
|
async def protocol_fees() -> ApiResponse:
|
||||||
|
"""Get protocol fees and revenue overview."""
|
||||||
|
data = await defi_service.get_protocol_fees()
|
||||||
|
return ApiResponse(data=data)
|
||||||
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