From 857a7364ddb9b10529593c5e9c3578eb95c1da9b Mon Sep 17 00:00:00 2001 From: salmonik Date: Sat, 18 Apr 2026 16:24:42 +0200 Subject: add a test suite Signed-off-by: salmonik --- pytest.ini | 7 ++ requirements-dev.txt | 59 +++++++++ tests/README.md | 84 +++++++++++++ tests/cogs/test_ai.py | 69 +++++++++++ tests/cogs/test_assistant.py | 66 ++++++++++ tests/cogs/test_compression_conversion.py | 139 +++++++++++++++++++++ tests/cogs/test_downloader.py | 93 ++++++++++++++ tests/cogs/test_math_commands.py | 55 +++++++++ tests/cogs/test_music_audio.py | 93 ++++++++++++++ tests/cogs/test_music_join.py | 53 ++++++++ tests/cogs/test_music_play.py | 104 ++++++++++++++++ tests/cogs/test_plot.py | 85 +++++++++++++ tests/cogs/test_qr.py | 75 +++++++++++ tests/cogs/test_say.py | 38 ++++++ tests/cogs/test_search.py | 83 +++++++++++++ tests/cogs/test_trigger.py | 106 ++++++++++++++++ tests/cogs/test_tts.py | 58 +++++++++ tests/cogs/test_vclogs.py | 83 +++++++++++++ tests/cogs/test_voice.py | 79 ++++++++++++ tests/cogs/test_web.py | 57 +++++++++ tests/conftest.py | 198 ++++++++++++++++++++++++++++++ 21 files changed, 1684 insertions(+) create mode 100644 pytest.ini create mode 100644 requirements-dev.txt create mode 100644 tests/README.md create mode 100644 tests/cogs/test_ai.py create mode 100644 tests/cogs/test_assistant.py create mode 100644 tests/cogs/test_compression_conversion.py create mode 100644 tests/cogs/test_downloader.py create mode 100644 tests/cogs/test_math_commands.py create mode 100644 tests/cogs/test_music_audio.py create mode 100644 tests/cogs/test_music_join.py create mode 100644 tests/cogs/test_music_play.py create mode 100644 tests/cogs/test_plot.py create mode 100644 tests/cogs/test_qr.py create mode 100644 tests/cogs/test_say.py create mode 100644 tests/cogs/test_search.py create mode 100644 tests/cogs/test_trigger.py create mode 100644 tests/cogs/test_tts.py create mode 100644 tests/cogs/test_vclogs.py create mode 100644 tests/cogs/test_voice.py create mode 100644 tests/cogs/test_web.py create mode 100644 tests/conftest.py diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..03a070f --- /dev/null +++ b/pytest.ini @@ -0,0 +1,7 @@ +[pytest] +asyncio_default_fixture_loop_scope = function +python_files = test_*.py +python_classes = Test* +python_functions = test_* +testpaths = tests +pythonpath = . \ No newline at end of file diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..abba086 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,59 @@ +pytest==8.3.4 +pytest-asyncio==0.25.2 +pytest-mock==3.14.0 +aiofiles==25.1.0 +aiohappyeyeballs==2.6.1 +aiohttp==3.13.3 +aiosignal==1.4.0 +anyio==4.12.1 +attrs==25.4.0 +basedpyright==1.39.2 +beautifulsoup4==4.14.3 +brotli==1.2.0 +bs4==0.0.2 +certifi==2026.2.25 +cffi==2.0.0 +charset-normalizer==3.4.6 +click==8.1.8 +contourpy==1.3.3 +croniter==6.2.2 +cycler==0.12.1 +davey==0.1.4 +dotenv==0.9.9 +fonttools==4.62.1 +frozenlist==1.8.0 +gTTS==2.5.4 +h11==0.16.0 +httpcore==1.0.9 +httpx==0.28.1 +idna==3.11 +kiwisolver==1.5.0 +matplotlib==3.10.8 +multidict==6.7.1 +mutagen==1.47.0 +nodejs-wheel-binaries==24.14.1 +numpy==2.4.4 +packaging==26.1 +pillow==12.2.0 +propcache==0.4.1 +py-cord @ file:///home/salmon/Documents/botcode/senator-sperma/pycord +pycparser==3.0 +pycryptodomex==3.23.0 +PyNaCl==1.6.2 +pyparsing==3.3.2 +python-dateutil==2.9.0.post0 +python-dotenv==1.2.2 +pytube==15.0.0 +pyzbar==0.1.9 +qrcode==8.2 +RapidFuzz==3.14.3 +requests==2.32.5 +six==1.17.0 +soupsieve==2.8.3 +typing_extensions==4.15.0 +urllib3==2.6.3 +websockets==16.0 +yarl==1.23.0 +youtube-search-python==1.6.6 +yt-dlp==2026.3.17 +yt-dlp-ejs==0.8.0 diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..77b832c --- /dev/null +++ b/tests/README.md @@ -0,0 +1,84 @@ +# Test Suite Coverage + +This document tracks the test coverage for Senator Sperma bot. + +## Running Tests + +```bash +pytest tests/ -v +``` + +## Coverage by Cog + +| Cog | Test File | Tests | Coverage | +|-----|-----------|-------|----------| +| **say.py** | test_say.py | 3 | Basic command execution, empty text, long text | +| **web.py** | test_web.py | 3 | Success, no results, HTTP error | +| **qr.py** | test_qr.py | 4 | Generate, empty text, decode unavailable, download failure | +| **math_commands.py** | test_math_commands.py | 3 | Valid expression, invalid expression, process error | +| **ai.py** | test_ai.py | 3 | Success, empty choices, no choices key | +| **downloader.py** | test_downloader.py | 5 | File success, URL success, error, message with/without URL | +| **search.py** | test_search.py | 5 | Success, no cache, file not found, empty filename, autocomplete | +| **tts.py** | test_tts.py | 3 | File output, voice output not in channel, error handling | +| **trigger.py** | test_trigger.py | 5 | Create, add, add not found, delete, help | +| **compression.py** | test_compression_conversion.py | 4 | Filetype detection, get compression commands | +| **conversion.py** | test_compression_conversion.py | 5 | Build conversion commands, cleanup | +| **plot.py** | test_plot.py | 6 | Parse expression, is implicit, parse implicit, command execution | +| **vclogs.py** | test_vclogs.py | 7 | Format duration, load/save activity, state change handlers | +| **voice.py** | test_voice.py | 5 | Get manager, join channel, get status, stop | +| **music/join.py** | test_music_join.py | 1 | Join command success | +| **music/stop.py** | test_music_join.py | 1 | Stop command success | +| **music/play_commands.py** | test_music_play.py | 4 | Search embed, validation errors | +| **music/loop.py** | test_music_play.py | 2 | Is looping, toggle | +| **music/audio_playback.py** | test_music_audio.py | 8 | Validate voice, prepare source, cleanup | +| **assistant.py** | test_assistant.py | 4 | Keyword, on_message filtering, mentions | + +## Test Structure + +``` +tests/ +├── conftest.py # Shared fixtures and mock classes +├── cogs/ +│ ├── test_say.py +│ ├── test_web.py +│ ├── test_qr.py +│ ├── test_math_commands.py +│ ├── test_ai.py +│ ├── test_downloader.py +│ ├── test_search.py +│ ├── test_tts.py +│ ├── test_trigger.py +│ ├── test_compression_conversion.py +│ ├── test_plot.py +│ ├── test_vclogs.py +│ ├── test_voice.py +│ ├── test_music_join.py +│ ├── test_music_play.py +│ ├── test_music_audio.py +│ └── test_assistant.py +└── README.md # This file +``` + +## Mock Classes + +The test suite uses custom mock classes defined in `conftest.py`: + +- `MockUser` - Simulates Discord user +- `MockChannel` / `MockTextChannel` / `MockVoiceChannel` - Simulates Discord channels +- `MockGuild` - Simulates Discord guild +- `MockMessage` - Simulates Discord message +- `MockInteraction` - Simulates Discord interaction +- `MockApplicationContext` - Simulates slash command context + +## Test Categories + +1. **Unit tests** - Test individual functions and methods +2. **Integration tests** - Test command execution with mocked dependencies +3. **Error handling tests** - Test error conditions and edge cases + +## Notes + +- External services (OpenAI, yt-dlp, ffmpeg) are mocked +- Database operations in trigger.py are mocked +- File system operations are mocked where needed +- Some tests may require specific environment variables to be set (mocked) \ No newline at end of file diff --git a/tests/cogs/test_ai.py b/tests/cogs/test_ai.py new file mode 100644 index 0000000..bd8f813 --- /dev/null +++ b/tests/cogs/test_ai.py @@ -0,0 +1,69 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +import discord +from tests.conftest import MockApplicationContext + + +@pytest.fixture +def ai_cog(bot): + from cogs.ai import AI + cog = AI(bot) + return cog + + +@pytest.fixture +def mock_ai_response(): + return { + "choices": [ + { + "message": { + "content": "Test response from AI" + } + } + ] + } + + +@pytest.mark.asyncio +async def test_senator_command_success(ai_cog, mock_channel, mock_guild, bot, mock_ai_response): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_response = MagicMock() + mock_response.json = MagicMock(return_value=mock_ai_response) + mock_response.status_code = 200 + + with patch('cogs.ai.requests.post', return_value=mock_response): + with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup: + await ai_cog.senator.callback(ai_cog, ctx, text="Hello", tts=False) + mock_followup.assert_called_once_with("Test response from AI") + + +@pytest.mark.asyncio +async def test_senator_command_empty_choices(ai_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_response = MagicMock() + mock_response.json = MagicMock(return_value={"choices": []}) + mock_response.status_code = 200 + + with patch('cogs.ai.requests.post', return_value=mock_response): + with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup: + await ai_cog.senator.callback(ai_cog, ctx, text="Hello", tts=False) + mock_followup.assert_called_once_with("bot siem zesroł") + + +@pytest.mark.asyncio +async def test_senator_command_no_choices_key(ai_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_response = MagicMock() + mock_response.json = MagicMock(return_value={}) + mock_response.status_code = 200 + + with patch('cogs.ai.requests.post', return_value=mock_response): + with patch('cogs.ai.logger') as mock_logger: + try: + await ai_cog.senator.callback(ai_cog, ctx, text="Hello", tts=False) + except KeyError: + pass + assert mock_logger.info.called \ No newline at end of file diff --git a/tests/cogs/test_assistant.py b/tests/cogs/test_assistant.py new file mode 100644 index 0000000..09c3934 --- /dev/null +++ b/tests/cogs/test_assistant.py @@ -0,0 +1,66 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from tests.conftest import MockApplicationContext + + +@pytest.fixture +def assistant_cog(): + with patch('os.getenv', return_value="test_keyword"): + from cogs.assistant import Jarvis + + mock_bot = MagicMock() + mock_bot.user = MagicMock() + mock_bot.user.id = 111 + + cog = Jarvis(mock_bot) + return cog + + +def test_assistant_keyword_default(assistant_cog): + assert assistant_cog.keyword == "test_keyword" + + +@pytest.mark.asyncio +async def test_assistant_on_message_ignored_bot_message(assistant_cog): + mock_author = MagicMock() + mock_author.id = 456 + mock_author.bot = True + + mock_message = MagicMock() + mock_message.author = mock_author + + with patch.object(assistant_cog, 'assistant', new_callable=AsyncMock) as mock_assistant: + await assistant_cog.on_message(mock_message) + mock_assistant.assert_not_called() + + +@pytest.mark.asyncio +async def test_assistant_on_message_mentions_bot(assistant_cog): + mock_message = MagicMock() + mock_message.author = MagicMock() + mock_message.author.id = 123 + + mock_mention = MagicMock() + mock_mention.id = 111 + mock_message.mentions = [mock_mention] + mock_message.content = "test" + + with patch.object(assistant_cog, 'assistant', new_callable=AsyncMock) as mock_assistant: + await assistant_cog.on_message(mock_message) + mock_assistant.assert_called_once_with(mock_message) + + +@pytest.mark.asyncio +async def test_assistant_on_message_keyword_match(assistant_cog): + assistant_cog.keyword = "test" + + mock_message = MagicMock() + mock_message.author = MagicMock() + mock_message.author.id = 123 + + mock_message.mentions = [] + mock_message.content = "test message with test keyword" + + with patch.object(assistant_cog, 'assistant', new_callable=AsyncMock) as mock_assistant: + await assistant_cog.on_message(mock_message) + mock_assistant.assert_called_once_with(mock_message) \ No newline at end of file diff --git a/tests/cogs/test_compression_conversion.py b/tests/cogs/test_compression_conversion.py new file mode 100644 index 0000000..f9f74ad --- /dev/null +++ b/tests/cogs/test_compression_conversion.py @@ -0,0 +1,139 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +import os +import discord +from tests.conftest import MockApplicationContext + + +def test_compression_filetype_video(): + from cogs.compression import filetype + assert filetype("test.mp4") == "Video" + assert filetype("test.avi") == "Video" + + +def test_compression_filetype_audio(): + from cogs.compression import filetype + assert filetype("test.mp3") == "Audio" + assert filetype("test.wav") == "Audio" + + +def test_compression_filetype_image(): + from cogs.compression import filetype + assert filetype("test.jpg") == "Image" + assert filetype("test.png") == "Image" + + +def test_conversion_filetype(): + from cogs.conversion import filetype + assert filetype("test.mp4") == "Video" + assert filetype("test.mp3") == "Audio" + assert filetype("test.jpg") == "Image" + assert filetype("test.xyz") == "Unknown" + + +@pytest.mark.asyncio +async def test_build_conversion_command_video(): + with patch('os.getenv', return_value="https://example.com"): + with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): + with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): + import importlib + import cogs.conversion + importlib.reload(cogs.conversion) + from cogs.conversion import Conversion + + cog = Conversion.__new__(Conversion) + cog._baseurl = "https://example.com" + cog._uploadurl = "https://example.com" + + cmd = cog._build_conversion_command("Video", "/tmp/test.mp4", "/tmp/output.mp4", "") + assert cmd is not None + assert "ffmpeg" in cmd + + +@pytest.mark.asyncio +async def test_build_conversion_command_image(): + with patch('os.getenv', return_value="https://example.com"): + with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): + with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): + import importlib + import cogs.conversion + importlib.reload(cogs.conversion) + from cogs.conversion import Conversion + + cog = Conversion.__new__(Conversion) + cog._baseurl = "https://example.com" + cog._uploadurl = "https://example.com" + + cmd = cog._build_conversion_command("Image", "/tmp/test.jpg", "/tmp/output.png", "") + assert cmd is not None + assert "magick" in cmd + + +@pytest.mark.asyncio +async def test_build_conversion_command_unknown(): + with patch('os.getenv', return_value="https://example.com"): + with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): + with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): + import importlib + import cogs.conversion + importlib.reload(cogs.conversion) + from cogs.conversion import Conversion + + cog = Conversion.__new__(Conversion) + cog._baseurl = "https://example.com" + cog._uploadurl = "https://example.com" + + cmd = cog._build_conversion_command("Unknown", "/tmp/test.xyz", "/tmp/output.abc", "") + assert cmd is None + + +@pytest.mark.asyncio +async def test_compression_get_command_video(): + with patch('os.getenv', return_value="https://example.com"): + with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): + with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): + import importlib + import cogs.compression + importlib.reload(cogs.compression) + from cogs.compression import Compression + + cog = Compression.__new__(Compression) + cog.temp_dir = "/tmp" + + cmd = cog._get_compression_command("Video", "/tmp/test.mp4", "/tmp/output.mp4") + assert cmd is not None + assert "ffmpeg" in cmd + + +@pytest.mark.asyncio +async def test_compression_get_command_image(): + with patch('os.getenv', return_value="https://example.com"): + with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): + with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): + import importlib + import cogs.compression + importlib.reload(cogs.compression) + from cogs.compression import Compression + + cog = Compression.__new__(Compression) + cog.temp_dir = "/tmp" + + cmd = cog._get_compression_command("Image", "/tmp/test.jpg", "/tmp/output.jpg") + assert cmd is not None + + +@pytest.mark.asyncio +async def test_compression_get_command_unknown(): + with patch('os.getenv', return_value="https://example.com"): + with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): + with patch('cogs.utils.settings.get_baseurl', return_value="https://example.com"): + import importlib + import cogs.compression + importlib.reload(cogs.compression) + from cogs.compression import Compression + + cog = Compression.__new__(Compression) + cog.temp_dir = "/tmp" + + cmd = cog._get_compression_command("Unknown", "/tmp/test.xyz", "/tmp/output.xyz") + assert cmd is None \ No newline at end of file diff --git a/tests/cogs/test_downloader.py b/tests/cogs/test_downloader.py new file mode 100644 index 0000000..91eef93 --- /dev/null +++ b/tests/cogs/test_downloader.py @@ -0,0 +1,93 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +import discord +from tests.conftest import MockApplicationContext, MockMessage + + +@pytest.fixture +def downloader_cog(bot): + from cogs.downloader import Downloader + cog = Downloader(bot) + return cog + + +@pytest.mark.asyncio +async def test_download_command_success_file(downloader_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_file_path = "/tmp/test_video.mp4" + + with patch('cogs.downloader.Downloader.download_standalone', return_value={ + "status": "success", + "type": "file", + "content": mock_file_path, + "filename": "test_video.mp4" + }): + with patch('discord.File', return_value=MagicMock()) as mock_file: + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await downloader_cog.download.callback(downloader_cog, ctx, url="https://example.com/video", catbox=False, fformat="mp4", filename_user=None) + mock_respond.assert_called_once() + call_args = mock_respond.call_args + assert 'file' in str(call_args) or mock_file.called + + +@pytest.mark.asyncio +async def test_download_command_success_url(downloader_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + with patch('cogs.downloader.Downloader.download_standalone', return_value={ + "status": "success", + "type": "url", + "content": "https://catbox.2137697.xyz/sperma/test.mp4" + }): + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await downloader_cog.download.callback(downloader_cog, ctx, url="https://example.com/video", catbox=True, fformat="mp4", filename_user=None) + mock_respond.assert_called_once() + call_args = mock_respond.call_args + assert "pobioron" in str(call_args) + + +@pytest.mark.asyncio +async def test_download_command_error(downloader_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + with patch('cogs.downloader.Downloader.download_standalone', return_value={ + "status": "error", + "type": "error", + "content": "Download failed", + "details": "Connection timeout" + }): + with patch('cogs.downloader.logger') as mock_logger: + await downloader_cog.download.callback(downloader_cog, ctx, url="https://badurl.com", catbox=False, fformat="mp4", filename_user=None) + assert mock_logger.error.called + + +@pytest.mark.asyncio +async def test_message_download_with_url(downloader_cog, mock_channel, mock_guild, bot, mock_user): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_message = MagicMock() + mock_message.content = "Check this out https://example.com/video" + + with patch('cogs.downloader.Downloader.download_standalone', return_value={ + "status": "success", + "type": "file", + "content": "/tmp/test.mp4" + }): + with patch.object(downloader_cog, 'download', new_callable=AsyncMock) as mock_download: + await downloader_cog.message_download.callback(downloader_cog, ctx, message=mock_message) + mock_download.assert_called_once() + + +@pytest.mark.asyncio +async def test_message_download_no_url(downloader_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_message = MagicMock() + mock_message.content = "No URL here" + + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await downloader_cog.message_download.callback(downloader_cog, ctx, message=mock_message) + mock_respond.assert_called_once() + call_args = mock_respond.call_args + assert "Brak" in str(call_args) \ No newline at end of file diff --git a/tests/cogs/test_math_commands.py b/tests/cogs/test_math_commands.py new file mode 100644 index 0000000..0acac4a --- /dev/null +++ b/tests/cogs/test_math_commands.py @@ -0,0 +1,55 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from tests.conftest import MockApplicationContext + + +@pytest.fixture +def math_cog(bot): + from cogs.math_commands import Math + cog = Math(bot) + return cog + + +@pytest.mark.asyncio +async def test_math_command_valid_expression(math_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_process = MagicMock() + mock_process.returncode = 0 + mock_process.communicate = AsyncMock(return_value=(b"4", b"")) + + with patch('cogs.math_commands.asyncio.create_subprocess_exec', return_value=mock_process) as mock_subprocess: + with patch('cogs.math_commands.re.search', return_value=None): + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await math_cog.math.callback(math_cog, ctx, equation="2+2") + mock_subprocess.assert_called_once() + mock_respond.assert_called_once() + + +@pytest.mark.asyncio +async def test_math_command_invalid_expression(math_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + with patch('cogs.math_commands.re.search', return_value=MagicMock()): + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await math_cog.math.callback(math_cog, ctx, equation="eval(1)") + mock_respond.assert_called_once() + args = mock_respond.call_args + assert "Invalid" in str(args) + + +@pytest.mark.asyncio +async def test_math_command_process_error(math_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_process = MagicMock() + mock_process.returncode = 1 + mock_process.communicate = AsyncMock(return_value=(b"", b"Error: invalid expression")) + + with patch('cogs.math_commands.asyncio.create_subprocess_exec', return_value=mock_process): + with patch('cogs.math_commands.re.search', return_value=None): + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await math_cog.math.callback(math_cog, ctx, equation="2++") + mock_respond.assert_called_once() + args = mock_respond.call_args + assert "error" in str(args).lower() or "Error" in str(args) \ No newline at end of file diff --git a/tests/cogs/test_music_audio.py b/tests/cogs/test_music_audio.py new file mode 100644 index 0000000..b53cf95 --- /dev/null +++ b/tests/cogs/test_music_audio.py @@ -0,0 +1,93 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +import os +from tests.conftest import MockApplicationContext + + +@pytest.mark.asyncio +async def test_validate_voice_channel_not_in_voice(): + from cogs.music.audio_playback import AudioPlaybackManager + + mock_ctx = MagicMock() + mock_ctx.author.voice = None + mock_ctx.respond = AsyncMock() + + result = await AudioPlaybackManager.validate_voice_channel(mock_ctx) + + assert result is None + mock_ctx.respond.assert_called_once() + + +@pytest.mark.asyncio +async def test_validate_voice_channel_in_voice(): + from cogs.music.audio_playback import AudioPlaybackManager + + mock_channel = MagicMock() + mock_ctx = MagicMock() + mock_ctx.author.voice = MagicMock() + mock_ctx.author.voice.channel = mock_channel + mock_ctx.respond = AsyncMock() + + result = await AudioPlaybackManager.validate_voice_channel(mock_ctx) + + assert result == mock_channel + + +@pytest.mark.asyncio +async def test_prepare_file_source_file_not_found(): + from cogs.music.audio_playback import AudioPlaybackManager + + display_name, absolute_path = await AudioPlaybackManager.prepare_file_source("/nonexistent/file.mp3") + + assert display_name is None + assert absolute_path is None + + +@pytest.mark.asyncio +async def test_prepare_file_source_absolute_path(): + from cogs.music.audio_playback import AudioPlaybackManager + + with patch('os.path.exists', return_value=True): + with patch('os.path.isabs', return_value=True): + display_name, absolute_path = await AudioPlaybackManager.prepare_file_source("/tmp/test.mp3") + + assert display_name == "test" + assert absolute_path == "/tmp/test.mp3" + + +@pytest.mark.asyncio +async def test_prepare_source_url(): + from cogs.music.audio_playback import AudioPlaybackManager + + source, display_name, is_url = await AudioPlaybackManager.prepare_source(MagicMock(), "https://example.com/stream") + + assert is_url == True + assert source == "https://example.com/stream" + + +@pytest.mark.asyncio +async def test_prepare_source_local_file(): + from cogs.music.audio_playback import AudioPlaybackManager + + with patch('os.path.exists', return_value=True): + source, display_name, is_url = await AudioPlaybackManager.prepare_source(MagicMock(), "/tmp/test.mp3") + + assert is_url == False + assert display_name == "test" + + +@pytest.mark.asyncio +async def test_cleanup_temp_file(): + from cogs.music.audio_playback import AudioPlaybackManager + + with patch('os.path.exists', return_value=True): + with patch('os.remove'): + await AudioPlaybackManager.cleanup_temp_file("/tmp/test.mp3") + + +@pytest.mark.asyncio +async def test_cleanup_temp_file_not_exists(): + from cogs.music.audio_playback import AudioPlaybackManager + + with patch('os.path.exists', return_value=False): + await AudioPlaybackManager.cleanup_temp_file("/nonexistent/file.mp3") \ No newline at end of file diff --git a/tests/cogs/test_music_join.py b/tests/cogs/test_music_join.py new file mode 100644 index 0000000..5fc66f6 --- /dev/null +++ b/tests/cogs/test_music_join.py @@ -0,0 +1,53 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from tests.conftest import MockApplicationContext + + +@pytest.fixture +def join_cog(bot): + from cogs.music.join import Join + cog = Join(bot) + return cog + + +@pytest.fixture +def stop_cog(bot): + from cogs.music.stop import Stop + cog = Stop(bot) + return cog + + +@pytest.mark.asyncio +async def test_join_command_success(join_cog, mock_channel, mock_guild, bot, mock_user): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + ctx.user = mock_user + ctx.author = mock_user + + mock_voice_channel = MagicMock() + mock_voice = MagicMock() + mock_voice_channel.connect = AsyncMock(return_value=mock_voice) + + ctx.author.voice = MagicMock() + ctx.author.voice.channel = mock_voice_channel + + with patch.object(mock_voice_channel, 'connect', new_callable=AsyncMock, return_value=mock_voice): + with patch.object(ctx, 'respond', new_callable=AsyncMock): + with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): + await join_cog.join.callback(join_cog, ctx) + + +@pytest.mark.asyncio +async def test_stop_command_success(stop_cog, mock_channel, mock_guild, bot, mock_user): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + ctx.user = mock_user + ctx.author = mock_user + + mock_voice_client = MagicMock() + mock_voice_client.disconnect = AsyncMock() + ctx.voice_client = mock_voice_client + + with patch.object(ctx, 'respond', new_callable=AsyncMock): + with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock): + await stop_cog.stop.callback(stop_cog, ctx) + + mock_voice_client.disconnect.assert_called_once() \ No newline at end of file diff --git a/tests/cogs/test_music_play.py b/tests/cogs/test_music_play.py new file mode 100644 index 0000000..abdd81c --- /dev/null +++ b/tests/cogs/test_music_play.py @@ -0,0 +1,104 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +import os +from tests.conftest import MockApplicationContext + + +@pytest.fixture +def play_commands_cog(bot): + with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): + from cogs.music.play_commands import PlayCommands + cog = PlayCommands(bot) + return cog + + +@pytest.fixture +def loop_cog(bot): + from cogs.music.loop import Loop + cog = Loop(bot) + return cog + + +@pytest.mark.asyncio +async def test_create_search_embed_empty(play_commands_cog): + embed, view = await play_commands_cog._create_search_embed([]) + + assert embed.title == "Wyniki wyszukiwania" + assert len(view.children) == 0 + + +@pytest.mark.asyncio +async def test_create_search_embed_with_candidates(play_commands_cog): + candidates = [ + "https://example.com/test1.mp3", + "https://example.com/test2.mp3" + ] + + embed, view = await play_commands_cog._create_search_embed(candidates) + + assert embed.title == "Wyniki wyszukiwania" + assert len(embed.fields) == 2 + + +@pytest.mark.asyncio +async def test_play_command_no_file_or_search(play_commands_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await play_commands_cog.play.callback(play_commands_cog, ctx, melodia=None, file=None, compressed=False) + mock_respond.assert_called_once() + args = mock_respond.call_args + assert "Musisz podać" in str(args) + + +@pytest.mark.asyncio +async def test_play_command_both_file_and_search(play_commands_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_file = MagicMock() + mock_file.filename = "test.mp3" + + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await play_commands_cog.play.callback(play_commands_cog, ctx, melodia="test", file=mock_file, compressed=False) + mock_respond.assert_called_once() + args = mock_respond.call_args + assert "nie oba" in str(args) or "LUB" in str(args) + + +def test_loop_cog_is_looping_initial(loop_cog): + assert loop_cog.is_looping(123) == False + + +def test_loop_cog_set_looping(loop_cog): + loop_cog.set_looping(123, True) + assert loop_cog.is_looping(123) == True + + +@pytest.mark.asyncio +async def test_loop_command_toggle_on(loop_cog, mock_channel, mock_guild, bot, mock_user): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + ctx.user = mock_user + ctx.author = mock_user + ctx.guild.id = 123 + + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await loop_cog.loop.callback(loop_cog, ctx) + mock_respond.assert_called_once() + args = mock_respond.call_args + assert "włączony" in str(args) + + +@pytest.mark.asyncio +async def test_loop_command_toggle_off(loop_cog, mock_channel, mock_guild, bot, mock_user): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + ctx.user = mock_user + ctx.author = mock_user + ctx.guild.id = 456 + + loop_cog.set_looping(456, True) + + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await loop_cog.loop.callback(loop_cog, ctx) + mock_respond.assert_called_once() + args = mock_respond.call_args + assert "wyłączony" in str(args) \ No newline at end of file diff --git a/tests/cogs/test_plot.py b/tests/cogs/test_plot.py new file mode 100644 index 0000000..e38a2c7 --- /dev/null +++ b/tests/cogs/test_plot.py @@ -0,0 +1,85 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +import os +from tests.conftest import MockApplicationContext + + +def test_parse_expression(): + from cogs.plot import parse_expression + + assert parse_expression("x^2") == "x**2" + assert parse_expression("x!") == "factorial(x)" + assert parse_expression("2x") == "2*x" + assert parse_expression("x2") == "x*2" + assert parse_expression("sin(x)") == "sin(x)" + + +def test_is_implicit(): + from cogs.plot import is_implicit + + assert is_implicit("x^2 + y^2 = 1") == True + assert is_implicit("x^2 = y") == True + assert is_implicit("x^2 + 1") == False + + +def test_parse_implicit(): + from cogs.plot import parse_implicit + + lhs, rhs = parse_implicit("x^2 + y = 2") + assert "x**2" in lhs + assert "+ y" in lhs or "+y" in lhs + assert rhs == "2" + + +@pytest.fixture +def plot_cog(bot): + from cogs.plot import Plot + cog = Plot(bot) + return cog + + +@pytest.mark.asyncio +async def test_plot_command_not_available(plot_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + with patch('cogs.plot._ensure_plot', return_value=False): + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await plot_cog.plot.callback(plot_cog, ctx, funkcja="x^2") + mock_respond.assert_called_once() + args = mock_respond.call_args + assert "matplotlib" in str(args).lower() or "nie jest dostępne" in str(args) + + +@pytest.mark.asyncio +async def test_plot_command_success(plot_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + with patch('cogs.plot._ensure_plot', return_value=True): + with patch('cogs.plot._plt_module') as mock_plt: + with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): + with patch('os.path.exists', return_value=True): + with patch('os.unlink'): + with patch.object(ctx, 'followup', send=AsyncMock()) as mock_followup: + mock_fig = MagicMock() + mock_plt.subplots.return_value = (mock_fig, MagicMock()) + + await plot_cog.plot.callback(plot_cog, ctx, funkcja="x^2") + mock_followup.send.assert_called_once() + + +@pytest.mark.asyncio +async def test_plot_command_error_handling(plot_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + with patch('cogs.plot._ensure_plot', return_value=True): + with patch('cogs.plot._plt_module') as mock_plt: + with patch('cogs.utils.temp.get_temp_dir', return_value="/tmp"): + with patch('os.path.exists', return_value=True): + with patch('os.unlink'): + with patch.object(ctx, 'followup', send=AsyncMock()) as mock_followup: + mock_plt.subplots.side_effect = ValueError("Test error") + + await plot_cog.plot.callback(plot_cog, ctx, funkcja="invalid!!") + mock_followup.send.assert_called_once() + args = mock_followup.send.call_args + assert "Nie można" in str(args) or "Błąd" in str(args) \ No newline at end of file diff --git a/tests/cogs/test_qr.py b/tests/cogs/test_qr.py new file mode 100644 index 0000000..f3688e3 --- /dev/null +++ b/tests/cogs/test_qr.py @@ -0,0 +1,75 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock, mock_open +import os +from cogs.qr import QR +from tests.conftest import MockApplicationContext + + +@pytest.fixture +def qr_cog(bot): + cog = QR(bot) + return cog + + +@pytest.fixture +def mock_temp_file(): + with patch('tempfile.NamedTemporaryFile') as mock: + mock_file = MagicMock() + mock_file.name = "/tmp/test_qr.png" + mock.return_value = mock_file + yield mock_file + + +@pytest.mark.asyncio +async def test_qr_generate_success(qr_cog, mock_channel, mock_guild, bot, mock_temp_file): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + with patch('cogs.qr.QR._generate_qr') as mock_generate: + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + with patch('os.path.exists', return_value=True): + with patch('os.unlink'): + await qr_cog.qr.callback(qr_cog, ctx, text="https://example.com") + mock_generate.assert_called_once() + mock_respond.assert_called_once() + + +@pytest.mark.asyncio +async def test_qr_generate_empty_text(qr_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + with patch('cogs.qr.QR._generate_qr'): + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + with patch('os.path.exists', return_value=False): + await qr_cog.qr.callback(qr_cog, ctx, text="") + mock_respond.assert_called_once() + + +@pytest.mark.asyncio +async def test_qr_decode_not_available(qr_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_attachment = MagicMock() + mock_attachment.url = "https://example.com/qr.png" + + with patch('cogs.qr._ensure_qr', return_value=False): + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await qr_cog.decode.callback(qr_cog, ctx, image=mock_attachment) + mock_respond.assert_called_once() + args = mock_respond.call_args + assert "nie jest dostępne" in str(args) or "not available" in str(args).lower() + + +@pytest.mark.asyncio +async def test_qr_decode_download_failure(qr_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_attachment = MagicMock() + mock_attachment.url = "https://example.com/qr.png" + + with patch('cogs.qr._ensure_qr', return_value=True): + with patch.object(qr_cog, '_download_image', return_value=None): + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await qr_cog.decode.callback(qr_cog, ctx, image=mock_attachment) + mock_respond.assert_called_once() + args = mock_respond.call_args + assert "Nie udało się" in str(args) or "failed" in str(args).lower() \ No newline at end of file diff --git a/tests/cogs/test_say.py b/tests/cogs/test_say.py new file mode 100644 index 0000000..12f2d2a --- /dev/null +++ b/tests/cogs/test_say.py @@ -0,0 +1,38 @@ +import pytest +from unittest.mock import AsyncMock, patch +from cogs.say import Say +from tests.conftest import MockApplicationContext + + +@pytest.fixture +def say_cog(bot): + cog = Say(bot) + return cog + + +@pytest.mark.asyncio +async def test_say_command(say_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + with patch.object(ctx, 'send', new_callable=AsyncMock) as mock_send: + await say_cog.say.callback(say_cog, ctx, "Hello World") + mock_send.assert_called_once_with(content="Hello World") + + +@pytest.mark.asyncio +async def test_say_command_empty_text(say_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + with patch.object(ctx, 'send', new_callable=AsyncMock) as mock_send: + await say_cog.say.callback(say_cog, ctx, "") + mock_send.assert_called_once_with(content="") + + +@pytest.mark.asyncio +async def test_say_command_long_text(say_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + long_text = "A" * 2000 + + with patch.object(ctx, 'send', new_callable=AsyncMock) as mock_send: + await say_cog.say.callback(say_cog, ctx, long_text) + mock_send.assert_called_once_with(content=long_text) \ No newline at end of file diff --git a/tests/cogs/test_search.py b/tests/cogs/test_search.py new file mode 100644 index 0000000..4fde494 --- /dev/null +++ b/tests/cogs/test_search.py @@ -0,0 +1,83 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +import discord +from tests.conftest import MockApplicationContext + + +@pytest.fixture +def search_cog(bot): + from cogs.search import Search + cog = Search(bot) + return cog + + +@pytest.mark.asyncio +async def test_search_command_success(search_cog, mock_channel, mock_guild, bot, mock_user): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + ctx.user = mock_user + ctx.author = mock_user + + search_cog._cached_results[mock_user.id] = { + "testfile.mp4": "https://example.com/files/testfile.mp4" + } + + with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup: + await search_cog.search.callback(search_cog, ctx, filename="testfile.mp4") + mock_followup.assert_called_once_with("https://example.com/files/testfile.mp4") + assert mock_user.id not in search_cog._cached_results + + +@pytest.mark.asyncio +async def test_search_command_no_cache(search_cog, mock_channel, mock_guild, bot, mock_user): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + ctx.user = mock_user + ctx.author = mock_user + + with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup: + await search_cog.search.callback(search_cog, ctx, filename="testfile.mp4") + mock_followup.assert_called_once_with("Search results expired. Please search again.") + + +@pytest.mark.asyncio +async def test_search_command_file_not_found(search_cog, mock_channel, mock_guild, bot, mock_user): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + ctx.user = mock_user + ctx.author = mock_user + + search_cog._cached_results[mock_user.id] = { + "testfile.mp4": "https://example.com/files/testfile.mp4" + } + + with patch.object(ctx, 'send_followup', new_callable=AsyncMock) as mock_followup: + await search_cog.search.callback(search_cog, ctx, filename="nonexistent.mp4") + mock_followup.assert_called_once_with("File 'nonexistent.mp4' not found.") + + +@pytest.mark.asyncio +async def test_search_command_empty_filename(search_cog, mock_channel, mock_guild, bot, mock_user): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + ctx.user = mock_user + ctx.author = mock_user + + with patch.object(ctx, 'reply', new_callable=AsyncMock) as mock_reply: + await search_cog.search.callback(search_cog, ctx, filename="") + mock_reply.assert_called_once_with("Please select a file from the autocomplete options.") + + +@pytest.mark.asyncio +async def test_file_autocomplete(search_cog, mock_channel, mock_guild, bot, mock_user): + mock_autocomplete_ctx = MagicMock() + mock_autocomplete_ctx.value = "test" + mock_autocomplete_ctx.interaction = MagicMock() + mock_autocomplete_ctx.interaction.user = mock_user + + with patch('cogs.utils.file_search.UniversalSearcher.search_copyparty_autocomplete', new_callable=AsyncMock) as mock_search: + mock_search.return_value = [ + "https://example.com/files/test1.mp4", + "https://example.com/files/test2.mp4" + ] + + choices = await search_cog.file_autocomplete(mock_autocomplete_ctx) + + assert len(choices) == 2 + assert mock_user.id in search_cog._cached_results \ No newline at end of file diff --git a/tests/cogs/test_trigger.py b/tests/cogs/test_trigger.py new file mode 100644 index 0000000..befc360 --- /dev/null +++ b/tests/cogs/test_trigger.py @@ -0,0 +1,106 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +import sqlite3 +from tests.conftest import MockApplicationContext + + +@pytest.fixture +def trigger_cog(bot): + with patch('cogs.trigger.get_connection') as mock_conn: + mock_conn.return_value.__enter__ = MagicMock(return_value=MagicMock()) + mock_conn.return_value.__exit__ = MagicMock(return_value=False) + from cogs.trigger import Trigger + cog = Trigger(bot) + return cog + + +@pytest.fixture +def mock_db_connection(): + with patch('cogs.trigger.get_connection') as mock_get_conn: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value = mock_cursor + mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) + mock_get_conn.return_value.__exit__ = MagicMock(return_value=False) + yield mock_conn, mock_cursor + + +@pytest.mark.asyncio +async def test_trigger_create_command(trigger_cog, mock_channel, mock_guild, bot, mock_db_connection): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_conn, mock_cursor = mock_db_connection + mock_cursor.fetchone.return_value = None + + with patch('cogs.trigger.get_connection') as mock_get_conn: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value = mock_cursor + mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) + mock_get_conn.return_value.__exit__ = MagicMock(return_value=False) + + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await trigger_cog.create.callback(trigger_cog, ctx, name="test_trigger") + mock_respond.assert_called_once_with("trigger added") + + +@pytest.mark.asyncio +async def test_trigger_add_command(trigger_cog, mock_channel, mock_guild, bot, mock_db_connection): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + with patch('cogs.trigger.get_connection') as mock_get_conn: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchone.return_value = (1,) # trigger exists + mock_conn.cursor.return_value = mock_cursor + mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) + mock_get_conn.return_value.__exit__ = MagicMock(return_value=False) + + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await trigger_cog.add.callback(trigger_cog, ctx, name="test_trigger", trigger="hello", response="Hi there!") + mock_respond.assert_called_once_with("trigger updated") + + +@pytest.mark.asyncio +async def test_trigger_add_command_not_found(trigger_cog, mock_channel, mock_guild, bot, mock_db_connection): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + with patch('cogs.trigger.get_connection') as mock_get_conn: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchone.return_value = None # trigger not found + mock_conn.cursor.return_value = mock_cursor + mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) + mock_get_conn.return_value.__exit__ = MagicMock(return_value=False) + + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await trigger_cog.add.callback(trigger_cog, ctx, name="nonexistent", trigger="hello", response="Hi") + mock_respond.assert_called_once_with("Trigger not found.") + + +@pytest.mark.asyncio +async def test_trigger_delete_command(trigger_cog, mock_channel, mock_guild, bot, mock_db_connection): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + with patch('cogs.trigger.get_connection') as mock_get_conn: + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchone.return_value = (1,) # trigger exists + mock_conn.cursor.return_value = mock_cursor + mock_get_conn.return_value.__enter__ = MagicMock(return_value=mock_conn) + mock_get_conn.return_value.__exit__ = MagicMock(return_value=False) + + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await trigger_cog.delete.callback(trigger_cog, ctx, name="test_trigger") + mock_respond.assert_called_once_with("trigger deleted") + + +@pytest.mark.asyncio +async def test_trigger_help_command(trigger_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await trigger_cog.help.callback(trigger_cog, ctx) + mock_respond.assert_called_once() + args = mock_respond.call_args + assert "Trigger create" in str(args) \ No newline at end of file diff --git a/tests/cogs/test_tts.py b/tests/cogs/test_tts.py new file mode 100644 index 0000000..8c95670 --- /dev/null +++ b/tests/cogs/test_tts.py @@ -0,0 +1,58 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +import discord +from tests.conftest import MockApplicationContext + + +@pytest.fixture +def tts_cog(bot): + from cogs.tts import TTS + cog = TTS(bot) + return cog + + +@pytest.mark.asyncio +async def test_tts_file_output(tts_cog, mock_channel, mock_guild, bot, mock_user): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + ctx.user = mock_user + ctx.author = mock_user + + mock_temp_path = "/tmp/test_tts.mp3" + + with patch('cogs.tts.gTTS') as mock_gtts: + with patch('os.path.exists', return_value=True): + with patch('os.unlink'): + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + with patch('cogs.tts.get_volume', return_value=1.0): + await tts_cog.tts.callback(tts_cog, ctx, text="Hello", output="file") + mock_respond.assert_called_once() + + +@pytest.mark.asyncio +async def test_tts_voice_output_not_in_channel(tts_cog, mock_channel, mock_guild, bot, mock_user): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + ctx.user = mock_user + ctx.author = mock_user + ctx.author.voice = None + + with patch('cogs.tts.gTTS'): + with patch('os.path.exists', return_value=True): + with patch('os.unlink'): + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + with patch('cogs.tts.get_volume', return_value=1.0): + await tts_cog.tts.callback(tts_cog, ctx, text="Hello", output="vc") + + +@pytest.mark.asyncio +async def test_tts_error_handling(tts_cog, mock_channel, mock_guild, bot, mock_user): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + ctx.user = mock_user + ctx.author = mock_user + + with patch('cogs.tts.gTTS', side_effect=Exception("TTS Error")): + with patch('os.path.exists', return_value=False): + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await tts_cog.tts.callback(tts_cog, ctx, text="Hello", output="file") + mock_respond.assert_called_once() + args = mock_respond.call_args + assert "Error" in str(args) or "eror" in str(args) \ No newline at end of file diff --git a/tests/cogs/test_vclogs.py b/tests/cogs/test_vclogs.py new file mode 100644 index 0000000..14bc6d5 --- /dev/null +++ b/tests/cogs/test_vclogs.py @@ -0,0 +1,83 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +import json +from tests.conftest import MockApplicationContext + + +def test_format_duration(): + from cogs.vclogs import format_duration + + assert format_duration(30) == "30s" + assert format_duration(90) == "1m 30s" + assert format_duration(3661) == "1h 1m 1s" + + +def test_load_activity_data(): + with patch('builtins.open', MagicMock()): + with patch('os.path.exists', return_value=True): + with patch('json.load', return_value={"123": {"active": True}}): + from cogs.vclogs import load_activity_data + data = load_activity_data() + assert isinstance(data, dict) + + +def test_save_activity_data(): + with patch('builtins.open', MagicMock()) as mock_open: + with patch('json.dump'): + from cogs.vclogs import save_activity_data + save_activity_data({"123": {"active": True}}) + mock_open.assert_called_once() + + +@pytest.fixture +def vclogs_cog(bot): + with patch('cogs.vclogs.get_voice_manager'): + with patch('cogs.vclogs.get_volume', return_value=1.0): + with patch('cogs.vclogs.save_settings'): + from cogs.vclogs import VcLogs + cog = VcLogs(bot) + return cog + + +@pytest.mark.asyncio +async def test_handle_state_change_deaf_true(vclogs_cog): + mock_member = MagicMock() + mock_member.display_name = "TestUser" + mock_channel = MagicMock() + + tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'deaf', True, None) + + assert "jest bity przez ojca" in tts_text + + +@pytest.mark.asyncio +async def test_handle_state_change_deaf_false(vclogs_cog): + mock_member = MagicMock() + mock_member.display_name = "TestUser" + mock_channel = MagicMock() + + tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'deaf', False, None) + + assert "uciek od ojca" in tts_text + + +@pytest.mark.asyncio +async def test_handle_state_change_mute_true(vclogs_cog): + mock_member = MagicMock() + mock_member.display_name = "TestUser" + mock_channel = MagicMock() + + tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'mute', True, None) + + assert "idzie robić jajecznice" in tts_text + + +@pytest.mark.asyncio +async def test_handle_state_change_mute_false(vclogs_cog): + mock_member = MagicMock() + mock_member.display_name = "TestUser" + mock_channel = MagicMock() + + tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'mute', False, None) + + assert "zrobił jajecznice" in tts_text \ No newline at end of file diff --git a/tests/cogs/test_voice.py b/tests/cogs/test_voice.py new file mode 100644 index 0000000..f5784f6 --- /dev/null +++ b/tests/cogs/test_voice.py @@ -0,0 +1,79 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +import asyncio +from tests.conftest import MockApplicationContext + + +@pytest.mark.asyncio +async def test_get_voice_manager(): + from cogs.voice import get_voice_manager, VoiceManager + + mock_bot = MagicMock() + mock_bot.user = MagicMock() + mock_bot.user.id = 12345 + + manager1 = get_voice_manager(mock_bot) + manager2 = get_voice_manager(mock_bot) + + assert isinstance(manager1, VoiceManager) + assert manager1 is manager2 + + +@pytest.mark.asyncio +async def test_voice_manager_join_channel_not_found(): + from cogs.voice import VoiceManager + + mock_bot = MagicMock() + mock_bot.get_channel.return_value = None + + manager = VoiceManager(mock_bot) + result = await manager.join_by_channel_id(999) + + assert result is None + + +@pytest.mark.asyncio +async def test_voice_manager_join_invalid_channel(): + from cogs.voice import VoiceManager + + mock_bot = MagicMock() + mock_channel = MagicMock() + mock_bot.get_channel.return_value = mock_channel + + manager = VoiceManager(mock_bot) + result = await manager.join_by_channel_id(999) + + assert result is None + + +@pytest.mark.asyncio +async def test_voice_manager_get_voice_status_not_connected(): + from cogs.voice import VoiceManager + + mock_bot = MagicMock() + mock_guild = MagicMock() + + with patch('cogs.voice.VoiceManager.get_voice_client', return_value=None): + manager = VoiceManager(mock_bot) + voice_client, is_connected, is_playing = await manager.get_voice_status(mock_guild) + + assert voice_client is None + assert is_connected == False + assert is_playing == False + + +@pytest.mark.asyncio +async def test_voice_manager_stop(): + from cogs.voice import VoiceManager + + mock_bot = MagicMock() + mock_guild = MagicMock() + mock_voice_client = MagicMock() + mock_voice_client.disconnect = AsyncMock() + + with patch('cogs.voice.VoiceManager.get_voice_client', return_value=mock_voice_client): + manager = VoiceManager(mock_bot) + await manager.stop(mock_guild) + + mock_voice_client.stop.assert_called_once() + mock_voice_client.disconnect.assert_called_once() \ No newline at end of file diff --git a/tests/cogs/test_web.py b/tests/cogs/test_web.py new file mode 100644 index 0000000..53a44e5 --- /dev/null +++ b/tests/cogs/test_web.py @@ -0,0 +1,57 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from cogs.web import Web +from tests.conftest import MockApplicationContext + + +@pytest.fixture +def web_cog(bot): + cog = Web(bot) + return cog + + +@pytest.mark.asyncio +async def test_web_command_success(web_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = ''' + + Test Result + Test Result 2 + + ''' + + with patch('cogs.web.requests.get', return_value=mock_response) as mock_get: + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await web_cog.web.callback(web_cog, ctx, query="test query") + mock_get.assert_called_once() + mock_respond.assert_called_once() + + +@pytest.mark.asyncio +async def test_web_command_no_results(web_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.text = '' + + with patch('cogs.web.requests.get', return_value=mock_response): + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await web_cog.web.callback(web_cog, ctx, query="test") + mock_respond.assert_called_once_with(content="No results found.", ephemeral=True) + + +@pytest.mark.asyncio +async def test_web_command_http_error(web_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_response = MagicMock() + mock_response.status_code = 500 + + with patch('cogs.web.requests.get', return_value=mock_response): + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await web_cog.web.callback(web_cog, ctx, query="test") + mock_respond.assert_called_once_with(content="Error: 500", ephemeral=True) \ No newline at end of file diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..c471b6b --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,198 @@ +import asyncio +from unittest.mock import AsyncMock, MagicMock +import pytest + +import discord +from discord.ext import commands + + +class MockUser: + def __init__(self, id=12345, name="testuser", discriminator="0001"): + self.id = id + self.name = name + self.discriminator = discriminator + self.mention = f"@{name}" + self.display_name = name + self.avatar = None + self.bot = False + self.guild = None + self._state = MagicMock() + + def __str__(self): + return f"{self.name}#{self.discriminator}" + + +class MockChannel: + def __init__(self, id=11111, name="test-channel", type=discord.ChannelType.text): + self.id = id + self.name = name + self.type = type + self.mention = f"#{name}" + self.guild = None + self.category = None + self._state = MagicMock() + + async def send(self, *args, **kwargs): + return MagicMock() + + +class MockTextChannel(MockChannel): + def __init__(self, id=11111, name="test-channel"): + super().__init__(id, name, discord.ChannelType.text) + + +class MockVoiceChannel(MockChannel): + def __init__(self, id=11112, name="test-voice"): + super().__init__(id, name, discord.ChannelType.voice) + self.voice_states = {} + + +class MockGuild: + def __init__(self, id=99999, name="Test Server"): + self.id = id + self.name = name + self.channels = [] + self.roles = [] + self.emojis = [] + self.me = MockUser(id=111111, name="Sen Bot") + self._state = MagicMock() + self._default_role = None + + def get_channel(self, channel_id): + for ch in self.channels: + if ch.id == channel_id: + return ch + return None + + def get_role(self, role_id): + for role in self.roles: + if role.id == role_id: + return role + return None + + def get_member(self, member_id): + return None + + +class MockMessage: + def __init__(self, id=22222, content="test message", author=None, channel=None, guild=None): + self.id = id + self.content = content + self.author = author or MockUser() + self.channel = channel or MockChannel() + self.guild = guild + self.attachments = [] + self.embeds = [] + self.jump_url = "https://discord.com/channels/..." + self.created_at = None + + async def reply(self, *args, **kwargs): + return MagicMock() + + +class MockInteraction: + def __init__(self, user=None, channel=None, guild=None): + self.user = user or MockUser() + self.channel = channel or MockChannel() + self.guild = guild or MockGuild() + self.message = None + self.response = AsyncMock() + self.followup = AsyncMock() + self.client = None + self.id = 33333 + self.token = "test_token" + self._state = MagicMock() + + async def defer(self, *args, **kwargs): + pass + + async def respond(self, *args, **kwargs): + pass + + async def delete_original_response(self, *args, **kwargs): + pass + + async def send(self, *args, **kwargs): + return MagicMock() + + +class MockApplicationContext: + def __init__(self, bot=None, interaction=None, user=None, channel=None, guild=None): + self.bot = bot + self.interaction = interaction or MockInteraction(user=user, channel=channel, guild=guild) + self.user = user or MockUser() + self.channel = channel or MockChannel() + self.guild = guild or MockGuild() + self.message = None + self.response = AsyncMock() + self.followup = AsyncMock() + self.client = self.bot + self.id = 33333 + self.token = "test_token" + self._state = self.interaction._state + self.author = self.user + + async def defer(self, *args, **kwargs): + pass + + async def respond(self, *args, **kwargs): + pass + + async def delete_original_response(self, *args, **kwargs): + pass + + async def send(self, *args, **kwargs): + return MagicMock() + + async def reply(self, *args, **kwargs): + return MagicMock() + + async def send_followup(self, *args, **kwargs): + return MagicMock() + + +@pytest.fixture +def mock_guild(): + return MockGuild() + + +@pytest.fixture +def mock_channel(mock_guild): + channel = MockTextChannel() + channel.guild = mock_guild + return channel + + +@pytest.fixture +def mock_voice_channel(mock_guild): + channel = MockVoiceChannel() + channel.guild = mock_guild + return channel + + +@pytest.fixture +def mock_user(): + return MockUser() + + +@pytest.fixture +def mock_member(mock_guild): + member = MockUser() + member.guild = mock_guild + return member + + +@pytest.fixture +def mock_message(mock_channel, mock_user, mock_guild): + return MockMessage(channel=mock_channel, author=mock_user, guild=mock_guild) + + +@pytest.fixture +def mock_interaction(mock_channel, mock_user, mock_guild): + return MockInteraction(user=mock_user, channel=mock_channel, guild=mock_guild) + + +@pytest.fixture +def bot(): + intents = discord.Intents.all() + return commands.Bot(intents=intents, command_prefix="!", loop=asyncio.new_event_loop()) \ No newline at end of file -- cgit v1.2.3