diff --git a/README.md b/README.md index e03adc5acff2e8cd2c6e2225617d571568ac66ae..3fcc5cbb749e60877073f34fb863203e2c45645c 100644 --- a/README.md +++ b/README.md @@ -197,4 +197,13 @@ Hasil dari mutation testing - timeout: 0 (0.0%) ``` -Karena mutation score dari test adalah 100%, maka tidak perlu menambahkan test case baru untuk menambahkan kualitas. \ No newline at end of file +Karena mutation score dari test adalah 100%, maka tidak perlu menambahkan test case baru untuk menambahkan kualitas. + + +## Exercise 8 + +### Manual Mocking (Monkeypatching) vs Mock Library +1. Menggunakan mock library menghasilkan code yang lebih clean. +2. Dalam mock library, cukup menggunakan decorator `patch` untuk menunjuk object yang ingin di-mock. Setelah test yang terdapat decorator tersebut dijalankan, object yang di-mock akan kembali ke bentuk awalnya sehingga tidak merubah object dalam test lain. Sementara dalam monkeypatching object tidak kembali ke keadaan awalnya sehingga dapat merusak test lain. +3. Mock library dapat me-record behaviour dari mock object yang dipanggil. +4. Mock library dapat me-return value spesifik dengan `return_value`, dan raise exception dengan `side_effect`. \ No newline at end of file diff --git a/accounts/authentication.py b/accounts/authentication.py index a274bb29ae42bdf4c8951bb5dd37b52abcbb07a5..22238817966a080e00a51d061d4f61a6a4431d04 100644 --- a/accounts/authentication.py +++ b/accounts/authentication.py @@ -1,23 +1,19 @@ import sys -from accounts.models import ListUser, Token +from accounts.models import User, Token class PasswordlessAuthenticationBackend(object): def authenticate(self, uid): - print('uid', uid, file=sys.stderr) - if not Token.objects.filter(uid=uid).exists(): - print('no token found', file=sys.stderr) - return None - token = Token.objects.get(uid=uid) - print('got token', file=sys.stderr) try: - user = ListUser.objects.get(email=token.email) - print('got user', file=sys.stderr) - return user - except ListUser.DoesNotExist: - print('new user', file=sys.stderr) - return ListUser.objects.create(email=token.email) - + token = Token.objects.get(uid=uid) + return User.objects.get(email=token.email) + except User.DoesNotExist: + return User.objects.create(email=token.email) + except Token.DoesNotExist: + return None def get_user(self, email): - return ListUser.objects.get(email=email) \ No newline at end of file + try: + return User.objects.get(email=email) + except User.DoesNotExist: + return None \ No newline at end of file diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py index daddd5f632460ced66e4b7796cc785d14c14b6ef..ee93760fd337a3e4df1dbe61cbc0d1080cddab63 100644 --- a/accounts/migrations/0001_initial.py +++ b/accounts/migrations/0001_initial.py @@ -1,4 +1,6 @@ -# Generated by Django 2.1.7 on 2019-11-13 16:08 +# -*- coding: utf-8 -*- +# Generated by Django 1.11.17 on 2019-11-19 09:42 +from __future__ import unicode_literals from django.db import migrations, models @@ -8,7 +10,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ('auth', '0009_alter_user_last_name_max_length'), + ('auth', '0008_alter_user_username_max_length'), ] operations = [ diff --git a/accounts/migrations/0002_user.py b/accounts/migrations/0002_user.py new file mode 100644 index 0000000000000000000000000000000000000000..6a755749489fbaa6cc44ca63701587b5581cafd8 --- /dev/null +++ b/accounts/migrations/0002_user.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.17 on 2019-11-20 15:18 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('email', models.EmailField(max_length=254, primary_key=True, serialize=False)), + ], + ), + ] diff --git a/accounts/models.py b/accounts/models.py index a80d96b4df2cbedeb5807af34745d682de04d9ed..4a5e618e97ee8b1c5890f0664d398dd9d77d9cb1 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -2,11 +2,12 @@ from django.db import models from django.contrib.auth.models import ( AbstractBaseUser, BaseUserManager, PermissionsMixin ) +import uuid class Token(models.Model): email = models.EmailField() - uid = models.CharField(max_length=255) + uid = models.CharField(default=uuid.uuid4, max_length=40) class ListUserManager(BaseUserManager): @@ -31,3 +32,10 @@ class ListUser(AbstractBaseUser, PermissionsMixin): def is_active(self): return True +class User(models.Model): + email = models.EmailField(primary_key=True) + + REQUIRED_FIELDS = [] + USERNAME_FIELD = 'email' + is_anonymous = False + is_authenticated = True \ No newline at end of file diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 7ce503c2dd97ba78597f6ff6e4393132753573f6..0000000000000000000000000000000000000000 --- a/accounts/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/accounts/tests/test_authentication.py b/accounts/tests/test_authentication.py new file mode 100644 index 0000000000000000000000000000000000000000..8417dbcac17d0bea360f6a72080c33d1015166ef --- /dev/null +++ b/accounts/tests/test_authentication.py @@ -0,0 +1,48 @@ +from django.test import TestCase +from django.contrib.auth import get_user_model +from accounts.authentication import PasswordlessAuthenticationBackend +from accounts.models import Token + +User = get_user_model() + + +class AuthenticateTest(TestCase): + + def test_returns_None_if_no_such_token(self): + result = PasswordlessAuthenticationBackend().authenticate( + 'no-such-token' + ) + self.assertIsNone(result) + + + def test_returns_new_user_with_correct_email_if_token_exists(self): + email = 'edith@example.com' + token = Token.objects.create(email=email) + user = PasswordlessAuthenticationBackend().authenticate(token.uid) + new_user = User.objects.get(email=email) + self.assertEqual(user, new_user) + + + def test_returns_existing_user_with_correct_email_if_token_exists(self): + email = 'edith@example.com' + existing_user = User.objects.create(email=email) + token = Token.objects.create(email=email) + user = PasswordlessAuthenticationBackend().authenticate(token.uid) + self.assertEqual(user, existing_user) + + +class GetUserTest(TestCase): + + def test_gets_user_by_email(self): + User.objects.create(email='another@example.com') + desired_user = User.objects.create(email='edith@example.com') + found_user = PasswordlessAuthenticationBackend().get_user( + 'edith@example.com' + ) + self.assertEqual(found_user, desired_user) + + + def test_returns_None_if_no_user_with_that_email(self): + self.assertIsNone( + PasswordlessAuthenticationBackend().get_user('edith@example.com') + ) \ No newline at end of file diff --git a/accounts/tests/test_models.py b/accounts/tests/test_models.py new file mode 100644 index 0000000000000000000000000000000000000000..79a3a3a61f4ab6fd5f403c34bfaed7fcefc20271 --- /dev/null +++ b/accounts/tests/test_models.py @@ -0,0 +1,19 @@ +from accounts.models import Token +from django.test import TestCase +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class UserModelTest(TestCase): + + def test_user_is_valid_with_email_only(self): + user = User(email='a@b.com') + user.full_clean() # should not raise + +class TokenModelTest(TestCase): + + def test_links_user_with_auto_generated_uid(self): + token1 = Token.objects.create(email='a@b.com') + token2 = Token.objects.create(email='a@b.com') + self.assertNotEqual(token1.uid, token2.uid) \ No newline at end of file diff --git a/accounts/tests/test_views.py b/accounts/tests/test_views.py new file mode 100644 index 0000000000000000000000000000000000000000..5fcfcfe36e27b19b53f029644ae23ea5fb30f2e3 --- /dev/null +++ b/accounts/tests/test_views.py @@ -0,0 +1,61 @@ +from django.test import TestCase +from unittest.mock import patch, call +from accounts.models import Token + + +class SendLoginEmailViewTest(TestCase): + def test_redirects_to_home_page(self): + response = self.client.post('/accounts/send_login_email', data={ + 'email': 'edith@example.com' + }) + self.assertRedirects(response, '/') + + @patch('accounts.views.send_mail') + def test_sends_mail_to_address_from_post(self, mock_send_mail): + self.client.post('/accounts/send_login_email', data={ + 'email': 'edith@example.com' + }) + + self.assertEqual(mock_send_mail.called, True) + (subject, body, from_email, to_list), kwargs = mock_send_mail.call_args + self.assertEqual(subject, 'Your login link for Superlists') + self.assertEqual(from_email, 'noreply@superlists') + self.assertEqual(to_list, ['edith@example.com']) + + def test_adds_success_message(self): + response = self.client.post('/accounts/send_login_email', data={ + 'email': 'edith@example.com' + }, follow=True) + + message = list(response.context['messages'])[0] + self.assertEqual( + message.message, + "Check your email, we've sent you a link you can use to log in." + ) + self.assertEqual(message.tags, "success") + +@patch('accounts.views.auth') +class LoginViewTest(TestCase): + def test_redirects_to_home_page(self, mock_auth): + response = self.client.get('/accounts/login?token=abcd123') + self.assertRedirects(response, '/') + + + def test_calls_authenticate_with_uid_from_get_request(self, mock_auth): + self.client.get('/accounts/login?token=abcd123') + self.assertEqual( + mock_auth.authenticate.call_args, + call(uid='abcd123') + ) + + def test_calls_auth_login_with_user_if_there_is_one(self, mock_auth): + response = self.client.get('/accounts/login?token=abcd123') + self.assertEqual( + mock_auth.login.call_args, + call(response.wsgi_request, mock_auth.authenticate.return_value) + ) + + def test_does_not_login_if_user_is_not_authenticated(self, mock_auth): + mock_auth.authenticate.return_value = None + self.client.get('/accounts/login?token=abcd123') + self.assertEqual(mock_auth.login.called, False) \ No newline at end of file diff --git a/accounts/urls.py b/accounts/urls.py index 10f0e241ced16c3e23ac731cc40a705c2d33ec0d..52fbebb7b6257905b529afc208b970d2cbb23308 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -2,7 +2,7 @@ from django.conf.urls import url from accounts import views urlpatterns = [ - url(r'^send_email$', views.send_login_email, name='send_login_email'), + url(r'^send_login_email$', views.send_login_email, name='send_login_email'), url(r'^login$', views.login, name='login'), url(r'^logout$', views.logout, name='logout'), ] \ No newline at end of file diff --git a/accounts/views.py b/accounts/views.py index 842a06b518c94c675eb4ab9eba362c69e579b010..eb9879e738bc1a6d3d96eb9b3862e20460d14e7e 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -2,32 +2,37 @@ import uuid import sys from django.shortcuts import render, redirect from django.core.mail import send_mail -from django.contrib.auth import authenticate +from django.contrib import auth, messages from django.contrib.auth import login as auth_login, logout as auth_logout +from django.urls import reverse + from accounts.models import Token def send_login_email(request): email = request.POST['email'] - uid = str(uuid.uuid4()) - Token.objects.create(email=email, uid=uid) - print('saving uid', uid, 'for email', email, file=sys.stderr) - url = request.build_absolute_uri(f'/accounts/login?uid={uid}') + token = Token.objects.create(email=email) + url = request.build_absolute_uri( + reverse('login') + '?token=' + str(token.uid) + ) + message_body = f'Use this link to log in:\n\n{url}' send_mail( 'Your login link for Superlists', - f'Use this link to log in:\n\n{url}', + message_body, 'noreply@superlists', [email], ) - return render(request, 'login_email_sent.html') + messages.success( + request, + "Check your email, we've sent you a link you can use to log in." + ) + return redirect('/') def login(request): - print('login view', file=sys.stderr) - uid = request.GET.get('uid') - user = authenticate(uid=uid) - if user is not None: - auth_login(request, user) + user = auth.authenticate(uid=request.GET.get('token')) + if user: + auth.login(request, user) return redirect('/') def logout(request): diff --git a/functional_tests/test_login.py b/functional_tests/test_login.py new file mode 100644 index 0000000000000000000000000000000000000000..1d86b19f474742b0cb99757266e0f8f2fce70a7f --- /dev/null +++ b/functional_tests/test_login.py @@ -0,0 +1,48 @@ +from django.core import mail +from selenium.webdriver.common.keys import Keys +import re + +from .base import FunctionalTest + +TEST_EMAIL = 'edith@example.com' +SUBJECT = 'Your login link for Superlists' + + +class LoginTest(FunctionalTest): + + def test_can_get_email_link_to_log_in(self): + # Edith goes to the awesome superlists site + # and notices a "Log in" section in the navbar for the first time + # It's telling her to enter her email address, so she does + self.browser.get(self.live_server_url) + self.browser.find_element_by_name('email').send_keys(TEST_EMAIL) + self.browser.find_element_by_name('email').send_keys(Keys.ENTER) + + # A message appears telling her an email has been sent + self.wait_for(lambda: self.assertIn( + 'Check your email', + self.browser.find_element_by_tag_name('body').text + )) + + # She checks her email and finds a message + email = mail.outbox[0] + self.assertIn(TEST_EMAIL, email.to) + self.assertEqual(email.subject, SUBJECT) + + # It has a url link in it + self.assertIn('Use this link to log in', email.body) + url_search = re.search(r'http://.+/.+$', email.body) + if not url_search: + self.fail(f'Could not find url in email body:\n{email.body}') + url = url_search.group(0) + self.assertIn(self.live_server_url, url) + + # she clicks it + self.browser.get(url) + + # she is logged in! + # self.wait_for( + # lambda: self.browser.find_element_by_link_text('Log out') + # ) + # navbar = self.browser.find_element_by_css_selector('.navbar') + # self.assertIn(TEST_EMAIL, navbar.text) \ No newline at end of file diff --git a/functional_tests/test_simple_list_creation.py b/functional_tests/test_simple_list_creation.py index 190df28287003c3d5ca248ca50ab0cda8a71575d..90a10407828d23c72adcf7b3ebc79693fb787bad 100644 --- a/functional_tests/test_simple_list_creation.py +++ b/functional_tests/test_simple_list_creation.py @@ -17,8 +17,8 @@ class NewVisitorTest(FunctionalTest): self.browser.get(self.live_server_url) # She notices the page title and header mention to-do lists self.assertIn('To-Do', self.browser.title) - header_text = self.browser.find_element_by_tag_name('h1').text - self.assertIn('To-Do', header_text) + header_text = self.browser.find_element_by_tag_name('h2').text + self.assertIn('Rahmania Astrid Mochtar', header_text) # She is invited to enter a to-do item straight away inputbox = self.browser.find_element_by_id('id_new_item') diff --git a/lists/templates/base.html b/lists/templates/base.html index 68208b536f0de780d567e92ecd7dcd2b73e216ee..d3cc5338a993b1ee18ce22e997f9276f8474e71d 100644 --- a/lists/templates/base.html +++ b/lists/templates/base.html @@ -21,13 +21,29 @@

Logged in as {{ user.email }}

Log out

{% else %} -
+ Enter email to log in: {% csrf_token %}
{% endif %} + {% if messages %} + ​
+ ​
+ ​{% for message in messages %} + ​{% if message.level_tag == 'success' %} + ​
{{ message }}
+ ​{% else %} + ​
{{ message }}
+ ​{% endif %} + ​{% endfor %} + ​
+ ​
+ ​{% endif %} + ​
diff --git a/superlists/settings.py b/superlists/settings.py index 6058613fc9ce259ae66e242d694122575a7127aa..476117e9295ce9102b2ec0035af9227f99d105b3 100644 --- a/superlists/settings.py +++ b/superlists/settings.py @@ -46,7 +46,7 @@ INSTALLED_APPS = [ 'accounts', ] -AUTH_USER_MODEL = 'accounts.ListUser' +AUTH_USER_MODEL = 'accounts.User' AUTHENTICATION_BACKENDS = [ 'accounts.authentication.PasswordlessAuthenticationBackend', ]