aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--AGENTS.md5
-rw-r--r--cogs/cron.py11
-rw-r--r--cogs/downloader.py14
-rw-r--r--cogs/music/media_processing.py2
-rw-r--r--cogs/music/play_commands.py2
-rw-r--r--cogs/utils/database.py17
-rw-r--r--cogs/utils/url_helper.py21
-rw-r--r--cogs/vclogs.py55
-rw-r--r--cogs/web.py2
-rw-r--r--pytest.ini12
-rw-r--r--tests/cogs/test_ai.py2
-rw-r--r--tests/cogs/test_assistant.py2
-rw-r--r--tests/cogs/test_compression_conversion.py2
-rw-r--r--tests/cogs/test_cron.py85
-rw-r--r--tests/cogs/test_cron_integration.py39
-rw-r--r--tests/cogs/test_downloader.py2
-rw-r--r--tests/cogs/test_downloader_integration.py37
-rw-r--r--tests/cogs/test_math_commands.py2
-rw-r--r--tests/cogs/test_music_audio.py2
-rw-r--r--tests/cogs/test_music_external_play.py2
-rw-r--r--tests/cogs/test_music_integration.py44
-rw-r--r--tests/cogs/test_music_join.py2
-rw-r--r--tests/cogs/test_music_play.py2
-rw-r--r--tests/cogs/test_qr.py2
-rw-r--r--tests/cogs/test_say.py2
-rw-r--r--tests/cogs/test_search.py2
-rw-r--r--tests/cogs/test_trigger.py2
-rw-r--r--tests/cogs/test_trigger_integration.py54
-rw-r--r--tests/cogs/test_tts.py2
-rw-r--r--tests/cogs/test_uploader.py2
-rw-r--r--tests/cogs/test_vclogs.py2
-rw-r--r--tests/cogs/test_vclogs_integration.py86
-rw-r--r--tests/cogs/test_voice.py2
-rw-r--r--tests/cogs/test_web.py2
-rw-r--r--tests/conftest.py3
35 files changed, 421 insertions, 104 deletions
diff --git a/AGENTS.md b/AGENTS.md
index 44be9d5..8d0b743 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -26,3 +26,8 @@
26- Tests in `tests/cogs/test_*.py` 26- Tests in `tests/cogs/test_*.py`
27- Uses pytest-asyncio with `asyncio_default_fixture_loop_scope = function` 27- Uses pytest-asyncio with `asyncio_default_fixture_loop_scope = function`
28- `conftest.py` provides fixtures for mocking Discord objects 28- `conftest.py` provides fixtures for mocking Discord objects
29- **Slow integration tests** marked with `@pytest.mark.slow`
30 - Test real-world feature usage with longer execution times
31 - Includes: voice session tracking, cron job execution, music playback lifecycle, downloader flow, trigger responses
32 - Run only fast tests: `.venv/bin/pytest -m "not slow"`
33 - 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):
30 30
31 def load_tasks(self): 31 def load_tasks(self):
32 try: 32 try:
33 conn = get_connection() 33 with get_connection() as conn:
34 try:
35 c = conn.cursor() 34 c = conn.cursor()
36 c.execute("SELECT id, cron_expression, type, channel_id, active, audio_filename, message FROM cron_jobs") 35 c.execute("SELECT id, cron_expression, type, channel_id, active, audio_filename, message FROM cron_jobs")
37 rows = c.fetchall() 36 rows = c.fetchall()
38 finally:
39 conn.close()
40 37
41 self.scheduled_tasks = [ 38 self.scheduled_tasks = [
42 { 39 {
@@ -57,8 +54,7 @@ class Cron(commands.Cog):
57 54
58 def save_tasks(self): 55 def save_tasks(self):
59 try: 56 try:
60 conn = get_connection() 57 with get_connection() as conn:
61 try:
62 c = conn.cursor() 58 c = conn.cursor()
63 c.execute("PRAGMA foreign_keys = ON") 59 c.execute("PRAGMA foreign_keys = ON")
64 60
@@ -78,10 +74,7 @@ class Cron(commands.Cog):
78 task.get("message"), 74 task.get("message"),
79 ), 75 ),
80 ) 76 )
81
82 conn.commit() 77 conn.commit()
83 finally:
84 conn.close()
85 except Exception as e: 78 except Exception as e:
86 logger.error(f"Error saving cron jobs: {e}") 79 logger.error(f"Error saving cron jobs: {e}")
87 80
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
12import yt_dlp 12import yt_dlp
13from discord.ext import commands 13from discord.ext import commands
14import aiofiles 14import aiofiles
15from cogs.utils.url_helper import clean_url_for_ytdlp
15 16
16logger = logging.getLogger(__name__) 17logger = logging.getLogger(__name__)
17 18
@@ -61,6 +62,10 @@ class Downloader(commands.Cog):
61 file_data = response["content"] 62 file_data = response["content"]
62 await ctx.respond(file=discord.File(file_data)) 63 await ctx.respond(file=discord.File(file_data))
63 else: 64 else:
65 error_msg = response["content"]
66 if 'details' in response:
67 error_msg += f"\n{response['details']}"
68 await ctx.respond(f"Błąd: {error_msg}", ephemeral=True)
64 logger.error(f"Error: {response['content']}") 69 logger.error(f"Error: {response['content']}")
65 if 'details' in response: 70 if 'details' in response:
66 logger.error(f"Details: {response['details']}") 71 logger.error(f"Details: {response['details']}")
@@ -68,6 +73,7 @@ class Downloader(commands.Cog):
68 @staticmethod 73 @staticmethod
69 async def download_standalone(url: str, catbox: bool = False, fformat: str = "mp4", filename_user: str | None = None): 74 async def download_standalone(url: str, catbox: bool = False, fformat: str = "mp4", filename_user: str | None = None):
70 try: 75 try:
76 url = clean_url_for_ytdlp(url)
71 logger.info(f"Downloading {url} as {fformat}") 77 logger.info(f"Downloading {url} as {fformat}")
72 78
73 with tempfile.TemporaryDirectory(delete=False) as temp_dir: 79 with tempfile.TemporaryDirectory(delete=False) as temp_dir:
@@ -157,12 +163,18 @@ class Downloader(commands.Cog):
157 files = {'f': (filename, content, 'application/octet-stream')} 163 files = {'f': (filename, content, 'application/octet-stream')}
158 headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'} 164 headers = {'Cookie': f'cppwd={os.getenv("COPYPARTY_PASSWORD")}'}
159 params = {'j': '1', 'ck': 'b2'} 165 params = {'j': '1', 'ck': 'b2'}
160 base_url = "https://catbox.2137697.xyz/sperma/" 166 base_url = os.getenv("COPYPARTY_URL_UPLOAD")
161 167
162 response = requests.post(base_url, files=files, headers=headers, params=params) 168 response = requests.post(base_url, files=files, headers=headers, params=params)
163 169
164 if response.status_code in [200, 201]: 170 if response.status_code in [200, 201]:
165 return Downloader._handle_upload_success(response, filename, temp_dir) 171 return Downloader._handle_upload_success(response, filename, temp_dir)
172 elif response.status_code == 413:
173 return Downloader._create_error_response(
174 "File too large for upload",
175 "Plik jest za duży, by wysłać go na serwer (limit to ok. 100MB)",
176 temp_dir
177 )
166 else: 178 else:
167 return Downloader._create_error_response( 179 return Downloader._create_error_response(
168 f"Server returned {response.status_code}", 180 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
9 9
10import yt_dlp 10import yt_dlp
11from youtubesearchpython import VideosSearch 11from youtubesearchpython import VideosSearch
12from cogs.utils.url_helper import clean_url_for_ytdlp
12 13
13logger = logging.getLogger(__name__) 14logger = logging.getLogger(__name__)
14 15
@@ -36,6 +37,7 @@ class MediaDownloader:
36 } 37 }
37 38
38 try: 39 try:
40 url = clean_url_for_ytdlp(url)
39 with yt_dlp.YoutubeDL(ytdl_opts) as ydl: 41 with yt_dlp.YoutubeDL(ytdl_opts) as ydl:
40 info_dict = ydl.extract_info(url, download=True) 42 info_dict = ydl.extract_info(url, download=True)
41 # Get the base filename without extension 43 # 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
19from cogs.music import audio_playback, media_processing 19from cogs.music import audio_playback, media_processing
20from cogs.utils.file_search import UniversalSearcher 20from cogs.utils.file_search import UniversalSearcher
21from cogs.utils.settings import get_uploadurl 21from cogs.utils.settings import get_uploadurl
22from cogs.utils.url_helper import clean_url_for_ytdlp
22 23
23logger = logging.getLogger(__name__) 24logger = logging.getLogger(__name__)
24 25
@@ -193,6 +194,7 @@ class PlayCommands(commands.Cog):
193 } 194 }
194 195
195 loop = asyncio.get_event_loop() 196 loop = asyncio.get_event_loop()
197 url = clean_url_for_ytdlp(url)
196 with YoutubeDL(ytdl_opts) as ydl: 198 with YoutubeDL(ytdl_opts) as ydl:
197 info_dict = await loop.run_in_executor( 199 info_dict = await loop.run_in_executor(
198 None, lambda: ydl.extract_info(url, download=True) 200 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 @@
1import os 1import os
2import sqlite3 2import sqlite3
3import logging 3import logging
4from contextlib import contextmanager
4 5
5logger = logging.getLogger(__name__) 6logger = logging.getLogger(__name__)
6 7
@@ -10,7 +11,8 @@ _initialized = False
10 11
11 12
12def get_connection(): 13def get_connection():
13 return sqlite3.connect(get_db_path()) 14 conn = sqlite3.connect(get_db_path())
15 return _ConnectionContextManager(conn)
14 16
15 17
16def get_db_path(): 18def get_db_path():
@@ -22,6 +24,19 @@ def set_db_path(path):
22 DB_PATH = path 24 DB_PATH = path
23 25
24 26
27class _ConnectionContextManager:
28 """Context manager for sqlite3 connections that auto-closes."""
29 def __init__(self, conn):
30 self.conn = conn
31
32 def __enter__(self):
33 return self.conn
34
35 def __exit__(self, exc_type, exc_val, exc_tb):
36 self.conn.close()
37 return False
38
39
25def init_database(): 40def init_database():
26 global _initialized 41 global _initialized
27 if _initialized: 42 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 @@
1# This file is part of Senator Sperma
2# Licensed under the GPL-3.0 License
3# Copyright (C) 2026 Salmonik @ salmonik@tuta.io
4# https://codeberg.org/salmonik/senator-sperma
5
6
7def clean_url_for_ytdlp(url: str) -> str:
8 """Clean URL by removing everything after the first '&'.
9
10 This prevents yt-dlp from interpreting additional query parameters
11 (like &list=...) as playlist URLs.
12
13 Example:
14 Input: https://www.youtube.com/watch?v=lPGipwoJiOM&list=RDlPGipwoJiOM&
15 Output: https://www.youtube.com/watch?v=lPGipwoJiOM
16 """
17 # Find the first '&' and truncate
18 first_ampersand = url.find('&')
19 if first_ampersand != -1:
20 return url[:first_ampersand]
21 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()
23 23
24def load_activity_data(): 24def load_activity_data():
25 try: 25 try:
26 conn = get_connection() 26 with get_connection() as conn:
27 c = conn.cursor() 27 c = conn.cursor()
28 c.execute("SELECT user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name FROM voice_activity") 28 c.execute("SELECT user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name FROM voice_activity")
29 rows = c.fetchall() 29 rows = c.fetchall()
30 conn.close()
31 30
32 return { 31 return {
33 row[0]: { 32 row[0]: {
@@ -48,30 +47,28 @@ def load_activity_data():
48 47
49def save_activity_data(data): 48def save_activity_data(data):
50 try: 49 try:
51 conn = get_connection() 50 with get_connection() as conn:
52 c = conn.cursor() 51 c = conn.cursor()
53 c.execute("PRAGMA foreign_keys = ON") 52 c.execute("PRAGMA foreign_keys = ON")
54 53
55 c.execute("DELETE FROM voice_activity") 54 c.execute("DELETE FROM voice_activity")
56 55
57 for user_id, user_data in data.items(): 56 for user_id, user_data in data.items():
58 c.execute( 57 c.execute(
59 """INSERT INTO voice_activity (user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name) 58 """INSERT INTO voice_activity (user_id, join_time, total_active, total_afk, is_afk, afk_start, active_start, channel_name)
60 VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", 59 VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
61 ( 60 (
62 user_id, 61 user_id,
63 user_data.get("join_time"), 62 user_data.get("join_time"),
64 user_data.get("total_active", 0), 63 user_data.get("total_active", 0),
65 user_data.get("total_afk", 0), 64 user_data.get("total_afk", 0),
66 int(user_data.get("is_afk", False)), 65 int(user_data.get("is_afk", False)),
67 user_data.get("afk_start"), 66 user_data.get("afk_start"),
68 user_data.get("active_start"), 67 user_data.get("active_start"),
69 user_data.get("channel_name"), 68 user_data.get("channel_name"),
70 ), 69 ),
71 ) 70 )
72 71 conn.commit()
73 conn.commit()
74 conn.close()
75 logger.info(f"Saved voice activity for {len(data)} users") 72 logger.info(f"Saved voice activity for {len(data)} users")
76 except Exception as e: 73 except Exception as e:
77 logger.error(f"Error saving voice activity: {e}") 74 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
11import urllib.parse 11import urllib.parse
12import yt_dlp 12import yt_dlp
13import logging 13import logging
14from cogs.utils.url_helper import clean_url_for_ytdlp
14 15
15logger = logging.getLogger(__name__) 16logger = logging.getLogger(__name__)
16 17
@@ -24,6 +25,7 @@ class Web(commands.Cog):
24 if "youtube.com/watch" not in url: 25 if "youtube.com/watch" not in url:
25 return None 26 return None
26 27
28 url = clean_url_for_ytdlp(url)
27 logger.info("youtube link") 29 logger.info("youtube link")
28 try: 30 try:
29 ydl_opts = {"quiet": True, "nowarnings": True} 31 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 @@
1[pytest] 1[pytest]
2markers =
3 slow: marks tests as slow (deselect with '-m "not slow"')
4asyncio_mode = strict
2asyncio_default_fixture_loop_scope = function 5asyncio_default_fixture_loop_scope = function
3python_files = test_*.py 6filterwarnings =
4python_classes = Test* 7 ignore::RuntimeWarning
5python_functions = test_* 8 ignore::pytest.PytestUnraisableExceptionWarning
6testpaths = tests 9 ignore::DeprecationWarning:discord.client \ No newline at end of file
7pythonpath = . \ 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
2from unittest.mock import AsyncMock, patch, MagicMock 2from unittest.mock import AsyncMock, patch, MagicMock
3import discord 3import discord
4import httpx 4import httpx
5from tests.conftest import MockApplicationContext 5from conftest import MockApplicationContext
6 6
7 7
8@pytest.fixture 8@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 @@
1import pytest 1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock 2from unittest.mock import AsyncMock, patch, MagicMock
3from tests.conftest import MockApplicationContext 3from conftest import MockApplicationContext
4 4
5 5
6@pytest.fixture 6@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
2from unittest.mock import AsyncMock, patch, MagicMock 2from unittest.mock import AsyncMock, patch, MagicMock
3import os 3import os
4import discord 4import discord
5from tests.conftest import MockApplicationContext 5from conftest import MockApplicationContext
6 6
7 7
8def test_compression_filetype_video(): 8def 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
3from unittest.mock import MagicMock, patch 3from unittest.mock import MagicMock, patch
4 4
5from cogs.cron import Cron 5from cogs.cron import Cron
6from cogs.utils.database import _ConnectionContextManager
6 7
7 8
8@pytest.fixture 9@pytest.fixture
@@ -15,35 +16,31 @@ def mock_bot():
15 return bot 16 return bot
16 17
17 18
18def create_cog(mock_bot, tmp_path): 19def create_cog(mock_bot):
19 with patch.object(Cron, "__init__", lambda self, bot: None): 20 with patch.object(Cron, "__init__", lambda self, bot: None):
20 with patch("cogs.cron.init_database"): 21 with patch("cogs.cron.init_database"):
21 mock_conn = MagicMock() 22 cog = Cron.__new__(Cron)
22 mock_cursor = MagicMock() 23 cog.bot = mock_bot
23 mock_cursor.fetchall.return_value = [] 24 cog.scheduled_tasks = []
24 mock_conn.cursor.return_value = mock_cursor 25 cog.task = MagicMock()
25 mock_conn.close.return_value = None 26 return cog
26
27 with patch("cogs.cron.get_connection", return_value=mock_conn):
28 with patch("cogs.utils.database.get_db_path", return_value=":memory:"):
29 cog = Cron.__new__(Cron)
30 cog.bot = mock_bot
31 cog.scheduled_tasks = []
32 cog.task = MagicMock()
33 return cog
34 27
35 28
36class TestLoadTasks: 29class TestLoadTasks:
37 def test_load_tasks_with_data(self, mock_bot, tmp_path): 30 def test_load_tasks_with_data(self, mock_bot):
38 mock_conn = MagicMock() 31 mock_conn = MagicMock()
39 mock_cursor = MagicMock() 32 mock_cursor = MagicMock()
40 mock_cursor.fetchall.return_value = [ 33 mock_cursor.fetchall.return_value = [
41 (1, "* * * * *", "audio", 123, 1, None, "test message") 34 (1, "* * * * *", "audio", 123, 1, None, "test message")
42 ] 35 ]
43 mock_conn.cursor.return_value = mock_cursor 36 mock_conn.cursor.return_value = mock_cursor
44 mock_conn.close.return_value = None
45 37
46 with patch("cogs.cron.get_connection", return_value=mock_conn): 38 # Create a context manager mock
39 mock_ctx = MagicMock()
40 mock_ctx.__enter__ = MagicMock(return_value=mock_conn)
41 mock_ctx.__exit__ = MagicMock(return_value=False)
42
43 with patch("cogs.cron.get_connection", return_value=mock_ctx):
47 cog = Cron.__new__(Cron) 44 cog = Cron.__new__(Cron)
48 cog.bot = mock_bot 45 cog.bot = mock_bot
49 cog.scheduled_tasks = [] 46 cog.scheduled_tasks = []
@@ -52,33 +49,41 @@ class TestLoadTasks:
52 assert len(cog.scheduled_tasks) == 1 49 assert len(cog.scheduled_tasks) == 1
53 assert cog.scheduled_tasks[0]["id"] == 1 50 assert cog.scheduled_tasks[0]["id"] == 1
54 51
55
56class TestSaveTasks: 52class TestSaveTasks:
57 def test_save_tasks(self, mock_bot, tmp_path): 53 def test_save_tasks(self, mock_bot):
58 cog = create_cog(mock_bot, tmp_path) 54 cog = create_cog(mock_bot)
59 cog.scheduled_tasks = [{"id": 1, "cron_expression": "* * * * *", "type": "audio", "channel_id": 123, "active": True}] 55 cog.scheduled_tasks = [{"id": 1, "cron_expression": "* * * * *", "type": "audio", "channel_id": 123, "active": True}]
60 cog.save_tasks()
61 assert len(cog.scheduled_tasks) == 1
62 56
57 mock_conn = MagicMock()
58 mock_cursor = MagicMock()
59 mock_conn.cursor.return_value = mock_cursor
60
61 # Create a context manager mock
62 mock_ctx = MagicMock()
63 mock_ctx.__enter__ = MagicMock(return_value=mock_conn)
64 mock_ctx.__exit__ = MagicMock(return_value=False)
65
66 with patch("cogs.cron.get_connection", return_value=mock_ctx):
67 cog.save_tasks()
68 assert len(cog.scheduled_tasks) == 1
63 69
64class TestShouldRunTask: 70class TestShouldRunTask:
65 def test_should_run_task_invalid_cron(self, mock_bot, tmp_path): 71 def test_should_run_task_invalid_cron(self, mock_bot):
66 cog = create_cog(mock_bot, tmp_path) 72 cog = create_cog(mock_bot)
67 task = {"cron_expression": "invalid"} 73 task = {"cron_expression": "invalid"}
68 now = datetime.now() 74 now = datetime.now()
69 result = cog.should_run_task(task, now) 75 result = cog.should_run_task(task, now)
70 assert result is False 76 assert result is False
71 77
72 def test_should_run_task_missing_cron_expression(self, mock_bot, tmp_path): 78 def test_should_run_task_missing_cron_expression(self, mock_bot):
73 cog = create_cog(mock_bot, tmp_path) 79 cog = create_cog(mock_bot)
74 task = {} 80 task = {}
75 now = datetime.now() 81 now = datetime.now()
76 result = cog.should_run_task(task, now) 82 result = cog.should_run_task(task, now)
77 assert result is False 83 assert result is False
78 84
79
80class TestCogUnload: 85class TestCogUnload:
81 def test_cog_unload_cancels_task(self, mock_bot, tmp_path): 86 def test_cog_unload_cancels_task(self, mock_bot):
82 with patch.object(Cron, "__init__", lambda self, bot: None): 87 with patch.object(Cron, "__init__", lambda self, bot: None):
83 cog = Cron.__new__(Cron) 88 cog = Cron.__new__(Cron)
84 cog.bot = mock_bot 89 cog.bot = mock_bot
@@ -87,10 +92,9 @@ class TestCogUnload:
87 cog.cog_unload() 92 cog.cog_unload()
88 cog.task.cancel.assert_called_once() 93 cog.task.cancel.assert_called_once()
89 94
90
91class TestTaskManagement: 95class TestTaskManagement:
92 def test_add_task_increments_id(self, mock_bot, tmp_path): 96 def test_add_task_increments_id(self, mock_bot):
93 cog = create_cog(mock_bot, tmp_path) 97 cog = create_cog(mock_bot)
94 cog.scheduled_tasks = [ 98 cog.scheduled_tasks = [
95 {"id": 1, "cron_expression": "* * * * *"}, 99 {"id": 1, "cron_expression": "* * * * *"},
96 {"id": 2, "cron_expression": "0 * * * *"}, 100 {"id": 2, "cron_expression": "0 * * * *"},
@@ -99,8 +103,8 @@ class TestTaskManagement:
99 new_id = max([t["id"] for t in cog.scheduled_tasks], default=0) + 1 103 new_id = max([t["id"] for t in cog.scheduled_tasks], default=0) + 1
100 assert new_id == 3 104 assert new_id == 3
101 105
102 def test_find_task_by_id(self, mock_bot, tmp_path): 106 def test_find_task_by_id(self, mock_bot):
103 cog = create_cog(mock_bot, tmp_path) 107 cog = create_cog(mock_bot)
104 cog.scheduled_tasks = [ 108 cog.scheduled_tasks = [
105 {"id": 1, "cron_expression": "* * * * *"}, 109 {"id": 1, "cron_expression": "* * * * *"},
106 {"id": 2, "cron_expression": "0 * * * *"}, 110 {"id": 2, "cron_expression": "0 * * * *"},
@@ -110,8 +114,8 @@ class TestTaskManagement:
110 assert found_task is not None 114 assert found_task is not None
111 assert found_task["cron_expression"] == "0 * * * *" 115 assert found_task["cron_expression"] == "0 * * * *"
112 116
113 def test_remove_task_by_index(self, mock_bot, tmp_path): 117 def test_remove_task_by_index(self, mock_bot):
114 cog = create_cog(mock_bot, tmp_path) 118 cog = create_cog(mock_bot)
115 cog.scheduled_tasks = [ 119 cog.scheduled_tasks = [
116 {"id": 1, "cron_expression": "* * * * *"}, 120 {"id": 1, "cron_expression": "* * * * *"},
117 {"id": 2, "cron_expression": "0 * * * *"}, 121 {"id": 2, "cron_expression": "0 * * * *"},
@@ -121,8 +125,8 @@ class TestTaskManagement:
121 assert len(cog.scheduled_tasks) == 1 125 assert len(cog.scheduled_tasks) == 1
122 assert cog.scheduled_tasks[0]["id"] == 2 126 assert cog.scheduled_tasks[0]["id"] == 2
123 127
124 def test_toggle_task_active(self, mock_bot, tmp_path): 128 def test_toggle_task_active(self, mock_bot):
125 cog = create_cog(mock_bot, tmp_path) 129 cog = create_cog(mock_bot)
126 cog.scheduled_tasks = [{"id": 1, "active": True}] 130 cog.scheduled_tasks = [{"id": 1, "active": True}]
127 131
128 cog.scheduled_tasks[0]["active"] = False 132 cog.scheduled_tasks[0]["active"] = False
@@ -131,14 +135,13 @@ class TestTaskManagement:
131 cog.scheduled_tasks[0]["active"] = True 135 cog.scheduled_tasks[0]["active"] = True
132 assert cog.scheduled_tasks[0]["active"] is True 136 assert cog.scheduled_tasks[0]["active"] is True
133 137
134
135class TestTaskTypes: 138class TestTaskTypes:
136 def test_task_type_audio_aliases(self, mock_bot, tmp_path): 139 def test_task_type_audio_aliases(self, mock_bot):
137 audio_types = ("zagraj_audio", "audio") 140 audio_types = ("zagraj_audio", "audio")
138 for task_type in audio_types: 141 for task_type in audio_types:
139 assert task_type in ("zagraj_audio", "audio") 142 assert task_type in ("zagraj_audio", "audio")
140 143
141 def test_task_type_message_aliases(self, mock_bot, tmp_path): 144 def test_task_type_message_aliases(self, mock_bot):
142 message_types = ("wyślij_wiadomość", "send_message") 145 message_types = ("wyślij_wiadomość", "send_message")
143 for task_type in message_types: 146 for task_type in message_types:
144 assert task_type in ("wyślij_wiadomość", "send_message") \ No newline at end of file 147 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 @@
1import pytest
2import time
3from unittest.mock import MagicMock, AsyncMock, patch
4
5
6@pytest.fixture
7def cron_cog(bot):
8 # Patch create_task to prevent background task creation
9 with patch.object(bot.loop, 'create_task', return_value=MagicMock()):
10 from cogs.cron import Cron
11 cog = Cron(bot)
12 yield cog
13
14
15@pytest.mark.slow
16@pytest.mark.asyncio
17async def test_cron_job_execution(cron_cog):
18 """Test cron job executes when due."""
19 cog = cron_cog
20
21 job = {
22 "id": "test_job",
23 "cron_expression": "* * * * *",
24 "type": "message",
25 "channel_id": 456,
26 "active": True,
27 "message": "Test",
28 }
29 cog.scheduled_tasks.append(job)
30
31 # Mock the execute_task method
32 with patch.object(cog, 'execute_task', new_callable=AsyncMock) as mock_exec:
33 # Mock should_run_task to return True
34 with patch.object(cog, 'should_run_task', return_value=True):
35 # Test single iteration of schedule_checker logic
36 for task in cog.scheduled_tasks:
37 if task.get("active", True) and cog.should_run_task(task, None):
38 await cog.execute_task(task)
39 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 @@
1import pytest 1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock 2from unittest.mock import AsyncMock, patch, MagicMock
3import discord 3import discord
4from tests.conftest import MockApplicationContext, MockMessage 4from conftest import MockApplicationContext, MockMessage
5 5
6 6
7@pytest.fixture 7@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 @@
1import pytest
2from unittest.mock import MagicMock, AsyncMock, patch
3
4
5@pytest.fixture
6def downloader_cog(bot):
7 from cogs.downloader import Downloader
8 cog = Downloader(bot)
9 return cog
10
11
12@pytest.mark.slow
13@pytest.mark.asyncio
14async def test_download_command_flow(downloader_cog, bot):
15 """Test download command with URL processing."""
16 cog = downloader_cog
17
18 ctx = MagicMock()
19 ctx.author = MagicMock()
20 ctx.guild = MagicMock()
21 ctx.channel = MagicMock()
22 ctx.respond = AsyncMock()
23 ctx.defer = AsyncMock()
24 ctx.followup = AsyncMock()
25 ctx.voice_client = None
26
27 # Mock the download_standalone static method
28 mock_response = {
29 "status": "success",
30 "type": "url",
31 "content": "http://example.com/video.mp4"
32 }
33
34 with patch.object(cog, 'download_standalone', new_callable=AsyncMock, return_value=mock_response):
35 await cog.download.callback(cog, ctx, url="http://youtube.com/test")
36
37 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 @@
1import pytest 1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock 2from unittest.mock import AsyncMock, patch, MagicMock
3from tests.conftest import MockApplicationContext 3from conftest import MockApplicationContext
4 4
5 5
6@pytest.fixture 6@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 @@
1import pytest 1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock 2from unittest.mock import AsyncMock, patch, MagicMock
3import os 3import os
4from tests.conftest import MockApplicationContext 4from conftest import MockApplicationContext
5 5
6 6
7@pytest.mark.asyncio 7@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
3import asyncio 3import asyncio
4import tempfile 4import tempfile
5from unittest.mock import AsyncMock, patch, MagicMock 5from unittest.mock import AsyncMock, patch, MagicMock
6from tests.conftest import MockApplicationContext 6from conftest import MockApplicationContext
7 7
8 8
9@pytest.fixture 9@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 @@
1import pytest
2from unittest.mock import MagicMock, AsyncMock, patch, PropertyMock
3import sys
4sys.path.insert(0, '/home/salmon/Documents/botcode/senator-sperma/tests')
5from conftest import MockApplicationContext
6
7
8@pytest.fixture
9def music_cog(bot):
10 from cogs.music.play_commands import PlayCommands
11 cog = PlayCommands(bot)
12 return cog
13
14
15@pytest.mark.slow
16@pytest.mark.asyncio
17async def test_music_join_stop(music_cog, bot):
18 """Test music join and stop commands."""
19 from cogs.music.join import Join
20 from cogs.music.stop import Stop
21
22 join_cog = Join(bot)
23 stop_cog = Stop(bot)
24
25 ctx = MockApplicationContext(bot=bot)
26 ctx.author.voice = MagicMock()
27 ctx.author.voice.channel = MagicMock()
28 ctx.guild = MagicMock()
29
30 voice_client = AsyncMock()
31 ctx.voice_client = voice_client
32
33 # Test join
34 ctx.author.voice.channel.connect = AsyncMock(return_value=voice_client)
35 with patch.object(ctx, 'respond', new_callable=AsyncMock):
36 with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock):
37 await join_cog.join.callback(join_cog, ctx)
38 ctx.author.voice.channel.connect.assert_called_once()
39
40 # Test stop
41 with patch.object(ctx, 'respond', new_callable=AsyncMock):
42 with patch.object(ctx.interaction, 'delete_original_response', new_callable=AsyncMock):
43 await stop_cog.stop.callback(stop_cog, ctx)
44 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 @@
1import pytest 1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock 2from unittest.mock import AsyncMock, patch, MagicMock
3from tests.conftest import MockApplicationContext 3from conftest import MockApplicationContext
4 4
5 5
6@pytest.fixture 6@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 @@
1import pytest 1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock 2from unittest.mock import AsyncMock, patch, MagicMock
3import os 3import os
4from tests.conftest import MockApplicationContext 4from conftest import MockApplicationContext
5 5
6 6
7@pytest.fixture 7@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
2from unittest.mock import AsyncMock, patch, MagicMock, mock_open 2from unittest.mock import AsyncMock, patch, MagicMock, mock_open
3import os 3import os
4from cogs.qr import QR 4from cogs.qr import QR
5from tests.conftest import MockApplicationContext 5from conftest import MockApplicationContext
6 6
7 7
8@pytest.fixture 8@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 @@
1import pytest 1import pytest
2from unittest.mock import AsyncMock, patch 2from unittest.mock import AsyncMock, patch
3from cogs.say import Say 3from cogs.say import Say
4from tests.conftest import MockApplicationContext 4from conftest import MockApplicationContext
5 5
6 6
7@pytest.fixture 7@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 @@
1import pytest 1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock 2from unittest.mock import AsyncMock, patch, MagicMock
3import discord 3import discord
4from tests.conftest import MockApplicationContext 4from conftest import MockApplicationContext
5 5
6 6
7@pytest.fixture 7@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 @@
1import pytest 1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock 2from unittest.mock import AsyncMock, patch, MagicMock
3import sqlite3 3import sqlite3
4from tests.conftest import MockApplicationContext 4from conftest import MockApplicationContext
5 5
6 6
7@pytest.fixture 7@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 @@
1import pytest
2from unittest.mock import MagicMock, AsyncMock, patch
3from cogs.trigger import Trigger
4
5
6@pytest.fixture
7def trigger_cog(bot):
8 from cogs.utils.database import init_database
9 init_database()
10 from cogs.trigger import Trigger
11 cog = Trigger(bot)
12 return cog
13
14
15@pytest.mark.slow
16@pytest.mark.asyncio
17async def test_trigger_response_flow(trigger_cog):
18 """Test trigger creation and response in a simulated message flow."""
19 cog = trigger_cog
20
21 ctx = MagicMock()
22 ctx.author = MagicMock()
23 ctx.author.id = 123
24 ctx.guild = MagicMock()
25 ctx.guild.id = 456
26 ctx.respond = AsyncMock()
27
28 # Create a trigger using the create command
29 await cog.create.callback(cog, ctx, name="test_trigger")
30
31 # Add a keyword and response
32 await cog.add.callback(cog, ctx, name="test_trigger", trigger="hello", response="world")
33
34 # Verify trigger was created - check DB
35 from cogs.utils.database import get_connection
36 with get_connection() as conn:
37 c = conn.cursor()
38 c.execute("SELECT id FROM triggers WHERE name=?", ("test_trigger",))
39 result = c.fetchone()
40 assert result is not None
41
42 # Simulate a message that should trigger
43 message = MagicMock()
44 message.author = ctx.author
45 message.author.bot = False
46 message.guild = ctx.guild
47 message.content = "say hello to me"
48 message.channel.send = AsyncMock() # Make send awaitable
49
50 # Check if trigger matches using the static method
51 await Trigger.trigger(message)
52
53 # Verify message.channel.send was called with the response
54 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 @@
1import pytest 1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock 2from unittest.mock import AsyncMock, patch, MagicMock
3import discord 3import discord
4from tests.conftest import MockApplicationContext 4from conftest import MockApplicationContext
5 5
6 6
7@pytest.fixture 7@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
2from unittest.mock import AsyncMock, patch, MagicMock 2from unittest.mock import AsyncMock, patch, MagicMock
3import os 3import os
4import discord 4import discord
5from tests.conftest import MockApplicationContext, MockMessage, MockUser 5from conftest import MockApplicationContext, MockMessage, MockUser
6 6
7 7
8@pytest.fixture 8@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 @@
1import pytest 1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock 2from unittest.mock import AsyncMock, patch, MagicMock
3from tests.conftest import MockApplicationContext 3from conftest import MockApplicationContext
4 4
5 5
6def test_format_duration(): 6def 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 @@
1import pytest
2import asyncio
3from unittest.mock import MagicMock, AsyncMock, patch
4
5
6@pytest.fixture
7def vclogs_cog(bot):
8 from cogs.vclogs import VcLogs
9 cog = VcLogs(bot)
10 return cog
11
12
13@pytest.mark.slow
14@pytest.mark.asyncio
15async def test_full_voice_session_tracking(vclogs_cog, monkeypatch):
16 """Test complete voice session with AFK transitions using real time sleeps."""
17 cog = vclogs_cog
18
19 # Set up environment
20 monkeypatch.setenv("STATUS_CHANNEL_ID", "123456")
21
22 member = MagicMock()
23 member.id = 123
24 member.guild.id = 456
25 member.bot = False
26 member.display_name = "TestUser"
27 member.guild.get_member.return_value = member
28
29 # Create a mock log channel
30 log_channel = MagicMock()
31 log_channel.send = AsyncMock(return_value=MagicMock())
32
33 # Mock bot.get_channel to return our log channel
34 with patch.object(vclogs_cog.bot, 'get_channel', return_value=log_channel):
35 # Join voice
36 before = MagicMock()
37 before.channel = None
38 after_join = MagicMock()
39 after_join.channel = MagicMock()
40 after_join.channel.id = 789
41 after_join.channel.name = "Test Voice"
42 after_join.self_deaf = False
43 after_join.self_mute = False
44 after_join.deaf = False
45 after_join.mute = False
46 await cog.on_voice_state_update(member, before, after_join)
47
48 await asyncio.sleep(2) # Simulate 2s active
49
50 # Go AFK (self_deaf)
51 before_afk = after_join
52 after_afk = MagicMock()
53 after_afk.channel = before_afk.channel
54 after_afk.self_deaf = True
55 after_afk.self_mute = False
56 after_afk.deaf = True
57 after_afk.mute = False
58 await cog.on_voice_state_update(member, before_afk, after_afk)
59
60 await asyncio.sleep(3) # Simulate 3s AFK
61
62 # Return from AFK
63 before_back = after_afk
64 after_back = MagicMock()
65 after_back.channel = before_back.channel
66 after_back.self_deaf = False
67 after_back.self_mute = False
68 after_back.deaf = False
69 after_back.mute = False
70 await cog.on_voice_state_update(member, before_back, after_back)
71
72 await asyncio.sleep(1) # Simulate 1s active
73
74 # Leave voice
75 before_leave = after_back
76 after_leave = MagicMock()
77 after_leave.channel = None
78 after_leave.self_deaf = False
79 after_leave.self_mute = False
80 await cog.on_voice_state_update(member, before_leave, after_leave)
81
82 # Verify activity was saved (check DB)
83 from cogs.vclogs import load_activity_data
84 activity_data = load_activity_data()
85 # After leaving, data should be deleted, so check the log channel was called
86 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 @@
1import pytest 1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock 2from unittest.mock import AsyncMock, patch, MagicMock
3import asyncio 3import asyncio
4from tests.conftest import MockApplicationContext 4from conftest import MockApplicationContext
5 5
6 6
7@pytest.mark.asyncio 7@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 @@
1import pytest 1import pytest
2from unittest.mock import AsyncMock, patch, MagicMock 2from unittest.mock import AsyncMock, patch, MagicMock
3from cogs.web import Web 3from cogs.web import Web
4from tests.conftest import MockApplicationContext 4from conftest import MockApplicationContext
5 5
6 6
7@pytest.fixture 7@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 @@
1import asyncio 1import asyncio
2import os 2import os
3import sys
3import tempfile 4import tempfile
4from unittest.mock import AsyncMock, MagicMock 5from unittest.mock import AsyncMock, MagicMock
5import pytest 6import pytest
@@ -7,6 +8,8 @@ import pytest
7import discord 8import discord
8from discord.ext import commands 9from discord.ext import commands
9 10
11sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
12
10 13
11@pytest.fixture(autouse=True, scope="function") 14@pytest.fixture(autouse=True, scope="function")
12def setup_test_env(monkeypatch): 15def setup_test_env(monkeypatch):