From 138352a901a12da3e7ae17b61772400555f33268 Mon Sep 17 00:00:00 2001 From: Marcello Date: Sun, 29 Mar 2026 12:41:21 +0200 Subject: [PATCH 1/2] test: Extend tests for beauty functionality --- tests/test_beauty.py | 130 +++++++++++++++++++++++++++++++++++++++++ tests/test_handlers.py | 52 ++++++++++++++++- 2 files changed, 181 insertions(+), 1 deletion(-) diff --git a/tests/test_beauty.py b/tests/test_beauty.py index 85cdcce..a1a8cab 100644 --- a/tests/test_beauty.py +++ b/tests/test_beauty.py @@ -3,6 +3,8 @@ from datetime import date, datetime, timedelta from unittest.mock import AsyncMock, MagicMock +import requests + from src import beauty @@ -97,5 +99,133 @@ def test_handle_beauty_returns_when_download_fails(monkeypatch): message.reply_photo.assert_not_awaited() +def test_handle_beauty_returns_when_update_has_no_message(monkeypatch): + update = MagicMock(message=None) + + get_path = AsyncMock() + monkeypatch.setattr(beauty, "get_beauty_image_path", get_path) + + asyncio.run(beauty.handle_beauty(update)) + + get_path.assert_not_awaited() + + +def test_get_beauty_image_path_returns_none_when_downloader_fails( + monkeypatch, tmp_path +): + path = tmp_path / "beauty.png" + monkeypatch.setattr(beauty, "is_beauty_image_fresh", lambda candidate: False) + + downloader = MagicMock(return_value=None) + to_thread = AsyncMock(return_value=None) + monkeypatch.setattr(beauty.asyncio, "to_thread", to_thread) + + result = asyncio.run(beauty.get_beauty_image_path(str(path), downloader)) + + assert result is None + to_thread.assert_awaited_once_with(downloader, beauty.DEFAULT_QUERY, str(path)) + + +def test_download_beauty_image_returns_none_without_api_key(monkeypatch): + monkeypatch.setattr(beauty, "load_dotenv", MagicMock()) + monkeypatch.setattr(beauty.os, "getenv", lambda key: None) + + result = beauty.download_beauty_image() + + assert result is None + + +def test_download_beauty_image_returns_none_when_no_results(monkeypatch): + monkeypatch.setattr(beauty, "load_dotenv", MagicMock()) + monkeypatch.setattr(beauty.os, "getenv", lambda key: "token") + monkeypatch.setattr(beauty, "cerca_immagini_pixabay", lambda query, api_key: []) + + result = beauty.download_beauty_image() + + assert result is None + + +def test_download_beauty_image_returns_output_path_on_success(monkeypatch, tmp_path): + output_path = tmp_path / "beauty.png" + + monkeypatch.setattr(beauty, "load_dotenv", MagicMock()) + monkeypatch.setattr(beauty.os, "getenv", lambda key: "token") + monkeypatch.setattr( + beauty, + "cerca_immagini_pixabay", + lambda query, api_key: ["https://img/1.png", "https://img/2.png"], + ) + monkeypatch.setattr(beauty.random, "choice", lambda items: items[1]) + monkeypatch.setattr(beauty, "scarica_risorsa", lambda url, path: True) + + result = beauty.download_beauty_image(output_path=str(output_path)) + + assert result == str(output_path) + + +def test_download_beauty_image_returns_none_when_resource_download_fails(monkeypatch): + monkeypatch.setattr(beauty, "load_dotenv", MagicMock()) + monkeypatch.setattr(beauty.os, "getenv", lambda key: "token") + monkeypatch.setattr( + beauty, "cerca_immagini_pixabay", lambda query, api_key: ["https://img/1.png"] + ) + monkeypatch.setattr(beauty.random, "choice", lambda items: items[0]) + monkeypatch.setattr(beauty, "scarica_risorsa", lambda url, path: False) + + result = beauty.download_beauty_image() + + assert result is None + + +def test_cerca_immagini_pixabay_returns_urls_on_success(monkeypatch): + response = MagicMock() + response.status_code = 200 + response.json.return_value = {"hits": [{"webformatURL": "https://img/1.png"}]} + monkeypatch.setattr(beauty.requests, "get", lambda *args, **kwargs: response) + + result = beauty.cerca_immagini_pixabay("flowers", "token") + + assert result == ["https://img/1.png"] + + +def test_cerca_immagini_pixabay_returns_empty_list_on_error(monkeypatch): + response = MagicMock() + response.status_code = 500 + monkeypatch.setattr(beauty.requests, "get", lambda *args, **kwargs: response) + + result = beauty.cerca_immagini_pixabay("flowers", "token") + + assert result == [] + + +def test_scarica_risorsa_writes_file_contents(monkeypatch, tmp_path): + output_path = tmp_path / "downloads" / "beauty.png" + + response = MagicMock() + response.__enter__.return_value = response + response.iter_content.return_value = [b"chunk-1", b"", b"chunk-2"] + response.raise_for_status.return_value = None + monkeypatch.setattr(beauty.requests, "get", lambda *args, **kwargs: response) + + result = beauty.scarica_risorsa("https://img/1.png", str(output_path)) + + assert result is True + assert output_path.read_bytes() == b"chunk-1chunk-2" + + +def test_scarica_risorsa_returns_false_on_request_exception(monkeypatch, tmp_path): + output_path = tmp_path / "downloads" / "beauty.png" + + monkeypatch.setattr( + beauty.requests, + "get", + MagicMock(side_effect=requests.RequestException("network error")), + ) + + result = beauty.scarica_risorsa("https://img/1.png", str(output_path)) + + assert result is False + + def _set_file_mtime(path, timestamp: float) -> None: os.utime(path, (timestamp, timestamp)) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index e20253d..c2056cc 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -3,7 +3,7 @@ import pytest from src.buttons import get_main_menu -from src.handlers import about, download, service, start +from src.handlers import about, beauty, download, service, start @pytest.mark.asyncio @@ -170,3 +170,53 @@ async def test_download_no_user(): await download(update, context) update.message.reply_text.assert_not_called() + + +@pytest.mark.asyncio +async def test_beauty_no_message(): + update = MagicMock() + context = MagicMock() + update.message = None + + await beauty(update, context) + + +@pytest.mark.asyncio +async def test_beauty_no_text(): + update = MagicMock() + context = MagicMock() + update.message.text = None + update.message.reply_text = AsyncMock() + + await beauty(update, context) + + update.message.reply_text.assert_not_called() + + +@pytest.mark.asyncio +async def test_beauty_with_extra_args(monkeypatch): + update = MagicMock() + context = MagicMock() + update.message.text = "/beauty extra" + update.message.reply_text = AsyncMock() + + expected_error = "Use /beauty without extra arguments." + monkeypatch.setattr("src.handlers.get_string", lambda user, key: expected_error) + + await beauty(update, context) + + update.message.reply_text.assert_awaited_once_with(expected_error) + + +@pytest.mark.asyncio +async def test_beauty_calls_handle_beauty(monkeypatch): + update = MagicMock() + context = MagicMock() + update.message.text = "/beauty" + + mocked_handle_beauty = AsyncMock() + monkeypatch.setattr("src.handlers.handle_beauty", mocked_handle_beauty) + + await beauty(update, context) + + mocked_handle_beauty.assert_awaited_once_with(update) From 11d8472273ecf8ea2400b884c93219e4ca07d580 Mon Sep 17 00:00:00 2001 From: Marcello Date: Sun, 29 Mar 2026 13:13:18 +0200 Subject: [PATCH 2/2] refactor: move core scripts into /core/ --- src/buttons.py | 4 ++-- src/core/__init__.py | 0 src/{ => core}/beauty.py | 0 src/{ => core}/downloader.py | 0 src/{ => core}/i18n.py | 0 src/handlers.py | 8 ++++---- src/messages.py | 2 +- tests/core/__init__.py | 0 tests/{ => core}/test_beauty.py | 2 +- tests/{ => core}/test_downloader.py | 16 ++++++++-------- tests/{ => core}/test_i18n.py | 20 ++++++++++---------- 11 files changed, 26 insertions(+), 26 deletions(-) create mode 100644 src/core/__init__.py rename src/{ => core}/beauty.py (100%) rename src/{ => core}/downloader.py (100%) rename src/{ => core}/i18n.py (100%) create mode 100644 tests/core/__init__.py rename tests/{ => core}/test_beauty.py (99%) rename tests/{ => core}/test_downloader.py (90%) rename tests/{ => core}/test_i18n.py (84%) diff --git a/src/buttons.py b/src/buttons.py index 2b31082..69c2517 100644 --- a/src/buttons.py +++ b/src/buttons.py @@ -5,8 +5,8 @@ from telegram.error import TelegramError from telegram.ext import CallbackContext, ContextTypes -from src.downloader import get_media, get_media_size -from src.i18n import get_string +from src.core.downloader import get_media, get_media_size +from src.core.i18n import get_string def get_main_menu(user: User) -> InlineKeyboardMarkup: diff --git a/src/core/__init__.py b/src/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/beauty.py b/src/core/beauty.py similarity index 100% rename from src/beauty.py rename to src/core/beauty.py diff --git a/src/downloader.py b/src/core/downloader.py similarity index 100% rename from src/downloader.py rename to src/core/downloader.py diff --git a/src/i18n.py b/src/core/i18n.py similarity index 100% rename from src/i18n.py rename to src/core/i18n.py diff --git a/src/handlers.py b/src/handlers.py index 94b9def..1d8f58d 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -5,9 +5,9 @@ from telegram.ext import ContextTypes import src.messages as message -from src.beauty import handle_beauty from src.buttons import get_main_menu -from src.i18n import get_string, set_user_language +from src.core.beauty import handle_beauty +from src.core.i18n import get_string, set_user_language async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: @@ -15,7 +15,7 @@ async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.message and user: set_user_language(user.id, "en") await update.message.reply_text( - f"{get_string(user,'hello')} {user.first_name} {get_string(user,'compare')}" + f"{get_string(user, 'hello')} {user.first_name} {get_string(user, 'compare')}" ) @@ -53,7 +53,7 @@ async def service(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: services = message.getServiceString(user) await update.message.reply_text( - f"{get_string(user,'hello')} {user.first_name}.\n\n{services}", + f"{get_string(user, 'hello')} {user.first_name}.\n\n{services}", parse_mode="HTML", ) diff --git a/src/messages.py b/src/messages.py index 40400ce..9b70062 100644 --- a/src/messages.py +++ b/src/messages.py @@ -1,6 +1,6 @@ from telegram import User -from src.i18n import get_string +from src.core.i18n import get_string def getAboutString(user: User) -> str: diff --git a/tests/core/__init__.py b/tests/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_beauty.py b/tests/core/test_beauty.py similarity index 99% rename from tests/test_beauty.py rename to tests/core/test_beauty.py index a1a8cab..fe32fd3 100644 --- a/tests/test_beauty.py +++ b/tests/core/test_beauty.py @@ -5,7 +5,7 @@ import requests -from src import beauty +from src.core import beauty def test_is_beauty_image_fresh_returns_false_when_file_does_not_exist(tmp_path): diff --git a/tests/test_downloader.py b/tests/core/test_downloader.py similarity index 90% rename from tests/test_downloader.py rename to tests/core/test_downloader.py index 068e23e..6bdba07 100644 --- a/tests/test_downloader.py +++ b/tests/core/test_downloader.py @@ -3,7 +3,7 @@ import yt_dlp -from src.downloader import DOWNLOAD_DIR, build_ydl_opts, get_media, get_media_size +from src.core.downloader import DOWNLOAD_DIR, build_ydl_opts, get_media, get_media_size # N.B Utilizzo il prefisso di un uid 05adfd95 ... perchè ogni download avrà come prefisso un identificativo univoco. @@ -60,7 +60,7 @@ def test_outtmpl_contains_template_titolo(): ) -@patch("src.downloader.yt_dlp.YoutubeDL") +@patch("src.core.downloader.yt_dlp.YoutubeDL") def test_getMedia_get_filepath(mock_ydl_class): mock_ydl = MagicMock() mock_ydl.extract_info.return_value = {"title": "video", "ext": "mp4"} @@ -71,7 +71,7 @@ def test_getMedia_get_filepath(mock_ydl_class): assert result == "/downloads/video.mp4" -@patch("src.downloader.yt_dlp.YoutubeDL") +@patch("src.core.downloader.yt_dlp.YoutubeDL") def test_getMedia_extract_info(mock_ydl_class): mock_ydl = MagicMock() mock_ydl.extract_info.return_value = {"title": "video", "ext": "mp4"} @@ -84,7 +84,7 @@ def test_getMedia_extract_info(mock_ydl_class): ) -@patch("src.downloader.yt_dlp.YoutubeDL") +@patch("src.core.downloader.yt_dlp.YoutubeDL") def test_getMedia_get_error_on_exception(mock_ydl_class): mock_ydl_class.return_value.__enter__.return_value.extract_info.side_effect = ( yt_dlp.utils.DownloadError("Network error") @@ -94,7 +94,7 @@ def test_getMedia_get_error_on_exception(mock_ydl_class): assert result == "error" -@patch("src.downloader.yt_dlp.YoutubeDL") +@patch("src.core.downloader.yt_dlp.YoutubeDL") def test_get_media_size_returns_zero_on_error(mock_ydl_class): mock_ydl_class.return_value.__enter__.return_value.extract_info.side_effect = ( yt_dlp.utils.DownloadError("errore") @@ -104,7 +104,7 @@ def test_get_media_size_returns_zero_on_error(mock_ydl_class): assert result == 0 -@patch("src.downloader.yt_dlp.YoutubeDL") +@patch("src.core.downloader.yt_dlp.YoutubeDL") def test_get_media_size_returns_correct_size(mock_ydl_class): fileSize = 1024 * 1024 * 10 @@ -118,7 +118,7 @@ def test_get_media_size_returns_correct_size(mock_ydl_class): assert result == fileSize -@patch("src.downloader.yt_dlp.YoutubeDL") +@patch("src.core.downloader.yt_dlp.YoutubeDL") def test_get_media_size_returns_zero_when_filesize_not_int(mock_ydl_class): mock_ydl_class.return_value.__enter__.return_value.extract_info.return_value = { "filesize": None, @@ -129,7 +129,7 @@ def test_get_media_size_returns_zero_when_filesize_not_int(mock_ydl_class): assert result == 0 -@patch("src.downloader.yt_dlp.YoutubeDL") +@patch("src.core.downloader.yt_dlp.YoutubeDL") def test_get_media_size_returns_zero_when_info_is_none(mock_ydl_class): mock_ydl_class.return_value.__enter__.return_value.extract_info.return_value = None diff --git a/tests/test_i18n.py b/tests/core/test_i18n.py similarity index 84% rename from tests/test_i18n.py rename to tests/core/test_i18n.py index 29edcc6..2b0cd21 100644 --- a/tests/test_i18n.py +++ b/tests/core/test_i18n.py @@ -2,7 +2,7 @@ from unittest.mock import MagicMock, mock_open, patch -from src.i18n import ( +from src.core.i18n import ( _load_users_db, _save_users_db, _telegram_id_to_sha256, @@ -46,7 +46,7 @@ def test_load_users_db_invalid_json(mock_file, mock_exists): @patch("builtins.open", new_callable=mock_open) -@patch("src.i18n.json.dump") +@patch("src.core.i18n.json.dump") def test_save_users_db(mock_json_dump, mock_file): # Verifica che la funzione provi a scrivere i dati nel file formattandoli correttamente dummy_data = {"user_hash_1": "en"} @@ -55,8 +55,8 @@ def test_save_users_db(mock_json_dump, mock_file): mock_json_dump.assert_called_once_with(dummy_data, mock_file(), indent=4) -@patch("src.i18n._load_users_db", return_value={}) -@patch("src.i18n._save_users_db") +@patch("src.core.i18n._load_users_db", return_value={}) +@patch("src.core.i18n._save_users_db") def test_set_user_language_new_user(mock_save, mock_load): user_id = 12345 set_user_language(user_id, "it") @@ -65,9 +65,9 @@ def test_set_user_language_new_user(mock_save, mock_load): mock_save.assert_called_once_with({expected_hash: "it"}) -@patch("src.i18n._load_users_db") +@patch("src.core.i18n._load_users_db") @patch.dict( - "src.i18n.translations", {"en": {"hello": "Hello"}, "it": {"hello": "Ciao"}} + "src.core.i18n.translations", {"en": {"hello": "Hello"}, "it": {"hello": "Ciao"}} ) def test_get_string_fallback_english(mock_load): # Se l'utente non è nel DB, deve usare l'inglese di default @@ -80,9 +80,9 @@ def test_get_string_fallback_english(mock_load): assert result == "Hello" -@patch("src.i18n._load_users_db") +@patch("src.core.i18n._load_users_db") @patch.dict( - "src.i18n.translations", {"en": {"hello": "Hello"}, "it": {"hello": "Ciao"}} + "src.core.i18n.translations", {"en": {"hello": "Hello"}, "it": {"hello": "Ciao"}} ) def test_get_string_with_saved_language(mock_load): mock_user = MagicMock() @@ -95,8 +95,8 @@ def test_get_string_with_saved_language(mock_load): assert result == "Ciao" -@patch("src.i18n._load_users_db", return_value={}) -@patch.dict("src.i18n.translations", {"en": {}}) +@patch("src.core.i18n._load_users_db", return_value={}) +@patch.dict("src.core.i18n.translations", {"en": {}}) def test_get_string_missing_key(mock_load): # Se chiediamo una stringa che non esiste nel file JSON, deve restituire la chiave stessa come fallback mock_user = MagicMock()