Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions lms/resources/_js_config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from datetime import timedelta
from enum import Enum, StrEnum
from typing import Any
from urllib.parse import urlparse

from lms.error_code import ErrorCode
from lms.events import LTIEvent
Expand All @@ -21,10 +22,31 @@
JSTORService,
OrganizationService,
VitalSourceService,
YouTubeService,
)
from lms.validation.authentication import BearerTokenSchema
from lms.views.helpers import via_url

# Regex to extract YouTube video ID (same URL patterns as frontend utils/youtube.ts)
_YOUTUBE_VIDEO_ID_RE = re.compile(
r"(?:youtu\.be/|v/|u/\w/|embed/|shorts/|live/|watch\?v=|&v=)([^#&?]*)",
re.IGNORECASE,
)


def _youtube_video_id_from_url(url: str) -> str | None:
"""Return the YouTube video ID if url is a YouTube URL, else None."""
try:
parsed = urlparse(url)
if parsed.scheme not in ("http", "https"):
return None
if parsed.netloc.lower() not in ("www.youtube.com", "youtube.com", "youtu.be"):
return None
match = _YOUTUBE_VIDEO_ID_RE.search(url)
return match.group(1) if match and match.group(1) else None
except (ValueError, AttributeError):
return None


class JSConfig:
"""The config for the app's JavaScript code."""
Expand Down Expand Up @@ -168,6 +190,9 @@ def add_document_url( # pylint: disable=too-complex,too-many-branches,useless-s
}
else:
self._config["viaUrl"] = via_url(self._request, document_url)
youtube_service = self._request.find_service(iface=YouTubeService)
if youtube_service.enabled and _youtube_video_id_from_url(document_url):
self._hypothesis_client["youtubeAssignment"] = True

def _update_focus_config(self, updates: dict):
"""
Expand Down
93 changes: 91 additions & 2 deletions tests/unit/lms/resources/_js_config/__init___test.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
from datetime import timedelta
from unittest.mock import create_autospec, sentinel
from unittest.mock import create_autospec, patch, sentinel

import pytest
from h_matchers import Any

from lms.models import Grouping, LTIParams
from lms.product.product import Routes
from lms.resources import LTILaunchResource, OAuth2RedirectResource
from lms.resources._js_config import JSConfig
from lms.resources._js_config import JSConfig, _youtube_video_id_from_url
from lms.security import Identity, Permissions
from lms.services import HAPIError
from lms.views.api.sync import APISyncSchema
Expand All @@ -22,6 +22,7 @@
"h_api",
"vitalsource_service",
"jstor_service",
"youtube_service",
"misc_plugin",
)

Expand Down Expand Up @@ -421,6 +422,94 @@ def test_jstor_sets_config(self, js_config, jstor_service, pyramid_request):
}
assert js_config.asdict()["viaUrl"] == jstor_service.via_url.return_value

def test_youtube_assignment_sets_client_flag(
self, js_config, youtube_service, course, assignment, via_url
):
youtube_service.enabled = True
js_config.add_document_url("https://www.youtube.com/watch?v=abc123")
js_config.enable_lti_launch_mode(course, assignment)
config = js_config.asdict()
assert config["hypothesisClient"]["youtubeAssignment"] is True
via_url.assert_called_once()

@pytest.mark.parametrize(
"url,sets_youtube_assignment",
[
# Supported YouTube URL patterns (regex + host check)
("https://www.youtube.com/watch?v=abc123", True),
("https://youtube.com/watch?v=def456", True),
("https://youtu.be/ghi789", True),
("https://www.youtube.com/embed/jkl012", True),
("https://www.youtube.com/shorts/mno345", True),
("https://www.youtube.com/live/pqr678", True),
# Negative: wrong host
("https://example.com/article", False),
("https://vimeo.com/123456", False),
# Negative: wrong scheme (host would be ok but scheme fails)
("ftp://www.youtube.com/watch?v=abc", False),
],
)
def test_youtube_assignment_detection_per_url_pattern(
self,
js_config,
youtube_service,
course,
assignment,
via_url, # noqa: ARG002
url,
sets_youtube_assignment,
):
"""Lock down detection for each URL pattern and negative cases."""
youtube_service.enabled = True
js_config.add_document_url(url)
js_config.enable_lti_launch_mode(course, assignment)
config = js_config.asdict()
if sets_youtube_assignment:
assert config["hypothesisClient"]["youtubeAssignment"] is True
else:
assert config["hypothesisClient"].get("youtubeAssignment") is not True

def test_youtube_disabled_does_not_set_client_flag(
self,
js_config,
youtube_service,
course,
assignment,
via_url, # noqa: ARG002
):
youtube_service.enabled = False
js_config.add_document_url("https://www.youtube.com/watch?v=abc123")
js_config.enable_lti_launch_mode(course, assignment)
config = js_config.asdict()
assert config["hypothesisClient"].get("youtubeAssignment") is not True

def test_non_youtube_url_does_not_set_client_flag(
self,
js_config,
youtube_service,
course,
assignment,
via_url, # noqa: ARG002
):
youtube_service.enabled = True
js_config.add_document_url("https://example.com/article")
js_config.enable_lti_launch_mode(course, assignment)
config = js_config.asdict()
assert config["hypothesisClient"].get("youtubeAssignment") is not True

def test_youtube_video_id_from_url_returns_none_on_parse_error(self):
"""Cover the except (ValueError, AttributeError) branch."""
with patch("lms.resources._js_config.urlparse", side_effect=ValueError):
assert (
_youtube_video_id_from_url("https://www.youtube.com/watch?v=abc")
is None
)

def test_youtube_video_id_from_url_is_case_insensitive_for_host(self):
"""Host is normalized so YouTube.com / YOUTUBE.COM work like the frontend."""
assert _youtube_video_id_from_url("https://YouTube.com/watch?v=xyz") == "xyz"
assert _youtube_video_id_from_url("https://YOUTU.BE/xyz") == "xyz"


class TestAddCanvasSpeedgraderSettings:
@pytest.mark.parametrize("group_set", (sentinel.group_set, None))
Expand Down