Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -28,7 +27,7 @@ interface FavoriteCinemasService {
@GET("$BASE_PATH/{cinemaId}")
suspend fun getFavoriteCinema(
@Path("cinemaId") cinemaId: Uuid,
): Response<FavoriteCinema>
): Response<FavoriteCinemaDto>

@POST("$BASE_PATH/delete-all-by-id")
suspend fun deleteFavoriteCinemas(@Body cinemaIds: List<Uuid>): Response<List<Uuid>>
Expand All @@ -38,4 +37,7 @@ interface FavoriteCinemasService {

@DELETE
suspend fun deleteAllFavoriteCinemas(): Response<Unit>

@DELETE("$BASE_PATH/cinemas/{cinemaId}")
suspend fun deleteFavoriteCinemaByCinemaId(@Path("cinemaId") cinemaId: Uuid): Response<Unit>
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ abstract class FavoriteCinemasDao {
@Query("DELETE FROM favorite_cinemas WHERE id IN (:ids)")
abstract suspend fun deleteAll(ids: List<Uuid>)

@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<Uuid>)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -170,19 +171,11 @@ class FavoriteCinemasRepository @Inject constructor(
).flow
}

suspend fun getAuthUserFavoriteCinema(cinemaId: Uuid): Either<Failure, FavoriteCinema?> {
val queryResult = daoQuery { favoriteCinemasDao.getByCinemaId(cinemaId) }

return when (queryResult) {
is Either.Left<Failure> -> queryResult
is Either.Right<FavoriteCinema?> -> {
if (queryResult.value == null && !favoriteCinemaListStatus.value.isLoadedSuccessfully) {
apiCall { favoriteCinemasService.getFavoriteCinema(cinemaId) }
} else {
queryResult
}
}
}
suspend fun getAuthUserFavoriteCinema(cinemaId: Uuid): Either<Failure, FavoriteCinema> {
return apiCall(
serviceCall = { favoriteCinemasService.getFavoriteCinema(cinemaId) },
converter = { it.asDomainModel() }
)
}

suspend fun addToFavoriteCinema(id: Uuid): Either<Failure, Unit> {
Expand Down Expand Up @@ -248,4 +241,20 @@ class FavoriteCinemasRepository @Inject constructor(

return result
}

suspend fun deleteFavoriteCinemaByCinemaId(
id: Uuid,
): Either<Failure, Unit> {
val apiResult = apiCallNoContent {
favoriteCinemasService.deleteFavoriteCinemaByCinemaId(id)
}

if (apiResult is Either.Left) {
return apiResult
}

daoQuery { favoriteCinemasDao.deleteByCinemaId(id) }

return right(Unit)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -200,20 +199,17 @@ class CinemaViewModel @Inject constructor(
sortController.toggleSortOption(sortCriterion)

fun fetchAuthUserReview() {
setIsAuthUserReviewLoading(true)

viewModelScope.launch {
setIsAuthUserReviewLoading(true)

when (val result = reviewRepository.getAuthUserReview(cinemaId)) {
is Either.Left<Failure> -> {
when (val failure = result.value) {
is Failure.HttpError -> {
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)
}
}
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
)
Expand All @@ -579,17 +574,17 @@ 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<Failure> -> {
when (val failure = result.value) {
is Failure.HttpError -> {
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)
}
}
Expand All @@ -599,14 +594,8 @@ class CinemaViewModel @Inject constructor(
}
}

is Either.Right<FavoriteCinema?> -> {
_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<FavoriteCinema> -> {
_favoriteCinemaState.update { it.copy(isFavorite = true) }
}
}

Expand All @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,4 +32,7 @@ public interface FavoriteCinemaRepository extends
int countByUserId(UUID userId);

Optional<FavoriteCinema> getByUserIdAndCinemaId(UUID userId, UUID cinemaId);

@Modifying
void deleteByUserIdAndCinemaId(UUID userId, UUID cinemaId);
}
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,20 @@ public List<UUID> 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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,6 @@ public class FavoriteCinemaServiceTest {
@Mock
private FavoriteCinemaRepository favoriteCinemaRepository;
@Mock
private UserService userService;
@Mock
private CinemaService cinemaService;

@InjectMocks
Expand Down Expand Up @@ -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<UserService> 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
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,6 @@ class ReviewServiceTest {
@Mock
private ReviewRepository reviewRepository;
@Mock
private UserService userService;
@Mock
private CinemaService cinemaService;
@InjectMocks
private ReviewService reviewService;
Expand Down