Skip to content

Commit 46d3384

Browse files
committed
tests: add unit tests to the LearningPathEnrollmentView and fixes related issues
1 parent cd8c2a0 commit 46d3384

File tree

13 files changed

+563
-17
lines changed

13 files changed

+563
-17
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,3 +63,5 @@ docs/learning_paths.*.rst
6363
# Private requirements
6464
requirements/private.in
6565
requirements/private.txt
66+
.idea
67+
default.db

docs/decisions/0003-learning-path-enrollment-api.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ Implement an API exposing the LearningPathEnrollment model. This API will allow
9898
learners to be enrolled in the Learning Path.
9999

100100
+---------------------+-------------------------------------------------------+
101-
| API Path | /api/v1/learning-path-enrollment/<learning_path_id> |
101+
| API Path | /api/v1/learning-paths/<learning_path_id>/enrollment/ |
102102
+---------------------+-------------------------------------------------------+
103103
| Methods | GET, POST, DELETE |
104104
+---------------------+-------------------------------------------------------+

learning_paths/api/v1/serializers.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,4 +90,4 @@ class LearningPathGradeSerializer(serializers.Serializer):
9090
class LearningPathEnrollmentSerializer(serializers.ModelSerializer):
9191
class Meta:
9292
model = LearningPathEnrollment
93-
fields = ("pk", "user", "learning_path", "is_active", "enrolled_at")
93+
fields = ("id", "user", "learning_path", "is_active", "enrolled_at")

learning_paths/api/v1/tests/factories.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
# pylint: disable=missing-module-docstring,missing-class-docstring
2+
from datetime import datetime, timezone
3+
24
import factory
35
from django.contrib import auth
46
from factory.fuzzy import FuzzyText
57

6-
from learning_paths.models import LearningPath, LearningPathGradingCriteria
8+
from learning_paths.models import (
9+
LearningPath,
10+
LearningPathEnrollment,
11+
LearningPathGradingCriteria,
12+
)
713

814
User = auth.get_user_model()
915

@@ -31,7 +37,6 @@ class Meta:
3137
uuid = factory.Faker("uuid4")
3238
display_name = FuzzyText()
3339
slug = FuzzyText()
34-
display_name = FuzzyText()
3540
description = FuzzyText()
3641
sequential = False
3742

@@ -43,3 +48,17 @@ class Meta:
4348
learning_path = factory.SubFactory(LearnerPathwayFactory)
4449
required_completion = 0.80
4550
required_grade = 0.75
51+
52+
53+
class LearningPathEnrollmentFactory(factory.django.DjangoModelFactory):
54+
"""
55+
Factory for LearningPathEnrollment model.
56+
"""
57+
58+
user = factory.SubFactory(UserFactory)
59+
learning_path = factory.SubFactory(LearnerPathwayFactory)
60+
is_active = True
61+
enrolled_at = factory.LazyFunction(lambda: datetime.now(timezone.utc))
62+
63+
class Meta:
64+
model = LearningPathEnrollment

learning_paths/api/v1/tests/test_views.py

Lines changed: 309 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# pylint: disable=missing-module-docstring,missing-class-docstring
22
from unittest.mock import patch
33

4+
from django.test import override_settings
45
from django.urls import reverse
56
from rest_framework import status
67
from rest_framework.test import APIRequestFactory, APITestCase, force_authenticate
@@ -12,6 +13,7 @@
1213
from learning_paths.api.v1.tests.factories import (
1314
LearnerPathGradingCriteriaFactory,
1415
LearnerPathwayFactory,
16+
LearningPathEnrollmentFactory,
1517
UserFactory,
1618
)
1719
from learning_paths.api.v1.views import (
@@ -123,3 +125,310 @@ def test_learning_path_grade_success(
123125
self.assertEqual(response.status_code, status.HTTP_200_OK)
124126
self.assertEqual(response.data["grade"], 0.85)
125127
self.assertTrue(response.data["required_grade"], 0.75)
128+
129+
130+
class LearningPathEnrollmentTests(APITestCase):
131+
def setUp(self) -> None:
132+
super().setUp()
133+
self.admin = UserFactory(is_staff=True, is_superuser=True)
134+
self.staff_user = UserFactory(is_staff=True)
135+
self.learner = UserFactory()
136+
self.another_learner = UserFactory()
137+
self.learning_path = LearnerPathwayFactory.create()
138+
self.url = f"/api/v1/learning-paths/{self.learning_path.uuid}/enrollments/"
139+
140+
def test_get_with_username_for_staff_or_admin(self):
141+
"""
142+
GIVEN `username` query parameter is present
143+
WHEN the request is made by staff or admin
144+
THEN return existing active enrollments for `username`.
145+
"""
146+
active_enrollment = LearningPathEnrollmentFactory(
147+
user=self.learner, learning_path=self.learning_path, is_active=True
148+
)
149+
150+
# Test for admin
151+
self.client.force_authenticate(user=self.admin)
152+
response = self.client.get(
153+
self.url, query_params={"username": self.learner.username}
154+
)
155+
self.assertEqual(response.status_code, status.HTTP_200_OK)
156+
self.assertIn(
157+
active_enrollment.id, [enrollment["id"] for enrollment in response.data]
158+
)
159+
160+
# Test for staff
161+
self.client.force_authenticate(user=self.staff_user)
162+
response = self.client.get(
163+
self.url, query_params={"username": self.learner.username}
164+
)
165+
self.assertEqual(response.status_code, status.HTTP_200_OK)
166+
self.assertIn(
167+
active_enrollment.id, [enrollment["id"] for enrollment in response.data]
168+
)
169+
170+
def test_get_with_username_for_non_staff(self):
171+
"""
172+
GIVEN `username` query parameter is present
173+
WHEN the request is made by non-staff
174+
THEN return active enrollment if username matches the current user,
175+
403 otherwise.
176+
"""
177+
active_enrollment = LearningPathEnrollmentFactory(
178+
user=self.learner, learning_path=self.learning_path, is_active=True
179+
)
180+
181+
# Test for matching username
182+
self.client.force_authenticate(user=self.learner)
183+
response = self.client.get(
184+
self.url, query_params={"username": self.learner.username}
185+
)
186+
self.assertEqual(response.status_code, status.HTTP_200_OK)
187+
self.assertIn(
188+
active_enrollment.id, [enrollment["id"] for enrollment in response.data]
189+
)
190+
191+
# Test for non-matching username
192+
other_user = UserFactory()
193+
response = self.client.get(self.url, {"username": other_user.username})
194+
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
195+
196+
def test_get_without_username_for_staff_or_admin(self):
197+
"""
198+
GIVEN `username` query parameter is missing or empty
199+
WHEN the request is made by staff or admin
200+
THEN return all the active enrollments for the learning path.
201+
"""
202+
enrollment = LearningPathEnrollmentFactory(
203+
user=self.learner, learning_path=self.learning_path, is_active=True
204+
)
205+
206+
# Test when enrollment is active for admin
207+
self.client.force_authenticate(user=self.admin)
208+
response = self.client.get(self.url)
209+
self.assertEqual(response.status_code, status.HTTP_200_OK)
210+
self.assertIn(enrollment.id, [enrollment["id"] for enrollment in response.data])
211+
212+
# Test when enrollment is inactive for admin
213+
enrollment.is_active = False # Mark the same enrollment as inactive
214+
enrollment.save()
215+
response = self.client.get(self.url)
216+
self.assertEqual(response.status_code, status.HTTP_200_OK)
217+
self.assertNotIn(
218+
enrollment.id, [enrollment["id"] for enrollment in response.data]
219+
)
220+
221+
# Test when enrollment is inactive for staff
222+
self.client.force_authenticate(user=self.staff_user)
223+
response = self.client.get(self.url)
224+
self.assertEqual(response.status_code, status.HTTP_200_OK)
225+
self.assertNotIn(
226+
enrollment.id, [enrollment["id"] for enrollment in response.data]
227+
)
228+
229+
# Test when enrollment is active again for staff
230+
enrollment.is_active = True # Mark it active again
231+
enrollment.save()
232+
response = self.client.get(self.url)
233+
self.assertEqual(response.status_code, status.HTTP_200_OK)
234+
self.assertIn(enrollment.id, [enrollment["id"] for enrollment in response.data])
235+
236+
def test_get_without_username_for_non_staff(self):
237+
"""
238+
GIVEN `username` query parameter is absent
239+
WHEN the request is made by non-staff
240+
THEN return active enrollment or empty list
241+
"""
242+
# No enrollment
243+
self.client.force_authenticate(user=self.learner)
244+
response = self.client.get(self.url)
245+
self.assertEqual(response.status_code, status.HTTP_200_OK)
246+
self.assertEqual(response.data, [])
247+
248+
# Active enrollment
249+
enrollment = LearningPathEnrollmentFactory(
250+
user=self.learner, learning_path=self.learning_path, is_active=True
251+
)
252+
253+
response = self.client.get(self.url)
254+
self.assertEqual(response.status_code, status.HTTP_200_OK)
255+
self.assertIn(enrollment.id, [enrollment["id"] for enrollment in response.data])
256+
257+
# Inactive enrollment
258+
enrollment.is_active = False
259+
enrollment.save()
260+
261+
response = self.client.get(self.url)
262+
self.assertEqual(response.status_code, status.HTTP_200_OK)
263+
self.assertEqual(response.data, [])
264+
265+
def test_enroll_current_user_when_username_absent(self):
266+
"""
267+
GIVEN `username` query parameter is absent
268+
WHEN the request is made
269+
THEN enroll the `currentUser` in the given Learning Path successfully.
270+
"""
271+
self.client.force_authenticate(user=self.learner)
272+
response = self.client.post(self.url)
273+
274+
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
275+
self.assertTrue(
276+
LearningPathEnrollmentFactory._meta.model.objects.filter(
277+
user=self.learner, learning_path=self.learning_path, is_active=True
278+
).exists()
279+
)
280+
281+
def test_enroll_different_user_when_current_user_is_staff_or_admin(self):
282+
"""
283+
GIVEN `username` query parameter is provided and different from the `currentUser`
284+
WHEN the `currentUser` is staff or admin
285+
THEN enroll the `username` in the Learning Path.
286+
"""
287+
self.client.force_authenticate(user=self.staff_user)
288+
response = self.client.post(
289+
f"{self.url}?username={self.another_learner.username}"
290+
)
291+
292+
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
293+
self.assertTrue(
294+
LearningPathEnrollmentFactory._meta.model.objects.filter(
295+
user=self.another_learner,
296+
learning_path=self.learning_path,
297+
is_active=True,
298+
).exists()
299+
)
300+
301+
def test_non_staff_user_enrolling_different_user_returns_403(self):
302+
"""
303+
GIVEN `username` query parameter is provided and different from the `currentUser`
304+
WHEN the `currentUser` is not staff or admin
305+
THEN return HTTP 403 Forbidden.
306+
"""
307+
self.client.force_authenticate(user=self.learner)
308+
309+
response = self.client.post(
310+
f"{self.url}?username={self.another_learner.username}"
311+
)
312+
313+
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
314+
315+
def test_enrollment_returns_404_for_invalid_user_or_learning_path(self):
316+
"""
317+
GIVEN invalid `username` or `learning_path_id`
318+
WHEN a POST request is made
319+
THEN return HTTP 404 Not Found.
320+
"""
321+
self.client.force_authenticate(user=self.admin)
322+
323+
# Test invalid username
324+
response = self.client.post(f"{self.url}?username=nonexistentuser")
325+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
326+
327+
# Test invalid learning_path_id
328+
response = self.client.post(
329+
"/api/learning-paths/2ac8a3cc-e492-4ce9-88a3-cce4922ce9df/enrollments/"
330+
) # Invalid Learning Path ID
331+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
332+
333+
def test_enrollment_returns_409_if_already_enrolled(self):
334+
"""
335+
GIVEN an active enrollment exists for the user and Learning Path
336+
WHEN a POST request is made
337+
THEN return HTTP 409 Conflict.
338+
"""
339+
LearningPathEnrollmentFactory(
340+
user=self.learner, learning_path=self.learning_path, is_active=True
341+
)
342+
self.client.force_authenticate(user=self.learner)
343+
344+
response = self.client.post(self.url)
345+
346+
self.assertEqual(response.status_code, status.HTTP_409_CONFLICT)
347+
348+
@override_settings(LEARNING_PATHS_ALLOW_SELF_UNENROLLMENT=True)
349+
def test_self_unenrollment_marks_enrollment_inactive(self):
350+
"""
351+
GIVEN an active enrollment exists for the current user
352+
WHEN a DELETE request is made with `LEARNING_PATHS_ALLOW_SELF_UNENROLLMENT=True`
353+
THEN the enrollment is marked inactive (`is_active=False`).
354+
"""
355+
enrollment = LearningPathEnrollmentFactory(
356+
user=self.learner, learning_path=self.learning_path, is_active=True
357+
)
358+
self.client.force_authenticate(user=self.learner)
359+
360+
response = self.client.delete(self.url)
361+
362+
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
363+
enrollment.refresh_from_db()
364+
self.assertFalse(enrollment.is_active) # Check is_active field is now False
365+
366+
@override_settings(LEARNING_PATHS_ALLOW_SELF_UNENROLLMENT=False)
367+
def test_self_unenrollment_denied_when_setting_disabled(self):
368+
"""
369+
GIVEN an active enrollment exists for the current user
370+
WHEN a DELETE request is made and `LEARNING_PATHS_ALLOW_SELF_UNENROLLMENT=False`
371+
THEN the request is denied with HTTP 403.
372+
"""
373+
LearningPathEnrollmentFactory(
374+
user=self.learner, learning_path=self.learning_path, is_active=True
375+
)
376+
self.client.force_authenticate(user=self.learner)
377+
378+
response = self.client.delete(self.url)
379+
380+
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
381+
382+
@override_settings(LEARNING_PATHS_ALLOW_SELF_UNENROLLMENT=False)
383+
def test_staff_unenrollment_succeeds_when_setting_disabled(self):
384+
"""
385+
GIVEN an active enrollment exists for a learner
386+
WHEN a DELETE request is made by a staff user and `LEARNING_PATHS_ALLOW_SELF_UNENROLLMENT=False`
387+
THEN the enrollment is marked inactive (`is_active=False`) successfully.
388+
389+
This is necessary to verify that the setting doesn't affect staff users.
390+
"""
391+
enrollment = LearningPathEnrollmentFactory(
392+
user=self.learner, learning_path=self.learning_path, is_active=True
393+
)
394+
self.client.force_authenticate(user=self.staff_user)
395+
396+
response = self.client.delete(f"{self.url}?username={self.learner.username}")
397+
398+
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
399+
enrollment.refresh_from_db()
400+
self.assertFalse(enrollment.is_active) # Check is_active field is now False
401+
402+
@override_settings(LEARNING_PATHS_ALLOW_SELF_UNENROLLMENT=True)
403+
def test_non_staff_users_cannot_unenroll_other_learners(self):
404+
"""
405+
GIVEN an active enrollment exists for the current user
406+
WHEN a DELETE request is made with `LEARNING_PATHS_ALLOW_SELF_UNENROLLMENT=True`
407+
THEN the enrollment is marked inactive (`is_active=False`).
408+
"""
409+
enrollment = LearningPathEnrollmentFactory(
410+
user=self.learner, learning_path=self.learning_path, is_active=True
411+
)
412+
self.client.force_authenticate(user=self.another_learner)
413+
414+
response = self.client.delete(self.url + "?username=" + self.learner.username)
415+
416+
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
417+
enrollment.refresh_from_db()
418+
self.assertTrue(enrollment.is_active)
419+
420+
def test_return_404_when_no_active_enrollments_exist(self):
421+
"""
422+
GIVEN no active enrollment exists for the user in the learning path
423+
WHEN a DELETE request is made
424+
THEN HTTP 404 Not Found is returned.
425+
"""
426+
# Create an inactive enrollment
427+
LearningPathEnrollmentFactory(
428+
user=self.learner, learning_path=self.learning_path, is_active=False
429+
)
430+
self.client.force_authenticate(user=self.learner)
431+
432+
response = self.client.delete(self.url)
433+
434+
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)

0 commit comments

Comments
 (0)