diff --git a/.gitignore b/.gitignore index 77aa56e..6ce13ad 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,5 @@ -/env -/.idea +/env* +/.* +/coverage +*.pyc +*.coverage \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..08c9ffa --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Books project + +One Paragraph of project description goes here - TODO + +## Getting Started + +### Prerequisites +Ensure that you have installed the 3rd python version. + +### Installing + +- Run `virtualenv --python=python3 env`. Please name your environment starting with env. +- Enter created environment with running `env\Scripts\activate` on Windows or `source env/bin/activate` on Mac OS. +- Install all necessary requirements `pip install -r requirements.txt` + +## Running project + +Ensure that you are using your virtual environment. + +Run +``` +python manage.py runserver +``` +to start server work. + +## Tests + +Run `pytest` command. +You will receive general testing status in console. Command will create coverage folder with coverage of all modules. \ No newline at end of file diff --git a/books/books/__init__.py b/app/__init__.py similarity index 100% rename from books/books/__init__.py rename to app/__init__.py diff --git a/books/books/asgi.py b/app/asgi.py similarity index 74% rename from books/books/asgi.py rename to app/asgi.py index 4f094bb..0c71a7f 100644 --- a/books/books/asgi.py +++ b/app/asgi.py @@ -1,5 +1,5 @@ """ -ASGI config for books project. +ASGI config for app project. It exposes the ASGI callable as a module-level variable named ``application``. @@ -11,6 +11,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'books.settings') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') application = get_asgi_application() diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..5ffc421 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,34 @@ +from rest_framework import authentication +from rest_framework import exceptions +import jwt + +key = 'secret' + + +class UserAuthentication(authentication.BaseAuthentication): + + def authenticate(self, request): + auth = authentication.get_authorization_header(request).split() + + if not auth or auth[0].lower() != b'bearer' or len(auth) == 1: + raise exceptions.AuthenticationFailed('Invalid token header. No credentials provided.') + elif len(auth) > 2: + raise exceptions.AuthenticationFailed('Invalid token header. Token string should not contain spaces.') + + try: + token = auth[1].decode() + decoded_token = jwt.decode(token, key, algorithm='HS256') + request.META['HTTP_CUSTOM_HEADER'] = decoded_token['books_ids'] + except jwt.ExpiredSignatureError: + raise exceptions.AuthenticationFailed('Token was expired') + except (jwt.DecodeError, UnicodeError): + raise exceptions.AuthenticationFailed('Invalid token header.') + + return decoded_token, None + + +class UsersViewAuthentication(UserAuthentication): + + def authenticate(self, request): + if request.method == 'GET': + return super().authenticate(request) diff --git a/books/polls/__init__.py b/app/books/__init__.py similarity index 100% rename from books/polls/__init__.py rename to app/books/__init__.py diff --git a/app/books/migrations/0001_initial.py b/app/books/migrations/0001_initial.py new file mode 100644 index 0000000..b7df47d --- /dev/null +++ b/app/books/migrations/0001_initial.py @@ -0,0 +1,24 @@ +# Generated by Django 3.0.1 on 2020-01-13 13:05 + +import datetime +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Book', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('description', models.TextField()), + ('creation_date', models.DateTimeField(default=datetime.datetime.now)), + ], + ), + ] diff --git a/app/books/migrations/0002_auto_20200113_1507.py b/app/books/migrations/0002_auto_20200113_1507.py new file mode 100644 index 0000000..ccd7b43 --- /dev/null +++ b/app/books/migrations/0002_auto_20200113_1507.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.1 on 2020-01-13 13:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('books', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='book', + name='description', + field=models.TextField(blank=True), + ), + ] diff --git a/app/books/migrations/0003_book_creator.py b/app/books/migrations/0003_book_creator.py new file mode 100644 index 0000000..d378af0 --- /dev/null +++ b/app/books/migrations/0003_book_creator.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.1 on 2020-01-20 13:16 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ('books', '0002_auto_20200113_1507'), + ] + + operations = [ + migrations.AddField( + model_name='book', + name='creator', + field=models.ForeignKey(default=1, on_delete=django.db.models.deletion.CASCADE, to='users.User'), + ), + ] diff --git a/app/books/migrations/0004_auto_20200120_1518.py b/app/books/migrations/0004_auto_20200120_1518.py new file mode 100644 index 0000000..6125449 --- /dev/null +++ b/app/books/migrations/0004_auto_20200120_1518.py @@ -0,0 +1,20 @@ +# Generated by Django 3.0.1 on 2020-01-20 13:18 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ('books', '0003_book_creator'), + ] + + operations = [ + migrations.AlterField( + model_name='book', + name='creator', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='users.User'), + ), + ] diff --git a/books/polls/migrations/__init__.py b/app/books/migrations/__init__.py similarity index 100% rename from books/polls/migrations/__init__.py rename to app/books/migrations/__init__.py diff --git a/app/books/models.py b/app/books/models.py new file mode 100644 index 0000000..c420c4c --- /dev/null +++ b/app/books/models.py @@ -0,0 +1,16 @@ +from django.db import models +from datetime import datetime +from app.users.models import User + + +class Book(models.Model): + name = models.CharField(max_length=50) + description = models.TextField(blank=True) + creation_date = models.DateTimeField(default=datetime.now) + creator = models.ForeignKey(User, on_delete=models.CASCADE, related_name='books') + + def obj(self): + return {'id': self.id, 'name': self.name, 'description': self.description, 'creator': self.creator.obj()} + + def __str__(self): + return 'id: ' + str(self.id) + ', name:' + str(self.name) + ', description:' + str(self.description) diff --git a/app/books/permissions.py b/app/books/permissions.py new file mode 100644 index 0000000..5d9a95d --- /dev/null +++ b/app/books/permissions.py @@ -0,0 +1,13 @@ +from rest_framework.exceptions import AuthenticationFailed + + +def book_write_permission(f): + + def check_permission(self, request, book_id): + if int(book_id) in request.META['HTTP_CUSTOM_HEADER']: + return f(self, request, book_id) + else: + raise AuthenticationFailed() + + return check_permission + diff --git a/app/books/urls.py b/app/books/urls.py new file mode 100644 index 0000000..a5b7dc5 --- /dev/null +++ b/app/books/urls.py @@ -0,0 +1,17 @@ +from django.conf.urls import url +from . import views + +urlpatterns = [ + url('^$', views.BooksView.as_view( + { + 'get': 'get', + 'post': 'post' + } + ), name='books_list'), + url('^(?P[0-9]+)/?$', views.SingleBookView.as_view( + { + 'get': 'get', + 'delete': 'delete', + 'put': 'update' + }), name='book'), +] diff --git a/app/books/views.py b/app/books/views.py new file mode 100644 index 0000000..90c2f87 --- /dev/null +++ b/app/books/views.py @@ -0,0 +1,63 @@ +from django.http import JsonResponse +from rest_framework.viewsets import ViewSet +from .models import Book +from django.core.exceptions import ObjectDoesNotExist +from app.auth import UserAuthentication +from .permissions import book_write_permission + + +class BooksView(ViewSet): + authentication_classes = (UserAuthentication,) + + def get(self, request): + all_books = list(Book.objects.all()) + return JsonResponse({'books': [b.obj() for b in all_books]}) + + def post(self, request): + name = request.data.get('name') + description = request.data.get('description') + creator_id = request.data.get('creator') + if name and creator_id: + new_book = Book.objects.create(name=name, description=description, creator_id=creator_id) + return JsonResponse({'message': 'Successfully created book, id: ' + str(new_book.id)}, status=201) + return JsonResponse({'message': 'Invalid data'}, status=400) + + +class SingleBookView(ViewSet): + authentication_classes = (UserAuthentication,) + + def get(self, request, book_id): + try: + book = Book.objects.get(id=book_id) + return JsonResponse({'book': book.obj()}) + except ObjectDoesNotExist: + return JsonResponse({'message': 'Book doesn\'t exist'}, status=401) + + @book_write_permission + def delete(self, request, book_id): + try: + removed_book = Book.objects.get(id=book_id) + Book.objects.filter(id=book_id).delete() + response_message = {'message': 'removed book'} + response_message['book'] = removed_book.obj() + return JsonResponse(response_message) + except ObjectDoesNotExist: + return JsonResponse({'message': 'Book doesn\'t exist'}, status=401) + + @book_write_permission + def update(self, request, book_id): + name = request.data.get('name') + description = request.data.get('description') + if name or description: + try: + existing_book = Book.objects.filter(id=book_id) + if not existing_book: + return JsonResponse({'message': 'Book doesn\'t exist'}, status=401) + if name: + existing_book.update(name=name) + if description: + existing_book.update(description=description) + return JsonResponse({'message': 'Successfully updated book', 'book': existing_book[0].obj()}) + except ObjectDoesNotExist: + return JsonResponse({'message': 'Book doesn\'t exist'}, status=401) + return JsonResponse({'message': 'Invalid data'}, status=400) diff --git a/books/books/settings.py b/app/settings.py similarity index 92% rename from books/books/settings.py rename to app/settings.py index 05ac9f6..3a0ae65 100644 --- a/books/books/settings.py +++ b/app/settings.py @@ -1,5 +1,5 @@ """ -Django settings for books project. +Django settings for app project. Generated by 'django-admin startproject' using Django 3.0.1. @@ -31,25 +31,28 @@ # Application definition INSTALLED_APPS = [ + 'app.books', + 'app.users', + 'rest_framework.authtoken', 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', - 'django.contrib.staticfiles', + 'django.contrib.staticfiles' ] MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', + # 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] -ROOT_URLCONF = 'books.urls' +ROOT_URLCONF = 'app.urls' TEMPLATES = [ { @@ -67,7 +70,7 @@ }, ] -WSGI_APPLICATION = 'books.wsgi.application' +WSGI_APPLICATION = 'app.wsgi.application' # Database diff --git a/books/books/urls.py b/app/urls.py similarity index 71% rename from books/books/urls.py rename to app/urls.py index 9cfe693..5f5e1e5 100644 --- a/books/books/urls.py +++ b/app/urls.py @@ -1,4 +1,4 @@ -"""books URL Configuration +"""app URL Configuration The `urlpatterns` list routes URLs to views. For more information please see: https://docs.djangoproject.com/en/3.0/topics/http/urls/ @@ -15,8 +15,15 @@ """ from django.contrib import admin from django.urls import include, path +from django.conf.urls import url +from app.users.views import UserLogin urlpatterns = [ - path('polls/', include('polls.urls')), + path('users/', include('app.users.urls')), + path('books/', include('app.books.urls')), path('admin/', admin.site.urls), + url('token/', UserLogin.as_view( + { + 'post': 'post' + }), name='login') ] diff --git a/books/db.sqlite3 b/app/users/__init__.py similarity index 100% rename from books/db.sqlite3 rename to app/users/__init__.py diff --git a/app/users/migrations/0001_initial.py b/app/users/migrations/0001_initial.py new file mode 100644 index 0000000..e99d122 --- /dev/null +++ b/app/users/migrations/0001_initial.py @@ -0,0 +1,22 @@ +# Generated by Django 3.0.1 on 2020-01-20 12:56 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('username', models.CharField(max_length=30)), + ('password_hash', models.TextField(max_length=64)), + ], + ), + ] diff --git a/app/users/migrations/__init__.py b/app/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/users/models.py b/app/users/models.py new file mode 100644 index 0000000..6282fc3 --- /dev/null +++ b/app/users/models.py @@ -0,0 +1,34 @@ +from datetime import datetime, timedelta +import hashlib +import jwt +from django.db import models + +key = 'secret' + + +class User(models.Model): + username = models.CharField(max_length=30) + password_hash = models.TextField(max_length=64) + + def obj(self): + return {'id': str(self.id), 'username': self.username} + + def __eq__(self, other): + return self.username == other.username + + def __hash__(self): + return self.id + + def create_user_token(self): + from app.books.models import Book + # print(User.objects.get(id=1).books) + books = Book.objects.filter(creator_id=self.id) + books_ids = [x.id for x in books] + return jwt.encode({'username': self.username, + 'books_ids': books_ids, + 'exp': (datetime.now() + timedelta(days=1)).timestamp()}, + key, algorithm='HS256').decode() + + @staticmethod + def get_hash(data): + return hashlib.sha256(data.encode('utf-8')).hexdigest() diff --git a/app/users/urls.py b/app/users/urls.py new file mode 100644 index 0000000..f846112 --- /dev/null +++ b/app/users/urls.py @@ -0,0 +1,18 @@ +from django.conf.urls import url +from . import views + +urlpatterns = [ + url('^$', views.UsersView.as_view( + { + 'get': 'get', + 'post': 'post' + } + ), name='users_list'), + url('^(?P[0-9]+)/?$', views.SingleUserView.as_view( + { + 'get': 'get', + 'delete': 'delete', + 'put': 'update' + }), name='user'), + +] diff --git a/app/users/views.py b/app/users/views.py new file mode 100644 index 0000000..2a2def4 --- /dev/null +++ b/app/users/views.py @@ -0,0 +1,73 @@ +from django.http import JsonResponse +from rest_framework.viewsets import ViewSet +from rest_framework.exceptions import AuthenticationFailed +from app.users.models import User +from django.core.exceptions import ObjectDoesNotExist +from app.auth import UserAuthentication, UsersViewAuthentication + + +class UsersView(ViewSet): + authentication_classes = (UsersViewAuthentication,) + + def get(self, request): + all_users = list(User.objects.all()) + return JsonResponse({'users': [b.obj() for b in all_users]}) + + def post(self, request): + username = request.data.get('username') + password = request.data.get('password') + if username and password: + is_user_present = User.objects.filter(username=username) + if len(is_user_present): + return JsonResponse({'message': 'User with such username already exists'}, status=409) + new_user = User.objects.create(username=username, password_hash=User.get_hash(password)) + return JsonResponse({'message': 'Successfully created user, id: ' + str(new_user.id)}, status=201) + return JsonResponse({'message': 'Invalid data'}, status=400) + + +class SingleUserView(ViewSet): + authentication_classes = (UserAuthentication,) + + def get(self, request, user_id: int): + try: + user = User.objects.get(id=user_id) + return JsonResponse({'user': user.obj()}) + except ObjectDoesNotExist: + return JsonResponse({'message': 'User doesn\'t exist'}, status=401) + + def update(self, request, user_id: int): + new_password = request.data.get('password') + if new_password: + existing_user = User.objects.filter(id=user_id) + if not existing_user: + return JsonResponse({'message': 'User doesn\'t exist'}, status=401) + existing_user.update(password_hash=User.get_hash(new_password)) + return JsonResponse({'message': 'Successfully updated user'}) + return JsonResponse({'message': 'Invalid data'}, status=400) + + def delete(self, request, user_id): + try: + removed_user = User.objects.get(id=user_id) + User.objects.filter(id=user_id).delete() + response_message = {'message': 'removed user'} + response_message['user'] = removed_user.obj() + return JsonResponse(response_message) + except Exception as e: + return JsonResponse({'message': str(e)}, status=409) + + +class UserLogin(ViewSet): + + def post(self, request): + username = request.data.get('username') + password = request.data.get('password') + if username and password: + try: + user = User.objects.get(username=username) + if user.password_hash == User.get_hash(password): + return JsonResponse({'access_token': User.create_user_token(user)}, status=201) + else: + raise AuthenticationFailed('Password is incorrect') + except ObjectDoesNotExist: + return JsonResponse({'message': 'User wasn\'t found'}, status=401) + return JsonResponse({'message': 'Invalid data'}, status=400) diff --git a/books/books/wsgi.py b/app/wsgi.py similarity index 74% rename from books/books/wsgi.py rename to app/wsgi.py index 60b2a6a..863ddf4 100644 --- a/books/books/wsgi.py +++ b/app/wsgi.py @@ -1,5 +1,5 @@ """ -WSGI config for books project. +WSGI config for app project. It exposes the WSGI callable as a module-level variable named ``application``. @@ -11,6 +11,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'books.settings') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') application = get_wsgi_application() diff --git a/books/books/__pycache__/__init__.cpython-38.pyc b/books/books/__pycache__/__init__.cpython-38.pyc deleted file mode 100644 index 344a058..0000000 Binary files a/books/books/__pycache__/__init__.cpython-38.pyc and /dev/null differ diff --git a/books/books/__pycache__/settings.cpython-38.pyc b/books/books/__pycache__/settings.cpython-38.pyc deleted file mode 100644 index 57070c2..0000000 Binary files a/books/books/__pycache__/settings.cpython-38.pyc and /dev/null differ diff --git a/books/books/__pycache__/urls.cpython-38.pyc b/books/books/__pycache__/urls.cpython-38.pyc deleted file mode 100644 index 77d6230..0000000 Binary files a/books/books/__pycache__/urls.cpython-38.pyc and /dev/null differ diff --git a/books/books/__pycache__/wsgi.cpython-38.pyc b/books/books/__pycache__/wsgi.cpython-38.pyc deleted file mode 100644 index d563047..0000000 Binary files a/books/books/__pycache__/wsgi.cpython-38.pyc and /dev/null differ diff --git a/books/polls/__pycache__/__init__.cpython-38.pyc b/books/polls/__pycache__/__init__.cpython-38.pyc deleted file mode 100644 index 939731c..0000000 Binary files a/books/polls/__pycache__/__init__.cpython-38.pyc and /dev/null differ diff --git a/books/polls/__pycache__/urls.cpython-38.pyc b/books/polls/__pycache__/urls.cpython-38.pyc deleted file mode 100644 index 38a94fd..0000000 Binary files a/books/polls/__pycache__/urls.cpython-38.pyc and /dev/null differ diff --git a/books/polls/__pycache__/views.cpython-38.pyc b/books/polls/__pycache__/views.cpython-38.pyc deleted file mode 100644 index 50ee168..0000000 Binary files a/books/polls/__pycache__/views.cpython-38.pyc and /dev/null differ diff --git a/books/polls/admin.py b/books/polls/admin.py deleted file mode 100644 index 8c38f3f..0000000 --- a/books/polls/admin.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.contrib import admin - -# Register your models here. diff --git a/books/polls/apps.py b/books/polls/apps.py deleted file mode 100644 index d0f109e..0000000 --- a/books/polls/apps.py +++ /dev/null @@ -1,5 +0,0 @@ -from django.apps import AppConfig - - -class PollsConfig(AppConfig): - name = 'polls' diff --git a/books/polls/models.py b/books/polls/models.py deleted file mode 100644 index 71a8362..0000000 --- a/books/polls/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/books/polls/tests.py b/books/polls/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/books/polls/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/books/polls/urls.py b/books/polls/urls.py deleted file mode 100644 index 88a9cac..0000000 --- a/books/polls/urls.py +++ /dev/null @@ -1,7 +0,0 @@ -from django.urls import path - -from . import views - -urlpatterns = [ - path('', views.index, name='index'), -] diff --git a/books/polls/views.py b/books/polls/views.py deleted file mode 100644 index 3aa92f6..0000000 --- a/books/polls/views.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.http import HttpResponse - - -def index(request): - return HttpResponse("Hello, world. You're at the polls index!!!") - diff --git a/db.sqlite3 b/db.sqlite3 new file mode 100644 index 0000000..badcc55 Binary files /dev/null and b/db.sqlite3 differ diff --git a/books/manage.py b/manage.py similarity index 88% rename from books/manage.py rename to manage.py index e357ea9..a3f0719 100644 --- a/books/manage.py +++ b/manage.py @@ -5,7 +5,7 @@ def main(): - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'books.settings') + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'app.settings') try: from django.core.management import execute_from_command_line except ImportError as exc: diff --git a/requirements.txt b/requirements.txt index 7a0dd0f..126218f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,23 @@ asgiref==3.2.3 +atomicwrites==1.3.0 +attrs==19.3.0 +colorama==0.4.3 +coverage==5.0.1 Django==3.0.1 +djangorestframework==3.11.0 +importlib-metadata==1.3.0 +mock==3.0.5 +more-itertools==8.0.2 +packaging==20.0 +pluggy==0.13.1 +py==1.8.1 +PyJWT==1.7.1 +pyparsing==2.4.6 +pytest==5.3.2 +pytest-cov==2.8.1 +pytest-django==3.7.0 pytz==2019.3 +six==1.13.0 sqlparse==0.3.0 +wcwidth==0.1.8 +zipp==0.6.0 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..1a45469 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,7 @@ +[tool:pytest] +DJANGO_SETTINGS_MODULE=app.settings +testpaths = tests/ +console_output_style = progress +addopts = -s --cov-report=html:coverage --cov=app/ +filterwarnings = + ignore::DeprecationWarning diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/__init__.cpython-38.pyc b/tests/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..3481cbf Binary files /dev/null and b/tests/__pycache__/__init__.cpython-38.pyc differ diff --git a/tests/books/__init__.py b/tests/books/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/books/mocks.py b/tests/books/mocks.py new file mode 100644 index 0000000..6c7f673 --- /dev/null +++ b/tests/books/mocks.py @@ -0,0 +1,38 @@ +import mock +from app.books.models import Book + + +class BookMock: + + def __init__(self): + self.objects = BookMock.BookMockObjects() + + class BookMockObjects: + + def __init__(self): + test_book = Book(name='test book') + test_book.id = 1 + test_book.obj = mock.Mock(return_value={'id': 'None', 'name': 'test book', 'description': ''}) + self.all = mock.Mock(return_value=[test_book]) + self.create = mock.Mock(return_value=test_book) + self.get = mock.Mock(return_value=test_book) + self.filter = mock.Mock(return_value=FilterMock(test_book)) + + +class FilterMock: + + def __init__(self, test_book): + self.test_book = test_book + self.return_value = mock.Mock(return_value=iter([test_book])) + self.delete = mock.Mock(return_value=(1, {'books.Book': 1})) + self.update = mock.Mock(return_value=[test_book]) + + def __getitem__(self, indices): + return self.test_book + + +class MockUserAuthentication: + + def authenticate(self, request): + request.META['HTTP_CUSTOM_HEADER'] = [1] + return 'decoded_token', None diff --git a/tests/books/test_books.py b/tests/books/test_books.py new file mode 100644 index 0000000..1f2e5f3 --- /dev/null +++ b/tests/books/test_books.py @@ -0,0 +1,131 @@ +from django.test import Client +from django.urls import reverse +from mock import PropertyMock, patch +from app.auth import UserAuthentication +from .mocks import BookMock, MockUserAuthentication +import mock +from django.core.exceptions import ObjectDoesNotExist + + +class TestBooksView: + + def setup(self) -> None: + self.book_mock = BookMock() + UserAuthentication.authenticate = MockUserAuthentication.authenticate + + def test_get(self): + with patch('app.books.views.Book.objects', new_callable=PropertyMock) as mock_books_objects: + mock_books_objects.return_value = self.book_mock.objects + + resp = Client().get(reverse('books_list')) + + assert {'books': [{'description': '', 'id': 'None', 'name': 'test book'}]} == resp.json() + assert 200 == resp.status_code + self.book_mock.objects.all.assert_called() + + def test_post(self): + with patch('app.books.views.Book.objects', new_callable=PropertyMock) as mock_books_objects: + mock_books_objects.return_value = self.book_mock.objects + + resp = Client().post(reverse('books_list'), {'name': 'test name', 'description': 'test description', + 'creator': 'test creator'}) + + assert {'message': 'Successfully created book, id: 1'} == resp.json() + assert 201 == resp.status_code + self.book_mock.objects.create.assert_called() + + def test_post_with_invalid_data(self): + resp = Client().post(reverse('books_list'), {'test': 'test data'}) + + assert {'message': 'Invalid data'} == resp.json() + assert 400 == resp.status_code + + +class TestSingleBookView: + + def setup(self) -> None: + self.book_mock = BookMock() + UserAuthentication.authenticate = MockUserAuthentication.authenticate + + def test_get_valid_book_id(self): + with patch('app.books.views.Book.objects', new_callable=PropertyMock) as mock_books_objects: + mock_books_objects.return_value = self.book_mock.objects + + resp = Client().get(reverse('book', args=[1])) + + assert {'book': {'id': 'None', 'name': 'test book', 'description': ''}} == resp.json() + assert 200 == resp.status_code + self.book_mock.objects.get.assert_called_with(id='1') + + def test_get_with_invalid_data(self): + with patch('app.books.views.Book.objects', new_callable=PropertyMock) as mock_books_objects: + mock_books_objects.return_value = self.book_mock.objects + self.book_mock.objects.get = mock.Mock(side_effect=ObjectDoesNotExist) + + resp = Client().get(reverse('book', args=[1])) + + assert {'message': 'Book doesn\'t exist'} == resp.json() + assert 401 == resp.status_code + self.book_mock.objects.get.assert_called_with(id='1') + + def test_delete_existing_book(self): + with patch('app.books.views.Book.objects', new_callable=PropertyMock) as mock_books_objects: + mock_books_objects.return_value = self.book_mock.objects + + resp = Client().delete(reverse('book', args=[1])) + + assert {'message': 'removed book', 'book': {'id': 'None', 'name': 'test book', 'description': ''}} == \ + resp.json() + assert 200 == resp.status_code + self.book_mock.objects.get.assert_called_with(id='1') + self.book_mock.objects.filter.assert_called_with(id='1') + self.book_mock.objects.filter().delete.assert_called() + + def test_delete_book_not_existing_book(self): + with patch('app.books.views.Book.objects', new_callable=PropertyMock) as mock_books_objects: + mock_books_objects.return_value = self.book_mock.objects + self.book_mock.objects.get = mock.Mock(side_effect=ObjectDoesNotExist) + + resp = Client().delete(reverse('book', args=[1])) + + assert {'message': 'Book doesn\'t exist'} == resp.json() + assert 401 == resp.status_code + self.book_mock.objects.get.assert_called_with(id='1') + + def test_update(self): + with patch('app.books.views.Book.objects', new_callable=PropertyMock) as mock_books_objects: + mock_books_objects.return_value = self.book_mock.objects + + resp = Client().put( + reverse('book', args=[1]), + {'name': 'updated_name', 'description': 'updated_description'}, + content_type='application/json') + + assert {'message': 'Successfully updated book', 'book': {'id': 'None', 'name': 'test book', + 'description': ''}} == resp.json() + assert 200 == resp.status_code + self.book_mock.objects.filter.assert_called_with(id='1') + self.book_mock.objects.filter().update.assert_called() + + def test_update_book_do_not_exists(self): + with patch('app.books.views.Book.objects', new_callable=PropertyMock) as mock_books_objects: + mock_books_objects.return_value = self.book_mock.objects + self.book_mock.objects.filter = mock.Mock(side_effect=ObjectDoesNotExist) + + resp = Client().put( + reverse('book', args=[1]), + {'name': 'updated_name'}, + content_type='application/json',) + + assert {'message': "Book doesn't exist"} == resp.json() + assert 401 == resp.status_code + self.book_mock.objects.filter.assert_called_with(id='1') + + def test_update_invalid_data(self): + resp = Client().put(reverse( + 'book', args=[1]), + {}, + content_type='application/json') + + assert {'message': 'Invalid data'} == resp.json() + assert 400 == resp.status_code diff --git a/tests/test_auth.py b/tests/test_auth.py new file mode 100644 index 0000000..c20dee1 --- /dev/null +++ b/tests/test_auth.py @@ -0,0 +1,71 @@ +import pytest +import jwt +import mock +from app.auth import UserAuthentication +from rest_framework.test import APIRequestFactory +import app.auth +from rest_framework import exceptions + + +class TestUserAuthentication: + + def setup(self) -> None: + self.factory = APIRequestFactory() + + def test_authenticate_without_token(self): + request = self.factory.post('/users/', + {}, + HTTP_AUTHORIZATION='Bearer') + + with pytest.raises(exceptions.AuthenticationFailed) as e: + UserAuthentication().authenticate(request) + + assert 'Invalid token header. No credentials provided.' == str(e.value) + + def test_authenticate_too_many_token_words(self): + request = self.factory.post('/users/', + {}, + HTTP_AUTHORIZATION='Bearer 1 2') + + with pytest.raises(exceptions.AuthenticationFailed) as e: + UserAuthentication().authenticate(request) + + assert 'Invalid token header. Token string should not contain spaces.' == str(e.value) + + def test_authenticate_valid_token(self): + decoded_token = {'username': 'test username', + 'books_ids': [1]} + jwt.decode = mock.Mock(return_value=decoded_token) + request = self.factory.post('/users/', + {}, + HTTP_AUTHORIZATION='Bearer {}'.format('test_token')) + + result = UserAuthentication().authenticate(request) + + assert decoded_token, None == result + assert request.META['HTTP_CUSTOM_HEADER'] == [1] + jwt.decode.assert_called_with('test_token', app.auth.key, algorithm='HS256') + + def test_authenticate_expired_token(self): + jwt.decode = mock.Mock(side_effect=jwt.ExpiredSignatureError()) + request = self.factory.post('/users/', + {}, + HTTP_AUTHORIZATION='Bearer {}'.format('test_token')) + + with pytest.raises(exceptions.AuthenticationFailed) as e: + UserAuthentication().authenticate(request) + + assert 'Token was expired' == str(e.value) + jwt.decode.assert_called_with('test_token', app.auth.key, algorithm='HS256') + + def test_authenticate_invalid_token(self): + jwt.decode = mock.Mock(side_effect=jwt.DecodeError()) + request = self.factory.post('/users/', + {}, + HTTP_AUTHORIZATION='Bearer {}'.format('test_token')) + + with pytest.raises(exceptions.AuthenticationFailed) as e: + UserAuthentication().authenticate(request) + + assert 'Invalid token header.' == str(e.value) + jwt.decode.assert_called_with('test_token', app.auth.key, algorithm='HS256') diff --git a/tests/users/__init__.py b/tests/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/users/__pycache__/__init__.cpython-38.pyc b/tests/users/__pycache__/__init__.cpython-38.pyc new file mode 100644 index 0000000..5ac5b59 Binary files /dev/null and b/tests/users/__pycache__/__init__.cpython-38.pyc differ diff --git a/tests/users/__pycache__/test_data.cpython-38-pytest-5.3.2.pyc b/tests/users/__pycache__/test_data.cpython-38-pytest-5.3.2.pyc new file mode 100644 index 0000000..6a5e2cd Binary files /dev/null and b/tests/users/__pycache__/test_data.cpython-38-pytest-5.3.2.pyc differ diff --git a/tests/users/__pycache__/test_data.cpython-38.pyc b/tests/users/__pycache__/test_data.cpython-38.pyc new file mode 100644 index 0000000..1298d24 Binary files /dev/null and b/tests/users/__pycache__/test_data.cpython-38.pyc differ diff --git a/tests/users/mocks.py b/tests/users/mocks.py new file mode 100644 index 0000000..3a3b231 --- /dev/null +++ b/tests/users/mocks.py @@ -0,0 +1,41 @@ +import mock +from app.users.models import User + + +class UserMock: + + def __init__(self): + self.objects = UserMock.UserMockObjects() + + class UserMockObjects: + + def __init__(self): + test_user = User(username='test', password_hash='test_hash') + self.all = mock.Mock(return_value=[test_user]) + self.create = mock.Mock(return_value=test_user) + self.get = mock.Mock(return_value=test_user) + self.filter = mock.Mock(return_value=NonEmptyFilterMock(test_user)) + + +class NonEmptyFilterMock: + + def __init__(self, test_user): + self.return_value = mock.Mock(return_value=iter([test_user])) + self.delete = mock.Mock(return_value=(1, {'users.User': 1})) + self.update = mock.Mock(return_value=[test_user]) + + def __len__(self): + return 1 + + +class EmptyFilterMock: + + def __len__(self): + return 0 + + +class MockUserAuthentication: + + def authenticate(self, request): + request.META['HTTP_CUSTOM_HEADER'] = [1] + return 'decoded_token', None diff --git a/tests/users/test_users.py b/tests/users/test_users.py new file mode 100644 index 0000000..7966374 --- /dev/null +++ b/tests/users/test_users.py @@ -0,0 +1,185 @@ +from django.test import Client +from django.urls import reverse +from mock import PropertyMock, patch +from .mocks import UserMock, EmptyFilterMock, MockUserAuthentication +import mock +from django.core.exceptions import ObjectDoesNotExist +from app.auth import UserAuthentication + + +class TestUsersView: + + def setup(self) -> None: + self.user_mock = UserMock() + + def test_get_when_users_arent_created_returns_empty_list(self): + UserAuthentication.authenticate = MockUserAuthentication.authenticate + with patch('app.users.views.User.objects', new_callable=PropertyMock) as mock_users_objects: + mock_users_objects.return_value = self.user_mock.objects + + resp = Client().get(reverse('users_list')) + + assert {'users': [{'id': 'None', 'username': 'test'}]} == resp.json() + assert 200 == resp.status_code + self.user_mock.objects.all.assert_called() + + def test_post_new_user(self): + with patch('app.users.views.User.objects', new_callable=PropertyMock) as mock_users_objects: + mock_users_objects.return_value = self.user_mock.objects + self.user_mock.objects.filter = mock.Mock(return_value=EmptyFilterMock()) + + resp = Client().post(reverse('users_list'), {'username': 'test username', 'password': 'test password'}) + + assert {'message': 'Successfully created user, id: None'} == resp.json() + assert 201 == resp.status_code + self.user_mock.objects.create.assert_called() + + def test_post_existing_user(self): + with patch('app.users.views.User.objects', new_callable=PropertyMock) as mock_users_objects: + mock_users_objects.return_value = self.user_mock.objects + + resp = Client().post(reverse('users_list'), {'username': 'test username', 'password': 'test password'}) + + assert {'message': 'User with such username already exists'} == resp.json() + assert 409 == resp.status_code + + def test_post_with_invalid_data(self): + resp = Client().post(reverse('users_list'), {'test': 'test data'}) + + assert {'message': 'Invalid data'} == resp.json() + assert 400 == resp.status_code + + +class TestSingleUserView: + + def setup(self) -> None: + self.user_mock = UserMock() + UserAuthentication.authenticate = MockUserAuthentication.authenticate + + def test_get_valid_user_id(self): + with patch('app.users.views.User.objects', new_callable=PropertyMock) as mock_users_objects: + mock_users_objects.return_value = self.user_mock.objects + + resp = Client().get(reverse('user', args=[1])) + + assert {'user': {'id': 'None', 'username': 'test'}} == resp.json() + assert 200 == resp.status_code + self.user_mock.objects.get.assert_called_with(id='1') + + def test_get_not_valid_user_id(self): + with patch('app.users.views.User.objects', new_callable=PropertyMock) as mock_users_objects: + mock_users_objects.return_value = self.user_mock.objects + self.user_mock.objects.get = mock.Mock(side_effect=ObjectDoesNotExist) + + resp = Client().get(reverse('user', args=[1])) + + assert {'message': "User doesn't exist"} == resp.json() + assert 401 == resp.status_code + self.user_mock.objects.get.assert_called_with(id='1') + + def test_update_existing_user(self): + with patch('app.users.views.User.objects', new_callable=PropertyMock) as mock_users_objects: + mock_users_objects.return_value = self.user_mock.objects + + resp = Client().put( + reverse('user', args=[1]), + {'password': 'updated_password'}, + content_type='application/json') + + assert {'message': 'Successfully updated user'} == resp.json() + assert 200 == resp.status_code + self.user_mock.objects.filter.assert_called_with(id='1') + self.user_mock.objects.filter().update.assert_called() + + def test_update_not_existing_user(self): + with patch('app.users.views.User.objects', new_callable=PropertyMock) as mock_users_objects: + mock_users_objects.return_value = self.user_mock.objects + self.user_mock.objects.filter = mock.Mock(return_value=EmptyFilterMock()) + + resp = Client().put( + reverse('user', args=[1]), + {'password': 'updated_password'}, + content_type='application/json') + + assert {'message': "User doesn't exist"} == resp.json() + assert 401 == resp.status_code + self.user_mock.objects.filter.assert_called_with(id='1') + + def test_update_invalid_data(self): + resp = Client().put( + reverse('user', args=[1]), + {'test': 'test_data'}, + content_type='application/json') + + assert {'message': 'Invalid data'} == resp.json() + + def test_delete_existing_user(self): + with patch('app.users.views.User.objects', new_callable=PropertyMock) as mock_users_objects: + mock_users_objects.return_value = self.user_mock.objects + + resp = Client().delete(reverse('user', args=[1])) + + assert {'message': 'removed user', 'user': {'id': 'None', 'username': 'test'}} == resp.json() + assert 200 == resp.status_code + self.user_mock.objects.get.assert_called_with(id='1') + self.user_mock.objects.filter.assert_called_with(id='1') + + def test_delete_not_existing_user(self): + with patch('app.users.views.User.objects', new_callable=PropertyMock) as mock_users_objects: + mock_users_objects.return_value = self.user_mock.objects + self.user_mock.objects.get = mock.Mock(side_effect=ObjectDoesNotExist('Test Exception')) + + resp = Client().delete(reverse('user', args=[1])) + + assert {'message': 'Test Exception'} == resp.json() + assert 409 == resp.status_code + self.user_mock.objects.get.assert_called_with(id='1') + + +class TestUserLogin: + + def setup(self) -> None: + self.user_mock = UserMock() + + @mock.patch('app.users.views.User.get_hash', return_value='test_hash') + @mock.patch('app.users.views.User.create_user_token', return_value='test_token') + def test_post_valid_username_and_password(self, mock_create_user_token, mock_get_hash): + with patch('app.users.views.User.objects', new_callable=PropertyMock) as mock_users_objects: + mock_users_objects.return_value = self.user_mock.objects + + resp = Client().post(reverse('login'), {'username': 'username', 'password': 'password'}) + + assert {'access_token': 'test_token'} == resp.json() + assert 201 == resp.status_code + self.user_mock.objects.get.assert_called_with(username='username') + mock_create_user_token.assert_called_with(self.user_mock.objects.get.return_value) + mock_get_hash.assert_called_with('password') + + @mock.patch('app.users.views.User.get_hash', return_value='test_incorrect_hash') + def test_post_valid_username_not_valid_password(self, mock_get_hash): + with patch('app.users.views.User.objects', new_callable=PropertyMock) as mock_users_objects: + mock_users_objects.return_value = self.user_mock.objects + + resp = Client().post(reverse('login'), {'username': 'username', 'password': 'password'}) + + assert {'detail': 'Password is incorrect'} == resp.json() + assert 403 == resp.status_code + self.user_mock.objects.get.assert_called_with(username='username') + mock_get_hash.assert_called_with('password') + + def test_post_not_valid_username(self): + with patch('app.users.views.User.objects', new_callable=PropertyMock) as mock_users_objects: + mock_users_objects.return_value = self.user_mock.objects + self.user_mock.objects.get = mock.Mock(side_effect=ObjectDoesNotExist('Test Exception')) + + resp = Client().post(reverse('login'), {'username': 'username', 'password': 'password'}) + + assert {'message': "User wasn't found"} == resp.json() + assert 401 == resp.status_code + self.user_mock.objects.get.assert_called_with(username='username') + + def test_post_invalid_data(self): + resp = Client().post(reverse('login'), {'test': 'test'}) + + assert {'message': 'Invalid data'} == resp.json() + assert 400 == resp.status_code