import asyncio
import pytest
from unittest.mock import patch, MagicMock
from utils.news import fetch_rss
class TestFetchRss:
"""Тесты функции fetch_rss() — получение и парсинг RSS-ленты."""
@patch("utils.news._session.get")
def test_fetch_rss_success_rss20(self, mock_get):
"""Успешный ответ RSS 2.0 должен вернуть список статей."""
rss_content = """
-
Статья 1
https://habr.com/1
https://habr.com/1
Mon, 28 May 2026 10:00:00 +0000
Автор 1
AI
ML
-
Статья 2
https://habr.com/2
Mon, 28 May 2026 12:00:00 +0000
Автор 2
""".encode()
mock_response = MagicMock()
mock_response.content = rss_content
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss"))
assert result is not None
assert len(result) == 2
assert result[0]["title"] == "Статья 1"
assert result[0]["link"] == "https://habr.com/1"
assert result[0]["pub_date"] == "Mon, 28 May 2026 10:00:00 +0000"
assert result[0]["creator"] == "Автор 1"
assert result[0]["tags"] == ["AI", "ML"]
assert result[1]["title"] == "Статья 2"
assert result[1]["creator"] == "Автор 2"
assert result[1]["tags"] == []
@patch("utils.news._session.get")
def test_fetch_rss_success_atom(self, mock_get):
"""Успешный ответ Atom должен вернуть список статей."""
atom_content = """
Atom статья 1
2026-05-28T10:00:00Z
Atom автор
AI
""".encode()
mock_response = MagicMock()
mock_response.content = atom_content
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/atom"))
assert result is not None
assert len(result) == 1
assert result[0]["title"] == "Atom статья 1"
assert result[0]["link"] == "https://habr.com/atom/1"
assert result[0]["pub_date"] == "2026-05-28T10:00:00Z"
assert result[0]["creator"] == "Atom автор"
assert result[0]["tags"] == ["AI"]
@patch("utils.news._session.get")
def test_fetch_rss_empty_items(self, mock_get):
"""RSS без items должен вернуть пустой список."""
rss_content = """
""".encode()
mock_response = MagicMock()
mock_response.content = rss_content
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss"))
assert result == []
@patch("utils.news._session.get")
def test_fetch_rss_no_matching_format(self, mock_get):
"""Неизвестный формат XML должен вернуть пустой список."""
xml_content = """
""".encode()
mock_response = MagicMock()
mock_response.content = xml_content
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/xml"))
assert result == []
@patch("utils.news._session.get")
def test_fetch_rss_missing_title(self, mock_get):
"""Статья без title должна получить 'Без названия'."""
rss_content = """
-
Без title
https://habr.com/1
Mon, 28 May 2026 10:00:00 +0000
""".encode()
mock_response = MagicMock()
mock_response.content = rss_content
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss"))
assert result is not None
assert result[0]["title"] == "Без title"
assert result[0]["link"] == "https://habr.com/1"
assert result[0]["pub_date"] == "Mon, 28 May 2026 10:00:00 +0000"
assert result[0]["creator"] == ""
assert result[0]["tags"] == []
@patch("utils.news._session.get")
def test_fetch_rss_missing_guid(self, mock_get):
"""Статья без guid isPermaLink должна иметь пустую ссылку."""
rss_content = """
-
Без guid
https://habr.com/1
https://habr.com/1
""".encode()
mock_response = MagicMock()
mock_response.content = rss_content
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss"))
assert result is not None
assert result[0]["title"] == "Без guid"
assert result[0]["link"] == ""
@patch("utils.news._session.get")
def test_fetch_rss_limit_to_10(self, mock_get):
"""Больше 10 items должно быть обрезано до 10."""
items = "\n".join(
f""" -
Статья {i}
https://habr.com/{i}
Mon, 28 May 2026 10:00:00 +0000
"""
for i in range(15)
)
rss_content = (f"""
{items}
""").encode()
mock_response = MagicMock()
mock_response.content = rss_content
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss"))
assert result is not None
assert len(result) == 10
assert result[0]["title"] == "Статья 0"
assert result[9]["title"] == "Статья 9"
@patch("utils.news._session.get")
def test_fetch_rss_http_error(self, mock_get):
"""HTTP-ошибка должна вернуть None."""
import requests
mock_get.side_effect = requests.exceptions.HTTPError("404 Not Found")
result = asyncio.run(fetch_rss("https://example.com/rss"))
assert result is None
@patch("utils.news._session.get")
def test_fetch_rss_connection_error(self, mock_get):
"""Ошибка соединения должна вернуть None."""
import requests
mock_get.side_effect = requests.exceptions.ConnectionError("No connection")
result = asyncio.run(fetch_rss("https://example.com/rss"))
assert result is None
@patch("utils.news._session.get")
def test_fetch_rss_timeout(self, mock_get):
"""Таймаут должен вернуть None."""
import requests
mock_get.side_effect = requests.exceptions.Timeout("Request timed out")
result = asyncio.run(fetch_rss("https://example.com/rss"))
assert result is None
@patch("utils.news._session.get")
def test_fetch_rss_ssl_error(self, mock_get):
"""SSLError должен вернуть None."""
import requests
mock_get.side_effect = requests.exceptions.SSLError("SSL handshake failed")
result = asyncio.run(fetch_rss("https://example.com/rss"))
assert result is None
@patch("utils.news._session.get")
def test_fetch_rss_empty_tags(self, mock_get):
"""Статья с пустыми тегами должна иметь пустые строки."""
rss_content = """
-
Пустые теги
https://habr.com/1
Mon, 28 May 2026 10:00:00 +0000
""".encode()
mock_response = MagicMock()
mock_response.content = rss_content
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss"))
assert result is not None
assert result[0]["title"] == "Пустые теги"
assert result[0]["link"] == "https://habr.com/1"
assert result[0]["pub_date"] == "Mon, 28 May 2026 10:00:00 +0000"
assert result[0]["creator"] == ""
assert result[0]["tags"] == []
@patch("utils.news._session.get")
def test_fetch_rss_category_without_text(self, mock_get):
"""Категория без текста должна быть пропущена."""
rss_content = """
-
Статья
https://habr.com/1
AI
""".encode()
mock_response = MagicMock()
mock_response.content = rss_content
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss"))
assert result is not None
assert result[0]["tags"] == ["AI"]
@patch("utils.news._session.get")
def test_fetch_rss_atom_missing_author(self, mock_get):
"""Atom feed без автора должен иметь пустого creator."""
atom_content = """
Без автора
2026-05-28T10:00:00Z
""".encode()
mock_response = MagicMock()
mock_response.content = atom_content
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/atom"))
assert result is not None
assert result[0]["title"] == "Без автора"
assert result[0]["creator"] == ""
@patch("utils.news._session.get")
def test_fetch_rss_atom_missing_link(self, mock_get):
"""Atom feed без link должен иметь пустую ссылку."""
atom_content = """
Без ссылки
2026-05-28T10:00:00Z
""".encode()
mock_response = MagicMock()
mock_response.content = atom_content
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/atom"))
assert result is not None
assert result[0]["title"] == "Без ссылки"
assert result[0]["link"] == ""
@patch("utils.news._session.get")
def test_fetch_rss_request_exception(self, mock_get):
"""Общий RequestException должен вернуть None."""
import requests
mock_get.side_effect = requests.RequestException("Generic error")
result = asyncio.run(fetch_rss("https://example.com/rss"))
assert result is None
@patch("utils.news._session.get")
def test_fetch_rss_guid_fallback_to_link(self, mock_get):
"""Если нет guid isPermaLink, ссылка должна быть пустой."""
rss_content = """
-
Статья
https://habr.com/alternative
https://habr.com/alternative
""".encode()
mock_response = MagicMock()
mock_response.content = rss_content
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss"))
assert result is not None
assert result[0]["link"] == ""
@patch("utils.news._session.get")
def test_fetch_rss_single_item(self, mock_get):
"""Один item должен быть распарсен корректно."""
rss_content = """
-
Единственная статья
https://habr.com/1
Mon, 28 May 2026 10:00:00 +0000
Единственный автор
ML
""".encode()
mock_response = MagicMock()
mock_response.content = rss_content
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss"))
assert result is not None
assert len(result) == 1
assert result[0]["title"] == "Единственная статья"
assert result[0]["link"] == "https://habr.com/1"
assert result[0]["creator"] == "Единственный автор"
assert result[0]["tags"] == ["ML"]
@patch("utils.news._session.get")
def test_fetch_rss_special_characters_in_title(self, mock_get):
"""Заголовки со спецсимволами должны парситься корректно."""
rss_content = """
-
AI & ML: будущее <технологий>
https://habr.com/1
Mon, 28 May 2026 10:00:00 +0000
""".encode()
mock_response = MagicMock()
mock_response.content = rss_content
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss"))
assert result is not None
assert "AI" in result[0]["title"]
assert "ML" in result[0]["title"]
@patch("utils.news._session.get")
def test_fetch_rss_date_with_gmt(self, mock_get):
"""Дата с GMT должна парситься корректно."""
rss_content = """
-
Статья
https://habr.com/1
Mon, 28 May 2026 10:00:00 GMT
""".encode()
mock_response = MagicMock()
mock_response.content = rss_content
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss"))
assert result is not None
assert result[0]["pub_date"] == "Mon, 28 May 2026 10:00:00 GMT"
@patch("utils.news._session.get")
def test_fetch_rss_many_categories(self, mock_get):
"""Множество категорий должны быть собраны."""
rss_content = """
-
Статья
https://habr.com/1
AI
ML
Deep Learning
NLP
Computer Vision
""".encode()
mock_response = MagicMock()
mock_response.content = rss_content
mock_response.raise_for_status = MagicMock()
mock_get.return_value = mock_response
result = asyncio.run(fetch_rss("https://example.com/rss"))
assert result is not None
assert result[0]["tags"] == ["AI", "ML", "Deep Learning", "NLP", "Computer Vision"]