Scrape SunkwiBOT/twitch-drops-api for data
All checks were successful
Deploy to Server / deploy (push) Successful in 29s

This commit is contained in:
Joakim Hellsén 2026-06-14 17:35:24 +02:00
commit 3535d7d2dd
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
28 changed files with 4272 additions and 576 deletions

View file

@ -190,6 +190,106 @@ class TwitchApiV1TestCase(TestCase):
assert data["items"][0]["status"] == "active"
assert data["items"][0]["game"]["twitch_id"] == "game123"
def test_v1_campaign_list_filters_by_game(self) -> None:
"""Filter campaigns by game twitch_id."""
response = self.client.get(
f"/twitch/api/v1/campaigns/?game={self.game.twitch_id}",
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["items"][0]["twitch_id"] == "campaign123"
def test_v1_campaign_list_game_filter_with_no_matches(self) -> None:
"""Return empty list when game filter matches no campaigns."""
response = self.client.get("/twitch/api/v1/campaigns/?game=nonexistent")
assert response.status_code == 200
data = response.json()
assert data["total"] == 0
assert data["items"] == []
def test_v1_campaign_list_status_upcoming(self) -> None:
"""Filter campaigns by status=upcoming."""
now = timezone.now()
DropCampaign.objects.create(
twitch_id="upcoming_campaign",
name="Upcoming Campaign",
game=self.game,
start_at=now + timedelta(days=1),
end_at=now + timedelta(days=2),
operation_names=["DropCampaignDetails"],
is_fully_imported=True,
)
response = self.client.get("/twitch/api/v1/campaigns/?status=upcoming")
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["items"][0]["status"] == "upcoming"
def test_v1_campaign_list_status_expired(self) -> None:
"""Filter campaigns by status=expired."""
now = timezone.now()
DropCampaign.objects.create(
twitch_id="expired_campaign",
name="Expired Campaign",
game=self.game,
start_at=now - timedelta(days=3),
end_at=now - timedelta(days=1),
operation_names=["DropCampaignDetails"],
is_fully_imported=True,
)
response = self.client.get("/twitch/api/v1/campaigns/?status=expired")
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["items"][0]["status"] == "expired"
def test_v1_campaign_list_default_page_size(self) -> None:
"""Return all campaigns when no page_size is specified."""
response = self.client.get("/twitch/api/v1/campaigns/")
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
assert data["page"] == 1
def test_v1_campaign_list_page_size_param(self) -> None:
"""Respect custom page_size parameter."""
response = self.client.get("/twitch/api/v1/campaigns/?page_size=1")
assert response.status_code == 200
data = response.json()
assert data["page_size"] == 1
assert len(data["items"]) == 1
def test_v1_campaign_list_summary_fields(self) -> None:
"""Return correct field shape for campaign summary items."""
response = self.client.get("/twitch/api/v1/campaigns/")
assert response.status_code == 200
data = response.json()
item = data["items"][0]
assert item["twitch_id"] == "campaign123"
assert item["name"] == "Test Campaign"
assert item["description"] == "A test campaign"
assert item["status"] == "active"
assert item["image_url"] == "https://example.com/campaign.png"
assert item["details_url"] == "https://example.com/details"
assert item["account_link_url"] == "https://example.com/link"
assert item["allow_is_enabled"] is True
assert item["is_fully_imported"] is True
assert "start_at" in item
assert "end_at" in item
assert "added_at" in item
assert "updated_at" in item
assert item["game"]["twitch_id"] == "game123"
def test_v1_campaign_detail(self) -> None:
"""Return nested campaign detail data from the v1 endpoint."""
response = self.client.get("/twitch/api/v1/campaigns/campaign123/")
@ -228,6 +328,35 @@ class TwitchApiV1TestCase(TestCase):
data = response.json()
assert data["game"]["box_art_url"] == self.game.box_art_file.url
def test_v1_campaign_detail_avoids_n_plus_one_on_benefits(self) -> None:
"""Fetch campaign detail without N+1 queries on DropBenefit fields.
Regression test: _serialize_benefit accesses created_at,
entitlement_limit, and is_ios_available; these must be included
in the Prefetch .only() in DropCampaign.for_detail_view to avoid
one extra query per benefit.
"""
with CaptureQueriesContext(connection) as capture:
response = self.client.get("/twitch/api/v1/campaigns/campaign123/")
assert response.status_code == 200
data = response.json()
benefits = data["drops"][0]["benefits"]
assert len(benefits) == 1
assert benefits[0]["created_at"] is None
assert benefits[0]["entitlement_limit"] == 1
assert benefits[0]["is_ios_available"] is False
# Expect 7 queries (5 core prefetches + 2 campaign COUNTs):
# 1. campaign + game (select_related)
# 2. game owners (Prefetch → owners_for_detail)
# 3. allow_channels (Prefetch → channels_ordered)
# 4. time_based_drops (Prefetch)
# 5. benefits through DropBenefitEdge (Prefetch)
# 6. campaign count for game
# 7. active campaign count for game
assert len(capture) <= 7
def test_v1_all_endpoints_handle_multiple_rows(self) -> None:
"""Exercise all v1 routes with enough rows to catch deferred loads."""
self._create_secondary_api_fixture()
@ -436,3 +565,263 @@ class TwitchApiV1TestCase(TestCase):
)
assert f'href="{campaign_api_url}"' in content
assert 'title="Twitch campaign API">[api]</a>' in content
# ── Games ──────────────────────────────────────────────────────────
def test_v1_game_list_fields(self) -> None:
"""Return correct field shape for game list items."""
response = self.client.get("/twitch/api/v1/games/")
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
item = data["items"][0]
assert item["twitch_id"] == "game123"
assert item["slug"] == "test-game"
assert item["name"] == "Test Game"
assert item["display_name"] == "Test Game"
assert isinstance(item["box_art_url"], str)
assert isinstance(item["organizations"], list)
assert item["organizations"][0]["twitch_id"] == "org123"
assert item["campaign_count"] >= 1
assert item["active_campaign_count"] >= 1
assert "added_at" in item
assert "updated_at" in item
def test_v1_game_list_pagination(self) -> None:
"""Paginate game list results."""
response = self.client.get("/twitch/api/v1/games/?page_size=1")
assert response.status_code == 200
data = response.json()
assert data["page_size"] == 1
assert len(data["items"]) == 1
def test_v1_game_detail_campaigns_and_owners(self) -> None:
"""Return campaigns and organizations in game detail."""
response = self.client.get("/twitch/api/v1/games/game123/")
assert response.status_code == 200
data = response.json()
assert data["campaigns"][0]["twitch_id"] == "campaign123"
assert data["campaigns"][0]["status"] == "active"
assert data["organizations"][0]["twitch_id"] == "org123"
def test_v1_game_detail_not_found(self) -> None:
"""Return 404 for missing game."""
response = self.client.get("/twitch/api/v1/games/nonexistent/")
assert response.status_code == 404
# ── Organizations ───────────────────────────────────────────────────
def test_v1_organization_list_fields(self) -> None:
"""Return correct field shape for organization list items."""
response = self.client.get("/twitch/api/v1/organizations/")
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
item = data["items"][0]
assert item["twitch_id"] == "org123"
assert item["name"] == "Test Organization"
assert "added_at" in item
assert "updated_at" in item
def test_v1_organization_detail_not_found(self) -> None:
"""Return 404 for missing organization."""
response = self.client.get("/twitch/api/v1/organizations/nonexistent/")
assert response.status_code == 404
# ── Channels ────────────────────────────────────────────────────────
def test_v1_channel_list_search(self) -> None:
"""Filter channels by search query."""
response = self.client.get("/twitch/api/v1/channels/?search=testchannel")
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["items"][0]["twitch_id"] == "channel123"
def test_v1_channel_list_search_no_match(self) -> None:
"""Return empty list when search matches no channels."""
response = self.client.get("/twitch/api/v1/channels/?search=zzzzz")
assert response.status_code == 200
data = response.json()
assert data["total"] == 0
assert data["items"] == []
def test_v1_channel_list_fields(self) -> None:
"""Return correct field shape for channel list items."""
response = self.client.get("/twitch/api/v1/channels/")
assert response.status_code == 200
data = response.json()
item = data["items"][0]
assert item["twitch_id"] == "channel123"
assert item["name"] == "testchannel"
assert item["display_name"] == "TestChannel"
assert item["allowed_campaign_count"] == 1
assert "added_at" in item
assert "updated_at" in item
def test_v1_channel_detail_campaigns(self) -> None:
"""Return campaign summaries in channel detail."""
response = self.client.get("/twitch/api/v1/channels/channel123/")
assert response.status_code == 200
data = response.json()
assert len(data["campaigns"]) == 1
assert data["campaigns"][0]["twitch_id"] == "campaign123"
assert data["campaigns"][0]["game"]["twitch_id"] == "game123"
def test_v1_channel_detail_not_found(self) -> None:
"""Return 404 for missing channel."""
response = self.client.get("/twitch/api/v1/channels/nonexistent/")
assert response.status_code == 404
# ── Reward Campaigns ────────────────────────────────────────────────
def test_v1_reward_campaign_list_filters_by_game(self) -> None:
"""Filter reward campaigns by game twitch_id."""
response = self.client.get(
f"/twitch/api/v1/reward-campaigns/?game={self.game.twitch_id}",
)
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["items"][0]["twitch_id"] == "reward123"
def test_v1_reward_campaign_list_status_active(self) -> None:
"""Filter reward campaigns by status=active."""
response = self.client.get("/twitch/api/v1/reward-campaigns/?status=active")
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["items"][0]["computed_status"] == "active"
def test_v1_reward_campaign_list_status_expired(self) -> None:
"""Filter reward campaigns by status=expired."""
now = timezone.now()
RewardCampaign.objects.create(
twitch_id="expired_reward",
name="Expired Reward",
brand="Expired Brand",
starts_at=now - timedelta(days=3),
ends_at=now - timedelta(days=1),
status="ACTIVE",
summary="Expired summary",
)
response = self.client.get("/twitch/api/v1/reward-campaigns/?status=expired")
assert response.status_code == 200
data = response.json()
assert data["total"] == 1
assert data["items"][0]["computed_status"] == "expired"
def test_v1_reward_campaign_list_fields(self) -> None:
"""Return correct field shape for reward campaign list items."""
response = self.client.get("/twitch/api/v1/reward-campaigns/")
assert response.status_code == 200
data = response.json()
item = data["items"][0]
assert item["twitch_id"] == "reward123"
assert item["name"] == "Test Reward"
assert item["brand"] == "Test Brand"
assert item["status"] == "ACTIVE"
assert item["computed_status"] == "active"
assert item["summary"] == "Reward summary"
assert item["is_sitewide"] is False
assert item["game"]["twitch_id"] == "game123"
assert "starts_at" in item
assert "ends_at" in item
assert "added_at" in item
assert "updated_at" in item
def test_v1_reward_campaign_detail_fields(self) -> None:
"""Return correct field shape for reward campaign detail."""
response = self.client.get("/twitch/api/v1/reward-campaigns/reward123/")
assert response.status_code == 200
data = response.json()
assert data["twitch_id"] == "reward123"
assert data["name"] == "Test Reward"
assert data["brand"] == "Test Brand"
assert isinstance(data["instructions"], str)
assert isinstance(data["external_url"], str)
assert isinstance(data["about_url"], str)
assert data["game"]["twitch_id"] == "game123"
def test_v1_reward_campaign_detail_no_game(self) -> None:
"""Return reward campaign detail with null game."""
now = timezone.now()
RewardCampaign.objects.create(
twitch_id="no_game_reward",
name="No Game Reward",
brand="Standalone Brand",
starts_at=now - timedelta(days=1),
ends_at=now + timedelta(days=1),
status="ACTIVE",
summary="No game attached",
is_sitewide=True,
)
response = self.client.get("/twitch/api/v1/reward-campaigns/no_game_reward/")
assert response.status_code == 200
data = response.json()
assert data["game"] is None
assert data["is_sitewide"] is True
def test_v1_reward_campaign_detail_not_found(self) -> None:
"""Return 404 for missing reward campaign."""
response = self.client.get("/twitch/api/v1/reward-campaigns/nonexistent/")
assert response.status_code == 404
# ── Badges ──────────────────────────────────────────────────────────
def test_v1_badge_list_fields(self) -> None:
"""Return correct field shape for badge set list items."""
response = self.client.get("/twitch/api/v1/badges/")
assert response.status_code == 200
data = response.json()
assert data["total"] >= 1
item = data["items"][0]
assert item["set_id"] == "test-badge-set"
assert len(item["badges"]) == 1
assert "added_at" in item
assert "updated_at" in item
def test_v1_badge_detail_fields(self) -> None:
"""Return correct field shape for badge set detail."""
response = self.client.get("/twitch/api/v1/badges/test-badge-set/")
assert response.status_code == 200
data = response.json()
assert data["set_id"] == "test-badge-set"
assert len(data["badges"]) == 1
badge = data["badges"][0]
assert badge["badge_id"] == "1"
assert badge["title"] == "Test Badge"
assert badge["description"] == "Test badge description"
assert "image_url_1x" in badge
assert "image_url_2x" in badge
assert "image_url_4x" in badge
assert "click_action" in badge
assert "click_url" in badge
def test_v1_badge_detail_not_found(self) -> None:
"""Return 404 for missing badge set."""
response = self.client.get("/twitch/api/v1/badges/nonexistent/")
assert response.status_code == 404

View file

@ -0,0 +1,714 @@
"""Tests for the import_twitch_drops_api management command."""
from __future__ import annotations
from typing import Self
from unittest.mock import MagicMock
from unittest.mock import patch
import httpx
import pytest
from django.test import TestCase
from twitch.management.commands.import_twitch_drops_api import Command
from twitch.models import DropBenefit
from twitch.models import DropBenefitEdge
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
# ---------------------------------------------------------------------------
# Sample data matching the SunkwiBOT/twitch-drops-api JSON formats
# ---------------------------------------------------------------------------
SAMPLE_DROPS_JSON: list[dict] = [
{
"endAt": "2026-06-21T10:59:00Z",
"gameBoxArtURL": "https://static-cdn.jtvnw.net/ttv-boxart/515025_IGDB-285x380.jpg",
"gameDisplayName": "Overwatch 2",
"gameId": "515025",
"rewards": [
{
"id": "reward-campaign-1",
"self": None,
"allow": {
"channels": [
{
"id": "123456789",
"displayName": "SomeStreamer",
"name": "somestreamer",
},
],
"isEnabled": True,
},
"accountLinkURL": "https://www.twitch.tv/drops/campaigns",
"description": "Earn Overwatch 2 drops by watching!",
"detailsURL": "https://www.twitch.tv/drops/campaigns/reward-campaign-1",
"endAt": "2026-06-21T10:59:00Z",
"eventBasedDrops": [],
"game": {
"id": "515025",
"slug": "overwatch-2",
"displayName": "Overwatch 2",
},
"imageURL": "https://example.com/campaign.png",
"name": "Overwatch 2 x Twitch Drops",
"owner": {
"id": "org-blizzard",
"name": "Blizzard Entertainment",
},
"startAt": "2026-06-12T15:00:00Z",
"status": "ACTIVE",
"timeBasedDrops": [
{
"id": "time-drop-1",
"requiredSubs": 0,
"benefitEdges": [
{
"benefit": {
"id": "benefit-1",
"createdAt": "2026-06-10T12:00:00Z",
"entitlementLimit": 1,
"game": {
"id": "515025",
"name": "Overwatch 2",
},
"imageAssetURL": "https://example.com/reward.png",
"isIosAvailable": True,
"name": "Ana's Cybermedic Skin",
"ownerOrganization": {
"id": "org-blizzard",
"name": "Blizzard Entertainment",
},
"distributionType": "MANUAL",
},
"entitlementLimit": 1,
},
],
"endAt": "2026-06-21T10:59:00Z",
"name": "Watch 2 hours",
"preconditionDrops": None,
"requiredMinutesWatched": 120,
"startAt": "2026-06-12T15:00:00Z",
},
],
},
],
"startAt": "2026-06-12T15:00:00Z",
},
]
SAMPLE_REWARDS_JSON: list[dict] = [
{
"aboutURL": "https://example.com/redeem",
"brand": "Mojang",
"endsAt": "2026-06-15T05:59:59.999Z",
"externalURL": "https://example.com/redeem",
"game": {
"displayName": "Minecraft",
"id": "27471",
"slug": "minecraft",
},
"id": "reward-campaign-2",
"image": {
"image1xURL": "https://example.com/reward-campaign.png",
},
"instructions": "",
"isSitewide": False,
"name": "Tubbo's WatchTime",
"rewards": [],
"rewardValueURLParam": "",
"startsAt": "2026-05-31T16:00:00Z",
"status": "UNKNOWN",
"summary": "Watch 5 minutes of Minecraft gameplay!",
"unlockRequirements": {
"minuteWatchedGoal": 5,
"subsGoal": 0,
},
},
]
def mock_httpx_get(data: list[dict]) -> MagicMock:
"""Return a mock httpx.Response that returns the given JSON data.
Args:
data: The JSON data to return from response.json().
Returns:
A MagicMock configured as an httpx response.
"""
mock_response = MagicMock()
mock_response.json.return_value = data
mock_response.raise_for_status.return_value = None
return mock_response
class MockResponse:
"""Minimal mock for httpx.Response used as a context-manager return."""
def __init__(self, json_data: list[dict]) -> None:
"""Initialise with JSON data to return from .json().
Args:
json_data: The data to return.
"""
self._json_data = json_data
def json(self) -> list[dict]:
"""Return the mocked JSON data.
Returns:
The JSON data.
"""
return self._json_data
def raise_for_status(self) -> None:
"""No-op for mock."""
class MockHttpxClient:
"""Mock for httpx.Client that returns canned data from its get() method."""
def __init__(self, drops_data: list[dict], rewards_data: list[dict]) -> None:
"""Initialise with data for each endpoint.
Args:
drops_data: Data to return for the drops URL.
rewards_data: Data to return for the rewards URL.
"""
self.drops_data = drops_data
self.rewards_data = rewards_data
def get(self, url: str, **kwargs: object) -> MockResponse:
"""Return canned data based on the URL.
Args:
url: The request URL.
**kwargs: Ignored.
Returns:
A MockResponse with the appropriate data.
Raises:
ValueError: If the URL is not recognised.
"""
if "drops.json" in url:
return MockResponse(self.drops_data)
if "rewards.json" in url:
return MockResponse(self.rewards_data)
msg = f"Unexpected URL: {url}"
raise ValueError(msg)
def __enter__(self) -> Self:
"""Support context manager usage.
Returns:
self.
"""
return self
def __exit__(self, *args: object) -> None:
"""No-op for context manager."""
@pytest.mark.no_zeal
class ImportTwitchDropsApiTests(TestCase):
"""Tests for the import_twitch_drops_api management command."""
drops_url = (
"https://raw.githubusercontent.com/SunkwiBOT/twitch-drops-api/main/drops.json"
)
rewards_url = (
"https://raw.githubusercontent.com/SunkwiBOT/twitch-drops-api/main/rewards.json"
)
def setUp(self) -> None:
"""Set up the command instance for each test."""
self.command = Command()
self.command._org_cache = {}
self.command._game_cache = {}
self.command._channel_cache = {}
self.command._benefit_cache = {}
# ------------------------------------------------------------------
# drops.json import tests
# ------------------------------------------------------------------
@patch("httpx.Client")
def test_import_drops_creates_campaign(self, mock_client: MagicMock) -> None:
"""Verify a basic drops.json import creates a DropCampaign with correct data."""
mock_client.return_value = MockHttpxClient(
drops_data=SAMPLE_DROPS_JSON,
rewards_data=[],
)
self.command.handle(
drops_url=self.drops_url,
rewards_url=self.rewards_url,
drops_only=True,
rewards_only=False,
source="SunkwiBOT/twitch-drops-api",
verbose=False,
crash_on_error=True,
)
# Game
game = Game.objects.get(twitch_id="515025")
assert game.display_name == "Overwatch 2"
# Organization
org = Organization.objects.get(twitch_id="org-blizzard")
assert org.name == "Blizzard Entertainment"
# DropCampaign
campaign = DropCampaign.objects.get(twitch_id="reward-campaign-1")
assert campaign.name == "Overwatch 2 x Twitch Drops"
assert campaign.data_source == "SunkwiBOT/twitch-drops-api"
assert campaign.is_fully_imported is True
# TimeBasedDrop
tbd = TimeBasedDrop.objects.get(twitch_id="time-drop-1")
assert tbd.name == "Watch 2 hours"
assert tbd.required_minutes_watched == 120
# DropBenefit
benefit = DropBenefit.objects.get(twitch_id="benefit-1")
assert benefit.name == "Ana's Cybermedic Skin"
assert benefit.distribution_type == "MANUAL"
# DropBenefitEdge
edge = DropBenefitEdge.objects.get(drop=tbd, benefit=benefit)
assert edge.entitlement_limit == 1
@patch("httpx.Client")
def test_import_drops_idempotent(self, mock_client: MagicMock) -> None:
"""Verify running the import twice doesn't create duplicates."""
mock_client.return_value = MockHttpxClient(
drops_data=SAMPLE_DROPS_JSON,
rewards_data=[],
)
# First run
self.command.handle(
drops_url=self.drops_url,
rewards_url=self.rewards_url,
drops_only=True,
rewards_only=False,
source="SunkwiBOT/twitch-drops-api",
verbose=False,
crash_on_error=True,
)
# Second run
self.command.handle(
drops_url=self.drops_url,
rewards_url=self.rewards_url,
drops_only=True,
rewards_only=False,
source="SunkwiBOT/twitch-drops-api",
verbose=False,
crash_on_error=True,
)
assert DropCampaign.objects.count() == 1
assert Game.objects.count() == 1
assert Organization.objects.count() == 1
assert TimeBasedDrop.objects.count() == 1
assert DropBenefit.objects.count() == 1
assert DropBenefitEdge.objects.count() == 1
@patch("httpx.Client")
def test_import_drops_sets_data_source(self, mock_client: MagicMock) -> None:
"""Verify the --source argument is stored on the DropCampaign."""
mock_client.return_value = MockHttpxClient(
drops_data=SAMPLE_DROPS_JSON,
rewards_data=[],
)
self.command.handle(
drops_url=self.drops_url,
rewards_url=self.rewards_url,
drops_only=True,
rewards_only=False,
source="CustomSource",
verbose=False,
crash_on_error=True,
)
campaign = DropCampaign.objects.get(twitch_id="reward-campaign-1")
assert campaign.data_source == "CustomSource"
# ------------------------------------------------------------------
# rewards.json import tests
# ------------------------------------------------------------------
@patch("httpx.Client")
def test_import_rewards_creates_reward_campaign(
self,
mock_client: MagicMock,
) -> None:
"""Verify a basic rewards.json import creates a RewardCampaign."""
# Create the game first since rewards.json references existing games
Game.objects.create(
twitch_id="27471",
display_name="Minecraft",
name="Minecraft",
)
mock_client.return_value = MockHttpxClient(
drops_data=[],
rewards_data=SAMPLE_REWARDS_JSON,
)
self.command.handle(
drops_url=self.drops_url,
rewards_url=self.rewards_url,
drops_only=False,
rewards_only=True,
source="SunkwiBOT/twitch-drops-api",
verbose=False,
crash_on_error=True,
)
reward = RewardCampaign.objects.get(twitch_id="reward-campaign-2")
assert reward.name == "Tubbo's WatchTime"
assert reward.brand == "Mojang"
assert reward.data_source == "SunkwiBOT/twitch-drops-api"
assert reward.is_sitewide is False
assert reward.game is not None
assert reward.game.twitch_id == "27471"
@patch("httpx.Client")
def test_import_rewards_sets_data_source(self, mock_client: MagicMock) -> None:
"""Verify the --source argument is stored on the RewardCampaign."""
mock_client.return_value = MockHttpxClient(
drops_data=[],
rewards_data=SAMPLE_REWARDS_JSON,
)
self.command.handle(
drops_url=self.drops_url,
rewards_url=self.rewards_url,
drops_only=False,
rewards_only=True,
source="CustomSource",
verbose=False,
crash_on_error=True,
)
reward = RewardCampaign.objects.get(twitch_id="reward-campaign-2")
assert reward.data_source == "CustomSource"
@patch("httpx.Client")
def test_import_rewards_idempotent(self, mock_client: MagicMock) -> None:
"""Verify running the rewards import twice doesn't create duplicates."""
Game.objects.create(
twitch_id="27471",
display_name="Minecraft",
name="Minecraft",
)
mock_client.return_value = MockHttpxClient(
drops_data=[],
rewards_data=SAMPLE_REWARDS_JSON,
)
# First run
self.command.handle(
drops_url=self.drops_url,
rewards_url=self.rewards_url,
drops_only=False,
rewards_only=True,
source="SunkwiBOT/twitch-drops-api",
verbose=False,
crash_on_error=True,
)
# Second run
self.command.handle(
drops_url=self.drops_url,
rewards_url=self.rewards_url,
drops_only=False,
rewards_only=True,
source="SunkwiBOT/twitch-drops-api",
verbose=False,
crash_on_error=True,
)
assert RewardCampaign.objects.count() == 1
# ------------------------------------------------------------------
# Combined import tests
# ------------------------------------------------------------------
@patch("httpx.Client")
def test_import_both(self, mock_client: MagicMock) -> None:
"""Verify importing both drops.json and rewards.json works together."""
Game.objects.create(
twitch_id="27471",
display_name="Minecraft",
name="Minecraft",
)
mock_client.return_value = MockHttpxClient(
drops_data=SAMPLE_DROPS_JSON,
rewards_data=SAMPLE_REWARDS_JSON,
)
self.command.handle(
drops_url=self.drops_url,
rewards_url=self.rewards_url,
drops_only=False,
rewards_only=False,
source="SunkwiBOT/twitch-drops-api",
verbose=False,
crash_on_error=True,
)
assert DropCampaign.objects.count() == 1
assert RewardCampaign.objects.count() == 1
assert Game.objects.count() == 2 # 515025 + 27471
# ------------------------------------------------------------------
# Error handling tests
# ------------------------------------------------------------------
@patch("httpx.Client")
def test_handles_invalid_json(self, mock_client: MagicMock) -> None:
"""Verify the command handles a non-list response gracefully."""
mock_response = MagicMock()
mock_response.json.return_value = {"not": "a list"}
mock_response.raise_for_status.return_value = None
mock_client.return_value.__enter__.return_value.get.return_value = mock_response
# The error is caught internally and logged; no data should be imported
self.command.handle(
drops_url=self.drops_url,
rewards_url=self.rewards_url,
drops_only=True,
rewards_only=False,
source="SunkwiBOT/twitch-drops-api",
verbose=False,
crash_on_error=False,
)
assert DropCampaign.objects.count() == 0
@patch("httpx.Client")
def test_handles_http_error(self, mock_client: MagicMock) -> None:
"""Verify the command handles HTTP errors gracefully."""
mock_response = MagicMock()
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
"404 Not Found",
request=MagicMock(),
response=MagicMock(),
)
mock_client.return_value.__enter__.return_value.get.return_value = mock_response
# Should not crash when crash_on_error is False
self.command.handle(
drops_url=self.drops_url,
rewards_url=self.rewards_url,
drops_only=False,
rewards_only=False,
source="SunkwiBOT/twitch-drops-api",
verbose=False,
crash_on_error=False,
)
# No data should have been imported
assert DropCampaign.objects.count() == 0
assert RewardCampaign.objects.count() == 0
# ---------------------------------------------------------------------------
# _strip_typename tests
# ---------------------------------------------------------------------------
class StripTypenameTests(TestCase):
"""Tests for Command._strip_typename."""
def test_removes_typename_from_dict(self) -> None:
"""Verify __typename is removed from a flat dict."""
result = Command._strip_typename({
"id": "123",
"__typename": "DropCampaign",
"name": "Test",
})
assert result == {"id": "123", "name": "Test"}
def test_removes_typename_nested(self) -> None:
"""Verify __typename is removed from nested dicts."""
result = Command._strip_typename({
"game": {
"id": "g1",
"__typename": "Game",
},
"__typename": "DropCampaign",
})
assert result == {"game": {"id": "g1"}}
def test_removes_typename_in_lists(self) -> None:
"""Verify __typename is removed from items inside lists."""
result = Command._strip_typename({
"rewards": [
{"id": "r1", "__typename": "Reward"},
{"id": "r2", "__typename": "Reward"},
],
})
assert result == {"rewards": [{"id": "r1"}, {"id": "r2"}]}
def test_handles_scalars(self) -> None:
"""Verify scalars and None pass through unchanged."""
assert Command._strip_typename("hello") == "hello"
assert Command._strip_typename(42) == 42
assert Command._strip_typename(None) is None
assert Command._strip_typename([1, 2, 3]) == [1, 2, 3]
def test_removes_typename_deeply_nested(self) -> None:
"""Verify deeply nested __typename is removed."""
data = {
"data": {
"user": {
"id": "u1",
"__typename": "User",
"campaigns": [
{
"id": "c1",
"__typename": "DropCampaign",
"game": {
"id": "g1",
"__typename": "Game",
},
},
],
},
},
"__typename": "Response",
}
result = Command._strip_typename(data)
# Should have no __typename anywhere
raw = str(result)
assert "__typename" not in raw
# ---------------------------------------------------------------------------
# Historical import tests
# ---------------------------------------------------------------------------
@pytest.mark.no_zeal
class HistoricalImportTests(TestCase):
"""Tests for the --historical mode of import_twitch_drops_api."""
def _init_caches(self, command: Command) -> None:
"""Initialise instance caches needed by import helpers."""
command._org_cache = {}
command._game_cache = {}
command._channel_cache = {}
command._benefit_cache = {}
def test_parse_and_import_drops_from_raw(self) -> None:
"""Verify _parse_and_import_drops works with pre-loaded data."""
command = Command()
self._init_caches(command)
count = command._parse_and_import_drops(
raw_data=SAMPLE_DROPS_JSON,
source="SunkwiBOT/twitch-drops-api",
verbose=False,
crash_on_error=True,
)
assert count == 1
assert DropCampaign.objects.count() == 1
campaign = DropCampaign.objects.get(twitch_id="reward-campaign-1")
assert campaign.data_source == "SunkwiBOT/twitch-drops-api"
def test_parse_and_import_rewards_from_raw(self) -> None:
"""Verify _parse_and_import_rewards works with pre-loaded data."""
Game.objects.create(twitch_id="27471", display_name="Minecraft")
command = Command()
self._init_caches(command)
count = command._parse_and_import_rewards(
raw_data=SAMPLE_REWARDS_JSON,
source="SunkwiBOT/twitch-drops-api",
verbose=False,
crash_on_error=True,
)
assert count == 1
assert RewardCampaign.objects.count() == 1
reward = RewardCampaign.objects.get(twitch_id="reward-campaign-2")
assert reward.data_source == "SunkwiBOT/twitch-drops-api"
def test_parse_and_import_drops_handles_typename(self) -> None:
"""Verify _parse_and_import_drops strips __typename before validation."""
raw = [
{
"endAt": "2026-06-21T10:59:00Z",
"gameBoxArtURL": "https://example.com/art.png",
"gameDisplayName": "Test Game",
"gameId": "test-game-1",
"rewards": [
{
"id": "hist-campaign-1",
"self": None,
"allow": {"isEnabled": True},
"accountLinkURL": "https://example.com",
"description": "Historical",
"detailsURL": "https://example.com",
"endAt": "2026-06-21T10:59:00Z",
"eventBasedDrops": [],
"game": {
"id": "test-game-1",
"slug": "test",
"displayName": "Test Game",
},
"imageURL": "",
"name": "Historical Campaign",
"owner": {"id": "org-1", "name": "Test Org"},
"startAt": "2026-06-12T15:00:00Z",
"status": "ACTIVE",
"timeBasedDrops": [],
"__typename": "DropCampaign",
},
],
"startAt": "2026-06-12T15:00:00Z",
"__typename": "DropGroup",
},
]
command = Command()
self._init_caches(command)
count = command._parse_and_import_drops(
raw_data=raw,
source="SunkwiBOT/twitch-drops-api",
verbose=False,
crash_on_error=True,
)
assert count == 1
assert DropCampaign.objects.filter(twitch_id="hist-campaign-1").exists()
@patch.object(Command, "_process_historical")
def test_historical_flag_calls_process_historical(
self,
mock_process: MagicMock,
) -> None:
"""Verify --historical calls _process_historical instead of fetching URLs."""
mock_process.return_value = (5, 2, [])
command = Command()
command.handle(
historical=True,
source="SunkwiBOT/twitch-drops-api",
verbose=False,
crash_on_error=False,
drops_url="",
rewards_url="",
drops_only=False,
rewards_only=False,
no_skip_latest=False,
max_commits=0,
git_dir="",
)
mock_process.assert_called_once()

View file

@ -5,7 +5,6 @@ from typing import TYPE_CHECKING
import pytest
from django.db import connection
from django.db.migrations.executor import MigrationExecutor
from django.db.migrations.state import StateApps
if TYPE_CHECKING:
from django.db.migrations.state import StateApps
@ -84,3 +83,12 @@ def test_0021_backfills_allowed_campaign_count() -> None: # noqa: PLR0914
"migration_backfill_channel_2": 1,
"migration_backfill_channel_3": 0,
}
# Restore database to the latest migration so subsequent tests don't
# encounter missing columns (SQLite DDL persists across transaction
# rollbacks, so the schema changes made by this test are permanent).
latest: list[tuple[str, str]] = [
("twitch", "0024_dropcampaign_data_source_rewardcampaign_data_source"),
]
executor = MigrationExecutor(connection)
executor.migrate(latest)

View file

@ -8,7 +8,6 @@ from typing import Literal
import pytest
from django.core.files.base import ContentFile
from django.core.handlers.wsgi import WSGIRequest
from django.core.paginator import Paginator
from django.db import connection
from django.db.models import Max
@ -43,17 +42,10 @@ if TYPE_CHECKING:
from django.test import Client
from django.test.client import _MonkeyPatchedWSGIResponse
from django.test.utils import ContextList
from pytest_django.fixtures import SettingsWrapper
from twitch.views import Page
@pytest.fixture(autouse=True)
def apply_base_url_override(settings: SettingsWrapper) -> None:
"""Ensure BASE_URL is globally overridden for all tests."""
settings.BASE_URL = "https://ttvdrops.lovinator.space" # pyright: ignore[reportAttributeAccessIssue]
@pytest.mark.django_db
class TestSearchView:
"""Tests for the search_view function."""

View file

@ -1,9 +1,7 @@
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
import pytest
from _pytest.capture import CaptureResult
from django.core.management.base import CommandError
from twitch.management.commands.watch_imports import Command