This commit is contained in:
parent
4627d1cea0
commit
d762081bd5
26 changed files with 5048 additions and 1 deletions
935
kick/tests/test_kick.py
Normal file
935
kick/tests/test_kick.py
Normal file
|
|
@ -0,0 +1,935 @@
|
|||
import re
|
||||
from datetime import UTC
|
||||
from datetime import datetime as dt
|
||||
from datetime import timedelta
|
||||
from io import StringIO
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from django.core.management import call_command
|
||||
from django.test import TestCase
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from pydantic import ValidationError
|
||||
|
||||
from kick.feeds import discord_timestamp
|
||||
from kick.models import KickCategory
|
||||
from kick.models import KickChannel
|
||||
from kick.models import KickDropCampaign
|
||||
from kick.models import KickOrganization
|
||||
from kick.models import KickReward
|
||||
from kick.models import KickUser
|
||||
from kick.schemas import KickDropCampaignSchema
|
||||
from kick.schemas import KickDropsResponseSchema
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from django.test.client import _MonkeyPatchedWSGIResponse
|
||||
|
||||
from kick.schemas import KickDropCampaignSchema
|
||||
from kick.schemas import KickRewardSchema
|
||||
|
||||
# Minimal valid campaign fixture (single campaign, active status)
|
||||
SINGLE_CAMPAIGN_JSON: dict[str, list[dict[str, Any]] | str] = {
|
||||
"data": [
|
||||
{
|
||||
"id": "01KKBNEM8TZG7ASRG42TK7RKRB",
|
||||
"name": "PUBG 9th Anniversary",
|
||||
"status": "active",
|
||||
"starts_at": "2026-03-11T08:00:00Z",
|
||||
"ends_at": "2026-03-31T07:59:00Z",
|
||||
"created_at": "2026-03-10T10:42:29.793929Z",
|
||||
"updated_at": "2026-03-10T10:42:30.248864Z",
|
||||
"connect_url": "https://accounts.krafton.com/auth/kick/callback",
|
||||
"url": "https://accounts.krafton.com",
|
||||
"category": {
|
||||
"id": 53,
|
||||
"name": "PUBG: Battlegrounds",
|
||||
"slug": "pubg-battlegrounds",
|
||||
"image_url": "https://files.kick.com/images/subcategories/53/banner/pubg-battlegrounds.jpg",
|
||||
},
|
||||
"organization": {
|
||||
"id": "01KFF1CP2Q2X6EFDN9M85M3M97",
|
||||
"name": "KRAFTON",
|
||||
"logo_url": "https://krafton.com/wp-content/uploads/2021/06/logo-krafton-brandcenter.png",
|
||||
"url": "https://krafton.com",
|
||||
"restricted": False,
|
||||
"email_notification": False,
|
||||
},
|
||||
"channels": [],
|
||||
"rewards": [
|
||||
{
|
||||
"id": "01KKBM2YDPSBJD437HF7CDPTQT",
|
||||
"name": "9th Anniversary Flamin' Cake",
|
||||
"image_url": "drops/reward-image/01kkbm2ydpsbjd437hf7cdptqt-01kkbm2ydpsbjd437hf8yhrzqm.png",
|
||||
"required_units": 30,
|
||||
"category_id": 53,
|
||||
"organization_id": "01KFF1CP2Q2X6EFDN9M85M3M97",
|
||||
},
|
||||
],
|
||||
"rule": {"id": 1, "name": "Watch to redeem"},
|
||||
},
|
||||
],
|
||||
"message": "Success",
|
||||
}
|
||||
|
||||
# Campaign with channels fixture
|
||||
CAMPAIGN_WITH_CHANNELS_JSON: dict[str, list[dict[str, Any]] | str] = {
|
||||
"data": [
|
||||
{
|
||||
"id": "01K8X4WHMVTF4HQNT9RRTGBACK",
|
||||
"name": "Team Ricoy MP5",
|
||||
"status": "expired",
|
||||
"starts_at": "2025-11-13T21:00:00Z",
|
||||
"ends_at": "2025-11-23T23:59:00Z",
|
||||
"created_at": "2025-10-31T12:46:39.78377Z",
|
||||
"updated_at": "2025-11-05T20:42:33.761611Z",
|
||||
"connect_url": "https://kick.facepunch.com",
|
||||
"url": "https://kick.facepunch.com",
|
||||
"category": {
|
||||
"id": 13,
|
||||
"name": "Rust",
|
||||
"slug": "rust",
|
||||
"image_url": "https://files.kick.com/images/subcategories/13/banner/77786fd4-4f33-4221-b8ee-2bf3cb4d041e",
|
||||
},
|
||||
"organization": {
|
||||
"id": "01K6WKP5BBMPZJ89G5Y7QK1E9P",
|
||||
"name": "Facepunch Studios",
|
||||
"logo_url": "https://images.squarespace-cdn.com/content/v1/627cb6fa4355783e5e375440/b47ec50e-bc8b-4801-87cb-dee12da27748/default-light.png?format=1500w",
|
||||
"url": "https://kick.facepunch.com",
|
||||
"restricted": False,
|
||||
"email_notification": False,
|
||||
},
|
||||
"channels": [
|
||||
{
|
||||
"id": 1661881,
|
||||
"slug": "ricoy",
|
||||
"description": "",
|
||||
"banner_picture_url": "https://files.kick.com/images/channel/1661881/banner_image/default-banner-2.jpg",
|
||||
"user": {
|
||||
"id": 1711034,
|
||||
"username": "Ricoy",
|
||||
"profile_picture": "https://files.kick.com/images/user/1711034/profile_image/conversion/101aa7a7-6f75-43d2-a0d3-8fde7aa7f664-medium.webp",
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": 969446,
|
||||
"slug": "dilanzito",
|
||||
"description": "",
|
||||
"banner_picture_url": "https://files.kick.com/images/channel/969446/banner_image/default-banner-2.jpg",
|
||||
"user": {
|
||||
"id": 1011977,
|
||||
"username": "dilanzito",
|
||||
"profile_picture": "https://files.kick.com/images/user/1011977/profile_image/conversion/32a59b29-c319-4210-b5a0-ac14dd112f13-medium.webp",
|
||||
},
|
||||
},
|
||||
],
|
||||
"rewards": [
|
||||
{
|
||||
"id": "01K8X3P1D3AE2PNEBP1VJCVXAR",
|
||||
"name": "Team Ricoy MP5",
|
||||
"image_url": "drops/reward-image/01k8x3p1d3ae2pnebp1vjcvxar.png",
|
||||
"required_units": 120,
|
||||
"category_id": 13,
|
||||
"organization_id": "01K6WKP5BBMPZJ89G5Y7QK1E9P",
|
||||
},
|
||||
],
|
||||
"rule": {"id": 1, "name": "Watch to redeem"},
|
||||
},
|
||||
],
|
||||
"message": "Success",
|
||||
}
|
||||
|
||||
|
||||
# MARK: Schema tests
|
||||
class KickDropsResponseSchemaTest(TestCase):
|
||||
"""Tests for Pydantic schema validation of the Kick drops API response."""
|
||||
|
||||
def test_valid_single_campaign(self) -> None:
|
||||
"""Schema validates a minimal valid campaign without channels."""
|
||||
result: KickDropsResponseSchema = KickDropsResponseSchema.model_validate(
|
||||
SINGLE_CAMPAIGN_JSON,
|
||||
)
|
||||
assert result.message == "Success"
|
||||
assert len(result.data) == 1
|
||||
campaign: KickDropCampaignSchema = result.data[0]
|
||||
assert campaign.id == "01KKBNEM8TZG7ASRG42TK7RKRB"
|
||||
assert campaign.name == "PUBG 9th Anniversary"
|
||||
assert campaign.status == "active"
|
||||
assert len(campaign.channels) == 0
|
||||
assert len(campaign.rewards) == 1
|
||||
|
||||
def test_campaign_with_channels(self) -> None:
|
||||
"""Schema validates a campaign that includes channel data."""
|
||||
result: KickDropsResponseSchema = KickDropsResponseSchema.model_validate(
|
||||
CAMPAIGN_WITH_CHANNELS_JSON,
|
||||
)
|
||||
campaign: KickDropCampaignSchema = result.data[0]
|
||||
assert len(campaign.channels) == 2
|
||||
assert campaign.channels[0].slug == "ricoy"
|
||||
assert campaign.channels[0].user.username == "Ricoy"
|
||||
|
||||
def test_reward_fields(self) -> None:
|
||||
"""Reward fields parse correctly including relative image URL."""
|
||||
result: KickDropsResponseSchema = KickDropsResponseSchema.model_validate(
|
||||
SINGLE_CAMPAIGN_JSON,
|
||||
)
|
||||
reward: KickRewardSchema = result.data[0].rewards[0]
|
||||
assert reward.id == "01KKBM2YDPSBJD437HF7CDPTQT"
|
||||
assert reward.required_units == 30
|
||||
assert "drops/reward-image/" in reward.image_url
|
||||
|
||||
def test_empty_channels_list(self) -> None:
|
||||
"""Schema accepts an empty channels list."""
|
||||
result: KickDropsResponseSchema = KickDropsResponseSchema.model_validate(
|
||||
SINGLE_CAMPAIGN_JSON,
|
||||
)
|
||||
assert result.data[0].channels == []
|
||||
|
||||
def test_extra_fields_rejected(self) -> None:
|
||||
"""Extra fields in the API response cause a ValidationError."""
|
||||
bad_payload: dict[str, str | list] = {
|
||||
"data": [],
|
||||
"message": "Success",
|
||||
"unexpected_field": "oops",
|
||||
}
|
||||
with pytest.raises(ValidationError):
|
||||
KickDropsResponseSchema.model_validate(bad_payload)
|
||||
|
||||
|
||||
# MARK: Model tests
|
||||
class KickRewardFullImageUrlTest(TestCase):
|
||||
"""Tests for KickReward.full_image_url property."""
|
||||
|
||||
def _make_reward(self, image_url: str) -> KickReward:
|
||||
org: KickOrganization = KickOrganization.objects.create(
|
||||
kick_id="org-1",
|
||||
name="Org",
|
||||
)
|
||||
cat: KickCategory = KickCategory.objects.create(
|
||||
kick_id=99,
|
||||
name="Cat",
|
||||
slug="cat",
|
||||
)
|
||||
campaign: KickDropCampaign = KickDropCampaign.objects.create(
|
||||
kick_id="camp-1",
|
||||
name="Test Campaign",
|
||||
organization=org,
|
||||
category=cat,
|
||||
rule_id=1,
|
||||
rule_name="Watch to redeem",
|
||||
)
|
||||
return KickReward.objects.create(
|
||||
kick_id="reward-1",
|
||||
name="Reward",
|
||||
image_url=image_url,
|
||||
required_units=60,
|
||||
campaign=campaign,
|
||||
category=cat,
|
||||
organization=org,
|
||||
)
|
||||
|
||||
def test_relative_image_url_gets_base_prefix(self) -> None:
|
||||
"""If image_url is relative, full_image_url should prepend the Kick files base URL."""
|
||||
reward: KickReward = self._make_reward("drops/reward-image/abc.png")
|
||||
assert (
|
||||
reward.full_image_url
|
||||
== "https://ext.cdn.kick.com/drops/reward-image/abc.png"
|
||||
)
|
||||
|
||||
def test_absolute_image_url_unchanged(self) -> None:
|
||||
"""If image_url is already absolute, full_image_url should return it unchanged."""
|
||||
reward: KickReward = self._make_reward("https://example.com/image.png")
|
||||
assert reward.full_image_url == "https://example.com/image.png"
|
||||
|
||||
def test_empty_image_url_returns_empty(self) -> None:
|
||||
"""If image_url is empty, full_image_url should also be empty."""
|
||||
reward: KickReward = self._make_reward("")
|
||||
assert not reward.full_image_url
|
||||
|
||||
|
||||
class KickChannelUrlTest(TestCase):
|
||||
"""Tests for KickChannel.channel_url property."""
|
||||
|
||||
def test_url_with_slug(self) -> None:
|
||||
"""If slug is present, channel_url should return the full Kick channel URL."""
|
||||
user: KickUser = KickUser.objects.create(kick_id=1, username="testuser")
|
||||
channel: KickChannel = KickChannel.objects.create(
|
||||
kick_id=100,
|
||||
slug="testuser",
|
||||
user=user,
|
||||
)
|
||||
assert channel.channel_url == "https://kick.com/testuser"
|
||||
|
||||
def test_url_without_slug(self) -> None:
|
||||
"""If slug is empty, channel_url should return an empty string."""
|
||||
channel: KickChannel = KickChannel.objects.create(kick_id=101, slug="")
|
||||
assert not channel.channel_url
|
||||
|
||||
|
||||
class KickDropCampaignIsActiveTest(TestCase):
|
||||
"""Tests for KickDropCampaign.is_active property."""
|
||||
|
||||
def _make_campaign(
|
||||
self,
|
||||
starts_at: dt | None,
|
||||
ends_at: dt | None,
|
||||
) -> KickDropCampaign:
|
||||
org: KickOrganization = KickOrganization.objects.create(
|
||||
kick_id="org-active",
|
||||
name="Org",
|
||||
)
|
||||
cat: KickCategory = KickCategory.objects.create(
|
||||
kick_id=1,
|
||||
name="Cat",
|
||||
slug="cat",
|
||||
)
|
||||
return KickDropCampaign.objects.create(
|
||||
kick_id="camp-active",
|
||||
name="Active Campaign",
|
||||
starts_at=starts_at,
|
||||
ends_at=ends_at,
|
||||
organization=org,
|
||||
category=cat,
|
||||
rule_id=1,
|
||||
rule_name="Watch to redeem",
|
||||
)
|
||||
|
||||
def test_no_dates_is_not_active(self) -> None:
|
||||
"""If starts_at and ends_at are None, is_active should return False."""
|
||||
campaign: KickDropCampaign = self._make_campaign(None, None)
|
||||
assert not campaign.is_active
|
||||
|
||||
def test_expired_campaign_is_not_active(self) -> None:
|
||||
"""If current date is past ends_at, is_active should return False."""
|
||||
campaign: KickDropCampaign = self._make_campaign(
|
||||
dt(2020, 1, 1, tzinfo=UTC),
|
||||
dt(2020, 12, 31, tzinfo=UTC),
|
||||
)
|
||||
assert not campaign.is_active
|
||||
|
||||
def test_future_campaign_is_not_active(self) -> None:
|
||||
"""If current date is before starts_at, is_active should return False."""
|
||||
campaign: KickDropCampaign = self._make_campaign(
|
||||
dt(2099, 1, 1, tzinfo=UTC),
|
||||
dt(2099, 12, 31, tzinfo=UTC),
|
||||
)
|
||||
assert not campaign.is_active
|
||||
|
||||
|
||||
class KickDropCampaignMergedRewardsTest(TestCase):
|
||||
"""Tests for KickDropCampaign.merged_rewards property."""
|
||||
|
||||
def _make_campaign(self) -> KickDropCampaign:
|
||||
org: KickOrganization = KickOrganization.objects.create(
|
||||
kick_id="org-merge",
|
||||
name="Merge Org",
|
||||
)
|
||||
cat: KickCategory = KickCategory.objects.create(
|
||||
kick_id=801,
|
||||
name="Merge Cat",
|
||||
slug="merge-cat",
|
||||
)
|
||||
return KickDropCampaign.objects.create(
|
||||
kick_id="camp-merge",
|
||||
name="Merge Campaign",
|
||||
organization=org,
|
||||
category=cat,
|
||||
rule_id=1,
|
||||
rule_name="Watch to redeem",
|
||||
)
|
||||
|
||||
def test_merges_reward_with_con_suffix(self) -> None:
|
||||
"""Rewards with and without '(Con)' should be treated as one entry."""
|
||||
campaign: KickDropCampaign = self._make_campaign()
|
||||
KickReward.objects.create(
|
||||
kick_id="reward-base",
|
||||
name="Anniversary Cake",
|
||||
image_url="drops/reward-image/base.png",
|
||||
required_units=30,
|
||||
campaign=campaign,
|
||||
category=campaign.category,
|
||||
organization=campaign.organization,
|
||||
)
|
||||
KickReward.objects.create(
|
||||
kick_id="reward-con",
|
||||
name="Anniversary Cake (Con)",
|
||||
image_url="drops/reward-image/con.png",
|
||||
required_units=30,
|
||||
campaign=campaign,
|
||||
category=campaign.category,
|
||||
organization=campaign.organization,
|
||||
)
|
||||
|
||||
merged: list[KickReward] = campaign.merged_rewards
|
||||
assert len(merged) == 1
|
||||
assert merged[0].name == "Anniversary Cake"
|
||||
|
||||
def test_keeps_con_name_when_only_variant_available(self) -> None:
|
||||
"""If only '(Con)' exists, it should still appear in merged rewards."""
|
||||
campaign: KickDropCampaign = self._make_campaign()
|
||||
KickReward.objects.create(
|
||||
kick_id="reward-only-con",
|
||||
name="Anniversary Cake(Con)",
|
||||
image_url="drops/reward-image/con-only.png",
|
||||
required_units=30,
|
||||
campaign=campaign,
|
||||
category=campaign.category,
|
||||
organization=campaign.organization,
|
||||
)
|
||||
|
||||
merged: list[KickReward] = campaign.merged_rewards
|
||||
assert len(merged) == 1
|
||||
assert merged[0].name == "Anniversary Cake(Con)"
|
||||
|
||||
def test_merges_rewards_with_ampersand_spacing_difference(self) -> None:
|
||||
"""Rewards that only differ by ampersand spacing should be treated as one entry."""
|
||||
campaign: KickDropCampaign = self._make_campaign()
|
||||
KickReward.objects.create(
|
||||
kick_id="reward-anniversary-base",
|
||||
name="9th Anniversary Cake & Confetti",
|
||||
image_url="drops/reward-image/cake-confetti-base.png",
|
||||
required_units=60,
|
||||
campaign=campaign,
|
||||
category=campaign.category,
|
||||
organization=campaign.organization,
|
||||
)
|
||||
KickReward.objects.create(
|
||||
kick_id="reward-anniversary-con",
|
||||
name="9th Anniversary Cake&Confetti (Con)",
|
||||
image_url="drops/reward-image/cake-confetti-con.png",
|
||||
required_units=60,
|
||||
campaign=campaign,
|
||||
category=campaign.category,
|
||||
organization=campaign.organization,
|
||||
)
|
||||
|
||||
merged: list[KickReward] = campaign.merged_rewards
|
||||
assert len(merged) == 1
|
||||
assert merged[0].name == "9th Anniversary Cake & Confetti"
|
||||
|
||||
|
||||
# MARK: Management command tests
|
||||
class ImportKickDropsCommandTest(TestCase):
|
||||
"""Tests for the import_kick_drops management command."""
|
||||
|
||||
def _run_command(self, json_payload: dict) -> tuple[str, str]:
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = json_payload
|
||||
mock_response.raise_for_status.return_value = None
|
||||
|
||||
stdout = StringIO()
|
||||
stderr = StringIO()
|
||||
with patch(
|
||||
"kick.management.commands.import_kick_drops.httpx.get",
|
||||
return_value=mock_response,
|
||||
):
|
||||
call_command("import_kick_drops", stdout=stdout, stderr=stderr)
|
||||
return stdout.getvalue(), stderr.getvalue()
|
||||
|
||||
def test_imports_single_campaign(self) -> None:
|
||||
"""Command creates campaign and related objects from valid API response."""
|
||||
self._run_command(SINGLE_CAMPAIGN_JSON)
|
||||
assert KickDropCampaign.objects.count() == 1
|
||||
assert KickOrganization.objects.count() == 1
|
||||
assert KickCategory.objects.count() == 1
|
||||
assert KickReward.objects.count() == 1
|
||||
|
||||
campaign: KickDropCampaign = KickDropCampaign.objects.get()
|
||||
assert campaign.name == "PUBG 9th Anniversary"
|
||||
assert campaign.status == "active"
|
||||
assert campaign.organization.name == "KRAFTON"
|
||||
assert campaign.category.name == "PUBG: Battlegrounds"
|
||||
|
||||
def test_imports_campaign_with_channels(self) -> None:
|
||||
"""Command creates channels and links them to the campaign."""
|
||||
self._run_command(CAMPAIGN_WITH_CHANNELS_JSON)
|
||||
campaign: KickDropCampaign = KickDropCampaign.objects.get()
|
||||
assert campaign.channels.count() == 2
|
||||
assert KickUser.objects.count() == 2
|
||||
assert KickChannel.objects.count() == 2
|
||||
slugs: set[str] = set(campaign.channels.values_list("slug", flat=True))
|
||||
assert slugs == {"ricoy", "dilanzito"}
|
||||
|
||||
def test_import_is_idempotent(self) -> None:
|
||||
"""Running the import twice does not duplicate records."""
|
||||
self._run_command(SINGLE_CAMPAIGN_JSON)
|
||||
self._run_command(SINGLE_CAMPAIGN_JSON)
|
||||
assert KickDropCampaign.objects.count() == 1
|
||||
assert KickOrganization.objects.count() == 1
|
||||
assert KickReward.objects.count() == 1
|
||||
|
||||
def test_http_error_is_handled_gracefully(self) -> None:
|
||||
"""HTTP error during fetch writes to stderr and does not crash."""
|
||||
stdout = StringIO()
|
||||
stderr = StringIO()
|
||||
with patch(
|
||||
"kick.management.commands.import_kick_drops.httpx.get",
|
||||
side_effect=httpx.HTTPError("connection refused"),
|
||||
):
|
||||
call_command("import_kick_drops", stdout=stdout, stderr=stderr)
|
||||
|
||||
assert "HTTP error" in stderr.getvalue()
|
||||
assert KickDropCampaign.objects.count() == 0
|
||||
|
||||
def test_validation_error_is_handled_gracefully(self) -> None:
|
||||
"""Invalid JSON structure writes to stderr and does not crash."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.json.return_value = {"totally": "wrong"}
|
||||
mock_response.raise_for_status.return_value = None
|
||||
|
||||
stdout = StringIO()
|
||||
stderr = StringIO()
|
||||
with patch(
|
||||
"kick.management.commands.import_kick_drops.httpx.get",
|
||||
return_value=mock_response,
|
||||
):
|
||||
call_command("import_kick_drops", stdout=stdout, stderr=stderr)
|
||||
|
||||
assert "validation failed" in stderr.getvalue()
|
||||
assert KickDropCampaign.objects.count() == 0
|
||||
|
||||
def test_success_message_printed(self) -> None:
|
||||
"""Success output should report the number of campaigns imported."""
|
||||
stdout, _ = self._run_command(SINGLE_CAMPAIGN_JSON)
|
||||
assert "1/1" in stdout
|
||||
|
||||
|
||||
# MARK: View tests
|
||||
class KickDashboardViewTest(TestCase):
|
||||
"""Tests for the kick dashboard view."""
|
||||
|
||||
def _make_active_campaign(self) -> KickDropCampaign:
|
||||
org: KickOrganization = KickOrganization.objects.create(
|
||||
kick_id="org-view-1",
|
||||
name="Org View",
|
||||
)
|
||||
cat: KickCategory = KickCategory.objects.create(
|
||||
kick_id=200,
|
||||
name="View Cat",
|
||||
slug="view-cat",
|
||||
)
|
||||
return KickDropCampaign.objects.create(
|
||||
kick_id="camp-view-1",
|
||||
name="Active View Campaign",
|
||||
status="active",
|
||||
starts_at=dt(2020, 1, 1, tzinfo=UTC),
|
||||
ends_at=dt(2099, 12, 31, tzinfo=UTC),
|
||||
organization=org,
|
||||
category=cat,
|
||||
rule_id=1,
|
||||
rule_name="Watch to redeem",
|
||||
)
|
||||
|
||||
def test_dashboard_returns_200(self) -> None:
|
||||
"""Dashboard view should return HTTP 200 status code."""
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:dashboard"),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_dashboard_shows_active_campaigns(self) -> None:
|
||||
"""Active campaigns should be displayed on the dashboard."""
|
||||
campaign: KickDropCampaign = self._make_active_campaign()
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:dashboard"),
|
||||
)
|
||||
assert campaign.name in response.content.decode()
|
||||
|
||||
|
||||
class KickCampaignListViewTest(TestCase):
|
||||
"""Tests for the kick campaign list view."""
|
||||
|
||||
def _make_campaign(
|
||||
self,
|
||||
kick_id: str,
|
||||
name: str,
|
||||
status: str = "active",
|
||||
) -> KickDropCampaign:
|
||||
org, _ = KickOrganization.objects.get_or_create(
|
||||
kick_id="org-list",
|
||||
defaults={"name": "List Org"},
|
||||
)
|
||||
cat, _ = KickCategory.objects.get_or_create(
|
||||
kick_id=300,
|
||||
defaults={"name": "List Cat", "slug": "list-cat"},
|
||||
)
|
||||
# Set dates so the active/expired filter works correctly
|
||||
if status == "active":
|
||||
starts_at = dt(2020, 1, 1, tzinfo=UTC)
|
||||
ends_at = dt(2099, 12, 31, tzinfo=UTC)
|
||||
else:
|
||||
starts_at = dt(2020, 1, 1, tzinfo=UTC)
|
||||
ends_at = dt(2020, 12, 31, tzinfo=UTC)
|
||||
return KickDropCampaign.objects.create(
|
||||
kick_id=kick_id,
|
||||
name=name,
|
||||
status=status,
|
||||
starts_at=starts_at,
|
||||
ends_at=ends_at,
|
||||
organization=org,
|
||||
category=cat,
|
||||
rule_id=1,
|
||||
rule_name="Watch to redeem",
|
||||
)
|
||||
|
||||
def test_campaign_list_returns_200(self) -> None:
|
||||
"""Campaign list view should return HTTP 200 status code."""
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:campaign_list"),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_campaign_list_shows_campaigns(self) -> None:
|
||||
"""Campaigns should be displayed in the campaign list view."""
|
||||
campaign: KickDropCampaign = self._make_campaign("camp-list-1", "List Campaign")
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:campaign_list"),
|
||||
)
|
||||
assert campaign.name in response.content.decode()
|
||||
|
||||
def test_campaign_list_status_filter(self) -> None:
|
||||
"""Filtering by status should show only campaigns with that status."""
|
||||
active: KickDropCampaign = self._make_campaign(
|
||||
"camp-list-a",
|
||||
"Active Camp",
|
||||
status="active",
|
||||
)
|
||||
expired: KickDropCampaign = self._make_campaign(
|
||||
"camp-list-e",
|
||||
"Expired Camp",
|
||||
status="expired",
|
||||
)
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:campaign_list") + "?status=active",
|
||||
)
|
||||
content: str = response.content.decode()
|
||||
assert active.name in content
|
||||
assert expired.name not in content
|
||||
|
||||
|
||||
class KickCampaignDetailViewTest(TestCase):
|
||||
"""Tests for the kick campaign detail view."""
|
||||
|
||||
def _make_campaign(self) -> KickDropCampaign:
|
||||
"""Helper method to create a campaign with related org and category for detail view tests.
|
||||
|
||||
Returns:
|
||||
KickDropCampaign: The created campaign instance.
|
||||
"""
|
||||
org: KickOrganization = KickOrganization.objects.create(
|
||||
kick_id="org-det-1",
|
||||
name="Detail Org",
|
||||
)
|
||||
cat: KickCategory = KickCategory.objects.create(
|
||||
kick_id=400,
|
||||
name="Detail Cat",
|
||||
slug="detail-cat",
|
||||
)
|
||||
return KickDropCampaign.objects.create(
|
||||
kick_id="camp-det-1",
|
||||
name="Detail Campaign",
|
||||
organization=org,
|
||||
category=cat,
|
||||
rule_id=1,
|
||||
rule_name="Watch to redeem",
|
||||
)
|
||||
|
||||
def test_campaign_detail_returns_200(self) -> None:
|
||||
"""Campaign detail view should return HTTP 200 status code for existing campaign."""
|
||||
campaign: KickDropCampaign = self._make_campaign()
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:campaign_detail", kwargs={"kick_id": campaign.kick_id}),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_campaign_detail_shows_name(self) -> None:
|
||||
"""Campaign detail view should display the campaign name."""
|
||||
campaign: KickDropCampaign = self._make_campaign()
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:campaign_detail", kwargs={"kick_id": campaign.kick_id}),
|
||||
)
|
||||
assert campaign.name in response.content.decode()
|
||||
|
||||
def test_campaign_detail_404_for_unknown(self) -> None:
|
||||
"""Campaign detail view should return HTTP 404 status code for unknown campaign."""
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:campaign_detail", kwargs={"kick_id": "nonexistent-id"}),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class KickCategoryListViewTest(TestCase):
|
||||
"""Tests for the kick category list view."""
|
||||
|
||||
def test_category_list_returns_200(self) -> None:
|
||||
"""Category list view should return HTTP 200 status code."""
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:game_list"),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_category_list_shows_categories(self) -> None:
|
||||
"""Category list view should display the categories."""
|
||||
cat: KickCategory = KickCategory.objects.create(
|
||||
kick_id=500,
|
||||
name="Category View",
|
||||
slug="category-view",
|
||||
)
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:game_list"),
|
||||
)
|
||||
assert cat.name in response.content.decode()
|
||||
|
||||
|
||||
class KickCategoryDetailViewTest(TestCase):
|
||||
"""Tests for the kick category detail view."""
|
||||
|
||||
def _make_category(self) -> KickCategory:
|
||||
org: KickOrganization = KickOrganization.objects.create(
|
||||
kick_id="org-catdet-1",
|
||||
name="Catdet Org",
|
||||
)
|
||||
cat: KickCategory = KickCategory.objects.create(
|
||||
kick_id=600,
|
||||
name="Catdet Cat",
|
||||
slug="catdet-cat",
|
||||
)
|
||||
KickDropCampaign.objects.create(
|
||||
kick_id="camp-catdet-1",
|
||||
name="Catdet Campaign",
|
||||
organization=org,
|
||||
category=cat,
|
||||
rule_id=1,
|
||||
rule_name="Watch to redeem",
|
||||
)
|
||||
return cat
|
||||
|
||||
def test_category_detail_returns_200(self) -> None:
|
||||
"""Category detail view should return HTTP 200 status code for existing category."""
|
||||
cat: KickCategory = self._make_category()
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:game_detail", kwargs={"kick_id": cat.kick_id}),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_category_detail_shows_name(self) -> None:
|
||||
"""Category detail view should display the category name."""
|
||||
cat: KickCategory = self._make_category()
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:game_detail", kwargs={"kick_id": cat.kick_id}),
|
||||
)
|
||||
assert cat.name in response.content.decode()
|
||||
|
||||
def test_category_detail_404_for_unknown(self) -> None:
|
||||
"""Category detail view should return HTTP 404 status code for unknown category."""
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:game_detail", kwargs={"kick_id": 99999}),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class KickOrganizationListViewTest(TestCase):
|
||||
"""Tests for the kick organization list view."""
|
||||
|
||||
def test_organization_list_returns_200(self) -> None:
|
||||
"""Organization list view should return HTTP 200 status code."""
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:organization_list"),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_organization_list_shows_orgs(self) -> None:
|
||||
"""Organization list view should display the organizations."""
|
||||
org: KickOrganization = KickOrganization.objects.create(
|
||||
kick_id="org-list-1",
|
||||
name="List Org View",
|
||||
)
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:organization_list"),
|
||||
)
|
||||
assert org.name in response.content.decode()
|
||||
|
||||
|
||||
class KickOrganizationDetailViewTest(TestCase):
|
||||
"""Tests for the kick organization detail view."""
|
||||
|
||||
def _make_org(self) -> KickOrganization:
|
||||
org: KickOrganization = KickOrganization.objects.create(
|
||||
kick_id="org-orgdet-1",
|
||||
name="Orgdet Org",
|
||||
)
|
||||
cat: KickCategory = KickCategory.objects.create(
|
||||
kick_id=700,
|
||||
name="Orgdet Cat",
|
||||
slug="orgdet-cat",
|
||||
)
|
||||
KickDropCampaign.objects.create(
|
||||
kick_id="camp-orgdet-1",
|
||||
name="Orgdet Campaign",
|
||||
organization=org,
|
||||
category=cat,
|
||||
rule_id=1,
|
||||
rule_name="Watch to redeem",
|
||||
)
|
||||
return org
|
||||
|
||||
def test_organization_detail_returns_200(self) -> None:
|
||||
"""Organization detail view should return HTTP 200 status code for existing organization."""
|
||||
org: KickOrganization = self._make_org()
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:organization_detail", kwargs={"kick_id": org.kick_id}),
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_organization_detail_shows_name(self) -> None:
|
||||
"""Organization detail view should display the organization name."""
|
||||
org: KickOrganization = self._make_org()
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse("kick:organization_detail", kwargs={"kick_id": org.kick_id}),
|
||||
)
|
||||
assert org.name in response.content.decode()
|
||||
|
||||
def test_organization_detail_404_for_unknown(self) -> None:
|
||||
"""Organization detail view should return HTTP 404 status code for unknown organization."""
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(
|
||||
reverse(
|
||||
"kick:organization_detail",
|
||||
kwargs={"kick_id": "nonexistent-org-id"},
|
||||
),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class KickFeedsTest(TestCase):
|
||||
"""Tests for Kick RSS/Atom/Discord feed endpoints."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""Create a minimal active campaign fixture for feed tests."""
|
||||
self.org: KickOrganization = KickOrganization.objects.create(
|
||||
kick_id="org-feed-1",
|
||||
name="Feed Org",
|
||||
logo_url="https://example.com/org-logo.png",
|
||||
url="https://example.com/org",
|
||||
)
|
||||
self.category: KickCategory = KickCategory.objects.create(
|
||||
kick_id=123,
|
||||
name="Feed Category",
|
||||
slug="feed-category",
|
||||
image_url="https://example.com/category.png",
|
||||
)
|
||||
self.campaign: KickDropCampaign = KickDropCampaign.objects.create(
|
||||
kick_id="camp-feed-1",
|
||||
name="Feed Campaign",
|
||||
status="active",
|
||||
starts_at=timezone.now() - timedelta(hours=1),
|
||||
ends_at=timezone.now() + timedelta(days=1),
|
||||
organization=self.org,
|
||||
category=self.category,
|
||||
url="https://example.com/campaign",
|
||||
connect_url="https://example.com/connect",
|
||||
rule_id=1,
|
||||
rule_name="Watch to redeem",
|
||||
)
|
||||
self.user: KickUser = KickUser.objects.create(
|
||||
kick_id=2001,
|
||||
username="feeduser",
|
||||
)
|
||||
self.channel: KickChannel = KickChannel.objects.create(
|
||||
kick_id=2002,
|
||||
slug="feedchannel",
|
||||
user=self.user,
|
||||
)
|
||||
self.campaign.channels.add(self.channel)
|
||||
KickReward.objects.create(
|
||||
kick_id="reward-feed-1",
|
||||
name="Feed Reward",
|
||||
image_url="https://example.com/reward.png",
|
||||
required_units=30,
|
||||
campaign=self.campaign,
|
||||
category=self.category,
|
||||
organization=self.org,
|
||||
)
|
||||
|
||||
def test_rss_feed_routes_return_200(self) -> None:
|
||||
"""Kick RSS feeds should return 200 and browser-friendly content type."""
|
||||
urls: list[str] = [
|
||||
reverse("kick:campaign_feed"),
|
||||
reverse("kick:game_feed"),
|
||||
reverse("kick:game_campaign_feed", args=[self.category.kick_id]),
|
||||
reverse("kick:organization_feed"),
|
||||
]
|
||||
|
||||
for url in urls:
|
||||
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_atom_feed_routes_return_200(self) -> None:
|
||||
"""Kick Atom feeds should return 200 and include Atom XML root."""
|
||||
urls: list[str] = [
|
||||
reverse("kick:campaign_feed_atom"),
|
||||
reverse("kick:game_feed_atom"),
|
||||
reverse("kick:game_campaign_feed_atom", args=[self.category.kick_id]),
|
||||
reverse("kick:organization_feed_atom"),
|
||||
]
|
||||
|
||||
for url in urls:
|
||||
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 "<feed" in content
|
||||
assert "http://www.w3.org/2005/Atom" in content
|
||||
|
||||
def test_discord_feed_routes_return_200(self) -> None:
|
||||
"""Kick Discord feeds should return 200 and include Atom XML root."""
|
||||
urls: list[str] = [
|
||||
reverse("kick:campaign_feed_discord"),
|
||||
reverse("kick:game_feed_discord"),
|
||||
reverse(
|
||||
"kick:game_campaign_feed_discord",
|
||||
args=[self.category.kick_id],
|
||||
),
|
||||
reverse("kick:organization_feed_discord"),
|
||||
]
|
||||
|
||||
for url in urls:
|
||||
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 "<feed" in content
|
||||
assert "http://www.w3.org/2005/Atom" in content
|
||||
|
||||
def test_discord_campaign_feeds_contain_discord_timestamps(self) -> None:
|
||||
"""Kick Discord campaign feeds should include escaped Discord relative timestamps."""
|
||||
urls: list[str] = [
|
||||
reverse("kick:campaign_feed_discord"),
|
||||
reverse(
|
||||
"kick:game_campaign_feed_discord",
|
||||
args=[self.category.kick_id],
|
||||
),
|
||||
]
|
||||
|
||||
for url in urls:
|
||||
response: _MonkeyPatchedWSGIResponse = self.client.get(url)
|
||||
assert response.status_code == 200
|
||||
content: str = response.content.decode("utf-8")
|
||||
discord_pattern: re.Pattern[str] = re.compile(r"&lt;t:\d+:R&gt;")
|
||||
assert discord_pattern.search(content), (
|
||||
f"Expected Discord timestamp in feed {url}, got: {content}"
|
||||
)
|
||||
|
||||
def test_discord_timestamp_helper(self) -> None:
|
||||
"""discord_timestamp helper should return escaped Discord token and handle None."""
|
||||
sample_dt: dt = dt(2026, 3, 14, 12, 0, 0, tzinfo=UTC)
|
||||
result: str = str(discord_timestamp(sample_dt))
|
||||
assert result.startswith("<t:")
|
||||
assert result.endswith(":R>")
|
||||
assert not str(discord_timestamp(None))
|
||||
Loading…
Add table
Add a link
Reference in a new issue