Move all the APIs under the same router

This commit is contained in:
Joakim Hellsén 2026-07-21 04:05:35 +02:00
commit c54ceeb7a8
Signed by: Joakim Hellsén
SSH key fingerprint: SHA256:/9h/CsExpFp+PRhsfA0xznFx2CGfTT5R/kpuFfUgEQk
27 changed files with 1289 additions and 351 deletions

View file

@ -84,66 +84,63 @@ class KickCampaignApiTest(TestCase):
"kick.api.timezone.now",
return_value=datetime(2026, 7, 15, tzinfo=UTC),
):
response = self.client.get(reverse("kick:campaign_api_list"))
response = self.client.get(reverse("api-v1:kick-api-v1_list_campaigns"))
assert response.status_code == 200
assert response["Content-Type"] == "application/json"
assert response.json() == {
"total": 1,
"page": 1,
"page_size": 100,
"items": [
{
"kick_id": "campaign-api",
"name": "API Campaign",
"status": "active",
"image_url": "https://ext.cdn.kick.com/drops/reward-image/reward.png",
"connect_url": "https://example.com/connect",
"url": "https://example.com/campaign",
"starts_at": "2026-07-01T00:00:00Z",
"ends_at": "2026-08-01T00:00:00Z",
"category": {
"kick_id": 123,
"name": "API Game",
"slug": "api-game",
"image_url": "https://example.com/game.png",
},
"organization": {
"kick_id": "org-api",
"name": "API Organization",
"logo_url": "https://example.com/org.png",
"url": "https://example.com/org",
},
"channels": [
{
"kick_id": 789,
"slug": "api-streamer",
"url": "https://kick.com/api-streamer",
"user": {
"kick_id": 456,
"username": "api-streamer",
"profile_picture": "https://example.com/avatar.png",
},
},
],
"rewards": [
{
"kick_id": "reward-api",
"name": "API Reward",
"image_url": "https://ext.cdn.kick.com/drops/reward-image/reward.png",
"required_minutes_watched": 30,
},
],
"is_fully_imported": True,
"added_at": self.campaign.added_at.isoformat().replace(
"+00:00", "Z"
),
"updated_at": self.campaign.updated_at.isoformat().replace(
"+00:00", "Z"
),
},
],
assert response["Content-Type"].startswith("application/json")
data = response.json()
assert data["total"] == 1
assert data["page"] == 1
assert data["page_size"] == 100
item = data["items"][0]
assert item["kick_id"] == "campaign-api"
assert item["name"] == "API Campaign"
assert item["status"] == "active"
assert (
item["image_url"]
== "https://ext.cdn.kick.com/drops/reward-image/reward.png"
)
assert item["connect_url"] == "https://example.com/connect"
assert item["url"] == "https://example.com/campaign"
assert item["starts_at"] == "2026-07-01T00:00:00Z"
assert item["ends_at"] == "2026-08-01T00:00:00Z"
assert item["category"] == {
"kick_id": 123,
"name": "API Game",
"slug": "api-game",
"image_url": "https://example.com/game.png",
}
assert item["organization"] == {
"kick_id": "org-api",
"name": "API Organization",
"logo_url": "https://example.com/org.png",
"url": "https://example.com/org",
}
assert item["reward_count"] == 1
assert item["channels"] == [
{
"kick_id": 789,
"slug": "api-streamer",
"url": "https://kick.com/api-streamer",
"user": {
"kick_id": 456,
"username": "api-streamer",
"profile_picture": "https://example.com/avatar.png",
},
},
]
assert item["rewards"] == [
{
"kick_id": "reward-api",
"name": "API Reward",
"image_url": "https://ext.cdn.kick.com/drops/reward-image/reward.png",
"required_minutes_watched": 30,
},
]
assert item["is_fully_imported"] is True
assert "added_at" in item
assert "updated_at" in item
def test_filters_campaigns_by_game(self) -> None:
"""A Kick category ID can select a downstream import subset."""
@ -162,7 +159,7 @@ class KickCampaignApiTest(TestCase):
)
response = self.client.get(
reverse("kick:campaign_api_list"),
reverse("api-v1:kick-api-v1_list_campaigns"),
{"game": self.category.kick_id},
)
@ -204,12 +201,12 @@ class KickCampaignApiTest(TestCase):
for status, expected_id in expected_ids.items():
with self.subTest(status=status):
response = self.client.get(
reverse("kick:campaign_api_list"),
reverse("api-v1:kick-api-v1_list_campaigns"),
{"status": status},
)
assert response.status_code == 200
assert [item["kick_id"] for item in response.json()["items"]] == [
expected_id
expected_id,
]
def test_status_filters_exclude_campaigns_with_partial_dates(self) -> None:
@ -240,7 +237,7 @@ class KickCampaignApiTest(TestCase):
for status in ("upcoming", "expired"):
with self.subTest(status=status):
response = self.client.get(
reverse("kick:campaign_api_list"),
reverse("api-v1:kick-api-v1_list_campaigns"),
{"status": status},
)
assert all(
@ -261,7 +258,7 @@ class KickCampaignApiTest(TestCase):
)
response = self.client.get(
reverse("kick:campaign_api_list"),
reverse("api-v1:kick-api-v1_list_campaigns"),
{"page": 2, "page_size": 1},
)
@ -277,7 +274,7 @@ class KickCampaignApiTest(TestCase):
def test_clamps_page_size(self) -> None:
"""A caller cannot request an unbounded response page."""
response = self.client.get(
reverse("kick:campaign_api_list"),
reverse("api-v1:kick-api-v1_list_campaigns"),
{"page_size": 1000},
)
@ -287,7 +284,7 @@ class KickCampaignApiTest(TestCase):
def test_clamps_pagination_minimums(self) -> None:
"""Zero and negative pagination values use the first one-item page."""
response = self.client.get(
reverse("kick:campaign_api_list"),
reverse("api-v1:kick-api-v1_list_campaigns"),
{"page": 0, "page_size": -1},
)
@ -300,7 +297,7 @@ class KickCampaignApiTest(TestCase):
for field in ("page", "page_size"):
with self.subTest(field=field):
response = self.client.get(
reverse("kick:campaign_api_list"),
reverse("api-v1:kick-api-v1_list_campaigns"),
{field: "not-an-integer"},
)
assert response.status_code == 422
@ -311,7 +308,7 @@ class KickCampaignApiTest(TestCase):
page = 10**100
response = self.client.get(
reverse("kick:campaign_api_list"),
reverse("api-v1:kick-api-v1_list_campaigns"),
{"page": page},
)
@ -322,7 +319,7 @@ class KickCampaignApiTest(TestCase):
def test_rejects_invalid_status(self) -> None:
"""Unknown status values return a client error instead of an empty result."""
response = self.client.get(
reverse("kick:campaign_api_list"),
reverse("api-v1:kick-api-v1_list_campaigns"),
{"status": "running"},
)
@ -332,7 +329,7 @@ class KickCampaignApiTest(TestCase):
def test_rejects_empty_status(self) -> None:
"""An explicitly empty status is invalid rather than an omitted filter."""
response = self.client.get(
reverse("kick:campaign_api_list"),
reverse("api-v1:kick-api-v1_list_campaigns"),
{"status": ""},
)
@ -340,14 +337,14 @@ class KickCampaignApiTest(TestCase):
def test_rejects_non_get_methods(self) -> None:
"""The campaign feed is a read-only endpoint."""
response = self.client.post(reverse("kick:campaign_api_list"))
response = self.client.post(reverse("api-v1:kick-api-v1_list_campaigns"))
assert response.status_code == 405
def test_rejects_invalid_game_id(self) -> None:
"""A non-numeric Kick category ID returns a validation response."""
response = self.client.get(
reverse("kick:campaign_api_list"),
reverse("api-v1:kick-api-v1_list_campaigns"),
{"game": "not-an-id"},
)
@ -363,7 +360,7 @@ class KickCampaignApiTest(TestCase):
)
self.campaign.channels.add(channel)
response = self.client.get(reverse("kick:campaign_api_list"))
response = self.client.get(reverse("api-v1:kick-api-v1_list_campaigns"))
channels = response.json()["items"][0]["channels"]
assert len(channels) == 7
@ -383,7 +380,7 @@ class KickCampaignApiTest(TestCase):
campaign=self.campaign,
)
response = self.client.get(reverse("kick:campaign_api_list"))
response = self.client.get(reverse("api-v1:kick-api-v1_list_campaigns"))
rewards = response.json()["items"][0]["rewards"]
assert [reward["name"] for reward in rewards] == [
@ -404,7 +401,7 @@ class KickCampaignApiTest(TestCase):
is_fully_imported=True,
)
response = self.client.get(reverse("kick:campaign_api_list"))
response = self.client.get(reverse("api-v1:kick-api-v1_list_campaigns"))
item = next(
item
@ -418,7 +415,7 @@ class KickCampaignApiTest(TestCase):
def select_count() -> int:
with CaptureQueriesContext(connection) as queries:
response = self.client.get(reverse("kick:campaign_api_list"))
response = self.client.get(reverse("api-v1:kick-api-v1_list_campaigns"))
assert response.status_code == 200
return sum(
query["sql"].lstrip().upper().startswith("SELECT")
@ -447,3 +444,367 @@ class KickCampaignApiTest(TestCase):
scaled = select_count()
assert scaled == baseline
class KickCampaignDetailApiTest(TestCase):
"""Tests for the campaign detail endpoint."""
@classmethod
def setUpTestData(cls) -> None:
cls.organization = KickOrganization.objects.create(
kick_id="org-detail",
name="Detail Org",
)
cls.category = KickCategory.objects.create(
kick_id=200,
name="Detail Game",
slug="detail-game",
)
cls.campaign = KickDropCampaign.objects.create(
kick_id="campaign-detail",
name="Detail Campaign",
starts_at=datetime(2026, 7, 1, tzinfo=UTC),
ends_at=datetime(2026, 8, 1, tzinfo=UTC),
organization=cls.organization,
category=cls.category,
rule_id=1,
rule_name="Watch to earn",
is_fully_imported=True,
)
cls.user = KickUser.objects.create(
kick_id=457,
username="detail-streamer",
profile_picture="https://example.com/detail.png",
)
cls.channel = KickChannel.objects.create(
kick_id=790,
slug="detail-streamer",
user=cls.user,
)
cls.campaign.channels.add(cls.channel)
cls.reward = KickReward.objects.create(
kick_id="reward-detail",
name="Detail Reward",
required_units=45,
campaign=cls.campaign,
category=cls.category,
organization=cls.organization,
)
def test_detail_returns_campaign(self) -> None:
"""A fully imported campaign returns its full serialized payload."""
with mock.patch(
"kick.api.timezone.now",
return_value=datetime(2026, 7, 15, tzinfo=UTC),
):
response = self.client.get(
reverse(
"api-v1:kick-api-v1_get_campaign",
args=[self.campaign.kick_id],
),
)
assert response.status_code == 200
data = response.json()
assert data["kick_id"] == "campaign-detail"
assert data["status"] == "active"
assert data["name"] == "Detail Campaign"
assert len(data["channels"]) == 1
assert len(data["rewards"]) == 1
def test_detail_not_found(self) -> None:
"""A non-existent campaign ID returns 404."""
response = self.client.get(
reverse("api-v1:kick-api-v1_get_campaign", args=["nonexistent-id"]),
)
assert response.status_code == 404
def test_detail_not_fully_imported(self) -> None:
"""A campaign that is not fully imported returns 404."""
KickDropCampaign.objects.create(
kick_id="not-imported-detail",
name="Not Imported",
organization=self.organization,
category=self.category,
starts_at=datetime(2026, 7, 1, tzinfo=UTC),
ends_at=datetime(2026, 8, 1, tzinfo=UTC),
is_fully_imported=False,
)
response = self.client.get(
reverse("api-v1:kick-api-v1_get_campaign", args=["not-imported-detail"]),
)
assert response.status_code == 404
class KickCampaignSearchFilterTest(TestCase):
"""Tests for the search and organization filter on the campaign list."""
@classmethod
def setUpTestData(cls) -> None:
cls.org_a = KickOrganization.objects.create(
kick_id="org-a",
name="Studio Alpha",
)
cls.org_b = KickOrganization.objects.create(kick_id="org-b", name="Beta Corp")
cls.cat = KickCategory.objects.create(kick_id=300, name="Test Game")
cls.camp_alpha = KickDropCampaign.objects.create(
kick_id="alpha-camp",
name="Alpha Drop Event",
starts_at=datetime(2026, 6, 1, tzinfo=UTC),
ends_at=datetime(2026, 7, 1, tzinfo=UTC),
organization=cls.org_a,
category=cls.cat,
is_fully_imported=True,
)
cls.camp_beta = KickDropCampaign.objects.create(
kick_id="beta-camp",
name="Beta Giveaway",
starts_at=datetime(2026, 7, 1, tzinfo=UTC),
ends_at=datetime(2026, 8, 1, tzinfo=UTC),
organization=cls.org_b,
category=cls.cat,
is_fully_imported=True,
)
cls.camp_alpha2 = KickDropCampaign.objects.create(
kick_id="alpha-camp-2",
name="Alpha Second Wave",
starts_at=datetime(2026, 8, 1, tzinfo=UTC),
ends_at=datetime(2026, 9, 1, tzinfo=UTC),
organization=cls.org_a,
category=cls.cat,
is_fully_imported=True,
)
def test_filters_by_organization(self) -> None:
"""An organization kick_id returns only that org's campaigns."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"organization": "org-a"},
)
assert response.status_code == 200
ids = [item["kick_id"] for item in response.json()["items"]]
assert sorted(ids) == ["alpha-camp", "alpha-camp-2"]
def test_organization_filter_with_no_matches(self) -> None:
"""An org with no campaigns returns an empty list."""
KickOrganization.objects.create(kick_id="org-empty", name="Empty Org")
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"organization": "org-empty"},
)
assert response.status_code == 200
assert response.json()["items"] == []
def test_searches_by_name(self) -> None:
"""A search term filters campaigns by case-insensitive name match."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"search": "alpha"},
)
assert response.status_code == 200
ids = [item["kick_id"] for item in response.json()["items"]]
assert sorted(ids) == ["alpha-camp", "alpha-camp-2"]
def test_search_with_no_matches(self) -> None:
"""A search term with no matches returns an empty list."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"search": "zzzzz"},
)
assert response.status_code == 200
assert response.json()["items"] == []
def test_search_case_insensitive(self) -> None:
"""Search is case-insensitive."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"search": "ALPHA"},
)
assert response.status_code == 200
ids = [item["kick_id"] for item in response.json()["items"]]
assert sorted(ids) == ["alpha-camp", "alpha-camp-2"]
def test_organization_filter_with_search(self) -> None:
"""Organization filter and search can be combined."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_campaigns"),
{"organization": "org-a", "search": "Second"},
)
assert response.status_code == 200
ids = [item["kick_id"] for item in response.json()["items"]]
assert ids == ["alpha-camp-2"]
class KickOrganizationApiTest(TestCase):
"""Tests for the organization API endpoints."""
@classmethod
def setUpTestData(cls) -> None:
cls.org_a = KickOrganization.objects.create(
kick_id="org-list-a",
name="Alpha Studios",
logo_url="https://example.com/alpha.png",
url="https://example.com/alpha",
)
cls.org_b = KickOrganization.objects.create(
kick_id="org-list-b",
name="Beta Games",
logo_url="https://example.com/beta.png",
url="https://example.com/beta",
)
cat = KickCategory.objects.create(kick_id=400, name="Game")
for org in (cls.org_a, cls.org_b):
KickDropCampaign.objects.create(
kick_id=f"camp-{org.kick_id}",
name=f"{org.name} Campaign",
starts_at=datetime(2026, 7, 1, tzinfo=UTC),
ends_at=datetime(2026, 8, 1, tzinfo=UTC),
organization=org,
category=cat,
is_fully_imported=True,
)
def test_list_returns_organizations(self) -> None:
"""Organization list returns all orgs with campaign counts."""
response = self.client.get(reverse("api-v1:kick-api-v1_list_organizations"))
assert response.status_code == 200
data = response.json()
assert data["total"] == 2
names = [item["name"] for item in data["items"]]
assert names == ["Alpha Studios", "Beta Games"]
assert all(item["campaign_count"] == 1 for item in data["items"])
def test_detail_returns_organization_with_campaigns(self) -> None:
"""Organization detail returns org metadata and its campaigns."""
response = self.client.get(
reverse("api-v1:kick-api-v1_get_organization", args=["org-list-a"]),
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Alpha Studios"
assert data["campaign_count"] == 1
assert len(data["campaigns"]) == 1
assert data["campaigns"][0]["name"] == "Alpha Studios Campaign"
def test_detail_not_found(self) -> None:
"""A non-existent org returns 404."""
response = self.client.get(
reverse("api-v1:kick-api-v1_get_organization", args=["nonexistent"]),
)
assert response.status_code == 404
def test_list_pagination(self) -> None:
"""Organization list respects pagination."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_organizations"),
{"page": 1, "page_size": 1},
)
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
assert data["total"] == 2
def test_list_search(self) -> None:
"""Organization list supports search by name."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_organizations"),
{"search": "alpha"},
)
assert response.status_code == 200
names = [item["name"] for item in response.json()["items"]]
assert names == ["Alpha Studios"]
class KickCategoryApiTest(TestCase):
"""Tests for the category (game) API endpoints."""
@classmethod
def setUpTestData(cls) -> None:
cls.cat_a = KickCategory.objects.create(
kick_id=500,
name="Action Game",
slug="action-game",
image_url="https://example.com/action.png",
)
cls.cat_b = KickCategory.objects.create(
kick_id=501,
name="Racing Game",
slug="racing-game",
image_url="https://example.com/racing.png",
)
org = KickOrganization.objects.create(
kick_id="cat-org",
name="Category Org",
)
for cat in (cls.cat_a, cls.cat_b):
KickDropCampaign.objects.create(
kick_id=f"camp-{cat.kick_id}",
name=f"{cat.name} Campaign",
starts_at=datetime(2026, 7, 1, tzinfo=UTC),
ends_at=datetime(2026, 8, 1, tzinfo=UTC),
organization=org,
category=cat,
is_fully_imported=True,
)
def test_list_returns_categories(self) -> None:
"""Category list returns all categories with campaign counts."""
response = self.client.get(reverse("api-v1:kick-api-v1_list_games"))
assert response.status_code == 200
data = response.json()
assert data["total"] == 2
names = [item["name"] for item in data["items"]]
assert names == ["Action Game", "Racing Game"]
assert all(item["campaign_count"] == 1 for item in data["items"])
def test_detail_returns_category_with_campaigns(self) -> None:
"""Category detail returns metadata and its campaigns."""
response = self.client.get(
reverse("api-v1:kick-api-v1_get_game", args=[500]),
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "Action Game"
assert data["campaign_count"] == 1
assert len(data["campaigns"]) == 1
assert data["campaigns"][0]["name"] == "Action Game Campaign"
def test_detail_not_found(self) -> None:
"""A non-existent category returns 404."""
response = self.client.get(
reverse("api-v1:kick-api-v1_get_game", args=[99999]),
)
assert response.status_code == 404
def test_list_search(self) -> None:
"""Category list supports search by name."""
response = self.client.get(
reverse("api-v1:kick-api-v1_list_games"),
{"search": "action"},
)
assert response.status_code == 200
names = [item["name"] for item in response.json()["items"]]
assert names == ["Action Game"]
class KickStatsApiTest(TestCase):
"""Tests for the stats endpoint."""
@classmethod
def setUpTestData(cls) -> None:
KickOrganization.objects.create(kick_id="stats-org", name="Stats Org")
KickCategory.objects.create(kick_id=600, name="Stats Game")
KickChannel.objects.create(kick_id=800, slug="stats-channel")
def test_stats_returns_aggregate_counts(self) -> None:
"""Stats endpoint returns counts for all entity types."""
response = self.client.get(reverse("api-v1:kick-api-v1_stats"))
assert response.status_code == 200
data = response.json()
assert data["total_campaigns"] == 0
assert data["total_organizations"] == 1
assert data["total_categories"] == 1
assert data["total_channels"] == 1
for key in ("active", "upcoming", "expired", "partial_dates"):
assert key in data