diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d12cf9e..1ac6315 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -91,4 +91,4 @@ jobs: ❌ Tests failed! Deployment blocked. Please fix the issues and try again. 🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - continue-on-error: true + continue-on-error: true \ No newline at end of file diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d26d3cd..98d39eb 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -231,7 +231,7 @@ jobs: github.event.inputs.action == 'rollback' environment: name: production - + steps: - name: Checkout code uses: actions/checkout@v4 @@ -354,3 +354,4 @@ jobs: 🔗 View details: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} continue-on-error: true + diff --git a/.gitignore b/.gitignore index 6cc702c..8016b04 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,9 @@ database/test.db test.db *.db +# Случайно созданный файл при использовании SQLite :memory: не по назначению +:memory: + # IDE and editor files .vscode/ .idea/ diff --git a/:memory: b/:memory: deleted file mode 100644 index 159e90a..0000000 Binary files a/:memory: and /dev/null differ diff --git a/tests/test_audio_file_service.py b/tests/test_audio_file_service.py index 5452f47..6235909 100644 --- a/tests/test_audio_file_service.py +++ b/tests/test_audio_file_service.py @@ -183,42 +183,46 @@ class TestDownloadAndSaveAudio: @pytest.mark.asyncio async def test_download_and_save_audio_no_message(self, audio_service, mock_bot): - """Тест скачивания когда сообщение отсутствует""" - with pytest.raises(FileOperationError) as exc_info: - await audio_service.download_and_save_audio(mock_bot, None, "test_audio") - + """Тест скачивания когда сообщение отсутствует.""" + with patch('helper_bot.handlers.voice.services.asyncio.sleep', new_callable=AsyncMock): + with pytest.raises(FileOperationError) as exc_info: + await audio_service.download_and_save_audio(mock_bot, None, "test_audio") + assert "Сообщение или голосовое сообщение не найдено" in str(exc_info.value) - + @pytest.mark.asyncio async def test_download_and_save_audio_no_voice(self, audio_service, mock_bot): - """Тест скачивания когда у сообщения нет voice атрибута""" + """Тест скачивания когда у сообщения нет voice атрибута.""" message = Mock() message.voice = None - - with pytest.raises(FileOperationError) as exc_info: - await audio_service.download_and_save_audio(mock_bot, message, "test_audio") - + + with patch('helper_bot.handlers.voice.services.asyncio.sleep', new_callable=AsyncMock): + with pytest.raises(FileOperationError) as exc_info: + await audio_service.download_and_save_audio(mock_bot, message, "test_audio") + assert "Сообщение или голосовое сообщение не найдено" in str(exc_info.value) - + @pytest.mark.asyncio async def test_download_and_save_audio_download_failed(self, audio_service, mock_bot, mock_message, mock_file_info): - """Тест скачивания когда загрузка не удалась""" + """Тест скачивания когда загрузка не удалась.""" mock_bot.get_file.return_value = mock_file_info mock_bot.download_file.return_value = None - - with pytest.raises(FileOperationError) as exc_info: - await audio_service.download_and_save_audio(mock_bot, mock_message, "test_audio") - + + with patch('helper_bot.handlers.voice.services.asyncio.sleep', new_callable=AsyncMock): + with pytest.raises(FileOperationError) as exc_info: + await audio_service.download_and_save_audio(mock_bot, mock_message, "test_audio") + assert "Не удалось скачать файл" in str(exc_info.value) - + @pytest.mark.asyncio async def test_download_and_save_audio_exception_handling(self, audio_service, mock_bot, mock_message): - """Тест обработки исключений при скачивании""" + """Тест обработки исключений при скачивании.""" mock_bot.get_file.side_effect = Exception("Network error") - - with pytest.raises(FileOperationError) as exc_info: - await audio_service.download_and_save_audio(mock_bot, mock_message, "test_audio") - + + with patch('helper_bot.handlers.voice.services.asyncio.sleep', new_callable=AsyncMock): + with pytest.raises(FileOperationError) as exc_info: + await audio_service.download_and_save_audio(mock_bot, mock_message, "test_audio") + assert "Не удалось скачать и сохранить аудио" in str(exc_info.value) diff --git a/tests/test_rate_limiter.py b/tests/test_rate_limiter.py index 9ffbc80..d8aeef1 100644 --- a/tests/test_rate_limiter.py +++ b/tests/test_rate_limiter.py @@ -57,42 +57,37 @@ class TestChatRateLimiter: @pytest.mark.asyncio async def test_wait_if_needed_with_wait(self): - """Тест что ждет если нужно""" + """Тест что ждет если нужно (sleep патчится, проверяем вызов с нужной длительностью).""" config = RateLimitConfig(messages_per_second=0.5, burst_limit=10) # 1 сообщение в 2 секунды limiter = ChatRateLimiter(config) - - # Первый вызов не должен ждать - start_time = time.time() - await limiter.wait_if_needed() - first_call_time = time.time() - start_time - - # Второй вызов должен ждать - start_time = time.time() - await limiter.wait_if_needed() - second_call_time = time.time() - start_time - - assert first_call_time < 0.1 - assert second_call_time >= 1.8 # Должно ждать около 2 секунд - + + with patch('helper_bot.utils.rate_limiter.asyncio.sleep', new_callable=AsyncMock) as mock_sleep: + await limiter.wait_if_needed() + mock_sleep.assert_not_called() + + await limiter.wait_if_needed() + mock_sleep.assert_called_once() + # min_interval = 2.0, ждём ~2 сек + call_arg = mock_sleep.call_args[0][0] + assert 1.8 <= call_arg <= 2.2 + @pytest.mark.asyncio async def test_burst_limit(self): - """Тест ограничения burst""" + """Тест ограничения burst (sleep патчится, проверяем вызов на 3-м вызове).""" config = RateLimitConfig(messages_per_second=10.0, burst_limit=2) limiter = ChatRateLimiter(config) - - # Первые два вызова не должны ждать - start_time = time.time() - await limiter.wait_if_needed() - await limiter.wait_if_needed() - first_two_calls_time = time.time() - start_time - - # Третий вызов должен ждать - start_time = time.time() - await limiter.wait_if_needed() - third_call_time = time.time() - start_time - - assert first_two_calls_time < 0.2 # Более мягкое ограничение - assert third_call_time >= 0.8 # Должно ждать около 1 секунды (с учетом погрешности) + + with patch('helper_bot.utils.rate_limiter.asyncio.sleep', new_callable=AsyncMock) as mock_sleep: + await limiter.wait_if_needed() + await limiter.wait_if_needed() + mock_sleep.reset_mock() + + await limiter.wait_if_needed() + # Третий вызов: сначала sleep по burst (~1.0 с), затем по min_interval (~0.1 с) + assert mock_sleep.call_count >= 1 + args = [c[0][0] for c in mock_sleep.call_args_list] + burst_waits = [a for a in args if 0.8 <= a <= 1.2] + assert len(burst_waits) >= 1, f"Ожидался вызов sleep(~1.0) по burst, получены: {args}" class TestGlobalRateLimiter: @@ -145,33 +140,28 @@ class TestRetryHandler: @pytest.mark.asyncio async def test_execute_with_retry_retry_after(self): - """Тест retry после RetryAfter ошибки""" + """Тест retry после RetryAfter ошибки (sleep патчится, проверяем вызов).""" from aiogram.exceptions import TelegramRetryAfter - + config = RateLimitConfig(retry_after_multiplier=1.0, max_retry_delay=1.0) handler = RetryHandler(config) - + mock_func = AsyncMock() - # Создаем мок для TelegramRetryAfter from unittest.mock import MagicMock retry_after_error = TelegramRetryAfter( method=MagicMock(), message="Flood control exceeded", - retry_after=1 # 1 секунда + retry_after=1 ) - - mock_func.side_effect = [ - retry_after_error, # Первый вызов - ошибка - "success" # Второй вызов - успех - ] - - start_time = time.time() - result = await handler.execute_with_retry(mock_func, 123, max_retries=1) - end_time = time.time() - + mock_func.side_effect = [retry_after_error, "success"] + + with patch('helper_bot.utils.rate_limiter.asyncio.sleep', new_callable=AsyncMock) as mock_sleep: + result = await handler.execute_with_retry(mock_func, 123, max_retries=1) + assert result == "success" assert mock_func.call_count == 2 - assert end_time - start_time >= 0.1 # Должно ждать + mock_sleep.assert_called_once() + assert mock_sleep.call_args[0][0] == 1.0 # retry_after class TestTelegramRateLimiter: diff --git a/tests/test_voice_bot_architecture.py b/tests/test_voice_bot_architecture.py index f0ca934..f83d382 100644 --- a/tests/test_voice_bot_architecture.py +++ b/tests/test_voice_bot_architecture.py @@ -123,36 +123,32 @@ class TestVoiceBotService: @pytest.mark.asyncio async def test_send_welcome_messages_success(self, voice_service, mock_bot_db, mock_settings): - """Тест успешной отправки приветственных сообщений""" + """Тест успешной отправки приветственных сообщений.""" mock_message = Mock() mock_message.from_user.id = 123 mock_message.answer = AsyncMock() mock_message.answer.return_value = Mock() mock_message.answer_sticker = AsyncMock() - - with patch.object(voice_service, 'get_welcome_sticker') as mock_sticker: - mock_sticker.return_value = 'test_sticker.tgs' - - await voice_service.send_welcome_messages(mock_message, '😊') - - # Проверяем, что сообщения отправлены - assert mock_message.answer.call_count >= 1 - + + with patch.object(voice_service, 'get_welcome_sticker', new_callable=AsyncMock, return_value='test_sticker.tgs'): + with patch('helper_bot.handlers.voice.services.asyncio.sleep', new_callable=AsyncMock): + await voice_service.send_welcome_messages(mock_message, '😊') + + assert mock_message.answer.call_count >= 1 + @pytest.mark.asyncio async def test_send_welcome_messages_no_sticker(self, voice_service, mock_bot_db, mock_settings): - """Тест отправки приветственных сообщений без стикера""" + """Тест отправки приветственных сообщений без стикера.""" mock_message = Mock() mock_message.from_user.id = 123 mock_message.answer = AsyncMock() mock_message.answer.return_value = Mock() - - with patch.object(voice_service, 'get_welcome_sticker') as mock_sticker: - mock_sticker.return_value = None - - await voice_service.send_welcome_messages(mock_message, '😊') - - # Проверяем, что сообщения отправлены - assert mock_message.answer.call_count >= 1 + + with patch.object(voice_service, 'get_welcome_sticker', new_callable=AsyncMock, return_value=None): + with patch('helper_bot.handlers.voice.services.asyncio.sleep', new_callable=AsyncMock): + await voice_service.send_welcome_messages(mock_message, '😊') + + assert mock_message.answer.call_count >= 1 class TestVoiceHandlers: diff --git a/tests/test_voice_services.py b/tests/test_voice_services.py index 0853244..f28937b 100644 --- a/tests/test_voice_services.py +++ b/tests/test_voice_services.py @@ -161,53 +161,47 @@ class TestVoiceBotService: @pytest.mark.asyncio async def test_send_welcome_messages_success(self, voice_service, mock_bot_db, mock_settings): - """Тест успешной отправки приветственных сообщений""" + """Тест успешной отправки приветственных сообщений.""" mock_message = Mock() mock_message.from_user.id = 123 mock_message.answer = AsyncMock() mock_message.answer.return_value = Mock() mock_message.answer_sticker = AsyncMock() - - with patch.object(voice_service, 'get_welcome_sticker') as mock_sticker: - mock_sticker.return_value = 'test_sticker.tgs' - - await voice_service.send_welcome_messages(mock_message, '😊') - - # Проверяем, что сообщения отправлены - assert mock_message.answer.call_count >= 1 - + + with patch.object(voice_service, 'get_welcome_sticker', new_callable=AsyncMock, return_value='test_sticker.tgs'): + with patch('helper_bot.handlers.voice.services.asyncio.sleep', new_callable=AsyncMock): + await voice_service.send_welcome_messages(mock_message, '😊') + + assert mock_message.answer.call_count >= 1 + @pytest.mark.asyncio async def test_send_welcome_messages_no_sticker(self, voice_service, mock_bot_db, mock_settings): - """Тест отправки приветственных сообщений без стикера""" + """Тест отправки приветственных сообщений без стикера.""" mock_message = Mock() mock_message.from_user.id = 123 mock_message.answer = AsyncMock() mock_message.answer.return_value = Mock() - - with patch.object(voice_service, 'get_welcome_sticker') as mock_sticker: - mock_sticker.return_value = None - - await voice_service.send_welcome_messages(mock_message, '😊') - - # Проверяем, что сообщения отправлены - assert mock_message.answer.call_count >= 1 - + + with patch.object(voice_service, 'get_welcome_sticker', new_callable=AsyncMock, return_value=None): + with patch('helper_bot.handlers.voice.services.asyncio.sleep', new_callable=AsyncMock): + await voice_service.send_welcome_messages(mock_message, '😊') + + assert mock_message.answer.call_count >= 1 + @pytest.mark.asyncio async def test_send_welcome_messages_with_sticker(self, voice_service, mock_bot_db, mock_settings): - """Тест отправки приветственных сообщений со стикером""" + """Тест отправки приветственных сообщений со стикером.""" mock_message = Mock() mock_message.from_user.id = 123 mock_message.answer = AsyncMock() mock_message.answer.return_value = Mock() mock_message.answer_sticker = AsyncMock() - - with patch.object(voice_service, 'get_welcome_sticker') as mock_sticker: - mock_sticker.return_value = 'test_sticker.tgs' - - await voice_service.send_welcome_messages(mock_message, '😊') - - # Проверяем, что сообщения отправлены - assert mock_message.answer.call_count >= 1 + + with patch.object(voice_service, 'get_welcome_sticker', new_callable=AsyncMock, return_value='test_sticker.tgs'): + with patch('helper_bot.handlers.voice.services.asyncio.sleep', new_callable=AsyncMock): + await voice_service.send_welcome_messages(mock_message, '😊') + + assert mock_message.answer.call_count >= 1 @pytest.mark.asyncio async def test_get_welcome_sticker_with_tgs_files(self, voice_service, mock_settings):