Legacy Lobotomy — Refactoring of the Password Reset Request Endpoint

Yevhen Nosolenko
Level Up Coding
Published in
19 min readApr 16, 2024

--

Photo by Towfiqu barbhuiya on Unsplash

This is the 4th tutorial in the series about refactoring a legacy Django project. In this tutorial, I will demonstrate the problems with the current implementation of the reset password functionality in general and the password reset request endpoint in particular.

To get more ideas regarding the project used for this tutorial or check other tutorials in this series, check the introductory article published earlier.

Origin branch and destination branch

  1. If you want to follow the steps described in this tutorial, you should start from the configuring-running-tests-with-pytest branch.
  2. If you want to only see final code state after all changes from this tutorial are applied, you can check the password-reset-request-refactoring branch.

Auth functionality with django-rest-auth package

The project uses the django-rest-auth package for solving problems with authentication and password reset functionality. This package provides a set of generic endpoints and allows to override their behavior easily. For instance, we don’t want /login endpoint to trim value passed in the password field (default behavior). To achieve this we need to do the following changes:

Step 1. Override LoginSerializer class provided by django-rest-auth package.

from rest_auth.serializers import LoginSerializer as RestAuthLoginSerializer

class LoginSerializer(RestAuthLoginSerializer):
password = serializers.CharField(style={'input_type': 'password'}, trim_whitespace=False)

Step 2. Update project settings.

REST_AUTH_SERIALIZERS = {
# django-rest-auth will prioritize value specified in
# this setting over default serializer it uses by default
'LOGIN_SERIALIZER': 'users.serializers.LoginSerializer',
}

That’s it, we are done. Now the django-rest-auth package will use our custom serializer for /login endpoint.

Do we really need tests for endpoints provided by 3rd party package?

No, it doesn’t make sense to create a set of tests for the functionality which we get with the 3rd party packages. But if we need to override some of this functionality, we should create a set of tests to make sure we don’t break anything and the behavior of the components was changed according to our expectations.

Although, all auth functionality is provided by django-rest-auth package, there are 3 endpoints which were overridden for this project, and we should create tests for them:

  1. Password reset request endpoint
  2. Password reset confirm endpoint
  3. Login endpoint

Analysis of the password reset functionality

If we look inside the ~/src/app/urls.py module, we will notice that it looks quite messy. For instance, the endpoint defined with the line

path('auth/password-reset/', PasswordResetView.as_view())

uses the PasswordResetView view class shipped with the django-rest-auth package. Also, there is another line

path('auth/', include('rest_auth.urls'))

which includes all endpoints provided by the django-rest-auth package. If we navigate inside the urls.py file of this package, we will see that it also uses the same view class with another endpoint

url(r'^password/reset/$', PasswordResetView.as_view(),
name='rest_password_reset'),

So it gives us 2 different endpoints which provide identical functionality:

  1. POST /auth/password/reset/ - this endpoint is declared in the django-rest-auth package
  2. POST /auth/password-reset/ - this is a custom endpoint

The same situation is with the password reset confirm functionality. Let’s take a look at the next 2 lines — they both use the same custom PasswordResetConfirmView view class, thus they provide the same functionality.

path('auth/password/reset/confirm/', PasswordResetConfirmView.as_view(),
name='rest_password_reset_confirm')

# ---------- Other endpoints are omitted for simplicity ------------ #

path('auth/password-reset-confirm/<uidb64>/<token>/',
PasswordResetConfirmView.as_view(), name='password_reset_confirm')

These instructions declare 2 different endpoints for password reset confirm functionality:

  1. POST /auth/password/reset/confirm/ - this endpoint just overrides the endpoint available in the django-rest-auth package
  2. POST /auth/password-reset-confirm/<uidb64>/<token>/ - this is a custom endpoint

It doesn’t look nice, so I checked the code of the mobile app which uses this API. I discovered that it uses the following endpoints:

  1. POST /auth/password/reset/ is called when a user initiates a password reset request and the system sends an email with a password reset link
  2. POST /auth/password/reset/confirm/ is called when a user confirms password reset request and sets up new password

It means that we don’t need the following custom endpoints:

  1. POST /auth/password-reset/
  2. POST /auth/password-reset-confirm/<uidb64>/<token>/

Our goal is to make sure that the following endpoints work as expected:

  1. POST /auth/password/reset/
  2. POST /auth/password/reset/confirm/

Note: In this tutorial we will be working on removing the POST /auth/password-reset/ endpoint and fixing and covering with tests POST /auth/password/reset/. The endpoints related to the password reset confirmation is the topic for the next tutorial.

As it was already shown, the password reset email template has an issue which makes it impossible to properly use the password reset functionality in multiple environments because a target domain in the password reset email is hardcoded. It’s the most important problem in this functionality, however there are a few more minor issues as well which we are going to fix.

Prepare required fixtures

Let’s start from creating a few fixtures which we will be using in our tests. If you are not familiar with pytest fixtures, check out the official documentation for details.

Create API Client fixture

For testing API endpoints we need to be able to send HTTP requests from our tests. Django REST Framework provides a great class called APIClient which contains an implementation of an API client which can be used for testing purposes. We will need an instance of this class in all our API tests, so it makes sense to create a custom fixture which can be used later from everywhere.

For this purpose, let’s add a new conftest.py file into the ~/src directory with the following declaration of the client fixture with the function scope:

import pytest


@pytest.fixture
def client():
from rest_framework.test import APIClient
return APIClient()

The APIClient class usually sends requests with the multipart/form-data content type by default. However, for most cases involving RESTful APIs, it is better to use the application/json content type because it is simpler and easier to handle. Therefore, it is a good idea to change the default content type for all our tests, except for those specifically designed to test endpoints that require binary data (for file uploading) or a combination of text and binary data (for form data with uploaded files). In these particular tests, we can directly override the content type as needed.

To change the content type used by default, we can override the REST_FRAMEWORK settings in the ~/src/app/settings/environments/testing.py module by specifying json value for the TEST_REQUEST_DEFAULT_FORMAT key. The following code should be added to the end of the file, right after all imports:

REST_FRAMEWORK = {
**REST_FRAMEWORK,
'TEST_REQUEST_DEFAULT_FORMAT': 'json',
}

Create uid fixture

If we look at email_password_reset_confirm.html template, we will see the following code for generating a password reset link:

<a href="https://legacy-Lobotomy-be.com/password-reset-confirm/{{uid}}/{{token}}/">https://legacy-lobotomy-be.com/password-reset-confirm/{{uid}}/{{token}}/</a>

Parameters uid and token are passed with the context and generated by the django.contrib.auth.forms.PasswordResetForm form used internally by rest_auth.serializers.PasswordResetSerializer serializer. Here is a fragment of the source code which demonstrates how these parameters are generated and what else passed as a context for building the password reset email.

context = {
'email': user_email,
'domain': domain,
'site_name': site_name,
'uid': urlsafe_base64_encode(force_bytes(user.pk)),
'user': user,
'token': token_generator.make_token(user),
'protocol': 'https' if use_https else 'http',
**(extra_email_context or {}),
}

In order to properly test the password reset email generated by the system, we need to be able to generate the same values. Of course, we could copy and paste the code urlsafe_base64_encode(force_bytes(user.pk)) everywhere we need it, but it leads to having duplicates in the code, and it's not good. So it's much better to create a pytest fixture called uid which would encapsulate calling of these methods.

To do this, let’s create a new file fixtures.py inside the users/tests package and put there the content below.

import pytest
from django.utils.encoding import force_bytes
from django.utils.http import urlsafe_base64_encode


@pytest.fixture
def uid():
def uid_internal(value):
return urlsafe_base64_encode(force_bytes(value))

return uid_internal

Now we need to make our new fixture available for all tests within the users package. For this purpose, let's create a new file conftest.py in the root of the users app with the following content:

from .tests.fixtures import uid  # noqa

Create UserFactory class, user and user_factory fixtures

Very often we need to check how our API endpoints interacts with database. In order to initialize some database entries for using in tests, we can create specific factory classes using capabilities provided by thefactory_boy package. For covering the password reset request endpoint with tests, we need to be able to create users. For this purpose we need to create UserFactory class.

Step 1. Create a new package factories in the users app.

Step 2. Add a new Python module user.py inside the factories package with the following content:

import factory
from django.contrib.auth import get_user_model

# 1. Return the User model that is active in this project
User = get_user_model()


# 2. Create a class derived from factory.django.DjangoModelFactory to create a factory which can work with Django models
class UserFactory(factory.django.DjangoModelFactory):
# 3. Specify a model this factory can work with
class Meta:
model = User

# 4. Define the rule for data initialization.
# In this definition, every next user will be assigned an email with a number one greater than the previous email:
# fake-user-0@fakemail.com, fake-user-1@fakemail.com, fake-user-2@fakemail.com, ..., fake-user-N@fakemail.com
email = factory.Sequence(lambda n: f'fake-user-{n}@fakemail.com')

# 5. Applies make_password() to the clear-text argument before to generate the object. Use 123456 as a default passowrd.
password = factory.django.Password('123456')

Step 3. Add the line below to the __init__.py file of the factories package:

from .user import UserFactory

Step 4. Now we need to register our UserFactory class to get 2 fixtures, user and user_factory, which we can use in the tests within the `users` app. For this purpose add the following code into the conftest.py file in the root of the users app:

from pytest_factoryboy import register

from .factories import UserFactory

register(UserFactory)

After these changes, this file should look like the following:

from pytest_factoryboy import register

from .factories import UserFactory
from .tests.fixtures import uid # noqa

register(UserFactory)

Preparing end-to-end tests

Since there are no tests yet, and we want to make sure that everything that worked before our refactoring still works, we have to start with preparing end-to-end tests for the functionality we intend to change. In this section we will be working with the test_password_reset.py module created in the previous steps. Let's start with deleting all the content from this module.

Then, we will need the following imports for these test class:

# 1. This one is needed for mocking default_token_generator object, so that we can use predefined token value in the tests
from unittest.mock import patch

# 2. Provides useful decorators for test methods
import pytest

# 3. An instance of a token generator which is used by the `django-rest-auth` package for generating a unique password reset token
from django.contrib.auth.tokens import default_token_generator

# 4. A Django REST Framework module which contains constants for HTTP codes
from rest_framework import status

Let’s create a new class which will serve as a container for all our tests for this specific endpoint and declare a class variable with URI of the tested endpoint:

class TestPasswordReset:
reset_password_endpoint = '/auth/password/reset/'

Validation tests

When we start working on a new API endpoint or need to implement tests for existing one, it’s always a good idea to start with checking validation rules and make sure that they all satisfied or fix discovered issues otherwise.

Test #1. When request body is empty, server should return 400 Bad Request HTTP error. Put the following test method into the test class:

# 1. Request pytest to inject our client fixture, so we can send HTTP requests from the test
def test_when_email_field_is_absent_then_400_bad_request_status_should_be_returned(self, client):
# 2. Declare HTTP request content
body = {}

# 3. Send a post request to the /auth/password/reset/ endpoint with empty body
response = client.post(self.reset_password_endpoint, body)

# 4. Verify that the response status code is 400 Bad Request
assert response.status_code == status.HTTP_400_BAD_REQUEST

# 5. Verify that the response body contains detailed information about invalid field
assert response.json() == {'email': ['This field is required.']}

Test #2. When the email field exists and has invalid value, server should return 400 Bad Request HTTP error. Put the following test method into the test class:

@pytest.mark.parametrize(
'email, expected_message',
[
('', 'This field may not be blank.'), # email field is empty
('invalid-email', 'Enter a valid email address.'), # email field doesn't have @ + domain part
('invalid-email@', 'Enter a valid email address.'), # email field doesn't have a domain part
('invalid-email@fake', 'Enter a valid email address.'), # email field has an incorrect domain
# email field is too long
(
'email' + 'a' * (255 - len('email@fake.com')) + '@fake.com',
"Ensure this value has at most 254 characters (it has 255)."
)
]
)
def test_when_request_has_invalid_data_then_400_bad_request_status_should_be_returned(
self, email, expected_message, client
):
# 1. Send a post request to the /auth/password/reset/ endpoint with email value injected into the test method
response = client.post(self.reset_password_endpoint, {'email': email})

assert response.status_code == status.HTTP_400_BAD_REQUEST

# 2. Verify that the response body contains an expected message injected into the test method and the same
# format for all validation errors.
assert response.json() == {'email': [expected_message]}

In this test we use a powerful parametrize pytest decorator which allows us to define a set of inputs and inject them as arguments of the test method.

Examining and fixing the problem with the format of validation error

If we run the tests, we will see that all tests pass except for the one which validates the length of the email field. In the test run output we can find the reason:

>       assert response.json() == {'email': [expected_message]}
E AssertionError: assert {'email': {'e... has 255).']}} == {'email': ['E...t has 255).']}
E Differing items:
E {'email': {'email': ['Ensure this value has at most 254 characters (it has 255).']}} != {'email': ['Ensure this value has at most 254 characters (it has 255).']}

As we can see, for this validation server returned the response in the different format. Definitely it’s not good, so the reason should be found and fixed.

For this endpoint was created a custom PasswordResetSerializer serializer class inherited from the PasswordResetSerializer class provided by the django-rest-auth package. Local version of the serializer overrides only email templates which don’t impact the validation, so the problem must be in the original serializer. After some debugging I found the reason of this issue, which is in the validate_email method of the original serializer:

def validate_email(self, value):
# Create PasswordResetForm with the serializer
self.reset_form = self.password_reset_form_class(data=self.initial_data)
if not self.reset_form.is_valid():
raise serializers.ValidationError(self.reset_form.errors) # <--- the problem is here

return value

self.reset_form.errors property is a dictionary. Each key of this dictionary is a name of a form field that has at least one issue. Values of this dictionary are lists of all validation errors related to a specific form field. Check the following JSON object with an example of how this property looks:

{
"email": [
"Validation error 1",
"Validation error 2",
"Validation error N"
]
}

Once ValidationError is thrown in the serializer's validate_email method, the serializer will create a new entry in its own errors dictionary and use the entire self.reset_form.errors as a validation error. So the response of the server will have the following format:

{
"email": {
"email": [
"Validation error 1",
"Validation error 2",
"Validation error N"
]
}
}

It’s exactly our case. That’s why we need to override this method in our custom PasswordResetSerializer serializer to make it throw ValidationError error with passing a list of validation errors of the email field instead. Below is the overridden version of the method:

def validate_email(self, value):
self.reset_form = self.password_reset_form_class(data=self.initial_data)
# 1. Check that the email field is in the dictionary
if not self.reset_form.is_valid() and 'email' in self.reset_form.errors:
# 2. Raise ValidationError with self.reset_form.errors['email'] instead of self.reset_form.errors
raise serializers.ValidationError(self.reset_form.errors['email'])

return value

Now all tests should pass. We are done with validation tests, now it’s time to add some tests for sending email to a user with specified email.

Successful path tests

Once the tests for validation are done, we should make sure that the endpoint behaves as expected when valid data was passed. Here are the cases which we should cover in order to verify that the endpoint works well.

Test #1. When a user with a specified email doesn’t exist, the endpoint should return 200 OK HTTP code, but the email shouldn't be sent. Put the code below at the end of the TestPasswordReset class and run tests again. All tests should pass.

@pytest.mark.django_db
# 1. We use faker fixture to get access to the faker instance which allows us to generate random good-looking data
# 2. We use mailoutbox fixture to check all the emails sent by the system without sending them
def test_when_user_with_specified_email_does_not_exist_then_email_should_not_be_sent(
self, faker, client, mailoutbox
):
# 3. Generate a random email address to make the request body valid
response = client.post(self.reset_password_endpoint, {'email': faker.email()})

# 4. Verify that the status code is 200 and an email wasn't sent
assert response.status_code == status.HTTP_200_OK
assert len(mailoutbox) == 0

Test #2. When a user with a specified email exists, the endpoint should return 200 OK HTTP code and send an email using a custom template. Put the code below at the end of the TestPasswordReset class and run tests again. All tests should pass.

class TestPasswordReset:
# ---------- Other tests are omitted for simplicity ------------ #
@pytest.mark.django_db
# 1. Replace the make_token method with a mock, so that we can use a predefined value instead
@patch.object(default_token_generator, 'make_token')
# 2. Inject our custom uid fixtures which allows us to convert a user pk to the format used in the email context
# 3. By specifying the user argument, we ask pytest to inject a user instance created with the UserFactory class
def test_when_user_with_specified_email_exists_then_password_reset_email_should_be_sent(
self, make_token_mock, faker, uid, user, client, mailoutbox
):
# 4. Generate a token which should be sent to the user and make it a return value for mocked make_token method
password_reset_token = faker.pystr()
make_token_mock.return_value = password_reset_token

# 5. Make a request with the user's email to make sure that the user with such email can be found
response = client.post(self.reset_password_endpoint, {'email': user.email})

assert response.status_code == status.HTTP_200_OK
assert response.json() == {'detail': 'Password reset e-mail has been sent.'}

[email] = mailoutbox

# 6. We expect that an HTML template will be sent to a user (by default only plain text is sent)
[alternative_content, alternative_type] = email.alternatives[0]

# 7. Check that the custom template is used as a subject
assert email.subject == 'Password reset on Legacy Lobotomy'

# 8. Verify that the email was sent to a correct user
assert email.to == [user.email]

# 9. Check the body property to verify what will see users in case their mail client doesn't support HTML
assert email.body.strip() == f'''
You're receiving this email because you requested a password reset for your user account at example.com.

Please go to the following page and choose a new password:

http://example.com/auth/password-reset-confirm/{uid(user.pk)}/{password_reset_token}/

Your username, in case you’ve forgotten: {user.email}

Thanks for using our site!

The example.com team
'''.strip()

# 10. Verify that a custom HTML template was used for this email
assert alternative_content.strip() == f'''
You're receiving this email because you requested a password reset for your user account.
<br>
Please click on the following link and choose a new password:
<br>
<a href="https://legacy-Lobotomy-be.com/password-reset-confirm/{uid(user.pk)}/{password_reset_token}/">https://legacy-lobotomy-be.com/password-reset-confirm/{uid(user.pk)}/{password_reset_token}/</a>
<br>
Your email, in case you’ve forgotten: {user.email}
<br>
Thanks for using our site!
'''.strip()
assert alternative_type == 'text/html'

If we look carefully at this test, we will notice a few more problems with this email:

  1. We use a hardcoded site name in the email subject. It’s not a critical problem, but sometimes we prefer to use different names depending on the environment. In our case, we might want to use the subjects like “Password reset on Legacy Lobotomy Development” in the development environment or “Password reset on Legacy Lobotomy Staging” in the staging environment.
  2. Content of the email.body property is different from the content of the alternative_content variable. This means that if a user will open this email with a mail client which doesn't support HTML templates, they will see a default Django template and incorrect password reset link. It's a more serious problem.

All the found issues with the password reset email will be fixed in the next section.

Bug fixing

Let’s take a closer look at the context used for rendering the password reset template from the django.contrib.auth.forms.PasswordResetForm form (some irrelevant fields are omitted).

context = {
'domain': domain,
'site_name': site_name,
'protocol': 'https' if use_https else 'http',
}

Here we can see that inside a template we can get domain, site_name and protocol parameters which we can use instead of hardcoded URL. But how does the form get them? Under the hood, it uses Django sites framework. To make these 3 parameters work for us correctly, we need to configure a single entry of theSite model and specify its identifier in the SITE_ID parameter in the system settings file. We need to specify correct domain and site name, and then we can use this information in the system. To make it work in several environments (development, staging, production), we need to configure an instance of this model for each of them. For testing changed behavior, we need to have an ability to create random instances of the Site model. The most convenient way to do that is to create a factory class for this model and register it with pytest-factoryboy to be able to use site and site_factory fixtures in the tests.

Create SiteFactory class, site and site_factory fixtures

Although SiteFactory class isn't related to user functionality, I will create this factory class in the users app. Firstly, there are no good place for this class in the system. Secondly, most probably it will be used only in password reset tests.

Step 1. Add a new Python file site.py inside the users/factories package with the following content:

import factory
from django.contrib.sites.models import Site


class SiteFactory(factory.django.DjangoModelFactory):
class Meta:
model = Site

domain = factory.Faker('domain_name')
name = factory.Faker('sentence', nb_words=3)

Step 2. Add this line to the __init__.py file of the users/factories package:

from .site import SiteFactory

Step 3. Now we need to register our SiteFactory class to get 2 fixtures, site and site_factory, which we can use in the tests within the users app. For this purpose add the following code into the conftest.py file in the root of the users app:

# 1. Extend import command with SiteFactory class
from .factories import SiteFactory, UserFactory

# 2. Register SiteFactory class
register(SiteFactory)

Fix issue with incorrect template used for mail clients without HTML support

First of all, let’s change the test test_when_user_with_specified_email_exists_then_password_reset_email_should_be_sent to expect another value of the email.body property. We need to replace in this test assertion line assert email.body.strip() == ... with this code:

class TestPasswordReset:
# ---------- Other tests are omitted for simplicity ------------ #
def test_when_user_with_specified_email_exists_then_password_reset_email_should_be_sent(...)
# ---------- Other code is omitted for simplicity ------------ #
assert email.body.strip() == f'''
You're receiving this email because you requested a password reset for your user account.

Please click on the following link and choose a new password:

https://legacy-lobotomy-be.com/password-reset-confirm/{uid(user.pk)}/{password_reset_token}/

Your email, in case you've forgotten: {user.email}

Thanks for using our site!
'''.strip()

Note: When you copy and paste the code into PyCharm, make sure that indentations were pasted properly as in the example. IDEs can apply formatting automatically when the code pasted, and sometimes they may not recognize indentations properly and you will have problems with running the code.

Now, if we run tests, we will see that it fails because this assertion doesn’t pass. We changed the test to expect another value of the email.body property, but we didn't do anything with the used template.

In order to override the default password reset template, we need to add a new file with name password_reset_email.html into the src/templates/registration/ directory. In our case it will have the content as shown below:

{% load i18n %}{% autoescape off %}
{% blocktranslate %}You're receiving this email because you requested a password reset for your user account.{% endblocktranslate %}

{% translate "Please click on the following link and choose a new password:" %}
{% block reset_link %}
https://legacy-lobotomy-be.com/password-reset-confirm/{{uid}}/{{token}}/
{% endblock %}
{% translate "Your email, in case you've forgotten:" %} {{ user.get_username }}

{% translate "Thanks for using our site!" %}

{% endautoescape %}

If we run tests now, we will see that all tests pass. Template was updated, the issue was fixed. Let’s move to another problem.

Fix issue with hardcoded schema, domain and site name

For solving this problem, we will start from changing the test_when_user_with_specified_email_exists_then_password_reset_email_should_be_sent test. Here is what we need to do:

Step 1. Add parametrize decorator with the list of different values of the secure parameter and corresponding schema values. This will help us to check that our templates use correct schema passed in the context from the form.

@pytest.mark.parametrize('secure, expected_schema', [(True, 'https'), (False, 'http')])

Step 2. Add new arguments to the test method.

settings, secure, expected_schema, site

Here we want pytest to inject settings object, so we can override any of the values. Arguments secure and expected_schema will be taken from the parametrize decorator and site will represent an instance of the Site model which we need to verify that the password reset functionality will be using properties of the site specified in the settings.

Step 3. At the beginning of the test add the line below to override SITE_ID setting to point to our test site passed as an argument.

settings.SITE_ID = site.pk

Step 4. Specify if we need the API client to use HTTP or HTTPS schema for the request by replacing this line

response = client.post(self.reset_password_endpoint, {'email': user.email})

with this one

response = client.post(self.reset_password_endpoint, {'email': user.email}, secure=secure)

Step 5. We expect that the email subject will use a configured site’s name. To verify that, replace this line

assert email.subject == 'Password reset on Legacy Lobotomy'

with this one

assert email.subject == f'Password reset on {site.name}'

Step 6. In the assertions of email.body and alternative_content:

  1. Replace hardcoded https schema with {expected_schema} template
  2. Replace hardcoded legacy-Lobotomy-be.com domain with {site.domain} template

Step 7. Run the test. We can see, that running this test caused 2 tests run: True-https and False-http. That's because we used the decorator parametrize with a set of 2 different parameters. Both tests fails because our email still has a hardcoded site name. To fix this, we need to update password_reset_subject.txt file which is used as a subject template. Replace this line

{% blocktranslate %}Password reset on Legacy Lobotomy{% endblocktranslate %}

with this one

{% blocktranslate %}Password reset on {{ site_name }}{% endblocktranslate %}

Step 8. Run the test again. Now we can see that both subtests pass the assertion for subject. But now they both fail on body assertions because our template still uses a hardcoded domain and schema. Let’s update both password_reset_email.html and email_password_reset_confirm.html templates:

  1. Replace hardcoded https schema with {{ protocol }} template
  2. Replace hardcoded legacy-lobotomy-be.com domain with {{ domain }} template

Step 9. If we run the test again, we will see that both subtests pass. It means that we fixed one more issue. The final version of the test_when_user_with_specified_email_exists_then_password_reset_email_should_be_sent:

class TestPasswordReset:
# ---------- Other tests are omitted for simplicity ------------ #
@pytest.mark.django_db
@patch.object(default_token_generator, 'make_token')
@pytest.mark.parametrize('secure, expected_schema', [(True, 'https'), (False, 'http')])
def test_when_user_with_specified_email_exists_then_password_reset_email_should_be_sent(
self, make_token_mock, faker, uid, user, client, mailoutbox, settings, secure, expected_schema, site
):
settings.SITE_ID = site.pk

password_reset_token = faker.pystr()
make_token_mock.return_value = password_reset_token

response = client.post(self.reset_password_endpoint, {'email': user.email}, secure=secure)

assert response.status_code == status.HTTP_200_OK
assert response.json() == {'detail': 'Password reset e-mail has been sent.'}

[email] = mailoutbox
[alternative_content, alternative_type] = email.alternatives[0]

assert email.subject == f'Password reset on {site.name}'
assert email.to == [user.email]

assert email.body.strip() == f'''
You're receiving this email because you requested a password reset for your user account.

Please click on the following link and choose a new password:

{expected_schema}://{site.domain}/password-reset-confirm/{uid(user.pk)}/{password_reset_token}/

Your email, in case you've forgotten: {user.email}

Thanks for using our site!
'''.strip()

assert alternative_content.strip() == f'''
You're receiving this email because you requested a password reset for your user account.
<br>
Please click on the following link and choose a new password:
<br>
<a href="{expected_schema}://{site.domain}/password-reset-confirm/{uid(user.pk)}/{password_reset_token}/">{expected_schema}://{site.domain}/password-reset-confirm/{uid(user.pk)}/{password_reset_token}/</a>
<br>
Your email, in case you’ve forgotten: {user.email}
<br>
Thanks for using our site!
'''.strip()
assert alternative_type == 'text/html'

Refactoring

Since we already have tests which validate actual password reset email, we can make a tiny refactoring of the PasswordResetSerializer serializer.

Now we can remove subject_template_name from the object returned by its get_email_options method. We need to override this value if we want to use custom path or name of the subject template. But in our case, we use the same path and name as used by default.

Also, we can rename RAPasswordResetSerializer to RestAuthPasswordResetSerializer. It doesn't affect functionality, but it makes the origin of this serializer more obvious.

Also, we can delete this path from the ~/src/app/urls.py file because it's not used by the app:

path('auth/password-reset/', PasswordResetView.as_view()),

Now let’s run all the tests and make sure they all pass.

Conclusion

Someone might say that putting too much effort into fixing such simple issues is unnecessary. Sometimes, creating extensive tests for making minor changes might seem like a time-consuming task. This feeling is especially strong in legacy projects with no test coverage. Even advocates of automated testing might feel tempted to ignore the testing part and just make a hotfix. However, as we continue to add new tests, fixtures, and factories, the time needed for creating tests for other parts of the system reduces, and the stability of the system grows. It’s definitely worth investing time and effort in creating tests if you have to maintain the system long-term.

In this tutorial, we’ve added the first set of tests, which covers a very important part of the system. In the next tutorials, we will continue working on the password reset and sign-in functionality.

--

--

Python enthusiast and seasoned developer fallen in love with transforming legacy code into robust solutions, passionate about refactoring, auto-testing and TDD.