diff --git a/api/v1/routes/blog.py b/api/v1/routes/blog.py index 8a080ee84..fb951e801 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 +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 @@ -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("/dislikes/{blog_dislike_id}", + status_code=status.HTTP_204_NO_CONTENT) +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 `BlogDislike` + + args: + blog_dislike_id: `str` The ID of the BlogDislike object. + request: `default` Request. + db: `default` Session. + """ + blog_dislike_service = BlogDislikeService(db) + + # delete blog dislike + return 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 caa4365f3..d2f920957 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 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 after the actual dislike is 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,28 @@ def update_blog_comment( ) return comment + + +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, BlogDislike, 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() 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