diff --git a/requirements_dev.txt b/requirements_dev.txt index e4af0c5..0049079 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,11 +1,11 @@ +black +flake8 +isort +mypy +pylint pytest +pytest-asyncio pytest-mock pytest-cov -pylint -mypy -flake8 -black -isort -types-yt-dlp types-requests -pytest-asyncio \ No newline at end of file +types-yt-dlp \ No newline at end of file diff --git a/src/beauty.py b/src/beauty.py index b6dbc8c..6fa8a09 100644 --- a/src/beauty.py +++ b/src/beauty.py @@ -1,4 +1,4 @@ -import datetime +import asyncio import os import random from datetime import date @@ -10,35 +10,54 @@ DEFAULT_QUERY = "woman,models,real,beauty,pose" DEFAULT_OUTPUT_PATH = "tmp/downloads/beauty_of_the_day.png" +_BEAUTY_CACHE_LOCK = asyncio.Lock() async def handle_beauty(update: Update): - path = Path(DEFAULT_OUTPUT_PATH) + message = update.message + if message is None: + return + path = await get_beauty_image_path() + if path is None: + print("Errore nel download dell'immagine") + return + + with path.open("rb") as image_file: + await message.reply_photo(photo=image_file) + + +def is_beauty_image_fresh(path: Path, today: date | None = None) -> bool: if not path.exists(): - result = download_beauty_image() - if result is None: - print("Errore nel download dell'immagine") - return - path = Path(result) - print("non esiste ancora, la scarico") + return False + + if today is None: + today = date.today() - elif datetime.datetime.fromtimestamp(path.stat().st_ctime).day == date.today().day: - print("già scaricata, invio l'immagine") + return date.fromtimestamp(path.stat().st_mtime) == today - else: - result = download_beauty_image() + +async def get_beauty_image_path( + output_path: str = DEFAULT_OUTPUT_PATH, + downloader=None, +) -> Path | None: + if downloader is None: + downloader = download_beauty_image + + async with _BEAUTY_CACHE_LOCK: + path = Path(output_path) + + if is_beauty_image_fresh(path): + print("già scaricata, invio l'immagine") + return path + + result = await asyncio.to_thread(downloader, DEFAULT_QUERY, output_path) if result is None: - print("Errore nel download dell'immagine") - return - path = Path(result) - print("scarico l'immagine e la invio") + return None - with path.open("rb") as image_file: - message = update.message - if message is None or message.text is None: - return - await message.reply_photo(photo=image_file) + path = Path(result) + print("immagine aggiornata, invio l'immagine") + return path def download_beauty_image( diff --git a/src/handlers.py b/src/handlers.py index 3a7ecb6..acd2275 100644 --- a/src/handlers.py +++ b/src/handlers.py @@ -1,5 +1,6 @@ # pylint: disable=unused-argument + import validators from telegram import Update from telegram.ext import ContextTypes diff --git a/tests/test_beauty.py b/tests/test_beauty.py new file mode 100644 index 0000000..85cdcce --- /dev/null +++ b/tests/test_beauty.py @@ -0,0 +1,101 @@ +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))