492 lines
18 KiB
Python
492 lines
18 KiB
Python
"""
|
|
Unit tests for video utils.
|
|
"""
|
|
|
|
|
|
from datetime import datetime
|
|
from unittest import TestCase
|
|
from unittest import mock
|
|
|
|
import ddt
|
|
import pytz
|
|
import requests
|
|
from django.conf import settings
|
|
from django.core.files.base import ContentFile
|
|
from django.core.files.storage import get_storage_class
|
|
from django.core.files.uploadedfile import UploadedFile
|
|
from django.test.utils import override_settings
|
|
from edxval.api import create_profile, create_video, get_course_video_image_url, update_video_image
|
|
from storages.backends.s3boto3 import S3Boto3Storage
|
|
|
|
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
|
|
from cms.djangoapps.contentstore.video_utils import (
|
|
YOUTUBE_THUMBNAIL_SIZES,
|
|
download_youtube_video_thumbnail,
|
|
scrape_youtube_thumbnail,
|
|
validate_video_image
|
|
)
|
|
from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file
|
|
|
|
|
|
class ValidateVideoImageTestCase(TestCase):
|
|
"""
|
|
Tests for `validate_video_image` method.
|
|
"""
|
|
def test_invalid_image_file_info(self):
|
|
"""
|
|
Test that when no file information is provided to validate_video_image, it gives proper error message.
|
|
"""
|
|
error = validate_video_image({})
|
|
self.assertEqual(error, 'The image must have name, content type, and size information.')
|
|
|
|
def test_corrupt_image_file(self):
|
|
"""
|
|
Test that when corrupt file is provided to validate_video_image, it gives proper error message.
|
|
"""
|
|
with open(settings.MEDIA_ROOT + '/test-corrupt-image.png', 'w+') as image_file:
|
|
uploaded_image_file = UploadedFile(
|
|
image_file,
|
|
content_type='image/png',
|
|
size=settings.VIDEO_IMAGE_SETTINGS['VIDEO_IMAGE_MIN_BYTES']
|
|
)
|
|
error = validate_video_image(uploaded_image_file)
|
|
self.assertEqual(error, 'There is a problem with this image file. Try to upload a different file.')
|
|
|
|
|
|
@ddt.ddt
|
|
class ScrapeVideoThumbnailsTestCase(CourseTestCase):
|
|
"""
|
|
Test cases for scraping video thumbnails from youtube.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
course_ids = [str(self.course.id)]
|
|
profiles = ['youtube']
|
|
created = datetime.now(pytz.utc)
|
|
previous_uploads = [
|
|
{
|
|
'edx_video_id': 'test1',
|
|
'client_video_id': 'test1.mp4',
|
|
'duration': 42.0,
|
|
'status': 'upload',
|
|
'courses': course_ids,
|
|
'encoded_videos': [],
|
|
'created': created
|
|
},
|
|
{
|
|
'edx_video_id': 'test-youtube-video-1',
|
|
'client_video_id': 'test-youtube-id.mp4',
|
|
'duration': 128.0,
|
|
'status': 'file_complete',
|
|
'courses': course_ids,
|
|
'created': created,
|
|
'encoded_videos': [
|
|
{
|
|
'profile': 'youtube',
|
|
'url': '3_yD_cEKoCk',
|
|
'file_size': 1600,
|
|
'bitrate': 100,
|
|
}
|
|
],
|
|
},
|
|
{
|
|
'edx_video_id': 'test-youtube-video-2',
|
|
'client_video_id': 'test-youtube-id.mp4',
|
|
'image': 'image2.jpg',
|
|
'duration': 128.0,
|
|
'status': 'file_complete',
|
|
'courses': course_ids,
|
|
'created': created,
|
|
'encoded_videos': [
|
|
{
|
|
'profile': 'youtube',
|
|
'url': '3_yD_cEKoCk',
|
|
'file_size': 1600,
|
|
'bitrate': 100,
|
|
}
|
|
],
|
|
},
|
|
]
|
|
for profile in profiles:
|
|
create_profile(profile)
|
|
|
|
for video in previous_uploads:
|
|
create_video(video)
|
|
|
|
# Create video images.
|
|
with make_image_file() as image_file:
|
|
update_video_image(
|
|
'test-youtube-video-2', str(self.course.id), image_file, 'image.jpg'
|
|
)
|
|
|
|
def mocked_youtube_thumbnail_response(
|
|
self,
|
|
mocked_content=None,
|
|
error_response=False,
|
|
image_width=settings.VIDEO_IMAGE_MIN_WIDTH,
|
|
image_height=settings.VIDEO_IMAGE_MIN_HEIGHT
|
|
):
|
|
"""
|
|
Returns a mocked youtube thumbnail response.
|
|
"""
|
|
image_content = ''
|
|
with make_image_file(dimensions=(image_width, image_height), ) as image_file:
|
|
image_content = image_file.read()
|
|
|
|
if mocked_content or error_response:
|
|
image_content = mocked_content
|
|
|
|
mocked_response = requests.Response()
|
|
mocked_response.status_code = requests.codes.ok if image_content else requests.codes.not_found # pylint: disable=no-member
|
|
mocked_response._content = image_content # pylint: disable=protected-access
|
|
mocked_response.headers = {'content-type': 'image/jpeg'}
|
|
return mocked_response
|
|
|
|
@override_settings(AWS_ACCESS_KEY_ID='test_key_id', AWS_SECRET_ACCESS_KEY='test_secret')
|
|
@mock.patch('requests.get')
|
|
@ddt.data(
|
|
(
|
|
{
|
|
'maxresdefault': 'maxresdefault-result-image-content',
|
|
'sddefault': 'sddefault-result-image-content',
|
|
'hqdefault': 'hqdefault-result-image-content',
|
|
'mqdefault': 'mqdefault-result-image-content',
|
|
'default': 'default-result-image-content'
|
|
},
|
|
'maxresdefault-result-image-content'
|
|
),
|
|
(
|
|
{
|
|
'maxresdefault': '',
|
|
'sddefault': 'sddefault-result-image-content',
|
|
'hqdefault': 'hqdefault-result-image-content',
|
|
'mqdefault': 'mqdefault-result-image-content',
|
|
'default': 'default-result-image-content'
|
|
},
|
|
'sddefault-result-image-content'
|
|
),
|
|
(
|
|
{
|
|
'maxresdefault': '',
|
|
'sddefault': '',
|
|
'hqdefault': 'hqdefault-result-image-content',
|
|
'mqdefault': 'mqdefault-result-image-content',
|
|
'default': 'default-result-image-content'
|
|
},
|
|
'hqdefault-result-image-content'
|
|
),
|
|
(
|
|
{
|
|
'maxresdefault': '',
|
|
'sddefault': '',
|
|
'hqdefault': '',
|
|
'mqdefault': 'mqdefault-result-image-content',
|
|
'default': 'default-result-image-content'
|
|
},
|
|
'mqdefault-result-image-content'
|
|
),
|
|
(
|
|
{
|
|
'maxresdefault': '',
|
|
'sddefault': '',
|
|
'hqdefault': '',
|
|
'mqdefault': '',
|
|
'default': 'default-result-image-content'
|
|
},
|
|
'default-result-image-content'
|
|
),
|
|
)
|
|
@ddt.unpack
|
|
def test_youtube_video_thumbnail_download(
|
|
self,
|
|
thumbnail_content_data,
|
|
expected_thumbnail_content,
|
|
mocked_request
|
|
):
|
|
"""
|
|
Test that we get highest resolution video thumbnail available from youtube.
|
|
"""
|
|
# Mock get youtube thumbnail responses.
|
|
def mocked_youtube_thumbnail_responses(resolutions):
|
|
"""
|
|
Returns a list of mocked responses containing youtube thumbnails.
|
|
"""
|
|
mocked_responses = []
|
|
for resolution in YOUTUBE_THUMBNAIL_SIZES:
|
|
mocked_content = resolutions.get(resolution, '')
|
|
error_response = False if mocked_content else True # lint-amnesty, pylint: disable=simplifiable-if-expression
|
|
mocked_responses.append(self.mocked_youtube_thumbnail_response(mocked_content, error_response))
|
|
return mocked_responses
|
|
|
|
mocked_request.side_effect = mocked_youtube_thumbnail_responses(thumbnail_content_data)
|
|
|
|
thumbnail_content, thumbnail_content_type = download_youtube_video_thumbnail('test-yt-id')
|
|
|
|
# Verify that we get the expected thumbnail content.
|
|
self.assertEqual(thumbnail_content, expected_thumbnail_content)
|
|
self.assertEqual(thumbnail_content_type, 'image/jpeg')
|
|
|
|
@override_settings(AWS_ACCESS_KEY_ID='test_key_id', AWS_SECRET_ACCESS_KEY='test_secret')
|
|
@mock.patch('requests.get')
|
|
def test_scrape_youtube_thumbnail(self, mocked_request):
|
|
"""
|
|
Test that youtube thumbnails are correctly scrapped.
|
|
"""
|
|
course_id = str(self.course.id)
|
|
video1_edx_video_id = 'test-youtube-video-1'
|
|
video2_edx_video_id = 'test-youtube-video-2'
|
|
|
|
# Mock get youtube thumbnail responses.
|
|
mocked_request.side_effect = [self.mocked_youtube_thumbnail_response()]
|
|
|
|
# Verify that video1 has no image attached.
|
|
video1_image_url = get_course_video_image_url(course_id=course_id, edx_video_id=video1_edx_video_id)
|
|
self.assertIsNone(video1_image_url)
|
|
|
|
# Verify that video2 has already image attached.
|
|
video2_image_url = get_course_video_image_url(course_id=course_id, edx_video_id=video2_edx_video_id)
|
|
self.assertIsNotNone(video2_image_url)
|
|
|
|
# Scrape video thumbnails.
|
|
scrape_youtube_thumbnail(course_id, video1_edx_video_id, 'test-yt-id')
|
|
scrape_youtube_thumbnail(course_id, video2_edx_video_id, 'test-yt-id2')
|
|
|
|
# Verify that now video1 image is attached.
|
|
video1_image_url = get_course_video_image_url(course_id=course_id, edx_video_id=video1_edx_video_id)
|
|
self.assertIsNotNone(video1_image_url)
|
|
|
|
# Also verify that video2's image is not updated.
|
|
video2_image_url_latest = get_course_video_image_url(course_id=course_id, edx_video_id=video2_edx_video_id)
|
|
self.assertEqual(video2_image_url, video2_image_url_latest)
|
|
|
|
@ddt.data(
|
|
(
|
|
100,
|
|
100,
|
|
False
|
|
),
|
|
(
|
|
640,
|
|
360,
|
|
True
|
|
)
|
|
)
|
|
@override_settings(AWS_ACCESS_KEY_ID='test_key_id', AWS_SECRET_ACCESS_KEY='test_secret')
|
|
@mock.patch('cms.djangoapps.contentstore.video_utils.LOGGER')
|
|
@mock.patch('requests.get')
|
|
@ddt.unpack
|
|
def test_scrape_youtube_thumbnail_logging(
|
|
self,
|
|
image_width,
|
|
image_height,
|
|
is_success,
|
|
mocked_request,
|
|
mock_logger
|
|
):
|
|
"""
|
|
Test that we get correct logs in case of failure as well as success.
|
|
"""
|
|
course_id = str(self.course.id)
|
|
video1_edx_video_id = 'test-youtube-video-1'
|
|
mocked_request.side_effect = [
|
|
self.mocked_youtube_thumbnail_response(
|
|
image_width=image_width,
|
|
image_height=image_height
|
|
)
|
|
]
|
|
scrape_youtube_thumbnail(course_id, video1_edx_video_id, 'test-yt-id')
|
|
if is_success:
|
|
mock_logger.info.assert_called_with(
|
|
'VIDEOS: Scraping youtube video thumbnail for edx_video_id [%s] in course [%s]',
|
|
video1_edx_video_id,
|
|
course_id
|
|
)
|
|
else:
|
|
mock_logger.info.assert_called_with(
|
|
'VIDEOS: Scraping youtube video thumbnail failed for edx_video_id [%s] in course [%s] with error: %s',
|
|
video1_edx_video_id,
|
|
course_id,
|
|
'This image file must be larger than 2 KB.'
|
|
)
|
|
|
|
@ddt.data(
|
|
(
|
|
None,
|
|
'image/jpeg',
|
|
'This image file must be larger than {image_min_size}.'.format(
|
|
image_min_size=settings.VIDEO_IMAGE_MIN_FILE_SIZE_KB
|
|
)
|
|
),
|
|
(
|
|
b'dummy-content',
|
|
None,
|
|
'This image file type is not supported. Supported file types are {supported_file_formats}.'.format(
|
|
supported_file_formats=list(settings.VIDEO_IMAGE_SUPPORTED_FILE_FORMATS.keys())
|
|
)
|
|
),
|
|
(
|
|
None,
|
|
None,
|
|
'This image file type is not supported. Supported file types are {supported_file_formats}.'.format(
|
|
supported_file_formats=list(settings.VIDEO_IMAGE_SUPPORTED_FILE_FORMATS.keys())
|
|
)
|
|
),
|
|
)
|
|
@mock.patch('cms.djangoapps.contentstore.video_utils.LOGGER')
|
|
@mock.patch('cms.djangoapps.contentstore.video_utils.download_youtube_video_thumbnail')
|
|
@ddt.unpack
|
|
def test_no_video_thumbnail_downloaded(
|
|
self,
|
|
image_content,
|
|
image_content_type,
|
|
error_message,
|
|
mock_download_youtube_thumbnail,
|
|
mock_logger
|
|
):
|
|
"""
|
|
Test that when no thumbnail is downloaded, video image is not updated.
|
|
"""
|
|
mock_download_youtube_thumbnail.return_value = image_content, image_content_type
|
|
course_id = str(self.course.id)
|
|
video1_edx_video_id = 'test-youtube-video-1'
|
|
|
|
# Verify that video1 has no image attached.
|
|
video1_image_url = get_course_video_image_url(course_id=course_id, edx_video_id=video1_edx_video_id)
|
|
self.assertIsNone(video1_image_url)
|
|
|
|
# Scrape video thumbnail.
|
|
scrape_youtube_thumbnail(course_id, video1_edx_video_id, 'test-yt-id')
|
|
|
|
mock_logger.info.assert_called_with(
|
|
'VIDEOS: Scraping youtube video thumbnail failed for edx_video_id [%s] in course [%s] with error: %s',
|
|
video1_edx_video_id,
|
|
course_id,
|
|
error_message
|
|
)
|
|
|
|
# Verify that no image is attached to video1.
|
|
video1_image_url = get_course_video_image_url(course_id=course_id, edx_video_id=video1_edx_video_id)
|
|
self.assertIsNone(video1_image_url)
|
|
|
|
|
|
@ddt.ddt
|
|
class S3Boto3TestCase(TestCase):
|
|
""" verify s3boto3 returns valid backend."""
|
|
|
|
def setUp(self):
|
|
self.storage = S3Boto3Storage()
|
|
self.storage._connections.connection = mock.MagicMock() # pylint: disable=protected-access
|
|
|
|
def order_dict(self, dictionary):
|
|
"""
|
|
sorting dict key:values for tests cases.
|
|
"""
|
|
sorted_key_values = sorted(dictionary.items())
|
|
dictionary.clear()
|
|
dictionary.update(sorted_key_values)
|
|
return dictionary
|
|
|
|
def test_video_backend(self):
|
|
self.assertEqual(
|
|
S3Boto3Storage,
|
|
get_storage_class(
|
|
'storages.backends.s3boto3.S3Boto3Storage',
|
|
)(**settings.VIDEO_IMAGE_SETTINGS.get('STORAGE_KWARGS', {})).__class__
|
|
)
|
|
|
|
@override_settings(VIDEO_IMAGE_SETTINGS={
|
|
'STORAGE_CLASS': 'storages.backends.s3boto3.S3Boto3Storage',
|
|
'STORAGE_KWARGS':
|
|
{'bucket_name': 'test', 'default_acl': None, 'location': 'abc/def'}}
|
|
)
|
|
def test_boto3_backend_with_params(self):
|
|
storage = get_storage_class(
|
|
settings.VIDEO_IMAGE_SETTINGS.get('STORAGE_CLASS', {})
|
|
)(**settings.VIDEO_IMAGE_SETTINGS.get('STORAGE_KWARGS', {}))
|
|
|
|
self.assertEqual(S3Boto3Storage, storage.__class__)
|
|
|
|
def test_storage_without_global_default_acl_setting(self):
|
|
"""
|
|
In 1.9.1 package provides the default-acl=`public-read`.
|
|
AWS_DEFAULT_ACL is not defined but package will send public-read.
|
|
In 1.10.1 this test will fail because that version has no default value.
|
|
"""
|
|
name = 'test_storage_save231.txt'
|
|
content = ContentFile('new content')
|
|
|
|
storage = S3Boto3Storage(**{'bucket_name': 'test'})
|
|
storage._connections.connection = mock.MagicMock() # pylint: disable=protected-access
|
|
|
|
storage.save(name, content)
|
|
storage.bucket.Object.assert_called_once_with(name)
|
|
|
|
obj = storage.bucket.Object.return_value
|
|
obj.upload_fileobj.assert_called_with(
|
|
mock.ANY,
|
|
ExtraArgs=self.order_dict({
|
|
'ContentType': 'text/plain',
|
|
}),
|
|
Config=storage.transfer_config # pylint: disable=protected-access
|
|
)
|
|
|
|
@override_settings(AWS_DEFAULT_ACL='public-read')
|
|
@ddt.data(
|
|
('public-read', 'public-read'),
|
|
('private', 'private'),
|
|
(None, None)
|
|
)
|
|
@ddt.unpack
|
|
def test_storage_without_global_default_acl_setting_and_bucket_acls(self, default_acl, output_acl):
|
|
"""
|
|
AWS_DEFAULT_ACL set to private and let bucket level acl overrides it behaviour.
|
|
"""
|
|
name = 'test_storage_save.txt'
|
|
content = ContentFile('new content')
|
|
storage = S3Boto3Storage(**{'bucket_name': 'test', 'default_acl': default_acl})
|
|
storage._connections.connection = mock.MagicMock() # pylint: disable=protected-access
|
|
|
|
storage.save(name, content)
|
|
storage.bucket.Object.assert_called_once_with(name)
|
|
|
|
obj = storage.bucket.Object.return_value
|
|
|
|
ExtraArgs = {
|
|
'ACL': output_acl,
|
|
'ContentType': 'text/plain',
|
|
}
|
|
|
|
if default_acl is None:
|
|
del ExtraArgs['ACL']
|
|
|
|
obj.upload_fileobj.assert_called_with(
|
|
mock.ANY,
|
|
ExtraArgs=self.order_dict(ExtraArgs),
|
|
Config=storage.transfer_config # pylint: disable=protected-access
|
|
)
|
|
|
|
@ddt.data('public-read', 'private')
|
|
def test_storage_passing_default_acl_as_none(self, input_acl):
|
|
"""
|
|
check bucket-level None behaviour with different AWS_DEFAULT_ACL
|
|
"""
|
|
with override_settings(AWS_DEFAULT_ACL=input_acl):
|
|
name = 'test_storage_save231.txt'
|
|
content = ContentFile('new content')
|
|
|
|
storage = S3Boto3Storage(**{'bucket_name': 'test', 'default_acl': None})
|
|
storage._connections.connection = mock.MagicMock() # pylint: disable=protected-access
|
|
|
|
storage.save(name, content)
|
|
storage.bucket.Object.assert_called_once_with(name)
|
|
|
|
obj = storage.bucket.Object.return_value
|
|
obj.upload_fileobj.assert_called_with(
|
|
mock.ANY,
|
|
Config=storage.transfer_config, # pylint: disable=protected-access
|
|
ExtraArgs={
|
|
'ContentType': 'text/plain',
|
|
},
|
|
)
|