discord-rss-bot/tests/test_hoyolab_api.py
Joakim Hellsén c55610affa
All checks were successful
Test and build Docker image / docker (push) Successful in 1m40s
Improve tests
2026-04-09 21:38:33 +02:00

248 lines
8.9 KiB
Python

from __future__ import annotations
import json
import typing
from types import SimpleNamespace
from unittest.mock import MagicMock
from unittest.mock import patch
import pytest
import requests
from discord_rss_bot.hoyolab_api import create_hoyolab_webhook
from discord_rss_bot.hoyolab_api import extract_post_id_from_hoyolab_url
from discord_rss_bot.hoyolab_api import fetch_hoyolab_post
from discord_rss_bot.hoyolab_api import is_c3kay_feed
if typing.TYPE_CHECKING:
from reader import Entry
class TestExtractPostIdFromHoyolabUrl:
def test_extract_post_id_from_article_url(self) -> None:
"""Test extracting post ID from a direct article URL."""
test_cases: list[str] = [
"https://www.hoyolab.com/article/38588239",
"http://hoyolab.com/article/12345",
"https://www.hoyolab.com/article/987654321/comments",
]
expected_ids: list[str] = ["38588239", "12345", "987654321"]
for url, expected_id in zip(test_cases, expected_ids, strict=False):
assert extract_post_id_from_hoyolab_url(url) == expected_id
def test_url_without_post_id(self) -> None:
"""Test with a URL that doesn't have a post ID."""
test_cases: list[str] = [
"https://www.hoyolab.com/community",
]
for url in test_cases:
assert extract_post_id_from_hoyolab_url(url) is None
def test_edge_cases(self) -> None:
"""Test edge cases like None, empty string, and malformed URLs."""
test_cases: list[str | None] = [
None,
"",
"not_a_url",
"http:/", # Malformed URL
]
for url in test_cases:
assert extract_post_id_from_hoyolab_url(url) is None # type: ignore
def make_entry(link: str | None = "https://www.hoyolab.com/article/38588239") -> SimpleNamespace:
feed: SimpleNamespace = SimpleNamespace(url="https://feeds.c3kay.de/hoyolab.xml")
return SimpleNamespace(
id="entry-123",
link=link,
feed=feed,
)
class TestIsC3KayFeed:
def test_true_for_c3kay_feed(self) -> None:
assert is_c3kay_feed("https://feeds.c3kay.de/rss") is True
def test_false_for_non_c3kay_feed(self) -> None:
assert is_c3kay_feed("https://example.com/rss") is False
class TestFetchHoyolabPost:
@patch("discord_rss_bot.hoyolab_api.requests.get")
def test_returns_none_for_empty_post_id(self, mock_get: MagicMock) -> None:
assert fetch_hoyolab_post("") is None
mock_get.assert_not_called()
@patch("discord_rss_bot.hoyolab_api.requests.get")
def test_returns_post_data_for_success_response(self, mock_get: MagicMock) -> None:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"retcode": 0,
"data": {
"post": {
"post_id": "38588239",
"subject": "Event",
},
},
}
mock_get.return_value = mock_response
result = fetch_hoyolab_post("38588239")
assert result == {"post_id": "38588239", "subject": "Event"}
assert mock_get.call_args.args[0].endswith("post_id=38588239")
@patch("discord_rss_bot.hoyolab_api.logger")
@patch("discord_rss_bot.hoyolab_api.requests.get")
def test_returns_none_and_logs_warning_for_non_success_payload(
self,
mock_get: MagicMock,
mock_logger: MagicMock,
) -> None:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.text = "bad payload"
mock_response.json.return_value = {
"retcode": -1,
"data": {},
}
mock_get.return_value = mock_response
result = fetch_hoyolab_post("38588239")
assert result is None
mock_logger.warning.assert_called_once()
@patch("discord_rss_bot.hoyolab_api.logger")
@patch("discord_rss_bot.hoyolab_api.requests.get")
def test_returns_none_and_logs_exception_on_request_error(
self,
mock_get: MagicMock,
mock_logger: MagicMock,
) -> None:
mock_get.side_effect = requests.RequestException("network issue")
result = fetch_hoyolab_post("38588239")
assert result is None
mock_logger.exception.assert_called_once()
class TestCreateHoyolabWebhook:
@patch("discord_rss_bot.hoyolab_api.requests.get")
@patch("discord_rss_bot.hoyolab_api.DiscordEmbed")
@patch("discord_rss_bot.hoyolab_api.DiscordWebhook")
def test_builds_embed_webhook_with_full_post_data(
self,
mock_webhook_cls: MagicMock,
mock_embed_cls: MagicMock,
mock_requests_get: MagicMock,
) -> None:
webhook_instance = MagicMock()
embed_instance = MagicMock()
mock_webhook_cls.return_value = webhook_instance
mock_embed_cls.return_value = embed_instance
video_response = MagicMock()
video_response.ok = True
video_response.content = b"video-bytes"
mock_requests_get.return_value = video_response
post_data = {
"post": {
"subject": "Update 4.0",
"content": json.dumps({"describe": "Patch notes"}),
"desc": "fallback description",
"structured_content": json.dumps(
[{"insert": {"video": "https://www.youtube.com/embed/abc123_XY"}}],
),
"event_start_date": "1712000000",
"event_end_date": "1712600000",
"created_at": "1711000000",
},
"image_list": [{"url": "https://img.example.com/hero.jpg", "height": 1080, "width": 1920}],
"video": {"url": "https://cdn.example.com/video.mp4"},
"game": {"color": "#11AAFF"},
"user": {"nickname": "Paimon", "avatar_url": "https://img.example.com/avatar.jpg"},
"classification": {"name": "Official"},
}
entry = make_entry(link=None)
entry = typing.cast("Entry", entry)
webhook = create_hoyolab_webhook("https://discord.test/webhook", entry, post_data)
assert webhook is webhook_instance
mock_webhook_cls.assert_called_once_with(url="https://discord.test/webhook", rate_limit_retry=True)
embed_instance.set_title.assert_called_once_with("Update 4.0")
embed_instance.set_url.assert_called_once_with("https://feeds.c3kay.de/hoyolab.xml")
embed_instance.set_image.assert_called_once_with(
url="https://img.example.com/hero.jpg",
height=1080,
width=1920,
)
embed_instance.set_color.assert_called_once_with("11AAFF")
embed_instance.set_footer.assert_called_once_with(text="Official")
embed_instance.add_embed_field.assert_any_call(name="Start", value="<t:1712000000:R>")
embed_instance.add_embed_field.assert_any_call(name="End", value="<t:1712600000:R>")
embed_instance.set_timestamp.assert_called_once_with(timestamp="1711000000")
webhook_instance.add_file.assert_called_once_with(file=b"video-bytes", filename="entry-123.mp4")
webhook_instance.add_embed.assert_called_once_with(embed_instance)
assert webhook_instance.content == "https://www.youtube.com/watch?v=abc123_XY"
webhook_instance.remove_embeds.assert_called_once()
@patch("discord_rss_bot.hoyolab_api.requests.get")
@patch("discord_rss_bot.hoyolab_api.DiscordEmbed")
@patch("discord_rss_bot.hoyolab_api.DiscordWebhook")
def test_handles_invalid_structured_content_without_removing_embeds(
self,
mock_webhook_cls: MagicMock,
mock_embed_cls: MagicMock,
mock_requests_get: MagicMock,
) -> None:
webhook_instance = MagicMock()
embed_instance = MagicMock()
mock_webhook_cls.return_value = webhook_instance
mock_embed_cls.return_value = embed_instance
mock_requests_get.return_value = MagicMock(ok=False)
post_data = {
"post": {
"subject": "News",
"content": "{}",
"structured_content": "not-json",
},
}
entry = make_entry()
entry = typing.cast("Entry", entry)
webhook = create_hoyolab_webhook("https://discord.test/webhook", entry, post_data)
assert webhook is webhook_instance
webhook_instance.remove_embeds.assert_not_called()
def test_extract_post_id_with_querystring() -> None:
url = "https://www.hoyolab.com/article/38588239?utm_source=feed"
assert extract_post_id_from_hoyolab_url(url) == "38588239"
def test_extract_post_id_non_string_input_returns_none() -> None:
assert extract_post_id_from_hoyolab_url(None) is None # type: ignore[arg-type]
@pytest.mark.parametrize(
("url", "expected"),
[
("https://feeds.c3kay.de/rss", True),
("https://www.hoyolab.com/feed", False),
],
)
def test_is_c3kay_feed_parametrized(*, url: str, expected: bool) -> None:
assert is_c3kay_feed(url) is expected