From 1f5e931af6325ef79f62dcd80868692b04357916 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Helle=C5=9Ben?= Date: Mon, 16 Mar 2026 23:41:19 +0100 Subject: [PATCH 1/2] Modify terminology --- templates/youtube/index.html | 8 ++--- youtube/tests/test_youtube.py | 41 ++++++++++++++++++++++--- youtube/views.py | 57 ++++++++++++++++++++--------------- 3 files changed, 73 insertions(+), 33 deletions(-) diff --git a/templates/youtube/index.html b/templates/youtube/index.html index cf65d83..cda0134 100644 --- a/templates/youtube/index.html +++ b/templates/youtube/index.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% block title %} - YouTube channels with rewards + {{ page_title }} {% endblock title %} {% block content %}
@@ -14,17 +14,17 @@ Channel - Partner + Organization - {% for group in partner_groups %} + {% for group in organization_groups %} {% for item in group.channels %} {{ item.channel }} - {{ group.partner }} + {{ group.organization }} {% endfor %} {% endfor %} diff --git a/youtube/tests/test_youtube.py b/youtube/tests/test_youtube.py index 62cefb9..2ed165c 100644 --- a/youtube/tests/test_youtube.py +++ b/youtube/tests/test_youtube.py @@ -10,13 +10,44 @@ if TYPE_CHECKING: class YouTubeIndexViewTest(TestCase): """Tests for the YouTube drops channels index page.""" + def test_index_includes_page_specific_seo_metadata(self) -> None: + """The YouTube page should render dedicated title and description metadata.""" + response: _MonkeyPatchedWSGIResponse = self.client.get(reverse("youtube:index")) + content: str = response.content.decode() + + assert response.context is not None + assert response.context["page_title"] == "YouTube channels with rewards" + assert ( + response.context["page_description"] + == "Browse YouTube channels listed as reward-enabled, including Call of Duty, Blizzard, " + "Fortnite, Riot Games, and more." + ) + assert ( + '' + in content + ) + assert ( + '' + ) in content + assert ( + '' + in content + ) + assert ( + '' + ) in content + def test_index_returns_200(self) -> None: """The YouTube index page should return HTTP 200.""" response: _MonkeyPatchedWSGIResponse = self.client.get(reverse("youtube:index")) assert response.status_code == 200 def test_index_displays_known_channels(self) -> None: - """The page should include key known channels from the partner list.""" + """The page should include key known channels from the organization list.""" response: _MonkeyPatchedWSGIResponse = self.client.get(reverse("youtube:index")) content: str = response.content.decode() @@ -28,8 +59,8 @@ class YouTubeIndexViewTest(TestCase): assert "Riot Games" in content assert "Ubisoft" in content - def test_index_includes_partner_urls(self) -> None: - """The page should render partner channel links from the source list.""" + def test_index_includes_organization_urls(self) -> None: + """The page should render organization channel links from the source list.""" response: _MonkeyPatchedWSGIResponse = self.client.get(reverse("youtube:index")) content: str = response.content.decode() @@ -37,8 +68,8 @@ class YouTubeIndexViewTest(TestCase): assert "https://www.youtube.com/user/epicfortnite" in content assert "https://www.youtube.com/lolesports" in content - def test_index_groups_partners_alphabetically(self) -> None: - """Partner sections should render grouped and in alphabetical order.""" + def test_index_groups_organizations_alphabetically(self) -> None: + """Organization sections should render grouped and in alphabetical order.""" response: _MonkeyPatchedWSGIResponse = self.client.get(reverse("youtube:index")) content: str = response.content.decode() diff --git a/youtube/views.py b/youtube/views.py index 28b2e27..44f6b8e 100644 --- a/youtube/views.py +++ b/youtube/views.py @@ -8,90 +8,94 @@ if TYPE_CHECKING: from django.http import HttpResponse +PAGE_TITLE = "YouTube channels with rewards" +PAGE_DESCRIPTION = "Browse YouTube channels listed as reward-enabled, including Call of Duty, Blizzard, Fortnite, Riot Games, and more." + + def index(request: HttpRequest) -> HttpResponse: - """Render a minimal list of YouTube channels with known drops-enabled partners. + """Render a minimal list of YouTube channels with known drops-enabled organizations. Returns: HttpResponse: Rendered index page for YouTube drops channels. """ channels: list[dict[str, str]] = [ { - "partner": "Activision (Call of Duty)", + "organization": "Activision (Call of Duty)", "channel": "Call of Duty", "url": "https://www.youtube.com/channel/UCbLIqv9Puhyp9_ZjVtfOy7w", }, { - "partner": "Battle.net / Blizzard", + "organization": "Battle.net / Blizzard", "channel": "PlayOverwatch", "url": "https://www.youtube.com/c/playoverwatch/featured", }, { - "partner": "Battle.net / Blizzard", + "organization": "Battle.net / Blizzard", "channel": "Hearthstone", "url": "https://www.youtube.com/c/Hearthstone/featured", }, { - "partner": "Electronic Arts", + "organization": "Electronic Arts", "channel": "FIFA", "url": "https://www.youtube.com/channel/UCFA6YGp5lvgayO20lk7_Ung", }, { - "partner": "Electronic Arts", + "organization": "Electronic Arts", "channel": "EA Madden NFL", "url": "https://www.youtube.com/@EAMaddenNFL", }, { - "partner": "Epic Games", + "organization": "Epic Games", "channel": "Fortnite", "url": "https://www.youtube.com/user/epicfortnite", }, { - "partner": "Garena", + "organization": "Garena", "channel": "Free Fire", "url": "https://www.youtube.com/channel/UC_vVy4OI86F0amXqFN_zTMg", }, { - "partner": "Krafton (PUBG)", + "organization": "Krafton (PUBG)", "channel": "PUBG: BATTLEGROUNDS", "url": "https://www.youtube.com/channel/UCTDO0RgowRyaAEUrPnBAg4g", }, { - "partner": "MLBB", + "organization": "MLBB", "channel": "Mobile Legends: Bang Bang", "url": "https://www.youtube.com/channel/UCqmld-BIYME2i_ooRTo1EOg", }, { - "partner": "NBA", + "organization": "NBA", "channel": "NBA", "url": "https://www.youtube.com/user/NBA", }, { - "partner": "NFL", + "organization": "NFL", "channel": "NFL", "url": "https://www.youtube.com/@NFL", }, { - "partner": "PUBG Mobile", + "organization": "PUBG Mobile", "channel": "PUBG MOBILE", "url": "https://www.youtube.com/channel/UCTDO0RgowRyaAEUrPnBAg4g", }, { - "partner": "Riot Games", + "organization": "Riot Games", "channel": "Riot Games", "url": "https://www.youtube.com/user/RiotGamesInc", }, { - "partner": "Riot Games", + "organization": "Riot Games", "channel": "LoL Esports", "url": "https://www.youtube.com/lolesports", }, { - "partner": "Supercell", + "organization": "Supercell", "channel": "Clash Royale", "url": "https://www.youtube.com/channel/UC_F8DoJf9MZogEOU51TpTbQ", }, { - "partner": "Ubisoft", + "organization": "Ubisoft", "channel": "Ubisoft", "url": "https://www.youtube.com/user/ubisoft", }, @@ -99,17 +103,22 @@ def index(request: HttpRequest) -> HttpResponse: grouped_channels: dict[str, list[dict[str, str]]] = defaultdict(list) for channel in channels: - grouped_channels[channel["partner"]].append(channel) + grouped_channels[channel["organization"]].append(channel) - partner_groups: list[dict[str, str | list[dict[str, str]]]] = [] - for partner in sorted(grouped_channels.keys(), key=str.lower): + organization_groups: list[dict[str, str | list[dict[str, str]]]] = [] + for organization in sorted(grouped_channels.keys(), key=str.lower): sorted_items: list[dict[str, str]] = sorted( - grouped_channels[partner], + grouped_channels[organization], key=lambda item: item["channel"].lower(), ) - partner_groups.append({"partner": partner, "channels": sorted_items}) + organization_groups.append({ + "organization": organization, + "channels": sorted_items, + }) - context: dict[str, list[dict[str, str | list[dict[str, str]]]]] = { - "partner_groups": partner_groups, + context: dict[str, str | list[dict[str, str | list[dict[str, str]]]]] = { + "page_title": PAGE_TITLE, + "page_description": PAGE_DESCRIPTION, + "organization_groups": organization_groups, } return render(request=request, template_name="youtube/index.html", context=context) From 60ac907163a23db669c94501e48f38d1e53c8d3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20Helle=C5=9Ben?= Date: Mon, 16 Mar 2026 23:47:07 +0100 Subject: [PATCH 2/2] Add Schema.org ItemList support to YouTube index page --- youtube/tests/test_youtube.py | 49 +++++++++++++++++++++++++++++++++++ youtube/views.py | 27 ++++++++++++++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/youtube/tests/test_youtube.py b/youtube/tests/test_youtube.py index 2ed165c..36c9a42 100644 --- a/youtube/tests/test_youtube.py +++ b/youtube/tests/test_youtube.py @@ -1,4 +1,6 @@ +import json from typing import TYPE_CHECKING +from typing import Any from django.test import TestCase from django.urls import reverse @@ -41,6 +43,53 @@ class YouTubeIndexViewTest(TestCase): 'Blizzard, Fortnite, Riot Games, and more." />' ) in content + def test_index_schema_data_is_valid_itemlist(self) -> None: + """The page should include a valid Schema.org ItemList in the JSON-LD context.""" + response: _MonkeyPatchedWSGIResponse = self.client.get(reverse("youtube:index")) + + assert response.context is not None + assert "schema_data" in response.context + + schema: dict[str, Any] = json.loads(response.context["schema_data"]) + assert schema["@context"] == "https://schema.org" + assert schema["@type"] == "ItemList" + assert schema["name"] == "YouTube channels with rewards" + assert "itemListElement" in schema + + items: list[dict[str, Any]] = schema["itemListElement"] + assert len(items) > 0 + + # Every entry must be a ListItem wrapping an Organization + for item in items: + assert item["@type"] == "ListItem" + assert "position" in item + org: dict[str, Any] = item["item"] + assert org["@type"] == "Organization" + assert "name" in org + assert isinstance(org["sameAs"], list) + assert len(org["sameAs"]) > 0 + + def test_index_schema_data_includes_known_orgs(self) -> None: + """The Schema.org ItemList should contain entries for known organizations.""" + response: _MonkeyPatchedWSGIResponse = self.client.get(reverse("youtube:index")) + schema: dict[str, Any] = json.loads(response.context["schema_data"]) # type: ignore[index] + org_names: list[str] = [ + item["item"]["name"] for item in schema["itemListElement"] + ] + + assert "Activision (Call of Duty)" in org_names + assert "Battle.net / Blizzard" in org_names + assert "Riot Games" in org_names + assert "Epic Games" in org_names + + def test_index_schema_data_org_same_as_are_youtube_urls(self) -> None: + """Each Organization's sameAs values should be YouTube URLs.""" + response: _MonkeyPatchedWSGIResponse = self.client.get(reverse("youtube:index")) + schema: dict[str, Any] = json.loads(response.context["schema_data"]) # type: ignore[index] + for list_item in schema["itemListElement"]: + for url in list_item["item"]["sameAs"]: + assert url.startswith("https://www.youtube.com/") + def test_index_returns_200(self) -> None: """The YouTube index page should return HTTP 200.""" response: _MonkeyPatchedWSGIResponse = self.client.get(reverse("youtube:index")) diff --git a/youtube/views.py b/youtube/views.py index 44f6b8e..5695dba 100644 --- a/youtube/views.py +++ b/youtube/views.py @@ -1,5 +1,7 @@ +import json from collections import defaultdict from typing import TYPE_CHECKING +from typing import Any from django.shortcuts import render @@ -116,9 +118,32 @@ def index(request: HttpRequest) -> HttpResponse: "channels": sorted_items, }) - context: dict[str, str | list[dict[str, str | list[dict[str, str]]]]] = { + list_items: list[dict[str, Any]] = [ + { + "@type": "ListItem", + "position": position, + "item": { + "@type": "Organization", + "name": group["organization"], + "sameAs": [ch["url"] for ch in group["channels"]], # type: ignore[index] + }, + } + for position, group in enumerate(organization_groups, start=1) + ] + + schema: dict[str, Any] = { + "@context": "https://schema.org", + "@type": "ItemList", + "name": PAGE_TITLE, + "description": PAGE_DESCRIPTION, + "url": request.build_absolute_uri(), + "itemListElement": list_items, + } + + context: dict[str, Any] = { "page_title": PAGE_TITLE, "page_description": PAGE_DESCRIPTION, "organization_groups": organization_groups, + "schema_data": json.dumps(schema), } return render(request=request, template_name="youtube/index.html", context=context)