FAQ | This is a LIVE service | Changelog

test_student_api.py 16.7 KB
Newer Older
Robin Goodall's avatar
Robin Goodall committed
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
from typing import List, Dict
import logging
from unittest import TestCase
from urllib.parse import urljoin
import faker

from identitylib.identifiers import IdentifierSchemes

from . import MockSession, SR_INST_VALUE
from lookupsync.tests.fakeproviders import StudentProvider

from lookupsync.student_api import (
    fetch_all_students, get_students_by_group,
    STUDENT_API_ROOT, ACADEMIC_CAREER_MAPPING, Student)
from lookupsync.lookup import group_name

STUDENT_API_INITIAL_PAGE_URL = urljoin(STUDENT_API_ROOT, 'students')

INST_MAPPING = {
    SR_INST_VALUE: 'FOO',
    'SRID2': 'FOO',
    'SRID3': 'BAR',
    'SRID4': 'BAZ',
}

# Typical response from Student Records API
# {
#   'results': [
#     {
#       'identifiers': [
#         {
#           'value': '123456789',
#           'scheme': 'person.v1.student-records.university.identifiers.cam.ac.uk'
#         }
#       ],
#       'namePrefixes': 'Mr',
#       'surname': 'Smith',
#       'forenames': 'John George',
#       'dateOfBirth': '2000-01-02',
#       'affiliations': [
#         {
#           'value': 'EDM2PSY',
#           'status': 'PGRD',
#           'scheme': 'academic-plan.v1.student-records.university.identifiers.cam.ac.uk',
#           'start': '2021-10-01',
#           'end': '2022-06-18'
#         }, {
#           'value': 'W',
#           'status': 'PGRD',
#           'scheme': 'institution.v1.student-records.university.identifiers.cam.ac.uk',
#           'start': '2021-10-01',
#           'end': '2022-06-18'
#         }, {
#           'value': 'EF',
#           'status': 'PGRD',
#           'scheme': 'institution.v1.student-records.university.identifiers.cam.ac.uk',
#           'start': '2021-10-01',
#           'end': '2022-06-18'
#         }
#       ]
#     }
#   ],
#   'next': '....?cursor=FOO',
# }


class StudentAPITest(TestCase):

    def setUp(self):
        self.fake = faker.Faker()
        self.fake.seed_instance(0xdeadbeef)
        self.fake.add_provider(StudentProvider)

    def test_fetch_all_students(self):
        """
        fetch_all_students gets paged responses from the Student API yielding each
        student as a Student instance.

        """
        # Create 100 random students and split them in to pages
        NUMBER_STUDENTS = 100
        PAGE_SIZE = 29
        fake_students = [self.fake.student() for _ in range(NUMBER_STUDENTS)]
        pages = [fake_students[i:i + PAGE_SIZE] for i in range(0, len(fake_students), PAGE_SIZE)]

        # Create a dict of requests and responses for MockSession to provide
        responses = {}
        all_urls = set()
        for idx, page in enumerate(pages):
            url = STUDENT_API_INITIAL_PAGE_URL + (
                '' if idx == 0 else f'?cursor=PAGE{idx}'
            )
            all_urls.add(url)
            next = (
                None if idx == len(pages)-1
                else f'{STUDENT_API_INITIAL_PAGE_URL}?cursor=PAGE{idx+1}'
            )
            responses[url] = {
                'results': [s for s in page],
                'next': next,
            }
        self.assertEqual(len(all_urls), len(pages))
        session = MockSession(responses)

        # Fetch all students
        all_students = list(fetch_all_students(session))

        # All pages requested
        self.assertEqual(session.urls_got, all_urls)

        # Result matches expectations
        self.assertEqual(all_students, [
            Student.parse_obj(s) for s in fake_students
        ])

    def _students_to_responses(self, students: List[Dict]) -> Dict[str, Dict]:
        """
        Take a list of students and return a single page response for the initial
        page request

        """
        return {
            STUDENT_API_INITIAL_PAGE_URL: {
                'results': [s for s in students],
                'next': None,
            }
        }

    def test_get_students_by_group(self):
        """
        Students with a single affiliation and USN identifier all get returned

        """
        # Provider gives us students like this by default
        NUMBER_STUDENTS = 100
        while True:
            fake_students = [self.fake.student() for _ in range(NUMBER_STUDENTS)]
            # Make sure at least one affiliation per expected status
            statuses = {s['affiliations'][0]['status'] for s in fake_students}
            if set(statuses) == set(ACADEMIC_CAREER_MAPPING.keys()):
                break

        # get students by groups with list of fake students in mock response
        session = MockSession(self._students_to_responses(fake_students))
145
        students_by_group, student_names_by_id = get_students_by_group(session, INST_MAPPING)
Robin Goodall's avatar
Robin Goodall committed
146
147
148
149
150
151
152
153
154
155
156
157

        # Expect groups for all status at single lookup inst match keys of result
        lookup_inst = INST_MAPPING.get(SR_INST_VALUE)
        expected_groups = {
            group_name(lookup_inst, status)
            for status in ACADEMIC_CAREER_MAPPING.values()
        }
        self.assertEqual(expected_groups, set(students_by_group.keys()))

        # Expect all students to be returned (being only in a single group)
        count = sum([len(s) for s in students_by_group.values()])
        self.assertEqual(count, NUMBER_STUDENTS)
158
        self.assertEqual(len(student_names_by_id), NUMBER_STUDENTS)
Robin Goodall's avatar
Robin Goodall committed
159
160
161
162
163
164
165
166

    def test_get_students_by_group_multiple_affiliations(self):
        """
        Students with multiple different affiliations get assigned to all matching groups

        """
        # Single student
        STUDENT_USN = '123456789'
167
168
        FORENAMES = 'John'
        SURNAME = 'Smith'
Robin Goodall's avatar
Robin Goodall committed
169
170
171
172
173
174
175
176
177
178
179
180
181
        identifier_id = f'{STUDENT_USN}@{IdentifierSchemes.USN}'
        # in multiple distinct lookup institutions
        AFFILIATION_ID_VALUES = {'SRID1', 'SRID3', 'SRID4'}
        lookup_id_values = {INST_MAPPING.get(v) for v in AFFILIATION_ID_VALUES}
        affiliations = [
            self.fake.affiliation(
                affiliation_id=f'{value}@{IdentifierSchemes.STUDENT_INSTITUTION}',
                status='UGRD',  # as an undergraduate
            )
            for value in AFFILIATION_ID_VALUES
        ]
        fake_student = self.fake.student(
            identifier_id=identifier_id,
182
183
184
            affiliations=affiliations,
            surname=SURNAME,
            forenames=FORENAMES,
Robin Goodall's avatar
Robin Goodall committed
185
186
187
188
        )

        # get students by groups with just fake student in mock response
        session = MockSession(self._students_to_responses([fake_student]))
189
        students_by_group, student_names_by_id = get_students_by_group(session, INST_MAPPING)
Robin Goodall's avatar
Robin Goodall committed
190
191
192
193
194
195
196
197
198

        # Expect groups for all status at single lookup inst match keys of result
        expected_groups = {
            group_name(lookup_id, 'ug')
            for lookup_id in lookup_id_values
        }
        self.assertEqual(expected_groups, set(students_by_group.keys()))

        # The student is the single member of each list
199
        for _, students in students_by_group.items():
Robin Goodall's avatar
Robin Goodall committed
200
201
            self.assertEqual(len(students), 1)
            self.assertEqual(students, {STUDENT_USN})
202
203
        # and their name is mapped to USN
        self.assertEqual(student_names_by_id.get(STUDENT_USN), f'{FORENAMES} {SURNAME}')
Robin Goodall's avatar
Robin Goodall committed
204
205
206
207
208
209
210
211
212

    def test_get_students_by_group_depreciated(self):
        """
        Students with depreciated but valid Student Records inst or person id schemes still get
        mapped to appropriate group

        """
        # Single student with depreciated USN scheme
        STUDENT_USN = '123456789'
213
214
        FORENAMES = 'John'
        SURNAME = 'Smith'
Robin Goodall's avatar
Robin Goodall committed
215
216
217
218
219
220
221
222
223
        identifier_id = f'{STUDENT_USN}@person.camsis.identifiers.admin.cam.ac.uk'
        # and affiliation with depreciated institution scheme
        AFFILIATION_VALUE = 'SRID3'
        affiliation_id = (
            f'{AFFILIATION_VALUE}@institution.v1.student.university.identifiers.cam.ac.uk')
        lookup_id = INST_MAPPING.get(AFFILIATION_VALUE)
        fake_student = self.fake.student(
            identifier_id=identifier_id,
            affiliation_id=affiliation_id,
224
225
            surname=SURNAME,
            forenames=FORENAMES,
Robin Goodall's avatar
Robin Goodall committed
226
227
228
229
230
            status='PGRD',  # as a postgraduate
        )

        # get students by groups with just fake student in mock response
        session = MockSession(self._students_to_responses([fake_student]))
231
        students_by_group, student_names_by_id = get_students_by_group(session, INST_MAPPING)
Robin Goodall's avatar
Robin Goodall committed
232
233
234
235

        # Expect just single lookup group with single matching student
        expected_group = group_name(lookup_id, 'pg')
        self.assertEqual({expected_group: {STUDENT_USN}}, students_by_group)
236
237
        # and their name is mapped to USN
        self.assertEqual(student_names_by_id.get(STUDENT_USN), f'{FORENAMES} {SURNAME}')
Robin Goodall's avatar
Robin Goodall committed
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253

    def test_get_students_by_group_no_aff(self):
        """
        Students with no appropriate affiliations don't appear in any group

        """
        # Single student with USN but no affiliations
        STUDENT_USN = '123456789'
        identifier_id = f'{STUDENT_USN}@{IdentifierSchemes.USN}'
        fake_student = self.fake.student(
            identifier_id=identifier_id,
            affiliation_id=f'ADMIN@{IdentifierSchemes.HR_INSTITUTION}',  # not Student Records inst
        )

        # get students by groups with just fake student in mock response
        session = MockSession(self._students_to_responses([fake_student]))
254
        students_by_group, student_names_by_id = get_students_by_group(session, INST_MAPPING)
Robin Goodall's avatar
Robin Goodall committed
255
256
257

        # Expect empty result
        self.assertEqual({}, students_by_group)
258
        self.assertEqual({}, student_names_by_id)
Robin Goodall's avatar
Robin Goodall committed
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274

    def test_get_students_by_group_invalid_aff(self):
        """
        Students with affiliations with invalid scheme don't appear as group isn't even created

        """
        # Single student with USN but only invalid affiliation
        STUDENT_USN = '123456789'
        identifier_id = f'{STUDENT_USN}@{IdentifierSchemes.USN}'
        fake_student = self.fake.student(
            identifier_id=identifier_id,
            affiliation_id='SRID1@invalid.inst.id.scheme',
        )

        # get students by groups with just fake student in mock response
        session = MockSession(self._students_to_responses([fake_student]))
275
        students_by_group, student_names_by_id = get_students_by_group(session, INST_MAPPING)
Robin Goodall's avatar
Robin Goodall committed
276
277
278

        # Expect empty result
        self.assertEqual({}, students_by_group)
279
        self.assertEqual({}, student_names_by_id)
Robin Goodall's avatar
Robin Goodall committed
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297

    def test_get_students_by_group_no_usn(self):
        """
        Students with no USN don't appear in any group though group gets created

        """
        # Single student with affiliation but no USN
        AFFILIATION_VALUE = 'SRID3'
        affiliation_id = f'{AFFILIATION_VALUE}@{IdentifierSchemes.STUDENT_INSTITUTION}'
        lookup_id = INST_MAPPING.get(AFFILIATION_VALUE)
        fake_student = self.fake.student(
            identifier_id=f'abc123@{IdentifierSchemes.CRSID}',  # CRSid instead of USN
            affiliation_id=affiliation_id,
            status='PGRD',  # as a postgraduate
        )

        # get students by groups with just fake student in mock response
        session = MockSession(self._students_to_responses([fake_student]))
298
        students_by_group, student_names_by_id = get_students_by_group(session, INST_MAPPING)
Robin Goodall's avatar
Robin Goodall committed
299
300
301
302

        # Expect result with single group but no students
        expected_group = group_name(lookup_id, 'pg')
        self.assertEqual({expected_group: set()}, students_by_group)
303
        self.assertEqual({}, student_names_by_id)
Robin Goodall's avatar
Robin Goodall committed
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322

    def test_get_students_by_group_invalid_usn(self):
        """
        Students with invalid person identifier scheme don't appear in matching group

        """
        # Single student with invalid person identifier scheme
        STUDENT_USN = '123456789'
        AFFILIATION_VALUE = 'SRID3'
        affiliation_id = f'{AFFILIATION_VALUE}@{IdentifierSchemes.STUDENT_INSTITUTION}'
        lookup_id = INST_MAPPING.get(AFFILIATION_VALUE)
        fake_student = self.fake.student(
            identifier_id=f'{STUDENT_USN}@invalid.person.id.scheme',
            affiliation_id=affiliation_id,
            status='PGRD',  # as a postgraduate
        )

        # get students by groups with just fake student in mock response
        session = MockSession(self._students_to_responses([fake_student]))
323
        students_by_group, student_names_by_id = get_students_by_group(session, INST_MAPPING)
Robin Goodall's avatar
Robin Goodall committed
324
325
326
327

        # Expect result with single group but no students
        expected_group = group_name(lookup_id, 'pg')
        self.assertEqual({expected_group: set()}, students_by_group)
328
        self.assertEqual({}, student_names_by_id)
Robin Goodall's avatar
Robin Goodall committed
329
330
331
332
333
334
335
336
337

    def test_get_students_by_group_periods(self):
        """
        Students with current, expired and yet to start affiliations only appear in groups for
        the current affiliation.

        """
        # Student with multiple affiliation
        STUDENT_USN = '123456789'
338
339
        FORENAMES = 'John'
        SURNAME = 'Smith'
Robin Goodall's avatar
Robin Goodall committed
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
        identifier_id = f'{STUDENT_USN}@{IdentifierSchemes.USN}'
        AFFILIATION_ID_VALUES = ['SRID1', 'SRID3', 'SRID4']
        lookup_id_values = [INST_MAPPING.get(v) for v in AFFILIATION_ID_VALUES]

        # but only one current
        PAST_DATE = self.fake.past_date().strftime('%Y-%m-%d')
        FUTURE_DATE = self.fake.future_date().strftime('%Y-%m-%d')
        # first is in past, second is current, last is in future
        periods = [(None, PAST_DATE), (PAST_DATE, FUTURE_DATE), (FUTURE_DATE, None)]
        affiliations = [
            self.fake.affiliation(
                affiliation_id=f'{value}@{IdentifierSchemes.STUDENT_INSTITUTION}',
                status='UGRD',  # as an undergraduate
                affiliation_period=periods[idx],
            )
            for idx, value in enumerate(AFFILIATION_ID_VALUES)
        ]
        fake_student = self.fake.student(
            identifier_id=identifier_id,
359
360
361
            affiliations=affiliations,
            surname=SURNAME,
            forenames=FORENAMES,
Robin Goodall's avatar
Robin Goodall committed
362
        )
363
364
        print(fake_student)
        print(self._students_to_responses([fake_student]))
Robin Goodall's avatar
Robin Goodall committed
365
366
367

        # get students by groups with just fake student in mock response
        session = MockSession(self._students_to_responses([fake_student]))
368
        students_by_group, student_names_by_id = get_students_by_group(session, INST_MAPPING)
Robin Goodall's avatar
Robin Goodall committed
369
370
371
        # Expect just one group (for current affiliation) with the student
        expected_group = group_name(lookup_id_values[1], 'ug')
        self.assertEqual({expected_group: {STUDENT_USN}}, students_by_group)
372
373
        # and their name is mapped to USN
        self.assertEqual(student_names_by_id.get(STUDENT_USN), f'{FORENAMES} {SURNAME}')
Robin Goodall's avatar
Robin Goodall committed
374
375
376
377
378
379
380
381

    def test_get_students_by_group_ignored(self):
        """
        Students with unmappable affiliations or careers cause these to be logged

        """
        # Student with multiple affiliation (last one unmappable)
        STUDENT_USN = '123456789'
382
383
        FORENAMES = 'John'
        SURNAME = 'Smith'
Robin Goodall's avatar
Robin Goodall committed
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
        identifier_id = f'{STUDENT_USN}@{IdentifierSchemes.USN}'
        AFFILIATION_ID_VALUES = ['SRID1', 'SRID3', 'BADAFF']
        lookup_id_values = [INST_MAPPING.get(v) for v in AFFILIATION_ID_VALUES]
        # and second affiliation with unmappable career
        CAREERS = ['UGRD', 'BADCAR', 'PGRD']
        career_mappings = [ACADEMIC_CAREER_MAPPING.get(c) for c in CAREERS]
        affiliations = [
            self.fake.affiliation(
                affiliation_id=f'{value}@{IdentifierSchemes.STUDENT_INSTITUTION}',
                status=CAREERS[idx],
            )
            for idx, value in enumerate(AFFILIATION_ID_VALUES)
        ]
        fake_student = self.fake.student(
            identifier_id=identifier_id,
399
400
401
            affiliations=affiliations,
            surname=SURNAME,
            forenames=FORENAMES,
Robin Goodall's avatar
Robin Goodall committed
402
403
404
405
406
        )

        # get students by groups with just fake student in mock response
        session = MockSession(self._students_to_responses([fake_student]))
        with self.assertLogs(level=logging.INFO) as captured:
407
            students_by_group, student_names_by_id = get_students_by_group(session, INST_MAPPING)
Robin Goodall's avatar
Robin Goodall committed
408
409
410
411

        # Result contains just mappable affiliation (the first)
        expected_group = group_name(lookup_id_values[0], career_mappings[0])
        self.assertEqual({expected_group: {STUDENT_USN}}, students_by_group)
412
413
        # and their name is mapped to USN
        self.assertEqual(student_names_by_id.get(STUDENT_USN), f'{FORENAMES} {SURNAME}')
Robin Goodall's avatar
Robin Goodall committed
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429

        log_messages = [r.getMessage() for r in captured.records]

        # Warning given for ignored affiliation
        try:
            ignored_log_idx = log_messages.index('Ignored Affiliations:')
        except ValueError:
            self.fail('No ignored affiliations')
        self.assertEqual('- BADAFF', log_messages[ignored_log_idx+1])

        # Warning given for ignored career
        try:
            ignored_log_idx = log_messages.index('Ignored Academic Careers:')
        except ValueError:
            self.fail('No ignored careers')
        self.assertEqual('- BADCAR', log_messages[ignored_log_idx+1])