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/core/test_beauty.py b/tests/core/test_beauty.py new file mode 100644 index 0000000..fe32fd3 --- /dev/null +++ b/tests/core/test_beauty.py @@ -0,0 +1,231 @@ +import asyncio +import os +from datetime import date, datetime, timedelta +from unittest.mock import AsyncMock, MagicMock + +import requests + +from src.core import beauty + + +def test_is_beauty_image_fresh_returns_false_when_file_does_not_exist(tmp_path): + path = tmp_path / "missing.png" + + assert beauty.is_beauty_image_fresh(path, today=date(2026, 3, 28)) is False + + +def test_is_beauty_image_fresh_returns_true_for_file_from_same_day(tmp_path): + path = tmp_path / "beauty.png" + path.write_bytes(b"image") + + today = date.today() + _set_file_mtime(path, datetime.combine(today, datetime.min.time()).timestamp()) + + assert beauty.is_beauty_image_fresh(path, today=today) is True + + +def test_is_beauty_image_fresh_returns_false_for_file_from_previous_day(tmp_path): + path = tmp_path / "beauty.png" + path.write_bytes(b"image") + + today = date.today() + yesterday = today - timedelta(days=1) + _set_file_mtime(path, datetime.combine(yesterday, datetime.min.time()).timestamp()) + + assert beauty.is_beauty_image_fresh(path, today=today) is False + + +def test_get_beauty_image_path_returns_cached_file_without_downloading( + tmp_path, monkeypatch +): + path = tmp_path / "beauty.png" + path.write_bytes(b"cached") + monkeypatch.setattr( + beauty, "is_beauty_image_fresh", lambda candidate: candidate == path + ) + + downloader = MagicMock() + to_thread = AsyncMock() + monkeypatch.setattr(beauty.asyncio, "to_thread", to_thread) + + result = asyncio.run(beauty.get_beauty_image_path(str(path), downloader)) + + assert result == path + downloader.assert_not_called() + to_thread.assert_not_awaited() + + +def test_get_beauty_image_path_downloads_stale_file_in_thread(tmp_path, monkeypatch): + path = tmp_path / "beauty.png" + monkeypatch.setattr(beauty, "is_beauty_image_fresh", lambda candidate: False) + + downloader = MagicMock(return_value=str(path)) + to_thread = AsyncMock(return_value=str(path)) + monkeypatch.setattr(beauty.asyncio, "to_thread", to_thread) + + result = asyncio.run(beauty.get_beauty_image_path(str(path), downloader)) + + assert result == path + to_thread.assert_awaited_once_with(downloader, beauty.DEFAULT_QUERY, str(path)) + + +def test_handle_beauty_replies_with_photo(monkeypatch, tmp_path): + path = tmp_path / "beauty.png" + path.write_bytes(b"image-content") + + message = MagicMock() + message.reply_photo = AsyncMock() + update = MagicMock(message=message) + + get_path = AsyncMock(return_value=path) + monkeypatch.setattr(beauty, "get_beauty_image_path", get_path) + + asyncio.run(beauty.handle_beauty(update)) + + message.reply_photo.assert_awaited_once() + sent_photo = message.reply_photo.await_args.kwargs["photo"] + assert sent_photo.name == str(path) + + +def test_handle_beauty_returns_when_download_fails(monkeypatch): + message = MagicMock() + message.reply_photo = AsyncMock() + update = MagicMock(message=message) + + monkeypatch.setattr(beauty, "get_beauty_image_path", AsyncMock(return_value=None)) + + asyncio.run(beauty.handle_beauty(update)) + + 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_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() diff --git a/tests/test_beauty.py b/tests/test_beauty.py deleted file mode 100644 index 85cdcce..0000000 --- a/tests/test_beauty.py +++ /dev/null @@ -1,101 +0,0 @@ -import asyncio -import os -from datetime import date, datetime, timedelta -from unittest.mock import AsyncMock, MagicMock - -from src import beauty - - -def test_is_beauty_image_fresh_returns_false_when_file_does_not_exist(tmp_path): - path = tmp_path / "missing.png" - - assert beauty.is_beauty_image_fresh(path, today=date(2026, 3, 28)) is False - - -def test_is_beauty_image_fresh_returns_true_for_file_from_same_day(tmp_path): - path = tmp_path / "beauty.png" - path.write_bytes(b"image") - - today = date.today() - _set_file_mtime(path, datetime.combine(today, datetime.min.time()).timestamp()) - - assert beauty.is_beauty_image_fresh(path, today=today) is True - - -def test_is_beauty_image_fresh_returns_false_for_file_from_previous_day(tmp_path): - path = tmp_path / "beauty.png" - path.write_bytes(b"image") - - today = date.today() - yesterday = today - timedelta(days=1) - _set_file_mtime(path, datetime.combine(yesterday, datetime.min.time()).timestamp()) - - assert beauty.is_beauty_image_fresh(path, today=today) is False - - -def test_get_beauty_image_path_returns_cached_file_without_downloading( - tmp_path, monkeypatch -): - path = tmp_path / "beauty.png" - path.write_bytes(b"cached") - monkeypatch.setattr( - beauty, "is_beauty_image_fresh", lambda candidate: candidate == path - ) - - downloader = MagicMock() - to_thread = AsyncMock() - monkeypatch.setattr(beauty.asyncio, "to_thread", to_thread) - - result = asyncio.run(beauty.get_beauty_image_path(str(path), downloader)) - - assert result == path - downloader.assert_not_called() - to_thread.assert_not_awaited() - - -def test_get_beauty_image_path_downloads_stale_file_in_thread(tmp_path, monkeypatch): - path = tmp_path / "beauty.png" - monkeypatch.setattr(beauty, "is_beauty_image_fresh", lambda candidate: False) - - downloader = MagicMock(return_value=str(path)) - to_thread = AsyncMock(return_value=str(path)) - monkeypatch.setattr(beauty.asyncio, "to_thread", to_thread) - - result = asyncio.run(beauty.get_beauty_image_path(str(path), downloader)) - - assert result == path - to_thread.assert_awaited_once_with(downloader, beauty.DEFAULT_QUERY, str(path)) - - -def test_handle_beauty_replies_with_photo(monkeypatch, tmp_path): - path = tmp_path / "beauty.png" - path.write_bytes(b"image-content") - - message = MagicMock() - message.reply_photo = AsyncMock() - update = MagicMock(message=message) - - get_path = AsyncMock(return_value=path) - monkeypatch.setattr(beauty, "get_beauty_image_path", get_path) - - asyncio.run(beauty.handle_beauty(update)) - - message.reply_photo.assert_awaited_once() - sent_photo = message.reply_photo.await_args.kwargs["photo"] - assert sent_photo.name == str(path) - - -def test_handle_beauty_returns_when_download_fails(monkeypatch): - message = MagicMock() - message.reply_photo = AsyncMock() - update = MagicMock(message=message) - - monkeypatch.setattr(beauty, "get_beauty_image_path", AsyncMock(return_value=None)) - - asyncio.run(beauty.handle_beauty(update)) - - message.reply_photo.assert_not_awaited() - - -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)