Files
edx-platform/common/djangoapps/track/views/tests/test_segmentio.py

496 lines
21 KiB
Python

"""Ensure we can parse events sent to us from the Segment webhook integration"""
import json
from dateutil import parser
from ddt import data, ddt, unpack
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.test.utils import override_settings
from mock import sentinel
from openedx.core.lib.tests.assertions.events import assert_event_matches
from common.djangoapps.track.middleware import TrackMiddleware
from common.djangoapps.track.views import segmentio
from common.djangoapps.track.views.tests.base import SEGMENTIO_TEST_ENDPOINT, SEGMENTIO_TEST_USER_ID, SegmentIOTrackingTestCaseBase # lint-amnesty, pylint: disable=line-too-long
def expect_failure_with_message(message):
"""Ensure the test raises an exception and does not emit an event"""
def test_decorator(func):
def test_decorated(self, *args, **kwargs):
self.assertRaisesRegex(segmentio.EventValidationError, message, func, self, *args, **kwargs)
self.assert_no_events_emitted()
return test_decorated
return test_decorator
@ddt
class SegmentIOTrackingTestCase(SegmentIOTrackingTestCaseBase):
"""
Test processing of Segment events.
"""
def setUp(self):
super(SegmentIOTrackingTestCase, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments
User.objects.create(pk=SEGMENTIO_TEST_USER_ID, username=str(sentinel.username))
def test_get_request(self):
request = self.request_factory.get(SEGMENTIO_TEST_ENDPOINT)
response = segmentio.segmentio_event(request)
assert response.status_code == 405
self.assert_no_events_emitted()
@override_settings(
TRACKING_SEGMENTIO_WEBHOOK_SECRET=None
)
def test_no_secret_config(self):
request = self.request_factory.post(SEGMENTIO_TEST_ENDPOINT)
response = segmentio.segmentio_event(request)
assert response.status_code == 401
self.assert_no_events_emitted()
def test_no_secret_provided(self):
request = self.request_factory.post(SEGMENTIO_TEST_ENDPOINT)
response = segmentio.segmentio_event(request)
assert response.status_code == 401
self.assert_no_events_emitted()
def test_secret_mismatch(self):
request = self.create_request(key='y')
response = segmentio.segmentio_event(request)
assert response.status_code == 401
self.assert_no_events_emitted()
@data('identify', 'Group', 'Alias', 'Page', 'identify', 'screen')
def test_segmentio_ignore_actions(self, action):
self.post_segmentio_event(action=action)
self.assert_no_events_emitted()
def test_segmentio_ignore_missing_context_entry(self):
sample_event_raw = self.create_segmentio_event()
del sample_event_raw['context']
self.post_modified_segmentio_event(sample_event_raw)
self.assert_no_events_emitted()
def test_segmentio_ignore_null_context_entry(self):
sample_event_raw = self.create_segmentio_event()
sample_event_raw['context'] = None
self.post_modified_segmentio_event(sample_event_raw)
self.assert_no_events_emitted()
def test_segmentio_ignore_missing_library_entry(self):
sample_event_raw = self.create_segmentio_event()
del sample_event_raw['context']['library']
self.post_modified_segmentio_event(sample_event_raw)
self.assert_no_events_emitted()
def test_segmentio_ignore_null_library_entry(self):
sample_event_raw = self.create_segmentio_event()
sample_event_raw['context']['library'] = None
self.post_modified_segmentio_event(sample_event_raw)
self.assert_no_events_emitted()
def test_segmentio_ignore_unknown_libraries(self):
self.post_segmentio_event(library_name='foo')
self.assert_no_events_emitted()
@expect_failure_with_message(segmentio.ERROR_MISSING_NAME)
def test_segmentio_ignore_missing_properties_entry(self):
sample_event_raw = self.create_segmentio_event()
del sample_event_raw['properties']
self.post_modified_segmentio_event(sample_event_raw)
@expect_failure_with_message(segmentio.ERROR_MISSING_NAME)
def test_segmentio_ignore_null_properties_entry(self):
sample_event_raw = self.create_segmentio_event()
sample_event_raw['properties'] = None
self.post_modified_segmentio_event(sample_event_raw)
@expect_failure_with_message(segmentio.ERROR_USER_NOT_EXIST)
def test_no_user_for_user_id(self):
self.post_segmentio_event(user_id=40)
@expect_failure_with_message(segmentio.ERROR_INVALID_USER_ID)
def test_invalid_user_id(self):
self.post_segmentio_event(user_id='foobar')
@data('foo/bar/baz', 'course-v1:foo+bar+baz')
def test_success(self, course_id):
middleware = TrackMiddleware()
request = self.create_request(
data=self.create_segmentio_event_json(data={'foo': 'bar'}, course_id=course_id),
content_type='application/json'
)
middleware.process_request(request)
# The middleware normally emits an event, make sure it doesn't in this case.
self.assert_no_events_emitted()
try:
response = segmentio.segmentio_event(request)
assert response.status_code == 200
expected_event = {
'accept_language': '',
'referer': '',
'username': str(sentinel.username),
'ip': '',
'session': '',
'event_source': 'mobile',
'event_type': str(sentinel.name),
'name': str(sentinel.name),
'event': {'foo': 'bar'},
'agent': str(sentinel.user_agent),
'page': None,
'time': parser.parse("2014-08-27T16:33:39.215Z"),
'host': 'testserver',
'context': {
'application': {
'name': 'edx.mobile.android',
'version': '1.0.1',
},
'user_id': SEGMENTIO_TEST_USER_ID,
'course_id': course_id,
'org_id': u'foo',
'path': SEGMENTIO_TEST_ENDPOINT,
'client': {
'library': {
'name': 'test-app',
'version': 'unknown'
},
'app': {
'version': '1.0.1',
},
},
'received_at': parser.parse("2014-08-27T16:33:39.100Z"),
},
}
finally:
middleware.process_response(request, None)
assert_event_matches(expected_event, self.get_event())
def test_invalid_course_id(self):
request = self.create_request(
data=self.create_segmentio_event_json(course_id='invalid'),
content_type='application/json'
)
segmentio.track_segmentio_event(request)
self.assert_events_emitted()
@data(
None,
'a string',
['a', 'list'],
)
@expect_failure_with_message(segmentio.ERROR_INVALID_CONTEXT_FIELD_TYPE)
def test_invalid_context_field_type(self, invalid_value):
sample_event_raw = self.create_segmentio_event()
sample_event_raw['properties']['context'] = invalid_value
self.post_modified_segmentio_event(sample_event_raw)
@data(
None,
'a string',
['a', 'list'],
)
@expect_failure_with_message(segmentio.ERROR_INVALID_DATA_FIELD_TYPE)
def test_invalid_data_field_type(self, invalid_value):
sample_event_raw = self.create_segmentio_event()
sample_event_raw['properties']['data'] = invalid_value
self.post_modified_segmentio_event(sample_event_raw)
@expect_failure_with_message(segmentio.ERROR_MISSING_NAME)
def test_missing_name(self):
sample_event_raw = self.create_segmentio_event()
del sample_event_raw['properties']['name']
self.post_modified_segmentio_event(sample_event_raw)
@expect_failure_with_message(segmentio.ERROR_MISSING_DATA)
def test_missing_data(self):
sample_event_raw = self.create_segmentio_event()
del sample_event_raw['properties']['data']
self.post_modified_segmentio_event(sample_event_raw)
@expect_failure_with_message(segmentio.ERROR_MISSING_TIMESTAMP)
def test_missing_timestamp(self):
sample_event_raw = self.create_event_without_fields('timestamp')
self.post_modified_segmentio_event(sample_event_raw)
@expect_failure_with_message(segmentio.ERROR_MISSING_RECEIVED_AT)
def test_missing_received_at(self):
sample_event_raw = self.create_event_without_fields('receivedAt')
self.post_modified_segmentio_event(sample_event_raw)
def create_event_without_fields(self, *fields):
"""Create a fake event and remove some fields from it"""
event = self.create_segmentio_event()
for field in fields:
if field in event:
del event[field]
return event
def test_string_user_id(self):
self.post_segmentio_event(user_id=str(SEGMENTIO_TEST_USER_ID))
self.assert_events_emitted()
@data(
'2018-12-11T07:27:28.015900357Z',
'2014-08-27T16:33:39.100Z',
'2014-08-27T16:33:39.215Z'
)
def test_timestamp_success(self, timestamp):
sample_event_raw = self.create_segmentio_event()
sample_event_raw['receivedAt'] = timestamp
sample_event_raw['timestamp'] = timestamp
request = self.create_request(
data=json.dumps(sample_event_raw),
content_type='application/json'
)
response = segmentio.segmentio_event(request)
assert response.status_code == 200
self.assert_events_emitted()
def test_hiding_failure(self):
sample_event_raw = self.create_event_without_fields('timestamp')
request = self.create_request(
data=json.dumps(sample_event_raw),
content_type='application/json'
)
response = segmentio.segmentio_event(request)
assert response.status_code == 200
self.assert_no_events_emitted()
@data(
('edx.video.played', 'play_video'),
('edx.video.paused', 'pause_video'),
('edx.video.stopped', 'stop_video'),
('edx.video.loaded', 'load_video'),
('edx.video.position.changed', 'seek_video'),
('edx.video.transcript.shown', 'show_transcript'),
('edx.video.transcript.hidden', 'hide_transcript'),
)
@unpack
def test_video_event(self, name, event_type):
course_id = 'foo/bar/baz'
middleware = TrackMiddleware()
input_payload = {
'current_time': 132.134456,
'module_id': 'i4x://foo/bar/baz/some_module',
'code': 'mobile'
}
if name == 'edx.video.loaded':
# We use the same expected payload for all of these types of events, but the load video event is the only
# one that is not actually expected to contain a "current time" field. So we remove it from the expected
# event here.
del input_payload['current_time']
request = self.create_request(
data=self.create_segmentio_event_json(
name=name,
data=input_payload,
context={
'open_in_browser_url': 'https://testserver/courses/foo/bar/baz/courseware/Week_1/Activity/2',
'course_id': course_id,
'application': {
'name': 'edx.mobileapp.android',
'version': '29',
'component': 'videoplayer'
}
}),
content_type='application/json'
)
middleware.process_request(request)
try:
response = segmentio.segmentio_event(request)
assert response.status_code == 200
expected_event = {
'accept_language': '',
'referer': '',
'username': str(sentinel.username),
'ip': '',
'session': '',
'event_source': 'mobile',
'event_type': event_type,
'name': name,
'agent': str(sentinel.user_agent),
'page': 'https://testserver/courses/foo/bar/baz/courseware/Week_1/Activity',
'time': parser.parse("2014-08-27T16:33:39.215Z"),
'host': 'testserver',
'context': {
'user_id': SEGMENTIO_TEST_USER_ID,
'course_id': course_id,
'org_id': 'foo',
'path': SEGMENTIO_TEST_ENDPOINT,
'client': {
'library': {
'name': 'test-app',
'version': 'unknown'
},
'app': {
'version': '1.0.1',
},
},
'application': {
'name': 'edx.mobileapp.android',
'version': '29',
'component': 'videoplayer'
},
'received_at': parser.parse("2014-08-27T16:33:39.100Z"),
},
'event': {
'currentTime': 132.134456,
'id': 'i4x-foo-bar-baz-some_module',
'code': 'mobile'
}
}
if name == 'edx.video.loaded':
# We use the same expected payload for all of these types of events, but the load video event is the
# only one that is not actually expected to contain a "current time" field. So we remove it from the
# expected event here.
del expected_event['event']['currentTime']
finally:
middleware.process_response(request, None)
actual_event = self.get_event()
assert_event_matches(expected_event, actual_event)
@data(
# Verify positive slide case. Verify slide to onSlideSeek. Verify
# edx.video.seeked emitted from iOS v1.0.02 is changed to
# edx.video.position.changed.
(1, 1, "seek_type", "slide", "onSlideSeek", "edx.video.seeked", "edx.video.position.changed", 'edx.mobileapp.iOS', '1.0.02'), # lint-amnesty, pylint: disable=line-too-long
# Verify negative slide case. Verify slide to onSlideSeek. Verify
# edx.video.seeked to edx.video.position.changed.
(-2, -2, "seek_type", "slide", "onSlideSeek", "edx.video.seeked", "edx.video.position.changed", 'edx.mobileapp.iOS', '1.0.02'), # lint-amnesty, pylint: disable=line-too-long
# Verify +30 is changed to -30 which is incorrectly emitted in iOS
# v1.0.02. Verify skip to onSkipSeek
(30, -30, "seek_type", "skip", "onSkipSeek", "edx.video.position.changed", "edx.video.position.changed", 'edx.mobileapp.iOS', '1.0.02'), # lint-amnesty, pylint: disable=line-too-long
# Verify the correct case of -30 is also handled as well. Verify skip
# to onSkipSeek
(-30, -30, "seek_type", "skip", "onSkipSeek", "edx.video.position.changed", "edx.video.position.changed", 'edx.mobileapp.iOS', '1.0.02'), # lint-amnesty, pylint: disable=line-too-long
# Verify positive slide case where onSkipSeek is changed to
# onSlideSkip. Verify edx.video.seeked emitted from Android v1.0.02 is
# changed to edx.video.position.changed.
(1, 1, "type", "onSkipSeek", "onSlideSeek", "edx.video.seeked", "edx.video.position.changed", 'edx.mobileapp.android', '1.0.02'), # lint-amnesty, pylint: disable=line-too-long
# Verify positive slide case where onSkipSeek is changed to
# onSlideSkip. Verify edx.video.seeked emitted from Android v1.0.02 is
# changed to edx.video.position.changed.
(-2, -2, "type", "onSkipSeek", "onSlideSeek", "edx.video.seeked", "edx.video.position.changed", 'edx.mobileapp.android', '1.0.02'), # lint-amnesty, pylint: disable=line-too-long
# Verify positive skip case where onSkipSeek is not changed and does
# not become negative.
(30, 30, "type", "onSkipSeek", "onSkipSeek", "edx.video.position.changed", "edx.video.position.changed", 'edx.mobileapp.android', '1.0.02'), # lint-amnesty, pylint: disable=line-too-long
# Verify positive skip case where onSkipSeek is not changed.
(-30, -30, "type", "onSkipSeek", "onSkipSeek", "edx.video.position.changed", "edx.video.position.changed", 'edx.mobileapp.android', '1.0.02') # lint-amnesty, pylint: disable=line-too-long
)
@unpack
def test_previous_builds(self,
requested_skip_interval,
expected_skip_interval,
seek_type_key,
seek_type,
expected_seek_type,
name,
expected_name,
platform,
version,
):
"""
Test backwards compatibility of previous app builds
iOS version 1.0.02: Incorrectly emits the skip back 30 seconds as +30
instead of -30.
Android version 1.0.02: Skip and slide were both being returned as a
skip. Skip or slide is determined by checking if the skip time is == -30
Additionally, for both of the above mentioned versions, edx.video.seeked
was sent instead of edx.video.position.changed
"""
course_id = 'foo/bar/baz'
middleware = TrackMiddleware()
input_payload = {
"code": "mobile",
"new_time": 89.699177437,
"old_time": 119.699177437,
seek_type_key: seek_type,
"requested_skip_interval": requested_skip_interval,
'module_id': 'i4x://foo/bar/baz/some_module',
}
request = self.create_request(
data=self.create_segmentio_event_json(
name=name,
data=input_payload,
context={
'open_in_browser_url': 'https://testserver/courses/foo/bar/baz/courseware/Week_1/Activity/2',
'course_id': course_id,
'application': {
'name': platform,
'version': version,
'component': 'videoplayer'
}
},
),
content_type='application/json'
)
middleware.process_request(request)
try:
response = segmentio.segmentio_event(request)
assert response.status_code == 200
expected_event = {
'accept_language': '',
'referer': '',
'username': str(sentinel.username),
'ip': '',
'session': '',
'event_source': 'mobile',
'event_type': "seek_video",
'name': expected_name,
'agent': str(sentinel.user_agent),
'page': 'https://testserver/courses/foo/bar/baz/courseware/Week_1/Activity',
'time': parser.parse("2014-08-27T16:33:39.215Z"),
'host': 'testserver',
'context': {
'user_id': SEGMENTIO_TEST_USER_ID,
'course_id': course_id,
'org_id': 'foo',
'path': SEGMENTIO_TEST_ENDPOINT,
'client': {
'library': {
'name': 'test-app',
'version': 'unknown'
},
'app': {
'version': '1.0.1',
},
},
'application': {
'name': platform,
'version': version,
'component': 'videoplayer'
},
'received_at': parser.parse("2014-08-27T16:33:39.100Z"),
},
'event': {
"code": "mobile",
"new_time": 89.699177437,
"old_time": 119.699177437,
"type": expected_seek_type,
"requested_skip_interval": expected_skip_interval,
'id': 'i4x-foo-bar-baz-some_module',
}
}
finally:
middleware.process_response(request, None)
actual_event = self.get_event()
assert_event_matches(expected_event, actual_event)