diff --git a/codeforlife/tests/api.py b/codeforlife/tests/api.py index 09fee04a..ea0622a5 100644 --- a/codeforlife/tests/api.py +++ b/codeforlife/tests/api.py @@ -2,16 +2,28 @@ from unittest.mock import patch from pyotp import TOTP +from django.urls import reverse from django.utils import timezone +from django.db.models import Model from rest_framework.test import APITestCase as _APITestCase from rest_framework.test import APIClient as _APIClient +from rest_framework.serializers import BaseSerializer +from rest_framework.response import Response from ..user.models import User, AuthFactor +AnySerializer = t.TypeVar("AnySerializer", bound=BaseSerializer) +AnyModel = t.TypeVar("AnyModel", bound=Model) + + class APIClient(_APIClient): StatusCodeAssertion = t.Optional[t.Union[int, t.Callable[[int], bool]]] + @staticmethod + def status_code_is_ok(status_code: int): + return 200 <= status_code < 300 + def generic( self, method, @@ -29,9 +41,7 @@ def generic( # Use a custom kwarg to handle the common case of checking the # response's status code. if status_code_assertion is None: - status_code_assertion = ( - lambda status_code: 200 <= status_code <= 299 - ) + status_code_assertion = self.status_code_is_ok elif isinstance(status_code_assertion, int): expected_status_code = status_code_assertion status_code_assertion = ( @@ -39,14 +49,14 @@ def generic( ) assert status_code_assertion( wsgi_response.status_code - ), f"Response has error status code: {wsgi_response.status_code}" + ), f"Unexpected status code: {wsgi_response.status_code}." return wsgi_response def login(self, **credentials): assert super().login( **credentials - ), f"Failed to login with credentials: {credentials}" + ), f"Failed to login with credentials: {credentials}." user = User.objects.get(session=self.session.session_key) @@ -58,12 +68,69 @@ def login(self, **credentials): with patch.object(timezone, "now", return_value=now): assert super().login( otp=otp - ), f'Failed to login with OTP "{otp}" at {now}' + ), f'Failed to login with OTP "{otp}" at {now}.' assert user.is_authenticated, "Failed to authenticate user." return user + @staticmethod + def assert_data_equals_model( + data: t.Dict[str, t.Any], + model: AnyModel, + serializer_class: t.Type[AnySerializer], + ): + assert ( + data == serializer_class(model).data + ), "Data does not equal serialized model." + + def retrieve( + self, + basename: str, + model: AnyModel, + serializer_class: t.Type[AnySerializer], + status_code_assertion: StatusCodeAssertion = None, + **kwargs, + ): + response: Response = self.get( + reverse(f"{basename}-detail", kwargs={"pk": model.pk}), + status_code_assertion=status_code_assertion, + **kwargs, + ) + + if self.status_code_is_ok(response.status_code): + self.assert_data_equals_model( + response.json(), + model, + serializer_class, + ) + + return response + + def list( + self, + basename: str, + models: t.Iterable[AnyModel], + serializer_class: t.Type[AnySerializer], + status_code_assertion: StatusCodeAssertion = None, + **kwargs, + ): + response: Response = self.get( + reverse(f"{basename}-list"), + status_code_assertion=status_code_assertion, + **kwargs, + ) + + if self.status_code_is_ok(response.status_code): + for data, model in zip(response.json()["data"], models): + self.assert_data_equals_model( + data, + model, + serializer_class, + ) + + return response + class APITestCase(_APITestCase): client: APIClient diff --git a/codeforlife/user/tests/views/test_user.py b/codeforlife/user/tests/views/test_user.py index 73e8e59b..95e8ea0a 100644 --- a/codeforlife/user/tests/views/test_user.py +++ b/codeforlife/user/tests/views/test_user.py @@ -1,4 +1,5 @@ -from django.urls import reverse +import typing as t + from django.db.models.query import QuerySet from rest_framework import status @@ -9,25 +10,11 @@ class TestUserViewSet(APITestCase): """ - Naming convention: - - test_{action}__{user_type}__{other_user_type}__{same_school} + Base naming convention: + test_{action} action: This view set action. https://www.django-rest-framework.org/api-guide/viewsets/#viewset-actions - - user_type: The type of user that is making the request. Options: - - teacher: A teacher. - - student: A student. - - other_user_type: The type of user whose data is being requested. Options: - - self: User's own data. - - teacher: Another teacher's data. - - student: Another student's data. - - same_school: A flag for if the other user is from the same school. Options: - - same_school: The other user is from the same school. - - not_same_school: The other user is not from the same school. """ # TODO: replace this setup with data fixtures. @@ -91,19 +78,6 @@ def _login_student(self): assert user.student.class_field.teacher.school return user - def _retrieve_user( - self, - user: User, - status_code_assertion: APIClient.StatusCodeAssertion = None, - ): - response = self.client.get( - reverse("user-detail", kwargs={"pk": user.id}), - status_code_assertion=status_code_assertion, - ) - if 200 <= response.status_code < 300: - assert response.json() == UserSerializer(user).data - return response - def _get_other_user( self, user: User, @@ -136,6 +110,36 @@ def _get_other_user( return other_user + """ + Retrieve naming convention: + test_retrieve__{user_type}__{other_user_type}__{same_school} + + user_type: The type of user that is making the request. Options: + - teacher: A teacher. + - student: A student. + + other_user_type: The type of user whose data is being requested. Options: + - self: User's own data. + - teacher: Another teacher's data. + - student: Another student's data. + + same_school: A flag for if the other user is from the same school. Options: + - same_school: The other user is from the same school. + - not_same_school: The other user is not from the same school. + """ + + def _retrieve_user( + self, + user: User, + status_code_assertion: APIClient.StatusCodeAssertion = None, + ): + return self.client.retrieve( + "user", + user, + UserSerializer, + status_code_assertion, + ) + def test_retrieve__teacher__self(self): """ Teacher can retrieve their own user data. @@ -152,7 +156,7 @@ def test_retrieve__student__self(self): user = self._login_student() self._retrieve_user(user) - def test_retrieve__teacher__not_self__same_school__teacher(self): + def test_retrieve__teacher__teacher__same_school(self): """ Teacher can retrieve another teacher from the same school. """ @@ -170,7 +174,7 @@ def test_retrieve__teacher__not_self__same_school__teacher(self): self._retrieve_user(other_user) - def test_retrieve__teacher__not_self__same_school__student(self): + def test_retrieve__teacher__student__same_school(self): """ Teacher can retrieve a student from the same school. """ @@ -188,7 +192,7 @@ def test_retrieve__teacher__not_self__same_school__student(self): self._retrieve_user(other_user) - def test_retrieve__student__not_self__same_school__teacher(self): + def test_retrieve__student__teacher__same_school(self): """ Student can not retrieve a teacher from the same school. """ @@ -209,7 +213,7 @@ def test_retrieve__student__not_self__same_school__teacher(self): status_code_assertion=status.HTTP_404_NOT_FOUND, ) - def test_retrieve__student__not_self__same_school__student(self): + def test_retrieve__student__student__same_school(self): """ Student can not retrieve another student from the same school. """ @@ -230,7 +234,7 @@ def test_retrieve__student__not_self__same_school__student(self): status_code_assertion=status.HTTP_404_NOT_FOUND, ) - def test_retrieve__teacher__not_self__not_same_school__teacher(self): + def test_retrieve__teacher__teacher__not_same_school(self): """ Teacher can not retrieve another teacher from another school. """ @@ -251,7 +255,7 @@ def test_retrieve__teacher__not_self__not_same_school__teacher(self): status_code_assertion=status.HTTP_404_NOT_FOUND, ) - def test_retrieve__teacher__not_self__not_same_school__student(self): + def test_retrieve__teacher__student__not_same_school(self): """ Teacher can not retrieve a student from another school. """ @@ -272,7 +276,7 @@ def test_retrieve__teacher__not_self__not_same_school__student(self): status_code_assertion=status.HTTP_404_NOT_FOUND, ) - def test_retrieve__student__not_self__not_same_school__teacher(self): + def test_retrieve__student__teacher__not_same_school(self): """ Student can not retrieve a teacher from another school. """ @@ -293,7 +297,7 @@ def test_retrieve__student__not_self__not_same_school__teacher(self): status_code_assertion=status.HTTP_404_NOT_FOUND, ) - def test_retrieve__student__not_self__not_same_school__student(self): + def test_retrieve__student__student__not_same_school(self): """ Student can not retrieve another student from another school. """ @@ -313,3 +317,47 @@ def test_retrieve__student__not_self__not_same_school__student(self): other_user, status_code_assertion=status.HTTP_404_NOT_FOUND, ) + + """ + List naming convention: + test_list__{user_type} + + user_type: The type of user that is making the request. Options: + - teacher: A teacher. + - student: A student. + """ + + def _list_users( + self, + users: t.Iterable[User], + status_code_assertion: APIClient.StatusCodeAssertion = None, + ): + return self.client.list( + "user", + users, + UserSerializer, + status_code_assertion, + ) + + def test_list__teacher(self): + """ + Teacher can list all the users in the same school. + """ + + user = self._login_teacher() + + self._list_users( + User.objects.filter(new_teacher__school=user.teacher.school) + | User.objects.filter( + new_student__class_field__teacher__school=user.teacher.school + ) + ) + + def test_list__student(self): + """ + Student can list only themselves. + """ + + user = self._login_student() + + self._list_users([user])