diff --git a/tests/test_fetch_weather.py b/tests/test_fetch_weather.py index 49c4332..19fd810 100644 --- a/tests/test_fetch_weather.py +++ b/tests/test_fetch_weather.py @@ -2,12 +2,17 @@ import pytest import requests from requests.exceptions import ConnectionError, Timeout, SSLError from unittest.mock import patch, MagicMock -from utils.pogoda import fetch_weather +from utils.pogoda import fetch_weather, clear_weather_cache class TestFetchWeather: """Тесты функции fetch_weather() — Яндекс Погода API с retry-логикой.""" + @pytest.fixture(autouse=True) + def _clear_cache(self) -> None: + """Очистить кэш перед каждым тестом.""" + clear_weather_cache() + @pytest.fixture(autouse=True) def _mock_api_key(self) -> None: """Мокаем _get_api_key для всех тестов в классе.""" @@ -299,3 +304,143 @@ class TestFetchWeather: result = await fetch_weather(max_retries=1) assert result is None + + +class TestWeatherCache: + """Тесты кэширования погоды.""" + + @pytest.fixture(autouse=True) + def _clear_cache(self) -> None: + clear_weather_cache() + + @pytest.fixture(autouse=True) + def _mock_api_key(self) -> None: + with patch("utils.pogoda._get_api_key", return_value="test-key"): + yield + + @patch("utils.pogoda._session.get") + async def test_cache_returns_cached_data(self, mock_get) -> None: + """Второй запрос в течение TTL возвращает кэш без вызова API.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "fact": { + "temp": 20, + "feels_like": 18, + "condition": "clear", + "wind_speed": 3, + "wind_gust": 5, + "wind_dir": "s", + "humidity": 50, + "pressure_mm": 760.0, + "pressure_pa": 1013, + } + } + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + result1 = await fetch_weather() + result2 = await fetch_weather() + + assert result1 is not None + assert result2 is not None + assert result1 == result2 + # API вызван только один раз + assert mock_get.call_count == 1 + + @patch("utils.pogoda._session.get") + async def test_bypass_cache_forces_new_request(self, mock_get) -> None: + """bypass_cache=True делает новый запрос к API.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "fact": { + "temp": 20, + "feels_like": 18, + "condition": "clear", + "wind_speed": 3, + "wind_gust": 5, + "wind_dir": "s", + "humidity": 50, + "pressure_mm": 760.0, + "pressure_pa": 1013, + } + } + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + await fetch_weather() + await fetch_weather(bypass_cache=True) + + assert mock_get.call_count == 2 + + @patch("utils.pogoda._session.get") + async def test_cache_stores_failure(self, mock_get) -> None: + """Провал запроса кэшируется — повторный вызов не долбит API.""" + mock_get.side_effect = ConnectionError("fail") + + result1 = await fetch_weather(max_retries=1) + result2 = await fetch_weather() + + assert result1 is None + assert result2 is None # из кэша + # API вызван только один раз (первый раз) + assert mock_get.call_count == 1 + + @patch("utils.pogoda._session.get") + async def test_clear_cache_invalidates(self, mock_get) -> None: + """clear_weather_cache сбрасывает кэш.""" + mock_response = MagicMock() + mock_response.json.return_value = { + "fact": { + "temp": 20, + "feels_like": 18, + "condition": "clear", + "wind_speed": 3, + "wind_gust": 5, + "wind_dir": "s", + "humidity": 50, + "pressure_mm": 760.0, + "pressure_pa": 1013, + } + } + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + await fetch_weather() + clear_weather_cache() + await fetch_weather() + + assert mock_get.call_count == 2 + + @patch("utils.pogoda._session.get") + async def test_cache_ttl_expiry(self, mock_get) -> None: + """После истечения TTL кэш инвалидируется.""" + import utils.pogoda as pogoda_module + + mock_response = MagicMock() + mock_response.json.return_value = { + "fact": { + "temp": 20, + "feels_like": 18, + "condition": "clear", + "wind_speed": 3, + "wind_gust": 5, + "wind_dir": "s", + "humidity": 50, + "pressure_mm": 760.0, + "pressure_pa": 1013, + } + } + mock_response.raise_for_status = MagicMock() + mock_get.return_value = mock_response + + # Устанавливаем TTL = 0, чтобы кэш сразу истёк + original_ttl = pogoda_module._WEATHER_CACHE_TTL + pogoda_module._WEATHER_CACHE_TTL = 0.0 + + try: + await fetch_weather() + await fetch_weather() + + assert mock_get.call_count == 2 + finally: + pogoda_module._WEATHER_CACHE_TTL = original_ttl diff --git a/utils/__init__.py b/utils/__init__.py index 005e231..a76d1c0 100644 --- a/utils/__init__.py +++ b/utils/__init__.py @@ -1,5 +1,6 @@ from .pogoda import ( _session as _weather_session, + clear_weather_cache, fetch_weather, format_weather_data_for_console, format_weather_for_message, @@ -22,6 +23,7 @@ from .cat import ( # noqa: E402 __all__ = [ # Погода + "clear_weather_cache", "fetch_weather", "format_weather_data_for_console", "format_weather_for_message", diff --git a/utils/pogoda.py b/utils/pogoda.py index 993b1bf..620dda4 100644 --- a/utils/pogoda.py +++ b/utils/pogoda.py @@ -10,6 +10,7 @@ import asyncio import json import logging import os +import time from typing import Optional import requests @@ -25,6 +26,12 @@ _LONGITUDE: float = 58.980289 _session = requests.Session() +# Кэш погоды: (data, timestamp) или (None, timestamp) при провале запроса +_weather_cache: tuple[Optional[dict], float] = (None, 0.0) +_weather_cache_lock = asyncio.Lock() +# TTL в секундах — по умолчанию 1 час, переопределяется WEATHER_CACHE_TTL +_WEATHER_CACHE_TTL: float = float(os.getenv("WEATHER_CACHE_TTL", "3600")) + def _get_api_key() -> str: """Получить API-ключ Яндекс Погоды из переменных окружения.""" @@ -36,17 +43,37 @@ def _get_api_key() -> str: return key +def clear_weather_cache() -> None: + """Очистить кэш погоды. Используется в тестах.""" + global _weather_cache + _weather_cache = (None, 0.0) + + async def fetch_weather( lat: float = _LATITUDE, lon: float = _LONGITUDE, timeout: int = 10, max_retries: int = 3, + bypass_cache: bool = False, ) -> Optional[dict]: """Получить текущую погоду через Яндекс Погоду API. - Возвращает dict в унифицированном формате для форматирования: - {"current_condition": [{"temp_C": ..., "weatherDesc": ..., ...}]} + Результат кэшируется на _WEATHER_CACHE_TTL секунд (по умолчанию 1 ч). + Повторные вызовы в течение TTL возвращают кэшированные данные без запроса к API. + + :param bypass_cache: Пропустить кэш и сделать свежий запрос. + :return: dict в унифицированном формате или None при ошибке. """ + global _weather_cache + + # Проверка кэша + if not bypass_cache: + async with _weather_cache_lock: + cached_data, cached_time = _weather_cache + if time.monotonic() - cached_time < _WEATHER_CACHE_TTL: + logger.debug("Погода: возвращены данные из кэша (TTL %.0f сек)", _WEATHER_CACHE_TTL) + return cached_data + await yandex_weather_limiter.acquire() url = f"https://api.weather.yandex.ru/v1/informers?lat={lat}&lon={lon}" headers = {"X-Yandex-API-Key": _get_api_key()} @@ -60,7 +87,7 @@ async def fetch_weather( data = response.json() fact = data.get("fact", {}) - return { + result = { "current_condition": [ { "temp_C": fact.get("temp"), @@ -75,6 +102,9 @@ async def fetch_weather( } ] } + async with _weather_cache_lock: + _weather_cache = (result, time.monotonic()) + return result except (SSLError, ConnectionError, Timeout): if attempt < max_retries - 1: delay = 2**attempt @@ -89,6 +119,9 @@ async def fetch_weather( break logger.warning("Все попытки Яндекс Погоды не удались") + # Сохраняем провал в кэш, чтобы не долбить API повторно + async with _weather_cache_lock: + _weather_cache = (None, time.monotonic()) return None