fix: H5 — 50 тестов asyncio.run() заменены на async def

- test_fetch_cat.py: 10 тестов (def → async def, asyncio.run → await)
- test_fetch_rss.py: 21 тест (def → async def, asyncio.run → await)
- test_fetch_weather.py: 19 тест (def → async def, asyncio.run → await)
- Удалён лишний import asyncio из тестовых файлов
- Больше не создаётся новый event loop на каждый тест
- RateLimiter (asyncio.Lock) теперь корректно работает в одном loop

230 тестов проходят.
This commit is contained in:
deadzilla 2026-07-07 21:52:18 +05:00
parent e1ab1e3a61
commit 6e2a092548
4 changed files with 104 additions and 105 deletions

View File

@ -194,7 +194,7 @@ async def on_ready() -> None:
--- ---
### H5. `asyncio.run()` в 50+ тестах вместо `async def` ### H5. `asyncio.run()` в 50+ тестах вместо `async def` — **ИСПРАВЛЕНО**
**Файлы:** **Файлы:**
- `tests/test_fetch_cat.py` -- 10 вхождений - `tests/test_fetch_cat.py` -- 10 вхождений
@ -540,7 +540,7 @@ DeprecationWarning: 'asyncio.iscoroutinefunction' is deprecated and slated for r
4. [x] H2: Отсортировать ключи `translate_weather` по убыванию длины — **выполнено** 4. [x] H2: Отсортировать ключи `translate_weather` по убыванию длины — **выполнено**
5. [x] H3: Исправить `pressure_to_mmhg` -- `not mb` -> `mb is None or mb == ""` — **выполнено** 5. [x] H3: Исправить `pressure_to_mmhg` -- `not mb` -> `mb is None or mb == ""` — **выполнено**
6. [x] H4: Добавить защиту от дублирования cogs в `on_ready` — **выполнено** 6. [x] H4: Добавить защиту от дублирования cogs в `on_ready` — **выполнено**
7. [ ] H5: Заменить `asyncio.run()` на `async def` в fetch-тестах (50+ тестов) 7. [x] H5: Заменить `asyncio.run()` на `async def` в fetch-тестах (50+ тестов) — **выполнено**
8. [ ] H6: Добавить HTTPError в retry-блок `fetch_weather` 8. [ ] H6: Добавить HTTPError в retry-блок `fetch_weather`
9. [ ] H7: Добавить HTTPException/BadRequest в `on_command_error` 9. [ ] H7: Добавить HTTPException/BadRequest в `on_command_error`
10. [ ] H8: Исправить утечку корутины в тестах Scheduler 10. [ ] H8: Исправить утечку корутины в тестах Scheduler

View File

@ -1,4 +1,3 @@
import asyncio
import json import json
import pytest import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
@ -9,94 +8,94 @@ class TestFetchCat:
"""Тесты функции fetch_cat() — получение URL случайного котика.""" """Тесты функции fetch_cat() — получение URL случайного котика."""
@patch("utils.cat._session.get") @patch("utils.cat._session.get")
def test_fetch_cat_success(self, mock_get): async def test_fetch_cat_success(self, mock_get):
"""Успешный ответ с URL должен вернуть строку.""" """Успешный ответ с URL должен вернуть строку."""
mock_response = MagicMock() mock_response = MagicMock()
mock_response.json.return_value = [{"url": "https://example.com/cat.jpg"}] mock_response.json.return_value = [{"url": "https://example.com/cat.jpg"}]
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_cat()) result = await fetch_cat()
assert result == "https://example.com/cat.jpg" assert result == "https://example.com/cat.jpg"
@patch("utils.cat._session.get") @patch("utils.cat._session.get")
def test_fetch_cat_empty_array(self, mock_get): async def test_fetch_cat_empty_array(self, mock_get):
"""Пустой массив должен вернуть None.""" """Пустой массив должен вернуть None."""
mock_response = MagicMock() mock_response = MagicMock()
mock_response.json.return_value = [] mock_response.json.return_value = []
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_cat()) result = await fetch_cat()
assert result is None assert result is None
@patch("utils.cat._session.get") @patch("utils.cat._session.get")
def test_fetch_cat_http_error(self, mock_get): async def test_fetch_cat_http_error(self, mock_get):
"""HTTP-ошибка (raise_for_status) должна вернуть None.""" """HTTP-ошибка (raise_for_status) должна вернуть None."""
import requests import requests
mock_response = MagicMock() mock_response = MagicMock()
mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found") mock_response.raise_for_status.side_effect = requests.HTTPError("404 Not Found")
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_cat()) result = await fetch_cat()
assert result is None assert result is None
@patch("utils.cat._session.get") @patch("utils.cat._session.get")
def test_fetch_cat_connection_error(self, mock_get): async def test_fetch_cat_connection_error(self, mock_get):
"""ConnectionError должна вернуть None.""" """ConnectionError должна вернуть None."""
from requests.exceptions import ConnectionError from requests.exceptions import ConnectionError
mock_get.side_effect = ConnectionError("No connection") mock_get.side_effect = ConnectionError("No connection")
result = asyncio.run(fetch_cat()) result = await fetch_cat()
assert result is None assert result is None
@patch("utils.cat._session.get") @patch("utils.cat._session.get")
def test_fetch_cat_timeout(self, mock_get): async def test_fetch_cat_timeout(self, mock_get):
"""Timeout должна вернуть None.""" """Timeout должна вернуть None."""
from requests.exceptions import Timeout from requests.exceptions import Timeout
mock_get.side_effect = Timeout("Request timed out") mock_get.side_effect = Timeout("Request timed out")
result = asyncio.run(fetch_cat()) result = await fetch_cat()
assert result is None assert result is None
@patch("utils.cat._session.get") @patch("utils.cat._session.get")
def test_fetch_cat_ssl_error(self, mock_get): async def test_fetch_cat_ssl_error(self, mock_get):
"""SSLError должна вернуть None.""" """SSLError должна вернуть None."""
from requests.exceptions import SSLError from requests.exceptions import SSLError
mock_get.side_effect = SSLError("SSL handshake failed") mock_get.side_effect = SSLError("SSL handshake failed")
result = asyncio.run(fetch_cat()) result = await fetch_cat()
assert result is None assert result is None
@patch("utils.cat._session.get") @patch("utils.cat._session.get")
def test_fetch_cat_json_parse_error(self, mock_get): async def test_fetch_cat_json_parse_error(self, mock_get):
"""Ошибка парсинга JSON должна вернуть None.""" """Ошибка парсинга JSON должна вернуть None."""
import requests import requests
mock_response = MagicMock() mock_response = MagicMock()
mock_response.json.side_effect = requests.JSONDecodeError("Expecting value", "", 0) mock_response.json.side_effect = requests.JSONDecodeError("Expecting value", "", 0)
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_cat()) result = await fetch_cat()
assert result is None assert result is None
@patch("utils.cat._session.get") @patch("utils.cat._session.get")
def test_fetch_cat_missing_url_key(self, mock_get): async def test_fetch_cat_missing_url_key(self, mock_get):
"""Отсутствие ключа 'url' в ответе должно вернуть None.""" """Отсутствие ключа 'url' в ответе должно вернуть None."""
mock_response = MagicMock() mock_response = MagicMock()
mock_response.json.return_value = [{"error": "no image"}] mock_response.json.return_value = [{"error": "no image"}]
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_cat()) result = await fetch_cat()
assert result is None assert result is None
@patch("utils.cat._session.get") @patch("utils.cat._session.get")
def test_fetch_cat_request_exception(self, mock_get): async def test_fetch_cat_request_exception(self, mock_get):
"""Общий RequestException должен вернуть None.""" """Общий RequestException должен вернуть None."""
import requests import requests
mock_get.side_effect = requests.RequestException("Generic error") mock_get.side_effect = requests.RequestException("Generic error")
result = asyncio.run(fetch_cat()) result = await fetch_cat()
assert result is None assert result is None
@patch("utils.cat._session.get") @patch("utils.cat._session.get")
def test_fetch_cat_url_with_special_chars(self, mock_get): async def test_fetch_cat_url_with_special_chars(self, mock_get):
"""URL со спецсимволами должен вернуться как есть.""" """URL со спецсимволами должен вернуться как есть."""
mock_response = MagicMock() mock_response = MagicMock()
mock_response.json.return_value = [{"url": "https://example.com/cat?w=100&h=200"}] mock_response.json.return_value = [{"url": "https://example.com/cat?w=100&h=200"}]
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_cat()) result = await fetch_cat()
assert result == "https://example.com/cat?w=100&h=200" assert result == "https://example.com/cat?w=100&h=200"

View File

@ -1,4 +1,4 @@
import asyncio
import pytest import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from utils.news import fetch_rss from utils.news import fetch_rss
@ -8,7 +8,7 @@ class TestFetchRss:
"""Тесты функции fetch_rss() — получение и парсинг RSS-ленты.""" """Тесты функции fetch_rss() — получение и парсинг RSS-ленты."""
@patch("utils.news._session.get") @patch("utils.news._session.get")
def test_fetch_rss_success_rss20(self, mock_get): async def test_fetch_rss_success_rss20(self, mock_get):
"""Успешный ответ RSS 2.0 должен вернуть список статей.""" """Успешный ответ RSS 2.0 должен вернуть список статей."""
rss_content = """<?xml version="1.0" encoding="UTF-8"?> rss_content = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"> <rss version="2.0">
@ -34,7 +34,7 @@ class TestFetchRss:
mock_response.content = rss_content mock_response.content = rss_content
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss")) result = await fetch_rss("https://example.com/rss")
assert result is not None assert result is not None
assert len(result) == 2 assert len(result) == 2
assert result[0]["title"] == "Статья 1" assert result[0]["title"] == "Статья 1"
@ -47,7 +47,7 @@ class TestFetchRss:
assert result[1]["tags"] == [] assert result[1]["tags"] == []
@patch("utils.news._session.get") @patch("utils.news._session.get")
def test_fetch_rss_success_atom(self, mock_get): async def test_fetch_rss_success_atom(self, mock_get):
"""Успешный ответ Atom должен вернуть список статей.""" """Успешный ответ Atom должен вернуть список статей."""
atom_content = """<?xml version="1.0" encoding="UTF-8"?> atom_content = """<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"> <feed xmlns="http://www.w3.org/2005/Atom">
@ -63,7 +63,7 @@ class TestFetchRss:
mock_response.content = atom_content mock_response.content = atom_content
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/atom")) result = await fetch_rss("https://example.com/atom")
assert result is not None assert result is not None
assert len(result) == 1 assert len(result) == 1
assert result[0]["title"] == "Atom статья 1" assert result[0]["title"] == "Atom статья 1"
@ -73,7 +73,7 @@ class TestFetchRss:
assert result[0]["tags"] == ["AI"] assert result[0]["tags"] == ["AI"]
@patch("utils.news._session.get") @patch("utils.news._session.get")
def test_fetch_rss_empty_items(self, mock_get): async def test_fetch_rss_empty_items(self, mock_get):
"""RSS без items должен вернуть пустой список.""" """RSS без items должен вернуть пустой список."""
rss_content = """<?xml version="1.0" encoding="UTF-8"?> rss_content = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"> <rss version="2.0">
@ -84,11 +84,11 @@ class TestFetchRss:
mock_response.content = rss_content mock_response.content = rss_content
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss")) result = await fetch_rss("https://example.com/rss")
assert result == [] assert result == []
@patch("utils.news._session.get") @patch("utils.news._session.get")
def test_fetch_rss_no_matching_format(self, mock_get): async def test_fetch_rss_no_matching_format(self, mock_get):
"""Неизвестный формат XML должен вернуть пустой список.""" """Неизвестный формат XML должен вернуть пустой список."""
xml_content = """<?xml version="1.0"?> xml_content = """<?xml version="1.0"?>
<unknown></unknown>""".encode() <unknown></unknown>""".encode()
@ -96,11 +96,11 @@ class TestFetchRss:
mock_response.content = xml_content mock_response.content = xml_content
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/xml")) result = await fetch_rss("https://example.com/xml")
assert result == [] assert result == []
@patch("utils.news._session.get") @patch("utils.news._session.get")
def test_fetch_rss_missing_title(self, mock_get): async def test_fetch_rss_missing_title(self, mock_get):
"""Статья без title должна получить 'Без названия'.""" """Статья без title должна получить 'Без названия'."""
rss_content = """<?xml version="1.0" encoding="UTF-8"?> rss_content = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"> <rss version="2.0">
@ -116,7 +116,7 @@ class TestFetchRss:
mock_response.content = rss_content mock_response.content = rss_content
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss")) result = await fetch_rss("https://example.com/rss")
assert result is not None assert result is not None
assert result[0]["title"] == "Без title" assert result[0]["title"] == "Без title"
assert result[0]["link"] == "https://habr.com/1" assert result[0]["link"] == "https://habr.com/1"
@ -125,7 +125,7 @@ class TestFetchRss:
assert result[0]["tags"] == [] assert result[0]["tags"] == []
@patch("utils.news._session.get") @patch("utils.news._session.get")
def test_fetch_rss_missing_guid(self, mock_get): async def test_fetch_rss_missing_guid(self, mock_get):
"""Статья без guid isPermaLink должна иметь пустую ссылку.""" """Статья без guid isPermaLink должна иметь пустую ссылку."""
rss_content = """<?xml version="1.0" encoding="UTF-8"?> rss_content = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"> <rss version="2.0">
@ -141,13 +141,13 @@ class TestFetchRss:
mock_response.content = rss_content mock_response.content = rss_content
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss")) result = await fetch_rss("https://example.com/rss")
assert result is not None assert result is not None
assert result[0]["title"] == "Без guid" assert result[0]["title"] == "Без guid"
assert result[0]["link"] == "" assert result[0]["link"] == ""
@patch("utils.news._session.get") @patch("utils.news._session.get")
def test_fetch_rss_limit_to_10(self, mock_get): async def test_fetch_rss_limit_to_10(self, mock_get):
"""Больше 10 items должно быть обрезано до 10.""" """Больше 10 items должно быть обрезано до 10."""
items = "\n".join( items = "\n".join(
f""" <item> f""" <item>
@ -167,46 +167,46 @@ class TestFetchRss:
mock_response.content = rss_content mock_response.content = rss_content
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss")) result = await fetch_rss("https://example.com/rss")
assert result is not None assert result is not None
assert len(result) == 10 assert len(result) == 10
assert result[0]["title"] == "Статья 0" assert result[0]["title"] == "Статья 0"
assert result[9]["title"] == "Статья 9" assert result[9]["title"] == "Статья 9"
@patch("utils.news._session.get") @patch("utils.news._session.get")
def test_fetch_rss_http_error(self, mock_get): async def test_fetch_rss_http_error(self, mock_get):
"""HTTP-ошибка должна вернуть None.""" """HTTP-ошибка должна вернуть None."""
import requests import requests
mock_get.side_effect = requests.exceptions.HTTPError("404 Not Found") mock_get.side_effect = requests.exceptions.HTTPError("404 Not Found")
result = asyncio.run(fetch_rss("https://example.com/rss")) result = await fetch_rss("https://example.com/rss")
assert result is None assert result is None
@patch("utils.news._session.get") @patch("utils.news._session.get")
def test_fetch_rss_connection_error(self, mock_get): async def test_fetch_rss_connection_error(self, mock_get):
"""Ошибка соединения должна вернуть None.""" """Ошибка соединения должна вернуть None."""
import requests import requests
mock_get.side_effect = requests.exceptions.ConnectionError("No connection") mock_get.side_effect = requests.exceptions.ConnectionError("No connection")
result = asyncio.run(fetch_rss("https://example.com/rss")) result = await fetch_rss("https://example.com/rss")
assert result is None assert result is None
@patch("utils.news._session.get") @patch("utils.news._session.get")
def test_fetch_rss_timeout(self, mock_get): async def test_fetch_rss_timeout(self, mock_get):
"""Таймаут должен вернуть None.""" """Таймаут должен вернуть None."""
import requests import requests
mock_get.side_effect = requests.exceptions.Timeout("Request timed out") mock_get.side_effect = requests.exceptions.Timeout("Request timed out")
result = asyncio.run(fetch_rss("https://example.com/rss")) result = await fetch_rss("https://example.com/rss")
assert result is None assert result is None
@patch("utils.news._session.get") @patch("utils.news._session.get")
def test_fetch_rss_ssl_error(self, mock_get): async def test_fetch_rss_ssl_error(self, mock_get):
"""SSLError должен вернуть None.""" """SSLError должен вернуть None."""
import requests import requests
mock_get.side_effect = requests.exceptions.SSLError("SSL handshake failed") mock_get.side_effect = requests.exceptions.SSLError("SSL handshake failed")
result = asyncio.run(fetch_rss("https://example.com/rss")) result = await fetch_rss("https://example.com/rss")
assert result is None assert result is None
@patch("utils.news._session.get") @patch("utils.news._session.get")
def test_fetch_rss_empty_tags(self, mock_get): async def test_fetch_rss_empty_tags(self, mock_get):
"""Статья с пустыми тегами должна иметь пустые строки.""" """Статья с пустыми тегами должна иметь пустые строки."""
rss_content = """<?xml version="1.0" encoding="UTF-8"?> rss_content = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"> <rss version="2.0">
@ -222,7 +222,7 @@ class TestFetchRss:
mock_response.content = rss_content mock_response.content = rss_content
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss")) result = await fetch_rss("https://example.com/rss")
assert result is not None assert result is not None
assert result[0]["title"] == "Пустые теги" assert result[0]["title"] == "Пустые теги"
assert result[0]["link"] == "https://habr.com/1" assert result[0]["link"] == "https://habr.com/1"
@ -231,7 +231,7 @@ class TestFetchRss:
assert result[0]["tags"] == [] assert result[0]["tags"] == []
@patch("utils.news._session.get") @patch("utils.news._session.get")
def test_fetch_rss_category_without_text(self, mock_get): async def test_fetch_rss_category_without_text(self, mock_get):
"""Категория без текста должна быть пропущена.""" """Категория без текста должна быть пропущена."""
rss_content = """<?xml version="1.0" encoding="UTF-8"?> rss_content = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"> <rss version="2.0">
@ -248,12 +248,12 @@ class TestFetchRss:
mock_response.content = rss_content mock_response.content = rss_content
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss")) result = await fetch_rss("https://example.com/rss")
assert result is not None assert result is not None
assert result[0]["tags"] == ["AI"] assert result[0]["tags"] == ["AI"]
@patch("utils.news._session.get") @patch("utils.news._session.get")
def test_fetch_rss_atom_missing_author(self, mock_get): async def test_fetch_rss_atom_missing_author(self, mock_get):
"""Atom feed без автора должен иметь пустого creator.""" """Atom feed без автора должен иметь пустого creator."""
atom_content = """<?xml version="1.0" encoding="UTF-8"?> atom_content = """<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"> <feed xmlns="http://www.w3.org/2005/Atom">
@ -267,13 +267,13 @@ class TestFetchRss:
mock_response.content = atom_content mock_response.content = atom_content
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/atom")) result = await fetch_rss("https://example.com/atom")
assert result is not None assert result is not None
assert result[0]["title"] == "Без автора" assert result[0]["title"] == "Без автора"
assert result[0]["creator"] == "" assert result[0]["creator"] == ""
@patch("utils.news._session.get") @patch("utils.news._session.get")
def test_fetch_rss_atom_missing_link(self, mock_get): async def test_fetch_rss_atom_missing_link(self, mock_get):
"""Atom feed без link должен иметь пустую ссылку.""" """Atom feed без link должен иметь пустую ссылку."""
atom_content = """<?xml version="1.0" encoding="UTF-8"?> atom_content = """<?xml version="1.0" encoding="UTF-8"?>
<feed xmlns="http://www.w3.org/2005/Atom"> <feed xmlns="http://www.w3.org/2005/Atom">
@ -286,21 +286,21 @@ class TestFetchRss:
mock_response.content = atom_content mock_response.content = atom_content
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/atom")) result = await fetch_rss("https://example.com/atom")
assert result is not None assert result is not None
assert result[0]["title"] == "Без ссылки" assert result[0]["title"] == "Без ссылки"
assert result[0]["link"] == "" assert result[0]["link"] == ""
@patch("utils.news._session.get") @patch("utils.news._session.get")
def test_fetch_rss_request_exception(self, mock_get): async def test_fetch_rss_request_exception(self, mock_get):
"""Общий RequestException должен вернуть None.""" """Общий RequestException должен вернуть None."""
import requests import requests
mock_get.side_effect = requests.RequestException("Generic error") mock_get.side_effect = requests.RequestException("Generic error")
result = asyncio.run(fetch_rss("https://example.com/rss")) result = await fetch_rss("https://example.com/rss")
assert result is None assert result is None
@patch("utils.news._session.get") @patch("utils.news._session.get")
def test_fetch_rss_guid_fallback_to_link(self, mock_get): async def test_fetch_rss_guid_fallback_to_link(self, mock_get):
"""Если нет guid isPermaLink, ссылка должна быть пустой.""" """Если нет guid isPermaLink, ссылка должна быть пустой."""
rss_content = """<?xml version="1.0" encoding="UTF-8"?> rss_content = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"> <rss version="2.0">
@ -316,12 +316,12 @@ class TestFetchRss:
mock_response.content = rss_content mock_response.content = rss_content
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss")) result = await fetch_rss("https://example.com/rss")
assert result is not None assert result is not None
assert result[0]["link"] == "" assert result[0]["link"] == ""
@patch("utils.news._session.get") @patch("utils.news._session.get")
def test_fetch_rss_single_item(self, mock_get): async def test_fetch_rss_single_item(self, mock_get):
"""Один item должен быть распарсен корректно.""" """Один item должен быть распарсен корректно."""
rss_content = """<?xml version="1.0" encoding="UTF-8"?> rss_content = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"> <rss version="2.0">
@ -339,7 +339,7 @@ class TestFetchRss:
mock_response.content = rss_content mock_response.content = rss_content
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss")) result = await fetch_rss("https://example.com/rss")
assert result is not None assert result is not None
assert len(result) == 1 assert len(result) == 1
assert result[0]["title"] == "Единственная статья" assert result[0]["title"] == "Единственная статья"
@ -348,7 +348,7 @@ class TestFetchRss:
assert result[0]["tags"] == ["ML"] assert result[0]["tags"] == ["ML"]
@patch("utils.news._session.get") @patch("utils.news._session.get")
def test_fetch_rss_special_characters_in_title(self, mock_get): async def test_fetch_rss_special_characters_in_title(self, mock_get):
"""Заголовки со спецсимволами должны парситься корректно.""" """Заголовки со спецсимволами должны парситься корректно."""
rss_content = """<?xml version="1.0" encoding="UTF-8"?> rss_content = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"> <rss version="2.0">
@ -364,13 +364,13 @@ class TestFetchRss:
mock_response.content = rss_content mock_response.content = rss_content
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss")) result = await fetch_rss("https://example.com/rss")
assert result is not None assert result is not None
assert "AI" in result[0]["title"] assert "AI" in result[0]["title"]
assert "ML" in result[0]["title"] assert "ML" in result[0]["title"]
@patch("utils.news._session.get") @patch("utils.news._session.get")
def test_fetch_rss_date_with_gmt(self, mock_get): async def test_fetch_rss_date_with_gmt(self, mock_get):
"""Дата с GMT должна парситься корректно.""" """Дата с GMT должна парситься корректно."""
rss_content = """<?xml version="1.0" encoding="UTF-8"?> rss_content = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"> <rss version="2.0">
@ -386,12 +386,12 @@ class TestFetchRss:
mock_response.content = rss_content mock_response.content = rss_content
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss")) result = await fetch_rss("https://example.com/rss")
assert result is not None assert result is not None
assert result[0]["pub_date"] == "Mon, 28 May 2026 10:00:00 GMT" assert result[0]["pub_date"] == "Mon, 28 May 2026 10:00:00 GMT"
@patch("utils.news._session.get") @patch("utils.news._session.get")
def test_fetch_rss_many_categories(self, mock_get): async def test_fetch_rss_many_categories(self, mock_get):
"""Множество категорий должны быть собраны.""" """Множество категорий должны быть собраны."""
rss_content = """<?xml version="1.0" encoding="UTF-8"?> rss_content = """<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0"> <rss version="2.0">
@ -411,6 +411,6 @@ class TestFetchRss:
mock_response.content = rss_content mock_response.content = rss_content
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss")) result = await fetch_rss("https://example.com/rss")
assert result is not None assert result is not None
assert result[0]["tags"] == ["AI", "ML", "Deep Learning", "NLP", "Computer Vision"] assert result[0]["tags"] == ["AI", "ML", "Deep Learning", "NLP", "Computer Vision"]

View File

@ -1,4 +1,4 @@
import asyncio
import pytest import pytest
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
from utils.pogoda import fetch_weather, fetch_open_meteo from utils.pogoda import fetch_weather, fetch_open_meteo
@ -8,17 +8,17 @@ class TestFetchWeather:
"""Тесты функции fetch_weather() — получение погоды с retry-логикой.""" """Тесты функции fetch_weather() — получение погоды с retry-логикой."""
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
def test_fetch_weather_success(self, mock_get): async def test_fetch_weather_success(self, mock_get):
"""Успешный ответ должен вернуть JSON-данные.""" """Успешный ответ должен вернуть JSON-данные."""
mock_response = MagicMock() mock_response = MagicMock()
mock_response.json.return_value = {"current_condition": [{"temp_C": 20}]} mock_response.json.return_value = {"current_condition": [{"temp_C": 20}]}
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_weather("https://test.example.com")) result = await fetch_weather("https://test.example.com")
assert result == {"current_condition": [{"temp_C": 20}]} assert result == {"current_condition": [{"temp_C": 20}]}
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
def test_fetch_weather_fallback_on_ssl_error(self, mock_get): async def test_fetch_weather_fallback_on_ssl_error(self, mock_get):
"""SSLError на первой попытке → fallback на Open-Meteo.""" """SSLError на первой попытке → fallback на Open-Meteo."""
from requests.exceptions import SSLError from requests.exceptions import SSLError
mock_get.side_effect = [ mock_get.side_effect = [
@ -27,64 +27,64 @@ class TestFetchWeather:
] ]
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback: with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
mock_fallback.return_value = {"result": "fallback"} mock_fallback.return_value = {"result": "fallback"}
result = asyncio.run(fetch_weather("https://test.example.com")) result = await fetch_weather("https://test.example.com")
assert result == {"result": "fallback"} assert result == {"result": "fallback"}
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
def test_fetch_weather_fallback_on_connection_error(self, mock_get): async def test_fetch_weather_fallback_on_connection_error(self, mock_get):
"""ConnectionError → fallback на Open-Meteo.""" """ConnectionError → fallback на Open-Meteo."""
from requests.exceptions import ConnectionError from requests.exceptions import ConnectionError
mock_get.side_effect = ConnectionError("No connection") mock_get.side_effect = ConnectionError("No connection")
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback: with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
mock_fallback.return_value = {"result": "fallback"} mock_fallback.return_value = {"result": "fallback"}
result = asyncio.run(fetch_weather("https://test.example.com")) result = await fetch_weather("https://test.example.com")
assert result == {"result": "fallback"} assert result == {"result": "fallback"}
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
def test_fetch_weather_fallback_on_timeout(self, mock_get): async def test_fetch_weather_fallback_on_timeout(self, mock_get):
"""Timeout → fallback на Open-Meteo.""" """Timeout → fallback на Open-Meteo."""
from requests.exceptions import Timeout from requests.exceptions import Timeout
mock_get.side_effect = Timeout("Timed out") mock_get.side_effect = Timeout("Timed out")
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback: with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
mock_fallback.return_value = {"result": "fallback"} mock_fallback.return_value = {"result": "fallback"}
result = asyncio.run(fetch_weather("https://test.example.com")) result = await fetch_weather("https://test.example.com")
assert result == {"result": "fallback"} assert result == {"result": "fallback"}
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
def test_fetch_weather_all_retries_fail(self, mock_get): async def test_fetch_weather_all_retries_fail(self, mock_get):
"""Все попытки не удались → fallback на Open-Meteo.""" """Все попытки не удались → fallback на Open-Meteo."""
from requests.exceptions import ConnectionError from requests.exceptions import ConnectionError
mock_get.side_effect = ConnectionError("No connection") mock_get.side_effect = ConnectionError("No connection")
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback: with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
mock_fallback.return_value = None mock_fallback.return_value = None
result = asyncio.run(fetch_weather("https://test.example.com")) result = await fetch_weather("https://test.example.com")
assert result is None assert result is None
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
def test_fetch_weather_request_exception(self, mock_get): async def test_fetch_weather_request_exception(self, mock_get):
"""Общий RequestException → fallback на Open-Meteo.""" """Общий RequestException → fallback на Open-Meteo."""
import requests import requests
mock_get.side_effect = requests.RequestException("Generic error") mock_get.side_effect = requests.RequestException("Generic error")
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback: with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
mock_fallback.return_value = {"result": "fallback"} mock_fallback.return_value = {"result": "fallback"}
result = asyncio.run(fetch_weather("https://test.example.com")) result = await fetch_weather("https://test.example.com")
assert result == {"result": "fallback"} assert result == {"result": "fallback"}
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
def test_fetch_weather_http_error_no_fallback(self, mock_get): async def test_fetch_weather_http_error_no_fallback(self, mock_get):
"""HTTP-ошибка (raise_for_status) не ловится, падает.""" """HTTP-ошибка (raise_for_status) не ловится, падает."""
mock_response = MagicMock() mock_response = MagicMock()
mock_response.raise_for_status.side_effect = Exception("HTTP 500") mock_response.raise_for_status.side_effect = Exception("HTTP 500")
mock_get.return_value = mock_response mock_get.return_value = mock_response
with pytest.raises(Exception): with pytest.raises(Exception):
asyncio.run(fetch_weather("https://test.example.com")) await fetch_weather("https://test.example.com")
class TestFetchOpenMeteo: class TestFetchOpenMeteo:
"""Тесты функции fetch_open_meteo() — fallback на Open-Meteo API.""" """Тесты функции fetch_open_meteo() — fallback на Open-Meteo API."""
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
def test_fetch_open_meteo_success(self, mock_get): async def test_fetch_open_meteo_success(self, mock_get):
"""Успешный ответ должен вернуть данные в формате current_condition.""" """Успешный ответ должен вернуть данные в формате current_condition."""
mock_response = MagicMock() mock_response = MagicMock()
mock_response.json.return_value = { mock_response.json.return_value = {
@ -99,7 +99,7 @@ class TestFetchOpenMeteo:
} }
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_open_meteo()) result = await fetch_open_meteo()
assert result is not None assert result is not None
assert "current_condition" in result assert "current_condition" in result
assert result["current_condition"][0]["temp_C"] == 15 assert result["current_condition"][0]["temp_C"] == 15
@ -108,13 +108,13 @@ class TestFetchOpenMeteo:
assert result["current_condition"][0]["pressure"] == 1013 assert result["current_condition"][0]["pressure"] == 1013
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
def test_fetch_open_meteo_custom_coords(self, mock_get): async def test_fetch_open_meteo_custom_coords(self, mock_get):
"""Кастомные координаты должны быть в URL.""" """Кастомные координаты должны быть в URL."""
mock_response = MagicMock() mock_response = MagicMock()
mock_response.json.return_value = {"current": {"temperature": 25, "apparent_temperature": 22, "weather_code": 0, "wind_speed_10m": 3, "relative_humidity_2m": 50, "pressure_msl": 1020}} mock_response.json.return_value = {"current": {"temperature": 25, "apparent_temperature": 22, "weather_code": 0, "wind_speed_10m": 3, "relative_humidity_2m": 50, "pressure_msl": 1020}}
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_open_meteo(lat=55.7558, lon=37.6173)) result = await fetch_open_meteo(lat=55.7558, lon=37.6173)
assert result is not None assert result is not None
mock_get.assert_called_once() mock_get.assert_called_once()
call_url = mock_get.call_args[0][0] call_url = mock_get.call_args[0][0]
@ -122,95 +122,95 @@ class TestFetchOpenMeteo:
assert "37.6173" in call_url assert "37.6173" in call_url
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
def test_fetch_open_meteo_missing_weather_code(self, mock_get): async def test_fetch_open_meteo_missing_weather_code(self, mock_get):
"""Отсутствующий weather_code → 'Неизвестно'.""" """Отсутствующий weather_code → 'Неизвестно'."""
mock_response = MagicMock() mock_response = MagicMock()
mock_response.json.return_value = {"current": {"temperature": 10}} mock_response.json.return_value = {"current": {"temperature": 10}}
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_open_meteo()) result = await fetch_open_meteo()
assert result is not None assert result is not None
assert result["current_condition"][0]["weatherDesc"] == [{"value": "Неизвестно"}] assert result["current_condition"][0]["weatherDesc"] == [{"value": "Неизвестно"}]
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
def test_fetch_open_meteo_ssl_error(self, mock_get): async def test_fetch_open_meteo_ssl_error(self, mock_get):
"""SSLError → вернуть None.""" """SSLError → вернуть None."""
from requests.exceptions import SSLError from requests.exceptions import SSLError
mock_get.side_effect = SSLError("SSL Error") mock_get.side_effect = SSLError("SSL Error")
with patch("utils.pogoda.fetch_open_meteo") as mock_fallback: with patch("utils.pogoda.fetch_open_meteo") as mock_fallback:
# Внутренний fallback тоже падает, проверяем что возвращается None # Внутренний fallback тоже падает, проверяем что возвращается None
pass pass
result = asyncio.run(fetch_open_meteo()) result = await fetch_open_meteo()
assert result is None assert result is None
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
def test_fetch_open_meteo_connection_error(self, mock_get): async def test_fetch_open_meteo_connection_error(self, mock_get):
"""ConnectionError → вернуть None.""" """ConnectionError → вернуть None."""
from requests.exceptions import ConnectionError from requests.exceptions import ConnectionError
mock_get.side_effect = ConnectionError("No connection") mock_get.side_effect = ConnectionError("No connection")
result = asyncio.run(fetch_open_meteo()) result = await fetch_open_meteo()
assert result is None assert result is None
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
def test_fetch_open_meteo_timeout(self, mock_get): async def test_fetch_open_meteo_timeout(self, mock_get):
"""Timeout → вернуть None.""" """Timeout → вернуть None."""
from requests.exceptions import Timeout from requests.exceptions import Timeout
mock_get.side_effect = Timeout("Timed out") mock_get.side_effect = Timeout("Timed out")
result = asyncio.run(fetch_open_meteo()) result = await fetch_open_meteo()
assert result is None assert result is None
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
def test_fetch_open_meteo_request_exception(self, mock_get): async def test_fetch_open_meteo_request_exception(self, mock_get):
"""Общий RequestException → вернуть None.""" """Общий RequestException → вернуть None."""
import requests import requests
mock_get.side_effect = requests.RequestException("Error") mock_get.side_effect = requests.RequestException("Error")
result = asyncio.run(fetch_open_meteo()) result = await fetch_open_meteo()
assert result is None assert result is None
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
def test_fetch_open_meteo_json_parse_error(self, mock_get): async def test_fetch_open_meteo_json_parse_error(self, mock_get):
"""Ошибка парсинга JSON → вернуть None.""" """Ошибка парсинга JSON → вернуть None."""
import requests import requests
mock_response = MagicMock() mock_response = MagicMock()
mock_response.json.side_effect = requests.RequestException("JSON Error") mock_response.json.side_effect = requests.RequestException("JSON Error")
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_open_meteo()) result = await fetch_open_meteo()
assert result is None assert result is None
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
def test_fetch_open_meteo_retry_on_error(self, mock_get): async def test_fetch_open_meteo_retry_on_error(self, mock_get):
"""Retry: первая попытка падает, вторая успешна.""" """Retry: первая попытка падает, вторая успешна."""
from requests.exceptions import ConnectionError from requests.exceptions import ConnectionError
success_response = MagicMock() success_response = MagicMock()
success_response.json.return_value = {"current": {"temperature": 20, "apparent_temperature": 18, "weather_code": 1, "wind_speed_10m": 4, "relative_humidity_2m": 60, "pressure_msl": 1015}} success_response.json.return_value = {"current": {"temperature": 20, "apparent_temperature": 18, "weather_code": 1, "wind_speed_10m": 4, "relative_humidity_2m": 60, "pressure_msl": 1015}}
success_response.raise_for_status = MagicMock() success_response.raise_for_status = MagicMock()
mock_get.side_effect = [ConnectionError("fail"), success_response] mock_get.side_effect = [ConnectionError("fail"), success_response]
result = asyncio.run(fetch_open_meteo(max_retries=2)) result = await fetch_open_meteo(max_retries=2)
assert result is not None assert result is not None
assert mock_get.call_count == 2 assert mock_get.call_count == 2
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
def test_fetch_open_meteo_all_retries_fail(self, mock_get): async def test_fetch_open_meteo_all_retries_fail(self, mock_get):
"""Все попытки неудачны → None.""" """Все попытки неудачны → None."""
from requests.exceptions import ConnectionError from requests.exceptions import ConnectionError
mock_get.side_effect = [ConnectionError("fail"), ConnectionError("fail"), ConnectionError("fail")] mock_get.side_effect = [ConnectionError("fail"), ConnectionError("fail"), ConnectionError("fail")]
result = asyncio.run(fetch_open_meteo(max_retries=3)) result = await fetch_open_meteo(max_retries=3)
assert result is None assert result is None
assert mock_get.call_count == 3 assert mock_get.call_count == 3
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
def test_fetch_open_meteo_http_error(self, mock_get): async def test_fetch_open_meteo_http_error(self, mock_get):
"""HTTP 404 → raise_for_status бросит исключение → None.""" """HTTP 404 → raise_for_status бросит исключение → None."""
import requests import requests
mock_response = MagicMock() mock_response = MagicMock()
mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP 404") mock_response.raise_for_status.side_effect = requests.HTTPError("HTTP 404")
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_open_meteo()) result = await fetch_open_meteo()
assert result is None assert result is None
@patch("utils.pogoda._session.get") @patch("utils.pogoda._session.get")
def test_fetch_open_meteo_wind_speed_0(self, mock_get): async def test_fetch_open_meteo_wind_speed_0(self, mock_get):
"""Нулевая скорость ветра должна корректно обрабатываться.""" """Нулевая скорость ветра должна корректно обрабатываться."""
mock_response = MagicMock() mock_response = MagicMock()
mock_response.json.return_value = { mock_response.json.return_value = {
@ -225,7 +225,7 @@ class TestFetchOpenMeteo:
} }
mock_response.raise_for_status = MagicMock() mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response mock_get.return_value = mock_response
result = asyncio.run(fetch_open_meteo()) result = await fetch_open_meteo()
assert result is not None assert result is not None
assert result["current_condition"][0]["windspeedKmph"] == 0 assert result["current_condition"][0]["windspeedKmph"] == 0
assert result["current_condition"][0]["pressure"] == 1000 assert result["current_condition"][0]["pressure"] == 1000