From 5675dfd20b24f1e0eef06b11d9ec41a894d3a5bf Mon Sep 17 00:00:00 2001 From: salmonik Date: Tue, 28 Apr 2026 20:05:36 +0200 Subject: multitude of changes. but primarly, use db for all data. Signed-off-by: salmonik --- tests/cogs/test_cron.py | 88 +++++++---------- tests/cogs/test_uploader.py | 226 ++++++++++++++++++++++++++++++++++++++++++++ tests/cogs/test_vclogs.py | 31 +++--- 3 files changed, 277 insertions(+), 68 deletions(-) create mode 100644 tests/cogs/test_uploader.py (limited to 'tests/cogs') diff --git a/tests/cogs/test_cron.py b/tests/cogs/test_cron.py index 93031f8..91fcb82 100644 --- a/tests/cogs/test_cron.py +++ b/tests/cogs/test_cron.py @@ -1,5 +1,3 @@ -import json -import os import pytest from datetime import datetime from unittest.mock import MagicMock, patch @@ -19,68 +17,48 @@ def mock_bot(): def create_cog(mock_bot, tmp_path): with patch.object(Cron, "__init__", lambda self, bot: None): - cog = Cron.__new__(Cron) - cog.bot = mock_bot - cog.scheduled_tasks = [] - cog.cron_file = str(tmp_path / "cron_jobs.json") - cog.task = MagicMock() - return cog + with patch("cogs.cron.init_database"): + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = [] + mock_conn.cursor.return_value = mock_cursor + mock_conn.close.return_value = None + + with patch("cogs.cron.get_connection", return_value=mock_conn): + with patch("cogs.utils.database.get_db_path", return_value=":memory:"): + cog = Cron.__new__(Cron) + cog.bot = mock_bot + cog.scheduled_tasks = [] + cog.task = MagicMock() + return cog class TestLoadTasks: - def test_load_tasks_file_not_exists(self, mock_bot, tmp_path): - cog = create_cog(mock_bot, tmp_path) - cog.cron_file = str(tmp_path / "nonexistent.json") - cog.load_tasks() - assert cog.scheduled_tasks == [] - - def test_load_tasks_empty_file(self, mock_bot, tmp_path): - cron_file = tmp_path / "empty.json" - cron_file.write_text("") - cog = create_cog(mock_bot, tmp_path) - cog.cron_file = str(cron_file) - cog.load_tasks() - assert cog.scheduled_tasks == [] - - def test_load_tasks_valid_file(self, mock_bot, tmp_path): - cron_file = tmp_path / "valid.json" - tasks = [{"id": 1, "cron_expression": "* * * * *", "type": "audio", "channel_id": 123, "active": True}] - cron_file.write_text(json.dumps(tasks)) - cog = create_cog(mock_bot, tmp_path) - cog.cron_file = str(cron_file) - cog.load_tasks() - assert len(cog.scheduled_tasks) == 1 - assert cog.scheduled_tasks[0]["id"] == 1 + def test_load_tasks_with_data(self, mock_bot, tmp_path): + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_cursor.fetchall.return_value = [ + (1, "* * * * *", "audio", 123, 1, None, "test message") + ] + mock_conn.cursor.return_value = mock_cursor + mock_conn.close.return_value = None - def test_load_tasks_io_error(self, mock_bot, tmp_path): - cog = create_cog(mock_bot, tmp_path) - with patch("builtins.open", side_effect=IOError("test error")): + with patch("cogs.cron.get_connection", return_value=mock_conn): + cog = Cron.__new__(Cron) + cog.bot = mock_bot + cog.scheduled_tasks = [] + cog.task = MagicMock() cog.load_tasks() - assert cog.scheduled_tasks == [] + assert len(cog.scheduled_tasks) == 1 + assert cog.scheduled_tasks[0]["id"] == 1 class TestSaveTasks: - def test_save_tasks_with_real_file(self, mock_bot, tmp_path): - cron_file = tmp_path / "cron_jobs.json" - cog = create_cog(mock_bot, tmp_path) - cog.cron_file = str(cron_file) - cog.scheduled_tasks = [{"id": 1, "cron_expression": "* * * * *"}] - cog.save_tasks() - assert os.path.exists(cron_file) - with open(cron_file) as f: - loaded = json.load(f) - assert len(loaded) == 1 - - def test_save_tasks_creates_directory_structure(self, mock_bot, tmp_path): + def test_save_tasks(self, mock_bot, tmp_path): cog = create_cog(mock_bot, tmp_path) - new_dir = tmp_path / "new_dir" - cron_file = new_dir / "cron_jobs.json" - cog.cron_file = str(cron_file) - cog.scheduled_tasks = [{"id": 1}] - + cog.scheduled_tasks = [{"id": 1, "cron_expression": "* * * * *", "type": "audio", "channel_id": 123, "active": True}] cog.save_tasks() - - assert os.path.exists(new_dir) + assert len(cog.scheduled_tasks) == 1 class TestShouldRunTask: @@ -163,4 +141,4 @@ class TestTaskTypes: def test_task_type_message_aliases(self, mock_bot, tmp_path): message_types = ("wyślij_wiadomość", "send_message") for task_type in message_types: - assert task_type in ("wyślij_wiadomość", "send_message") + assert task_type in ("wyślij_wiadomość", "send_message") \ No newline at end of file diff --git a/tests/cogs/test_uploader.py b/tests/cogs/test_uploader.py new file mode 100644 index 0000000..addf87e --- /dev/null +++ b/tests/cogs/test_uploader.py @@ -0,0 +1,226 @@ +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +import os +import discord +from tests.conftest import MockApplicationContext, MockMessage, MockUser + + +@pytest.fixture +def uploader_cog(bot): + with patch('cogs.utils.temp.get_temp_dir', return_value='/tmp/test'): + from cogs.uploader import Uploader + cog = Uploader(bot) + return cog + + +@pytest.mark.asyncio +async def test_file_autocomplete(uploader_cog): + mock_autocomplete_ctx = MagicMock() + mock_autocomplete_ctx.value = "test" + mock_interaction = MagicMock() + mock_user = MockUser(id=12345) + mock_interaction.user = mock_user + mock_autocomplete_ctx.interaction = mock_interaction + + with patch('cogs.utils.file_search.UniversalSearcher.search_copyparty_autocomplete', new_callable=AsyncMock) as mock_search: + mock_search.return_value = [ + "https://copyparty.local/testfile1.mp3", + "https://copyparty.local/testfile2.mp3" + ] + result = await uploader_cog._file_autocomplete(mock_autocomplete_ctx) + assert len(result) == 2 + assert result[0].name == "testfile1.mp3" + + +@pytest.mark.asyncio +async def test_file_autocomplete_empty_query(uploader_cog): + mock_autocomplete_ctx = MagicMock() + mock_autocomplete_ctx.value = "" + mock_interaction = MagicMock() + mock_interaction.user = MockUser(id=12345) + mock_autocomplete_ctx.interaction = mock_interaction + + with patch('cogs.utils.file_search.UniversalSearcher.search_copyparty_autocomplete', new_callable=AsyncMock) as mock_search: + result = await uploader_cog._file_autocomplete(mock_autocomplete_ctx) + mock_search.assert_called_once_with("", max_results=25) + + +@pytest.mark.asyncio +async def test_download_from_url_success(uploader_cog): + with patch('requests.get') as mock_get: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.content = b"test file content" + mock_get.return_value = mock_response + + mock_file = MagicMock() + mock_file.write = AsyncMock() + + mock_cm = AsyncMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_file) + mock_cm.__aexit__ = AsyncMock(return_value=None) + + with patch('cogs.uploader.aiofiles.open', return_value=mock_cm): + result = await uploader_cog._download_from_url("https://copyparty.local/file.txt") + + assert result is not None + assert "file.txt" in result + mock_file.write.assert_called_once_with(b"test file content") + + +@pytest.mark.asyncio +async def test_download_from_url_failure(uploader_cog): + with patch('requests.get') as mock_get: + mock_response = MagicMock() + mock_response.status_code = 404 + mock_get.return_value = mock_response + + result = await uploader_cog._download_from_url("https://copyparty.local/notfound.txt") + + assert result is None + + +@pytest.mark.asyncio +async def test_upload_to_copyparty_success(uploader_cog): + mock_file = MagicMock() + mock_file.read = AsyncMock(return_value=b"test content") + + mock_cm = AsyncMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_file) + mock_cm.__aexit__ = AsyncMock(return_value=None) + + with patch('cogs.uploader.aiofiles.open', return_value=mock_cm): + with patch('requests.post') as mock_post: + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = {"fileurl": "https://copyparty.local/uploaded.txt"} + mock_post.return_value = mock_response + + with patch.dict(os.environ, {"COPYPARTY_PASSWORD": "testpass"}): + result = await uploader_cog._upload_to_copyparty("/tmp/testfile.txt") + + assert result == "https://copyparty.local/uploaded.txt" + + +@pytest.mark.asyncio +async def test_upload_to_copyparty_failure(uploader_cog): + mock_file = MagicMock() + mock_file.read = AsyncMock(return_value=b"test content") + + mock_cm = AsyncMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_file) + mock_cm.__aexit__ = AsyncMock(return_value=None) + + with patch('cogs.uploader.aiofiles.open', return_value=mock_cm): + with patch('requests.post') as mock_post: + mock_response = MagicMock() + mock_response.status_code = 500 + mock_post.return_value = mock_response + + result = await uploader_cog._upload_to_copyparty("/tmp/testfile.txt") + + assert result is None + + +@pytest.mark.asyncio +async def test_upload_file_no_url(uploader_cog): + uploader_cog._uploadurl = None + + result = await uploader_cog._upload_file("/tmp/test.txt") + + assert result is None + + +@pytest.mark.asyncio +async def test_message_upload_no_attachments(uploader_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_message = MagicMock() + mock_message.attachments = [] + + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await uploader_cog.message_upload.callback(uploader_cog, ctx, message=mock_message) + mock_respond.assert_called_once_with("Brak załączników w wiadomości", ephemeral=True) + + +@pytest.mark.asyncio +async def test_message_upload_success(uploader_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_attachment = MagicMock() + mock_attachment.filename = "test.txt" + mock_attachment.save = AsyncMock() + + mock_message = MagicMock() + mock_message.attachments = [mock_attachment] + + with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload: + mock_upload.return_value = "https://copyparty.local/uploaded.txt" + + with patch('os.path.exists', return_value=True): + with patch('os.remove'): + with patch.object(ctx, 'defer', new_callable=AsyncMock): + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await uploader_cog.message_upload.callback(uploader_cog, ctx, message=mock_message) + mock_respond.assert_called_once() + assert "Wysłano" in str(mock_respond.call_args) + + +@pytest.mark.asyncio +async def test_message_upload_all_failed(uploader_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_attachment = MagicMock() + mock_attachment.filename = "test.txt" + mock_attachment.save = AsyncMock() + + mock_message = MagicMock() + mock_message.attachments = [mock_attachment] + + with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload: + mock_upload.return_value = None + + with patch('os.path.exists', return_value=True): + with patch('os.remove'): + with patch.object(ctx, 'defer', new_callable=AsyncMock): + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await uploader_cog.message_upload.callback(uploader_cog, ctx, message=mock_message) + mock_respond.assert_called_once_with("Nie udało się wysłać żadnych plików", ephemeral=True) + + +@pytest.mark.asyncio +async def test_upload_slash_command_success(uploader_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_attachment = MagicMock() + mock_attachment.filename = "test.txt" + mock_attachment.save = AsyncMock() + + with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload: + mock_upload.return_value = "https://copyparty.local/uploaded.txt" + + with patch('os.path.exists', return_value=True): + with patch('os.remove'): + with patch.object(ctx, 'defer', new_callable=AsyncMock): + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await uploader_cog.upload.callback(uploader_cog, ctx, zalacznik=mock_attachment) + mock_respond.assert_called_once_with("https://copyparty.local/uploaded.txt") + + +@pytest.mark.asyncio +async def test_upload_slash_command_failure(uploader_cog, mock_channel, mock_guild, bot): + ctx = MockApplicationContext(bot=bot, channel=mock_channel, guild=mock_guild) + + mock_attachment = MagicMock() + mock_attachment.filename = "test.txt" + mock_attachment.save = AsyncMock() + + with patch.object(uploader_cog, '_upload_file', new_callable=AsyncMock) as mock_upload: + mock_upload.return_value = None + + with patch('os.path.exists', return_value=True): + with patch('os.remove'): + with patch.object(ctx, 'defer', new_callable=AsyncMock): + with patch.object(ctx, 'respond', new_callable=AsyncMock) as mock_respond: + await uploader_cog.upload.callback(uploader_cog, ctx, zalacznik=mock_attachment) + mock_respond.assert_called_once_with("Nie udało się wysłać pliku", ephemeral=True) \ No newline at end of file diff --git a/tests/cogs/test_vclogs.py b/tests/cogs/test_vclogs.py index 2003478..da41482 100644 --- a/tests/cogs/test_vclogs.py +++ b/tests/cogs/test_vclogs.py @@ -1,6 +1,5 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock -import json from tests.conftest import MockApplicationContext @@ -13,20 +12,26 @@ def test_format_duration(): 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) + with patch("cogs.vclogs.get_connection") as mock_conn: + mock_cursor = MagicMock() + mock_conn.return_value.cursor.return_value = mock_cursor + mock_conn.return_value.close.return_value = None + mock_cursor.fetchall.return_value = [] + + 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() + with patch("cogs.vclogs.get_connection") as mock_conn: + mock_cursor = MagicMock() + mock_conn.return_value.cursor.return_value = mock_cursor + mock_conn.return_value.commit.return_value = None + mock_conn.return_value.close.return_value = None + + from cogs.vclogs import save_activity_data + save_activity_data({"123": {"active": True}}) @pytest.fixture @@ -80,4 +85,4 @@ async def test_handle_state_change_mute_false(vclogs_cog): tts_text, channel = await vclogs_cog.handle_state_change(mock_member, mock_channel, 'mute', False, None) - assert "zrobił jajecznice" in tts_text + assert "zrobił jajecznice" in tts_text \ No newline at end of file -- cgit v1.2.3 From dba3388567818cb5c60be9d406d3e1cccc84b3ea Mon Sep 17 00:00:00 2001 From: salmonik Date: Wed, 29 Apr 2026 14:22:03 +0200 Subject: Refactor database connections and add integration tests - Use context managers for sqlite3 connections in cron, vclogs, and database utils - Add URL helper utility for yt-dlp with clean_url_for_ytdlp function - Add slow integration test markers and new integration test files - Fix test imports to use conftest directly instead of tests.conftest - Improve downloader error handling and use COPYPARTY_URL_UPLOAD env var - Update pytest.ini with slow test markers and warning filters - Document slow test usage in AGENTS.md --- AGENTS.md | 5 ++ cogs/cron.py | 11 +--- cogs/downloader.py | 14 ++++- cogs/music/media_processing.py | 2 + cogs/music/play_commands.py | 2 + cogs/utils/database.py | 17 +++++- cogs/utils/url_helper.py | 21 ++++++++ cogs/vclogs.py | 55 ++++++++++---------- cogs/web.py | 2 + pytest.ini | 12 +++-- tests/cogs/test_ai.py | 2 +- tests/cogs/test_assistant.py | 2 +- tests/cogs/test_compression_conversion.py | 2 +- tests/cogs/test_cron.py | 85 +++++++++++++++--------------- tests/cogs/test_cron_integration.py | 39 ++++++++++++++ tests/cogs/test_downloader.py | 2 +- tests/cogs/test_downloader_integration.py | 37 +++++++++++++ tests/cogs/test_math_commands.py | 2 +- tests/cogs/test_music_audio.py | 2 +- tests/cogs/test_music_external_play.py | 2 +- tests/cogs/test_music_integration.py | 44 ++++++++++++++++ tests/cogs/test_music_join.py | 2 +- tests/cogs/test_music_play.py | 2 +- tests/cogs/test_qr.py | 2 +- tests/cogs/test_say.py | 2 +- tests/cogs/test_search.py | 2 +- tests/cogs/test_trigger.py | 2 +- tests/cogs/test_trigger_integration.py | 54 +++++++++++++++++++ tests/cogs/test_tts.py | 2 +- tests/cogs/test_uploader.py | 2 +- tests/cogs/test_vclogs.py | 2 +- tests/cogs/test_vclogs_integration.py | 86 +++++++++++++++++++++++++++++++ tests/cogs/test_voice.py | 2 +- tests/cogs/test_web.py | 2 +- tests/conftest.py | 3 ++ 35 files changed, 421 insertions(+), 104 deletions(-) create mode 100644 cogs/utils/url_helper.py create mode 100644 tests/cogs/test_cron_integration.py create mode 100644 tests/cogs/test_downloader_integration.py create mode 100644 tests/cogs/test_music_integration.py create mode 100644 tests/cogs/test_trigger_integration.py create mode 100644 tests/cogs/test_vclogs_integration.py (limited to 'tests/cogs') diff --git a/AGENTS.md b/AGENTS.md index 44be9d5..8d0b743 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,3 +26,8 @@ - Tests in `tests/cogs/test_*.py` - Uses pytest-asyncio with `asyncio_default_fixture_loop_scope = function` - `conftest.py` provides fixtures for mocking Discord objects +- **Slow integration tests** marked with `@pytest.mark.slow` + - Test real-world feature usage with longer execution times + - Includes: voice session tracking, cron job execution, music playback lifecycle, downloader flow, trigger responses + - Run only fast tests: `.venv/bin/pytest -m "not slow"` + - Run only slow tests: `.venv/bin/pytest -m slow` diff --git a/cogs/cron.py b/cogs/cron.py index b4f494a..aad43d2 100644 --- a/cogs/cron.py +++ b/cogs/cron.py @@ -30,13 +30,10 @@ class Cron(commands.Cog): def load_tasks(self): try: - conn = get_connection() - try: + with get_connection() as conn: c = conn.cursor() c.execute("SELECT id, cron_expression, type, channel_id, active, audio_filename, message FROM cron_jobs") rows = c.fetchall() - finally: - conn.close() self.scheduled_tasks = [ { @@ -57,8 +54,7 @@ class Cron(commands.Cog): def save_tasks(self): try: - conn = get_connection() - try: + with get_connection() as conn: c = conn.cursor() c.execute("PRAGMA foreign_keys = ON") @@ -78,10 +74,7 @@ class Cron(commands.Cog): task.get("message"), ), ) - conn.commit() - finally: - conn.close() except Exception as e: logger.error(f"Error saving cron jobs: {e}") diff --git a/cogs/downloader.py b/cogs/downloader.py index d829ed7..900bafb 100644 --- a/cogs/downloader.py +++ b/cogs/downloader.py @@ -12,6 +12,7 @@ import re import yt_dlp from discord.ext import commands import aiofiles +from cogs.utils.url_helper import clean_url_for_ytdlp logger = logging.getLogger(__name__) @@ -61,6 +62,10 @@ class Downloader(commands.Cog): file_data = response["content"] await ctx.respond(file=discord.File(file_data)) else: + error_msg = response["content"] + if 'details' in response: + error_msg += f"\n{response['details']}" + await ctx.respond(f"Błąd: {error_msg}", ephemeral=True) logger.error(f"Error: {response['content']}") if 'details' in response: logger.error(f"Details: {response['details']}") @@ -68,6 +73,7 @@ class Downloader(commands.Cog): @staticmethod async def download_standalone(url: str, catbox: bool = False, fformat: str = "mp4", filename_user: str | None = None): try: + url = clean_url_for_ytdlp(url) logger.info(f"Downloading {url} as {fformat}") with tempfile.TemporaryDirectory(delete=False) as temp_dir: @@ -157,12 +163,18 @@ class Downloader(commands.Cog): files = {'f': (filename, content, 'application/octet-stream')} headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'} params = {'j': '1', 'ck': 'b2'} - base_url = "https://catbox.2137697.xyz/sperma/" + base_url = os.getenv("COPYPARTY_URL_UPLOAD") response = requests.post(base_url, files=files, headers=headers, params=params) if response.status_code in [200, 201]: return Downloader._handle_upload_success(response, filename, temp_dir) + elif response.status_code == 413: + return Downloader._create_error_response( + "File too large for upload", + "Plik jest za duży, by wysłać go na serwer (limit to ok. 100MB)", + temp_dir + ) else: return Downloader._create_error_response( f"Server returned {response.status_code}", diff --git a/cogs/music/media_processing.py b/cogs/music/media_processing.py index b9b814c..91bd406 100644 --- a/cogs/music/media_processing.py +++ b/cogs/music/media_processing.py @@ -9,6 +9,7 @@ import os import yt_dlp from youtubesearchpython import VideosSearch +from cogs.utils.url_helper import clean_url_for_ytdlp logger = logging.getLogger(__name__) @@ -36,6 +37,7 @@ class MediaDownloader: } try: + url = clean_url_for_ytdlp(url) with yt_dlp.YoutubeDL(ytdl_opts) as ydl: info_dict = ydl.extract_info(url, download=True) # Get the base filename without extension diff --git a/cogs/music/play_commands.py b/cogs/music/play_commands.py index c53dbc7..bdb021d 100644 --- a/cogs/music/play_commands.py +++ b/cogs/music/play_commands.py @@ -19,6 +19,7 @@ from discord.ext import commands from cogs.music import audio_playback, media_processing from cogs.utils.file_search import UniversalSearcher from cogs.utils.settings import get_uploadurl +from cogs.utils.url_helper import clean_url_for_ytdlp logger = logging.getLogger(__name__) @@ -193,6 +194,7 @@ class PlayCommands(commands.Cog): } loop = asyncio.get_event_loop() + url = clean_url_for_ytdlp(url) with YoutubeDL(ytdl_opts) as ydl: info_dict = await loop.run_in_executor( None, lambda: ydl.extract_info(url, download=True) diff --git a/cogs/utils/database.py b/cogs/utils/database.py index 67cbc9f..ea7d356 100644 --- a/cogs/utils/database.py +++ b/cogs/utils/database.py @@ -1,6 +1,7 @@ import os import sqlite3 import logging +from contextlib import contextmanager logger = logging.getLogger(__name__) @@ -10,7 +11,8 @@ _initialized = False def get_connection(): - return sqlite3.connect(get_db_path()) + conn = sqlite3.connect(get_db_path()) + return _ConnectionContextManager(conn) def get_db_path(): @@ -22,6 +24,19 @@ def set_db_path(path): DB_PATH = path +class _ConnectionContextManager: + """Context manager for sqlite3 connections that auto-closes.""" + def __init__(self, conn): + self.conn = conn + + def __enter__(self): + return self.conn + + def __exit__(self, exc_type, exc_val, exc_tb): + self.conn.close() + return False + + def init_database(): global _initialized if _initialized: diff --git a/cogs/utils/url_helper.py b/cogs/utils/url_helper.py new file mode 100644 index 0000000..eac06f1 --- /dev/null +++ b/cogs/utils/url_helper.py @@ -0,0 +1,21 @@ +# This file is part of Senator Sperma +# Licensed under the GPL-3.0 License +# Copyright (C) 2026 Salmonik @ salmonik@tuta.io +# https://codeberg.org/salmonik/senator-sperma + + +def clean_url_for_ytdlp(url: str) -> str: + """Clean URL by removing everything after the first '&'. + + This prevents yt-dlp from interpreting additional query parameters + (like &list=...) as playlist URLs. + + Example: + Input: https://www.youtube.com/watch?v=lPGipwoJiOM&list=RDlPGipwoJiOM& + Output: https://www.youtube.com/watch?v=lPGipwoJiOM + """ + # Find the first '&' and truncate + first_ampersand = url.find('&') + if first_ampersand != -1: + return url[:first_ampersand] + return url diff --git a/cogs/vclogs.py b/cogs/vclogs.py index 404ffe0..e5ca202 100644 --- a/cogs/vclogs.py +++ b/cogs/vclogs.py @@ -23,11 +23,10 @@ init_database() def load_activity_data(): try: - conn = get_connection() - c = conn.cursor() - c.execute("SELECT user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name FROM voice_activity") - rows = c.fetchall() - conn.close() + with get_connection() as conn: + c = conn.cursor() + c.execute("SELECT user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name FROM voice_activity") + rows = c.fetchall() return { row[0]: { @@ -48,30 +47,28 @@ def load_activity_data(): def save_activity_data(data): try: - conn = get_connection() - c = conn.cursor() - c.execute("PRAGMA foreign_keys = ON") - - c.execute("DELETE FROM voice_activity") - - for user_id, user_data in data.items(): - c.execute( - """INSERT INTO voice_activity (user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", - ( - user_id, - user_data.get("join_time"), - user_data.get("total_active", 0), - user_data.get("total_afk", 0), - int(user_data.get("is_afk", False)), - user_data.get("afk_start"), - user_data.get("active_start"), - user_data.get("channel_name"), - ), - ) - - conn.commit() - conn.close() + with get_connection() as conn: + c = conn.cursor() + c.execute("PRAGMA foreign_keys = ON") + + c.execute("DELETE FROM voice_activity") + + for user_id, user_data in data.items(): + c.execute( + """INSERT INTO voice_activity (user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + ( + user_id, + user_data.get("join_time"), + user_data.get("total_active", 0), + user_data.get("total_afk", 0), + int(user_data.get("is_afk", False)), + user_data.get("afk_start"), + user_data.get("active_start"), + user_data.get("channel_name"), + ), + ) + conn.commit() logger.info(f"Saved voice activity for {len(data)} users") except Exception as e: logger.error(f"Error saving voice activity: {e}") diff --git a/cogs/web.py b/cogs/web.py index eea13b1..fd22f9f 100644 --- a/cogs/web.py +++ b/cogs/web.py @@ -11,6 +11,7 @@ from bs4 import BeautifulSoup import urllib.parse import yt_dlp import logging +from cogs.utils.url_helper import clean_url_for_ytdlp logger = logging.getLogger(__name__) @@ -24,6 +25,7 @@ class Web(commands.Cog): if "youtube.com/watch" not in url: return None + url = clean_url_for_ytdlp(url) logger.info("youtube link") try: ydl_opts = {"quiet": True, "nowarnings": True} diff --git a/pytest.ini b/pytest.ini index 03a070f..23e8e9c 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,7 +1,9 @@ [pytest] +markers = + slow: marks tests as slow (deselect with '-m "not slow"') +asyncio_mode = strict 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 +filterwarnings = + ignore::RuntimeWarning + ignore::pytest.PytestUnraisableExceptionWarning + ignore::DeprecationWarning:discord.client \ No newline at end of file diff --git a/tests/cogs/test_ai.py b/tests/cogs/test_ai.py index a1c3fd4..6d92f55 100644 --- a/tests/cogs/test_ai.py +++ b/tests/cogs/test_ai.py @@ -2,7 +2,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock import discord import httpx -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/cogs/test_assistant.py b/tests/cogs/test_assistant.py index 9a33232..056a868 100644 --- a/tests/cogs/test_assistant.py +++ b/tests/cogs/test_assistant.py @@ -1,6 +1,6 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/cogs/test_compression_conversion.py b/tests/cogs/test_compression_conversion.py index f9d3427..892482b 100644 --- a/tests/cogs/test_compression_conversion.py +++ b/tests/cogs/test_compression_conversion.py @@ -2,7 +2,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock import os import discord -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext def test_compression_filetype_video(): diff --git a/tests/cogs/test_cron.py b/tests/cogs/test_cron.py index 91fcb82..28ccfe1 100644 --- a/tests/cogs/test_cron.py +++ b/tests/cogs/test_cron.py @@ -3,6 +3,7 @@ from datetime import datetime from unittest.mock import MagicMock, patch from cogs.cron import Cron +from cogs.utils.database import _ConnectionContextManager @pytest.fixture @@ -15,35 +16,31 @@ def mock_bot(): return bot -def create_cog(mock_bot, tmp_path): +def create_cog(mock_bot): with patch.object(Cron, "__init__", lambda self, bot: None): with patch("cogs.cron.init_database"): - mock_conn = MagicMock() - mock_cursor = MagicMock() - mock_cursor.fetchall.return_value = [] - mock_conn.cursor.return_value = mock_cursor - mock_conn.close.return_value = None - - with patch("cogs.cron.get_connection", return_value=mock_conn): - with patch("cogs.utils.database.get_db_path", return_value=":memory:"): - cog = Cron.__new__(Cron) - cog.bot = mock_bot - cog.scheduled_tasks = [] - cog.task = MagicMock() - return cog + cog = Cron.__new__(Cron) + cog.bot = mock_bot + cog.scheduled_tasks = [] + cog.task = MagicMock() + return cog class TestLoadTasks: - def test_load_tasks_with_data(self, mock_bot, tmp_path): + def test_load_tasks_with_data(self, mock_bot): mock_conn = MagicMock() mock_cursor = MagicMock() mock_cursor.fetchall.return_value = [ (1, "* * * * *", "audio", 123, 1, None, "test message") ] mock_conn.cursor.return_value = mock_cursor - mock_conn.close.return_value = None - with patch("cogs.cron.get_connection", return_value=mock_conn): + # Create a context manager mock + mock_ctx = MagicMock() + mock_ctx.__enter__ = MagicMock(return_value=mock_conn) + mock_ctx.__exit__ = MagicMock(return_value=False) + + with patch("cogs.cron.get_connection", return_value=mock_ctx): cog = Cron.__new__(Cron) cog.bot = mock_bot cog.scheduled_tasks = [] @@ -52,33 +49,41 @@ class TestLoadTasks: assert len(cog.scheduled_tasks) == 1 assert cog.scheduled_tasks[0]["id"] == 1 - class TestSaveTasks: - def test_save_tasks(self, mock_bot, tmp_path): - cog = create_cog(mock_bot, tmp_path) + def test_save_tasks(self, mock_bot): + cog = create_cog(mock_bot) cog.scheduled_tasks = [{"id": 1, "cron_expression": "* * * * *", "type": "audio", "channel_id": 123, "active": True}] - cog.save_tasks() - assert len(cog.scheduled_tasks) == 1 + mock_conn = MagicMock() + mock_cursor = MagicMock() + mock_conn.cursor.return_value = mock_cursor + + # Create a context manager mock + mock_ctx = MagicMock() + mock_ctx.__enter__ = MagicMock(return_value=mock_conn) + mock_ctx.__exit__ = MagicMock(return_value=False) + + with patch("cogs.cron.get_connection", return_value=mock_ctx): + cog.save_tasks() + assert len(cog.scheduled_tasks) == 1 class TestShouldRunTask: - def test_should_run_task_invalid_cron(self, mock_bot, tmp_path): - cog = create_cog(mock_bot, tmp_path) + def test_should_run_task_invalid_cron(self, mock_bot): + cog = create_cog(mock_bot) task = {"cron_expression": "invalid"} now = datetime.now() result = cog.should_run_task(task, now) assert result is False - def test_should_run_task_missing_cron_expression(self, mock_bot, tmp_path): - cog = create_cog(mock_bot, tmp_path) + def test_should_run_task_missing_cron_expression(self, mock_bot): + cog = create_cog(mock_bot) task = {} now = datetime.now() result = cog.should_run_task(task, now) assert result is False - class TestCogUnload: - def test_cog_unload_cancels_task(self, mock_bot, tmp_path): + def test_cog_unload_cancels_task(self, mock_bot): with patch.object(Cron, "__init__", lambda self, bot: None): cog = Cron.__new__(Cron) cog.bot = mock_bot @@ -87,10 +92,9 @@ class TestCogUnload: cog.cog_unload() cog.task.cancel.assert_called_once() - class TestTaskManagement: - def test_add_task_increments_id(self, mock_bot, tmp_path): - cog = create_cog(mock_bot, tmp_path) + def test_add_task_increments_id(self, mock_bot): + cog = create_cog(mock_bot) cog.scheduled_tasks = [ {"id": 1, "cron_expression": "* * * * *"}, {"id": 2, "cron_expression": "0 * * * *"}, @@ -99,8 +103,8 @@ class TestTaskManagement: new_id = max([t["id"] for t in cog.scheduled_tasks], default=0) + 1 assert new_id == 3 - def test_find_task_by_id(self, mock_bot, tmp_path): - cog = create_cog(mock_bot, tmp_path) + def test_find_task_by_id(self, mock_bot): + cog = create_cog(mock_bot) cog.scheduled_tasks = [ {"id": 1, "cron_expression": "* * * * *"}, {"id": 2, "cron_expression": "0 * * * *"}, @@ -110,8 +114,8 @@ class TestTaskManagement: assert found_task is not None assert found_task["cron_expression"] == "0 * * * *" - def test_remove_task_by_index(self, mock_bot, tmp_path): - cog = create_cog(mock_bot, tmp_path) + def test_remove_task_by_index(self, mock_bot): + cog = create_cog(mock_bot) cog.scheduled_tasks = [ {"id": 1, "cron_expression": "* * * * *"}, {"id": 2, "cron_expression": "0 * * * *"}, @@ -121,8 +125,8 @@ class TestTaskManagement: assert len(cog.scheduled_tasks) == 1 assert cog.scheduled_tasks[0]["id"] == 2 - def test_toggle_task_active(self, mock_bot, tmp_path): - cog = create_cog(mock_bot, tmp_path) + def test_toggle_task_active(self, mock_bot): + cog = create_cog(mock_bot) cog.scheduled_tasks = [{"id": 1, "active": True}] cog.scheduled_tasks[0]["active"] = False @@ -131,14 +135,13 @@ class TestTaskManagement: cog.scheduled_tasks[0]["active"] = True assert cog.scheduled_tasks[0]["active"] is True - class TestTaskTypes: - def test_task_type_audio_aliases(self, mock_bot, tmp_path): + def test_task_type_audio_aliases(self, mock_bot): audio_types = ("zagraj_audio", "audio") for task_type in audio_types: assert task_type in ("zagraj_audio", "audio") - def test_task_type_message_aliases(self, mock_bot, tmp_path): + def test_task_type_message_aliases(self, mock_bot): message_types = ("wyślij_wiadomość", "send_message") for task_type in message_types: - assert task_type in ("wyślij_wiadomość", "send_message") \ No newline at end of file + assert task_type in ("wyślij_wiadomość", "send_message") diff --git a/tests/cogs/test_cron_integration.py b/tests/cogs/test_cron_integration.py new file mode 100644 index 0000000..f56fc46 --- /dev/null +++ b/tests/cogs/test_cron_integration.py @@ -0,0 +1,39 @@ +import pytest +import time +from unittest.mock import MagicMock, AsyncMock, patch + + +@pytest.fixture +def cron_cog(bot): + # Patch create_task to prevent background task creation + with patch.object(bot.loop, 'create_task', return_value=MagicMock()): + from cogs.cron import Cron + cog = Cron(bot) + yield cog + + +@pytest.mark.slow +@pytest.mark.asyncio +async def test_cron_job_execution(cron_cog): + """Test cron job executes when due.""" + cog = cron_cog + + job = { + "id": "test_job", + "cron_expression": "* * * * *", + "type": "message", + "channel_id": 456, + "active": True, + "message": "Test", + } + cog.scheduled_tasks.append(job) + + # Mock the execute_task method + with patch.object(cog, 'execute_task', new_callable=AsyncMock) as mock_exec: + # Mock should_run_task to return True + with patch.object(cog, 'should_run_task', return_value=True): + # Test single iteration of schedule_checker logic + for task in cog.scheduled_tasks: + if task.get("active", True) and cog.should_run_task(task, None): + await cog.execute_task(task) + mock_exec.assert_called_once_with(job) \ No newline at end of file diff --git a/tests/cogs/test_downloader.py b/tests/cogs/test_downloader.py index 6ac8ae7..125a103 100644 --- a/tests/cogs/test_downloader.py +++ b/tests/cogs/test_downloader.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock import discord -from tests.conftest import MockApplicationContext, MockMessage +from conftest import MockApplicationContext, MockMessage @pytest.fixture diff --git a/tests/cogs/test_downloader_integration.py b/tests/cogs/test_downloader_integration.py new file mode 100644 index 0000000..c7c9790 --- /dev/null +++ b/tests/cogs/test_downloader_integration.py @@ -0,0 +1,37 @@ +import pytest +from unittest.mock import MagicMock, AsyncMock, patch + + +@pytest.fixture +def downloader_cog(bot): + from cogs.downloader import Downloader + cog = Downloader(bot) + return cog + + +@pytest.mark.slow +@pytest.mark.asyncio +async def test_download_command_flow(downloader_cog, bot): + """Test download command with URL processing.""" + cog = downloader_cog + + ctx = MagicMock() + ctx.author = MagicMock() + ctx.guild = MagicMock() + ctx.channel = MagicMock() + ctx.respond = AsyncMock() + ctx.defer = AsyncMock() + ctx.followup = AsyncMock() + ctx.voice_client = None + + # Mock the download_standalone static method + mock_response = { + "status": "success", + "type": "url", + "content": "http://example.com/video.mp4" + } + + with patch.object(cog, 'download_standalone', new_callable=AsyncMock, return_value=mock_response): + await cog.download.callback(cog, ctx, url="http://youtube.com/test") + + ctx.respond.assert_called_once() diff --git a/tests/cogs/test_math_commands.py b/tests/cogs/test_math_commands.py index 5c78027..21e9a2e 100644 --- a/tests/cogs/test_math_commands.py +++ b/tests/cogs/test_math_commands.py @@ -1,6 +1,6 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/cogs/test_music_audio.py b/tests/cogs/test_music_audio.py index 7ea902e..705f1b3 100644 --- a/tests/cogs/test_music_audio.py +++ b/tests/cogs/test_music_audio.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock import os -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.mark.asyncio diff --git a/tests/cogs/test_music_external_play.py b/tests/cogs/test_music_external_play.py index fd4b625..2e9c5b1 100644 --- a/tests/cogs/test_music_external_play.py +++ b/tests/cogs/test_music_external_play.py @@ -3,7 +3,7 @@ import os import asyncio import tempfile from unittest.mock import AsyncMock, patch, MagicMock -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/cogs/test_music_integration.py b/tests/cogs/test_music_integration.py new file mode 100644 index 0000000..1c0260a --- /dev/null +++ b/tests/cogs/test_music_integration.py @@ -0,0 +1,44 @@ +import pytest +from unittest.mock import MagicMock, AsyncMock, patch, PropertyMock +import sys +sys.path.insert(0, '/home/salmon/Documents/botcode/senator-sperma/tests') +from conftest import MockApplicationContext + + +@pytest.fixture +def music_cog(bot): + from cogs.music.play_commands import PlayCommands + cog = PlayCommands(bot) + return cog + + +@pytest.mark.slow +@pytest.mark.asyncio +async def test_music_join_stop(music_cog, bot): + """Test music join and stop commands.""" + from cogs.music.join import Join + from cogs.music.stop import Stop + + join_cog = Join(bot) + stop_cog = Stop(bot) + + ctx = MockApplicationContext(bot=bot) + ctx.author.voice = MagicMock() + ctx.author.voice.channel = MagicMock() + ctx.guild = MagicMock() + + voice_client = AsyncMock() + ctx.voice_client = voice_client + + # Test join + ctx.author.voice.channel.connect = AsyncMock(return_value=voice_client) + 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) + ctx.author.voice.channel.connect.assert_called_once() + + # Test stop + 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) + voice_client.disconnect.assert_called_once() diff --git a/tests/cogs/test_music_join.py b/tests/cogs/test_music_join.py index 3c7ca6e..0176759 100644 --- a/tests/cogs/test_music_join.py +++ b/tests/cogs/test_music_join.py @@ -1,6 +1,6 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/cogs/test_music_play.py b/tests/cogs/test_music_play.py index e720c9a..7684729 100644 --- a/tests/cogs/test_music_play.py +++ b/tests/cogs/test_music_play.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock import os -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/cogs/test_qr.py b/tests/cogs/test_qr.py index 2a132ca..b5cb31f 100644 --- a/tests/cogs/test_qr.py +++ b/tests/cogs/test_qr.py @@ -2,7 +2,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock, mock_open import os from cogs.qr import QR -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/cogs/test_say.py b/tests/cogs/test_say.py index 13cad40..76f1b94 100644 --- a/tests/cogs/test_say.py +++ b/tests/cogs/test_say.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import AsyncMock, patch from cogs.say import Say -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/cogs/test_search.py b/tests/cogs/test_search.py index 8c04dfc..c9c7d0e 100644 --- a/tests/cogs/test_search.py +++ b/tests/cogs/test_search.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock import discord -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/cogs/test_trigger.py b/tests/cogs/test_trigger.py index 6aaaa11..a60a139 100644 --- a/tests/cogs/test_trigger.py +++ b/tests/cogs/test_trigger.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock import sqlite3 -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/cogs/test_trigger_integration.py b/tests/cogs/test_trigger_integration.py new file mode 100644 index 0000000..4828387 --- /dev/null +++ b/tests/cogs/test_trigger_integration.py @@ -0,0 +1,54 @@ +import pytest +from unittest.mock import MagicMock, AsyncMock, patch +from cogs.trigger import Trigger + + +@pytest.fixture +def trigger_cog(bot): + from cogs.utils.database import init_database + init_database() + from cogs.trigger import Trigger + cog = Trigger(bot) + return cog + + +@pytest.mark.slow +@pytest.mark.asyncio +async def test_trigger_response_flow(trigger_cog): + """Test trigger creation and response in a simulated message flow.""" + cog = trigger_cog + + ctx = MagicMock() + ctx.author = MagicMock() + ctx.author.id = 123 + ctx.guild = MagicMock() + ctx.guild.id = 456 + ctx.respond = AsyncMock() + + # Create a trigger using the create command + await cog.create.callback(cog, ctx, name="test_trigger") + + # Add a keyword and response + await cog.add.callback(cog, ctx, name="test_trigger", trigger="hello", response="world") + + # Verify trigger was created - check DB + from cogs.utils.database import get_connection + with get_connection() as conn: + c = conn.cursor() + c.execute("SELECT id FROM triggers WHERE name=?", ("test_trigger",)) + result = c.fetchone() + assert result is not None + + # Simulate a message that should trigger + message = MagicMock() + message.author = ctx.author + message.author.bot = False + message.guild = ctx.guild + message.content = "say hello to me" + message.channel.send = AsyncMock() # Make send awaitable + + # Check if trigger matches using the static method + await Trigger.trigger(message) + + # Verify message.channel.send was called with the response + message.channel.send.assert_called_once_with("world") diff --git a/tests/cogs/test_tts.py b/tests/cogs/test_tts.py index de68580..c6563e6 100644 --- a/tests/cogs/test_tts.py +++ b/tests/cogs/test_tts.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock import discord -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/cogs/test_uploader.py b/tests/cogs/test_uploader.py index addf87e..0777126 100644 --- a/tests/cogs/test_uploader.py +++ b/tests/cogs/test_uploader.py @@ -2,7 +2,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock import os import discord -from tests.conftest import MockApplicationContext, MockMessage, MockUser +from conftest import MockApplicationContext, MockMessage, MockUser @pytest.fixture diff --git a/tests/cogs/test_vclogs.py b/tests/cogs/test_vclogs.py index da41482..17ff839 100644 --- a/tests/cogs/test_vclogs.py +++ b/tests/cogs/test_vclogs.py @@ -1,6 +1,6 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext def test_format_duration(): diff --git a/tests/cogs/test_vclogs_integration.py b/tests/cogs/test_vclogs_integration.py new file mode 100644 index 0000000..9391876 --- /dev/null +++ b/tests/cogs/test_vclogs_integration.py @@ -0,0 +1,86 @@ +import pytest +import asyncio +from unittest.mock import MagicMock, AsyncMock, patch + + +@pytest.fixture +def vclogs_cog(bot): + from cogs.vclogs import VcLogs + cog = VcLogs(bot) + return cog + + +@pytest.mark.slow +@pytest.mark.asyncio +async def test_full_voice_session_tracking(vclogs_cog, monkeypatch): + """Test complete voice session with AFK transitions using real time sleeps.""" + cog = vclogs_cog + + # Set up environment + monkeypatch.setenv("STATUS_CHANNEL_ID", "123456") + + member = MagicMock() + member.id = 123 + member.guild.id = 456 + member.bot = False + member.display_name = "TestUser" + member.guild.get_member.return_value = member + + # Create a mock log channel + log_channel = MagicMock() + log_channel.send = AsyncMock(return_value=MagicMock()) + + # Mock bot.get_channel to return our log channel + with patch.object(vclogs_cog.bot, 'get_channel', return_value=log_channel): + # Join voice + before = MagicMock() + before.channel = None + after_join = MagicMock() + after_join.channel = MagicMock() + after_join.channel.id = 789 + after_join.channel.name = "Test Voice" + after_join.self_deaf = False + after_join.self_mute = False + after_join.deaf = False + after_join.mute = False + await cog.on_voice_state_update(member, before, after_join) + + await asyncio.sleep(2) # Simulate 2s active + + # Go AFK (self_deaf) + before_afk = after_join + after_afk = MagicMock() + after_afk.channel = before_afk.channel + after_afk.self_deaf = True + after_afk.self_mute = False + after_afk.deaf = True + after_afk.mute = False + await cog.on_voice_state_update(member, before_afk, after_afk) + + await asyncio.sleep(3) # Simulate 3s AFK + + # Return from AFK + before_back = after_afk + after_back = MagicMock() + after_back.channel = before_back.channel + after_back.self_deaf = False + after_back.self_mute = False + after_back.deaf = False + after_back.mute = False + await cog.on_voice_state_update(member, before_back, after_back) + + await asyncio.sleep(1) # Simulate 1s active + + # Leave voice + before_leave = after_back + after_leave = MagicMock() + after_leave.channel = None + after_leave.self_deaf = False + after_leave.self_mute = False + await cog.on_voice_state_update(member, before_leave, after_leave) + + # Verify activity was saved (check DB) + from cogs.vclogs import load_activity_data + activity_data = load_activity_data() + # After leaving, data should be deleted, so check the log channel was called + assert log_channel.send.called diff --git a/tests/cogs/test_voice.py b/tests/cogs/test_voice.py index ade29cb..104eea6 100644 --- a/tests/cogs/test_voice.py +++ b/tests/cogs/test_voice.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock import asyncio -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.mark.asyncio diff --git a/tests/cogs/test_web.py b/tests/cogs/test_web.py index e0d2208..4de2e8b 100644 --- a/tests/cogs/test_web.py +++ b/tests/cogs/test_web.py @@ -1,7 +1,7 @@ import pytest from unittest.mock import AsyncMock, patch, MagicMock from cogs.web import Web -from tests.conftest import MockApplicationContext +from conftest import MockApplicationContext @pytest.fixture diff --git a/tests/conftest.py b/tests/conftest.py index 01ece10..6b7c3bc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ import asyncio import os +import sys import tempfile from unittest.mock import AsyncMock, MagicMock import pytest @@ -7,6 +8,8 @@ import pytest import discord from discord.ext import commands +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + @pytest.fixture(autouse=True, scope="function") def setup_test_env(monkeypatch): -- cgit v1.2.3