From 75aa1443aa451b41715d34df28495d1a23b5f783 Mon Sep 17 00:00:00 2001 From: Chime Date: Sat, 24 Aug 2024 11:33:16 +0100 Subject: [PATCH 1/6] Add code for delete blog like endpoint --- api/v1/routes/blog.py | 30 ++++++++++++++- api/v1/services/blog.py | 81 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 108 insertions(+), 3 deletions(-) diff --git a/api/v1/routes/blog.py b/api/v1/routes/blog.py index 8a080ee84..71564be18 100644 --- a/api/v1/routes/blog.py +++ b/api/v1/routes/blog.py @@ -20,7 +20,7 @@ CommentRequest, CommentUpdateResponseModel ) -from api.v1.services.blog import BlogService +from api.v1.services.blog import BlogService, BlogLikeService, BlogDislikeService from api.v1.services.user import user_service from api.v1.schemas.comment import CommentCreate, CommentSuccessResponse from api.v1.services.comment import comment_service @@ -118,6 +118,7 @@ def like_blog_post( current_user: User = Depends(user_service.get_current_user), ): """Endpoint to add `like` to a blog post. + Existing `dislike` by the `current_user` is automatically deleted. args: blog_id: `str` The ID of the blog post. @@ -137,6 +138,9 @@ def like_blog_post( # confirm current user has NOT liked before blog_service.check_user_already_liked_blog(blog_p, current_user) + # check for BlogDislike by current user and delete it + blog_service.delete_opposite_blog_like_or_dislike(blog_p, current_user, "like") + # update likes new_like = blog_service.create_blog_like( db, blog_p.id, current_user.id, ip_address=get_ip_address(request)) @@ -160,6 +164,7 @@ def dislike_blog_post( current_user: User = Depends(user_service.get_current_user), ): """Endpoint to add `dislike` to a blog post. + Existing `like` by the `current_user` is automatically deleted. args: blog_id: `str` The ID of the blog post. @@ -179,6 +184,9 @@ def dislike_blog_post( # confirm current user has NOT disliked before blog_service.check_user_already_disliked_blog(blog_p, current_user) + # check for BlogLike by current user and delete it + blog_service.delete_opposite_blog_like_or_dislike(blog_p, current_user, "dislike") + # update disikes new_dislike = blog_service.create_blog_dislike( db, blog_p.id, current_user.id, ip_address=get_ip_address(request)) @@ -299,3 +307,23 @@ async def update_blog_comment( status_code=200, data=jsonable_encoder(updated_blog_comment) ) + + +@blog.delete("/likes/{blog_like_id}", + status_code=status.HTTP_204_NO_CONTENT) +def delete_blog_like( + blog_like_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(user_service.get_current_user), +): + """Endpoint to delete `BlogLike` + + args: + blog_like_id: `str` The ID of the BlogLike object. + request: `default` Request. + db: `default` Session. + """ + blog_like_service = BlogLikeService(db) + + # delete blog like + blog_like_service.delete(blog_like_id, current_user.id) diff --git a/api/v1/services/blog.py b/api/v1/services/blog.py index caa4365f3..4478230c3 100644 --- a/api/v1/services/blog.py +++ b/api/v1/services/blog.py @@ -118,7 +118,7 @@ def fetch_blog_dislike(self, blog_id: str, user_id: str): ) return blog_dislike - def check_user_already_liked_blog(self, blog: Blog, user: Blog): + def check_user_already_liked_blog(self, blog: Blog, user: User): existing_like = self.fetch_blog_like(blog.id, user.id) if isinstance(existing_like, BlogLike): raise HTTPException( @@ -126,13 +126,40 @@ def check_user_already_liked_blog(self, blog: Blog, user: Blog): status_code=status.HTTP_403_FORBIDDEN, ) - def check_user_already_disliked_blog(self, blog: Blog, user: Blog): + def check_user_already_disliked_blog(self, blog: Blog, user: User): existing_dislike = self.fetch_blog_dislike(blog.id, user.id) if isinstance(existing_dislike, BlogDislike): raise HTTPException( detail="You have already disliked this blog post", status_code=status.HTTP_403_FORBIDDEN, ) + + def delete_opposite_blog_like_or_dislike(self, blog: Blog, user: User, creating: str): + """ + This method checks if there's a BlogLike by `user` on `blog` when a BlogDislike + is being created and deletes the BlogLike. The same for BlogLike creation. \n + + :param blog: `Blog` The blog being liked or disliked + :param user: `User` The user liking or disliking the blog + :param creating: `str` The operation being performed by the user. One of "like", "dislike" + """ + if creating == "like": + existing_dislike = self.fetch_blog_dislike(blog.id, user.id) + if existing_dislike: + # delete, but do not commit yet. Allow everything + # to be commited when operation like created + self.db.delete(existing_dislike) + if creating == "dislike": + existing_like = self.fetch_blog_like(blog.id, user.id) + if existing_like: + # delete, but do not commit yet. Allow everything + # to be commited when operation dislike created + self.db.delete(existing_like) + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Invalid `creating` value for blog like/dislike" + ) def num_of_likes(self, blog_id: str) -> int: """Get the number of likes a blog post has""" @@ -211,3 +238,53 @@ def update_blog_comment( ) return comment + + +class BlogLikeService: + """BlogLike service functionality""" + + def __init__(self, db: Session): + self.db = db + + def fetch(self, blog_like_id: str): + """Fetch a blog like by its ID""" + return check_model_existence(self.db, BlogLike, blog_like_id) + + def delete(self, blog_like_id: str, user_id: str): + """Delete blog like""" + blog_like = self.fetch(blog_like_id) + + # check that current user owns the blog like + if blog_like.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Insufficient permission" + ) + + self.db.delete(blog_like) + self.db.commit() + + +class BlogDislikeService: + """BlogDislike service functionality""" + + def __init__(self, db: Session): + self.db = db + + def fetch(self, blog_dislike_id: str): + """Fetch a blog dislike by its ID""" + return check_model_existence(self.db, BlogLike, blog_dislike_id) + + def delete(self, blog_dislike_id: str, user_id: str): + """Delete blog dislike""" + blog_dislike = self.fetch(blog_dislike_id) + + # check that current user owns the blog like + if blog_dislike.user_id != user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Insufficient permission" + ) + + self.db.delete(blog_dislike) + self.db.commit() From 296892d68cdfbcbe185cdb5ffa2913b3041b0c4c Mon Sep 17 00:00:00 2001 From: Chime Date: Sat, 24 Aug 2024 11:44:36 +0100 Subject: [PATCH 2/6] Add code for delete blog dislike endpoint --- api/v1/routes/blog.py | 16 ++++++++-------- api/v1/services/blog.py | 25 ------------------------- 2 files changed, 8 insertions(+), 33 deletions(-) diff --git a/api/v1/routes/blog.py b/api/v1/routes/blog.py index 71564be18..ee38db619 100644 --- a/api/v1/routes/blog.py +++ b/api/v1/routes/blog.py @@ -309,21 +309,21 @@ async def update_blog_comment( ) -@blog.delete("/likes/{blog_like_id}", +@blog.delete("/dislikes/{blog_dislike_id}", status_code=status.HTTP_204_NO_CONTENT) -def delete_blog_like( - blog_like_id: str, +def delete_blog_dislike( + blog_dislike_id: str, db: Session = Depends(get_db), current_user: User = Depends(user_service.get_current_user), ): - """Endpoint to delete `BlogLike` + """Endpoint to delete `BlogDislike` args: - blog_like_id: `str` The ID of the BlogLike object. + blog_dislike_id: `str` The ID of the BlogDislike object. request: `default` Request. db: `default` Session. """ - blog_like_service = BlogLikeService(db) + blog_dislike_service = BlogDislikeService(db) - # delete blog like - blog_like_service.delete(blog_like_id, current_user.id) + # delete blog dislike + blog_dislike_service.delete(blog_dislike_id, current_user.id) diff --git a/api/v1/services/blog.py b/api/v1/services/blog.py index 4478230c3..c2dd86713 100644 --- a/api/v1/services/blog.py +++ b/api/v1/services/blog.py @@ -240,31 +240,6 @@ def update_blog_comment( return comment -class BlogLikeService: - """BlogLike service functionality""" - - def __init__(self, db: Session): - self.db = db - - def fetch(self, blog_like_id: str): - """Fetch a blog like by its ID""" - return check_model_existence(self.db, BlogLike, blog_like_id) - - def delete(self, blog_like_id: str, user_id: str): - """Delete blog like""" - blog_like = self.fetch(blog_like_id) - - # check that current user owns the blog like - if blog_like.user_id != user_id: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="Insufficient permission" - ) - - self.db.delete(blog_like) - self.db.commit() - - class BlogDislikeService: """BlogDislike service functionality""" From 3742223f8825934dfca05babeabe41dc7021a6be Mon Sep 17 00:00:00 2001 From: Chime Date: Sat, 24 Aug 2024 13:29:08 +0100 Subject: [PATCH 3/6] Add test for delete blog dislike endpoint --- api/v1/routes/blog.py | 4 +- api/v1/services/blog.py | 2 +- tests/v1/blog/test_delete_blog_dislike.py | 142 ++++++++++++++++++++++ 3 files changed, 145 insertions(+), 3 deletions(-) create mode 100644 tests/v1/blog/test_delete_blog_dislike.py diff --git a/api/v1/routes/blog.py b/api/v1/routes/blog.py index ee38db619..a4f4632b3 100644 --- a/api/v1/routes/blog.py +++ b/api/v1/routes/blog.py @@ -10,7 +10,7 @@ from api.utils.pagination import paginated_response from api.utils.success_response import success_response from api.v1.models.user import User -from api.v1.models.blog import Blog, BlogDislike, BlogLike +from api.v1.models.blog import Blog from api.v1.schemas.blog import ( BlogCreate, BlogPostResponse, @@ -20,7 +20,7 @@ CommentRequest, CommentUpdateResponseModel ) -from api.v1.services.blog import BlogService, BlogLikeService, BlogDislikeService +from api.v1.services.blog import BlogService, BlogDislikeService from api.v1.services.user import user_service from api.v1.schemas.comment import CommentCreate, CommentSuccessResponse from api.v1.services.comment import comment_service diff --git a/api/v1/services/blog.py b/api/v1/services/blog.py index c2dd86713..26266ba15 100644 --- a/api/v1/services/blog.py +++ b/api/v1/services/blog.py @@ -248,7 +248,7 @@ def __init__(self, db: Session): def fetch(self, blog_dislike_id: str): """Fetch a blog dislike by its ID""" - return check_model_existence(self.db, BlogLike, blog_dislike_id) + return check_model_existence(self.db, BlogDislike, blog_dislike_id) def delete(self, blog_dislike_id: str, user_id: str): """Delete blog dislike""" diff --git a/tests/v1/blog/test_delete_blog_dislike.py b/tests/v1/blog/test_delete_blog_dislike.py new file mode 100644 index 000000000..946d8a2e1 --- /dev/null +++ b/tests/v1/blog/test_delete_blog_dislike.py @@ -0,0 +1,142 @@ +import pytest +from main import app +from uuid_extensions import uuid7 +from sqlalchemy.orm import Session +from api.db.database import get_db +from datetime import datetime, timezone +from fastapi.testclient import TestClient +from unittest.mock import patch, MagicMock +from api.v1.models import User, BlogDislike +from api.v1.services.user import user_service + +client = TestClient(app) + +# Mock database +@pytest.fixture +def mock_db_session(mocker): + db_session_mock = mocker.MagicMock(spec=Session) + app.dependency_overrides[get_db] = lambda: db_session_mock + return db_session_mock + + +@pytest.fixture +def mock_user_service(): + with patch("api.v1.services.user.user_service", autospec=True) as user_service_mock: + yield user_service_mock + + +@pytest.fixture +def mock_blog_service(): + with patch("api.v1.services.blog.BlogService", autospec=True) as blog_service_mock: + yield blog_service_mock + + +# Test User +@pytest.fixture +def test_user(): + return User( + id=str(uuid7()), + email="testuser@gmail.com", + password="hashedpassword", + first_name="test", + last_name="user", + is_active=True, + ) + + +# Another User +@pytest.fixture +def another_user(): + return User( + id=str(uuid7()), + email="anotheruser@gmail.com", + password="hashedpassword", + first_name="another", + last_name="user", + is_active=True, + ) + +@pytest.fixture +def test_blog_dislike(test_user): + return BlogDislike( + id=str(uuid7()), + user_id=test_user.id, + blog_id=str(uuid7()), + ip_address="192.168.1.0", + created_at=datetime.now(tz=timezone.utc) + ) + +@pytest.fixture +def access_token_user(test_user): + return user_service.create_access_token(user_id=test_user.id) + +@pytest.fixture +def access_token_another(another_user): + return user_service.create_access_token(user_id=another_user.id) + + +def make_request(blog_dislike_id, token): + return client.delete( + f"/api/v1/blogs/dislikes/{blog_dislike_id}", + headers={"Authorization": f"Bearer {token}"} + ) + + +# test for successful delete +@patch("api.v1.services.blog.BlogDislikeService.fetch") +def test_successful_delete_blog_dislike( + mock_fetch_blog_dislike, + mock_db_session, + test_user, + test_blog_dislike, + access_token_user +): + # mock current-user AND blog-like + mock_db_session.query().filter().first.return_value = test_user + mock_fetch_blog_dislike.return_value = test_blog_dislike + + resp = make_request(test_blog_dislike.id, access_token_user) + assert resp.status_code == 204 + + +# Test for wrong blog like id +def test_wrong_blog_dislike_id( + mock_db_session, + test_user, + access_token_user, +): + mock_db_session.query().filter().first.return_value = test_user + mock_db_session.get.return_value = None + + ### TEST REQUEST WITH WRONG blog_dislike_id ### + resp = make_request(str(uuid7()), access_token_user) + assert resp.status_code == 404 + assert resp.json()['message'] == "BlogDislike does not exist" + + +# Test for unauthenticated user +def test_wrong_auth_token( + test_blog_dislike +): + mock_user_service.get_current_user = None + + ### TEST ATTEMPT WITH INVALID AUTH ### + resp = make_request(test_blog_dislike.id, None) + assert resp.status_code == 401 + assert resp.json()['message'] == 'Could not validate credentials' + + +# Test for wrong owner request +def test_wrong_owner_request( + mock_db_session, + test_blog_dislike, + another_user, + access_token_another +): + mock_user_service.get_current_user = another_user + mock_db_session.get.return_value = test_blog_dislike + + ### TEST ATTEMPT BY NON OWNER ### + resp = make_request(test_blog_dislike.id, access_token_another) + assert resp.status_code == 401 + assert resp.json()['message'] == 'Insufficient permission' \ No newline at end of file From 6d61c373f69e9c0f4236513e206697ad5a96ea46 Mon Sep 17 00:00:00 2001 From: Chime Date: Sat, 24 Aug 2024 15:11:20 +0100 Subject: [PATCH 4/6] Finish up delete blog dislike endpoint --- api/v1/routes/blog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/v1/routes/blog.py b/api/v1/routes/blog.py index a4f4632b3..fb951e801 100644 --- a/api/v1/routes/blog.py +++ b/api/v1/routes/blog.py @@ -326,4 +326,4 @@ def delete_blog_dislike( blog_dislike_service = BlogDislikeService(db) # delete blog dislike - blog_dislike_service.delete(blog_dislike_id, current_user.id) + return blog_dislike_service.delete(blog_dislike_id, current_user.id) From 4fe2584089bec0a1d7f8b25e58ba0046ab6f4db7 Mon Sep 17 00:00:00 2001 From: Chime Date: Sat, 24 Aug 2024 15:55:05 +0100 Subject: [PATCH 5/6] Fix else/if bug in services.blog.BlogService.delete_opposite_blog_like_or_dislike --- api/v1/services/blog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/v1/services/blog.py b/api/v1/services/blog.py index 26266ba15..305653bb2 100644 --- a/api/v1/services/blog.py +++ b/api/v1/services/blog.py @@ -149,7 +149,7 @@ def delete_opposite_blog_like_or_dislike(self, blog: Blog, user: User, creating: # delete, but do not commit yet. Allow everything # to be commited when operation like created self.db.delete(existing_dislike) - if creating == "dislike": + elif creating == "dislike": existing_like = self.fetch_blog_like(blog.id, user.id) if existing_like: # delete, but do not commit yet. Allow everything From 50d2f3fdec7bfadcb561a8f7a3a3877da1d4b793 Mon Sep 17 00:00:00 2001 From: Chime Date: Sat, 24 Aug 2024 16:15:38 +0100 Subject: [PATCH 6/6] Fix comment in services.blog.BlogService.delete_opposite_blog_like_or_dislike --- api/v1/services/blog.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/v1/services/blog.py b/api/v1/services/blog.py index 305653bb2..d2f920957 100644 --- a/api/v1/services/blog.py +++ b/api/v1/services/blog.py @@ -147,13 +147,13 @@ def delete_opposite_blog_like_or_dislike(self, blog: Blog, user: User, creating: existing_dislike = self.fetch_blog_dislike(blog.id, user.id) if existing_dislike: # delete, but do not commit yet. Allow everything - # to be commited when operation like created + # to be commited after the actual like is created self.db.delete(existing_dislike) elif creating == "dislike": existing_like = self.fetch_blog_like(blog.id, user.id) if existing_like: # delete, but do not commit yet. Allow everything - # to be commited when operation dislike created + # to be commited after the actual dislike is created self.db.delete(existing_like) else: raise HTTPException(