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..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
@@ -10,13 +12,91 @@ 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_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"))
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 +108,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 +117,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..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
@@ -8,90 +10,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 +105,45 @@ 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,
+ 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)