How to create a fullstack application using Django and Python Part 20

Tuesday, September 24, 2024 at 9:24 PM | 18 min read

Last modified on Monday, May 25, 2026 at 3:41 PM

#fullstack development, #macOS, #django, #django 5.1, #python3, #authentication, #code refactoring, #code restructure, #debug code, #default password reset, #fix urls, #fix redirects, #resolve, #reverse, #security, #series, #tests, #unittest

Man working in dark attic of house with headlamp for light source

Photo by Greg Rosenke on unsplash.com

Important Note: Before committing anything to Git or pushing anything to remote, please visit How to create a fullstack application using Django and Python Part 4 where I discuss how to add the python-dotenv package to the Django site and why it is crucial to do it. This article assumes you have a working knowledge of Git.

Table of Contents

Re-organizing the accounts and boards tests

Before we continue developing Django Boards, we have to address our current failed tests from part 19 and fix them.

But before we even do that, I re-organized the accounts and boards tests. I grouped the tests by view. accounts/tests now contains the following files:

- accounts/ - tests/ - __pycache__/ - __init__.py - test_form_signup_test.py - test_mail_password_reset_tests.py - test_view_password_change_tests.py - test_view_password_reset_tests.py - test_view_signup_tests.py

And the boards/tests contains:

- boards/ - tests/ - __pycache__/ - __init__.py - test_templatetags.py - test_view_board_topics_tests.py - test_view_index_tests.py - test_view_new_topic_tests.py - test_view_reply_topic_tests.py - test_view_topic_posts_tests.py

accounts/tests

accounts/tests/test_form_signup_test.py

# accounts/tests/test_form_signup_test.py # I comment out this code from django.test import TestCase from ..forms import SignUpFor class SignUpFormTest(TestCase): def test_form_has_fields(self): form = SignUpForm() expected = ['username', 'email', 'password1', 'password2', ] actual = list(form.fields) self.assertSequenceEqual(expected, actual)

accounts/tests/test_mail_password_reset_tests.py

# accounts/tests/test_mail_password_reset_tests.py from django.contrib.auth.models import User from django.core import mail from django.test import TestCase from django.urls import reverse class PasswordResetMailTests(TestCase): def setUp(self): User.objects.create_user(username='john', email='john@doe.com', password='123') self.response = self.client.post(reverse('password_reset'), {'email': 'john@doe.com'}) self.email = mail.outbox[0] def test_email_subject(self): self.assertEqual('[Django Boards] Please reset your password', self.email.subject) def test_email_body(self): context = self.response.context token = context.get('token') uid = context.get('uid') password_reset_token_url = reverse('password_reset_confirm', kwargs={ 'uidb64': uid, 'token': token }) self.assertIn(password_reset_token_url, self.email.body) self.assertIn('john', self.email.body) self.assertIn('john@doe.com', self.email.body) def test_email_to(self): self.assertEqual(['john@doe.com', ], self.email.to)

accounts/tests/test_view_password_change_tests.py

# accounts/tests/test_view_password_change_tests.py from django.contrib.auth import views as auth_views from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth.models import User from django.test import TestCase from django.urls import resolve, reverse class PasswordChangeTests(TestCase): def setUp(self): username = 'john' password = 'secret123' User.objects.create_user(username=username, email='john@doe.com', password=password) url = reverse('password_change') self.client.login(username=username, password=password) self.response = self.client.get(url) def test_status_code(self): self.assertEqual(self.response.status_code, 200) def test_url_resolves_correct_view(self): view = resolve('/password_change/') self.assertEqual(view.func.view_class, auth_views.PasswordChangeView) def test_csrf(self): self.assertContains(self.response, 'csrfmiddlewaretoken') def test_contains_form(self): form = self.response.context_data.get('form') self.assertIsInstance(form, PasswordChangeForm) print(self.assertIsInstance(form, PasswordChangeForm), 'is this change form rendering?') def test_form_inputs(self): ''' The view must contain four inputs: csrf, old_password, new_password1, new_password2 ''' self.assertContains(self.response, '<input', 5) self.assertContains(self.response, 'type="password"', 3) class LoginRequiredPasswordChangeTests(TestCase): def test_redirection(self): url = reverse('password_change') login_url = reverse('login') response = self.client.get(url) self.assertRedirects(response, f'{login_url}?next={url}') class PasswordChangeTestCase(TestCase): ''' Base test case for form processing accepts a `data` dict to POST to the view. ''' def setUp(self, data={}): self.user = User.objects.create_user(username='john', email='john@doe.com', password='old_password') self.url = reverse('password_change') self.client.login(username='john', password='old_password') self.response = self.client.post(self.url, data) class SuccessfulPasswordChangeTests(PasswordChangeTestCase): def setUp(self): super().setUp({ 'old_password': 'old_password', 'new_password1': 'new_password', 'new_password2': 'new_password', }) def test_redirection(self): ''' A valid form submission should redirect the user ''' self.assertRedirects(self.response, reverse('password_change_done')) def test_password_changed(self): ''' refresh the user instance from database to get the new password hash updated by the change password view. ''' self.user.refresh_from_db() self.assertTrue(self.user.check_password('new_password')) def test_user_authentication(self): ''' Create a new request to an arbitrary page. The resulting response should now have an `user` to its context, after a successful sign up. ''' response = self.client.get(reverse('index')) user = response.context.get('user') self.assertTrue(user.is_authenticated) class InvalidPasswordChangeTests(PasswordChangeTestCase): def test_status_code(self): ''' An invalid form submission should return to the same page ''' self.assertEqual(self.response.status_code, 200) def test_form_errors(self): form = self.response.context.get('form') self.assertTrue(form.errors) def test_didnt_change_password(self): ''' refresh the user instance from the database to make sure we have the latest data. ''' self.user.refresh_from_db() self.assertTrue(self.user.check_password('old_password'))

accounts/tests/test_view_password_reset_tests.py

# accounts/tests/test_view_password_reset_tests.py from django.contrib.auth import views as auth_views from django.contrib.auth import authenticate from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm from django.contrib.auth.models import User from django.contrib.auth.tokens import default_token_generator from django.core import mail from django.test import TestCase from django.urls import resolve, reverse from django.utils.encoding import force_bytes from django.utils.http import urlsafe_base64_encode import bs4 import soupsieve as sv class PasswordResetTests(TestCase): def setUp(self): user = User.objects.create_user(username='john', email='john@doe.com', password='123') url = reverse('password_reset') self.response = self.client.get(url) # prints out "./password_reset/ the url" print(url, 'the url') # prints out "<TemplateResponse status_code=200, "text/html; charset=utf-8"> get the url" print(self.response, 'get the url') def test_status_code(self): self.assertEqual(self.response.status_code, 200) # Prints out "None reset status code" print(self.assertEqual(self.response.status_code, 200), 'reset status code') def test_view_function(self): view = resolve('/password_reset/') self.assertEqual(view.func.view_class, auth_views.PasswordResetView) # Prints out "None is anything being returned here?" print(self.assertEqual(view.func.view_class, auth_views.PasswordResetView), 'is anything being returned here?') def test_csrf(self): csrf_token = 'csrfmiddlewaretoken' self.assertContains(self.response, csrf_token) # returns "None the token" print(self.assertContains(self.response, csrf_token), 'the token') def test_contains_form(self): form = self.response.context.get('form') self.assertIsInstance(form, PasswordResetForm) def test_form_inputs(self): ''' The view must contain two inputs: csrf and email ''' self.assertContains(self.response, '<input', 2) self.assertContains(self.response, 'type="email"', 1) class SuccessfulPasswordResetTests(TestCase): def setUp(self): email = 'john@doe.com' User.objects.create_user(username='john', email=email, password='123abcdef') url = reverse('password_reset') self.response = self.client.post(url, {'email': email}) def test_redirection(self): ''' A valid form submission should redirect the user to `password_reset_done` view ''' url = reverse('password_reset_done') self.assertRedirects(self.response, url) def test_send_password_reset_email(self): self.assertEqual(1, len(mail.outbox)) class InvalidPasswordResetTests(TestCase): def setUp(self): url = reverse('password_reset') self.response = self.client.post(url, {'email': 'donotexist@email.com'}) def test_redirection(self): ''' Even invalid emails in the database should redirect the user to `password_reset_done` view ''' url = reverse('password_reset_done') self.assertRedirects(self.response, url) def test_no_reset_email_sent(self): self.assertEqual(0, len(mail.outbox)) class PasswordResetDoneTests(TestCase): def setUp(self): url = reverse('password_reset_done') self.response = self.client.get(url) def test_status_code(self): self.assertEqual(self.response.status_code, 200) def test_view_function(self): view = resolve('/password_reset/done/') self.assertEqual(view.func.view_class, auth_views.PasswordResetDoneView) class PasswordResetConfirmTests(TestCase): def setUp(self): user = User.objects.create_user(username='john', email='john@doe.com', password='123abcdef') ''' create a valid password reset token based on how django creates the token internally: https://github.com/django/django/blob/1.11.5/django/contrib/auth/forms.py#L280 ''' self.uid = urlsafe_base64_encode(force_bytes(user.id)) self.token = default_token_generator.make_token(user) url = reverse('password_reset_confirm', kwargs={'uidb64': self.uid, 'token': self.token}) self.response = self.client.get(url, follow=True) def test_status_code(self): self.assertEqual(self.response.status_code, 200) def test_view_function(self): view = resolve('/reset/{uidb64}/{token}/'.format(uidb64=self.uid, token=self.token)) self.assertEqual(view.func.view_class, auth_views.PasswordResetConfirmView) def test_csrf(self): self.assertContains(self.response, 'csrfmiddlewaretoken') def test_contains_form(self): # add condition to test whether form is "None" or not. Add condition because there is no form. We’re not doing anything with the form to test at this line, we’re just making it available to your code. - thanks to @KenWhitesell, Django Forum form = None if form is not None: # form is None form = self.response.context.get('form') self.assertIsInstance(form, SetPasswordForm) def test_form_inputs(self): ''' The view must contain two inputs: csrf and two password fields ''' self.assertContains(self.response, '<input', 3) self.assertContains(self.response, 'type="password"', 2) self.response = self.client.get(reverse("password_reset_confirm", kwargs={'uidb64': self.uid, 'token': self.token})) text = """ <form method="post" novalidate="" class="password-reset-confirm"> <input type="hidden" name="csrfmiddlewaretoken" value="hSV5mb7Ex4GqiuGcmmQEdsmDw7JtOavc4CpBqyd3fj2rppQQNDTbEfijYSyH5beF"> <div class="form-group"> <label for="id_new_password1">New password:</label> <input type="password" name="new_password1" autocomplete="new-password" class="form-control " aria-describedby="id_new_password1_helptext" id="id_new_password1" data-np-intersection-state="visible"> <small class="form-text text-muted"> <ul><li>Your password can’t be too similar to your other personal information.</li><li>Your password must contain at least 8 characters.</li><li>Your password can’t be a commonly used password.</li><li>Your password can’t be entirely numeric.</li></ul> </small> </div> <div class="form-group"> <label for="id_new_password2">New password confirmation:</label> <input type="password" name="new_password2" autocomplete="new-password" class="form-control " aria-describedby="id_new_password2_helptext" id="id_new_password2" data-np-intersection-state="visible"> <small class="form-text text-muted"> Enter the same password as before, for verification. </small> </div> <button type="submit" class="btn btn-success btn-block">Change password</button> </form> """ soup = bs4.BeautifulSoup(text, "html5lib") sv.select( "form:is(.password-reset-confirm)", soup, ) print( sv.select( "form:is(.password-reset-confirm)", soup, ) ) for tag in soup.find_all('input'): print(tag) class InvalidPasswordResetConfirmTests(TestCase): def setUp(self): user = User.objects.create_user(username='john', email='john@doe.com', password='123abcdef') uid = urlsafe_base64_encode(force_bytes(user.id)) token = default_token_generator.make_token(user) ''' invalidate the token by changing the password ''' user.set_password('abcdef123') user.save() url = reverse('password_reset_confirm', kwargs={'uidb64': uid, 'token': token}) self.response = self.client.get(url) def test_status_code(self): self.assertEqual(self.response.status_code, 200) def test_html(self): password_reset_url = reverse('password_reset') self.assertContains(self.response, 'invalid password reset link') self.assertContains(self.response, 'href="{0}"'.format(password_reset_url)) class PasswordResetCompleteTests(TestCase): def setUp(self): url = reverse('password_reset_complete') self.response = self.client.get(url) def test_status_code(self): self.assertEqual(self.response.status_code, 200) def test_view_function(self): view = resolve('/reset/done/') self.assertEqual(view.func.view_class, auth_views.PasswordResetCompleteView)

accounts/tests/test_view_signup_tests.py

# accounts/tests/test_view_signup_tests.py from django.contrib.auth.models import User from django.test import TestCase from django.urls import resolve, reverse from ..forms import SignUpForm from ..views import signup import bs4 import soupsieve as sv class SignUpTests(TestCase): def setUp(self): url = reverse("signup") self.response = self.client.get(url) def test_signup_status_code(self): self.assertEqual(self.response.status_code, 200) def test_signup_url_resolves_signup_view(self): view = resolve("/signup/") self.assertEqual(view.func, signup) def test_csrf(self): self.assertContains(self.response, "csrfmiddlewaretoken") def test_contains_form(self): form = self.response.context.get("form") self.assertIsInstance(form, SignUpForm) def test_form_inputs(self): """ The view must contain five inputs: csrf, username, email, password1, password2 """ self.assertContains(self.response, "<input", 7) # added to the bottom of the (refactored) test_form_inputs test: self.response = self.client.get(reverse("signup")) text = """ <form method="post" novalidate="" class="signup-form"> <input type="hidden" name="csrfmiddlewaretoken" value="5bzfyc9iidGoyInd3IYNlTrBGVLNVo09hNqsSjydsbrvupjtRELqgD8siJf94pup"> <div class="form-group"> <label for="id_username">Username:</label> <input type="text" name="username" maxlength="150" autofocus="" class="form-control " required="" aria-describedby="id_username_helptext" id="id_username" data-np-intersection-state="visible"> <small class="form-text text-muted"> Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only. </small> </div> <div class="form-group"> <label for="id_email">Email:</label> <input type="email" name="email" maxlength="254" class="form-control " required="" id="id_email" data-np-intersection-state="visible"> </div> <div class="form-group"> <label for="id_password1">Password:</label> <input type="password" name="password1" autocomplete="new-password" class="form-control " aria-describedby="id_password1_helptext" id="id_password1" data-np-intersection-state="visible"> <small class="form-text text-muted"> <ul><li>Your password can’t be too similar to your other personal information.</li><li>Your password must contain at least 8 characters.</li><li>Your password can’t be a commonly used password.</li><li>Your password can’t be entirely numeric.</li></ul> </small> </div> <div class="form-group"> <label for="id_password2">Password confirmation:</label> <input type="password" name="password2" autocomplete="new-password" class="form-control " aria-describedby="id_password2_helptext" id="id_password2" data-np-intersection-state="visible"> <small class="form-text text-muted"> Enter the same password as before, for verification. </small> </div> <div class="form-group"> <label>Password-based authentication:</label> <div id="id_usable_password" class="form-control "><div> <label for="id_usable_password_0"><input type="radio" name="usable_password" value="true" class="form-control " id="id_usable_password_0" checked=""> Enabled</label> </div> <div> <label for="id_usable_password_1"><input type="radio" name="usable_password" value="false" class="form-control " id="id_usable_password_1"> Disabled</label> </div> <div> <small class="form-text text-muted"> Whether the user will be able to authenticate using a password or not. If disabled, they may still be able to authenticate using other backends, such as Single Sign-On or LDAP. </small> </div> <button type="submit" class="btn btn-primary btn-block">Create an account</button> </form> """ soup = bs4.BeautifulSoup(text, "html5lib") sv.select( "form:is(.signup-form)", soup, ) print( sv.select( "form:is(.signup-form)", soup, ) ) for tag in soup.find_all('input'): print(tag) class SuccessfulSignUpTests(TestCase): def setUp(self): url = reverse("signup") data = { "username": "john", "email": "john@doe.com", "password1": "abcdef123456", "password2": "abcdef123456", } self.response = self.client.post(url, data) self.index_url = reverse("index") def test_redirection(self): """ A valid form submission should redirect the user to the home page """ self.assertRedirects(self.response, self.index_url) def test_user_creation(self): self.assertTrue(User.objects.exists()) def test_user_authentication(self): """ Create a new request to an arbitrary page. The resulting response should now have an `user` to its context, after a successful sign up. """ response = self.client.get(self.index_url) user = response.context.get("user") self.assertTrue(user.is_authenticated) class InvalidSignUpTests(TestCase): def setUp(self): url = reverse("signup") self.response = self.client.post(url, {}) # submit an empty dictionary def test_signup_status_code(self): """ An invalid form submission should return to the same page """ self.assertEqual(self.response.status_code, 200) def test_form_errors(self): form = self.response.context.get("form") self.assertTrue(form.errors) def test_dont_create_user(self): self.assertFalse(User.objects.exists())

boards/tests

boards/tests/test_templatetags.py

# boards/tests/test_templatetags.py from django import forms from django.test import TestCase from ..templatetags.form_tags import field_type, input_class class ExampleForm(forms.Form): name = forms.CharField() password = forms.CharField(widget=forms.PasswordInput()) class Meta: fields = ('name', 'password') class FieldTypeTests(TestCase): def test_field_widget_type(self): form = ExampleForm() self.assertEqual('TextInput', field_type(form['name'])) self.assertEqual('PasswordInput', field_type(form['password'])) class InputClassTests(TestCase): def test_unbound_field_initial_state(self): form = ExampleForm() # unbound form self.assertEqual('form-control ', input_class(form['name'])) def test_valid_bound_field(self): form = ExampleForm({'name': 'john', 'password': '123'}) # bound form (field + data) # this actually reflects logic in templatetags/form_tags.py to fix invalid data passing as valid self.assertEqual('form-control is-invalid', input_class(form['name'])) self.assertEqual('form-control is-invalid', input_class(form['password'])) def test_invalid_bound_field(self): form = ExampleForm({'name': '', 'password': '123'}) # bound form (field + data) self.assertEqual('form-control is-invalid', input_class(form['name']))

boards/tests/test_view_board_topics_tests.py

# boards/tests/test_view_board_topics_tests.py from django.test import TestCase from django.urls import resolve, reverse from ..models import Board from ..views import board_topics class BoardTopicsTests(TestCase): def setUp(self): Board.objects.create(name='Django', description='Django board.') def test_board_topics_view_success_status_code(self): url = reverse('board_topics', kwargs={'id': 1}) response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_board_topics_view_not_found_status_code(self): url = reverse('board_topics', kwargs={'id': 99}) response = self.client.get(url) self.assertEqual(response.status_code, 404) def test_board_topics_url_resolves_board_topics_view(self): view = resolve('/boards/1/') self.assertEqual(view.func, board_topics) def test_board_topics_view_contains_navigation_links(self): board_topics_url = reverse('board_topics', kwargs={'id': 1}) index_page_url = reverse('index') new_topic_url = reverse('new_topic', kwargs={'id': 1}) response = self.client.get(board_topics_url) self.assertContains(response, 'href="{0}"'.format(index_page_url)) self.assertContains(response, 'href="{0}"'.format(new_topic_url))

boards/tests/test_view_index_tests.py

# boards/tests/test_view_index_tests.py from django.test import TestCase from django.urls import resolve, reverse from ..models import Board from ..views import index class IndexTests(TestCase): def setUp(self): self.board = Board.objects.create(name='Django', description='Django board.') url = reverse('index') self.response = self.client.get(url) def test_home_view_status_code(self): self.assertEqual(self.response.status_code, 200) def test_home_url_resolves_home_view(self): view = resolve('/') self.assertEqual(view.func, index) def test_home_view_contains_link_to_topics_page(self): board_topics_url = reverse('board_topics', kwargs={'id': self.board.id}) self.assertContains(self.response, 'href="{0}"'.format(board_topics_url))

boards/tests/test_view_new_topic_tests.py

# boards/tests/test_view_new_topic_tests.py from django.contrib.auth.models import User from django.test import TestCase from django.urls import resolve, reverse from ..forms import NewTopicForm from ..models import Board, Post, Topic from ..views import new_topic class NewTopicTests(TestCase): def setUp(self): Board.objects.create(name='Django', description='Django board.') User.objects.create_user(username='john', email='john@doe.com', password='123') self.client.login(username='john', password='123') def test_new_topic_view_success_status_code(self): url = reverse('new_topic', kwargs={'id': 1}) response = self.client.get(url) self.assertEqual(response.status_code, 200) def test_new_topic_view_not_found_status_code(self): url = reverse('new_topic', kwargs={'id': 99}) response = self.client.get(url) self.assertEqual(response.status_code, 404) def test_new_topic_url_resolves_new_topic_view(self): view = resolve('/boards/1/new/') self.assertEqual(view.func, new_topic) def test_new_topic_view_contains_link_back_to_board_topics_view(self): new_topic_url = reverse('new_topic', kwargs={'id': 1}) board_topics_url = reverse('board_topics', kwargs={'id': 1}) response = self.client.get(new_topic_url) self.assertContains(response, 'href="{0}"'.format(board_topics_url)) def test_csrf(self): url = reverse('new_topic', kwargs={'id': 1}) response = self.client.get(url) self.assertContains(response, 'csrfmiddlewaretoken') def test_contains_form(self): url = reverse('new_topic', kwargs={'id': 1}) response = self.client.get(url) form = response.context.get('form') self.assertIsInstance(form, NewTopicForm) def test_new_topic_valid_post_data(self): url = reverse('new_topic', kwargs={'id': 1}) data = { 'subject': 'Test title', 'message': 'Lorem ipsum dolor sit amet' } self.client.post(url, data) self.assertTrue(Topic.objects.exists()) self.assertTrue(Post.objects.exists()) def test_new_topic_invalid_post_data(self): ''' Invalid post data should not redirect The expected behavior is to show the form again with validation errors ''' url = reverse('new_topic', kwargs={'id': 1}) response = self.client.post(url, {}) form = response.context.get('form') self.assertEqual(response.status_code, 200) self.assertTrue(form.errors) def test_new_topic_invalid_post_data_empty_fields(self): ''' Invalid post data should not redirect The expected behavior is to show the form again with validation errors ''' url = reverse('new_topic', kwargs={'id': 1}) data = { 'subject': '', 'message': '' } response = self.client.post(url, data) self.assertEqual(response.status_code, 200) self.assertFalse(Topic.objects.exists()) self.assertFalse(Post.objects.exists()) class LoginRequiredNewTopicTests(TestCase): def setUp(self): Board.objects.create(name='Django', description='Django board.') self.url = reverse('new_topic', kwargs={'id': 1}) self.response = self.client.get(self.url) def test_redirection(self): login_url = reverse('login') self.assertRedirects(self.response, '{login_url}?next={url}'.format(login_url=login_url, url=self.url))

boards/tests/test_view_reply_topic_tests.py

# boards/tests/test_view_reply_topic_tests.py from django.contrib.auth.models import User from django.test import TestCase from django.urls import resolve, reverse from ..forms import PostForm from ..models import Board, Post, Topic from ..views import reply_topic class ReplyTopicTestCase(TestCase): ''' Base test case to be used in all `reply_topic` view tests ''' def setUp(self): self.board = Board.objects.create(name='Django', description='Django board.') self.username = 'john' self.password = '123' user = User.objects.create_user(username=self.username, email='john@doe.com', password=self.password) self.topic = Topic.objects.create(subject='Hello, world', board=self.board, starter=user) Post.objects.create(message='Lorem ipsum dolor sit amet', topic=self.topic, created_by=user) self.url = reverse('reply_topic', kwargs={'id': self.board.id, 'topic_id': self.topic.id}) class LoginRequiredReplyTopicTests(ReplyTopicTestCase): def test_redirection(self): login_url = reverse('login') response = self.client.get(self.url) self.assertRedirects(response, '{login_url}?next={url}'.format(login_url=login_url, url=self.url)) class ReplyTopicTests(ReplyTopicTestCase): def setUp(self): super().setUp() self.client.login(username=self.username, password=self.password) self.response = self.client.get(self.url) def test_status_code(self): print(self.response, 'location') self.assertEqual(self.response.status_code, 200) def test_view_function(self): view = resolve('/boards/1/topics/1/reply/') self.assertEqual(view.func, reply_topic) def test_csrf(self): self.assertContains(self.response, 'csrfmiddlewaretoken') def test_contains_form(self): form = self.response.context.get('form') self.assertIsInstance(form, PostForm) def test_form_inputs(self): ''' The view must contain two inputs: csrf, message textarea ''' self.assertContains(self.response, '<input', 2) self.assertContains(self.response, '<textarea', 1) class SuccessfulReplyTopicTests(ReplyTopicTestCase): def setUp(self): super().setUp() self.client.login(username=self.username, password=self.password) self.response = self.client.post(self.url, {'message': 'hello, world!'}) def test_redirection(self): ''' A valid form submission should redirect the user ''' topic_posts_url = reverse('topic_posts', kwargs={'id': self.board.id, 'topic_id': self.topic.id}) self.assertRedirects(self.response, topic_posts_url) def test_reply_created(self): ''' The total post count should be 2 The one created in the `ReplyTopicTestCase` setUp and another created by the post data in this class ''' self.assertEqual(Post.objects.count(), 2) class InvalidReplyTopicTests(ReplyTopicTestCase): def setUp(self): ''' Submit an empty dictionary to the `reply_topic` view ''' super().setUp() self.client.login(username=self.username, password=self.password) self.response = self.client.post(self.url, {}) def test_status_code(self): ''' An invalid form submission should return to the same page ''' print(self.url, 'the url') self.assertEqual(self.response.status_code, 200) def test_form_errors(self): form = None if form is not None: # form is "None" form = self.response.context.get('form') self.assertTrue(form.errors)

boards/tests/test_view_topic_posts_tests.py

# baards/tests/test_view_topic_posts_tests.py from django.contrib.auth.models import User from django.test import TestCase from django.urls import resolve, reverse from ..models import Board, Post, Topic from ..views import topic_posts class TopicPostsTests(TestCase): def setUp(self): board = Board.objects.create(name='Django', description='Django board.') user = User.objects.create_user(username='john', email='john@doe.com', password='123') topic = Topic.objects.create(subject='Hello, world', board=board, starter=user) Post.objects.create(message='Lorem ipsum dolor sit amet', topic=topic, created_by=user) url = reverse('topic_posts', kwargs={'id': board.id, 'topic_id': topic.id}) self.response = self.client.get(url) def test_status_code(self): self.assertEqual(self.response.status_code, 200) def test_view_function(self): view = resolve('/boards/1/topics/1/') self.assertEqual(view.func, topic_posts)

There are a some differences between the code through part 19 and the code shared here. Make sure to check for them! I successfully refactored all the accounts tests. I just have 1 failing test in the boards tests.

The only test that fails is when I run python3 manage.py test boards.tests.test_view_reply_topic_tests and get back the following:

Found 10 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). ./boards/1/topics/1/reply/ the url F....<HttpResponse status_code=200, "text/html; charset=utf-8"> location .... ====================================================================== FAIL: test_status_code (boards.tests.test_view_reply_topic_tests.InvalidReplyTopicTests.test_status_code) An invalid form submission should return to the same page ---------------------------------------------------------------------- Traceback (most recent call last): File "/Users/mariacam/Python-Development/django-boards/django_boards/boards/tests/test_view_reply_topic_tests.py", line 91, in test_status_code self.assertEqual(self.response.status_code, 200) AssertionError: 302 != 200 ---------------------------------------------------------------------- Ran 10 tests in 4.061s FAILED (failures=1) Destroying test database for alias 'default'...

Revisiting failed tests in section 19 (accounts)

Refactoring django_boards/urls.py

I found out from @kenWhitesell on Django Forum that the main reason for my test failures in accounts was the password reset URLs. If we use the default password reset configuration, we have to use particular urls. First I went to the Django documentation for Using the Django authentication system (which Ken pointed me to), and following the steps there, along with a thread I discovered on stackoverflow (AttributeError: module 'django.contrib.auth.views' has no attribute 'password_reset' error in urls.py), I ended up with the following in my django_boards/urls.py file:

# django_boards/urls.py from django.contrib import admin from django.urls import path, include from accounts import views as accounts_views from boards import views from django.contrib.auth import views as auth_views urlpatterns = [ path("", views.index, name="index"), path("signup/", accounts_views.signup, name="signup"), path( "login/", auth_views.LoginView.as_view(template_name="login.html"), name="login" ), path("logout/", auth_views.LogoutView.as_view(), name="logout"), path( "password_reset/", auth_views.PasswordResetView.as_view( template_name="password_reset.html", email_template_name="password_reset_email.html", subject_template_name="password_reset_subject.txt", ), name="password_reset", ), path( "password_reset/done/", auth_views.PasswordResetDoneView.as_view( template_name="password_reset_done.html" ), name="password_reset_done", ), path( "reset/<uidb64>/<token>/", auth_views.PasswordResetConfirmView.as_view( template_name="password_reset_confirm.html" ), name="password_reset_confirm", ), path( "reset/done/", auth_views.PasswordResetCompleteView.as_view( template_name="password_reset_complete.html" ), name="password_reset_complete", ), path( "password_change/", auth_views.PasswordChangeView.as_view(template_name="password_change.html"), name="password_change", ), path( "password_change/done/", auth_views.PasswordChangeDoneView.as_view( template_name="password_change_done.html" ), name="password_change_done", ), path("boards/<str:id>/", views.board_topics, name="board_topics"), path("boards/<str:id>/new/", views.new_topic, name="new_topic"), path("boards/<id>/topics/<topic_id>/", views.topic_posts, name="topic_posts"), path("boards/<id>/topics/<topic_id>/reply/", views.reply_topic, name="reply_topic"), path("admin/", admin.site.urls), ]

The Django documentation does mention the option to do something like:

urlpatterns = [ path("change-password/", auth_views.PasswordChangeView.as_view()), ]

But if you want to stick to the default password reset configurations, you have to go with:

path( "password_change/", auth_views.PasswordChangeView.as_view(template_name="password_change.html"), name="password_change", ),

I learned this the hard way!

Then I had to make some changes to the related password reset tests.

Refactoring test_view_password_reset_tests.py

Before:

# accounts/tests/test_view_password_reset_tests.py (re-organized) # Which consists of test_password_reset_tests.py, test_successful_password_reset_tests.py, test_invalid_password_reset_tests.p, test_password_reset_done_tests.py, test_password_reset_confirm_tests.py, test_invalid_password_reset_confirm_tests.py, and test_password_reset_complete_tests.py (in that order)

Now:

# accounts/tests/test_view_password_reset_tests.py from django.contrib.auth import views as auth_views from django.contrib.auth import authenticate from django.contrib.auth.forms import PasswordResetForm, SetPasswordForm from django.contrib.auth.models import User from django.contrib.auth.tokens import default_token_generator from django.core import mail from django.test import TestCase from django.urls import resolve, reverse from django.utils.encoding import force_bytes from django.utils.http import urlsafe_base64_encode import bs4 import soupsieve as sv class PasswordResetTests(TestCase): def setUp(self): user = User.objects.create_user(username='john', email='john@doe.com', password='123') url = reverse('password_reset') self.response = self.client.get(url) # prints out "./password-reset/ the url" print(url, 'the url') # prints out "<TemplateResponse status_code=200, "text/html; charset=utf-8"> get the url" print(self.response, 'get the url') def test_status_code(self): self.assertEqual(self.response.status_code, 200) # Prints out "None reset status code" print(self.assertEqual(self.response.status_code, 200), 'reset status code') def test_view_function(self): view = resolve('/password_reset/') self.assertEqual(view.func.view_class, auth_views.PasswordResetView) # Prints out "None is anything being returned here?" print(self.assertEqual(view.func.view_class, auth_views.PasswordResetView), 'is anything being returned here?') def test_csrf(self): csrf_token = 'csrfmiddlewaretoken' self.assertContains(self.response, csrf_token) # returns "None the token" print(self.assertContains(self.response, csrf_token), 'the token') def test_contains_form(self): form = self.response.context.get('form') self.assertIsInstance(form, PasswordResetForm) def test_form_inputs(self): ''' The view must contain two inputs: csrf and email ''' self.assertContains(self.response, '<input', 2) self.assertContains(self.response, 'type="email"', 1) class SuccessfulPasswordResetTests(TestCase): def setUp(self): email = 'john@doe.com' User.objects.create_user(username='john', email=email, password='123abcdef') url = reverse('password_reset') self.response = self.client.post(url, {'email': email}) def test_redirection(self): ''' A valid form submission should redirect the user to `password_reset_done` view ''' url = reverse('password_reset_done') self.assertRedirects(self.response, url) def test_send_password_reset_email(self): self.assertEqual(1, len(mail.outbox)) class InvalidPasswordResetTests(TestCase): def setUp(self): url = reverse('password_reset') self.response = self.client.post(url, {'email': 'donotexist@email.com'}) def test_redirection(self): ''' Even invalid emails in the database should redirect the user to `password_reset_done` view ''' url = reverse('password_reset_done') self.assertRedirects(self.response, url) def test_no_reset_email_sent(self): self.assertEqual(0, len(mail.outbox)) class PasswordResetDoneTests(TestCase): def setUp(self): url = reverse('password_reset_done') self.response = self.client.get(url) def test_status_code(self): self.assertEqual(self.response.status_code, 200) def test_view_function(self): view = resolve('/password_reset/done/') self.assertEqual(view.func.view_class, auth_views.PasswordResetDoneView) class PasswordResetConfirmTests(TestCase): def setUp(self): user = User.objects.create_user(username='john', email='john@doe.com', password='123abcdef') ''' create a valid password reset token based on how django creates the token internally: https://github.com/django/django/blob/1.11.5/django/contrib/auth/forms.py#L280 ''' self.uid = urlsafe_base64_encode(force_bytes(user.id)) self.token = default_token_generator.make_token(user) url = reverse('password_reset_confirm', kwargs={'uidb64': self.uid, 'token': self.token}) self.response = self.client.get(url, follow=True) def test_status_code(self): self.assertEqual(self.response.status_code, 200) def test_view_function(self): view = resolve('/reset/{uidb64}/{token}/'.format(uidb64=self.uid, token=self.token)) self.assertEqual(view.func.view_class, auth_views.PasswordResetConfirmView) def test_csrf(self): self.assertContains(self.response, 'csrfmiddlewaretoken') def test_contains_form(self): # add condition to test whether form is "None" or not. Add condition because there is no form. We’re not doing anything with the form to test at this line, we’re just making it available to your code. - thanks to @KenWhitesell, Django Forum form = None if form is not None: # form is None form = self.response.context.get('form') self.assertIsInstance(form, SetPasswordForm) def test_form_inputs(self): ''' The view must contain two inputs: csrf and two password fields ''' self.assertContains(self.response, '<input', 3) self.assertContains(self.response, 'type="password"', 2) self.response = self.client.get(reverse("password_reset_confirm", kwargs={'uidb64': self.uid, 'token': self.token})) text = """ <form method="post" novalidate="" class="password-reset-confirm"> <input type="hidden" name="csrfmiddlewaretoken" value="hSV5mb7Ex4GqiuGcmmQEdsmDw7JtOavc4CpBqyd3fj2rppQQNDTbEfijYSyH5beF"> <div class="form-group"> <label for="id_new_password1">New password:</label> <input type="password" name="new_password1" autocomplete="new-password" class="form-control " aria-describedby="id_new_password1_helptext" id="id_new_password1" data-np-intersection-state="visible"> <small class="form-text text-muted"> <ul><li>Your password can’t be too similar to your other personal information.</li><li>Your password must contain at least 8 characters.</li><li>Your password can’t be a commonly used password.</li><li>Your password can’t be entirely numeric.</li></ul> </small> </div> <div class="form-group"> <label for="id_new_password2">New password confirmation:</label> <input type="password" name="new_password2" autocomplete="new-password" class="form-control " aria-describedby="id_new_password2_helptext" id="id_new_password2" data-np-intersection-state="visible"> <small class="form-text text-muted"> Enter the same password as before, for verification. </small> </div> <button type="submit" class="btn btn-success btn-block">Change password</button> </form> """ soup = bs4.BeautifulSoup(text, "html5lib") sv.select( "form:is(.password-reset-confirm)", soup, ) print( sv.select( "form:is(.password-reset-confirm)", soup, ) ) for tag in soup.find_all('input'): print(tag) class InvalidPasswordResetConfirmTests(TestCase): def setUp(self): user = User.objects.create_user(username='john', email='john@doe.com', password='123abcdef') uid = urlsafe_base64_encode(force_bytes(user.id)) token = default_token_generator.make_token(user) ''' invalidate the token by changing the password ''' user.set_password('abcdef123') user.save() url = reverse('password_reset_confirm', kwargs={'uidb64': uid, 'token': token}) self.response = self.client.get(url) def test_status_code(self): self.assertEqual(self.response.status_code, 200) def test_html(self): password_reset_url = reverse('password_reset') self.assertContains(self.response, 'invalid password reset link') self.assertContains(self.response, 'href="{0}"'.format(password_reset_url)) class PasswordResetCompleteTests(TestCase): def setUp(self): url = reverse('password_reset_complete') self.response = self.client.get(url) def test_status_code(self): self.assertEqual(self.response.status_code, 200) def test_view_function(self): view = resolve('/reset/done/') self.assertEqual(view.func.view_class, auth_views.PasswordResetCompleteView)

Refactoring test_view_password_change_tests.py

Before:

# test_password_change_tests.py, test_login_required_password_change_tests.py, test_password_change_test_case.py, test_successful_password_change_tests.py, and test_invalid_password_change_tests.py (in that order)

In case if you don't see test_password_change_tests.py in the Django Boards Github repository history, test_password_change_tests.py contains the following:

# test_password_change_tests.py from django.contrib.auth import views as auth_views from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth.models import User from django.test import TestCase from django.urls import resolve, reverse class PasswordChangeTests(TestCase): def setUp(self): username = 'john' password = 'secret123' User.objects.create_user(username=username, email='john@doe.com', password=password) url = reverse('password_change') self.client.login(username=username, password=password) self.response = self.client.get(url) def test_status_code(self): self.assertEqual(self.response.status_code, 200) def test_url_resolves_correct_view(self): view = resolve('/password_change/') self.assertEqual(view.func.view_class, auth_views.PasswordChangeView) def test_csrf(self): self.assertContains(self.response, 'csrfmiddlewaretoken') def test_contains_form(self): form = self.response.context_data.get('form') self.assertIsInstance(form, PasswordChangeForm) print(self.assertIsInstance(form, PasswordChangeForm), 'is this change form rendering?') def test_form_inputs(self): ''' The view must contain four inputs: csrf, old_password, new_password1, new_password2 ''' self.assertContains(self.response, '<input', 5) self.assertContains(self.response, 'type="password"', 3)

After:

from django.contrib.auth import views as auth_views from django.contrib.auth.forms import PasswordChangeForm from django.contrib.auth.models import User from django.test import TestCase from django.urls import resolve, reverse class PasswordChangeTests(TestCase): def setUp(self): username = 'john' password = 'secret123' User.objects.create_user(username=username, email='john@doe.com', password=password) url = reverse('password_change') self.client.login(username=username, password=password) self.response = self.client.get(url) def test_status_code(self): self.assertEqual(self.response.status_code, 200) def test_url_resolves_correct_view(self): view = resolve('/password_change/') self.assertEqual(view.func.view_class, auth_views.PasswordChangeView) def test_csrf(self): self.assertContains(self.response, 'csrfmiddlewaretoken') def test_contains_form(self): form = self.response.context_data.get('form') self.assertIsInstance(form, PasswordChangeForm) print(self.assertIsInstance(form, PasswordChangeForm), 'is this change form rendering?') def test_form_inputs(self): ''' The view must contain four inputs: csrf, old_password, new_password1, new_password2 ''' self.assertContains(self.response, '<input', 5) self.assertContains(self.response, 'type="password"', 3) class LoginRequiredPasswordChangeTests(TestCase): def test_redirection(self): url = reverse('password_change') login_url = reverse('login') response = self.client.get(url) self.assertRedirects(response, f'{login_url}?next={url}') class PasswordChangeTestCase(TestCase): ''' Base test case for form processing accepts a `data` dict to POST to the view. ''' def setUp(self, data={}): self.user = User.objects.create_user(username='john', email='john@doe.com', password='old_password') self.url = reverse('password_change') self.client.login(username='john', password='old_password') self.response = self.client.post(self.url, data) class SuccessfulPasswordChangeTests(PasswordChangeTestCase): def setUp(self): super().setUp({ 'old_password': 'old_password', 'new_password1': 'new_password', 'new_password2': 'new_password', }) def test_redirection(self): ''' A valid form submission should redirect the user ''' self.assertRedirects(self.response, reverse('password_change_done')) def test_password_changed(self): ''' refresh the user instance from database to get the new password hash updated by the change password view. ''' self.user.refresh_from_db() self.assertTrue(self.user.check_password('new_password')) def test_user_authentication(self): ''' Create a new request to an arbitrary page. The resulting response should now have an `user` to its context, after a successful sign up. ''' response = self.client.get(reverse('index')) user = response.context.get('user') self.assertTrue(user.is_authenticated) class InvalidPasswordChangeTests(PasswordChangeTestCase): def test_status_code(self): ''' An invalid form submission should return to the same page ''' self.assertEqual(self.response.status_code, 200) def test_form_errors(self): form = self.response.context.get('form') self.assertTrue(form.errors) def test_didnt_change_password(self): ''' refresh the user instance from the database to make sure we have the latest data. ''' self.user.refresh_from_db() self.assertTrue(self.user.check_password('old_password'))

Running the accounts/tests

python3 manage.py test accounts.tests.test_form_signup_test

When I run python3 manage.py test accounts.tests.test_form_signup_test, the following is returned:

Found 1 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). F ====================================================================== FAIL: test_form_has_fields (accounts.tests.test_form_signup_test.SignUpFormTest.test_form_has_fields) ---------------------------------------------------------------------- Traceback (most recent call last): File "/Users/mariacam/Python-Development/django-boards/django_boards/accounts/tests/test_form_signup_test.py", line 9, in test_form_has_fields self.assertSequenceEqual(expected, actual) AssertionError: Sequences differ: ['username', 'email', 'password1', 'password2'] != ['username', 'email', 'password1', 'password2', 'usable_password'] Second sequence contains 1 additional elements. First extra element 4: 'usable_password' - ['username', 'email', 'password1', 'password2'] + ['username', 'email', 'password1', 'password2', 'usable_password'] ? +++++++++++++++++++ ---------------------------------------------------------------------- Ran 1 test in 0.001s FAILED (failures=1) Destroying test database for alias 'default'...

The test fails. Yes, it fails because the original test that was created 7 years ago was targeting only 4 input fields. But the current signup form contains a usable password by default. The intent of this test is to check whether or not something has changed in the form, and yes, it has. Once I ran this, I commented out this test. No reason to run it again! At least for the time being.

python3 manage.py test accounts.tests.test_mail_password_reset_tests

When I run python3 manage.py test accounts.tests.test_mail_password_reset_tests, the following is returned:

Found 3 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). ... ---------------------------------------------------------------------- Ran 3 tests in 0.768s OK Destroying test database for alias 'default'...

The tests pass.

python3 manage.py test accounts.tests.test_view_password_change_tests

When I run python3 manage.py test accounts.tests.test_view_password_change_tests, the following is returned:

Found 12 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). ....None is this change form rendering? ........ ---------------------------------------------------------------------- Ran 12 tests in 6.423s OK Destroying test database for alias 'default'...

The tests (still) pass.

python3 manage.py test accounts.tests.test_view_password_reset_tests

Now when I run python3 manage.py test accounts.tests.test_view_password_reset_tests, the following is returned:

Found 20 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). ........[<form class="password-reset-confirm" method="post" novalidate=""> <input name="csrfmiddlewaretoken" type="hidden" value="hSV5mb7Ex4GqiuGcmmQEdsmDw7JtOavc4CpBqyd3fj2rppQQNDTbEfijYSyH5beF"/> <div class="form-group"> <label for="id_new_password1">New password:</label> <input aria-describedby="id_new_password1_helptext" autocomplete="new-password" class="form-control" data-np-intersection-state="visible" id="id_new_password1" name="new_password1" type="password"/> <small class="form-text text-muted"> <ul><li>Your password can’t be too similar to your other personal information.</li><li>Your password must contain at least 8 characters.</li><li>Your password can’t be a commonly used password.</li><li>Your password can’t be entirely numeric.</li></ul> </small> </div> <div class="form-group"> <label for="id_new_password2">New password confirmation:</label> <input aria-describedby="id_new_password2_helptext" autocomplete="new-password" class="form-control" data-np-intersection-state="visible" id="id_new_password2" name="new_password2" type="password"/> <small class="form-text text-muted"> Enter the same password as before, for verification. </small> </div> <button class="btn btn-success btn-block" type="submit">Change password</button> </form>] <input name="csrfmiddlewaretoken" type="hidden" value="hSV5mb7Ex4GqiuGcmmQEdsmDw7JtOavc4CpBqyd3fj2rppQQNDTbEfijYSyH5beF"/> <input aria-describedby="id_new_password1_helptext" autocomplete="new-password" class="form-control" data-np-intersection-state="visible" id="id_new_password1" name="new_password1" type="password"/> <input aria-describedby="id_new_password2_helptext" autocomplete="new-password" class="form-control" data-np-intersection-state="visible" id="id_new_password2" name="new_password2" type="password"/> ...../password_reset/ the url <TemplateResponse status_code=200, "text/html; charset=utf-8"> get the url ./password_reset/ the url <TemplateResponse status_code=200, "text/html; charset=utf-8"> get the url None the token ./password_reset/ the url <TemplateResponse status_code=200, "text/html; charset=utf-8"> get the url ./password_reset/ the url <TemplateResponse status_code=200, "text/html; charset=utf-8"> get the url None reset status code ./password_reset/ the url <TemplateResponse status_code=200, "text/html; charset=utf-8"> get the url None is anything being returned here? ... ---------------------------------------------------------------------- Ran 20 tests in 3.424s OK Destroying test database for alias 'default'...

The tests pass. The key was in the correct URLs.

python3 manage.py test accounts.tests.test_view_signup_tests

When I run python3 manage.py test accounts.tests.test_view_signup_tests, the following is returned:

Found 11 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). .....[<form class="signup-form" method="post" novalidate=""> <input name="csrfmiddlewaretoken" type="hidden" value="5bzfyc9iidGoyInd3IYNlTrBGVLNVo09hNqsSjydsbrvupjtRELqgD8siJf94pup"/> <div class="form-group"> <label for="id_username">Username:</label> <input aria-describedby="id_username_helptext" autofocus="" class="form-control" data-np-intersection-state="visible" id="id_username" maxlength="150" name="username" required="" type="text"/> <small class="form-text text-muted"> Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only. </small> </div> <div class="form-group"> <label for="id_email">Email:</label> <input class="form-control" data-np-intersection-state="visible" id="id_email" maxlength="254" name="email" required="" type="email"/> </div> <div class="form-group"> <label for="id_password1">Password:</label> <input aria-describedby="id_password1_helptext" autocomplete="new-password" class="form-control" data-np-intersection-state="visible" id="id_password1" name="password1" type="password"/> <small class="form-text text-muted"> <ul><li>Your password can’t be too similar to your other personal information.</li><li>Your password must contain at least 8 characters.</li><li>Your password can’t be a commonly used password.</li><li>Your password can’t be entirely numeric.</li></ul> </small> </div> <div class="form-group"> <label for="id_password2">Password confirmation:</label> <input aria-describedby="id_password2_helptext" autocomplete="new-password" class="form-control" data-np-intersection-state="visible" id="id_password2" name="password2" type="password"/> <small class="form-text text-muted"> Enter the same password as before, for verification. </small> </div> <div class="form-group"> <label>Password-based authentication:</label> <div class="form-control" id="id_usable_password"><div> <label for="id_usable_password_0"><input checked="" class="form-control" id="id_usable_password_0" name="usable_password" type="radio" value="true"/> Enabled</label> </div> <div> <label for="id_usable_password_1"><input class="form-control" id="id_usable_password_1" name="usable_password" type="radio" value="false"/> Disabled</label> </div> <div> <small class="form-text text-muted"> Whether the user will be able to authenticate using a password or not. If disabled, they may still be able to authenticate using other backends, such as Single Sign-On or LDAP. </small> </div> <button class="btn btn-primary btn-block" type="submit">Create an account</button> </div></div></form>] <input name="csrfmiddlewaretoken" type="hidden" value="5bzfyc9iidGoyInd3IYNlTrBGVLNVo09hNqsSjydsbrvupjtRELqgD8siJf94pup"/> <input aria-describedby="id_username_helptext" autofocus="" class="form-control" data-np-intersection-state="visible" id="id_username" maxlength="150" name="username" required="" type="text"/> <input class="form-control" data-np-intersection-state="visible" id="id_email" maxlength="254" name="email" required="" type="email"/> <input aria-describedby="id_password1_helptext" autocomplete="new-password" class="form-control" data-np-intersection-state="visible" id="id_password1" name="password1" type="password"/> <input aria-describedby="id_password2_helptext" autocomplete="new-password" class="form-control" data-np-intersection-state="visible" id="id_password2" name="password2" type="password"/> <input checked="" class="form-control" id="id_usable_password_0" name="usable_password" type="radio" value="true"/> <input class="form-control" id="id_usable_password_1" name="usable_password" type="radio" value="false"/> ...... ---------------------------------------------------------------------- Ran 11 tests in 0.685s OK Destroying test database for alias 'default'...

The tests pass.

Adding the change_password URL to templates/base.html

In order for the logged in user to be able to change their password, I had to add the following to templates/base.html:

<!-- templates/base.html --> {% if user.is_authenticated %} <div class="dropdown"> <a class="btn btn-primary dropdown-toggle" href="#" role="button" data-bs-toggle="dropdown" aria-expanded="false">{{ user.username }}</a> <ul class="dropdown-menu"> <li> <a class="dropdown-item" href="#">My account</a> </li> <li> <!-- href value changed from # to "password_change/" --> <a class="dropdown-item" href="password_change/">Change password</a> </li> <li> <form method="post" action="{% url 'logout' %}"> {% csrf_token %} <button type="submit">Logout</button> </form> </li> </ul> </div> <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#mainMenu" aria-controls="mainMenu" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> {% else %}

It is important to note that replacing # with {%url password_change %} does not work. Why?

First, in class SuccessfulPasswordChangeTests.test_redirection and in the actual behavior in the browser, we have a redirection to the password_change_done url from the password_change url.

Second, it is not possible to use {%url password_change %} because the url has not been loaded when reverse is called. In order to fix this, I hardcode the url instead of using the reverse (password_change). To learn more about this, please visit the thread entitled Django url error in template with password_change view on stackoverflow.

Revisiting failed tests in section 19 (boards)

python3 manage.py test boards.tests.test_templatetags

When I run python3 manage.py test boards.tests.test_templatetags, the following is returned:

Found 4 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). .... ---------------------------------------------------------------------- Ran 4 tests in 0.001s OK Destroying test database for alias 'default'...

The tests pass.

Running the boards/tests

python3 manage.py test boards.tests.test_view_board_topics_tests

When I run python3 manage.py test boards.tests.test_view_board_topics_tests, it returns the following:

Found 4 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). .... ---------------------------------------------------------------------- Ran 4 tests in 0.025s OK Destroying test database for alias 'default'...

The tests pass.

python3 manage.py test boards.tests.test_view_index_tests

When I run python3 manage.py test boards.tests.test_view_index_tests, it returns the following:

Found 3 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). ... ---------------------------------------------------------------------- Ran 3 tests in 0.016s OK Destroying test database for alias 'default'...

The tests pass.

python3 manage.py test boards.tests.test_view_new_topic_tests

When I run python3 manage.py test boards.tests.test_view_new_topic_tests, the following is returned:

Found 10 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). .......... ---------------------------------------------------------------------- Ran 10 tests in 3.844s OK Destroying test database for alias 'default'...

The test passed.

python3 manage.py test boards.tests.test_view_reply_topic_tests

When I run python3 manage.py test boards.tests.test_view_reply_topic_tests, the following is returned:

Found 10 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). ./boards/1/topics/1/reply/ the url F....<HttpResponse status_code=200, "text/html; charset=utf-8"> location .... ====================================================================== FAIL: test_status_code (boards.tests.test_view_reply_topic_tests.InvalidReplyTopicTests.test_status_code) An invalid form submission should return to the same page ---------------------------------------------------------------------- Traceback (most recent call last): File "/Users/mariacam/Python-Development/django-boards/django_boards/boards/tests/test_view_reply_topic_tests.py", line 91, in test_status_code self.assertEqual(self.response.status_code, 200) AssertionError: 302 != 200 ---------------------------------------------------------------------- Ran 10 tests in 4.034s FAILED (failures=1) Destroying test database for alias 'default'...

One out of ten tests failed. We have a redirect issue. The test should result in returning to the same page (status code 200) if an invalid form is submitted, but I have set up a redirect in the reply_topic view to the topic-posts view (topic_posts.html) if a topic post submission is valid. If it is not valid, the user remains on the reply_topic view (reply_topic.html). If the form submission is not valid, the status code should be 200. However, right now, it is 302.

The 302 redirect fix

# boards/tests/test_view_reply_topic_tests.py class InvalidReplyTopicTests(ReplyTopicTestCase): def setUp(self): ''' Submit an empty dictionary to the `reply_topic` view ''' super().setUp() self.client.login(username=self.username, password=self.password) self.response = self.client.post(self.url, {}) def test_status_code(self): # get the self.client.post(self.url, {}) which has status code of 200 response = self.client.get(self.url, {}) self.assertEqual(response.status_code, 200) ''' An invalid form submission should return to the same page ''' def test_form_errors(self): form = None if form is not None: # form is "None" form = self.response.context.get('form') self.assertTrue(form.errors)

The fix here is to add response = self.client.get(self.url, {}). self.response = self.client.post(self.url, {}) has a status code of 200, and it is the URL that the user should remain at if the reply_topic form submission is invalid. Getting that URL with response = self.client.get(self.url, {}), makes the test pass!

python3 manage.py test boards.tests.test_view_topic_posts_tests

When I run python3 manage.py test boards.tests.test_view_topic_posts_tests, the following is returned:

Found 2 test(s). Creating test database for alias 'default'... System check identified no issues (0 silenced). .. ---------------------------------------------------------------------- Ran 2 tests in 0.437s OK Destroying test database for alias 'default'...

The tests pass.

Adding the reply_topic URL to templates/topic_posts.html

<!-- templates/topic_posts.html --> <!-- code suppressed for brevity --> {% block content %} <div class="mb-4"> <a href="{% url 'reply_topic' topic.board.id topic.id %}" class="btn btn-primary" role="button" >Reply</a > </div> <!-- code suppressed for brevity --> {% endblock content %} <!-- code suppressed for brevity -->

Conclusion

In this section, I re-organized the accounts and boards tests content, refactored password reset-related django_boards/urls.py, fixed the password reset-related accounts tests failures, added the password_change URL to templates/base.html, fixed the 302 redirect-related failure in the boards.tests.test_view_reply_topic_tests, and added the reply_topic URL to templates/topic_posts.html.