Test all urls

This commit is contained in:
Joakim Hellsén 2026-02-01 23:08:48 +01:00
commit de7a7d5d0e
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
5 changed files with 198 additions and 136 deletions

View file

@ -19,7 +19,14 @@ dependencies = [
] ]
[dependency-groups] [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] [tool.pytest.ini_options]
DJANGO_SETTINGS_MODULE = "config.settings" DJANGO_SETTINGS_MODULE = "config.settings"

View file

@ -4,6 +4,7 @@ import logging
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from django.db import models from django.db import models
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
if TYPE_CHECKING: if TYPE_CHECKING:
@ -133,12 +134,14 @@ class Game(models.Model):
return f"{self.display_name} ({self.name})" return f"{self.display_name} ({self.name})"
return self.display_name or self.name or self.slug or self.twitch_id 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 @property
def organizations(self) -> models.QuerySet[Organization]: def organizations(self) -> models.QuerySet[Organization]:
"""Return orgs that own games with campaigns for this game.""" """Return orgs that own games with campaigns for this game."""
return Organization.objects.filter( return Organization.objects.filter(games__drop_campaigns__game=self).distinct()
games__drop_campaigns__game=self,
).distinct()
@property @property
def get_game_name(self) -> str: def get_game_name(self) -> str:

View file

@ -3,37 +3,46 @@
from __future__ import annotations from __future__ import annotations
from datetime import timedelta from datetime import timedelta
from typing import TYPE_CHECKING
from django.test import TestCase import pytest
from django.urls import reverse from django.urls import reverse
from django.utils import timezone 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 ChatBadge
from twitch.models import ChatBadgeSet from twitch.models import ChatBadgeSet
from twitch.models import DropBenefit from twitch.models import DropBenefit
from twitch.models import DropCampaign from twitch.models import DropCampaign
from twitch.models import Game from twitch.models import Game
from twitch.models import Organization from twitch.models import Organization
from twitch.models import RewardCampaign
from twitch.models import TimeBasedDrop 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): class RSSFeedTestCase(TestCase):
"""Test RSS feeds.""" """Test RSS feeds."""
def setUp(self) -> None: def setUp(self) -> None:
"""Set up test fixtures.""" """Set up test fixtures."""
self.org = Organization.objects.create( self.org: Organization = Organization.objects.create(
twitch_id="test-org-123", twitch_id="test-org-123",
name="Test Organization", name="Test Organization",
) )
self.game = Game.objects.create( self.game: Game = Game.objects.create(
twitch_id="test-game-123", twitch_id="test-game-123",
slug="test-game", slug="test-game",
name="Test Game", name="Test Game",
display_name="Test Game", display_name="Test Game",
) )
self.game.owners.add(self.org) self.game.owners.add(self.org)
self.campaign = DropCampaign.objects.create( self.campaign: DropCampaign = DropCampaign.objects.create(
twitch_id="test-campaign-123", twitch_id="test-campaign-123",
name="Test Campaign", name="Test Campaign",
game=self.game, game=self.game,
@ -44,42 +53,42 @@ class RSSFeedTestCase(TestCase):
def test_organization_feed(self) -> None: def test_organization_feed(self) -> None:
"""Test organization feed returns 200.""" """Test organization feed returns 200."""
url = reverse("twitch:organization_feed") url: str = reverse("twitch:organization_feed")
response = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
assert response["Content-Type"] == "application/rss+xml; charset=utf-8" assert response["Content-Type"] == "application/rss+xml; charset=utf-8"
def test_game_feed(self) -> None: def test_game_feed(self) -> None:
"""Test game feed returns 200.""" """Test game feed returns 200."""
url = reverse("twitch:game_feed") url: str = reverse("twitch:game_feed")
response = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
assert response["Content-Type"] == "application/rss+xml; charset=utf-8" assert response["Content-Type"] == "application/rss+xml; charset=utf-8"
def test_campaign_feed(self) -> None: def test_campaign_feed(self) -> None:
"""Test campaign feed returns 200.""" """Test campaign feed returns 200."""
url = reverse("twitch:campaign_feed") url: str = reverse("twitch:campaign_feed")
response = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
assert response["Content-Type"] == "application/rss+xml; charset=utf-8" assert response["Content-Type"] == "application/rss+xml; charset=utf-8"
def test_campaign_feed_includes_badge_description(self) -> None: def test_campaign_feed_includes_badge_description(self) -> None:
"""Badge benefit descriptions should be visible in the RSS drop summary.""" """Badge benefit descriptions should be visible in the RSS drop summary."""
drop = TimeBasedDrop.objects.create( drop: TimeBasedDrop = TimeBasedDrop.objects.create(
twitch_id="drop-1", twitch_id="drop-1",
name="Diana Chat Badge", name="Diana Chat Badge",
campaign=self.campaign, campaign=self.campaign,
required_minutes_watched=0, required_minutes_watched=0,
required_subs=1, required_subs=1,
) )
benefit = DropBenefit.objects.create( benefit: DropBenefit = DropBenefit.objects.create(
twitch_id="benefit-1", twitch_id="benefit-1",
name="Diana", name="Diana",
distribution_type="BADGE", distribution_type="BADGE",
) )
drop.benefits.add(benefit) drop.benefits.add(benefit)
badge_set = ChatBadgeSet.objects.create(set_id="diana") badge_set: ChatBadgeSet = ChatBadgeSet.objects.create(set_id="diana")
ChatBadge.objects.create( ChatBadge.objects.create(
badge_set=badge_set, badge_set=badge_set,
badge_id="1", badge_id="1",
@ -90,36 +99,36 @@ class RSSFeedTestCase(TestCase):
description="This badge was earned by subscribing.", description="This badge was earned by subscribing.",
) )
url = reverse("twitch:campaign_feed") url: str = reverse("twitch:campaign_feed")
response = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200 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 assert "This badge was earned by subscribing." in content
def test_game_campaign_feed(self) -> None: def test_game_campaign_feed(self) -> None:
"""Test game-specific campaign feed returns 200.""" """Test game-specific campaign feed returns 200."""
url = reverse("twitch:game_campaign_feed", args=[self.game.twitch_id]) url: str = reverse("twitch:game_campaign_feed", args=[self.game.twitch_id])
response = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
assert response["Content-Type"] == "application/rss+xml; charset=utf-8" assert response["Content-Type"] == "application/rss+xml; charset=utf-8"
# Verify the game name is in the feed # 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 assert "Test Game" in content
def test_organization_campaign_feed(self) -> None: def test_organization_campaign_feed(self) -> None:
"""Test organization-specific campaign feed returns 200.""" """Test organization-specific campaign feed returns 200."""
url = reverse("twitch:organization_campaign_feed", args=[self.org.twitch_id]) url: str = reverse("twitch:organization_campaign_feed", args=[self.org.twitch_id])
response = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
assert response.status_code == 200 assert response.status_code == 200
assert response["Content-Type"] == "application/rss+xml; charset=utf-8" assert response["Content-Type"] == "application/rss+xml; charset=utf-8"
# Verify the organization name is in the feed # 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 assert "Test Organization" in content
def test_game_campaign_feed_filters_correctly(self) -> None: def test_game_campaign_feed_filters_correctly(self) -> None:
"""Test game campaign feed only shows campaigns for that game.""" """Test game campaign feed only shows campaigns for that game."""
# Create another game with a campaign # Create another game with a campaign
other_game = Game.objects.create( other_game: Game = Game.objects.create(
twitch_id="other-game-123", twitch_id="other-game-123",
slug="other-game", slug="other-game",
name="Other Game", name="Other Game",
@ -136,9 +145,9 @@ class RSSFeedTestCase(TestCase):
) )
# Get feed for first game # Get feed for first game
url = reverse("twitch:game_campaign_feed", args=[self.game.twitch_id]) url: str = reverse("twitch:game_campaign_feed", args=[self.game.twitch_id])
response = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
content = response.content.decode("utf-8") content: str = response.content.decode("utf-8")
# Should contain first campaign # Should contain first campaign
assert "Test Campaign" in content assert "Test Campaign" in content
@ -169,11 +178,118 @@ class RSSFeedTestCase(TestCase):
) )
# Get feed for first organization # Get feed for first organization
url = reverse("twitch:organization_campaign_feed", args=[self.org.twitch_id]) url: str = reverse("twitch:organization_campaign_feed", args=[self.org.twitch_id])
response = self.client.get(url) response: _MonkeyPatchedWSGIResponse = self.client.get(url)
content = response.content.decode("utf-8") content: str = response.content.decode("utf-8")
# Should contain first campaign # Should contain first campaign
assert "Test Campaign" in content assert "Test Campaign" in content
# Should NOT contain other campaign # Should NOT contain other campaign
assert "Other Campaign 2" not in content 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/<str:twitch_id>/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

View file

@ -21,117 +21,25 @@ app_name = "twitch"
rss_feeds_latest: list[URLPattern] = [ rss_feeds_latest: list[URLPattern] = [
# /rss/campaigns - RSS feed for latest drop campaigns. path("rss/campaigns/", DropCampaignFeed(), name="campaign_feed"),
# Example: path("rss/games/", GameFeed(), name="game_feed"),
# https://ttvdrops.lovinator.space/rss/campaigns/ path("rss/games/<str:twitch_id>/campaigns/", GameCampaignFeed(), name="game_campaign_feed"),
# http://localhost:8000/rss/campaigns/ path("rss/organizations/", OrganizationFeed(), name="organization_feed"),
path( path("rss/organizations/<str:twitch_id>/campaigns/", OrganizationCampaignFeed(), name="organization_campaign_feed"),
"rss/campaigns/", path("rss/reward-campaigns/", RewardCampaignFeed(), name="reward_campaign_feed"),
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/<twitch_id>/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/<str:twitch_id>/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/<str:twitch_id>/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/<str:twitch_id>/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",
),
] ]
v1_rss_feeds: list[URLPattern] = [ v1_rss_feeds: list[URLPattern] = [
# /rss/v1/campaigns - RSS feed for latest drop campaigns. path("rss/v1/campaigns/", DropCampaignFeed(), name="campaign_feed_v1"),
# Example: path("rss/v1/games/", GameFeed(), name="game_feed_v1"),
# https://ttvdrops.lovinator.space/rss/campaigns/ path("rss/v1/games/<str:twitch_id>/campaigns/", GameCampaignFeed(), name="game_campaign_feed_v1"),
# http://localhost:8000/rss/campaigns/ path("rss/v1/organizations/", OrganizationFeed(), name="organization_feed_v1"),
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/<twitch_id>/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/<str:twitch_id>/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/<str:twitch_id>/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( path(
"rss/v1/organizations/<str:twitch_id>/campaigns/", "rss/v1/organizations/<str:twitch_id>/campaigns/",
OrganizationCampaignFeed(), OrganizationCampaignFeed(),
name="organization_campaign_feed_v1", name="organization_campaign_feed_v1",
), ),
# /rss/reward-campaigns/ - RSS feed for campaigns of a specific organization. path("rss/v1/reward-campaigns/", RewardCampaignFeed(), name="reward_campaign_feed_v1"),
# 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",
),
] ]

28
uv.lock generated
View file

@ -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" }, { 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]] [[package]]
name = "idna" name = "idna"
version = "3.11" 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" }, { 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]] [[package]]
name = "sqlparse" name = "sqlparse"
version = "0.5.5" version = "0.5.5"
@ -584,6 +610,7 @@ dependencies = [
dev = [ dev = [
{ name = "django-stubs" }, { name = "django-stubs" },
{ name = "djlint" }, { name = "djlint" },
{ name = "hypothesis", extra = ["django"] },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-cov" }, { name = "pytest-cov" },
{ name = "pytest-django" }, { name = "pytest-django" },
@ -608,6 +635,7 @@ requires-dist = [
dev = [ dev = [
{ name = "django-stubs" }, { name = "django-stubs" },
{ name = "djlint" }, { name = "djlint" },
{ name = "hypothesis", extras = ["django"] },
{ name = "pytest" }, { name = "pytest" },
{ name = "pytest-cov" }, { name = "pytest-cov" },
{ name = "pytest-django" }, { name = "pytest-django" },