diff --git a/android/app/src/main/java/com/github/bytestrick/vertex/api/FavoriteCinemasService.kt b/android/app/src/main/java/com/github/bytestrick/vertex/api/FavoriteCinemasService.kt index 0aafbeec7..485e2f54e 100644 --- a/android/app/src/main/java/com/github/bytestrick/vertex/api/FavoriteCinemasService.kt +++ b/android/app/src/main/java/com/github/bytestrick/vertex/api/FavoriteCinemasService.kt @@ -2,7 +2,6 @@ package com.github.bytestrick.vertex.api import com.github.bytestrick.vertex.api.dto.FavoriteCinemaDto import com.github.bytestrick.vertex.api.dto.Slice -import com.github.bytestrick.vertex.data.entity.FavoriteCinema import retrofit2.Response import retrofit2.http.Body import retrofit2.http.DELETE @@ -28,7 +27,7 @@ interface FavoriteCinemasService { @GET("$BASE_PATH/{cinemaId}") suspend fun getFavoriteCinema( @Path("cinemaId") cinemaId: Uuid, - ): Response + ): Response @POST("$BASE_PATH/delete-all-by-id") suspend fun deleteFavoriteCinemas(@Body cinemaIds: List): Response> @@ -38,4 +37,7 @@ interface FavoriteCinemasService { @DELETE suspend fun deleteAllFavoriteCinemas(): Response + + @DELETE("$BASE_PATH/cinemas/{cinemaId}") + suspend fun deleteFavoriteCinemaByCinemaId(@Path("cinemaId") cinemaId: Uuid): Response } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/bytestrick/vertex/data/dao/FavoriteCinemasDao.kt b/android/app/src/main/java/com/github/bytestrick/vertex/data/dao/FavoriteCinemasDao.kt index 2fae2d3c7..a9b6c28ea 100644 --- a/android/app/src/main/java/com/github/bytestrick/vertex/data/dao/FavoriteCinemasDao.kt +++ b/android/app/src/main/java/com/github/bytestrick/vertex/data/dao/FavoriteCinemasDao.kt @@ -44,6 +44,9 @@ abstract class FavoriteCinemasDao { @Query("DELETE FROM favorite_cinemas WHERE id IN (:ids)") abstract suspend fun deleteAll(ids: List) + @Query("DELETE FROM favorite_cinemas WHERE cinema_id = :id") + abstract suspend fun deleteByCinemaId(id: Uuid) + @Query("UPDATE favorite_cinemas SET is_deleted = 1 WHERE id IN (:ids)") abstract suspend fun softDeleteAll(ids: List) diff --git a/android/app/src/main/java/com/github/bytestrick/vertex/repository/FavoriteCinemasRepository.kt b/android/app/src/main/java/com/github/bytestrick/vertex/repository/FavoriteCinemasRepository.kt index 20d9f6f5a..bb34bde3b 100644 --- a/android/app/src/main/java/com/github/bytestrick/vertex/repository/FavoriteCinemasRepository.kt +++ b/android/app/src/main/java/com/github/bytestrick/vertex/repository/FavoriteCinemasRepository.kt @@ -10,6 +10,7 @@ import androidx.work.WorkManager import com.github.bytestrick.vertex.api.Either import com.github.bytestrick.vertex.api.FavoriteCinemasService import com.github.bytestrick.vertex.api.apiCall +import com.github.bytestrick.vertex.api.apiCallNoContent import com.github.bytestrick.vertex.api.dto.FavoriteCinemaDto import com.github.bytestrick.vertex.api.dto.Slice import com.github.bytestrick.vertex.api.left @@ -170,19 +171,11 @@ class FavoriteCinemasRepository @Inject constructor( ).flow } - suspend fun getAuthUserFavoriteCinema(cinemaId: Uuid): Either { - val queryResult = daoQuery { favoriteCinemasDao.getByCinemaId(cinemaId) } - - return when (queryResult) { - is Either.Left -> queryResult - is Either.Right -> { - if (queryResult.value == null && !favoriteCinemaListStatus.value.isLoadedSuccessfully) { - apiCall { favoriteCinemasService.getFavoriteCinema(cinemaId) } - } else { - queryResult - } - } - } + suspend fun getAuthUserFavoriteCinema(cinemaId: Uuid): Either { + return apiCall( + serviceCall = { favoriteCinemasService.getFavoriteCinema(cinemaId) }, + converter = { it.asDomainModel() } + ) } suspend fun addToFavoriteCinema(id: Uuid): Either { @@ -248,4 +241,20 @@ class FavoriteCinemasRepository @Inject constructor( return result } + + suspend fun deleteFavoriteCinemaByCinemaId( + id: Uuid, + ): Either { + val apiResult = apiCallNoContent { + favoriteCinemasService.deleteFavoriteCinemaByCinemaId(id) + } + + if (apiResult is Either.Left) { + return apiResult + } + + daoQuery { favoriteCinemasDao.deleteByCinemaId(id) } + + return right(Unit) + } } \ No newline at end of file diff --git a/android/app/src/main/java/com/github/bytestrick/vertex/ui/cinema/CinemaViewModel.kt b/android/app/src/main/java/com/github/bytestrick/vertex/ui/cinema/CinemaViewModel.kt index c0df58f79..de35b2ae7 100644 --- a/android/app/src/main/java/com/github/bytestrick/vertex/ui/cinema/CinemaViewModel.kt +++ b/android/app/src/main/java/com/github/bytestrick/vertex/ui/cinema/CinemaViewModel.kt @@ -12,7 +12,6 @@ import com.github.bytestrick.vertex.api.Either import com.github.bytestrick.vertex.data.entity.Cinema import com.github.bytestrick.vertex.data.entity.FavoriteCinema import com.github.bytestrick.vertex.data.entity.Review -import com.github.bytestrick.vertex.data.model.DeleteMode import com.github.bytestrick.vertex.data.model.ReviewWithRatingMetadata import com.github.bytestrick.vertex.data.model.ReviewsStatsData import com.github.bytestrick.vertex.data.model.sortOptions.ReviewsSortOptions @@ -200,9 +199,9 @@ class CinemaViewModel @Inject constructor( sortController.toggleSortOption(sortCriterion) fun fetchAuthUserReview() { - setIsAuthUserReviewLoading(true) - viewModelScope.launch { + setIsAuthUserReviewLoading(true) + when (val result = reviewRepository.getAuthUserReview(cinemaId)) { is Either.Left -> { when (val failure = result.value) { @@ -210,10 +209,7 @@ class CinemaViewModel @Inject constructor( when (failure.code) { 401 -> Snackbar.show(R.string.reviews_get_mine_error_unauthorized) // auth user does not have a review in this cinema - 404 -> { - setIsAuthUserReviewLoading(false) - } - + 404 -> setIsAuthUserReviewLoading(false) else -> Snackbar.show(R.string.reviews_get_mine_error_fallback) } } @@ -371,12 +367,12 @@ class CinemaViewModel @Inject constructor( } fun postReview() { - if (_authUserReviewState.value.review != null) { - Log.w(tag, "postReview: auth user already has a review, skipping post") - return - } - viewModelScope.launch { + if (_authUserReviewState.value.review != null) { + Log.w(tag, "postReview: auth user already has a review, skipping post") + return@launch + } + Log.i(tag, "postReview: attempt to post review for cinemaId=$cinemaId") if (showErrorRelatedToReviewCreationModule()) { Log.w(tag, "postReview: validation failed, aborting") @@ -442,19 +438,19 @@ class CinemaViewModel @Inject constructor( } fun postUpdatedReview() { - val currentAuthUserReview = _authUserReviewState.value.review + viewModelScope.launch { + val currentAuthUserReview = _authUserReviewState.value.review - if (currentAuthUserReview == null) { - Log.w(tag, "postUpdatedReview: no auth user review present, skipping update") - return - } + if (currentAuthUserReview == null) { + Log.w(tag, "postUpdatedReview: no auth user review present, skipping update") + return@launch + } - if (isUpdatedReviewEqualToTheCurrentOne()) { - Log.w(tag, "postUpdatedReview: review is equal to the previous one, skipping update") - return - } + if (isUpdatedReviewEqualToTheCurrentOne()) { + Log.w(tag, "postUpdatedReview: review is equal to the previous one, skipping update") + return@launch + } - viewModelScope.launch { Log.i(tag, "postUpdatedReview: updating reviewId=${currentAuthUserReview.id}") if (showErrorRelatedToReviewCreationModule()) { Log.w(tag, "postUpdatedReview: validation failed, aborting") @@ -565,7 +561,6 @@ class CinemaViewModel @Inject constructor( //region favorite cinemas data class FavoriteCinemaState( - val id: Uuid? = null, val isLoading: Boolean = false, val isFavorite: Boolean = false, ) @@ -579,9 +574,9 @@ class CinemaViewModel @Inject constructor( } private fun updateIsAuthUserFavoriteCinema() { - _favoriteCinemaState.update { it.copy(isLoading = true) } - viewModelScope.launch { + _favoriteCinemaState.update { it.copy(isLoading = true) } + when (val result = favoriteCinemasRepository.getAuthUserFavoriteCinema(cinemaId)) { is Either.Left -> { when (val failure = result.value) { @@ -589,7 +584,7 @@ class CinemaViewModel @Inject constructor( when (failure.code) { 400 -> Snackbar.show(R.string.favorite_cinemas_get_error_invalid_request) 401 -> Snackbar.show(R.string.favorite_cinemas_get_error_unauthorized) - 404 -> Snackbar.show(R.string.favorite_cinemas_get_error_not_found) + 404 -> { /* empty */ } else -> Snackbar.show(R.string.favorite_cinemas_get_error_fallback) } } @@ -599,14 +594,8 @@ class CinemaViewModel @Inject constructor( } } - is Either.Right -> { - _favoriteCinemaState.update { it.copy(id = result.value?.id) } - - if (result.value == null) { - _favoriteCinemaState.update { it.copy(isFavorite = false) } - } else { - _favoriteCinemaState.update { it.copy(isFavorite = true) } - } + is Either.Right -> { + _favoriteCinemaState.update { it.copy(isFavorite = true) } } } @@ -615,14 +604,11 @@ class CinemaViewModel @Inject constructor( } fun removeFromFavoriteCinema() { - val favoriteCinemaId = favoriteCinemaState.value.id ?: return - - _favoriteCinemaState.update { it.copy(isLoading = true) } - viewModelScope.launch { - val result = favoriteCinemasRepository.deleteFavoriteCinemas( - listOf(favoriteCinemaId), - DeleteMode.INCLUDE + _favoriteCinemaState.update { it.copy(isLoading = true) } + + val result = favoriteCinemasRepository.deleteFavoriteCinemaByCinemaId( + cinemaId ) if (result.isRight()) { diff --git a/android/app/src/main/java/com/github/bytestrick/vertex/ui/common/composable/ProgressAwareButton.kt b/android/app/src/main/java/com/github/bytestrick/vertex/ui/common/composable/ProgressAwareButton.kt index 1c51d081b..23ddb5b9a 100644 --- a/android/app/src/main/java/com/github/bytestrick/vertex/ui/common/composable/ProgressAwareButton.kt +++ b/android/app/src/main/java/com/github/bytestrick/vertex/ui/common/composable/ProgressAwareButton.kt @@ -68,10 +68,9 @@ fun ProgressAwareFilledIconButton( enabled = !loading, modifier = modifier, ) { - AnimatedContent(loading) { + AnimatedContent(loading, modifier = Modifier.size(24.dp)) { if (it) { CircularProgressIndicator( - modifier = Modifier.size(16.dp), strokeWidth = 2.dp ) } else { diff --git a/backend/src/main/java/com/github/bytestrick/vertex/controller/FavoriteCinemasController.java b/backend/src/main/java/com/github/bytestrick/vertex/controller/FavoriteCinemasController.java index 939b31d24..0e02926f8 100644 --- a/backend/src/main/java/com/github/bytestrick/vertex/controller/FavoriteCinemasController.java +++ b/backend/src/main/java/com/github/bytestrick/vertex/controller/FavoriteCinemasController.java @@ -152,6 +152,30 @@ public void clearFavoriteCinemas() { favoriteCinemaService.clearFavoriteCinemasList(); } + @Operation( + summary = "Remove a favorite cinema", + description = "Removes the specified cinema from the currently authenticated user's favorites list." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "204", + description = "The cinema was successfully removed from the user's favorites." + ), + @ApiResponse( + responseCode = "401", + description = "Unauthorized - authentication is required to call this endpoint." + ), + @ApiResponse( + responseCode = "500", + description = "Unexpected server error while deleting the provided cinema." + ) + }) + @DeleteMapping("/cinemas/{cinemaId}") + @ResponseStatus(code = HttpStatus.NO_CONTENT) + public void deleteFavoriteCinemaByCinemaId(@PathVariable @Valid @NotNull UUID cinemaId) { + favoriteCinemaService.deleteFavoriteCinemaByCinemaId(cinemaId); + } + @Operation( summary = "Add a cinema to the authenticated user's favorite list", description = "Adds the specified cinema to the authenticated user's favorite list." diff --git a/backend/src/main/java/com/github/bytestrick/vertex/data/repository/FavoriteCinemaRepository.java b/backend/src/main/java/com/github/bytestrick/vertex/data/repository/FavoriteCinemaRepository.java index 90faed95f..d0e5c2f7b 100644 --- a/backend/src/main/java/com/github/bytestrick/vertex/data/repository/FavoriteCinemaRepository.java +++ b/backend/src/main/java/com/github/bytestrick/vertex/data/repository/FavoriteCinemaRepository.java @@ -32,4 +32,7 @@ public interface FavoriteCinemaRepository extends int countByUserId(UUID userId); Optional getByUserIdAndCinemaId(UUID userId, UUID cinemaId); + + @Modifying + void deleteByUserIdAndCinemaId(UUID userId, UUID cinemaId); } diff --git a/backend/src/main/java/com/github/bytestrick/vertex/data/service/FavoriteCinemaService.java b/backend/src/main/java/com/github/bytestrick/vertex/data/service/FavoriteCinemaService.java index 9ecfcf70d..fc3cc8bd1 100644 --- a/backend/src/main/java/com/github/bytestrick/vertex/data/service/FavoriteCinemaService.java +++ b/backend/src/main/java/com/github/bytestrick/vertex/data/service/FavoriteCinemaService.java @@ -127,6 +127,20 @@ public List deleteFavoriteCinemas( return toDelete.stream().map(FavoriteCinema::getId).toList(); } + /** + * Removes the favorite cinema associated with the currently authenticated user. + * + * @param cinemaId UUID of the cinema to be removed from the user's favorites + * @throws AuthenticationCredentialsNotFoundException if user is not authenticated + */ + @Transactional + public void deleteFavoriteCinemaByCinemaId( + final UUID cinemaId + ) throws AuthenticationCredentialsNotFoundException { + final UUID authUserId = UserService.getAuthenticatedUser().getId(); + favoriteCinemaRepository.deleteByUserIdAndCinemaId(authUserId, cinemaId); + } + /** * Adds a new cinema to favorites for the authenticated user if not already present. * diff --git a/backend/src/test/java/com/github/bytestrick/vertex/controller/FavoriteCinemasControllerTest.java b/backend/src/test/java/com/github/bytestrick/vertex/controller/FavoriteCinemasControllerTest.java index 5c6b719a5..18d4f09c0 100644 --- a/backend/src/test/java/com/github/bytestrick/vertex/controller/FavoriteCinemasControllerTest.java +++ b/backend/src/test/java/com/github/bytestrick/vertex/controller/FavoriteCinemasControllerTest.java @@ -294,4 +294,27 @@ public void addCinemaToFavorite_shouldReturnInternalServerError_whenUnexpectedEr .value("Failed to process the request")); } //endregion + + //region deleteFavoriteCinemaByCinemaId + @Test + void deleteFavoriteCinemaByCinemaId_shouldDeleteFavorite_whenCinemaIdIsFavorite() throws Exception { + doNothing() + .when(favoriteCinemaService).deleteFavoriteCinemaByCinemaId(any(UUID.class)); + + mockMvc.perform(delete(basePath + "/cinemas/{cinemaId}", UUID.randomUUID())) + .andExpect(status().isNoContent()); + } + + @Test + void deleteFavoriteCinemaByCinemaId_shouldThroeInternalServerError_whenAnUnexpectedErrorOccur() throws Exception { + doThrow(new RuntimeException()) + .when(favoriteCinemaService).deleteFavoriteCinemaByCinemaId(any(UUID.class)); + + mockMvc.perform(delete(basePath + "/cinemas/{cinemaId}", UUID.randomUUID())) + .andExpect(status().isInternalServerError()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON_VALUE)) + .andExpect(jsonPath("$.message") + .value("Failed to process the request")); + } + //endregion } diff --git a/backend/src/test/java/com/github/bytestrick/vertex/data/service/FavoriteCinemaServiceTest.java b/backend/src/test/java/com/github/bytestrick/vertex/data/service/FavoriteCinemaServiceTest.java index 062944812..f4af7669d 100644 --- a/backend/src/test/java/com/github/bytestrick/vertex/data/service/FavoriteCinemaServiceTest.java +++ b/backend/src/test/java/com/github/bytestrick/vertex/data/service/FavoriteCinemaServiceTest.java @@ -34,8 +34,6 @@ public class FavoriteCinemaServiceTest { @Mock private FavoriteCinemaRepository favoriteCinemaRepository; @Mock - private UserService userService; - @Mock private CinemaService cinemaService; @InjectMocks @@ -231,4 +229,22 @@ void addCinemaToFavorite_whenCinemaNotFound_shouldPropagateException() { assertThrows(CinemaNotFoundException.class, () -> favoriteCinemaService.addCinemaToFavorite(cinemaId)); } //endregion + + //region deleteFavoriteCinemaByCinemaId + @Test + void deleteFavoriteCinemaByCinemaId_shouldInvokeRepositoryDelete_whenCinemaIdIsFound() { + UUID cinemaId = UUID.randomUUID(); + + try (MockedStatic mockedStaticUserService = mockStatic(UserService.class)) { + mockedStaticUserService.when(UserService::getAuthenticatedUser) + .thenReturn(authUser); + + favoriteCinemaService.deleteFavoriteCinemaByCinemaId(cinemaId); + + mockedStaticUserService.verify(UserService::getAuthenticatedUser, times(1)); + verify(favoriteCinemaRepository) + .deleteByUserIdAndCinemaId(authUserId, cinemaId); + } + } + //endregion } \ No newline at end of file diff --git a/backend/src/test/java/com/github/bytestrick/vertex/data/service/ReviewServiceTest.java b/backend/src/test/java/com/github/bytestrick/vertex/data/service/ReviewServiceTest.java index 60f9d3cc6..94a9bf437 100644 --- a/backend/src/test/java/com/github/bytestrick/vertex/data/service/ReviewServiceTest.java +++ b/backend/src/test/java/com/github/bytestrick/vertex/data/service/ReviewServiceTest.java @@ -34,8 +34,6 @@ class ReviewServiceTest { @Mock private ReviewRepository reviewRepository; @Mock - private UserService userService; - @Mock private CinemaService cinemaService; @InjectMocks private ReviewService reviewService;