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 6 7 8
from functools import partial

from django.db import DatabaseError
from django.test import TestCase
9
from mock import Mock, patch
10
from nose.plugins.attrib import attr
11 12 13
from xblock.core import XBlock
from xblock.exceptions import KeyValueMultiSaveError
from xblock.fields import BlockScope, Scope, ScopeIds
14

15
from courseware.model_data import DjangoKeyValueStore, FieldDataCache, InvalidScopeError
16 17 18 19 20 21 22 23 24 25 26 27 28 29
from courseware.models import (
    StudentModule,
    XModuleStudentInfoField,
    XModuleStudentPrefsField,
    XModuleUserStateSummaryField
)
from courseware.tests.factories import StudentModuleFactory as cmfStudentModuleFactory
from courseware.tests.factories import (
    StudentInfoFactory,
    StudentPrefsFactory,
    UserStateSummaryFactory,
    course_id,
    location
)
30
from student.tests.factories import UserFactory
31 32


33 34 35 36 37 38
def mock_field(scope, name):
    field = Mock()
    field.scope = scope
    field.name = name
    return field

39

Calen Pennington committed
40
def mock_descriptor(fields=[]):
41
    descriptor = Mock(entry_point=XBlock.entry_point)
42
    descriptor.scope_ids = ScopeIds('user1', 'mock_problem', location('def_id'), location('usage_id'))
Calen Pennington committed
43 44
    descriptor.module_class.fields.values.return_value = fields
    descriptor.fields.values.return_value = fields
45
    descriptor.module_class.__name__ = 'MockProblemModule'
46 47
    return descriptor

48 49 50
# 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.
51 52 53 54
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')
55
user_info_key = partial(DjangoKeyValueStore.Key, Scope.user_info, 1, None)
56 57


58
class StudentModuleFactory(cmfStudentModuleFactory):
59
    module_state_key = location('usage_id')
60 61 62
    course_id = course_id


63
@attr(shard=1)
64 65
class TestInvalidScopes(TestCase):
    def setUp(self):
66
        super(TestInvalidScopes, self).setUp()
67
        self.user = UserFactory.create(username='user')
Calen Pennington committed
68 69
        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)
70 71

    def test_invalid_scopes(self):
72 73 74
        for scope in (Scope(user=True, block=BlockScope.DEFINITION),
                      Scope(user=False, block=BlockScope.TYPE),
                      Scope(user=False, block=BlockScope.ALL)):
Calen Pennington committed
75
            key = DjangoKeyValueStore.Key(scope, None, None, 'field')
76 77 78 79 80 81

            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'})
82 83


84
@attr(shard=1)
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108
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")


109
@attr(shard=1)
110 111 112 113
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"
114 115
    # Tell Django to clean out all databases, not just default
    multi_db = True
116 117

    def setUp(self):
118
        super(TestStudentModuleStorage, self).setUp()
119
        student_module = StudentModuleFactory(state=json.dumps({'a_field': 'a_value', 'b_field': 'b_value'}))
120
        self.user = student_module.student
121
        self.assertEqual(self.user.id, 1)   # check our assumption hard-coded in the key functions above.
122 123 124

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

Calen Pennington committed
129
        self.kvs = DjangoKeyValueStore(self.field_data_cache)
130 131 132

    def test_get_existing_field(self):
        "Test that getting an existing field in an existing StudentModule works"
133 134 135
        # 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')))
136 137 138

    def test_get_missing_field(self):
        "Test that getting a missing field from an existing StudentModule raises a KeyError"
139 140 141
        # 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'))
142 143

    def test_set_existing_field(self):
144
        "Test that setting an existing user_state field changes the value"
145
        # We are updating a problem, so we write to courseware_studentmodulehistory
146 147 148 149
        # 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).
150
        with self.assertNumQueries(4, using='default'):
151 152
            with self.assertNumQueries(1, using='student_module_history'):
                self.kvs.set(user_state_key('a_field'), 'new_value')
153
        self.assertEquals(1, StudentModule.objects.all().count())
154
        self.assertEquals({'b_field': 'b_value', 'a_field': 'new_value'}, json.loads(StudentModule.objects.all()[0].state))
155 156

    def test_set_missing_field(self):
157
        "Test that setting a new user_state field changes the value"
158
        # We are updating a problem, so we write to courseware_studentmodulehistory
159 160 161 162
        # 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).
163
        with self.assertNumQueries(4, using='default'):
164 165
            with self.assertNumQueries(1, using='student_module_history'):
                self.kvs.set(user_state_key('not_a_field'), 'new_value')
166
        self.assertEquals(1, StudentModule.objects.all().count())
167
        self.assertEquals({'b_field': 'b_value', 'a_field': 'a_value', 'not_a_field': 'new_value'}, json.loads(StudentModule.objects.all()[0].state))
168 169 170

    def test_delete_existing_field(self):
        "Test that deleting an existing field removes it from the StudentModule"
171
        # We are updating a problem, so we write to courseware_studentmodulehistory
172 173 174 175
        # 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).
176 177 178
        with self.assertNumQueries(2, using='default'):
            with self.assertNumQueries(1, using='student_module_history'):
                self.kvs.delete(user_state_key('a_field'))
179
        self.assertEquals(1, StudentModule.objects.all().count())
180
        self.assertRaises(KeyError, self.kvs.get, user_state_key('not_a_field'))
181 182 183

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

189 190
    def test_has_existing_field(self):
        "Test that `has` returns True for existing fields in StudentModules"
191 192
        with self.assertNumQueries(0):
            self.assertTrue(self.kvs.has(user_state_key('a_field')))
193 194 195

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

199
    def construct_kv_dict(self):
200
        """Construct a kv_dict that can be passed to set_many"""
201 202 203 204 205 206 207
        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):
208
        "Test setting many fields that are scoped to Scope.user_state"
209
        kv_dict = self.construct_kv_dict()
210 211 212 213

        # 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
214 215 216
        # 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).
217
        with self.assertNumQueries(4, using="default"):
218 219
            with self.assertNumQueries(1, using="student_module_history"):
                self.kvs.set_many(kv_dict)
220 221 222 223 224

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

    def test_set_many_failure(self):
225
        "Test failures when setting many fields that are scoped to Scope.user_state"
226 227 228 229 230 231 232 233 234
        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)
235
        self.assertEquals(exception_context.exception.saved_field_names, [])
236

237

238
@attr(shard=1)
239
class TestMissingStudentModule(TestCase):
240 241 242
    # Tell Django to clean out all databases, not just default
    multi_db = True

243
    def setUp(self):
244 245
        super(TestMissingStudentModule, self).setUp()

246
        self.user = UserFactory.create(username='user')
247
        self.assertEqual(self.user.id, 1)   # check our assumption hard-coded in the key functions above.
248 249 250 251

        # 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
252
        self.kvs = DjangoKeyValueStore(self.field_data_cache)
253 254 255

    def test_get_field_from_missing_student_module(self):
        "Test that getting a field from a missing StudentModule raises a KeyError"
256 257
        with self.assertNumQueries(0):
            self.assertRaises(KeyError, self.kvs.get, user_state_key('a_field'))
258 259 260

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

264
        # We are updating a problem, so we write to courseware_studentmodulehistoryextended
265 266 267 268
        # 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).
269 270
        # Django 1.8 also has a number of other BEGIN and SAVESTATE queries.
        with self.assertNumQueries(4, using='default'):
271 272
            with self.assertNumQueries(1, using='student_module_history'):
                self.kvs.set(user_state_key('a_field'), 'a_value')
273

274
        self.assertEquals(1, sum(len(cache) for cache in self.field_data_cache.cache.values()))
275 276 277 278 279
        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)
280
        self.assertEquals(location('usage_id').replace(run=None), student_module.module_state_key)
281 282 283 284
        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"
285 286
        with self.assertNumQueries(0):
            self.assertRaises(KeyError, self.kvs.delete, user_state_key('a_field'))
287

288 289
    def test_has_field_for_missing_student_module(self):
        "Test that `has` returns False for missing StudentModules"
290 291
        with self.assertNumQueries(0):
            self.assertFalse(self.kvs.has(user_state_key('a_field')))
292 293


294
@attr(shard=1)
295
class StorageTestBase(object):
296 297 298 299
    """
    A base class for that gets subclassed when testing each of the scopes.

    """
300 301
    # 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.
302
    # pylint: disable=no-member, not-callable
303

304 305 306 307
    factory = None
    scope = None
    key_factory = None
    storage_class = None
308 309

    def setUp(self):
310 311 312 313 314
        field_storage = self.factory.create()
        if hasattr(field_storage, 'student'):
            self.user = field_storage.student
        else:
            self.user = UserFactory.create()
315 316 317
        self.mock_descriptor = mock_descriptor([
            mock_field(self.scope, 'existing_field'),
            mock_field(self.scope, 'other_existing_field')])
318 319 320 321
        # 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
322 323
        self.kvs = DjangoKeyValueStore(self.field_data_cache)

324
    def test_set_and_get_existing_field(self):
325
        with self.assertNumQueries(1):
326 327 328
            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')))
329

330
    def test_get_existing_field(self):
331
        "Test that getting an existing field in an existing Storage Field works"
332 333
        with self.assertNumQueries(0):
            self.assertEquals('old_value', self.kvs.get(self.key_factory('existing_field')))
334 335

    def test_get_missing_field(self):
336
        "Test that getting a missing field from an existing Storage Field raises a KeyError"
337 338
        with self.assertNumQueries(0):
            self.assertRaises(KeyError, self.kvs.get, self.key_factory('missing_field'))
339 340 341

    def test_set_existing_field(self):
        "Test that setting an existing field changes the value"
342
        with self.assertNumQueries(1):
343
            self.kvs.set(self.key_factory('existing_field'), 'new_value')
344 345
        self.assertEquals(1, self.storage_class.objects.all().count())
        self.assertEquals('new_value', json.loads(self.storage_class.objects.all()[0].value))
346 347 348

    def test_set_missing_field(self):
        "Test that setting a new field changes the value"
349
        with self.assertNumQueries(1):
350
            self.kvs.set(self.key_factory('missing_field'), 'new_value')
351 352 353
        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))
354 355 356

    def test_delete_existing_field(self):
        "Test that deleting an existing field removes it"
357 358
        with self.assertNumQueries(1):
            self.kvs.delete(self.key_factory('existing_field'))
359
        self.assertEquals(0, self.storage_class.objects.all().count())
360 361

    def test_delete_missing_field(self):
362
        "Test that deleting a missing field from an existing Storage Field raises a KeyError"
363 364
        with self.assertNumQueries(0):
            self.assertRaises(KeyError, self.kvs.delete, self.key_factory('missing_field'))
365
        self.assertEquals(1, self.storage_class.objects.all().count())
366

367 368
    def test_has_existing_field(self):
        "Test that `has` returns True for an existing Storage Field"
369 370
        with self.assertNumQueries(0):
            self.assertTrue(self.kvs.has(self.key_factory('existing_field')))
371

372 373
    def test_has_missing_field(self):
        "Test that `has` return False for an existing Storage Field"
374 375
        with self.assertNumQueries(0):
            self.assertFalse(self.kvs.has(self.key_factory('missing_field')))
376

377
    def construct_kv_dict(self):
378
        """Construct a kv_dict that can be passed to set_many"""
379 380 381 382 383 384 385 386 387 388
        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()

389 390
        # Each field is a separate row in the database, hence
        # a separate query
391
        with self.assertNumQueries(len(kv_dict)):
392
            self.kvs.set_many(kv_dict)
393 394 395 396 397 398
        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()
399 400
        for key in kv_dict:
            with self.assertNumQueries(1):
401
                self.kvs.set(key, 'test value')
402 403 404 405 406 407

        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
408
        self.assertEquals(exception.saved_field_names, ['existing_field', 'other_existing_field'])
409 410


411 412
class TestUserStateSummaryStorage(StorageTestBase, TestCase):
    """Tests for UserStateSummaryStorage"""
Calen Pennington committed
413 414 415
    factory = UserStateSummaryFactory
    scope = Scope.user_state_summary
    key_factory = user_state_summary_key
muhammad-ammar committed
416
    storage_class = XModuleUserStateSummaryField
417 418


419 420
class TestStudentPrefsStorage(OtherUserFailureTestMixin, StorageTestBase, TestCase):
    """Tests for StudentPrefStorage"""
421
    factory = StudentPrefsFactory
422 423
    scope = Scope.preferences
    key_factory = prefs_key
424
    storage_class = XModuleStudentPrefsField
425 426
    other_key_factory = partial(DjangoKeyValueStore.Key, Scope.preferences, 2, 'mock_problem')  # user_id=2, not 1
    existing_field_name = "existing_field"
427 428


429 430
class TestStudentInfoStorage(OtherUserFailureTestMixin, StorageTestBase, TestCase):
    """Tests for StudentInfoStorage"""
431
    factory = StudentInfoFactory
432 433
    scope = Scope.user_info
    key_factory = user_info_key
434
    storage_class = XModuleStudentInfoField
435 436
    other_key_factory = partial(DjangoKeyValueStore.Key, Scope.user_info, 2, 'mock_problem')  # user_id=2, not 1
    existing_field_name = "existing_field"