test_model_data.py 20 KB
Newer Older
1 2 3
"""
Test for lms courseware app, module data (runtime data storage for XBlocks)
"""
4
import json
5
from mock import Mock, patch
6
from nose.plugins.attrib import attr
7 8
from functools import partial

9
from courseware.model_data import DjangoKeyValueStore, FieldDataCache, InvalidScopeError
muhammad-ammar committed
10
from courseware.models import StudentModule, XModuleUserStateSummaryField
11 12 13
from courseware.models import XModuleStudentInfoField, XModuleStudentPrefsField

from student.tests.factories import UserFactory
14
from courseware.tests.factories import StudentModuleFactory as cmfStudentModuleFactory, location, course_id
Calen Pennington committed
15
from courseware.tests.factories import UserStateSummaryFactory
16 17
from courseware.tests.factories import StudentPrefsFactory, StudentInfoFactory

18
from xblock.fields import Scope, BlockScope, ScopeIds
19 20
from xblock.exceptions import KeyValueMultiSaveError
from xblock.core import XBlock
21
from django.test import TestCase
22
from django.db import DatabaseError
23 24


25 26 27 28 29 30
def mock_field(scope, name):
    field = Mock()
    field.scope = scope
    field.name = name
    return field

31

Calen Pennington committed
32
def mock_descriptor(fields=[]):
33
    descriptor = Mock(entry_point=XBlock.entry_point)
34
    descriptor.scope_ids = ScopeIds('user1', 'mock_problem', location('def_id'), location('usage_id'))
Calen Pennington committed
35 36
    descriptor.module_class.fields.values.return_value = fields
    descriptor.fields.values.return_value = fields
37
    descriptor.module_class.__name__ = 'MockProblemModule'
38 39
    return descriptor

40 41 42
# The user ids here are 1 because we make a student in the setUp functions, and
# they get an id of 1.  There's an assertion in setUp to ensure that assumption
# is still true.
43 44 45 46
user_state_summary_key = partial(DjangoKeyValueStore.Key, Scope.user_state_summary, None, location('usage_id'))
settings_key = partial(DjangoKeyValueStore.Key, Scope.settings, None, location('usage_id'))
user_state_key = partial(DjangoKeyValueStore.Key, Scope.user_state, 1, location('usage_id'))
prefs_key = partial(DjangoKeyValueStore.Key, Scope.preferences, 1, 'mock_problem')
47
user_info_key = partial(DjangoKeyValueStore.Key, Scope.user_info, 1, None)
48 49


50
class StudentModuleFactory(cmfStudentModuleFactory):
51
    module_state_key = location('usage_id')
52 53 54
    course_id = course_id


55
@attr(shard=1)
56 57
class TestInvalidScopes(TestCase):
    def setUp(self):
58
        super(TestInvalidScopes, self).setUp()
59
        self.user = UserFactory.create(username='user')
Calen Pennington committed
60 61
        self.field_data_cache = FieldDataCache([mock_descriptor([mock_field(Scope.user_state, 'a_field')])], course_id, self.user)
        self.kvs = DjangoKeyValueStore(self.field_data_cache)
62 63

    def test_invalid_scopes(self):
64 65 66
        for scope in (Scope(user=True, block=BlockScope.DEFINITION),
                      Scope(user=False, block=BlockScope.TYPE),
                      Scope(user=False, block=BlockScope.ALL)):
Calen Pennington committed
67
            key = DjangoKeyValueStore.Key(scope, None, None, 'field')
68 69 70 71 72 73

            self.assertRaises(InvalidScopeError, self.kvs.get, key)
            self.assertRaises(InvalidScopeError, self.kvs.set, key, 'value')
            self.assertRaises(InvalidScopeError, self.kvs.delete, key)
            self.assertRaises(InvalidScopeError, self.kvs.has, key)
            self.assertRaises(InvalidScopeError, self.kvs.set_many, {key: 'value'})
74 75


76
@attr(shard=1)
77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100
class OtherUserFailureTestMixin(object):
    """
    Mixin class to add test cases for failures when a user trying to use the kvs is not
    the one that instantiated the kvs.
    Doing a mixin rather than modifying StorageTestBase (below) because some scopes don't fail in this case, because
    they aren't bound to a particular user

    assumes that this is mixed into a class that defines other_key_factory and existing_field_name
    """
    def test_other_user_kvs_get_failure(self):
        """
        Test for assert failure when a user who didn't create the kvs tries to get from it it
        """
        with self.assertRaises(AssertionError):
            self.kvs.get(self.other_key_factory(self.existing_field_name))

    def test_other_user_kvs_set_failure(self):
        """
        Test for assert failure when a user who didn't create the kvs tries to get from it it
        """
        with self.assertRaises(AssertionError):
            self.kvs.set(self.other_key_factory(self.existing_field_name), "new_value")


101
@attr(shard=1)
102 103 104 105
class TestStudentModuleStorage(OtherUserFailureTestMixin, TestCase):
    """Tests for user_state storage via StudentModule"""
    other_key_factory = partial(DjangoKeyValueStore.Key, Scope.user_state, 2, location('usage_id'))  # user_id=2, not 1
    existing_field_name = "a_field"
106 107
    # Tell Django to clean out all databases, not just default
    multi_db = True
108 109

    def setUp(self):
110
        super(TestStudentModuleStorage, self).setUp()
111
        student_module = StudentModuleFactory(state=json.dumps({'a_field': 'a_value', 'b_field': 'b_value'}))
112
        self.user = student_module.student
113
        self.assertEqual(self.user.id, 1)   # check our assumption hard-coded in the key functions above.
114 115 116

        # There should be only one query to load a single descriptor with a single user_state field
        with self.assertNumQueries(1):
Adam Palay committed
117 118 119
            self.field_data_cache = FieldDataCache(
                [mock_descriptor([mock_field(Scope.user_state, 'a_field')])], course_id, self.user
            )
120

Calen Pennington committed
121
        self.kvs = DjangoKeyValueStore(self.field_data_cache)
122 123 124

    def test_get_existing_field(self):
        "Test that getting an existing field in an existing StudentModule works"
125 126 127
        # This should only read from the cache, not the database
        with self.assertNumQueries(0):
            self.assertEquals('a_value', self.kvs.get(user_state_key('a_field')))
128 129 130

    def test_get_missing_field(self):
        "Test that getting a missing field from an existing StudentModule raises a KeyError"
131 132 133
        # This should only read from the cache, not the database
        with self.assertNumQueries(0):
            self.assertRaises(KeyError, self.kvs.get, user_state_key('not_a_field'))
134 135

    def test_set_existing_field(self):
136
        "Test that setting an existing user_state field changes the value"
137
        # We are updating a problem, so we write to courseware_studentmodulehistory
138 139 140 141
        # as well as courseware_studentmodule. We also need to read the database
        # to discover if something other than the DjangoXBlockUserStateClient
        # has written to the StudentModule (such as UserStateCache setting the score
        # on the StudentModule).
142
        with self.assertNumQueries(4, using='default'):
143 144
            with self.assertNumQueries(1, using='student_module_history'):
                self.kvs.set(user_state_key('a_field'), 'new_value')
145
        self.assertEquals(1, StudentModule.objects.all().count())
146
        self.assertEquals({'b_field': 'b_value', 'a_field': 'new_value'}, json.loads(StudentModule.objects.all()[0].state))
147 148

    def test_set_missing_field(self):
149
        "Test that setting a new user_state field changes the value"
150
        # We are updating a problem, so we write to courseware_studentmodulehistory
151 152 153 154
        # as well as courseware_studentmodule. We also need to read the database
        # to discover if something other than the DjangoXBlockUserStateClient
        # has written to the StudentModule (such as UserStateCache setting the score
        # on the StudentModule).
155
        with self.assertNumQueries(4, using='default'):
156 157
            with self.assertNumQueries(1, using='student_module_history'):
                self.kvs.set(user_state_key('not_a_field'), 'new_value')
158
        self.assertEquals(1, StudentModule.objects.all().count())
159
        self.assertEquals({'b_field': 'b_value', 'a_field': 'a_value', 'not_a_field': 'new_value'}, json.loads(StudentModule.objects.all()[0].state))
160 161 162

    def test_delete_existing_field(self):
        "Test that deleting an existing field removes it from the StudentModule"
163
        # We are updating a problem, so we write to courseware_studentmodulehistory
164 165 166 167
        # as well as courseware_studentmodule. We also need to read the database
        # to discover if something other than the DjangoXBlockUserStateClient
        # has written to the StudentModule (such as UserStateCache setting the score
        # on the StudentModule).
168 169 170
        with self.assertNumQueries(2, using='default'):
            with self.assertNumQueries(1, using='student_module_history'):
                self.kvs.delete(user_state_key('a_field'))
171
        self.assertEquals(1, StudentModule.objects.all().count())
172
        self.assertRaises(KeyError, self.kvs.get, user_state_key('not_a_field'))
173 174 175

    def test_delete_missing_field(self):
        "Test that deleting a missing field from an existing StudentModule raises a KeyError"
176 177
        with self.assertNumQueries(0):
            self.assertRaises(KeyError, self.kvs.delete, user_state_key('not_a_field'))
178
        self.assertEquals(1, StudentModule.objects.all().count())
179
        self.assertEquals({'b_field': 'b_value', 'a_field': 'a_value'}, json.loads(StudentModule.objects.all()[0].state))
180

181 182
    def test_has_existing_field(self):
        "Test that `has` returns True for existing fields in StudentModules"
183 184
        with self.assertNumQueries(0):
            self.assertTrue(self.kvs.has(user_state_key('a_field')))
185 186 187

    def test_has_missing_field(self):
        "Test that `has` returns False for missing fields in StudentModule"
188 189
        with self.assertNumQueries(0):
            self.assertFalse(self.kvs.has(user_state_key('not_a_field')))
190

191
    def construct_kv_dict(self):
192
        """Construct a kv_dict that can be passed to set_many"""
193 194 195 196 197 198 199
        key1 = user_state_key('field_a')
        key2 = user_state_key('field_b')
        new_value = 'new value'
        newer_value = 'newer value'
        return {key1: new_value, key2: newer_value}

    def test_set_many(self):
200
        "Test setting many fields that are scoped to Scope.user_state"
201
        kv_dict = self.construct_kv_dict()
202 203 204 205

        # Scope.user_state is stored in a single row in the database, so we only
        # need to send a single update to that table.
        # We also are updating a problem, so we write to courseware student module history
206 207 208
        # We also need to read the database to discover if something other than the
        # DjangoXBlockUserStateClient has written to the StudentModule (such as
        # UserStateCache setting the score on the StudentModule).
209
        with self.assertNumQueries(4, using="default"):
210 211
            with self.assertNumQueries(1, using="student_module_history"):
                self.kvs.set_many(kv_dict)
212 213 214 215 216

        for key in kv_dict:
            self.assertEquals(self.kvs.get(key), kv_dict[key])

    def test_set_many_failure(self):
217
        "Test failures when setting many fields that are scoped to Scope.user_state"
218 219 220 221 222 223 224 225 226
        kv_dict = self.construct_kv_dict()
        # because we're patching the underlying save, we need to ensure the
        # fields are in the cache
        for key in kv_dict:
            self.kvs.set(key, 'test_value')

        with patch('django.db.models.Model.save', side_effect=DatabaseError):
            with self.assertRaises(KeyValueMultiSaveError) as exception_context:
                self.kvs.set_many(kv_dict)
227
        self.assertEquals(exception_context.exception.saved_field_names, [])
228

229

230
@attr(shard=1)
231
class TestMissingStudentModule(TestCase):
232 233 234
    # Tell Django to clean out all databases, not just default
    multi_db = True

235
    def setUp(self):
236 237
        super(TestMissingStudentModule, self).setUp()

238
        self.user = UserFactory.create(username='user')
239
        self.assertEqual(self.user.id, 1)   # check our assumption hard-coded in the key functions above.
240 241 242 243

        # The descriptor has no fields, so FDC shouldn't send any queries
        with self.assertNumQueries(0):
            self.field_data_cache = FieldDataCache([mock_descriptor()], course_id, self.user)
Calen Pennington committed
244
        self.kvs = DjangoKeyValueStore(self.field_data_cache)
245 246 247

    def test_get_field_from_missing_student_module(self):
        "Test that getting a field from a missing StudentModule raises a KeyError"
248 249
        with self.assertNumQueries(0):
            self.assertRaises(KeyError, self.kvs.get, user_state_key('a_field'))
250 251 252

    def test_set_field_in_missing_student_module(self):
        "Test that setting a field in a missing StudentModule creates the student module"
253
        self.assertEquals(0, len(self.field_data_cache))
254 255
        self.assertEquals(0, StudentModule.objects.all().count())

256
        # We are updating a problem, so we write to courseware_studentmodulehistoryextended
257 258 259 260
        # as well as courseware_studentmodule. We also need to read the database
        # to discover if something other than the DjangoXBlockUserStateClient
        # has written to the StudentModule (such as UserStateCache setting the score
        # on the StudentModule).
261 262
        # Django 1.8 also has a number of other BEGIN and SAVESTATE queries.
        with self.assertNumQueries(4, using='default'):
263 264
            with self.assertNumQueries(1, using='student_module_history'):
                self.kvs.set(user_state_key('a_field'), 'a_value')
265

266
        self.assertEquals(1, sum(len(cache) for cache in self.field_data_cache.cache.values()))
267 268 269 270 271
        self.assertEquals(1, StudentModule.objects.all().count())

        student_module = StudentModule.objects.all()[0]
        self.assertEquals({'a_field': 'a_value'}, json.loads(student_module.state))
        self.assertEquals(self.user, student_module.student)
272
        self.assertEquals(location('usage_id').replace(run=None), student_module.module_state_key)
273 274 275 276
        self.assertEquals(course_id, student_module.course_id)

    def test_delete_field_from_missing_student_module(self):
        "Test that deleting a field from a missing StudentModule raises a KeyError"
277 278
        with self.assertNumQueries(0):
            self.assertRaises(KeyError, self.kvs.delete, user_state_key('a_field'))
279

280 281
    def test_has_field_for_missing_student_module(self):
        "Test that `has` returns False for missing StudentModules"
282 283
        with self.assertNumQueries(0):
            self.assertFalse(self.kvs.has(user_state_key('a_field')))
284 285


286
@attr(shard=1)
287
class StorageTestBase(object):
288 289 290 291
    """
    A base class for that gets subclassed when testing each of the scopes.

    """
292 293
    # Disable pylint warnings that arise because of the way the child classes call
    # this base class -- pylint's static analysis can't keep up with it.
294
    # pylint: disable=no-member, not-callable
295

296 297 298 299
    factory = None
    scope = None
    key_factory = None
    storage_class = None
300 301

    def setUp(self):
302 303 304 305 306
        field_storage = self.factory.create()
        if hasattr(field_storage, 'student'):
            self.user = field_storage.student
        else:
            self.user = UserFactory.create()
307 308 309
        self.mock_descriptor = mock_descriptor([
            mock_field(self.scope, 'existing_field'),
            mock_field(self.scope, 'other_existing_field')])
310 311 312 313
        # Each field is stored as a separate row in the table,
        # but we can query them in a single query
        with self.assertNumQueries(1):
            self.field_data_cache = FieldDataCache([self.mock_descriptor], course_id, self.user)
Calen Pennington committed
314 315
        self.kvs = DjangoKeyValueStore(self.field_data_cache)

316
    def test_set_and_get_existing_field(self):
317
        with self.assertNumQueries(1):
318 319 320
            self.kvs.set(self.key_factory('existing_field'), 'test_value')
        with self.assertNumQueries(0):
            self.assertEquals('test_value', self.kvs.get(self.key_factory('existing_field')))
321

322
    def test_get_existing_field(self):
323
        "Test that getting an existing field in an existing Storage Field works"
324 325
        with self.assertNumQueries(0):
            self.assertEquals('old_value', self.kvs.get(self.key_factory('existing_field')))
326 327

    def test_get_missing_field(self):
328
        "Test that getting a missing field from an existing Storage Field raises a KeyError"
329 330
        with self.assertNumQueries(0):
            self.assertRaises(KeyError, self.kvs.get, self.key_factory('missing_field'))
331 332 333

    def test_set_existing_field(self):
        "Test that setting an existing field changes the value"
334
        with self.assertNumQueries(1):
335
            self.kvs.set(self.key_factory('existing_field'), 'new_value')
336 337
        self.assertEquals(1, self.storage_class.objects.all().count())
        self.assertEquals('new_value', json.loads(self.storage_class.objects.all()[0].value))
338 339 340

    def test_set_missing_field(self):
        "Test that setting a new field changes the value"
341
        with self.assertNumQueries(1):
342
            self.kvs.set(self.key_factory('missing_field'), 'new_value')
343 344 345
        self.assertEquals(2, self.storage_class.objects.all().count())
        self.assertEquals('old_value', json.loads(self.storage_class.objects.get(field_name='existing_field').value))
        self.assertEquals('new_value', json.loads(self.storage_class.objects.get(field_name='missing_field').value))
346 347 348

    def test_delete_existing_field(self):
        "Test that deleting an existing field removes it"
349 350
        with self.assertNumQueries(1):
            self.kvs.delete(self.key_factory('existing_field'))
351
        self.assertEquals(0, self.storage_class.objects.all().count())
352 353

    def test_delete_missing_field(self):
354
        "Test that deleting a missing field from an existing Storage Field raises a KeyError"
355 356
        with self.assertNumQueries(0):
            self.assertRaises(KeyError, self.kvs.delete, self.key_factory('missing_field'))
357
        self.assertEquals(1, self.storage_class.objects.all().count())
358

359 360
    def test_has_existing_field(self):
        "Test that `has` returns True for an existing Storage Field"
361 362
        with self.assertNumQueries(0):
            self.assertTrue(self.kvs.has(self.key_factory('existing_field')))
363

364 365
    def test_has_missing_field(self):
        "Test that `has` return False for an existing Storage Field"
366 367
        with self.assertNumQueries(0):
            self.assertFalse(self.kvs.has(self.key_factory('missing_field')))
368

369
    def construct_kv_dict(self):
370
        """Construct a kv_dict that can be passed to set_many"""
371 372 373 374 375 376 377 378 379 380
        key1 = self.key_factory('existing_field')
        key2 = self.key_factory('other_existing_field')
        new_value = 'new value'
        newer_value = 'newer value'
        return {key1: new_value, key2: newer_value}

    def test_set_many(self):
        """Test that setting many regular fields at the same time works"""
        kv_dict = self.construct_kv_dict()

381 382
        # Each field is a separate row in the database, hence
        # a separate query
383
        with self.assertNumQueries(len(kv_dict)):
384
            self.kvs.set_many(kv_dict)
385 386 387 388 389 390
        for key in kv_dict:
            self.assertEquals(self.kvs.get(key), kv_dict[key])

    def test_set_many_failure(self):
        """Test that setting many regular fields with a DB error """
        kv_dict = self.construct_kv_dict()
391 392
        for key in kv_dict:
            with self.assertNumQueries(1):
393
                self.kvs.set(key, 'test value')
394 395 396 397 398 399

        with patch('django.db.models.Model.save', side_effect=[None, DatabaseError]):
            with self.assertRaises(KeyValueMultiSaveError) as exception_context:
                self.kvs.set_many(kv_dict)

        exception = exception_context.exception
400
        self.assertEquals(exception.saved_field_names, ['existing_field', 'other_existing_field'])
401 402


403 404
class TestUserStateSummaryStorage(StorageTestBase, TestCase):
    """Tests for UserStateSummaryStorage"""
Calen Pennington committed
405 406 407
    factory = UserStateSummaryFactory
    scope = Scope.user_state_summary
    key_factory = user_state_summary_key
muhammad-ammar committed
408
    storage_class = XModuleUserStateSummaryField
409 410


411 412
class TestStudentPrefsStorage(OtherUserFailureTestMixin, StorageTestBase, TestCase):
    """Tests for StudentPrefStorage"""
413
    factory = StudentPrefsFactory
414 415
    scope = Scope.preferences
    key_factory = prefs_key
416
    storage_class = XModuleStudentPrefsField
417 418
    other_key_factory = partial(DjangoKeyValueStore.Key, Scope.preferences, 2, 'mock_problem')  # user_id=2, not 1
    existing_field_name = "existing_field"
419 420


421 422
class TestStudentInfoStorage(OtherUserFailureTestMixin, StorageTestBase, TestCase):
    """Tests for StudentInfoStorage"""
423
    factory = StudentInfoFactory
424 425
    scope = Scope.user_info
    key_factory = user_info_key
426
    storage_class = XModuleStudentInfoField
427 428
    other_key_factory = partial(DjangoKeyValueStore.Key, Scope.user_info, 2, 'mock_problem')  # user_id=2, not 1
    existing_field_name = "existing_field"