diff --git a/.env.example b/.env.example index 09c905f..b915148 100644 --- a/.env.example +++ b/.env.example @@ -1,2 +1,3 @@ BOT_TOKEN="INSERT_YOUR_BOT_TOKEN_HERE_MY_FRIEND" -PIXABAY_TOKEN="INSERT_YOUR_PIXABAY_TOKEN_MY_FRIEND" \ No newline at end of file +PIXABAY_TOKEN="INSERT_YOUR_PIXABAY_TOKEN_MY_FRIEND" +HASH_SALT="INSERT_YOUR_SALT_HERE_MY_FRIEND" diff --git a/.gitignore b/.gitignore index 66e30eb..0e8a0d2 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ .venv/ .env .coverage -tmp/ \ No newline at end of file +tmp/ +users.json diff --git a/lang/en.json b/lang/en.json new file mode 100644 index 0000000..41beb97 --- /dev/null +++ b/lang/en.json @@ -0,0 +1,22 @@ +{ + "operation_canceled": "Operation canceled.", + "cancel_button": "❌ Cancel", + "confirm_mp3_download": "You are about to download an mp3", + "confirm_mp4_download": "You are about to download an mp4", + "file_too_large": "❌ File too large for Telegram (max 50MB).", + "loading": "⏳ Download in progress...", + "error_sending_file": "Error sending file!", + "error_removing_file": "Error removing file!", + "download_failed": "Download failed.", + "hello": "Hello", + "compare": "your trusty buddy is here to help you download your media!", + "user": "user.", + "download_params_error": "You need to format your message like this: /download ", + "download_content": "download your content!", + "beauty_params_error": "Please use /beauty without any additional arguments.", + "about_string": "⚡️ Video Downloader Bot\nDownload your favorite videos from YouTube and other services in seconds.\n\nCreated and maintained by the team:\n👨‍💻 Gianmarco [@sean8819]\n👨‍💻 Marce [@Marss08]\n👨‍💻 Enzo [@enzobarba]\n\n❤️ Thank you for choosing our bot!", + "services_string": "🛠 My services\n\n📥 Media Download: Send a link to easily download videos or audio tracks from YouTube and other supported services.\n\n🌍 Multilingual (i18n): Native support for Italian and English.\n\n🌅 Daily Inspiration: Receive the beauty photo of the day for a daily touch of wonder.\n\n", + "lang_format_error": "⚠️ Invalid format. Use: /lang it or /lang en", + "language_not_supported":"⚠️ Language not supported\nUse 'it' for Italian or 'en' for English.", + "lang_updated":"✅ Language successfully updated" +} \ No newline at end of file diff --git a/lang/it.json b/lang/it.json new file mode 100644 index 0000000..9f407cc --- /dev/null +++ b/lang/it.json @@ -0,0 +1,22 @@ +{ + "operation_canceled": "Operazione annullata.", + "cancel_button": "❌ Annulla", + "confirm_mp3_download": "Scaricherai un mp3", + "confirm_mp4_download": "Scaricherai un mp4", + "file_too_large":"❌ File troppo grande per Telegram (max 50MB).", + "loading":"⏳ Download in corso...", + "error_sending_file":"Errore invio file!", + "error_removing_file":"Errore eliminazione file!", + "download_failed":"Download fallito.", + "hello":"Ciao", + "compare":"il tuo compare di fiducia ti aiuta a scaricare i tuoi media!", + "user":"utente.", + "download_params_error":"Devi fornire un messaggio così formato /download ", + "download_content":"scarica il tuo contenuto!", + "beauty_params_error":"Usa solo /beauty senza altri argomenti.", + "about_string":"⚡️ Video Downloader Bot\nScarica i tuoi video preferiti da YouTube e altri servizi in pochi secondi.\n\nCreato e mantenuto dal team:\n👨‍💻 Gianmarco [@sean8819]\n👨‍💻 Marce [@Marss08]\n👨‍💻 Enzo [@enzobarba]\n\n❤️ Grazie per aver scelto il nostro bot!", + "services_string":"🛠 I miei servizi\n\n📥 Download Media: Invia un link per scaricare facilmente video o tracce audio da YouTube e altri servizi supportati.\n\n🌍 Multilingua (i18n): Supporto nativo per lingua italiana e inglese.\n\n🌅 Ispirazione Quotidiana: Ricevi la foto della bellezza del giorno per un tocco di meraviglia quotidiana.\n\n", + "lang_format_error": "⚠️ Comando non valido. Usa: /lang it o /lang en", + "language_not_supported":"⚠️ Lingua non supportata.\nUsa 'it' per Italiano o 'en' per Inglese.", + "lang_updated":"✅ Lingua cambiata con successo" +} \ No newline at end of file diff --git a/main.py b/main.py index 5b16f8f..bd77bb8 100644 --- a/main.py +++ b/main.py @@ -11,7 +11,7 @@ ) from src.buttons import handle_buttons, handle_resolution -from src.handlers import about, beauty, download, service, start +from src.handlers import about, beauty, download, lang, service, start from src.logo import print_logo @@ -28,13 +28,14 @@ async def post_init(application): BotCommand("service", "Servizi disponibili"), BotCommand("beauty", "Bellezza del giorno"), BotCommand("about", "Info sul progetto"), + BotCommand("lang", "Cambia lingua del bot"), ] ) def main(): - load_dotenv() + load_dotenv(override=True) logging.basicConfig(level=logging.ERROR) @@ -53,6 +54,7 @@ def main(): app.add_handler(CommandHandler("about", about)) app.add_handler(CommandHandler("service", service)) app.add_handler(CommandHandler("beauty", beauty)) + app.add_handler(CommandHandler("lang", lang)) app.add_handler( CallbackQueryHandler(handle_buttons, pattern="^(audio|video|annulla)$") ) diff --git a/pyproject.toml b/pyproject.toml index 17e4e37..8d3cd8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ line_length = 120 ignore_missing_imports = true [tool.coverage.run] -omit = ["src/logo.py", "src/messages.py", "__init__.py"] +omit = ["src/logo.py", "__init__.py"] [tool.pytest.ini_options] asyncio_mode = "auto" \ No newline at end of file diff --git a/src/buttons.py b/src/buttons.py index 91a029c..2b31082 100644 --- a/src/buttons.py +++ b/src/buttons.py @@ -1,14 +1,15 @@ import asyncio import os -from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update +from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update, User 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 -def get_main_menu() -> InlineKeyboardMarkup: +def get_main_menu(user: User) -> InlineKeyboardMarkup: return InlineKeyboardMarkup( [ [ @@ -16,13 +17,15 @@ def get_main_menu() -> InlineKeyboardMarkup: InlineKeyboardButton("📹 Video", callback_data="video"), ], [ - InlineKeyboardButton("❌ Annulla", callback_data="annulla"), + InlineKeyboardButton( + get_string(user, "cancel_button"), callback_data="annulla" + ), ], ] ) -def get_resolution_video() -> InlineKeyboardMarkup: +def get_resolution_video(user: User) -> InlineKeyboardMarkup: return InlineKeyboardMarkup( [ [ @@ -31,7 +34,9 @@ def get_resolution_video() -> InlineKeyboardMarkup: InlineKeyboardButton("720p", callback_data="720"), ], [ - InlineKeyboardButton("❌ Annulla", callback_data="annulla"), + InlineKeyboardButton( + get_string(user, "cancel_button"), callback_data="annulla" + ), ], ] ) @@ -47,7 +52,9 @@ async def handle_resolution(update: Update, context: ContextTypes.DEFAULT_TYPE) if query.data == "annulla": if context.user_data is not None: context.user_data.clear() - await query.edit_message_text("Operazione annullata.") + await query.edit_message_text( + get_string(update.effective_user, "operation_canceled") + ) return elif query.data == "360": @@ -74,20 +81,28 @@ async def handle_buttons(update: Update, context: ContextTypes.DEFAULT_TYPE) -> if query.data == "annulla": context.user_data.clear() - await query.edit_message_text("Operazione annullata.") + await query.edit_message_text( + get_string(update.effective_user, "operation_canceled") + ) return if query.data == "audio": context.user_data["download_audio"] = True context.user_data["download_video"] = False await handle_download(update, context) - await query.edit_message_text("Scaricherai un mp3") + await query.edit_message_text( + get_string(update.effective_user, "confirm_mp3_download") + ) elif query.data == "video": context.user_data["download_video"] = True context.user_data["download_audio"] = False + user = update.effective_user + if user is None: + return await query.edit_message_text( - "Scaricherai un mp4", reply_markup=get_resolution_video() + get_string(update.effective_user, "confirm_mp4_download"), + reply_markup=get_resolution_video(user), ) @@ -119,11 +134,13 @@ async def handle_download(update: Update, context: CallbackContext) -> None: if size > telegram_max_upload_file: await update.callback_query.edit_message_text( - "❌ File troppo grande per Telegram (max 50MB)." + get_string(update.effective_user, "file_too_large") ) return - await update.callback_query.edit_message_text("⏳ Download in corso...") + await update.callback_query.edit_message_text( + get_string(update.effective_user, "loading") + ) file_path = await asyncio.to_thread( get_media, context.user_data["url"], resolution, media_type @@ -156,7 +173,7 @@ async def handle_download(update: Update, context: CallbackContext) -> None: print(e) await context.bot.send_message( chat_id=chat_id, - text="Errore invio file!", + text=get_string(update.effective_user, "error_sending_file"), ) finally: if os.path.exists(file_path): @@ -165,7 +182,12 @@ async def handle_download(update: Update, context: CallbackContext) -> None: except OSError: await context.bot.send_message( chat_id=chat_id, - text="Errore eliminazione file!", + text=get_string( + update.effective_user, "error_removing_file" + ), ) else: - await context.bot.send_message(chat_id=chat_id, text="Download fallito.") + await context.bot.send_message( + chat_id=chat_id, + text=get_string(update.effective_user, "download_failed"), + ) diff --git a/src/handlers.py b/src/handlers.py index acd2275..94b9def 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -1,32 +1,41 @@ # pylint: disable=unused-argument - import validators from telegram import Update 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 async def start(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: - if update.message and update.effective_user: + user = update.effective_user + if update.message and user: + set_user_language(user.id, "en") await update.message.reply_text( - f"Ciao, {update.effective_user.first_name} il tuo compare di fiducia ti aiuta a scaricare i tuoi media!" + f"{get_string(user,'hello')} {user.first_name} {get_string(user,'compare')}" ) async def about(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if update.message: - user = update.effective_user if not user: - await update.message.reply_text("Ciao, utente informazioni sul progetto.") + await update.message.reply_text( + "Hello user.\n\nProject info.", + parse_mode="HTML", + disable_web_page_preview=True, + ) return + info = message.getAboutString(user) await update.message.reply_text( - f"Ciao, {user.first_name} informazioni sul progetto." + f"{get_string(user, 'hello')} {user.first_name}\n\n{info}", + parse_mode="HTML", + disable_web_page_preview=True, ) @@ -36,42 +45,44 @@ async def service(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: user = update.effective_user if not user: - await update.message.reply_text("Ciao, utente stato dei servizi.") + await update.message.reply_text( + "Hello user.\n\nServices info.", + parse_mode="HTML", + ) return - await update.message.reply_text(f"Ciao, {user.first_name} stato dei servizi.") + services = message.getServiceString(user) + await update.message.reply_text( + f"{get_string(user,'hello')} {user.first_name}.\n\n{services}", + parse_mode="HTML", + ) async def download(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message or not update.message.text: return + user = update.effective_user + if not user: + return + parts = update.message.text.split(" ", 1) if len(parts) != 2: - await update.message.reply_text( - "Devi fornire un messaggio così formato /download " - ) + await update.message.reply_text(get_string(user, "download_params_error")) return url_video = parts[1] - # Verifico se è un url ben formato. if validators.url(url_video): - if context.user_data is None: return context.user_data["url"] = url_video - user = update.effective_user - - if not user: - return - await update.message.reply_text( - f"{user.first_name}, scarica il tuo contenuto!", - reply_markup=get_main_menu(), + f"{user.first_name}, {get_string(user, 'download_content')}", + reply_markup=get_main_menu(user), ) @@ -79,11 +90,41 @@ async def beauty(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: if not update.message or not update.message.text: return + user = update.effective_user + raw_text = update.message.text.strip() command = raw_text.split()[0] if raw_text != command: - await update.message.reply_text("Usa solo /beauty senza altri argomenti.") + await update.message.reply_text(get_string(user, "beauty_params_error")) return await handle_beauty(update) + + +async def lang(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None: + if not update.message or not update.message.text: + return + + user = update.effective_user + if not user: + return + + parts = update.message.text.split(" ", 1) + + if len(parts) != 2: + error_message = get_string(user, "lang_format_error") + await update.message.reply_text(error_message) + return + + requested_lang = parts[1].strip().lower() + + if requested_lang not in ["it", "en"]: + await update.message.reply_text(get_string(user, "language_not_supported")) + return + + set_user_language(user.id, requested_lang) + + confirmation_message = get_string(user, "lang_updated") + + await update.message.reply_text(confirmation_message) diff --git a/src/i18n.py b/src/i18n.py new file mode 100644 index 0000000..017f644 --- /dev/null +++ b/src/i18n.py @@ -0,0 +1,55 @@ +import hashlib +import json +import os + +USERS_DB_FILE = "users.json" +SALT = os.getenv("HASH_SALT", "fallback_salt") + +supported_languages = ["it", "en"] +translations = {} + +for lan in supported_languages: + with open(f"lang/{lan}.json", "r", encoding="utf-8") as file: + translations[lan] = json.load(file) + + +def _telegram_id_to_sha256(user_id: int) -> str: + string_to_hash = f"{user_id}{SALT}" + return hashlib.sha256(string_to_hash.encode("utf-8")).hexdigest() + + +def _load_users_db() -> dict: + if not os.path.exists(USERS_DB_FILE): + return {} + try: + with open(USERS_DB_FILE, "r", encoding="utf-8") as f: + return json.load(f) + except json.JSONDecodeError: + return {} + + +def _save_users_db(data: dict): + with open(USERS_DB_FILE, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4) + + +def set_user_language(user_id: int, lang: str = "en"): + + user_hash = _telegram_id_to_sha256(user_id) + users = _load_users_db() + + users[user_hash] = lang + + _save_users_db(users) + + +def get_string(telegram_user, key: str) -> str: + + user_hash = _telegram_id_to_sha256(telegram_user.id) + users = _load_users_db() + + lang = users.get(user_hash, "en") + + dictionary = translations.get(lang, translations["en"]) + + return dictionary.get(key, key) diff --git a/src/messages.py b/src/messages.py index 1657eac..40400ce 100644 --- a/src/messages.py +++ b/src/messages.py @@ -1,6 +1,11 @@ -def getAboutString() -> str: - return "Siamo una squadra fortissimi!" +from telegram import User +from src.i18n import get_string -def getServiceString() -> str: - return "Download video ... ok!" + +def getAboutString(user: User) -> str: + return get_string(user, "about_string") + + +def getServiceString(user: User) -> str: + return get_string(user, "services_string") diff --git a/tests/test_buttons.py b/tests/test_buttons.py index 95f3e90..8ee16f2 100644 --- a/tests/test_buttons.py +++ b/tests/test_buttons.py @@ -1,4 +1,4 @@ -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, MagicMock, mock_open, patch import pytest from telegram import InlineKeyboardMarkup @@ -14,24 +14,27 @@ def test_get_main_menu(): - menu = get_main_menu() + mock_user = MagicMock() + mock_user.id = 12345 + menu = get_main_menu(mock_user) assert isinstance(menu, InlineKeyboardMarkup) assert len(menu.inline_keyboard) == 2 assert menu.inline_keyboard[0][0].text == "🎶 Audio" assert menu.inline_keyboard[0][1].text == "📹 Video" - assert menu.inline_keyboard[1][0].text == "❌ Annulla" + assert menu.inline_keyboard[1][0].text == "❌ Cancel" def test_get_resolution_video(): - menu = get_resolution_video() + mock_user = MagicMock() + menu = get_resolution_video(mock_user) assert isinstance(menu, InlineKeyboardMarkup) assert len(menu.inline_keyboard) == 2 assert menu.inline_keyboard[0][0].text == "360p" assert menu.inline_keyboard[0][1].text == "480p" assert menu.inline_keyboard[0][2].text == "720p" - assert menu.inline_keyboard[1][0].text == "❌ Annulla" + assert menu.inline_keyboard[1][0].text == "❌ Cancel" @pytest.mark.asyncio @@ -48,7 +51,7 @@ async def test_handle_resolution_annulla(): assert context.user_data == {} update.callback_query.edit_message_text.assert_called_once_with( - "Operazione annullata." + "Operation canceled." ) @@ -132,7 +135,7 @@ async def test_handle_buttons_annulla(): assert context.user_data == {} update.callback_query.edit_message_text.assert_called_once_with( - "Operazione annullata." + "Operation canceled." ) @@ -170,7 +173,8 @@ async def test_handle_buttons_video(): assert context.user_data["download_video"] assert not context.user_data["download_audio"] update.callback_query.edit_message_text.assert_called_once_with( - "Scaricherai un mp4", reply_markup=get_resolution_video() + "You are about to download an mp4", + reply_markup=get_resolution_video(update.effective_user), ) @@ -228,7 +232,7 @@ async def test_handle_download_file_too_large(): with patch("src.buttons.get_media_size", return_value=100 * 1024 * 1024): await handle_download(update, context) update.callback_query.edit_message_text.assert_called_once_with( - "❌ File troppo grande per Telegram (max 50MB)." + "❌ File too large for Telegram (max 50MB)." ) @@ -248,7 +252,7 @@ async def test_handle_download_audio_success(): with patch("src.buttons.get_media_size", return_value=10 * 1024 * 1024), patch( "src.buttons.get_media", return_value="/tmp/audio.mp3" ), patch("os.path.exists", return_value=True), patch("os.remove"), patch( - "builtins.open", MagicMock() + "builtins.open", mock_open(read_data="{}") ): await handle_download(update, context) context.bot.send_audio.assert_called_once() @@ -271,7 +275,7 @@ async def test_handle_download_video_success(): with patch("src.buttons.get_media_size", return_value=10 * 1024 * 1024), patch( "src.buttons.get_media", return_value="/tmp/video.mp4" ), patch("os.path.exists", return_value=True), patch("os.remove"), patch( - "builtins.open", MagicMock() + "builtins.open", mock_open(read_data="{}") ): await handle_download(update, context) context.bot.send_video.assert_called_once() @@ -292,10 +296,12 @@ async def test_handle_download_failed(): with patch("src.buttons.get_media_size", return_value=10 * 1024 * 1024), patch( "src.buttons.get_media", return_value="error" - ), patch("os.path.exists", return_value=False): + ), patch("os.path.exists", return_value=False), patch( + "builtins.open", mock_open(read_data="{}") + ): await handle_download(update, context) context.bot.send_message.assert_called_once_with( - chat_id=update.callback_query.message.chat.id, text="Download fallito." + chat_id=update.callback_query.message.chat.id, text="Download failed." ) @@ -317,11 +323,11 @@ async def test_handle_download_telegram_error(): with patch("src.buttons.get_media_size", return_value=10 * 1024 * 1024), patch( "src.buttons.get_media", return_value="/tmp/audio.mp3" ), patch("os.path.exists", return_value=True), patch("os.remove"), patch( - "builtins.open", MagicMock() + "builtins.open", mock_open(read_data="{}") ): await handle_download(update, context) context.bot.send_message.assert_called_once_with( - chat_id=update.callback_query.message.chat.id, text="Errore invio file!" + chat_id=update.callback_query.message.chat.id, text="Error sending file!" ) @@ -344,10 +350,10 @@ async def test_handle_download_os_error_on_remove(): ), patch("os.path.exists", return_value=True), patch( "os.remove", side_effect=OSError("errore") ), patch( - "builtins.open", MagicMock() + "builtins.open", mock_open(read_data="{}") ): await handle_download(update, context) context.bot.send_message.assert_called_once_with( chat_id=update.callback_query.message.chat.id, - text="Errore eliminazione file!", + text="Error removing file!", ) diff --git a/tests/test_downloader.py b/tests/test_downloader.py index a4fe29f..068e23e 100644 --- a/tests/test_downloader.py +++ b/tests/test_downloader.py @@ -55,7 +55,7 @@ def test_outtmpl_contains_download_dir(): def test_outtmpl_contains_template_titolo(): opts = build_ydl_opts(720, "mp4", "05adfd95 ...") assert ( - os.path.join(DOWNLOAD_DIR, f"{"05adfd95 ..."}.%(title)s.%(ext)s") + os.path.join(DOWNLOAD_DIR, f'{"05adfd95 ..."}.%(title)s.%(ext)s') in opts["outtmpl"] ) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index f852b42..e20253d 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -17,7 +17,7 @@ async def test_start(): await start(update, context) update.message.reply_text.assert_called_once_with( - "Ciao, Erika il tuo compare di fiducia ti aiuta a scaricare i tuoi media!" + "Hello Erika your trusty buddy is here to help you download your media!" ) @@ -27,12 +27,26 @@ async def test_about(): context = MagicMock() update.message.reply_text = AsyncMock() - update.effective_user.first_name = "Erika" + + mock_user = MagicMock() + mock_user.first_name = "Erika" + update.effective_user = mock_user await about(update, context) + expected_text = ( + "Hello Erika\n\n" + "⚡️ Video Downloader Bot\n" + "Download your favorite videos from YouTube and other services in seconds.\n\n" + "Created and maintained by the team:\n" + "👨‍💻 Gianmarco [@sean8819]\n" + "👨‍💻 Marce [@Marss08]\n" + "👨‍💻 Enzo [@enzobarba]\n\n" + "❤️ Thank you for choosing our bot!" + ) + update.message.reply_text.assert_called_once_with( - "Ciao, Erika informazioni sul progetto." + expected_text, parse_mode="HTML", disable_web_page_preview=True ) @@ -46,7 +60,15 @@ async def test_service(): await service(update, context) - update.message.reply_text.assert_called_once_with("Ciao, Erika stato dei servizi.") + expected_text = ( + "Hello Erika.\n\n" + "🛠 My services\n\n" + "📥 Media Download: Send a link to easily download videos or audio tracks from YouTube and other supported services.\n\n" + "🌍 Multilingual (i18n): Native support for Italian and English.\n\n" + "🌅 Daily Inspiration: Receive the beauty photo of the day for a daily touch of wonder.\n\n" + ) + + update.message.reply_text.assert_called_once_with(expected_text, parse_mode="HTML") @pytest.mark.asyncio @@ -69,7 +91,7 @@ async def test_download_no_url(): await download(update, context) update.message.reply_text.assert_called_once_with( - "Devi fornire un messaggio così formato /download " + "You need to format your message like this: /download " ) @@ -100,7 +122,8 @@ async def test_download_valid_url(): assert context.user_data["url"] == "https://www.youtube.com/watch?v=xxx" update.message.reply_text.assert_called_once_with( - "Erika, scarica il tuo contenuto!", reply_markup=get_main_menu() + "Erika, download your content!", + reply_markup=get_main_menu(update.effective_user), ) @@ -115,7 +138,7 @@ async def test_about_no_user(): await about(update, context) update.message.reply_text.assert_called_once_with( - "Ciao, utente informazioni sul progetto." + "Hello user.\n\nProject info.", parse_mode="HTML", disable_web_page_preview=True ) @@ -129,7 +152,9 @@ async def test_service_no_user(): await service(update, context) - update.message.reply_text.assert_called_once_with("Ciao, utente stato dei servizi.") + update.message.reply_text.assert_called_once_with( + "Hello user.\n\nServices info.", parse_mode="HTML" + ) @pytest.mark.asyncio diff --git a/tests/test_i18n.py b/tests/test_i18n.py new file mode 100644 index 0000000..29edcc6 --- /dev/null +++ b/tests/test_i18n.py @@ -0,0 +1,106 @@ +# pylint: disable=unused-argument + +from unittest.mock import MagicMock, mock_open, patch + +from src.i18n import ( + _load_users_db, + _save_users_db, + _telegram_id_to_sha256, + get_string, + set_user_language, +) + + +def test_telegram_id_to_sha256_is_deterministic(): + hash1 = _telegram_id_to_sha256(123456) + hash2 = _telegram_id_to_sha256(123456) + assert hash1 == hash2 + + +def test_telegram_id_to_sha256_different_users(): + hash1 = _telegram_id_to_sha256(123456) + hash2 = _telegram_id_to_sha256(789012) + assert hash1 != hash2 + + +@patch("os.path.exists", return_value=False) +def test_load_users_db_file_not_exists(mock_exists): + result = _load_users_db() + assert result == {} + + +@patch("os.path.exists", return_value=True) +@patch("builtins.open", new_callable=mock_open, read_data='{"user_hash_1": "it"}') +def test_load_users_db_valid_json(mock_file, mock_exists): + # Se il file esiste ed è un JSON valido, deve restituire il dizionario + result = _load_users_db() + assert result == {"user_hash_1": "it"} + + +@patch("os.path.exists", return_value=True) +@patch("builtins.open", new_callable=mock_open, read_data="invalid_json") +def test_load_users_db_invalid_json(mock_file, mock_exists): + # Se il JSON è corrotto, per evitare crash deve restituire un dizionario vuoto + result = _load_users_db() + assert result == {} + + +@patch("builtins.open", new_callable=mock_open) +@patch("src.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"} + _save_users_db(dummy_data) + + 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") +def test_set_user_language_new_user(mock_save, mock_load): + user_id = 12345 + set_user_language(user_id, "it") + + expected_hash = _telegram_id_to_sha256(user_id) + mock_save.assert_called_once_with({expected_hash: "it"}) + + +@patch("src.i18n._load_users_db") +@patch.dict( + "src.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 + mock_load.return_value = {} + + mock_user = MagicMock() + mock_user.id = 12345 + + result = get_string(mock_user, "hello") + assert result == "Hello" + + +@patch("src.i18n._load_users_db") +@patch.dict( + "src.i18n.translations", {"en": {"hello": "Hello"}, "it": {"hello": "Ciao"}} +) +def test_get_string_with_saved_language(mock_load): + mock_user = MagicMock() + mock_user.id = 12345 + user_hash = _telegram_id_to_sha256(mock_user.id) + + mock_load.return_value = {user_hash: "it"} + + result = get_string(mock_user, "hello") + assert result == "Ciao" + + +@patch("src.i18n._load_users_db", return_value={}) +@patch.dict("src.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() + mock_user.id = 12345 + + result = get_string(mock_user, "chiave_inesistente") + assert result == "chiave_inesistente" diff --git a/tests/test_messages.py b/tests/test_messages.py new file mode 100644 index 0000000..e705bce --- /dev/null +++ b/tests/test_messages.py @@ -0,0 +1,27 @@ +from unittest.mock import MagicMock, patch + +from telegram import User + +from src.messages import getAboutString, getServiceString + + +@patch("src.messages.get_string") +def test_getAboutString(mock_get_string): + mock_user = MagicMock(spec=User) + mock_get_string.return_value = "Testo About" + + result = getAboutString(mock_user) + + mock_get_string.assert_called_once_with(mock_user, "about_string") + assert result == "Testo About" + + +@patch("src.messages.get_string") +def test_getServiceString(mock_get_string): + mock_user = MagicMock(spec=User) + mock_get_string.return_value = "Testo Servizi" + + result = getServiceString(mock_user) + + mock_get_string.assert_called_once_with(mock_user, "services_string") + assert result == "Testo Servizi"