diff --git a/build.gradle.kts b/build.gradle.kts index 28305b8..9f09a0f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -67,6 +67,9 @@ dependencies { implementation("io.grpc:grpc-netty-shaded:$grpcVersion") compileOnly("javax.annotation:javax.annotation-api:1.3.2") + // gRPC Client + implementation("net.devh:grpc-client-spring-boot-starter:3.1.0.RELEASE") + // Tracing (Zipkin) implementation("org.springframework.boot:spring-boot-micrometer-tracing-brave") implementation("org.springframework.boot:spring-boot-starter-zipkin") diff --git a/environment/.local.env b/environment/.local.env index b956e2f..efdc29c 100644 --- a/environment/.local.env +++ b/environment/.local.env @@ -10,4 +10,8 @@ RABBITMQ_HOST=devoops-rabbitmq RABBITMQ_PORT=5672 RABBITMQ_USERNAME=devoops RABBITMQ_PASSWORD=devoops123 -RABBITMQ_VHOST=/ \ No newline at end of file +RABBITMQ_VHOST=/ +RESERVATION_SERVICE_GRPC_HOST=devoops-reservation-service +RESERVATION_SERVICE_GRPC_PORT=9090 +ACCOMMODATION_SERVICE_GRPC_HOST=devoops-accommodation-service +ACCOMMODATION_SERVICE_GRPC_PORT=9090 \ No newline at end of file diff --git a/src/main/java/com/devoops/user/controller/UserController.java b/src/main/java/com/devoops/user/controller/UserController.java index 6d355f3..b98aa9c 100644 --- a/src/main/java/com/devoops/user/controller/UserController.java +++ b/src/main/java/com/devoops/user/controller/UserController.java @@ -41,4 +41,11 @@ public ResponseEntity changePassword( userService.changePassword(userContext.userId(), request); return ResponseEntity.noContent().build(); } + + @DeleteMapping + @RequireRole({"HOST", "GUEST"}) + public ResponseEntity deleteAccount(UserContext userContext) { + userService.deleteAccount(userContext.userId()); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/com/devoops/user/exception/AccountDeletionException.java b/src/main/java/com/devoops/user/exception/AccountDeletionException.java new file mode 100644 index 0000000..149c4b4 --- /dev/null +++ b/src/main/java/com/devoops/user/exception/AccountDeletionException.java @@ -0,0 +1,18 @@ +package com.devoops.user.exception; + +import lombok.Getter; + +/** + * Exception thrown when an account cannot be deleted due to business rules. + * For example, when a guest has active reservations or a host has future reservations. + */ +@Getter +public class AccountDeletionException extends RuntimeException { + + private final int activeReservationCount; + + public AccountDeletionException(String message, int activeReservationCount) { + super(message); + this.activeReservationCount = activeReservationCount; + } +} diff --git a/src/main/java/com/devoops/user/exception/GlobalExceptionHandler.java b/src/main/java/com/devoops/user/exception/GlobalExceptionHandler.java index 96d1083..5fddbc3 100644 --- a/src/main/java/com/devoops/user/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/devoops/user/exception/GlobalExceptionHandler.java @@ -95,4 +95,13 @@ public ProblemDetail handleValidationErrors(MethodArgumentNotValidException ex) problemDetail.setProperty("errors", errors); return problemDetail; } + + @ExceptionHandler(AccountDeletionException.class) + public ProblemDetail handleAccountDeletion(AccountDeletionException ex) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage()); + problemDetail.setTitle("Account Deletion Failed"); + problemDetail.setProperty("timestamp", Instant.now()); + problemDetail.setProperty("activeReservationCount", ex.getActiveReservationCount()); + return problemDetail; + } } diff --git a/src/main/java/com/devoops/user/grpc/AccommodationGrpcClient.java b/src/main/java/com/devoops/user/grpc/AccommodationGrpcClient.java new file mode 100644 index 0000000..0ff14d5 --- /dev/null +++ b/src/main/java/com/devoops/user/grpc/AccommodationGrpcClient.java @@ -0,0 +1,47 @@ +package com.devoops.user.grpc; + +import com.devoops.user.grpc.proto.accommodation.*; +import io.grpc.StatusRuntimeException; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.client.inject.GrpcClient; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +@Slf4j +public class AccommodationGrpcClient { + + @GrpcClient("accommodation-service") + private AccommodationInternalServiceGrpc.AccommodationInternalServiceBlockingStub accommodationStub; + + /** + * Delete all accommodations for a host (cascade deletion). + */ + public CascadeDeleteResult deleteAccommodationsByHost(UUID hostId) { + log.debug("Deleting all accommodations for host {}", hostId); + + try { + DeleteByHostRequest request = DeleteByHostRequest.newBuilder() + .setHostId(hostId.toString()) + .build(); + + DeleteByHostResponse response = accommodationStub.deleteAccommodationsByHost(request); + + if (response.getSuccess()) { + log.info("Successfully deleted {} accommodations for host {}", response.getDeletedCount(), hostId); + } else { + log.error("Failed to delete accommodations for host {}: {}", hostId, response.getErrorMessage()); + } + + return new CascadeDeleteResult( + response.getSuccess(), + response.getDeletedCount(), + response.getErrorMessage() + ); + } catch (StatusRuntimeException e) { + log.error("gRPC error deleting accommodations for host {}: {}", hostId, e.getStatus(), e); + throw new RuntimeException("Failed to delete host accommodations", e); + } + } +} diff --git a/src/main/java/com/devoops/user/grpc/CascadeDeleteResult.java b/src/main/java/com/devoops/user/grpc/CascadeDeleteResult.java new file mode 100644 index 0000000..d955a97 --- /dev/null +++ b/src/main/java/com/devoops/user/grpc/CascadeDeleteResult.java @@ -0,0 +1,15 @@ +package com.devoops.user.grpc; + +/** + * Result of cascade deleting accommodations for a host. + * + * @param success true if deletion was successful + * @param deletedCount the number of accommodations deleted + * @param errorMessage error message if deletion failed, empty otherwise + */ +public record CascadeDeleteResult( + boolean success, + int deletedCount, + String errorMessage +) { +} diff --git a/src/main/java/com/devoops/user/grpc/DeletionCheckResult.java b/src/main/java/com/devoops/user/grpc/DeletionCheckResult.java new file mode 100644 index 0000000..a2158cf --- /dev/null +++ b/src/main/java/com/devoops/user/grpc/DeletionCheckResult.java @@ -0,0 +1,15 @@ +package com.devoops.user.grpc; + +/** + * Result of checking whether a user can be deleted. + * + * @param canBeDeleted true if the user can be deleted + * @param reason the reason if the user cannot be deleted, empty otherwise + * @param activeReservationCount the number of active reservations blocking deletion + */ +public record DeletionCheckResult( + boolean canBeDeleted, + String reason, + int activeReservationCount +) { +} diff --git a/src/main/java/com/devoops/user/grpc/ReservationGrpcClient.java b/src/main/java/com/devoops/user/grpc/ReservationGrpcClient.java new file mode 100644 index 0000000..8694ac2 --- /dev/null +++ b/src/main/java/com/devoops/user/grpc/ReservationGrpcClient.java @@ -0,0 +1,65 @@ +package com.devoops.user.grpc; + +import com.devoops.user.grpc.proto.reservation.*; +import io.grpc.StatusRuntimeException; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.client.inject.GrpcClient; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +@Slf4j +public class ReservationGrpcClient { + + @GrpcClient("reservation-service") + private ReservationInternalServiceGrpc.ReservationInternalServiceBlockingStub reservationStub; + + /** + * Check if a guest can be deleted (has no active reservations). + */ + public DeletionCheckResult checkGuestCanBeDeleted(UUID guestId) { + log.debug("Checking if guest {} can be deleted", guestId); + + try { + CheckGuestDeletionRequest request = CheckGuestDeletionRequest.newBuilder() + .setGuestId(guestId.toString()) + .build(); + + CheckDeletionResponse response = reservationStub.checkGuestCanBeDeleted(request); + + return new DeletionCheckResult( + response.getCanBeDeleted(), + response.getReason(), + response.getActiveReservationCount() + ); + } catch (StatusRuntimeException e) { + log.error("gRPC error checking if guest {} can be deleted: {}", guestId, e.getStatus(), e); + throw new RuntimeException("Failed to check guest deletion eligibility", e); + } + } + + /** + * Check if a host can be deleted (has no active reservations on their accommodations). + */ + public DeletionCheckResult checkHostCanBeDeleted(UUID hostId) { + log.debug("Checking if host {} can be deleted", hostId); + + try { + CheckHostDeletionRequest request = CheckHostDeletionRequest.newBuilder() + .setHostId(hostId.toString()) + .build(); + + CheckDeletionResponse response = reservationStub.checkHostCanBeDeleted(request); + + return new DeletionCheckResult( + response.getCanBeDeleted(), + response.getReason(), + response.getActiveReservationCount() + ); + } catch (StatusRuntimeException e) { + log.error("gRPC error checking if host {} can be deleted: {}", hostId, e.getStatus(), e); + throw new RuntimeException("Failed to check host deletion eligibility", e); + } + } +} diff --git a/src/main/java/com/devoops/user/grpc/UserGrpcService.java b/src/main/java/com/devoops/user/grpc/UserGrpcService.java index 292b391..dbe681e 100644 --- a/src/main/java/com/devoops/user/grpc/UserGrpcService.java +++ b/src/main/java/com/devoops/user/grpc/UserGrpcService.java @@ -42,15 +42,27 @@ private GetUserSummaryResponse processRequest(GetUserSummaryRequest request) { return buildNotFoundResponse(); } + // First try to find active (non-deleted) user Optional userOpt = userRepository.findById(userId); - if (userOpt.isEmpty()) { - log.debug("User not found: {}", userId); - return buildNotFoundResponse(); + if (userOpt.isPresent()) { + User user = userOpt.get(); + log.debug("Found active user: {} {} ({})", user.getFirstName(), user.getLastName(), user.getRole()); + return buildUserResponse(user, false); + } + + // If not found, check if user exists but is deleted + Optional deletedUserOpt = userRepository.findByIdIncludingDeleted(userId); + if (deletedUserOpt.isPresent()) { + User user = deletedUserOpt.get(); + log.debug("Found deleted user: {} {} ({})", user.getFirstName(), user.getLastName(), user.getRole()); + return buildUserResponse(user, true); } - User user = userOpt.get(); - log.debug("Found user: {} {} ({})", user.getFirstName(), user.getLastName(), user.getRole()); + log.debug("User not found: {}", userId); + return buildNotFoundResponse(); + } + private GetUserSummaryResponse buildUserResponse(User user, boolean isDeleted) { return GetUserSummaryResponse.newBuilder() .setFound(true) .setUserId(user.getId().toString()) @@ -58,6 +70,7 @@ private GetUserSummaryResponse processRequest(GetUserSummaryRequest request) { .setFirstName(user.getFirstName()) .setLastName(user.getLastName()) .setRole(user.getRole().name()) + .setIsDeleted(isDeleted) .build(); } diff --git a/src/main/java/com/devoops/user/repository/UserRepository.java b/src/main/java/com/devoops/user/repository/UserRepository.java index 9092b09..1ef8514 100644 --- a/src/main/java/com/devoops/user/repository/UserRepository.java +++ b/src/main/java/com/devoops/user/repository/UserRepository.java @@ -2,6 +2,8 @@ import com.devoops.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Optional; @@ -19,4 +21,12 @@ public interface UserRepository extends JpaRepository { boolean existsByUsername(String username); boolean existsByEmail(String email); + + /** + * Find a user by ID including deleted users. + * This bypasses the @SQLRestriction filter to allow fetching deleted user info + * for historical data display (e.g., showing guest name on past reservations). + */ + @Query(value = "SELECT * FROM users WHERE id = :id", nativeQuery = true) + Optional findByIdIncludingDeleted(@Param("id") UUID id); } diff --git a/src/main/java/com/devoops/user/service/UserService.java b/src/main/java/com/devoops/user/service/UserService.java index 5028c3d..7376b63 100644 --- a/src/main/java/com/devoops/user/service/UserService.java +++ b/src/main/java/com/devoops/user/service/UserService.java @@ -4,27 +4,38 @@ import com.devoops.user.dto.request.UpdateUserRequest; import com.devoops.user.dto.response.AuthenticationResponse; import com.devoops.user.dto.response.UserResponse; +import com.devoops.user.entity.Role; import com.devoops.user.entity.User; +import com.devoops.user.exception.AccountDeletionException; import com.devoops.user.exception.InvalidPasswordException; import com.devoops.user.exception.UserAlreadyExistsException; import com.devoops.user.exception.UserNotFoundException; +import com.devoops.user.grpc.AccommodationGrpcClient; +import com.devoops.user.grpc.CascadeDeleteResult; +import com.devoops.user.grpc.DeletionCheckResult; +import com.devoops.user.grpc.ReservationGrpcClient; import com.devoops.user.mapper.UserMapper; import com.devoops.user.repository.UserRepository; import com.devoops.user.security.JwtService; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.UUID; @Service @RequiredArgsConstructor +@Slf4j public class UserService { private final UserRepository userRepository; private final UserMapper userMapper; private final PasswordEncoder passwordEncoder; private final JwtService jwtService; + private final ReservationGrpcClient reservationGrpcClient; + private final AccommodationGrpcClient accommodationGrpcClient; public UserResponse getProfile(UUID userId) { User user = userRepository.findById(userId) @@ -69,4 +80,64 @@ public void changePassword(UUID userId, ChangePasswordRequest request) { user.setPassword(passwordEncoder.encode(request.newPassword())); userRepository.save(user); } + + /** + * Delete a user account. + *

+ * Guests can only delete their account if they have no active reservations + * (PENDING or APPROVED with endDate >= today). + *

+ * Hosts can only delete their account if no future reservations exist for any + * of their accommodations. When a host deletes their account, all their + * accommodations are also soft-deleted. + * + * @param userId the ID of the user to delete + * @throws AccountDeletionException if the account cannot be deleted + */ + @Transactional + public void deleteAccount(UUID userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new UserNotFoundException("User does not exist")); + + log.info("Attempting to delete account for user {} with role {}", userId, user.getRole()); + + if (user.getRole() == Role.GUEST) { + DeletionCheckResult checkResult = reservationGrpcClient.checkGuestCanBeDeleted(userId); + + if (!checkResult.canBeDeleted()) { + log.warn("Cannot delete guest account {}: {}", userId, checkResult.reason()); + throw new AccountDeletionException( + "Cannot delete account: you have " + checkResult.activeReservationCount() + + " active reservation(s). Please cancel or complete them before deleting your account.", + checkResult.activeReservationCount() + ); + } + } else if (user.getRole() == Role.HOST) { + DeletionCheckResult checkResult = reservationGrpcClient.checkHostCanBeDeleted(userId); + + if (!checkResult.canBeDeleted()) { + log.warn("Cannot delete host account {}: {}", userId, checkResult.reason()); + throw new AccountDeletionException( + "Cannot delete account: you have " + checkResult.activeReservationCount() + + " active reservation(s) on your accommodations. " + + "Please wait for them to complete before deleting your account.", + checkResult.activeReservationCount() + ); + } + + // Cascade delete all accommodations for the host + CascadeDeleteResult cascadeResult = accommodationGrpcClient.deleteAccommodationsByHost(userId); + if (!cascadeResult.success()) { + log.error("Failed to delete accommodations for host {}: {}", userId, cascadeResult.errorMessage()); + throw new RuntimeException("Failed to delete accommodations: " + cascadeResult.errorMessage()); + } + log.info("Deleted {} accommodations for host {}", cascadeResult.deletedCount(), userId); + } + + // Soft delete the user + user.setDeleted(true); + userRepository.save(user); + + log.info("Successfully deleted account for user {}", userId); + } } diff --git a/src/main/proto/accommodation_internal.proto b/src/main/proto/accommodation_internal.proto new file mode 100644 index 0000000..11480d4 --- /dev/null +++ b/src/main/proto/accommodation_internal.proto @@ -0,0 +1,38 @@ +syntax = "proto3"; +package accommodation; + +option java_multiple_files = true; +option java_package = "com.devoops.user.grpc.proto.accommodation"; + +service AccommodationInternalService { + rpc ValidateAndCalculatePrice(ReservationValidationRequest) returns (ReservationValidationResponse); + rpc DeleteAccommodationsByHost(DeleteByHostRequest) returns (DeleteByHostResponse); +} + +message ReservationValidationRequest { + string accommodation_id = 1; + string start_date = 2; + string end_date = 3; + int32 guest_count = 4; +} + +message ReservationValidationResponse { + bool valid = 1; + string error_code = 2; + string error_message = 3; + string host_id = 4; + string total_price = 5; + string pricing_mode = 6; + string approval_mode = 7; + string accommodation_name = 8; +} + +message DeleteByHostRequest { + string host_id = 1; +} + +message DeleteByHostResponse { + bool success = 1; + int32 deleted_count = 2; + string error_message = 3; +} diff --git a/src/main/proto/reservation_internal.proto b/src/main/proto/reservation_internal.proto new file mode 100644 index 0000000..c26d187 --- /dev/null +++ b/src/main/proto/reservation_internal.proto @@ -0,0 +1,36 @@ +syntax = "proto3"; + +package reservation; + +option java_multiple_files = true; +option java_package = "com.devoops.user.grpc.proto.reservation"; + +service ReservationInternalService { + rpc CheckReservationsExist(CheckReservationsExistRequest) returns (CheckReservationsExistResponse); + rpc CheckGuestCanBeDeleted(CheckGuestDeletionRequest) returns (CheckDeletionResponse); + rpc CheckHostCanBeDeleted(CheckHostDeletionRequest) returns (CheckDeletionResponse); +} + +message CheckReservationsExistRequest { + string accommodation_id = 1; + string start_date = 2; + string end_date = 3; +} + +message CheckReservationsExistResponse { + bool has_reservations = 1; +} + +message CheckGuestDeletionRequest { + string guest_id = 1; +} + +message CheckHostDeletionRequest { + string host_id = 1; +} + +message CheckDeletionResponse { + bool can_be_deleted = 1; + string reason = 2; + int32 active_reservation_count = 3; +} diff --git a/src/main/proto/user_internal.proto b/src/main/proto/user_internal.proto index b50505a..7fa9c43 100644 --- a/src/main/proto/user_internal.proto +++ b/src/main/proto/user_internal.proto @@ -19,4 +19,5 @@ message GetUserSummaryResponse { string first_name = 4; string last_name = 5; string role = 6; + bool is_deleted = 7; } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index adb2133..50d1db0 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -48,3 +48,9 @@ rabbitmq.routing-key.user-created=user.created # gRPC Server grpc.server.port=${GRPC_PORT:9090} + +# gRPC Clients +grpc.client.reservation-service.address=static://${RESERVATION_SERVICE_GRPC_HOST:devoops-reservation-service}:${RESERVATION_SERVICE_GRPC_PORT:9090} +grpc.client.reservation-service.negotiationType=plaintext +grpc.client.accommodation-service.address=static://${ACCOMMODATION_SERVICE_GRPC_HOST:devoops-accommodation-service}:${ACCOMMODATION_SERVICE_GRPC_PORT:9090} +grpc.client.accommodation-service.negotiationType=plaintext diff --git a/src/test/java/com/devoops/user/controller/UserControllerTest.java b/src/test/java/com/devoops/user/controller/UserControllerTest.java index 90705da..a6c423e 100644 --- a/src/test/java/com/devoops/user/controller/UserControllerTest.java +++ b/src/test/java/com/devoops/user/controller/UserControllerTest.java @@ -7,6 +7,7 @@ import com.devoops.user.dto.response.AuthenticationResponse; import com.devoops.user.dto.response.UserResponse; import com.devoops.user.entity.Role; +import com.devoops.user.exception.AccountDeletionException; import com.devoops.user.exception.GlobalExceptionHandler; import com.devoops.user.exception.InvalidCredentialsException; import com.devoops.user.exception.UserAlreadyExistsException; @@ -28,10 +29,8 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.doThrow; -import static org.mockito.Mockito.when; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @ExtendWith(MockitoExtension.class) @@ -302,4 +301,74 @@ void changePassword_WithShortNewPassword_Returns400() throws Exception { .andExpect(status().isBadRequest()); } } + + @Nested + @DisplayName("DELETE /api/user/me — deleteAccount") + class DeleteAccountTests { + + @Test + @DisplayName("Should return 204 NO CONTENT when account deletion is successful") + void deleteAccount_WithValidRequest_Returns204() throws Exception { + // Given + doNothing().when(userService).deleteAccount(eq(userId)); + + // When/Then + mockMvc.perform(delete("/api/user/me") + .header("X-User-Id", userId.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isNoContent()); + + verify(userService).deleteAccount(userId); + } + + @Test + @DisplayName("Should return 409 CONFLICT when guest has active reservations") + void deleteAccount_WithActiveReservations_Returns409() throws Exception { + // Given + doThrow(new AccountDeletionException("Cannot delete account: you have 2 active reservation(s)", 2)) + .when(userService).deleteAccount(eq(userId)); + + // When/Then + mockMvc.perform(delete("/api/user/me") + .header("X-User-Id", userId.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.title").value("Account Deletion Failed")) + .andExpect(jsonPath("$.detail").value("Cannot delete account: you have 2 active reservation(s)")) + .andExpect(jsonPath("$.activeReservationCount").value(2)); + } + + @Test + @DisplayName("Should return 401 when auth headers are missing") + void deleteAccount_WithoutHeaders_Returns401() throws Exception { + // When/Then + mockMvc.perform(delete("/api/user/me")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("Should return 403 when role is not allowed") + void deleteAccount_WithWrongRole_Returns403() throws Exception { + // When/Then + mockMvc.perform(delete("/api/user/me") + .header("X-User-Id", userId.toString()) + .header("X-User-Role", "ADMIN")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("Should work for HOST role") + void deleteAccount_WithHostRole_Returns204() throws Exception { + // Given + doNothing().when(userService).deleteAccount(eq(userId)); + + // When/Then + mockMvc.perform(delete("/api/user/me") + .header("X-User-Id", userId.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isNoContent()); + + verify(userService).deleteAccount(userId); + } + } } diff --git a/src/test/java/com/devoops/user/grpc/AccommodationGrpcClientTest.java b/src/test/java/com/devoops/user/grpc/AccommodationGrpcClientTest.java new file mode 100644 index 0000000..c4c2994 --- /dev/null +++ b/src/test/java/com/devoops/user/grpc/AccommodationGrpcClientTest.java @@ -0,0 +1,123 @@ +package com.devoops.user.grpc; + +import com.devoops.user.grpc.proto.accommodation.*; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AccommodationGrpcClientTest { + + @Mock + private AccommodationInternalServiceGrpc.AccommodationInternalServiceBlockingStub accommodationStub; + + private AccommodationGrpcClient accommodationGrpcClient; + + @BeforeEach + void setUp() throws Exception { + accommodationGrpcClient = new AccommodationGrpcClient(); + // Use reflection to inject the mock stub + Field stubField = AccommodationGrpcClient.class.getDeclaredField("accommodationStub"); + stubField.setAccessible(true); + stubField.set(accommodationGrpcClient, accommodationStub); + } + + @Nested + @DisplayName("deleteAccommodationsByHost Tests") + class DeleteAccommodationsByHostTests { + + @Test + @DisplayName("Should return success when accommodations are deleted successfully") + void deleteAccommodationsByHost_Success_ReturnsSuccessResult() { + // Given + UUID hostId = UUID.randomUUID(); + DeleteByHostResponse response = DeleteByHostResponse.newBuilder() + .setSuccess(true) + .setDeletedCount(5) + .setErrorMessage("") + .build(); + when(accommodationStub.deleteAccommodationsByHost(any(DeleteByHostRequest.class))) + .thenReturn(response); + + // When + CascadeDeleteResult result = accommodationGrpcClient.deleteAccommodationsByHost(hostId); + + // Then + assertThat(result.success()).isTrue(); + assertThat(result.deletedCount()).isEqualTo(5); + assertThat(result.errorMessage()).isEmpty(); + } + + @Test + @DisplayName("Should return success with zero count when host has no accommodations") + void deleteAccommodationsByHost_NoAccommodations_ReturnsSuccessWithZeroCount() { + // Given + UUID hostId = UUID.randomUUID(); + DeleteByHostResponse response = DeleteByHostResponse.newBuilder() + .setSuccess(true) + .setDeletedCount(0) + .setErrorMessage("") + .build(); + when(accommodationStub.deleteAccommodationsByHost(any(DeleteByHostRequest.class))) + .thenReturn(response); + + // When + CascadeDeleteResult result = accommodationGrpcClient.deleteAccommodationsByHost(hostId); + + // Then + assertThat(result.success()).isTrue(); + assertThat(result.deletedCount()).isZero(); + assertThat(result.errorMessage()).isEmpty(); + } + + @Test + @DisplayName("Should return failure when deletion fails") + void deleteAccommodationsByHost_Failure_ReturnsFailureResult() { + // Given + UUID hostId = UUID.randomUUID(); + DeleteByHostResponse response = DeleteByHostResponse.newBuilder() + .setSuccess(false) + .setDeletedCount(0) + .setErrorMessage("Database constraint violation") + .build(); + when(accommodationStub.deleteAccommodationsByHost(any(DeleteByHostRequest.class))) + .thenReturn(response); + + // When + CascadeDeleteResult result = accommodationGrpcClient.deleteAccommodationsByHost(hostId); + + // Then + assertThat(result.success()).isFalse(); + assertThat(result.deletedCount()).isZero(); + assertThat(result.errorMessage()).isEqualTo("Database constraint violation"); + } + + @Test + @DisplayName("Should throw RuntimeException when gRPC call fails") + void deleteAccommodationsByHost_GrpcError_ThrowsRuntimeException() { + // Given + UUID hostId = UUID.randomUUID(); + when(accommodationStub.deleteAccommodationsByHost(any(DeleteByHostRequest.class))) + .thenThrow(new StatusRuntimeException(Status.UNAVAILABLE)); + + // When/Then + assertThatThrownBy(() -> accommodationGrpcClient.deleteAccommodationsByHost(hostId)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to delete host accommodations"); + } + } +} diff --git a/src/test/java/com/devoops/user/grpc/ReservationGrpcClientTest.java b/src/test/java/com/devoops/user/grpc/ReservationGrpcClientTest.java new file mode 100644 index 0000000..e14a319 --- /dev/null +++ b/src/test/java/com/devoops/user/grpc/ReservationGrpcClientTest.java @@ -0,0 +1,164 @@ +package com.devoops.user.grpc; + +import com.devoops.user.grpc.proto.reservation.*; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ReservationGrpcClientTest { + + @Mock + private ReservationInternalServiceGrpc.ReservationInternalServiceBlockingStub reservationStub; + + private ReservationGrpcClient reservationGrpcClient; + + @BeforeEach + void setUp() throws Exception { + reservationGrpcClient = new ReservationGrpcClient(); + // Use reflection to inject the mock stub + Field stubField = ReservationGrpcClient.class.getDeclaredField("reservationStub"); + stubField.setAccessible(true); + stubField.set(reservationGrpcClient, reservationStub); + } + + @Nested + @DisplayName("checkGuestCanBeDeleted Tests") + class CheckGuestCanBeDeletedTests { + + @Test + @DisplayName("Should return can be deleted when guest has no active reservations") + void checkGuestCanBeDeleted_NoActiveReservations_ReturnsCanBeDeleted() { + // Given + UUID guestId = UUID.randomUUID(); + CheckDeletionResponse response = CheckDeletionResponse.newBuilder() + .setCanBeDeleted(true) + .setReason("") + .setActiveReservationCount(0) + .build(); + when(reservationStub.checkGuestCanBeDeleted(any(CheckGuestDeletionRequest.class))) + .thenReturn(response); + + // When + DeletionCheckResult result = reservationGrpcClient.checkGuestCanBeDeleted(guestId); + + // Then + assertThat(result.canBeDeleted()).isTrue(); + assertThat(result.reason()).isEmpty(); + assertThat(result.activeReservationCount()).isZero(); + } + + @Test + @DisplayName("Should return cannot be deleted when guest has active reservations") + void checkGuestCanBeDeleted_HasActiveReservations_ReturnsCannotBeDeleted() { + // Given + UUID guestId = UUID.randomUUID(); + CheckDeletionResponse response = CheckDeletionResponse.newBuilder() + .setCanBeDeleted(false) + .setReason("Guest has 3 active reservations") + .setActiveReservationCount(3) + .build(); + when(reservationStub.checkGuestCanBeDeleted(any(CheckGuestDeletionRequest.class))) + .thenReturn(response); + + // When + DeletionCheckResult result = reservationGrpcClient.checkGuestCanBeDeleted(guestId); + + // Then + assertThat(result.canBeDeleted()).isFalse(); + assertThat(result.reason()).isEqualTo("Guest has 3 active reservations"); + assertThat(result.activeReservationCount()).isEqualTo(3); + } + + @Test + @DisplayName("Should throw RuntimeException when gRPC call fails") + void checkGuestCanBeDeleted_GrpcError_ThrowsRuntimeException() { + // Given + UUID guestId = UUID.randomUUID(); + when(reservationStub.checkGuestCanBeDeleted(any(CheckGuestDeletionRequest.class))) + .thenThrow(new StatusRuntimeException(Status.UNAVAILABLE)); + + // When/Then + assertThatThrownBy(() -> reservationGrpcClient.checkGuestCanBeDeleted(guestId)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to check guest deletion eligibility"); + } + } + + @Nested + @DisplayName("checkHostCanBeDeleted Tests") + class CheckHostCanBeDeletedTests { + + @Test + @DisplayName("Should return can be deleted when host has no active reservations") + void checkHostCanBeDeleted_NoActiveReservations_ReturnsCanBeDeleted() { + // Given + UUID hostId = UUID.randomUUID(); + CheckDeletionResponse response = CheckDeletionResponse.newBuilder() + .setCanBeDeleted(true) + .setReason("") + .setActiveReservationCount(0) + .build(); + when(reservationStub.checkHostCanBeDeleted(any(CheckHostDeletionRequest.class))) + .thenReturn(response); + + // When + DeletionCheckResult result = reservationGrpcClient.checkHostCanBeDeleted(hostId); + + // Then + assertThat(result.canBeDeleted()).isTrue(); + assertThat(result.reason()).isEmpty(); + assertThat(result.activeReservationCount()).isZero(); + } + + @Test + @DisplayName("Should return cannot be deleted when host has active reservations") + void checkHostCanBeDeleted_HasActiveReservations_ReturnsCannotBeDeleted() { + // Given + UUID hostId = UUID.randomUUID(); + CheckDeletionResponse response = CheckDeletionResponse.newBuilder() + .setCanBeDeleted(false) + .setReason("Host has 5 active reservations on their accommodations") + .setActiveReservationCount(5) + .build(); + when(reservationStub.checkHostCanBeDeleted(any(CheckHostDeletionRequest.class))) + .thenReturn(response); + + // When + DeletionCheckResult result = reservationGrpcClient.checkHostCanBeDeleted(hostId); + + // Then + assertThat(result.canBeDeleted()).isFalse(); + assertThat(result.reason()).isEqualTo("Host has 5 active reservations on their accommodations"); + assertThat(result.activeReservationCount()).isEqualTo(5); + } + + @Test + @DisplayName("Should throw RuntimeException when gRPC call fails") + void checkHostCanBeDeleted_GrpcError_ThrowsRuntimeException() { + // Given + UUID hostId = UUID.randomUUID(); + when(reservationStub.checkHostCanBeDeleted(any(CheckHostDeletionRequest.class))) + .thenThrow(new StatusRuntimeException(Status.INTERNAL)); + + // When/Then + assertThatThrownBy(() -> reservationGrpcClient.checkHostCanBeDeleted(hostId)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to check host deletion eligibility"); + } + } +} diff --git a/src/test/java/com/devoops/user/grpc/UserGrpcServiceTest.java b/src/test/java/com/devoops/user/grpc/UserGrpcServiceTest.java index fd16ad3..d109282 100644 --- a/src/test/java/com/devoops/user/grpc/UserGrpcServiceTest.java +++ b/src/test/java/com/devoops/user/grpc/UserGrpcServiceTest.java @@ -85,6 +85,7 @@ void getUserSummary_WithExistingUser_ReturnsSummary() { assertThat(response.getFirstName()).isEqualTo("Test"); assertThat(response.getLastName()).isEqualTo("User"); assertThat(response.getRole()).isEqualTo("GUEST"); + assertThat(response.getIsDeleted()).isFalse(); } @Test @@ -96,6 +97,7 @@ void getUserSummary_WithNonExistingUser_ReturnsNotFound() { .setUserId(unknownId.toString()) .build(); when(userRepository.findById(unknownId)).thenReturn(Optional.empty()); + when(userRepository.findByIdIncludingDeleted(unknownId)).thenReturn(Optional.empty()); // When userGrpcService.getUserSummary(request, responseObserver); @@ -112,6 +114,36 @@ void getUserSummary_WithNonExistingUser_ReturnsNotFound() { assertThat(response.getEmail()).isEmpty(); } + @Test + @DisplayName("Should return deleted user with isDeleted flag when user is soft-deleted") + void getUserSummary_WithDeletedUser_ReturnsUserWithDeletedFlag() { + // Given + GetUserSummaryRequest request = GetUserSummaryRequest.newBuilder() + .setUserId(testUserId.toString()) + .build(); + testUser.setDeleted(true); + when(userRepository.findById(testUserId)).thenReturn(Optional.empty()); + when(userRepository.findByIdIncludingDeleted(testUserId)).thenReturn(Optional.of(testUser)); + + // When + userGrpcService.getUserSummary(request, responseObserver); + + // Then + ArgumentCaptor captor = ArgumentCaptor.forClass(GetUserSummaryResponse.class); + verify(responseObserver).onNext(captor.capture()); + verify(responseObserver).onCompleted(); + verify(responseObserver, never()).onError(any()); + + GetUserSummaryResponse response = captor.getValue(); + assertThat(response.getFound()).isTrue(); + assertThat(response.getUserId()).isEqualTo(testUserId.toString()); + assertThat(response.getEmail()).isEqualTo("test@example.com"); + assertThat(response.getFirstName()).isEqualTo("Test"); + assertThat(response.getLastName()).isEqualTo("User"); + assertThat(response.getRole()).isEqualTo("GUEST"); + assertThat(response.getIsDeleted()).isTrue(); + } + @Test @DisplayName("Should return not found when user ID is invalid UUID") void getUserSummary_WithInvalidUUID_ReturnsNotFound() { diff --git a/src/test/java/com/devoops/user/service/UserServiceTest.java b/src/test/java/com/devoops/user/service/UserServiceTest.java index f621d4b..61b5116 100644 --- a/src/test/java/com/devoops/user/service/UserServiceTest.java +++ b/src/test/java/com/devoops/user/service/UserServiceTest.java @@ -6,9 +6,14 @@ import com.devoops.user.dto.response.UserResponse; import com.devoops.user.entity.Role; import com.devoops.user.entity.User; +import com.devoops.user.exception.AccountDeletionException; import com.devoops.user.exception.InvalidPasswordException; import com.devoops.user.exception.UserAlreadyExistsException; import com.devoops.user.exception.UserNotFoundException; +import com.devoops.user.grpc.AccommodationGrpcClient; +import com.devoops.user.grpc.CascadeDeleteResult; +import com.devoops.user.grpc.DeletionCheckResult; +import com.devoops.user.grpc.ReservationGrpcClient; import com.devoops.user.mapper.UserMapper; import com.devoops.user.repository.UserRepository; import com.devoops.user.security.JwtService; @@ -46,6 +51,12 @@ class UserServiceTest { @Mock private JwtService jwtService; + @Mock + private ReservationGrpcClient reservationGrpcClient; + + @Mock + private AccommodationGrpcClient accommodationGrpcClient; + @InjectMocks private UserService userService; @@ -82,6 +93,19 @@ private User buildTestUser() { .build(); } + private User buildTestHost() { + return User.builder() + .id(testUserId) + .username("testhost") + .password("encoded_password") + .email("host@example.com") + .firstName("Test") + .lastName("Host") + .residence("Test City") + .role(Role.HOST) + .build(); + } + @Nested @DisplayName("getProfile Tests") class GetProfileTests { @@ -302,4 +326,157 @@ void changePassword_WithNonExistentUser_ThrowsUserNotFoundException() { .hasMessageContaining("User does not exist"); } } + + @Nested + @DisplayName("deleteAccount Tests") + class DeleteAccountTests { + + @Test + @DisplayName("Should delete guest account when no active reservations exist") + void deleteAccount_GuestWithNoReservations_DeletesSuccessfully() { + // Given + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + when(reservationGrpcClient.checkGuestCanBeDeleted(testUserId)) + .thenReturn(new DeletionCheckResult(true, "", 0)); + + // When + userService.deleteAccount(testUserId); + + // Then + assertThat(testUser.isDeleted()).isTrue(); + verify(userRepository).save(testUser); + verify(reservationGrpcClient).checkGuestCanBeDeleted(testUserId); + verify(accommodationGrpcClient, never()).deleteAccommodationsByHost(any()); + } + + @Test + @DisplayName("Should throw AccountDeletionException when guest has active reservations") + void deleteAccount_GuestWithActiveReservations_ThrowsAccountDeletionException() { + // Given + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + when(reservationGrpcClient.checkGuestCanBeDeleted(testUserId)) + .thenReturn(new DeletionCheckResult(false, "Guest has 2 active reservations", 2)); + + // When/Then + assertThatThrownBy(() -> userService.deleteAccount(testUserId)) + .isInstanceOf(AccountDeletionException.class) + .hasMessageContaining("2 active reservation(s)"); + + assertThat(testUser.isDeleted()).isFalse(); + verify(userRepository, never()).save(any()); + } + + @Test + @DisplayName("Should delete host account and cascade delete accommodations when no active reservations") + void deleteAccount_HostWithNoReservations_DeletesWithCascade() { + // Given + User hostUser = buildTestHost(); + when(userRepository.findById(testUserId)).thenReturn(Optional.of(hostUser)); + when(reservationGrpcClient.checkHostCanBeDeleted(testUserId)) + .thenReturn(new DeletionCheckResult(true, "", 0)); + when(accommodationGrpcClient.deleteAccommodationsByHost(testUserId)) + .thenReturn(new CascadeDeleteResult(true, 3, "")); + + // When + userService.deleteAccount(testUserId); + + // Then + assertThat(hostUser.isDeleted()).isTrue(); + verify(userRepository).save(hostUser); + verify(reservationGrpcClient).checkHostCanBeDeleted(testUserId); + verify(accommodationGrpcClient).deleteAccommodationsByHost(testUserId); + } + + @Test + @DisplayName("Should throw AccountDeletionException when host has active reservations on accommodations") + void deleteAccount_HostWithActiveReservations_ThrowsAccountDeletionException() { + // Given + User hostUser = buildTestHost(); + when(userRepository.findById(testUserId)).thenReturn(Optional.of(hostUser)); + when(reservationGrpcClient.checkHostCanBeDeleted(testUserId)) + .thenReturn(new DeletionCheckResult(false, "Host has 5 active reservations", 5)); + + // When/Then + assertThatThrownBy(() -> userService.deleteAccount(testUserId)) + .isInstanceOf(AccountDeletionException.class) + .hasMessageContaining("5 active reservation(s)") + .hasMessageContaining("your accommodations"); + + assertThat(hostUser.isDeleted()).isFalse(); + verify(userRepository, never()).save(any()); + verify(accommodationGrpcClient, never()).deleteAccommodationsByHost(any()); + } + + @Test + @DisplayName("Should throw RuntimeException when cascade delete fails") + void deleteAccount_HostCascadeDeleteFails_ThrowsRuntimeException() { + // Given + User hostUser = buildTestHost(); + when(userRepository.findById(testUserId)).thenReturn(Optional.of(hostUser)); + when(reservationGrpcClient.checkHostCanBeDeleted(testUserId)) + .thenReturn(new DeletionCheckResult(true, "", 0)); + when(accommodationGrpcClient.deleteAccommodationsByHost(testUserId)) + .thenReturn(new CascadeDeleteResult(false, 0, "Database error")); + + // When/Then + assertThatThrownBy(() -> userService.deleteAccount(testUserId)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to delete accommodations") + .hasMessageContaining("Database error"); + + assertThat(hostUser.isDeleted()).isFalse(); + verify(userRepository, never()).save(any()); + } + + @Test + @DisplayName("Should throw UserNotFoundException when user does not exist") + void deleteAccount_WithNonExistentUser_ThrowsUserNotFoundException() { + // Given + UUID unknownId = UUID.randomUUID(); + when(userRepository.findById(unknownId)).thenReturn(Optional.empty()); + + // When/Then + assertThatThrownBy(() -> userService.deleteAccount(unknownId)) + .isInstanceOf(UserNotFoundException.class) + .hasMessageContaining("User does not exist"); + + verify(reservationGrpcClient, never()).checkGuestCanBeDeleted(any()); + verify(reservationGrpcClient, never()).checkHostCanBeDeleted(any()); + } + + @Test + @DisplayName("Should not call host deletion check for guest user") + void deleteAccount_GuestUser_OnlyCallsGuestCheck() { + // Given + when(userRepository.findById(testUserId)).thenReturn(Optional.of(testUser)); + when(reservationGrpcClient.checkGuestCanBeDeleted(testUserId)) + .thenReturn(new DeletionCheckResult(true, "", 0)); + + // When + userService.deleteAccount(testUserId); + + // Then + verify(reservationGrpcClient).checkGuestCanBeDeleted(testUserId); + verify(reservationGrpcClient, never()).checkHostCanBeDeleted(any()); + } + + @Test + @DisplayName("Should not call guest deletion check for host user") + void deleteAccount_HostUser_OnlyCallsHostCheck() { + // Given + User hostUser = buildTestHost(); + when(userRepository.findById(testUserId)).thenReturn(Optional.of(hostUser)); + when(reservationGrpcClient.checkHostCanBeDeleted(testUserId)) + .thenReturn(new DeletionCheckResult(true, "", 0)); + when(accommodationGrpcClient.deleteAccommodationsByHost(testUserId)) + .thenReturn(new CascadeDeleteResult(true, 0, "")); + + // When + userService.deleteAccount(testUserId); + + // Then + verify(reservationGrpcClient).checkHostCanBeDeleted(testUserId); + verify(reservationGrpcClient, never()).checkGuestCanBeDeleted(any()); + } + } }