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