diff --git a/tests/test_bots.py b/tests/test_bots.py new file mode 100644 index 0000000..68a851d --- /dev/null +++ b/tests/test_bots.py @@ -0,0 +1,377 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +import discord +from discord.ext import commands + +from bots.commander import CommanderBot, pool, on_select_pool +from bots.fees import FeesBot +from bots.price import PriceBot +from bots.rewards import RewardsBot +from bots.tvl import TVLBot +from bots.ticker import TickerBot +from bots.data import LiquidityPool, LiquidityPoolEpoch +from bots.helpers import is_address + + +@pytest.fixture +def mock_interaction(): + """Mock Discord interaction for testing""" + interaction = MagicMock(spec=discord.Interaction) + interaction.response = MagicMock() + interaction.response.send_message = AsyncMock() + interaction.user = MagicMock() + interaction.user.id = 12345 + interaction.client = MagicMock() + interaction.client.emojis = [] + return interaction + + +@pytest.fixture +def mock_pool(): + """Mock LiquidityPool for testing""" + pool = MagicMock(spec=LiquidityPool) + pool.lp = "0x1234567890123456789012345678901234567890" + pool.token0 = MagicMock() + pool.token0.symbol = "WETH" + pool.token1 = MagicMock() + pool.token1.symbol = "USDC" + pool.is_stable = True + pool.pool_fee_percentage = 0.003 + pool.volume = 1000000 + pool.reserve0 = MagicMock() + pool.reserve0.amount = 100 + pool.reserve1 = MagicMock() + pool.reserve1.amount = 200000 + return pool + + +@pytest.fixture +def mock_pool_epoch(): + """Mock LiquidityPoolEpoch for testing""" + epoch = MagicMock(spec=LiquidityPoolEpoch) + epoch.total_fees = 50000 + epoch.total_bribes = 25000 + return epoch + + +class TestCommanderBot: + """Test cases for CommanderBot slash commands""" + + @pytest.mark.asyncio + async def test_pool_command_with_valid_address(self, mock_interaction, mock_pool): + """Test /pool command with a valid pool address""" + with patch('bots.commander.LiquidityPool.by_address', return_value=mock_pool), \ + patch('bots.commander.LiquidityPool.tvl', return_value=1000000), \ + patch('bots.commander.LiquidityPoolEpoch.fetch_for_pool', return_value=MagicMock()), \ + patch('bots.commander.PoolStats') as mock_pool_stats: + + mock_embed = MagicMock() + mock_pool_stats.return_value.render = AsyncMock(return_value=mock_embed) + + await pool(mock_interaction, "0x1234567890123456789012345678901234567890") + + mock_interaction.response.send_message.assert_called_once() + mock_pool_stats.assert_called_once() + + @pytest.mark.asyncio + async def test_pool_command_with_invalid_address(self, mock_interaction): + """Test /pool command with an invalid pool address""" + with patch('bots.commander.LiquidityPool.by_address', return_value=None): + await pool(mock_interaction, "0xinvalid") + + mock_interaction.response.send_message.assert_called_once_with( + "No pool found with this address: 0xinvalid" + ) + + @pytest.mark.asyncio + async def test_pool_command_with_search_query_single_result(self, mock_interaction, mock_pool): + """Test /pool command with search query that returns single result""" + with patch('bots.commander.is_address', return_value=False), \ + patch('bots.commander.LiquidityPool.search', return_value=[mock_pool]), \ + patch('bots.commander.LiquidityPool.tvl', return_value=1000000), \ + patch('bots.commander.LiquidityPoolEpoch.fetch_for_pool', return_value=MagicMock()), \ + patch('bots.commander.PoolStats') as mock_pool_stats: + + mock_embed = MagicMock() + mock_pool_stats.return_value.render = AsyncMock(return_value=mock_embed) + + await pool(mock_interaction, "WETH") + + mock_interaction.response.send_message.assert_called_once() + mock_pool_stats.assert_called_once() + + @pytest.mark.asyncio + async def test_pool_command_with_search_query_multiple_results(self, mock_interaction, mock_pool): + """Test /pool command with search query that returns multiple results""" + pools = [mock_pool, mock_pool] # Two pools + + with patch('bots.commander.is_address', return_value=False), \ + patch('bots.commander.LiquidityPool.search', return_value=pools), \ + patch('bots.commander.PoolsDropdown') as mock_dropdown: + + await pool(mock_interaction, "ETH") + + mock_interaction.response.send_message.assert_called_once_with( + "Choose a pool:", + view=mock_dropdown.return_value + ) + mock_dropdown.assert_called_once() + + @pytest.mark.asyncio + async def test_on_select_pool_with_address(self, mock_interaction, mock_pool, mock_pool_epoch): + """Test on_select_pool callback with pool address""" + with patch('bots.commander.LiquidityPool.by_address', return_value=mock_pool), \ + patch('bots.commander.LiquidityPool.tvl', return_value=1000000), \ + patch('bots.commander.LiquidityPoolEpoch.fetch_for_pool', return_value=mock_pool_epoch), \ + patch('bots.commander.PoolStats') as mock_pool_stats: + + mock_embed = MagicMock() + mock_pool_stats.return_value.render = AsyncMock(return_value=mock_embed) + + await on_select_pool(mock_interaction, mock_pool.lp) + + mock_interaction.response.send_message.assert_called_once_with(embed=mock_embed) + + +class TestTickerBots: + """Test cases for all TickerBot implementations""" + + @pytest.mark.asyncio + async def test_tvl_bot_on_ready(self): + """Test TVL bot initialization""" + with patch('bots.tvl.TickerBot.update_presence') as mock_update_presence: + bot = TVLBot(command_prefix="!", protocol_name="Velodrome") + bot.user = MagicMock() + bot.user.id = 12345 + + await bot.on_ready() + + mock_update_presence.assert_called_once_with("TVL Velodrome") + + @pytest.mark.asyncio + async def test_tvl_bot_ticker_success(self): + """Test TVL bot ticker update success""" + mock_pools = [MagicMock()] + mock_tokens = [MagicMock()] + + with patch('bots.tvl.LiquidityPool.get_pools', return_value=mock_pools), \ + patch('bots.tvl.LiquidityPool.tvl', return_value=5000000), \ + patch('bots.tvl.Token.get_all_listed_tokens', return_value=mock_tokens), \ + patch('bots.tvl.TickerBot.update_nick_for_all_servers') as mock_update_nick, \ + patch('bots.tvl.TickerBot.update_presence') as mock_update_presence: + + bot = TVLBot(command_prefix="!", protocol_name="Velodrome") + + await bot.ticker() + + mock_update_nick.assert_called_once_with("TVL ~$5.00M") + mock_update_presence.assert_called_once_with("Based on 1 listed tokens") + + @pytest.mark.asyncio + async def test_tvl_bot_ticker_error_handling(self): + """Test TVL bot ticker error handling""" + with patch('bots.tvl.LiquidityPool.get_pools', side_effect=Exception("API Error")), \ + patch('bots.tvl.LOGGER') as mock_logger: + + bot = TVLBot(command_prefix="!", protocol_name="Velodrome") + + await bot.ticker() + + mock_logger.error.assert_called_once() + + @pytest.mark.asyncio + async def test_fees_bot_on_ready(self): + """Test Fees bot initialization""" + with patch('bots.fees.TickerBot.update_presence') as mock_update_presence: + bot = FeesBot(command_prefix="!", protocol_name="Velodrome") + bot.user = MagicMock() + bot.user.id = 12345 + + await bot.on_ready() + + mock_update_presence.assert_called_once_with("Watching fees for Velodrome") + + @pytest.mark.asyncio + async def test_fees_bot_ticker_success(self): + """Test Fees bot ticker update success""" + mock_pools = [ + MagicMock(total_fees=1000), + MagicMock(total_fees=2000), + MagicMock(total_fees=1500) + ] + + with patch('bots.fees.LiquidityPool.get_pools', return_value=mock_pools), \ + patch('bots.fees.TickerBot.update_nick_for_all_servers') as mock_update_nick, \ + patch('bots.fees.TickerBot.update_presence') as mock_update_presence: + + bot = FeesBot(command_prefix="!", protocol_name="Velodrome") + + await bot.ticker() + + mock_update_nick.assert_called_once_with("Fees ~$4.5K") + mock_update_presence.assert_called_once_with("Based on 3 pools this epoch") + + @pytest.mark.asyncio + async def test_fees_bot_ticker_empty_pools(self): + """Test Fees bot with no pools""" + with patch('bots.fees.LiquidityPool.get_pools', return_value=[]), \ + patch('bots.fees.TickerBot.update_nick_for_all_servers') as mock_update_nick, \ + patch('bots.fees.TickerBot.update_presence') as mock_update_presence: + + bot = FeesBot(command_prefix="!", protocol_name="Velodrome") + + await bot.ticker() + + mock_update_nick.assert_called_once_with("Fees ~$0") + mock_update_presence.assert_called_once_with("Based on 0 pools this epoch") + + @pytest.mark.asyncio + async def test_price_bot_initialization(self): + """Test Price bot basic functionality""" + with patch('bots.price.TickerBot.update_presence') as mock_update_presence: + bot = PriceBot(command_prefix="!", protocol_name="Velodrome", token_symbol="VELO") + bot.user = MagicMock() + bot.user.id = 12345 + + await bot.on_ready() + + mock_update_presence.assert_called_once_with("VELO price on Velodrome") + + @pytest.mark.asyncio + async def test_rewards_bot_initialization(self): + """Test Rewards bot basic functionality""" + with patch('bots.rewards.TickerBot.update_presence') as mock_update_presence: + bot = RewardsBot(command_prefix="!", protocol_name="Velodrome") + bot.user = MagicMock() + bot.user.id = 12345 + + await bot.on_ready() + + mock_update_presence.assert_called_once_with("Rewards for Velodrome") + + +class TestHelperFunctions: + """Test cases for helper functions""" + + @pytest.mark.parametrize("address,expected", [ + ("0x1234567890123456789012345678901234567890", True), + ("0x123", False), + ("not_an_address", False), + ("", False), + ]) + def test_is_address(self, address, expected): + """Test address validation""" + assert is_address(address) == expected + + +class TestBotIntegration: + """Integration tests for bot functionality""" + + @pytest.mark.asyncio + async def test_commander_bot_singleton(self): + """Test that CommanderBot returns singleton instance""" + bot1 = CommanderBot() + bot2 = CommanderBot() + + assert bot1 is bot2 + assert isinstance(bot1, commands.Bot) + + @pytest.mark.asyncio + async def test_bot_initialization(self): + """Test bot initialization with proper intents""" + bot = CommanderBot() + + assert bot.command_prefix == "/" + assert bot.intents.message_content is True + assert bot.intents.guilds is True # Default intent + + +class TestErrorHandling: + """Test error handling scenarios""" + + @pytest.mark.asyncio + async def test_pool_command_network_error(self, mock_interaction): + """Test pool command handling of network/API errors""" + with patch('bots.commander.LiquidityPool.by_address', side_effect=Exception("Network error")): + await pool(mock_interaction, "0x1234567890123456789012345678901234567890") + + # Should handle error gracefully + mock_interaction.response.send_message.assert_called_once() + + @pytest.mark.asyncio + async def test_ticker_network_error(self): + """Test ticker error handling for network issues""" + with patch('bots.tvl.LiquidityPool.get_pools', side_effect=Exception("API timeout")), \ + patch('bots.tvl.LOGGER') as mock_logger: + + bot = TVLBot(command_prefix="!", protocol_name="Velodrome") + + await bot.ticker() + + mock_logger.error.assert_called_once_with("Ticker failed with API timeout") + + +class TestDataValidation: + """Test data validation and edge cases""" + + @pytest.mark.asyncio + async def test_empty_search_results(self, mock_interaction): + """Test handling of empty search results""" + with patch('bots.commander.is_address', return_value=False), \ + patch('bots.commander.LiquidityPool.search', return_value=[]): + + await pool(mock_interaction, "nonexistent") + + mock_interaction.response.send_message.assert_called_once_with( + "Choose a pool:", + view=mock_interaction.client.PoolsDropdown.return_value + ) + + @pytest.mark.asyncio + async def test_pool_stats_rendering(self, mock_interaction, mock_pool, mock_pool_epoch): + """Test pool stats rendering with various data""" + with patch('bots.commander.LiquidityPool.by_address', return_value=mock_pool), \ + patch('bots.commander.LiquidityPool.tvl', return_value=5000000), \ + patch('bots.commander.LiquidityPoolEpoch.fetch_for_pool', return_value=mock_pool_epoch), \ + patch('bots.commander.PoolStats') as mock_pool_stats: + + mock_embed = MagicMock() + mock_pool_stats.return_value.render = AsyncMock(return_value=mock_embed) + + await pool(mock_interaction, mock_pool.lp) + + # Verify PoolStats was called with correct parameters + mock_pool_stats.assert_called_once() + render_call = mock_pool_stats.return_value.render.call_args + assert render_call[0][0] == mock_pool # pool + assert render_call[0][1] == 5000000 # tvl + assert render_call[0][2] == mock_pool_epoch # pool_epoch + + +class TestConcurrency: + """Test concurrent operations and race conditions""" + + @pytest.mark.asyncio + async def test_multiple_pool_commands(self, mock_interaction, mock_pool): + """Test handling multiple simultaneous pool commands""" + interactions = [mock_interaction, mock_interaction, mock_interaction] + + with patch('bots.commander.LiquidityPool.by_address', return_value=mock_pool), \ + patch('bots.commander.LiquidityPool.tvl', return_value=1000000), \ + patch('bots.commander.LiquidityPoolEpoch.fetch_for_pool', return_value=MagicMock()), \ + patch('bots.commander.PoolStats') as mock_pool_stats: + + mock_embed = MagicMock() + mock_pool_stats.return_value.render = AsyncMock(return_value=mock_embed) + + # Execute multiple commands concurrently + await asyncio.gather(*[ + pool(interaction, mock_pool.lp) for interaction in interactions + ]) + + # Each interaction should have been handled + assert mock_interaction.response.send_message.call_count == 3 + + +# Import here to avoid circular imports in test file +import asyncio