diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..955a583 Binary files /dev/null and b/.coverage differ diff --git a/.env.example b/.env.example index 36492fc..7ef9148 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,4 @@ POSTGRES_USER=name POSTGRES_PASSWORD=password POSTGRES_DB=database-name POSTGRES_HOST=localhost +DEBUG=True diff --git a/README.md b/README.md index ea6674c..99dc591 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Django CI](https://github.com/NoobCoder12/DevSocial/actions/workflows/django-tests.yml/badge.svg)](https://github.com/NoobCoder12/DevSocial/actions/workflows/django-tests.yml) -> Version 1.2.1 +> Version 1.3.0 DevSocialApp is a social media platform designed for developers to share posts, interact with each other through likes and comments, and follow their peers. @@ -40,7 +40,8 @@ The project taught me: - **Database**: PostgreSQL - **Testing**: - **Pytest-Django**: For robust integration testing. - - **Model Bakery**: For efficient test data generation and relationship handling. + - **Model Bakery**: For efficient test data generation and relationship handling. + - **pytest-cov**: For test coverage reporting. - **DevOps**: Docker, Django Debug Toolbar. ## Project Structure @@ -80,9 +81,9 @@ The project taught me: ├── requirements.txt # Python dependencies ├── .env.example # Environment variables template ├── .gitignore +├── init.sql # Initial database setup script ├── docker-compose.yml ├── Dockerfile -├── init.sql └── README.md ``` @@ -100,10 +101,11 @@ To ensure the reliability of the social interactions and data integrity, the pro To run tests locally with coverage: ``` + docker compose up -d pytest --cov=backend/apps --cov-report=term-missing ``` -All test files are located in app's folders. +All test files are located in app's folders. The test suite includes 90+ tests (integration tests for DRF API endpoints and Django unit tests). ## Setup Instructions @@ -122,6 +124,7 @@ POSTGRES_USER=name POSTGRES_PASSWORD=password POSTGRES_DB=database-name POSTGRES_HOST=localhost +DEBUG=True ``` @@ -148,6 +151,12 @@ Things I'd add if I continue this project: ## Changelog +### v1.3.0 +- Added pytest for DRF endpoints. +- 93% code coverage achieved across 90 tests. +- Added GitHub Actions CI/CD pipeline with coverage reporting. +- Added type hints to views. + ### v1.2.1 - Improved Swagger documentation with endpoint descriptions and parameter types diff --git a/backend/apps/api/tests/test_comments.py b/backend/apps/api/tests/test_comments.py new file mode 100644 index 0000000..d5eeff8 --- /dev/null +++ b/backend/apps/api/tests/test_comments.py @@ -0,0 +1,144 @@ +import pytest +from django.urls import reverse +from model_bakery import baker +from backend.apps.posts.models import Post + + +@pytest.fixture +def create_comment(create_post, user_data): + """ + Fixture for one post + """ + post_id = create_post[1].get("id") + post = Post.objects.get(id=post_id) + comment = baker.make( + 'interactions.Comment', + user=user_data, + post=post, + body='This is comment body' + ) + return comment + + +@pytest.fixture +def create_two_comments(create_post, user_data): + """ + Fixture for creation of 2 comments + """ + post_id = create_post[1].get("id") + post = Post.objects.get(id=post_id) + comment = baker.make( + 'interactions.Comment', + user=user_data, + post=post, + body='This is comment body' + ) + comment2 = baker.make( + 'interactions.Comment', + user=user_data, + post=post, + body='And the second one' + ) + return comment, comment2 + + +class TestAllComments: + URL = reverse("comments-list") + + @pytest.mark.comments + def test_get_my_comments(self, create_comment, authorized_client, user_data): + """ + Test of GET method for comment of current user + """ + response = authorized_client.get(self.URL) + + assert response.status_code == 200 + assert response.json() is not None + assert len(response.json()) == 1 + data = response.json()[0] + + # Get each data from comment + assert data.get("body") == 'This is comment body' + assert data.get("user") == user_data.id + assert data.get("post") == create_comment.post.id + assert data.get("created_at") is not None + assert data.get("id") == create_comment.id + + @pytest.mark.comments + def test_get_my_two_comments( + self, + create_two_comments, + authorized_client, + user_data + ): + """ + Test of GET method for 2 comments of current user + """ + response = authorized_client.get(self.URL) + + assert response.status_code == 200 + assert response.json() is not None + assert len(response.json()) == 2 + + @pytest.mark.comments + def test_get_comments_401( + self, + api_client + ): + """ + Test of GET method for unauthorized user + """ + response = api_client.get(self.URL) + assert response.status_code == 401 + data = response.json() + assert data is not None + assert data.get("detail") == "Authentication credentials were not provided." + + +class TestCommentById: + @pytest.mark.comments + def test_get_comment_by_id(self, create_two_comments, authorized_client, user_data): + """ + Test of GET comment by id + """ + comment = create_two_comments[1] + comment_id = comment.id + URL = reverse("comments-detail", args=[comment_id]) + response = authorized_client.get(URL) + assert response.status_code == 200 + data = response.json() + assert data is not None + + # Check each field + assert data.get("id") == comment_id + assert data.get("body") == comment.body + assert data.get("created_at") is not None + assert data.get("user") == user_data.id + assert data.get("post") == comment.post.id + + @pytest.mark.comments + def test_get_comments_by_id_404(self, authorized_client): + """ + Test of GET comment by wrong id + """ + URL = reverse("comments-detail", args=[999]) + response = authorized_client.get(URL) + assert response.status_code == 404 + + data = response.json() + assert data is not None + + assert data.get("detail") == 'No Comment matches the given query.' + + @pytest.mark.comments + def test_get_comments_by_id_401(self, api_client): + """ + Test of GET comment by unauthorized user + """ + URL = reverse("comments-detail", args=[1]) + response = api_client.get(URL) + assert response.status_code == 401 + + data = response.json() + assert data is not None + assert data.get("detail") == "Authentication credentials were not provided." diff --git a/backend/apps/api/tests/test_feed_endpoint/__init__.py b/backend/apps/api/tests/test_feed_endpoint/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/apps/api/tests/test_feed_endpoint/conftest.py b/backend/apps/api/tests/test_feed_endpoint/conftest.py new file mode 100644 index 0000000..01796b7 --- /dev/null +++ b/backend/apps/api/tests/test_feed_endpoint/conftest.py @@ -0,0 +1,73 @@ +import pytest +from backend.apps.posts.models import Post +from backend.apps.interactions.models import Comment, Like + + +@pytest.fixture +def posts_created_by_followed_user(create_follow, second_user_data): + post1 = Post.objects.create( + title="First test post", + body='This is first post for feed test', + author=second_user_data + ) + post2 = Post.objects.create( + title="Dear Recruiters", + body='I hope you got to this point and the second test post is visible for you', + author=second_user_data + ) + + return post1, post2 + + +@pytest.fixture +def third_user_post(third_user_data): + """ + Fixture for post creation by different user + """ + post = Post.objects.create( + title="Post of third user", + body='This is a post of user non-followed by current user', + author=third_user_data + ) + + return post, post.id + + +@pytest.fixture +def add_comments_to_post(posts_created_by_followed_user, user_data, second_user_data): + """ + Fixture for creating comments under a post + """ + post1, _ = posts_created_by_followed_user + comment1 = Comment.objects.create( + user=user_data, + post=post1, + body="This post is great, I love testing it" + ) + comment2 = Comment.objects.create( + user=second_user_data, + post=post1, + body="I totally agree! Testing is amazing." + ) + + return comment1, comment2 + + +@pytest.fixture +def post1_id(posts_created_by_followed_user): + """ + Fixture for post1 ID + """ + return posts_created_by_followed_user[0].id + + +@pytest.fixture +def add_likes_to_post(posts_created_by_followed_user, user_data, second_user_data): + """ + Fixture for creating likes under a post + """ + post1, _ = posts_created_by_followed_user + like1 = Like.objects.create(user=user_data, post=post1) + like2 = Like.objects.create(user=second_user_data, post=post1) + + return like1, like2 diff --git a/backend/apps/api/tests/test_feed_endpoint/test_delete_feed_likes.py b/backend/apps/api/tests/test_feed_endpoint/test_delete_feed_likes.py new file mode 100644 index 0000000..fe427fd --- /dev/null +++ b/backend/apps/api/tests/test_feed_endpoint/test_delete_feed_likes.py @@ -0,0 +1,70 @@ +import pytest +from django.urls import reverse +from backend.apps.interactions.models import Like +from backend.apps.posts.models import Post + + +@pytest.fixture +def create_like(user_data, post1_id): + """ + Fixture for creating like under one post + """ + post = Post.objects.get(id=post1_id) + like = Like.objects.create(user=user_data, post=post) + return like + + +class TestDeleteLikesByPostId: + @pytest.mark.feed + def test_delete_feed_likes(self, create_like, authorized_client, post1_id): + """ + Test for DELETE method for like in feed + """ + # Check data first + URL_POST = reverse('feed-detail', args=[post1_id]) + response_before = authorized_client.get(URL_POST) + assert response_before.status_code == 200 + assert response_before.json().get('likes_count') == 1 + + # Delete like + URL_DELETE = reverse('feed-like', args=[post1_id]) + response_delete = authorized_client.delete(URL_DELETE) + assert response_delete.status_code == 200 + data = response_delete.json() + assert data is not None + assert data.get("message") == "Like deleted" + + # Check after + response_before = authorized_client.get(URL_POST) + assert response_before.status_code == 200 + assert response_before.json().get('likes_count') == 0 + + @pytest.mark.feed + def test_feed_delete_likes_404( + self, + authorized_client + ): + """ + Test of DELETE method for a like with invalid post id + """ + URL = reverse('feed-like', args=[99999]) + response_post = authorized_client.delete(URL) + assert response_post.status_code == 404 + data = response_post.json() + assert data is not None + assert data.get("detail") == 'No Post matches the given query.' + + @pytest.mark.feed + def test_feed_delete_like_401( + self, + api_client, + ): + """ + Test of DELETE method for a like with post id as unauthorized user + """ + URL = reverse('feed-like', args=[99999]) + response_post = api_client.delete(URL) + assert response_post.status_code == 401 + data = response_post.json() + assert data is not None + assert data.get("detail") == 'Authentication credentials were not provided.' diff --git a/backend/apps/api/tests/test_feed_endpoint/test_get_feed.py b/backend/apps/api/tests/test_feed_endpoint/test_get_feed.py new file mode 100644 index 0000000..6f65cb5 --- /dev/null +++ b/backend/apps/api/tests/test_feed_endpoint/test_get_feed.py @@ -0,0 +1,80 @@ +import pytest +from django.urls import reverse +from backend.apps.posts.models import Post + + +class TestGetFeed: + URL = reverse('feed-list') + + @pytest.mark.feed + def test_get_feed( + self, + posts_created_by_followed_user, + authorized_client, + second_user_data + ): + """ + Test of GET method for current user feed + """ + response = authorized_client.get(self.URL) + assert response.status_code == 200 + data = response.json() + assert data is not None + + # Split data for 2 posts + post1, post2 = data + + assert post1.get("id") != post2.get("id") + + # Get values from first post + assert isinstance(post1.get("id"), int) + assert isinstance(post1.get("likes_count"), int) + assert isinstance(post1.get("comments_count"), int) + assert post1.get("title") == 'First test post' + assert post1.get("slug") is not None + assert post1.get("body") == 'This is first post for feed test' + assert post1.get("date") is not None + assert post1.get("author") == second_user_data.id + + # Get values from second post + assert isinstance(post2.get("id"), int) + assert isinstance(post2.get("likes_count"), int) + assert isinstance(post2.get("comments_count"), int) + assert post2.get("title") == 'Dear Recruiters' + assert post2.get("slug") is not None + assert post2.get("body") == 'I hope you got to this point and the second test post is visible for you' + assert post2.get("date") is not None + assert post2.get("author") == second_user_data.id + + @pytest.mark.feed + def test_get_feed_no_follow( + self, + authorized_client, + posts_created_by_followed_user, + third_user_post + ): + """ + Test of GET method for feed of non-followed user + """ + # Check the quantity of all posts + posts = Post.objects.all() + assert len(posts) == 3 + + response = authorized_client.get(self.URL) + assert response.status_code == 200 + data = response.json() + + # Check the number of visible on feed posts + assert len(data) == 2 + + @pytest.mark.feed + def test_get_feed_401(self, api_client): + """ + Test of GET method by unauthorized user + """ + response = api_client.get(self.URL) + assert response.status_code == 401 + response_data = response.json() + + assert response_data is not None + assert response_data.get("detail") == 'Authentication credentials were not provided.' \ No newline at end of file diff --git a/backend/apps/api/tests/test_feed_endpoint/test_get_feed_comments.py b/backend/apps/api/tests/test_feed_endpoint/test_get_feed_comments.py new file mode 100644 index 0000000..0f8df7f --- /dev/null +++ b/backend/apps/api/tests/test_feed_endpoint/test_get_feed_comments.py @@ -0,0 +1,83 @@ +import pytest +from django.urls import reverse + + +class TestGetCommentsByPostId: + @pytest.mark.feed + def test_get_comments_by_post_id( + self, + add_comments_to_post, + authorized_client, + second_user_data, + user_data, + post1_id + ): + """ + Test for GET method for comments of chosen post + """ + URL = reverse('feed-comment', args=[post1_id]) + response = authorized_client.get(URL) + assert response.status_code == 200 + data = response.json() + assert data is not None + + assert len(data) == 2 + comment1, comment2 = data + + # Check 1st comment + assert isinstance(comment1.get("id"), int) + assert comment1.get("body") == 'I totally agree! Testing is amazing.' + assert comment1.get("created_at") is not None + assert comment1.get("user") == second_user_data.id + assert isinstance(comment1.get("post"), int) + + # Check 2nd comment + assert isinstance(comment2.get("id"), int) + assert comment2.get("body") == 'This post is great, I love testing it' + assert comment2.get("created_at") is not None + assert comment2.get("user") == user_data.id + assert isinstance(comment2.get("post"), int) + + @pytest.mark.feed + def test_get_comments_by_post_id_empty( + self, + authorized_client, + post1_id + ): + """ + Test for GET method for comments of chosen post. + Empty list + """ + URL = reverse('feed-comment', args=[post1_id]) + response = authorized_client.get(URL) + assert response.status_code == 200 + data = response.json() + assert data is not None + assert len(data) == 0 + + @pytest.mark.feed + def test_get_comments_by_post_id_404( + self, + authorized_client + ): + """ + Test of GET method by post ID for feed with 404 error + """ + URL = reverse('feed-comment', args=[999]) + response = authorized_client.get(URL) + assert response.status_code == 404 + data = response.json() + assert data is not None + assert data.get("detail") == 'No Post matches the given query.' + + @pytest.mark.feed + def test_get_feed_post_id_401(self, api_client): + """ + Test of GET method by unauthorized user + """ + URL = reverse('feed-comment', args=[999]) + response = api_client.get(URL) + assert response.status_code == 401 + response_data = response.json() + assert response_data is not None + assert response_data.get("detail") == 'Authentication credentials were not provided.' \ No newline at end of file diff --git a/backend/apps/api/tests/test_feed_endpoint/test_get_feed_id.py b/backend/apps/api/tests/test_feed_endpoint/test_get_feed_id.py new file mode 100644 index 0000000..5b12d5d --- /dev/null +++ b/backend/apps/api/tests/test_feed_endpoint/test_get_feed_id.py @@ -0,0 +1,58 @@ +import pytest +from django.urls import reverse + + +class TestGetFeedByPostId: + @pytest.mark.feed + def test_get_feed_post_id( + self, + authorized_client, + second_user_data, + post1_id + ): + """ + Test of GET method by post ID for feed + """ + URL = reverse('feed-detail', args=[post1_id]) + response = authorized_client.get(URL) + assert response.status_code == 200 + data = response.json() + assert data is not None + + + # Check every field + assert data.get("id") == post1_id + assert isinstance(data.get("likes_count"), int) + assert isinstance(data.get("comments_count"), int) + assert data.get("title") == 'First test post' + assert data.get("body") == 'This is first post for feed test' + assert data.get("slug") is not None + assert data.get("date") is not None + assert data.get("author") == second_user_data.id + + @pytest.mark.feed + def test_get_feed_post_id_404( + self, + authorized_client + ): + """ + Test of GET method by post ID for feed with 404 error + """ + URL = reverse('feed-detail', args=[999]) + response = authorized_client.get(URL) + assert response.status_code == 404 + data = response.json() + assert data is not None + assert data.get("detail") == 'No Post matches the given query.' + + @pytest.mark.feed + def test_get_feed_post_id_401(self, api_client): + """ + Test of GET method by unauthorized user + """ + URL = reverse('feed-detail', args=[999]) + response = api_client.get(URL) + assert response.status_code == 401 + response_data = response.json() + assert response_data is not None + assert response_data.get("detail") == 'Authentication credentials were not provided.' \ No newline at end of file diff --git a/backend/apps/api/tests/test_feed_endpoint/test_get_feed_likes.py b/backend/apps/api/tests/test_feed_endpoint/test_get_feed_likes.py new file mode 100644 index 0000000..79f2484 --- /dev/null +++ b/backend/apps/api/tests/test_feed_endpoint/test_get_feed_likes.py @@ -0,0 +1,81 @@ +import pytest +from django.urls import reverse + + +class TestGetLikesByPostId: + @pytest.mark.feed + def test_get_likes_by_post_id( + self, + add_likes_to_post, + authorized_client, + second_user_data, + user_data, + post1_id + ): + """ + Test for GET method for likes of chosen post + """ + URL = reverse('feed-like', args=[post1_id]) + response = authorized_client.get(URL) + assert response.status_code == 200 + data = response.json() + assert data is not None + + assert len(data) == 2 + like1, like2 = data + + # Check 1st like + assert isinstance(like1.get("id"), int) + assert like1.get("created_at") is not None + assert like1.get("user") == user_data.id + assert like1.get("post") == post1_id + + # Check 2n like + assert isinstance(like2.get("id"), int) + assert like2.get("created_at") is not None + assert like2.get("user") == second_user_data.id + assert like2.get("post") == post1_id + + @pytest.mark.feed + def test_get_likes_by_post_id_empty( + self, + authorized_client, + post1_id + ): + """ + Test for GET method for likes of chosen post. + Empty list + """ + URL = reverse('feed-like', args=[post1_id]) + response = authorized_client.get(URL) + assert response.status_code == 200 + data = response.json() + assert data is not None + assert len(data) == 0 + + @pytest.mark.feed + def test_get_likes_by_post_id_404( + self, + authorized_client + ): + """ + Test of GET method by post ID for feed with 404 error + """ + URL = reverse('feed-like', args=[999]) + response = authorized_client.get(URL) + assert response.status_code == 404 + data = response.json() + assert data is not None + assert data.get("detail") == 'No Post matches the given query.' + + @pytest.mark.feed + def test_get_feed_post_id_401(self, api_client): + """ + Test of GET method by unauthorized user + """ + URL = reverse('feed-like', args=[999]) + response = api_client.get(URL) + assert response.status_code == 401 + response_data = response.json() + assert response_data is not None + assert response_data.get("detail") == 'Authentication credentials were not provided.' diff --git a/backend/apps/api/tests/test_feed_endpoint/test_post_feed_comments.py b/backend/apps/api/tests/test_feed_endpoint/test_post_feed_comments.py new file mode 100644 index 0000000..842568d --- /dev/null +++ b/backend/apps/api/tests/test_feed_endpoint/test_post_feed_comments.py @@ -0,0 +1,112 @@ +import pytest +from django.urls import reverse + + +class TestAddCommentsByPostId: + @pytest.fixture + def comment_body(self): + comment = {'body': 'This is a comment added by endpoint'} + return comment + + @pytest.mark.feed + def test_feed_add_comment( + self, + authorized_client, + user_data, + post1_id, + comment_body + ): + """ + Test of POST method for a comment with post id + """ + # Block for checking comment section + URL = reverse('feed-comment', args=[post1_id]) + response_get = authorized_client.get(URL) + assert response_get.status_code == 200 + # Check if there is no comments before adding + assert len(response_get.json()) == 0 + + # Block for adding a comment + response_post = authorized_client.post(URL, data=comment_body, format='json') + assert response_post.status_code == 201 + data = response_post.json() + assert data is not None + assert data.get("message") == 'Comment added succesfully' + + # Check comments after adding + response_get_after = authorized_client.get(URL) + assert response_get_after.status_code == 200 + data_after = response_get_after.json() + assert len(data_after) == 1 + data_after = data_after[0] + + # Check comment fields + assert isinstance(data_after.get("id"), int) + assert data_after.get("body") == 'This is a comment added by endpoint' + assert data_after.get("created_at") is not None + assert data_after.get("user") == user_data.id + assert isinstance(data_after.get("post"), int) + + @pytest.mark.feed + def test_feed_add_more_comments( + self, + authorized_client, + post1_id, + comment_body + ): + """ + Test of POST method for a comments with post id + """ + # Block for checking comment section + URL = reverse('feed-comment', args=[post1_id]) + response_get = authorized_client.get(URL) + assert response_get.status_code == 200 + # Check if there is no comments before adding + assert len(response_get.json()) == 0 + + # Block for adding first comment + response_post = authorized_client.post(URL, data=comment_body, format='json') + assert response_post.status_code == 201 + + # Block for adding second comment + response_post = authorized_client.post(URL, data=comment_body, format='json') + assert response_post.status_code == 201 + + # Check comments after adding + response_get = authorized_client.get(URL) + assert response_get.status_code == 200 + assert len(response_get.json()) == 2 + + @pytest.mark.feed + def test_feed_add_comment_404( + self, + authorized_client, + comment_body + ): + """ + Test of POST method for a comment with invalid post id + """ + # Block for adding a comment + URL = reverse('feed-comment', args=[99999]) + response_post = authorized_client.post(URL, data=comment_body, format='json') + assert response_post.status_code == 404 + data = response_post.json() + assert data is not None + assert data.get("detail") == 'No Post matches the given query.' + + @pytest.mark.feed + def test_feed_add_comment_401( + self, + api_client, + comment_body + ): + """ + Test of POST method for a comment with post id as unauthorized user + """ + # Block for adding a comment + URL = reverse('feed-comment', args=[99999]) + response_post = api_client.post(URL, data=comment_body, format='json') + assert response_post.status_code == 401 + data = response_post.json() + assert data is not None + assert data.get("detail") == 'Authentication credentials were not provided.' diff --git a/backend/apps/api/tests/test_feed_endpoint/test_post_feed_likes.py b/backend/apps/api/tests/test_feed_endpoint/test_post_feed_likes.py new file mode 100644 index 0000000..ef8ae2e --- /dev/null +++ b/backend/apps/api/tests/test_feed_endpoint/test_post_feed_likes.py @@ -0,0 +1,119 @@ +import pytest +from django.urls import reverse +from backend.apps.interactions.models import Follow + + +@pytest.fixture +def create_second_mutual_follow(user_data, second_user_data, third_user_data): + """ + Fixture for creating follow second user -> third user and user -> third user + """ + follow1 = Follow.objects.create(follower=second_user_data, following=third_user_data) + follow2 = Follow.objects.create(follower=user_data, following=third_user_data) + return follow1, follow2 + + +class TestAddLikesByPostId: + @pytest.mark.feed + def test_feed_add_like( + self, + authorized_client, + user_data, + post1_id + ): + """ + Test of POST method for a like with post id + """ + # Block for checking likes + URL = reverse('feed-like', args=[post1_id]) + response_get = authorized_client.get(URL) + assert response_get.status_code == 200 + # Check if there is no likes before adding + assert len(response_get.json()) == 0 + + # Block for adding a like + response_post = authorized_client.post(URL) + assert response_post.status_code == 201 + data = response_post.json() + assert data is not None + assert data.get("message") == 'Like added' + + # Check likes after adding + URL = reverse('feed-like', args=[post1_id]) + response_get_after = authorized_client.get(URL) + assert response_get_after.status_code == 200 + data_after = response_get_after.json() + assert len(data_after) == 1 + + data_after = data_after[0] + + # Check like fields + assert isinstance(data_after.get("id"), int) + assert data_after.get("created_at") is not None + assert data_after.get("user") == user_data.id + assert data_after.get("post") == post1_id + + @pytest.mark.feed + def test_feed_add_more_likes( + self, + authorized_client, + second_authorized_client, + create_second_mutual_follow, + third_user_post + ): + """ + Test of POST method for a likes with post id + """ + # Block for checking likes section + post_id = third_user_post[1] # Get id for a post of user followed by both users + URL = reverse('feed-like', args=[post_id]) + response_get = authorized_client.get(URL) + assert response_get.status_code == 200 + # Check if there is no likes before adding + assert len(response_get.json()) == 0 + + # Block for adding first like + response_post = authorized_client.post(URL) + assert response_post.status_code == 201 + + # Block for adding second like + response_post = second_authorized_client.post(URL) + assert response_post.status_code == 201 + + # Check likes after adding + response_get = authorized_client.get(URL) + assert response_get.status_code == 200 + assert len(response_get.json()) == 2 + + @pytest.mark.feed + def test_feed_add_likes_404( + self, + authorized_client + ): + """ + Test of POST method for a like with invalid post id + """ + # Block for adding a like + URL = reverse('feed-like', args=[99999]) + response_post = authorized_client.post(URL) + assert response_post.status_code == 404 + data = response_post.json() + assert data is not None + assert data.get("detail") == 'No Post matches the given query.' + + @pytest.mark.feed + def test_feed_add_like_401( + self, + api_client, + ): + """ + Test of POST method for a like with post id as unauthorized user + """ + # Block for adding a comment + URL = reverse('feed-like', args=[99999]) + response_post = api_client.post(URL) + assert response_post.status_code == 401 + data = response_post.json() + assert data is not None + assert data.get("detail") == 'Authentication credentials were not provided.' + diff --git a/backend/apps/api/tests/test_follows.py b/backend/apps/api/tests/test_follows.py new file mode 100644 index 0000000..d1c7267 --- /dev/null +++ b/backend/apps/api/tests/test_follows.py @@ -0,0 +1,123 @@ +import pytest +from django.urls import reverse +from model_bakery import baker + + +@pytest.fixture +def create_follow(user_data, second_user_data): + follow = baker.make( + 'interactions.Follow', + follower=user_data, + following=second_user_data + ) + return follow + + +@pytest.fixture +def create_two_follows(user_data, second_user_data, third_user_data): + follow = baker.make( + 'interactions.Follow', + follower=user_data, + following=second_user_data + ) + follow2 = baker.make( + 'interactions.Follow', + follower=user_data, + following=third_user_data + ) + return follow, follow2 + + +class TestGetFollows: + URL = reverse("follows-list") + + @pytest.mark.follow + def test_get_follows(self, authorized_client, create_follow, user_data, second_user_data): + """ + Test of GET method for current user follows + """ + response = authorized_client.get(self.URL) + assert response.status_code == 200 + + data = response.json() + assert data is not None + assert len(data) == 1 + data = data[0] + + assert data.get("id") is not None + assert data.get("created_at") is not None + assert data.get("follower") == user_data.id + assert data.get("following") == second_user_data.id + + @pytest.mark.follow + def test_get_2_follows(self, authorized_client, create_two_follows): + """ + Test of GET method for current user follows. + Returns len == 2 + """ + response = authorized_client.get(self.URL) + assert response.status_code == 200 + + data = response.json() + assert data is not None + assert len(data) == 2 + + @pytest.mark.follow + def test_get_follows_401(self, api_client): + """ + Test of GET method as unauthorized user + """ + response = api_client.get(self.URL) + assert response.status_code == 401 + data = response.json() + + assert data is not None + assert data.get("detail") == "Authentication credentials were not provided." + + +class TestGetFollowById: + @pytest.mark.follow + def test_get_follow_by_id(self, authorized_client, create_follow, user_data, second_user_data): + """ + Test of GET method on a follow by its ID + """ + # Get id for URL + follow_id = create_follow.id + URL = reverse("follows-detail", args=[follow_id]) + response = authorized_client.get(URL) + assert response.status_code == 200 + + data = response.json() + assert data is not None + + # Check every field + assert data.get("id") == follow_id + assert data.get("created_at") is not None + assert data.get("follower") == user_data.id + assert data.get("following") == second_user_data.id + + @pytest.mark.follow + def test_get_follow_404(self, authorized_client): + """ + Test of GET method on a follow by non-existent ID + """ + URL = reverse("follows-detail", args=[999]) + response = authorized_client.get(URL) + assert response.status_code == 404 + + data = response.json() + assert data is not None + assert data.get("detail") == 'No Follow matches the given query.' + + @pytest.mark.follow + def test_get_follow_401(self, api_client): + """ + Test of GET method by unauthorized user + """ + URL = reverse("follows-detail", args=[999]) + response = api_client.get(URL) + assert response.status_code == 401 + + data = response.json() + assert data is not None + assert data.get("detail") == "Authentication credentials were not provided." diff --git a/backend/apps/api/tests/test_health_check.py b/backend/apps/api/tests/test_health_check.py new file mode 100644 index 0000000..d7afaed --- /dev/null +++ b/backend/apps/api/tests/test_health_check.py @@ -0,0 +1,11 @@ +import pytest +from django.urls import reverse + + +class TestApiCheck: + @pytest.mark.health + def test_api_check(self, api_client): + URL = reverse('health-check') + response = api_client.get(URL) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} diff --git a/backend/apps/api/tests/test_likes.py b/backend/apps/api/tests/test_likes.py new file mode 100644 index 0000000..dc49b8a --- /dev/null +++ b/backend/apps/api/tests/test_likes.py @@ -0,0 +1,132 @@ +import pytest +from django.urls import reverse +from model_bakery import baker +from backend.apps.posts.models import Post + + +@pytest.fixture +def create_like(create_post, user_data): + """ + Fixture for like creation + """ + post_id = create_post[1].get("id") + post = Post.objects.get(id=post_id) + like = baker.make( + 'interactions.Like', + user=user_data, + post=post + ) + return like + + +@pytest.fixture +def create_two_likes(create_two_posts, user_data): + """ + Fixture for 2 likes creation + """ + post1, post2 = create_two_posts + post1_id = post1[1].get("id") + post2_id = post2[1].get("id") + post1_obj = Post.objects.get(id=post1_id) + post2_obj = Post.objects.get(id=post2_id) + like1 = baker.make( + 'interactions.Like', + user=user_data, + post=post1_obj + ) + like2 = baker.make( + 'interactions.Like', + user=user_data, + post=post2_obj + ) + return like1, like2 + + +class TestGetLikes: + URL = reverse("likes-list") + + @pytest.mark.like + def test_get_user_likes(self, authorized_client, create_like, user_data): + """ + Test of GET method for current user likes + """ + response = authorized_client.get(self.URL) + assert response.status_code == 200 + data = response.json() + assert len(data) == 1 + + data = data[0] + assert data is not None + assert data.get("id") is not None + assert data.get("created_at") is not None + assert data.get("user") == user_data.id + assert data.get("post") == create_like.post.id + + @pytest.mark.like + def test_get_user_two_likes(self, authorized_client, create_two_likes): + """ + Test of GET method for multiple objects + """ + response = authorized_client.get(self.URL) + assert response.status_code == 200 + data = response.json() + assert data is not None + assert len(data) == 2 + + @pytest.mark.like + def test_get_user_likes_401(self, api_client): + """ + Test of GET method as unauthorized user + """ + response = api_client.get(self.URL) + assert response.status_code == 401 + + data = response.json() + assert data is not None + assert data.get("detail") == "Authentication credentials were not provided." + + +class TestGetLikesById: + @pytest.mark.like + def test_get_like_by_id(self, create_like, authorized_client, user_data): + """ + Test of GET method for a like by its id + """ + like_id = create_like.id + URL = reverse("likes-detail", args=[like_id]) + response = authorized_client.get(URL) + assert response.status_code == 200 + data = response.json() + + assert data is not None + assert data.get("id") == like_id + assert data.get("created_at") is not None + assert data.get("user") == user_data.id + assert data.get("post") == create_like.post.id + + @pytest.mark.like + def test_get_like_by_id_404(self, authorized_client): + """ + Test of GET method for a like by its id. + Returns error 404. + """ + URL = reverse("likes-detail", args=[999]) + response = authorized_client.get(URL) + assert response.status_code == 404 + + data = response.json() + assert data is not None + assert data.get("detail") == 'No Like matches the given query.' + + @pytest.mark.like + def test_get_like_by_id_401(self, api_client): + """ + Test of GET method by unauthorized user + """ + URL = reverse("likes-detail", args=[999]) + response = api_client.get(URL) + assert response.status_code == 401 + + data = response.json() + assert data is not None + assert data.get("detail") == "Authentication credentials were not provided." diff --git a/backend/apps/api/tests/test_posts.py b/backend/apps/api/tests/test_posts.py new file mode 100644 index 0000000..3d4047f --- /dev/null +++ b/backend/apps/api/tests/test_posts.py @@ -0,0 +1,426 @@ +import pytest +from django.urls import reverse + + +class TestPostPosts: + """ + Class for tests of /posts/ endpoint + """ + URL = reverse('posts-list') # show_urls will show all url names + POST_DATA = { + "title": "Random title", + "body": "Random body of a post" + } + + @pytest.mark.posts + def test_posts(self, authorized_client): + """ + Test for post creation + """ + # DRF automatically sends data is mulipart/form-data, not JSON + response = authorized_client.post(self.URL, data=self.POST_DATA, format='json') + assert response.status_code == 201 + data = response.json() + assert data.get("message") == "Post created" + + @pytest.mark.posts + def test_post_missing_title(self, authorized_client): + """ + Test of posting with missing title + """ + post = { + "title": "", + "body": "Random body of a post" + } + + response = authorized_client.post(self.URL, data=post, format='json') + assert response.status_code == 400 + + response_data = response.json() + assert response_data is not None + assert response_data.get("title") == ['This field may not be blank.'] + + @pytest.mark.posts + def test_post_missing_body(self, authorized_client): + """ + Test of posting with missing body + """ + post = { + "title": "Some title", + "body": "" + } + + response = authorized_client.post(self.URL, data=post, format='json') + assert response.status_code == 400 + + response_data = response.json() + assert response_data is not None + assert response_data.get("body") == ['This field may not be blank.'] + + @pytest.mark.posts + def test_post_unauthorized(self, api_client): + """ + Test of posting as unathorized user + """ + response = api_client.post(self.URL, data=self.POST_DATA, format='json') + assert response.status_code == 401 + response_data = response.json() + + assert response_data is not None + assert response_data.get("detail") == 'Authentication credentials were not provided.' + + +class TestGetPosts: + URL = reverse('posts-list') + + @pytest.fixture + def created_two_posts(self, authorized_client): + """ + Creating post as a fixture + """ + post1 = { + "title": "Random title", + "body": "Random body of a post" + } + + post2 = { + "title": "Definetely not random", + "body": "Some story" + } + + # DRF automatically sends data is mulipart/form-data, not JSON + authorized_client.post(self.URL, data=post1, format='json') + authorized_client.post(self.URL, data=post2, format='json') + + return post1, post2 + + @pytest.mark.posts + def test_get_posts(self, authorized_client, created_two_posts): + post1, post2 = created_two_posts + + response = authorized_client.get(self.URL) + assert response.status_code == 200 + response_data = response.json() + assert len(response_data) == 2 + + post1_check, post2_check = response_data + + assert post1_check is not None + assert post1_check.get("title") == post1.get("title") + assert post1_check.get("body") == post1.get("body") + + assert post2_check is not None + assert post2_check.get("title") == post2.get("title") + assert post2_check.get("body") == post2.get("body") + + @pytest.mark.posts + def test_get_unauthorized(self, api_client): + """ + Get posts as unauthorized user + """ + response = api_client.get(self.URL) + assert response.status_code == 401 + response_data = response.json() + + assert response_data is not None + assert response_data.get("detail") == 'Authentication credentials were not provided.' + + @pytest.mark.posts + def test_get_empty(self, authorized_client): + response = authorized_client.get(self.URL) + assert response.status_code == 200 + response_data = response.json() + assert len(response_data) == 0 + + +class TestGetIdPosts: + """ + Tests for getting a post by its ID + """ + + @pytest.mark.posts + def test_get_post(self, create_post, authorized_client): + """ + Get post by ID + """ + post_body, post_id_part = create_post + post_id = post_id_part.get("id") + GET_URL = reverse('posts-detail', args=[post_id]) + + response = authorized_client.get(GET_URL) + assert response.status_code == 200 + + post_data = response.json() + assert post_data is not None + assert create_post[1].get("id") == post_data.get("id") # Otherwise post would not be found + assert post_body.get("title") == post_data.get("title") + assert post_body.get("body") == post_data.get("body") + + @pytest.mark.posts + def test_get_post_404(self, authorized_client): + """ + Get non existing post + """ + post_id = 999 + GET_URL = reverse('posts-detail', args=[post_id]) + + response = authorized_client.get(GET_URL) + assert response.status_code == 404 + + post_data = response.json() + assert post_data is not None + assert post_data.get("detail") == 'No Post matches the given query.' + + @pytest.mark.posts + def test_get_post_401(self, api_client): + """ + Get post as unauthorized user + """ + post_id = 999 + GET_URL = reverse('posts-detail', args=[post_id]) + + response = api_client.get(GET_URL) + assert response.status_code == 401 + + post_data = response.json() + assert post_data is not None + assert post_data.get("detail") == 'Authentication credentials were not provided.' + + +class TestPutIdPost: + @pytest.mark.posts + def test_put_post(self, create_post, authorized_client): + """ + Test PUT method on a post + """ + # Extracting all data from post + post, post_info = create_post + post_title = post.get("title") + post_body = post.get("body") + post_id = post_info.get("id") + + URL = reverse("posts-detail", args=[post_id]) + + new_data = { + "title": "Welcome from new title", + "body": "And don't forget about me!" + } + + response = authorized_client.put(URL, data=new_data, format='json') + + assert response.status_code == 200 + + post_data = response.json() + assert post_data is not None + + # Get new post data + new_id = post_data.get("id") + new_title = post_data.get("title") + new_body = post_data.get("body") + + # Check if edited fields are saved + assert new_id == post_id + assert new_title != post_title + assert new_body != post_body + + @pytest.mark.posts + def test_put_missing_body(self, create_post, authorized_client): + # Extracting all data from post + post_info = create_post[1] + post_id = post_info.get("id") + + URL = reverse("posts-detail", args=[post_id]) + + new_data = { + "title": "Welcome from new title" + } + + response = authorized_client.put(URL, data=new_data, format='json') + + assert response.status_code == 400 + data = response.json() + + assert data is not None + assert data.get("body") == ['This field is required.'] + + @pytest.mark.posts + def test_put_missing_title(self, create_post, authorized_client): + # Extracting all data from post + post_info = create_post[1] + post_id = post_info.get("id") + + URL = reverse("posts-detail", args=[post_id]) + + new_data = { + "body": "And don't forget about me!" + } + + response = authorized_client.put(URL, data=new_data, format='json') + + assert response.status_code == 400 + data = response.json() + + assert data is not None + assert data.get("title") == ['This field is required.'] + + @pytest.mark.posts + def test_put_unauthorized(self, api_client): + # Test on any post, just authentication needed + URL = reverse("posts-detail", args=[1]) + + new_data = { + "title": "Welcome from new title", + "body": "And don't forget about me!" + } + + response = api_client.put(URL, data=new_data, format='json') + + assert response.status_code == 401 + data = response.json() + + assert data is not None + assert data.get("detail") == 'Authentication credentials were not provided.' + + +class TestPatchIdPost: + @pytest.mark.posts + def test_patch_post_title(self, create_post, authorized_client): + """ + Test PATCH method on a post + """ + # Extracting all data from post + post, post_info = create_post + post_title = post.get("title") + post_body = post.get("body") + post_id = post_info.get("id") + + URL = reverse("posts-detail", args=[post_id]) + + new_data = { + "title": "Welcome from new title" + } + + response = authorized_client.patch(URL, data=new_data, format='json') + + assert response.status_code == 200 + + post_data = response.json() + assert post_data is not None + + # Get new post data + new_id = post_data.get("id") + new_title = post_data.get("title") + new_body = post_data.get("body") + + # Check if edited fields are saved + assert new_id == post_id + assert new_title != post_title + assert new_body == post_body + + @pytest.mark.posts + def test_patch_post_body(self, create_post, authorized_client): + # Extracting all data from post + post, post_info = create_post + post_title = post.get("title") + post_body = post.get("body") + post_id = post_info.get("id") + + URL = reverse("posts-detail", args=[post_id]) + + new_data = { + "body": "And don't forget about me!" + } + + response = authorized_client.patch(URL, data=new_data, format='json') + + assert response.status_code == 200 + + post_data = response.json() + assert post_data is not None + + # Get new post data + new_id = post_data.get("id") + new_title = post_data.get("title") + new_body = post_data.get("body") + + # Check if edited fields are saved + assert new_id == post_id + assert new_title == post_title + assert new_body != post_body + + @pytest.mark.posts + def test_patch_post_unauthorized(self, api_client): + # Extracting all data from post + URL = reverse("posts-detail", args=[1]) + + response = api_client.patch(URL, data={}, format='json') + + assert response.status_code == 401 + + data = response.json() + assert data is not None + assert data.get("detail") == 'Authentication credentials were not provided.' + + @pytest.mark.posts + def test_patch_post_404(self, authorized_client): + URL = reverse("posts-detail", args=[999]) + + response = authorized_client.patch(URL, data={}, format='json') + + assert response.status_code == 404 + + data = response.json() + assert data is not None + assert data.get('detail') == 'No Post matches the given query.' + + +class TestDeleteIdPost: + @pytest.mark.posts + def test_delete_post(self, create_post, authorized_client): + """" + Test DELETE method on post + """ + # Extracting all id from post + post_info = create_post[1] + post_id = post_info.get("id") + + URL = reverse("posts-detail", args=[post_id]) + + response = authorized_client.delete(URL) + + assert response.status_code == 200 + + data = response.json() + assert data is not None + assert data.get("message") == 'Post deleted' + + @pytest.mark.posts + def test_delete_post_404(self, authorized_client): + """" + Test DELETE method on non existing post + """ + + URL = reverse("posts-detail", args=[999]) + + response = authorized_client.delete(URL) + + assert response.status_code == 404 + + data = response.json() + assert data is not None + assert data.get('detail') == 'No Post matches the given query.' + + @pytest.mark.posts + def test_delete_authorization(self, api_client): + """" + Test DELETE method as non authenticated user + """ + URL = reverse("posts-detail", args=[999]) + + response = api_client.delete(URL) + + assert response.status_code == 401 + + data = response.json() + assert data is not None + assert data.get('detail') == 'Authentication credentials were not provided.' diff --git a/backend/apps/api/tests/test_search.py b/backend/apps/api/tests/test_search.py new file mode 100644 index 0000000..7a32b90 --- /dev/null +++ b/backend/apps/api/tests/test_search.py @@ -0,0 +1,181 @@ +import pytest +from django.urls import reverse +from backend.apps.interactions.models import Follow + + +class TestGetSearch: + @pytest.mark.search + def test_get_search(self, authorized_client, second_user_data): + """ + Test for GET method for search by query + """ + query = second_user_data.username + URL = reverse('search-list') + f'?q={query}' + response = authorized_client.get(URL) + assert response.status_code == 200 + data = response.json()[0] + assert data is not None + + assert data.get('user') == second_user_data.id + assert data.get("username") == query + assert isinstance(data.get("bio"), str) + assert data.get("profile_picture") is not None + + @pytest.mark.search + def test_search_empty_result(self, authorized_client): + """ + Test for GET method for missing user + """ + URL = reverse('search-list') + f'?q=invisible-john' + response = authorized_client.get(URL) + assert response.status_code == 200 + data = response.json() + + assert len(data) == 0 + + @pytest.mark.search + def test_search_401(self, api_client): + """ + Test for GET method as unauthorized user + """ + URL = reverse('search-list') + f'?q=invisible-john' + response = api_client.get(URL) + assert response.status_code == 401 + data = response.json() + assert data.get("detail") == 'Authentication credentials were not provided.' + + +class TestGetSearchById: + @pytest.mark.search + def test_get_by_id(self, authorized_client, second_user_data): + """ + Test GET method by users ID + """ + user_id = second_user_data.id + URL = reverse("search-detail", args=[user_id]) + response = authorized_client.get(URL) + assert response.status_code == 200 + data = response.json() + assert data is not None + + assert data.get("user") == user_id + assert data.get("username") == second_user_data.username + assert isinstance(data.get("bio"), str) + assert data.get("profile_picture") is not None + + @pytest.mark.search + def test_get_by_id_404(self, authorized_client): + """ + Test GET method with wrong ID + """ + URL = reverse("search-detail", args=[999]) + response = authorized_client.get(URL) + assert response.status_code == 404 + data = response.json() + assert data.get("detail") == 'No Profile matches the given query.' + + @pytest.mark.search + def test_get_by_id_401(self, api_client): + """ + Test for GET method as unauthorized user + """ + URL = reverse("search-detail", args=[999]) + response = api_client.get(URL) + assert response.status_code == 401 + data = response.json() + assert data.get("detail") == 'Authentication credentials were not provided.' + + +class TestSearchFollowById: + @pytest.mark.search + def test_follow_by_id(self, authorized_client, user_data, second_user_data): + """ + Test of creating follow by POST method in search endpoint + """ + follows_before = Follow.objects.filter(follower=user_data.id, following=second_user_data.id) + assert len(follows_before) == 0 + URL = reverse('search-follow', args=[second_user_data.id]) + response = authorized_client.post(URL) + assert response.status_code == 201 + data = response.json() + + assert data is not None + assert data.get("message") == f'User {second_user_data.username} followed' + + # Check db after creation + follows_after = Follow.objects.filter(follower=user_data.id, following=second_user_data.id) + assert len(follows_after) == 1 + + @pytest.mark.search + def test_follow_by_id_404(self, authorized_client): + """ + Test of creating follow by POST method in search endpoint with wrong ID + """ + URL = reverse('search-follow', args=[999]) + response = authorized_client.post(URL) + assert response.status_code == 404 + data = response.json() + assert data.get("detail") == 'No Profile matches the given query.' + + @pytest.mark.search + def test_follow_by_id_401(self, api_client): + """ + Test of creating follow by POST method in search endpoint as unauthorized user + """ + URL = reverse('search-follow', args=[999]) + response = api_client.post(URL) + assert response.status_code == 401 + data = response.json() + assert data.get("detail") == 'Authentication credentials were not provided.' + + +class TestSearchUnfollowById: + @pytest.mark.search + def test_unfollow_by_id( + self, + authorized_client, + create_follow, + user_data, + second_user_data + ): + """ + Test of DELETE method for user ID + """ + follows_before = Follow.objects.filter( + follower=user_data.id, + following=second_user_data.id + ) + assert len(follows_before) == 1 + URL = reverse('search-follow', args=[second_user_data.id]) + response = authorized_client.delete(URL) + # Returns empty body + assert response.status_code == 204 + + # Check DB after deletion + follows_after = Follow.objects.filter( + follower=user_data.id, + following=second_user_data.id + ) + assert len(follows_after) == 0 + + @pytest.mark.search + def test_unfollow_by_id_404(self, authorized_client): + """ + Test of deletion follow by DELETE method in search endpoint with wrong ID + """ + URL = reverse('search-follow', args=[999]) + response = authorized_client.delete(URL) + assert response.status_code == 404 + data = response.json() + assert data.get("detail") == 'No Profile matches the given query.' + + @pytest.mark.search + def test_unfollow_by_id_401(self, api_client): + """ + Test of deletion follow by DELETE method in search endpoint as unauthorized user + """ + URL = reverse('search-follow', args=[999]) + response = api_client.delete(URL) + assert response.status_code == 401 + data = response.json() + assert data.get("detail") == 'Authentication credentials were not provided.' diff --git a/backend/apps/api/tests/test_token.py b/backend/apps/api/tests/test_token.py new file mode 100644 index 0000000..eabd0d7 --- /dev/null +++ b/backend/apps/api/tests/test_token.py @@ -0,0 +1,138 @@ +import pytest +from django.urls import reverse +from rest_framework_simplejwt.token_blacklist.models import BlacklistedToken + + +class TestObtainToken: + # Will be used in more places + INVALID_CRED_MSG = 'No active account found with the given credentials' + + @pytest.mark.token + def test_token(self, user_data, api_client): + """ + Test for obtaining access and refresh token + """ + url = reverse('token_obtain_pair') + data = { + "username": user_data.username, + "password": user_data.plain_password + } + # DRF automatically sends data is mulipart/form-data, not JSON + response = api_client.post(url, data=data, format='json') + assert response.status_code == 200 + received_data = response.json() + + # Get tokens from response + refresh_token = received_data.get("refresh") + access_token = received_data.get("access") + + # Check refresh token + assert refresh_token is not None + assert isinstance(refresh_token, str) + + # Check access token + assert access_token is not None + assert isinstance(access_token, str) + print(response.headers) + + @pytest.mark.token + def test_token_wrong_username(self, user_data, api_client): + """ + Test for obtaining access and refresh token with wrong username + """ + url = reverse('token_obtain_pair') + data = { + "username": "Wrong_username", + "password": user_data.plain_password + } + # DRF automatically sends data is mulipart/form-data, not JSON + response = api_client.post(url, data=data, format='json') + assert response.status_code == 401 + received_data = response.json() + assert received_data is not None + assert received_data.get("detail") == self.INVALID_CRED_MSG + + @pytest.mark.token + def test_token_wrong_password(self, user_data, api_client): + """ + Test for obtaining access and refresh token with wrong password + """ + url = reverse('token_obtain_pair') + data = { + "username": user_data.username, + "password": "TotallyRandom.123!" + } + # DRF automatically sends data is mulipart/form-data, not JSON + response = api_client.post(url, data=data, format='json') + assert response.status_code == 401 + received_data = response.json() + assert received_data is not None + assert received_data.get("detail") == self.INVALID_CRED_MSG + + +class TestBlacklistToken: + @pytest.mark.token + def test_blacklist_token(self, authorized_client, logged_user_access): + blacklisted_tokens = BlacklistedToken.objects.all() + assert len(blacklisted_tokens) == 0 + + refresh_token = logged_user_access.get("refresh") + data = {'refresh': refresh_token} + url = reverse("token_blacklist") + response = authorized_client.post(url, data=data, format='json') + + assert response.status_code == 200 + + blacklisted_tokens = BlacklistedToken.objects.all() + assert len(blacklisted_tokens) == 1 + + +class TestRefreshToken: + @pytest.mark.token + def test_refresh_token( + self, + authorized_client, + logged_user_access + ): + """ + Test of generating new access token by refreshing + """ + access_token = logged_user_access.get("access") + refresh_token = logged_user_access.get("refresh") + + data = {'refresh': refresh_token} + url = reverse("token_refresh") + response = authorized_client.post(url, data=data, format='json') + assert response.status_code == 200 + + response_data = response.json() + new_token = response_data.get("access") + + assert new_token is not None + assert new_token != access_token + + @pytest.mark.token + def test_refresh_with_blacklist_token( + self, + authorized_client, + logged_user_access + ): + """ + Test of usage blacklisted token + """ + + # Blacklisting refresh token + refresh_token = logged_user_access.get("refresh") + data = {'refresh': refresh_token} + url = reverse("token_blacklist") + authorized_client.post(url, data=data, format='json') + + # Refresh blacklisted token + refresh_url = reverse("token_refresh") + response = authorized_client.post(refresh_url, data=data, format='json') + + # Check result + assert response.status_code == 401 + response_data = response.json() + assert response_data.get("detail") == 'Token is blacklisted' + assert response_data.get("code") == 'token_not_valid' diff --git a/backend/apps/api/tests/test_user.py b/backend/apps/api/tests/test_user.py new file mode 100644 index 0000000..0e56381 --- /dev/null +++ b/backend/apps/api/tests/test_user.py @@ -0,0 +1,32 @@ +import pytest +from django.urls import reverse + + +class TestCurrentUser: + URL = reverse("user-list") + + @pytest.mark.user + def test_get_current_user(self, authorized_client, user_data): + """ + Test GET method on current user + """ + + response = authorized_client.get(self.URL) + assert response.status_code == 200 + + data = response.json()[0] + assert data is not None + assert data.get("username") == user_data.username + assert data.get("id") == user_data.id + + @pytest.mark.user + def test_get_current_user_401(self, api_client): + """ + Test GET method on unauthorized user + """ + response = api_client.get(self.URL) + assert response.status_code == 401 + + data = response.json() + assert data is not None + assert data.get("detail") == "Authentication credentials were not provided." diff --git a/backend/apps/api/urls.py b/backend/apps/api/urls.py index 4c86731..ab065a8 100644 --- a/backend/apps/api/urls.py +++ b/backend/apps/api/urls.py @@ -7,7 +7,7 @@ TokenBlacklistView ) from rest_framework.routers import DefaultRouter -from debug_toolbar.toolbar import debug_toolbar_urls +from backend.config import settings router = DefaultRouter() router.register('user', views.CurrentUserViewSet, basename="user") @@ -20,7 +20,7 @@ urlpatterns = [ path("", include(router.urls)), - path('', views.health_check, name='health-check'), + path('health/', views.health_check, name='health-check'), # Generates raw OpenAPI schema path('schema/', SpectacularAPIView.as_view(), name='schema'), # Gets the schema and renders as UI @@ -31,4 +31,7 @@ # Refresh token is sent to a black list, new table with blacklisted tokens is created # Access token (15min) remains valid until expiry. path('token/blacklist/', TokenBlacklistView.as_view(), name='token_blacklist'), -] + debug_toolbar_urls() +] +if settings.DEBUG: + from debug_toolbar.toolbar import debug_toolbar_urls + urlpatterns += debug_toolbar_urls() diff --git a/backend/apps/api/views.py b/backend/apps/api/views.py index de65611..4d99776 100644 --- a/backend/apps/api/views.py +++ b/backend/apps/api/views.py @@ -1,6 +1,7 @@ from rest_framework.decorators import api_view, permission_classes, action from rest_framework.permissions import AllowAny from rest_framework.response import Response +from rest_framework.request import Request from backend.apps.posts.models import Post from backend.apps.users.models import Profile from django.contrib.auth.models import User @@ -17,11 +18,13 @@ from rest_framework import status from drf_spectacular.utils import extend_schema, OpenApiParameter from rest_framework.mixins import ListModelMixin +from django.db.models import QuerySet + @extend_schema(exclude=True) # Do not add it to Swagger @api_view(['GET']) @permission_classes([AllowAny]) # Overwrites permision class for this view -def health_check(request): +def health_check(request: Request) -> Response: return Response({'status': 'ok'}) @@ -31,7 +34,7 @@ class CurrentUserViewSet(ListModelMixin, viewsets.GenericViewSet): # RetrieveMo """ serializer_class = UserSerializer - def get_queryset(self): + def get_queryset(self) -> QuerySet: return User.objects.filter(pk=self.request.user.pk) @@ -42,20 +45,20 @@ class UserPostViewSet(viewsets.ModelViewSet): """ serializer_class = PostSerializer - def get_queryset(self): + def get_queryset(self) -> QuerySet: return Post.objects.filter(author=self.request.user.pk) # # Adds current user as author # def perform_create(self, serializer): # serializer.save(author=self.request.user) - def create(self, request, *args, **kwargs): - serializer = self.get_serializer(data=request.data) + def create(self, request: Request, *args, **kwargs) -> Response: + serializer = self.get_serializer(data=request.data) # get_serializer() gets serializer from class serializer.is_valid(raise_exception=True) serializer.save(author=self.request.user) - return Response({'message': 'Post created'}, status=status.HTTP_201_CREATED) + return Response({'message': 'Post created', "id": serializer.data.get("id")}, status=status.HTTP_201_CREATED) - def destroy(self, request, *args, **kwargs): + def destroy(self, request: Request, *args, **kwargs) -> Response: instance = self.get_object() # Here is getting the object! self.perform_destroy(instance) return Response({'message': "Post deleted"}, status=status.HTTP_200_OK) @@ -69,12 +72,12 @@ class FeedViewSet(viewsets.ReadOnlyModelViewSet): """ serializer_class = PostSerializer - def get_queryset(self): + def get_queryset(self) -> QuerySet: # Get Follow objects and follow receiver ID following = self.request.user.following.all().values_list("following_id", flat=True) # Return posts of followed users return Post.objects.filter(author__in=following) - + # Decorator for Swagger documentation, defining data type # 'id' is a name of variable to define # int is a type of defined variable @@ -83,7 +86,7 @@ def get_queryset(self): # @action decorator adds action to basic URL in ViewSet @action(detail=True, methods=['post', 'delete', 'get']) # /like/ is created in URL from method name - def like(self, request, pk=None): + def like(self, request: Request, pk: int | None = None) -> Response: post = self.get_object() # Gets Post object with id from URL if request.method == "POST": Like.objects.get_or_create(user=request.user, post=post) @@ -98,7 +101,7 @@ def like(self, request, pk=None): @extend_schema(parameters=[OpenApiParameter('id', int, OpenApiParameter.PATH)]) @action(detail=True, methods=['post', 'get']) - def comment(self, request, pk=None): + def comment(self, request: Request, pk: int | None = None) -> Response: post = self.get_object() if request.method == "POST": Comment.objects.create(user=request.user, post=post, body=request.data.get('body')) @@ -117,7 +120,7 @@ class LikeViewSet(viewsets.ReadOnlyModelViewSet): serializer_class = LikeSerializer # Get likes of current user - def get_queryset(self): + def get_queryset(self) -> QuerySet: return Like.objects.filter(user=self.request.user) # # Save current user as author of like while creating object @@ -132,7 +135,7 @@ class FollowViewSet(viewsets.ReadOnlyModelViewSet): """ serializer_class = FollowSerializer - def get_queryset(self): + def get_queryset(self) -> QuerySet: return Follow.objects.filter(follower=self.request.user) @@ -142,7 +145,7 @@ class CommentViewSet(viewsets.ReadOnlyModelViewSet): """ serializer_class = CommentSerializer - def get_queryset(self): + def get_queryset(self) -> QuerySet: return Comment.objects.filter(user=self.request.user) @@ -154,14 +157,14 @@ class SearchUsersViewSet(viewsets.ReadOnlyModelViewSet): """ serializer_class = ProfileSerializer - def get_queryset(self): + def get_queryset(self) -> QuerySet: # query_params is a dict with ULR parameters query = self.request.query_params.get("q", "") return Profile.objects.filter(user__username__icontains=query) @extend_schema(parameters=[OpenApiParameter('id', int, OpenApiParameter.PATH)]) @action(detail=True, methods=['post', 'delete']) - def follow(self, request, pk=None): + def follow(self, request: Request, pk: int | None = None) -> Response: followed_user = self.get_object().user # Gets user by profile if request.method == "POST": Follow.objects.get_or_create(follower=request.user, following=followed_user) diff --git a/backend/apps/conftest.py b/backend/apps/conftest.py index 9046182..b55986b 100644 --- a/backend/apps/conftest.py +++ b/backend/apps/conftest.py @@ -7,6 +7,10 @@ import pytest from model_bakery import baker +from rest_framework.test import APIClient +from django.urls import reverse +from django.core.cache import cache +from backend.apps.interactions.models import Follow @pytest.fixture @@ -17,3 +21,167 @@ def user(db): # db needed to open connection with db @pytest.fixture def post(db, user): return baker.make("posts.Post", title="Testing", author=user) + + +@pytest.fixture +def api_client(): + return APIClient() # Better compatibility with JWT tokens also with DRF + + +@pytest.fixture(autouse=True) +def disable_debug(settings): # Pytest-Django provides settings fixture + settings.DEBUG = False + + +@pytest.fixture(autouse=True) +def disable_throttling(settings): + """ + Disable throttling for test purpose + """ + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_CLASSES"] = [] + settings.REST_FRAMEWORK["DEFAULT_THROTTLE_RATES"] = {} + + # Reset cache counter before next test + cache.clear() + + +@pytest.fixture +def user_data(db): + password = "Test.123!" + user = baker.make("auth.User", username="test_user") + user.set_password(password) + user.save() + user.plain_password = password # Assigned for test purpose + return user + + +@pytest.fixture +def logged_user_access(user_data, api_client): + url = reverse('token_obtain_pair') + data = { + "username": user_data.username, + "password": user_data.plain_password + } + # DRF automatically sends data is mulipart/form-data, not JSON + response = api_client.post(url, data=data, format='json') + received_data = response.json() + access_token = received_data.get("access") + refresh_token = received_data.get("refresh") + return { + "refresh": refresh_token, + "access": access_token + } + + +@pytest.fixture +def authorized_client(logged_user_access, api_client): + """ + Fixture for using authorized user + """ + access_token = logged_user_access.get("access") + api_client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}") + return api_client + + +@pytest.fixture +def second_authorized_client(second_user_data): + """ + Fixture for actions for second user + """ + client = APIClient() # Creating second instance not to overwrite the first one + url = reverse('token_obtain_pair') + data = { + "username": second_user_data.username, + "password": second_user_data.plain_password + } + + # DRF automatically sends data is mulipart/form-data, not JSON + response = client.post(url, data=data, format='json') + received_data = response.json() + access_token = received_data.get("access") + refresh_token = received_data.get("refresh") + tokens = { + "refresh": refresh_token, + "access": access_token + } + + # Authorize client + access_token = tokens.get("access") + client.credentials(HTTP_AUTHORIZATION=f"Bearer {access_token}") + return client + + +@pytest.fixture +def create_post(authorized_client): + """ + Creating single post as a fixture + """ + post = { + "title": "Hello recruiter", + "body": "I hope you are satisfied with what you see" + } + + # DRF automatically sends data is mulipart/form-data, not JSON + response = authorized_client.post(reverse('posts-list'), data=post, format='json') + + return post, response.json() + + +@pytest.fixture +def create_two_posts(authorized_client): + """ + Creating two posts as a fixture + """ + post1 = { + "title": "Hello recruiter", + "body": "I hope you are satisfied with what you see" + } + + post2 = { + "title": "Hello recruiter", + "body": "I hope you are satisfied with what you see" + } + + # DRF automatically sends data is mulipart/form-data, not JSON + response1 = authorized_client.post(reverse('posts-list'), data=post1, format='json') + response2 = authorized_client.post(reverse('posts-list'), data=post2, format='json') + + return (post1, response1.json()), (post2, response2.json()) + + +@pytest.fixture +def second_user_data(): + """ + Creating second user to follow + """ + password = "Test.123!" + user = baker.make("auth.User", username="second_user") + user.set_password(password) + user.save() + user.plain_password = password # Assigned for test purpose + return user + + +@pytest.fixture +def third_user_data(): + """ + Creating third user to follow + """ + password = "Test.123!" + user = baker.make("auth.User", username="third_user") + user.set_password(password) + user.save() + user.plain_password = password # Assigned for test purpose + return user + + +@pytest.fixture +def create_follow(user_data, second_user_data): + """ + Creating follow + """ + follow = Follow.objects.create( + follower=user_data, + following=second_user_data + ) + return follow \ No newline at end of file diff --git a/backend/apps/posts/models.py b/backend/apps/posts/models.py index 135c8df..8c1b032 100644 --- a/backend/apps/posts/models.py +++ b/backend/apps/posts/models.py @@ -15,6 +15,9 @@ class Post(models.Model): date = models.DateTimeField(auto_now_add=True) def save(self, *args, **kwargs): + """ + Overwriting method for slug creation + """ if not self.slug: base_slug = slugify(self.title) self.slug = f"{base_slug}-{uuid.uuid4().hex[:8]}" diff --git a/backend/config/settings.py b/backend/config/settings.py index 186b98f..b67fa03 100644 --- a/backend/config/settings.py +++ b/backend/config/settings.py @@ -33,6 +33,7 @@ # SECURITY WARNING: don't run with debug turned on in production! DEBUG = os.getenv("DEBUG", "False") == "True" +# TODO: add host before deploy ALLOWED_HOSTS = [] @@ -52,7 +53,7 @@ "drf_spectacular", "backend.apps.api", 'rest_framework_simplejwt.token_blacklist', - "debug_toolbar", + 'django_extensions', ] MIDDLEWARE = [ @@ -63,7 +64,6 @@ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', - "debug_toolbar.middleware.DebugToolbarMiddleware", ] ROOT_URLCONF = 'backend.config.urls' @@ -141,6 +141,9 @@ STATIC_URL = '/static/' +# Location of static files after `collectstatic` +STATIC_ROOT = BASE_DIR / "staticfiles" + STATICFILES_DIRS = [ BASE_DIR / "frontend" / "static", ] @@ -199,3 +202,6 @@ def show_toolbar(request): DEBUG_TOOLBAR_CONFIG = { 'SHOW_TOOLBAR_CALLBACK': show_toolbar, } + + INSTALLED_APPS += ['debug_toolbar'] + MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware'] diff --git a/pytest.ini b/pytest.ini index adc1c2c..5a6eb01 100644 --- a/pytest.ini +++ b/pytest.ini @@ -14,8 +14,27 @@ python_files = tests.py test_*.py *_tests.py # Django will try to reuse db instead of creating one, no migrations issues # --reuse-db use it only locally -addopts = -v --tb=short +addopts = + -v + --strict-markers + --tb=short + --cov=backend + --cov-report=term-missing + --cov-fail-under=80 + +# pytest-env allows creation of env for test purpose +env = + DEBUG=False markers: unit: logic tests - view: template and view test \ No newline at end of file + view: template and view test + token: test token obtain + posts: current user posts + comments: comments created by current user + user: current user data + health: health check + follow: current user follows + like: current user likes + search: other users search + feed: feed and its actions \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index e378450..47ab875 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,82 +1,32 @@ -altair==5.5.0 -annotated-doc==0.0.3 -annotated-types==0.7.0 -asgiref==3.9.2 -attrs==25.4.0 -blinker==1.9.0 -cachetools==6.2.1 -certifi==2025.8.3 -charset-normalizer==3.4.3 -click==8.3.0 +asgiref==3.11.1 +attrs==26.1.0 coverage==7.13.5 -Django==5.2.6 +Django==6.0.3 django-debug-toolbar==6.2.0 -djangorestframework==3.16.1 +django-extensions==4.1 +djangorestframework==3.17.1 djangorestframework_simplejwt==5.5.1 -dnspython==2.8.0 drf-spectacular==0.29.0 -email-validator==2.3.0 -gitdb==4.0.12 -GitPython==3.1.45 -h11==0.16.0 -httpcore==1.0.9 -httptools==0.7.1 -idna==3.10 inflection==0.5.1 iniconfig==2.3.0 -Jinja2==3.1.6 -joblib==1.5.2 -jsonschema==4.25.1 +jsonschema==4.26.0 jsonschema-specifications==2025.9.1 -lz4==4.4.5 -markdown-it-py==4.0.0 -MarkupSafe==3.0.3 -mdurl==0.1.2 model-bakery==1.23.3 -narwhals==2.8.0 -numpy==2.3.3 -packaging==25.0 -pandas==2.3.3 -pillow==11.3.0 +packaging==26.0 +pillow==12.1.1 pluggy==1.6.0 -protobuf==6.32.1 psycopg2-binary==2.9.11 -pyarrow==21.0.0 -pydantic==2.12.4 -pydantic_core==2.41.5 -pydeck==0.9.1 Pygments==2.19.2 PyJWT==2.12.1 pytest==9.0.2 -pytest-cov==7.0.0 +pytest-cov==7.1.0 pytest-django==4.12.0 -python-dateutil==2.9.0.post0 -python-dotenv==1.1.1 -python-multipart==0.0.20 -pytz==2025.2 +pytest-env==1.6.0 +python-dotenv==1.2.2 PyYAML==6.0.3 referencing==0.37.0 -requests==2.32.5 -rignore==0.7.6 -rpds-py==0.27.1 -scikit-learn==1.7.2 -scipy==1.16.2 -sentry-sdk==2.43.0 -shellingham==1.5.4 -six==1.17.0 -smmap==5.0.2 -sniffio==1.3.1 -sqlparse==0.5.3 -tenacity==9.1.2 -threadpoolctl==3.6.0 -toml==0.10.2 -tornado==6.5.2 -typing-inspection==0.4.2 +rpds-py==0.30.0 +sqlparse==0.5.5 typing_extensions==4.15.0 -tzdata==2025.2 uritemplate==4.2.0 -urllib3==2.5.0 -uvicorn==0.38.0 -uvloop==0.22.1 -websockets==15.0.1 -whitenoise==6.11.0 +whitenoise==6.12.0