From de7a7d5d0ec1f06a6bf93ee1740c25cf9583acb5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Hells=C3=A9n?= Date: Sun, 1 Feb 2026 23:08:48 +0100 Subject: [PATCH] Test all urls --- pyproject.toml | 9 +- twitch/models.py | 9 +- twitch/tests/test_feeds.py | 174 ++++++++++++++++++++++++++++++------- twitch/urls.py | 114 +++--------------------- uv.lock | 28 ++++++ 5 files changed, 198 insertions(+), 136 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index dce5df2..be2b6f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,7 +19,14 @@ dependencies = [ ] [dependency-groups] -dev = ["pytest", "pytest-django", "djlint", "django-stubs", "pytest-cov"] +dev = [ + "pytest", + "pytest-django", + "djlint", + "django-stubs", + "pytest-cov", + "hypothesis[django]", +] [tool.pytest.ini_options] DJANGO_SETTINGS_MODULE = "config.settings" diff --git a/twitch/models.py b/twitch/models.py index e8edd70..c1ed572 100644 --- a/twitch/models.py +++ b/twitch/models.py @@ -4,6 +4,7 @@ import logging from typing import TYPE_CHECKING from django.db import models +from django.urls import reverse from django.utils import timezone if TYPE_CHECKING: @@ -133,12 +134,14 @@ class Game(models.Model): return f"{self.display_name} ({self.name})" return self.display_name or self.name or self.slug or self.twitch_id + def get_absolute_url(self) -> str: + """Return canonical URL to the game details page.""" + return reverse("game_detail", kwargs={"twitch_id": self.twitch_id}) + @property def organizations(self) -> models.QuerySet[Organization]: """Return orgs that own games with campaigns for this game.""" - return Organization.objects.filter( - games__drop_campaigns__game=self, - ).distinct() + return Organization.objects.filter(games__drop_campaigns__game=self).distinct() @property def get_game_name(self) -> str: diff --git a/twitch/tests/test_feeds.py b/twitch/tests/test_feeds.py index 9c12c78..27199fe 100644 --- a/twitch/tests/test_feeds.py +++ b/twitch/tests/test_feeds.py @@ -3,37 +3,46 @@ from __future__ import annotations from datetime import timedelta +from typing import TYPE_CHECKING -from django.test import TestCase +import pytest from django.urls import reverse from django.utils import timezone +from hypothesis.extra.django import TestCase +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 +if TYPE_CHECKING: + from django.test.client import _MonkeyPatchedWSGIResponse + + 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.objects.create( + self.org: Organization = Organization.objects.create( twitch_id="test-org-123", name="Test Organization", ) - self.game = Game.objects.create( + 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.objects.create( + self.campaign: DropCampaign = DropCampaign.objects.create( twitch_id="test-campaign-123", name="Test Campaign", game=self.game, @@ -44,42 +53,42 @@ class RSSFeedTestCase(TestCase): def test_organization_feed(self) -> None: """Test organization feed returns 200.""" - url = reverse("twitch:organization_feed") - response = self.client.get(url) + url: str = reverse("twitch:organization_feed") + response: _MonkeyPatchedWSGIResponse = self.client.get(url) assert response.status_code == 200 assert response["Content-Type"] == "application/rss+xml; charset=utf-8" def test_game_feed(self) -> None: """Test game feed returns 200.""" - url = reverse("twitch:game_feed") - response = self.client.get(url) + url: str = reverse("twitch:game_feed") + response: _MonkeyPatchedWSGIResponse = self.client.get(url) assert response.status_code == 200 assert response["Content-Type"] == "application/rss+xml; charset=utf-8" def test_campaign_feed(self) -> None: """Test campaign feed returns 200.""" - url = reverse("twitch:campaign_feed") - response = self.client.get(url) + url: str = reverse("twitch:campaign_feed") + response: _MonkeyPatchedWSGIResponse = self.client.get(url) assert response.status_code == 200 assert response["Content-Type"] == "application/rss+xml; charset=utf-8" def test_campaign_feed_includes_badge_description(self) -> None: """Badge benefit descriptions should be visible in the RSS drop summary.""" - drop = TimeBasedDrop.objects.create( + drop: TimeBasedDrop = TimeBasedDrop.objects.create( twitch_id="drop-1", name="Diana Chat Badge", campaign=self.campaign, required_minutes_watched=0, required_subs=1, ) - benefit = DropBenefit.objects.create( + benefit: DropBenefit = DropBenefit.objects.create( twitch_id="benefit-1", name="Diana", distribution_type="BADGE", ) drop.benefits.add(benefit) - badge_set = ChatBadgeSet.objects.create(set_id="diana") + badge_set: ChatBadgeSet = ChatBadgeSet.objects.create(set_id="diana") ChatBadge.objects.create( badge_set=badge_set, badge_id="1", @@ -90,36 +99,36 @@ class RSSFeedTestCase(TestCase): description="This badge was earned by subscribing.", ) - url = reverse("twitch:campaign_feed") - response = self.client.get(url) + url: str = reverse("twitch:campaign_feed") + response: _MonkeyPatchedWSGIResponse = self.client.get(url) assert response.status_code == 200 - content = response.content.decode("utf-8") + content: str = response.content.decode("utf-8") assert "This badge was earned by subscribing." in content def test_game_campaign_feed(self) -> None: """Test game-specific campaign feed returns 200.""" - url = reverse("twitch:game_campaign_feed", args=[self.game.twitch_id]) - response = self.client.get(url) + url: str = reverse("twitch:game_campaign_feed", args=[self.game.twitch_id]) + response: _MonkeyPatchedWSGIResponse = self.client.get(url) assert response.status_code == 200 assert response["Content-Type"] == "application/rss+xml; charset=utf-8" # Verify the game name is in the feed - content = response.content.decode("utf-8") + content: str = response.content.decode("utf-8") assert "Test Game" in content def test_organization_campaign_feed(self) -> None: """Test organization-specific campaign feed returns 200.""" - url = reverse("twitch:organization_campaign_feed", args=[self.org.twitch_id]) - response = self.client.get(url) + url: str = reverse("twitch:organization_campaign_feed", args=[self.org.twitch_id]) + response: _MonkeyPatchedWSGIResponse = self.client.get(url) assert response.status_code == 200 assert response["Content-Type"] == "application/rss+xml; charset=utf-8" # Verify the organization name is in the feed - content = response.content.decode("utf-8") + content: str = response.content.decode("utf-8") assert "Test Organization" in content def test_game_campaign_feed_filters_correctly(self) -> None: """Test game campaign feed only shows campaigns for that game.""" # Create another game with a campaign - other_game = Game.objects.create( + other_game: Game = Game.objects.create( twitch_id="other-game-123", slug="other-game", name="Other Game", @@ -136,9 +145,9 @@ class RSSFeedTestCase(TestCase): ) # Get feed for first game - url = reverse("twitch:game_campaign_feed", args=[self.game.twitch_id]) - response = self.client.get(url) - content = response.content.decode("utf-8") + url: str = reverse("twitch:game_campaign_feed", args=[self.game.twitch_id]) + response: _MonkeyPatchedWSGIResponse = self.client.get(url) + content: str = response.content.decode("utf-8") # Should contain first campaign assert "Test Campaign" in content @@ -169,11 +178,118 @@ class RSSFeedTestCase(TestCase): ) # Get feed for first organization - url = reverse("twitch:organization_campaign_feed", args=[self.org.twitch_id]) - response = self.client.get(url) - content = response.content.decode("utf-8") + url: str = reverse("twitch:organization_campaign_feed", args=[self.org.twitch_id]) + response: _MonkeyPatchedWSGIResponse = self.client.get(url) + content: str = response.content.decode("utf-8") # Should contain first campaign assert "Test Campaign" in content # Should NOT contain other campaign assert "Other Campaign 2" not in content + + +URL_NAMES: list[tuple[str, dict[str, str]]] = [ + ("twitch:dashboard", {}), + ("twitch:badge_list", {}), + ("twitch:badge_set_detail", {"set_id": "test-set-123"}), + ("twitch:campaign_list", {}), + ("twitch:campaign_detail", {"twitch_id": "test-campaign-123"}), + ("twitch:channel_list", {}), + ("twitch:channel_detail", {"twitch_id": "test-channel-123"}), + ("twitch:debug", {}), + ("twitch:docs_rss", {}), + ("twitch:emote_gallery", {}), + ("twitch:game_list", {}), + ("twitch:game_list_simple", {}), + ("twitch:game_detail", {"twitch_id": "test-game-123"}), + ("twitch:org_list", {}), + ("twitch:organization_detail", {"twitch_id": "test-org-123"}), + ("twitch:reward_campaign_list", {}), + ("twitch:reward_campaign_detail", {"twitch_id": "test-reward-123"}), + ("twitch:search", {}), + ("twitch:campaign_feed", {}), + ("twitch:game_feed", {}), + ("twitch:game_campaign_feed", {"twitch_id": "test-game-123"}), + ("twitch:organization_feed", {}), + ("twitch:organization_campaign_feed", {"twitch_id": "test-org-123"}), + ("twitch:reward_campaign_feed", {}), + ("twitch:campaign_feed_v1", {}), + ("twitch:game_feed_v1", {}), + ("twitch:game_campaign_feed_v1", {"twitch_id": "test-game-123"}), + ("twitch:organization_feed_v1", {}), + ("twitch:organization_campaign_feed_v1", {"twitch_id": "test-org-123"}), + ("twitch:reward_campaign_feed_v1", {}), +] + + +@pytest.mark.django_db +@pytest.mark.parametrize(("url_name", "kwargs"), URL_NAMES) +def test_rss_feeds_return_200(client: Client, url_name: str, kwargs: dict[str, str]) -> None: + """Test if feeds return HTTP 200. + + Args: + client (Client): Django test client instance. + url_name (str): URL pattern from urls.py. + kwargs (dict[str, str]): Extra data used in URL. + For example 'rss/organizations//campaigns/' wants twitch_id. + """ + org: Organization = Organization.objects.create( + twitch_id="test-org-123", + name="Test Organization", + ) + game: Game = Game.objects.create( + twitch_id="test-game-123", + slug="test-game", + name="Test Game", + display_name="Test Game", + ) + game.owners.add(org) + _campaign: DropCampaign = DropCampaign.objects.create( + twitch_id="test-campaign-123", + name="Test Campaign", + game=game, + start_at=timezone.now(), + end_at=timezone.now() + timedelta(days=7), + operation_names=["DropCampaignDetails"], + ) + + _reward_campaign: RewardCampaign = RewardCampaign.objects.create( + twitch_id="test-reward-123", + name="Test Reward Campaign", + brand="Test Brand", + starts_at=timezone.now(), + ends_at=timezone.now() + timedelta(days=14), + 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=game, + ) + + _channel: Channel = Channel.objects.create( + twitch_id="test-channel-123", + name="testchannel", + display_name="TestChannel", + ) + + badge_set: ChatBadgeSet = ChatBadgeSet.objects.create( + set_id="test-set-123", + ) + + _badge: ChatBadge = ChatBadge.objects.create( + badge_set=badge_set, + badge_id="1", + image_url_1x="https://example.com/badge_18.png", + image_url_2x="https://example.com/badge_36.png", + image_url_4x="https://example.com/badge_72.png", + title="Test Badge", + description="Test badge description", + click_action="visit_url", + click_url="https://example.com", + ) + + url: str = reverse(viewname=url_name, kwargs=kwargs) + response: _MonkeyPatchedWSGIResponse = client.get(url) + assert response.status_code == 200 diff --git a/twitch/urls.py b/twitch/urls.py index 5394655..e096132 100644 --- a/twitch/urls.py +++ b/twitch/urls.py @@ -21,117 +21,25 @@ app_name = "twitch" rss_feeds_latest: list[URLPattern] = [ - # /rss/campaigns - RSS feed for latest drop campaigns. - # Example: - # https://ttvdrops.lovinator.space/rss/campaigns/ - # http://localhost:8000/rss/campaigns/ - path( - "rss/campaigns/", - DropCampaignFeed(), - name="campaign_feed", - ), - # /rss/games - RSS feed for latest games. - # Example: - # https://ttvdrops.lovinator.space/rss/games/ - # http://localhost:8000/rss/games/ - path( - "rss/games/", - GameFeed(), - name="game_feed", - ), - # /rss/games//campaigns/ - RSS feed for the latest drop campaigns of a specific game. - # Example: - # https://ttvdrops.lovinator.space/rss/games/21779/campaigns/ - # http://localhost:8000/rss/games/21779/campaigns/ - path( - "rss/games//campaigns/", - GameCampaignFeed(), - name="game_campaign_feed", - ), - # /rss/organizations/ - RSS feed for latest organizations. - # Example: - # https://ttvdrops.lovinator.space/rss/organizations/ - # http://localhost:8000/rss/organizations/ - path( - "rss/organizations/", - OrganizationFeed(), - name="organization_feed", - ), - # /rss/organizations//campaigns/ - RSS feed for campaigns of a specific organization. - # Example: - # https://ttvdrops.lovinator.space/rss/organizations/931fd934-2149-4a85-a6d8-2190aa4439f3/campaigns/ - # http://localhost:8000/rss/organizations/931fd934-2149-4a85-a6d8-2190aa4439f3/campaigns/ - path( - "rss/organizations//campaigns/", - OrganizationCampaignFeed(), - name="organization_campaign_feed", - ), - # /rss/reward-campaigns/ - RSS feed for campaigns of a specific organization. - # Example: - # https://ttvdrops.lovinator.space/rss/reward-campaigns - # http://localhost:8000/rss/organizations/931fd934-2149-4a85-a6d8-2190aa4439f3/campaigns/ - path( - "rss/reward-campaigns/", - RewardCampaignFeed(), - name="reward_campaign_feed", - ), + path("rss/campaigns/", DropCampaignFeed(), name="campaign_feed"), + path("rss/games/", GameFeed(), name="game_feed"), + path("rss/games//campaigns/", GameCampaignFeed(), name="game_campaign_feed"), + path("rss/organizations/", OrganizationFeed(), name="organization_feed"), + path("rss/organizations//campaigns/", OrganizationCampaignFeed(), name="organization_campaign_feed"), + path("rss/reward-campaigns/", RewardCampaignFeed(), name="reward_campaign_feed"), ] v1_rss_feeds: list[URLPattern] = [ - # /rss/v1/campaigns - RSS feed for latest drop campaigns. - # Example: - # https://ttvdrops.lovinator.space/rss/campaigns/ - # http://localhost:8000/rss/campaigns/ - path( - "rss/v1/campaigns/", - DropCampaignFeed(), - name="campaign_feed_v1", - ), - # /rss/v1/games - RSS feed for latest games. - # Example: - # https://ttvdrops.lovinator.space/rss/games/ - # http://localhost:8000/rss/games/ - path( - "rss/v1/games/", - GameFeed(), - name="game_feed_v1", - ), - # /rss/games//campaigns/ - RSS feed for the latest drop campaigns of a specific game. - # Example: - # https://ttvdrops.lovinator.space/rss/games/21779/campaigns/ - # http://localhost:8000/rss/games/21779/campaigns/ - path( - "rss/v1/games//campaigns/", - GameCampaignFeed(), - name="game_campaign_feed_v1", - ), - # /rss/organizations/ - RSS feed for latest organizations. - # Example: - # https://ttvdrops.lovinator.space/rss/organizations/ - # http://localhost:8000/rss/organizations/ - path( - "rss/v1/organizations/", - OrganizationFeed(), - name="organization_feed_v1", - ), - # /rss/organizations//campaigns/ - RSS feed for campaigns of a specific organization. - # Example: - # https://ttvdrops.lovinator.space/rss/organizations/931fd934-2149-4a85-a6d8-2190aa4439f3/campaigns/ - # http://localhost:8000/rss/organizations/931fd934-2149-4a85-a6d8-2190aa4439f3/campaigns/ + path("rss/v1/campaigns/", DropCampaignFeed(), name="campaign_feed_v1"), + path("rss/v1/games/", GameFeed(), name="game_feed_v1"), + path("rss/v1/games//campaigns/", GameCampaignFeed(), name="game_campaign_feed_v1"), + path("rss/v1/organizations/", OrganizationFeed(), name="organization_feed_v1"), path( "rss/v1/organizations//campaigns/", OrganizationCampaignFeed(), name="organization_campaign_feed_v1", ), - # /rss/reward-campaigns/ - RSS feed for campaigns of a specific organization. - # Example: - # https://ttvdrops.lovinator.space/rss/reward-campaigns - # http://localhost:8000/rss/organizations/931fd934-2149-4a85-a6d8-2190aa4439f3/campaigns/ - path( - "rss/v1/reward-campaigns/", - RewardCampaignFeed(), - name="reward_campaign_feed_v1", - ), + path("rss/v1/reward-campaigns/", RewardCampaignFeed(), name="reward_campaign_feed_v1"), ] diff --git a/uv.lock b/uv.lock index f13b9f0..1ea1e21 100644 --- a/uv.lock +++ b/uv.lock @@ -246,6 +246,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] +[[package]] +name = "hypothesis" +version = "6.151.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sortedcontainers" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/47/03/9fd03d5db09029250e69745c1600edab16fe90947636f77a12ba92d79939/hypothesis-6.151.4.tar.gz", hash = "sha256:658a62da1c3ccb36746ac2f7dc4bb1a6e76bd314e0dc54c4e1aaba2503d5545c", size = 475706, upload-time = "2026-01-29T01:30:14.985Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9b/6d/01ad1b6c3b8cb2bb47eeaa9765dabc27cbe68e3b59f6cff83d5668f57780/hypothesis-6.151.4-py3-none-any.whl", hash = "sha256:a1cf7e0fdaa296d697a68ff3c0b3912c0050f07aa37e7d2ff33a966749d1d9b4", size = 543146, upload-time = "2026-01-29T01:30:12.805Z" }, +] + +[package.optional-dependencies] +django = [ + { name = "django" }, +] + [[package]] name = "idna" version = "3.11" @@ -541,6 +558,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "sortedcontainers" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, +] + [[package]] name = "sqlparse" version = "0.5.5" @@ -584,6 +610,7 @@ dependencies = [ dev = [ { name = "django-stubs" }, { name = "djlint" }, + { name = "hypothesis", extra = ["django"] }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-django" }, @@ -608,6 +635,7 @@ requires-dist = [ dev = [ { name = "django-stubs" }, { name = "djlint" }, + { name = "hypothesis", extras = ["django"] }, { name = "pytest" }, { name = "pytest-cov" }, { name = "pytest-django" },