"""Test RSS feeds.""" import datetime import logging import re from collections.abc import Callable from contextlib import AbstractContextManager from datetime import UTC from datetime import timedelta from pathlib import Path from typing import TYPE_CHECKING import pytest from django.core.files.base import ContentFile from django.core.management import call_command from django.urls import reverse from django.utils import timezone from hypothesis.extra.django import TestCase from twitch.feeds import RSS_STYLESHEETS from twitch.feeds import DropCampaignFeed from twitch.feeds import GameCampaignFeed from twitch.feeds import GameFeed from twitch.feeds import OrganizationRSSFeed from twitch.feeds import RewardCampaignFeed from twitch.feeds import TTVDropsBaseFeed from twitch.feeds import discord_timestamp from twitch.models import Channel from twitch.models import ChatBadge from twitch.models import ChatBadgeSet from twitch.models import DropBenefit from twitch.models import DropCampaign from twitch.models import Game from twitch.models import Organization from twitch.models import RewardCampaign from twitch.models import TimeBasedDrop logger: logging.Logger = logging.getLogger(__name__) STYLESHEET_PATH: Path = ( Path(__file__).resolve().parents[2] / "static" / "rss_styles.xslt" ) if TYPE_CHECKING: from django.test.client import _MonkeyPatchedWSGIResponse from django.utils.feedgenerator import Enclosure from twitch.tests.test_badge_views import Client class RSSFeedTestCase(TestCase): """Test RSS feeds.""" def setUp(self) -> None: """Set up test fixtures.""" self.org: Organization = Organization.objects.create( twitch_id="test-org-123", name="Test Organization", ) self.org.save() self.game: Game = Game.objects.create( twitch_id="test-game-123", slug="test-game", name="Test Game", display_name="Test Game", ) self.game.owners.add(self.org) self.campaign: DropCampaign = DropCampaign.objects.create( twitch_id="test-campaign-123", name="Test Campaign", game=self.game, start_at=timezone.now(), end_at=timezone.now() + timedelta(days=7), operation_names=["DropCampaignDetails"], ) # populate the new enclosure metadata fields so feeds can return them self.game.box_art_size_bytes = 42 self.game.box_art_mime_type = "image/png" # provide a URL so that the RSS enclosure element is emitted self.game.box_art = "https://example.com/box.png" self.game.save() self.campaign.image_size_bytes = 314 self.campaign.image_mime_type = "image/gif" # feed will only include an enclosure if there is some image URL/field self.campaign.image_url = "https://example.com/campaign.png" self.campaign.save() self.reward_campaign: RewardCampaign = RewardCampaign.objects.create( twitch_id="test-reward-123", name="Test Reward Campaign", brand="Test Brand", starts_at=timezone.now() - timedelta(days=1), ends_at=timezone.now() + timedelta(days=7), status="ACTIVE", summary="Test reward summary", instructions="Watch and complete objectives", external_url="https://example.com/reward", about_url="https://example.com/about", is_sitewide=False, game=self.game, ) def test_organization_feed(self) -> None: """Test organization feed returns 200.""" url: str = reverse("core:organization_feed") response: _MonkeyPatchedWSGIResponse = self.client.get(url) assert response.status_code == 200 assert response["Content-Type"] == "application/xml; charset=utf-8" assert response["Content-Disposition"] == "inline" def test_game_feed(self) -> None: """Test game feed returns 200.""" url: str = reverse("core:game_feed") response: _MonkeyPatchedWSGIResponse = self.client.get(url) assert response.status_code == 200 assert response["Content-Type"] == "application/xml; charset=utf-8" assert response["Content-Disposition"] == "inline" content: str = response.content.decode("utf-8") assert "Owned by Test Organization." in content expected_rss_link: str = reverse( "core:game_campaign_feed", args=[self.game.twitch_id], ) assert expected_rss_link in content # enclosure metadata from our new fields should be present msg: str = f"Expected enclosure length from image_size_bytes, got: {content}" assert 'length="42"' in content, msg msg = f"Expected enclosure type from image_mime_type, got: {content}" assert 'type="image/png"' in content, msg def test_organization_atom_feed(self) -> None: """Test organization Atom feed returns 200 and Atom XML.""" url: str = reverse("core:organization_feed_atom") response: _MonkeyPatchedWSGIResponse = self.client.get(url) msg: str = f"Expected 200 OK, got {response.status_code} with content: {response.content.decode('utf-8')}" assert response.status_code == 200, msg assert response["Content-Type"] == "application/xml; charset=utf-8" content: str = response.content.decode("utf-8") assert " None: """Test game Atom feed returns 200 and contains expected content.""" url: str = reverse("core:game_feed_atom") response: _MonkeyPatchedWSGIResponse = self.client.get(url) assert response.status_code == 200 assert response["Content-Type"] == "application/xml; charset=utf-8" content: str = response.content.decode("utf-8") assert "Owned by Test Organization." in content expected_atom_link: str = reverse( "core:game_campaign_feed", args=[self.game.twitch_id], ) assert expected_atom_link in content # Atom should include box art URL somewhere in content assert "https://example.com/box.png" in content def test_campaign_atom_feed_uses_url_ids_and_correct_self_link(self) -> None: """Atom campaign feed should use URL ids and a matching self link.""" url: str = reverse("core:campaign_feed_atom") response: _MonkeyPatchedWSGIResponse = self.client.get(url) assert response.status_code == 200 content: str = response.content.decode("utf-8") msg: str = f"Expected self link in Atom feed, got: {content}" assert 'rel="self"' in content, msg msg: str = f"Expected self link to point to campaign feed URL, got: {content}" assert 'href="https://ttvdrops.lovinator.space/atom/campaigns/"' in content, msg msg: str = f"Expected entry ID to be the campaign URL, got: {content}" assert ( "https://ttvdrops.lovinator.space/twitch/campaigns/test-campaign-123/" in content ), msg def test_all_atom_feeds_use_url_ids_and_correct_self_links(self) -> None: """All Atom feeds should use absolute URL entry IDs and matching self links.""" atom_feed_cases: list[tuple[str, dict[str, str], str]] = [ ( "core:campaign_feed_atom", {}, f"https://ttvdrops.lovinator.space{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}", ), ( "core:game_feed_atom", {}, f"https://ttvdrops.lovinator.space{reverse('twitch:game_detail', args=[self.game.twitch_id])}", ), ( "core:game_campaign_feed_atom", {"twitch_id": self.game.twitch_id}, f"https://ttvdrops.lovinator.space{reverse('twitch:campaign_detail', args=[self.campaign.twitch_id])}", ), ( "core:organization_feed_atom", {}, f"https://ttvdrops.lovinator.space{reverse('twitch:organization_detail', args=[self.org.twitch_id])}", ), ( "core:reward_campaign_feed_atom", {}, f"https://ttvdrops.lovinator.space{reverse('twitch:reward_campaign_detail', args=[self.reward_campaign.twitch_id])}", ), ] for url_name, kwargs, expected_entry_id in atom_feed_cases: url: str = reverse(url_name, kwargs=kwargs) response: _MonkeyPatchedWSGIResponse = self.client.get(url) assert response.status_code == 200 content: str = response.content.decode("utf-8") expected_self_link: str = f'href="https://ttvdrops.lovinator.space{url}"' msg: str = f"Expected self link in Atom feed {url_name}, got: {content}" assert 'rel="self"' in content, msg msg = f"Expected self link to match feed URL for {url_name}, got: {content}" assert expected_self_link in content, msg msg = f"Expected entry ID to be absolute URL for {url_name}, got: {content}" assert f"{expected_entry_id}" in content, msg def test_campaign_atom_feed_summary_does_not_wrap_list_in_paragraph(self) -> None: """Atom summary HTML should not include invalid