Skip to content

Commit

Permalink
initial unit tests
Browse files Browse the repository at this point in the history
  • Loading branch information
SKairinos committed Dec 8, 2023
1 parent bb7750b commit 8d15d0a
Show file tree
Hide file tree
Showing 11 changed files with 431 additions and 95 deletions.
2 changes: 1 addition & 1 deletion codeforlife/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
© Ocado Group
Created on 04/12/2023 at 14:36:56(+00:00).
Base models.
Base models. Tests at: codeforlife.user.tests.models.test_abstract
"""

import typing as t
Expand Down
10 changes: 9 additions & 1 deletion codeforlife/tests/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,10 @@
from .api import APITestCase, APIClient
"""
© Ocado Group
Created on 08/12/2023 at 18:05:20(+00:00).
All test helpers.
"""

from .api import APIClient, APITestCase
from .cron import CronTestCase, CronTestClient
from .model import ModelTestCase
40 changes: 40 additions & 0 deletions codeforlife/tests/model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""
© Ocado Group
Created on 08/12/2023 at 18:05:47(+00:00).
Test helpers for Django models.
"""

import typing as t

from django.db.models import Model
from django.db.utils import IntegrityError
from django.test import TestCase

AnyModel = t.TypeVar("AnyModel", bound=Model)


class ModelTestCase(TestCase, t.Generic[AnyModel]):
"""Base for all model test cases."""

@classmethod
def get_model_class(cls) -> t.Type[AnyModel]:
"""Get the model's class.
Returns:
The model's class.
"""

# pylint: disable-next=no-member
return t.get_args(cls.__orig_bases__[0])[ # type: ignore[attr-defined]
0
]

def assert_raises_integrity_error(self, *args, **kwargs):
"""Assert the code block raises an integrity error.
Returns:
Error catcher that will assert if an integrity error is raised.
"""

return self.assertRaises(IntegrityError, *args, **kwargs)
8 changes: 6 additions & 2 deletions codeforlife/user/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 3.2.20 on 2023-12-08 15:41
# Generated by Django 3.2.20 on 2023-12-08 16:55

from django.conf import settings
import django.core.validators
Expand Down Expand Up @@ -30,7 +30,7 @@ class Migration(migrations.Migration):
('email', models.EmailField(blank=True, max_length=254, null=True, unique=True, verbose_name='email address')),
('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')),
('is_active', models.BooleanField(default=False, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')),
('date_joined', models.DateTimeField(default=django.utils.timezone.now, editable=False, verbose_name='date joined')),
('otp_secret', models.CharField(editable=False, help_text='Secret used to generate a OTP.', max_length=40, null=True, verbose_name='OTP secret')),
('last_otp_for_time', models.DateTimeField(editable=False, help_text='Used to prevent replay attacks, where the same OTP is used for different times.', null=True, verbose_name='last OTP for-time')),
],
Expand Down Expand Up @@ -205,6 +205,10 @@ class Migration(migrations.Migration):
name='authfactor',
unique_together={('user', 'type')},
),
migrations.AddConstraint(
model_name='user',
constraint=models.CheckConstraint(check=models.Q(models.Q(('student__isnull', False), ('teacher__isnull', True)), models.Q(('student__isnull', True), ('teacher__isnull', False)), models.Q(('student__isnull', True), ('teacher__isnull', True)), _connector='OR'), name='user__profile'),
),
migrations.AddConstraint(
model_name='user',
constraint=models.CheckConstraint(check=models.Q(models.Q(('email__isnull', False), ('teacher__isnull', False)), models.Q(('email__isnull', True), ('student__isnull', False)), models.Q(('email__isnull', False), ('student__isnull', True), ('teacher__isnull', True)), _connector='OR'), name='user__email'),
Expand Down
49 changes: 32 additions & 17 deletions codeforlife/user/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
from django.db import models
from django.db.models import Q
from django.db.models.query import QuerySet
from django.db.utils import IntegrityError
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from django_stubs_ext.db.models import TypedModelMeta
Expand All @@ -40,6 +41,11 @@ class Manager(BaseUserManager["User"]):
Custom user manager for custom user model.
"""

def create(self, **kwargs):
"""Prevent calling create to maintain data integrity."""

raise IntegrityError("Must call create_user instead.")

def _create_user(
self,
password: str,
Expand All @@ -57,41 +63,31 @@ def _create_user(
user.save(using=self._db)
return user

def create_user(
self,
password: str,
email: t.Optional[str] = None,
**fields,
):
def create_user(self, password: str, first_name: str, **fields):
"""Create a user.
https://github.com/django/django/blob/19bc11f636ca2b5b80c3d9ad5b489e43abad52bb/django/contrib/auth/models.py#L149C9-L149C20
Args:
password: The user's non-hashed password.
email: The user's email address.
first_name: The user's first name.
Returns:
A user instance.
"""

fields.setdefault("is_staff", False)
fields.setdefault("is_superuser", False)
return self._create_user(password, email, **fields)
return self._create_user(password, first_name=first_name, **fields)

def create_superuser(
self,
password: str,
email: t.Optional[str] = None,
**fields,
):
def create_superuser(self, password: str, first_name: str, **fields):
"""Create a super user.
https://github.com/django/django/blob/19bc11f636ca2b5b80c3d9ad5b489e43abad52bb/django/contrib/auth/models.py#L154C9-L154C25
Args:
password: The user's non-hashed password.
email: The user's email address.
first_name: The user's first name.
Raises:
ValueError: If is_staff is not True.
Expand All @@ -109,7 +105,7 @@ def create_superuser(
if fields.get("is_superuser") is not True:
raise ValueError("Superuser must have is_superuser=True.")

return self._create_user(password, email, **fields)
return self._create_user(password, first_name=first_name, **fields)

objects: Manager = Manager.from_queryset( # type: ignore[misc]
AbstractModel.QuerySet
Expand All @@ -125,7 +121,7 @@ def create_superuser(
blank=True,
)

# QUES: is last name required for teachers?
# TODO: is last name required for teachers?
last_name = models.CharField(
_("last name"),
max_length=150,
Expand Down Expand Up @@ -160,6 +156,7 @@ def create_superuser(
date_joined = models.DateTimeField(
_("date joined"),
default=timezone.now,
editable=False,
)

otp_secret = models.CharField(
Expand Down Expand Up @@ -202,6 +199,24 @@ class Meta(TypedModelMeta):
verbose_name = _("user")
verbose_name_plural = _("users")
constraints = [
models.CheckConstraint(
check=(
# pylint: disable-next=unsupported-binary-operation
Q(
teacher__isnull=True,
student__isnull=False,
)
| Q(
teacher__isnull=False,
student__isnull=True,
)
| Q(
teacher__isnull=True,
student__isnull=True,
)
),
name="user__profile",
),
models.CheckConstraint(
check=(
# pylint: disable-next=unsupported-binary-operation
Expand Down
107 changes: 107 additions & 0 deletions codeforlife/user/tests/models/test_abstract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""
© Ocado Group
Created on 08/12/2023 at 15:48:38(+00:00).
"""

from unittest.mock import patch

from django.test import TestCase
from django.utils import timezone

from ...models import User


class TestAbstract(TestCase):
"""
Tests the abstract model inherited by other models.
Abstract model path: codeforlife.models
"""

fixtures = [
"users",
"teachers",
]

def setUp(self):
self.john_doe = User.objects.get(pk=1)
self.jane_doe = User.objects.get(pk=2)

def test_delete(self):
"""
Deleting a model instance sets its deletion schedule.
"""

now = timezone.now()
with patch.object(timezone, "now", return_value=now) as timezone_now:
self.john_doe.delete()

assert timezone_now.call_count == 2
assert self.john_doe.delete_after == now + User.delete_wait
assert self.john_doe.last_saved_at == now

def test_objects__delete(self):
"""
Deleting a set of models in a query sets their deletion schedule.
"""

now = timezone.now()
with patch.object(timezone, "now", return_value=now) as timezone_now:
User.objects.filter(
pk__in=[
self.john_doe.pk,
self.jane_doe.pk,
]
).delete()

assert timezone_now.call_count == 2

self.john_doe.refresh_from_db()
assert self.john_doe.delete_after == now + User.delete_wait
assert self.john_doe.last_saved_at == now

self.jane_doe.refresh_from_db()
assert self.jane_doe.delete_after == now + User.delete_wait
assert self.jane_doe.last_saved_at == now

def test_objects__create(self):
"""
Creating a model records when it was first saved.
"""

now = timezone.now()
with patch.object(timezone, "now", return_value=now) as timezone_now:
user = User.objects.create_user(
password="password",
first_name="first_name",
last_name="last_name",
email="[email protected]",
)

assert timezone_now.call_count == 1
assert user.last_saved_at == now

def test_objects__bulk_create(self):
"""
Bulk creating models records when they were first saved.
"""

now = timezone.now()
with patch.object(timezone, "now", return_value=now) as timezone_now:
users = User.objects.bulk_create(
[
User(
first_name="first_name_1",
last_name="last_name_1",
email="[email protected]",
),
User(
first_name="first_name_2",
last_name="last_name_2",
email="[email protected]",
),
]
)

assert timezone_now.call_count == 2
assert all(user.last_saved_at == now for user in users)
18 changes: 18 additions & 0 deletions codeforlife/user/tests/models/test_klass.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""
© Ocado Group
Created on 08/12/2023 at 17:43:11(+00:00).
"""

from ....tests import ModelTestCase
from ...models import Class


class TestClass(ModelTestCase[Class]):
"""Tests the Class model."""

def test_id__validators__regex(self):
"""
Check the regex validation of a class' ID.
"""

raise NotImplementedError() # TODO
18 changes: 18 additions & 0 deletions codeforlife/user/tests/models/test_school.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
"""
© Ocado Group
Created on 08/12/2023 at 17:43:11(+00:00).
"""

from ....tests import ModelTestCase
from ...models import School


class TestSchool(ModelTestCase[School]):
"""Tests the School model."""

def test_constraints__no_uk_county_if_country_not_uk(self):
"""
Cannot have set a UK county if the country is not set to UK.
"""

raise NotImplementedError() # TODO
46 changes: 46 additions & 0 deletions codeforlife/user/tests/models/test_student.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""
© Ocado Group
Created on 08/12/2023 at 17:43:11(+00:00).
"""

from ....tests import ModelTestCase
from ...models import Student


class TestStudent(ModelTestCase[Student]):
"""Tests the Student model."""

def test_objects__create(self):
"""
Create a student.
"""

raise NotImplementedError() # TODO

def test_objects__bulk_create(self):
"""
Bulk create many students.
"""

raise NotImplementedError() # TODO

def test_objects__create_user(self):
"""
Create a user with a student profile.
"""

raise NotImplementedError() # TODO

def test_objects__bulk_create_users(self):
"""
Bulk create many users with a student profile.
"""

raise NotImplementedError() # TODO

def test_teacher(self):
"""
Get student's teacher.
"""

raise NotImplementedError() # TODO
Loading

0 comments on commit 8d15d0a

Please sign in to comment.