diff --git a/src/amdb/application/command_handlers/__init__.py b/src/amdb/application/command_handlers/__init__.py index cb3e81b..ce238d1 100644 --- a/src/amdb/application/command_handlers/__init__.py +++ b/src/amdb/application/command_handlers/__init__.py @@ -7,6 +7,7 @@ "UnrateMovieHandler", "ReviewMovieHandler", "AddToWatchlistHandler", + "DeleteFromWatchlistHandler", ) from .register_user import RegisterUserHandler @@ -17,3 +18,4 @@ from .unrate_movie import UnrateMovieHandler from .review_movie import ReviewMovieHandler from .add_to_watchlist import AddToWatchlistHandler +from .delete_from_watchlist import DeleteFromWatchlistHandler diff --git a/src/amdb/application/command_handlers/add_to_watchlist.py b/src/amdb/application/command_handlers/add_to_watchlist.py index 3a98c1e..eb0e70f 100644 --- a/src/amdb/application/command_handlers/add_to_watchlist.py +++ b/src/amdb/application/command_handlers/add_to_watchlist.py @@ -67,4 +67,6 @@ def execute(self, command: AddToWatchlistCommand) -> MovieForLaterId: ) self._movie_for_later_gateway.save(new_movie_for_later) + self._unit_of_work.commit() + return new_movie_for_later.id diff --git a/src/amdb/application/command_handlers/delete_from_watchlist.py b/src/amdb/application/command_handlers/delete_from_watchlist.py new file mode 100644 index 0000000..27a83a8 --- /dev/null +++ b/src/amdb/application/command_handlers/delete_from_watchlist.py @@ -0,0 +1,42 @@ +from amdb.application.common.gateways.movie_for_later import ( + MovieForLaterGateway, +) +from amdb.application.common.unit_of_work import UnitOfWork +from amdb.application.common.identity_provider import IdentityProvider +from amdb.application.common.constants.exceptions import ( + USER_IS_NOT_OWNER, + MOVIE_NOT_IN_WATCHLIST, +) +from amdb.application.common.exception import ApplicationError +from amdb.application.commands.delete_from_watchlist import ( + DeleteFromWatchlistCommand, +) + + +class DeleteFromWatchlistHandler: + def __init__( + self, + *, + movie_for_later_gateway: MovieForLaterGateway, + unit_of_work: UnitOfWork, + identity_provider: IdentityProvider, + ) -> None: + self._movie_for_later_gateway = movie_for_later_gateway + self._unit_of_work = unit_of_work + self._identity_provider = identity_provider + + def execute(self, command: DeleteFromWatchlistCommand) -> None: + current_user_id = self._identity_provider.user_id() + + movie_for_later = self._movie_for_later_gateway.with_id( + command.movie_for_later_id, + ) + if not movie_for_later: + raise ApplicationError(MOVIE_NOT_IN_WATCHLIST) + + if movie_for_later.user_id != current_user_id: + raise ApplicationError(USER_IS_NOT_OWNER) + + self._movie_for_later_gateway.delete(movie_for_later) + + self._unit_of_work.commit() diff --git a/src/amdb/application/commands/delete_from_watchlist.py b/src/amdb/application/commands/delete_from_watchlist.py new file mode 100644 index 0000000..e152f49 --- /dev/null +++ b/src/amdb/application/commands/delete_from_watchlist.py @@ -0,0 +1,8 @@ +from dataclasses import dataclass + +from amdb.domain.entities.movie_for_later import MovieForLaterId + + +@dataclass(frozen=True, slots=True) +class DeleteFromWatchlistCommand: + movie_for_later_id: MovieForLaterId diff --git a/src/amdb/application/common/constants/exceptions.py b/src/amdb/application/common/constants/exceptions.py index 1a5f1f7..350b6c7 100644 --- a/src/amdb/application/common/constants/exceptions.py +++ b/src/amdb/application/common/constants/exceptions.py @@ -20,3 +20,4 @@ REVIEW_DOES_NOT_EXIST = "Review doesn't exist" MOVIE_ALREADY_IN_WATCHLIST = "Movie already in watchlist" +MOVIE_NOT_IN_WATCHLIST = "Movie not in watchlist" diff --git a/src/amdb/application/common/gateways/movie_for_later.py b/src/amdb/application/common/gateways/movie_for_later.py index 975632e..2e531e5 100644 --- a/src/amdb/application/common/gateways/movie_for_later.py +++ b/src/amdb/application/common/gateways/movie_for_later.py @@ -1,11 +1,17 @@ from typing import Protocol, Optional -from amdb.domain.entities.movie_for_later import MovieForLater +from amdb.domain.entities.movie_for_later import MovieForLater, MovieForLaterId from amdb.domain.entities.movie import MovieId from amdb.domain.entities.user import UserId class MovieForLaterGateway(Protocol): + def with_id( + self, + movie_for_later_id: MovieForLaterId, + ) -> Optional[MovieForLater]: + raise NotImplementedError + def with_movie_id_and_user_id( self, user_id: UserId, @@ -15,3 +21,6 @@ def with_movie_id_and_user_id( def save(self, movie_for_later: MovieForLater) -> None: raise NotImplementedError + + def delete(self, movie_for_later: MovieForLater) -> None: + raise NotImplementedError diff --git a/src/amdb/infrastructure/persistence/sqlalchemy/mappers/entities/movie_for_later.py b/src/amdb/infrastructure/persistence/sqlalchemy/mappers/entities/movie_for_later.py index 0a751c2..4ab0eb4 100644 --- a/src/amdb/infrastructure/persistence/sqlalchemy/mappers/entities/movie_for_later.py +++ b/src/amdb/infrastructure/persistence/sqlalchemy/mappers/entities/movie_for_later.py @@ -1,6 +1,6 @@ from typing import Annotated, Optional -from sqlalchemy import Connection, Row, select, insert, and_ +from sqlalchemy import Connection, Row, select, insert, delete, and_ from amdb.domain.entities.user import UserId from amdb.domain.entities.movie import MovieId @@ -14,6 +14,18 @@ class MovieForLaterMapper: def __init__(self, connection: Connection) -> None: self._connection = connection + def with_id( + self, + movie_for_later_id: MovieForLaterId, + ) -> Optional[MovieForLater]: + statement = select(MovieForLaterModel).where( + MovieForLaterModel.id == movie_for_later_id, + ) + row = self._connection.execute(statement).one_or_none() + if row: + return self._to_entity(row) # type: ignore + return None + def with_movie_id_and_user_id( self, user_id: UserId, @@ -27,7 +39,7 @@ def with_movie_id_and_user_id( ) row = self._connection.execute(statement).one_or_none() if row: - return self._to_entity(row) + return self._to_entity(row) # type: ignore return None def save(self, movie_for_later: MovieForLater) -> None: @@ -40,6 +52,12 @@ def save(self, movie_for_later: MovieForLater) -> None: ) self._connection.execute(statement) + def delete(self, movie_for_later: MovieForLater) -> None: + statement = delete(MovieForLaterModel).where( + MovieForLaterModel.id == movie_for_later.id, + ) + self._connection.execute(statement) + def _to_entity( self, row: Annotated[MovieForLaterModel, Row], diff --git a/src/amdb/main/providers/command_handlers.py b/src/amdb/main/providers/command_handlers.py index 6487582..af91b18 100644 --- a/src/amdb/main/providers/command_handlers.py +++ b/src/amdb/main/providers/command_handlers.py @@ -32,6 +32,7 @@ UnrateMovieHandler, ReviewMovieHandler, AddToWatchlistHandler, + DeleteFromWatchlistHandler, ) from amdb.presentation.create_handler import CreateHandler @@ -108,7 +109,7 @@ def create_handler( return create_handler @provide - def rate_movie_handler( + def rate_movie( self, access_concern: AccessConcern, rate_movie: RateMovie, @@ -135,7 +136,7 @@ def create_handler( return create_handler @provide - def unrate_movie_handler( + def unrate_movie( self, access_concern: AccessConcern, unrate_movie: UnrateMovie, @@ -160,7 +161,7 @@ def create_handler( return create_handler @provide - def review_movie_handler( + def review_movie( self, access_concern: AccessConcern, review_movie: ReviewMovie, @@ -208,3 +209,20 @@ def create_handler( ) return create_handler + + @provide + def delete_from_watchlist( + self, + movie_for_later_gateway: MovieForLaterGateway, + unit_of_work: UnitOfWork, + ) -> CreateHandler[DeleteFromWatchlistHandler]: + def create_handler( + identity_provider: IdentityProvider, + ) -> DeleteFromWatchlistHandler: + return DeleteFromWatchlistHandler( + movie_for_later_gateway=movie_for_later_gateway, + unit_of_work=unit_of_work, + identity_provider=identity_provider, + ) + + return create_handler diff --git a/src/amdb/presentation/web_api/watchlists/delete_movie.py b/src/amdb/presentation/web_api/watchlists/delete_movie.py new file mode 100644 index 0000000..6c1baf3 --- /dev/null +++ b/src/amdb/presentation/web_api/watchlists/delete_movie.py @@ -0,0 +1,56 @@ +from typing import Annotated, Optional + +from fastapi import Cookie +from dishka.integrations.fastapi import FromDishka, inject + +from amdb.domain.entities.movie_for_later import MovieForLaterId +from amdb.application.commands.delete_from_watchlist import ( + DeleteFromWatchlistCommand, +) +from amdb.application.command_handlers.delete_from_watchlist import ( + DeleteFromWatchlistHandler, +) +from amdb.application.common.gateways.permissions import PermissionsGateway +from amdb.infrastructure.auth.session.session import SessionId +from amdb.infrastructure.auth.session.session_gateway import SessionGateway +from amdb.infrastructure.auth.session.identity_provider import ( + SessionIdentityProvider, +) +from amdb.presentation.create_handler import CreateHandler +from amdb.presentation.web_api.constants import SESSION_ID_COOKIE + + +HandlerMaker = CreateHandler[DeleteFromWatchlistHandler] + + +@inject +async def delete_movie_from_watchlist( + *, + create_handler: Annotated[HandlerMaker, FromDishka()], + session_gateway: Annotated[SessionGateway, FromDishka()], + permissions_gateway: Annotated[PermissionsGateway, FromDishka()], + session_id: Annotated[ + Optional[str], + Cookie(alias=SESSION_ID_COOKIE), + ] = None, + movie_for_later_id: MovieForLaterId, +) -> None: + """ + Deletes movie from watchlist. \n\n + + ### Returns 400: + * When movie not in watchlist + * When user is not a watchlist owner + """ + identity_provider = SessionIdentityProvider( + session_id=SessionId(session_id) if session_id else None, + session_gateway=session_gateway, + permissions_gateway=permissions_gateway, + ) + + handler = create_handler(identity_provider) + command = DeleteFromWatchlistCommand( + movie_for_later_id=movie_for_later_id, + ) + + handler.execute(command) diff --git a/src/amdb/presentation/web_api/watchlists/router.py b/src/amdb/presentation/web_api/watchlists/router.py index c676966..f4855e9 100644 --- a/src/amdb/presentation/web_api/watchlists/router.py +++ b/src/amdb/presentation/web_api/watchlists/router.py @@ -1,11 +1,17 @@ from fastapi import APIRouter from .add_movie import add_movie_to_watchlist +from .delete_movie import delete_movie_from_watchlist watchlists_router = APIRouter(tags=["watchlists"]) watchlists_router.add_api_route( - path="/my/watchlist/movies", + path="/my/movies-for-later", endpoint=add_movie_to_watchlist, methods=["POST"], ) +watchlists_router.add_api_route( + path="/my/movies-for-later/{movie_for_later_id}", + endpoint=delete_movie_from_watchlist, + methods=["DELETE"], +) diff --git a/tests/unit/application/command_handlers/test_delete_from_watchlist.py b/tests/unit/application/command_handlers/test_delete_from_watchlist.py new file mode 100644 index 0000000..5e6048e --- /dev/null +++ b/tests/unit/application/command_handlers/test_delete_from_watchlist.py @@ -0,0 +1,156 @@ +from datetime import date, datetime, timezone +from unittest.mock import Mock + +import pytest +from uuid_extensions import uuid7 + +from amdb.domain.entities.user import User, UserId +from amdb.domain.entities.movie import Movie, MovieId +from amdb.domain.entities.movie_for_later import MovieForLater, MovieForLaterId +from amdb.application.common.gateways.user import UserGateway +from amdb.application.common.gateways.movie import MovieGateway +from amdb.application.common.gateways.movie_for_later import ( + MovieForLaterGateway, +) +from amdb.application.common.unit_of_work import UnitOfWork +from amdb.application.common.identity_provider import IdentityProvider +from amdb.application.commands.delete_from_watchlist import ( + DeleteFromWatchlistCommand, +) +from amdb.application.command_handlers.delete_from_watchlist import ( + DeleteFromWatchlistHandler, +) +from amdb.application.common.constants.exceptions import ( + USER_IS_NOT_OWNER, + MOVIE_NOT_IN_WATCHLIST, +) +from amdb.application.common.exception import ApplicationError + + +def test_delete_from_watchlist( + user_gateway: UserGateway, + movie_gateway: MovieGateway, + movie_for_later_gateway: MovieForLaterGateway, + unit_of_work: UnitOfWork, +): + user = User( + id=UserId(uuid7()), + name="JohnDoe", + email="john@doe.com", + ) + user_gateway.save(user) + + movie = Movie( + id=MovieId(uuid7()), + title="Matrix", + release_date=date(1999, 3, 31), + rating=0, + rating_count=0, + ) + movie_gateway.save(movie) + + movie_for_later = MovieForLater( + id=MovieForLaterId(uuid7()), + user_id=user.id, + movie_id=movie.id, + note="Movie with Keanu Reeves that i saw on TV", + created_at=datetime.now(timezone.utc), + ) + movie_for_later_gateway.save(movie_for_later) + + unit_of_work.commit() + + identity_provider: IdentityProvider = Mock() + identity_provider.user_id = Mock(return_value=user.id) + + command = DeleteFromWatchlistCommand( + movie_for_later_id=movie_for_later.id, + ) + handler = DeleteFromWatchlistHandler( + movie_for_later_gateway=movie_for_later_gateway, + unit_of_work=unit_of_work, + identity_provider=identity_provider, + ) + + handler.execute(command) + + +def test_delete_from_watchlist_should_raise_error_when_movie_not_in_watchlist( + user_gateway: UserGateway, + movie_for_later_gateway: MovieForLaterGateway, + unit_of_work: UnitOfWork, +): + user = User( + id=UserId(uuid7()), + name="JohnDoe", + email="john@doe.com", + ) + user_gateway.save(user) + + identity_provider: IdentityProvider = Mock() + identity_provider.user_id = Mock(return_value=user.id) + + command = DeleteFromWatchlistCommand( + movie_for_later_id=MovieForLaterId(uuid7()), + ) + handler = DeleteFromWatchlistHandler( + movie_for_later_gateway=movie_for_later_gateway, + unit_of_work=unit_of_work, + identity_provider=identity_provider, + ) + + with pytest.raises(ApplicationError) as error: + handler.execute(command) + + assert error.value.message == MOVIE_NOT_IN_WATCHLIST + + +def test_delete_from_watchlist_should_raise_error_when_user_is_not_owner( + user_gateway: UserGateway, + movie_gateway: MovieGateway, + movie_for_later_gateway: MovieForLaterGateway, + unit_of_work: UnitOfWork, +): + user = User( + id=UserId(uuid7()), + name="JohnDoe", + email="john@doe.com", + ) + user_gateway.save(user) + + movie = Movie( + id=MovieId(uuid7()), + title="Matrix", + release_date=date(1999, 3, 31), + rating=0, + rating_count=0, + ) + movie_gateway.save(movie) + + movie_for_later = MovieForLater( + id=MovieForLaterId(uuid7()), + user_id=user.id, + movie_id=movie.id, + note="Movie with Keanu Reeves that i saw on TV", + created_at=datetime.now(timezone.utc), + ) + movie_for_later_gateway.save(movie_for_later) + + unit_of_work.commit() + + identity_provider: IdentityProvider = Mock() + identity_provider.user_id = Mock(return_value=UserId(uuid7())) + + command = DeleteFromWatchlistCommand( + movie_for_later_id=movie_for_later.id, + ) + handler = DeleteFromWatchlistHandler( + movie_for_later_gateway=movie_for_later_gateway, + unit_of_work=unit_of_work, + identity_provider=identity_provider, + ) + + with pytest.raises(ApplicationError) as error: + handler.execute(command) + + assert error.value.message == USER_IS_NOT_OWNER