diff --git a/src/main/java/com/devoops/reservation/controller/ReservationController.java b/src/main/java/com/devoops/reservation/controller/ReservationController.java index ff4505f..8c47b2d 100644 --- a/src/main/java/com/devoops/reservation/controller/ReservationController.java +++ b/src/main/java/com/devoops/reservation/controller/ReservationController.java @@ -4,6 +4,7 @@ import com.devoops.reservation.config.UserContext; import com.devoops.reservation.dto.request.CreateReservationRequest; import com.devoops.reservation.dto.response.ReservationResponse; +import com.devoops.reservation.dto.response.ReservationWithGuestInfoResponse; import com.devoops.reservation.service.ReservationService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -47,8 +48,8 @@ public ResponseEntity> getByGuest(UserContext userCont @GetMapping("/host") @RequireRole("HOST") - public ResponseEntity> getByHost(UserContext userContext) { - return ResponseEntity.ok(reservationService.getByHostId(userContext)); + public ResponseEntity> getByHost(UserContext userContext) { + return ResponseEntity.ok(reservationService.getByHostIdWithGuestInfo(userContext)); } @@ -69,4 +70,20 @@ public ResponseEntity cancel( reservationService.cancelReservation(id, userContext); return ResponseEntity.noContent().build(); } + + @PutMapping("/{id}/approve") + @RequireRole("HOST") + public ResponseEntity approve( + @PathVariable UUID id, + UserContext userContext) { + return ResponseEntity.ok(reservationService.approveReservation(id, userContext)); + } + + @PutMapping("/{id}/reject") + @RequireRole("HOST") + public ResponseEntity reject( + @PathVariable UUID id, + UserContext userContext) { + return ResponseEntity.ok(reservationService.rejectReservation(id, userContext)); + } } diff --git a/src/main/java/com/devoops/reservation/dto/message/ReservationResponseMessage.java b/src/main/java/com/devoops/reservation/dto/message/ReservationResponseMessage.java new file mode 100644 index 0000000..7c54186 --- /dev/null +++ b/src/main/java/com/devoops/reservation/dto/message/ReservationResponseMessage.java @@ -0,0 +1,18 @@ +package com.devoops.reservation.dto.message; + +import java.time.LocalDate; +import java.util.UUID; + +public record ReservationResponseMessage( + UUID userId, + String userEmail, + String hostName, + String accommodationName, + ReservationResponseStatus status, + LocalDate checkIn, + LocalDate checkOut +) { + public enum ReservationResponseStatus { + APPROVED, DECLINED + } +} diff --git a/src/main/java/com/devoops/reservation/dto/response/ReservationWithGuestInfoResponse.java b/src/main/java/com/devoops/reservation/dto/response/ReservationWithGuestInfoResponse.java new file mode 100644 index 0000000..6bf9f3c --- /dev/null +++ b/src/main/java/com/devoops/reservation/dto/response/ReservationWithGuestInfoResponse.java @@ -0,0 +1,40 @@ +package com.devoops.reservation.dto.response; + +import com.devoops.reservation.entity.ReservationStatus; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +public record ReservationWithGuestInfoResponse( + UUID id, + UUID accommodationId, + UUID guestId, + UUID hostId, + LocalDate startDate, + LocalDate endDate, + int guestCount, + BigDecimal totalPrice, + ReservationStatus status, + LocalDateTime createdAt, + LocalDateTime updatedAt, + long guestCancellationCount +) { + public static ReservationWithGuestInfoResponse from(ReservationResponse response, long cancellationCount) { + return new ReservationWithGuestInfoResponse( + response.id(), + response.accommodationId(), + response.guestId(), + response.hostId(), + response.startDate(), + response.endDate(), + response.guestCount(), + response.totalPrice(), + response.status(), + response.createdAt(), + response.updatedAt(), + cancellationCount + ); + } +} diff --git a/src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java b/src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java index f92c0ac..f2d6d43 100644 --- a/src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java +++ b/src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java @@ -2,6 +2,7 @@ import com.devoops.reservation.dto.message.ReservationCancelledMessage; import com.devoops.reservation.dto.message.ReservationCreatedMessage; +import com.devoops.reservation.dto.message.ReservationResponseMessage; import com.devoops.reservation.entity.Reservation; import com.devoops.reservation.grpc.UserGrpcClient; import com.devoops.reservation.grpc.UserSummaryResult; @@ -28,6 +29,9 @@ public class ReservationEventPublisherService { @Value("${rabbitmq.routing-key.reservation-cancelled}") private String reservationCancelledRoutingKey; + @Value("${rabbitmq.routing-key.reservation-response}") + private String reservationResponseRoutingKey; + public void publishReservationCreated(Reservation reservation, String accommodationName) { UserSummaryResult hostSummary = userGrpcClient.getUserSummary(reservation.getHostId()); UserSummaryResult guestSummary = userGrpcClient.getUserSummary(reservation.getGuestId()); @@ -87,4 +91,38 @@ public void publishReservationCancelled(Reservation reservation, String accommod rabbitTemplate.convertAndSend(notificationExchange, reservationCancelledRoutingKey, message); } + + public void publishReservationResponse(Reservation reservation, String accommodationName, boolean approved) { + UserSummaryResult guestSummary = userGrpcClient.getUserSummary(reservation.getGuestId()); + UserSummaryResult hostSummary = userGrpcClient.getUserSummary(reservation.getHostId()); + + if (!guestSummary.found()) { + log.warn("Guest not found for reservation {}, skipping notification", reservation.getId()); + return; + } + + if (!hostSummary.found()) { + log.warn("Host not found for reservation {}, skipping notification", reservation.getId()); + return; + } + + ReservationResponseMessage.ReservationResponseStatus status = approved + ? ReservationResponseMessage.ReservationResponseStatus.APPROVED + : ReservationResponseMessage.ReservationResponseStatus.DECLINED; + + ReservationResponseMessage message = new ReservationResponseMessage( + reservation.getGuestId(), + guestSummary.email(), + hostSummary.getFullName(), + accommodationName, + status, + reservation.getStartDate(), + reservation.getEndDate() + ); + + log.info("Publishing reservation response event: reservationId={}, guestEmail={}, status={}", + reservation.getId(), guestSummary.email(), status); + + rabbitTemplate.convertAndSend(notificationExchange, reservationResponseRoutingKey, message); + } } diff --git a/src/main/java/com/devoops/reservation/service/ReservationService.java b/src/main/java/com/devoops/reservation/service/ReservationService.java index 8e807dc..3379bce 100644 --- a/src/main/java/com/devoops/reservation/service/ReservationService.java +++ b/src/main/java/com/devoops/reservation/service/ReservationService.java @@ -3,6 +3,7 @@ import com.devoops.reservation.config.UserContext; import com.devoops.reservation.dto.request.CreateReservationRequest; import com.devoops.reservation.dto.response.ReservationResponse; +import com.devoops.reservation.dto.response.ReservationWithGuestInfoResponse; import com.devoops.reservation.entity.Reservation; import com.devoops.reservation.entity.ReservationStatus; import com.devoops.reservation.exception.AccommodationNotFoundException; @@ -109,6 +110,19 @@ public List getByHostId(UserContext userContext) { return reservationMapper.toResponseList(reservations); } + @Transactional(readOnly = true) + public List getByHostIdWithGuestInfo(UserContext userContext) { + List reservations = reservationRepository.findByHostId(userContext.userId()); + return reservations.stream() + .map(reservation -> { + ReservationResponse response = reservationMapper.toResponse(reservation); + long cancellationCount = reservationRepository.countByGuestIdAndStatus( + reservation.getGuestId(), ReservationStatus.CANCELLED); + return ReservationWithGuestInfoResponse.from(response, cancellationCount); + }) + .toList(); + } + @Transactional public void deleteRequest(UUID id, UserContext userContext) { Reservation reservation = findReservationOrThrow(id); @@ -160,16 +174,78 @@ public void cancelReservation(UUID id, UserContext userContext) { reservationRepository.save(reservation); log.info("Guest {} cancelled reservation {}", userContext.userId(), id); - // Fetch accommodation name for notification - AccommodationValidationResult accommodationInfo = accommodationGrpcClient.validateAndCalculatePrice( + String accommodationName = fetchAccommodationName(reservation); + eventPublisher.publishReservationCancelled(reservation, accommodationName); + } + + @Transactional + public ReservationResponse approveReservation(UUID id, UserContext userContext) { + Reservation reservation = findReservationOrThrow(id); + + // Only the host who owns this reservation can approve it + if (!reservation.getHostId().equals(userContext.userId())) { + throw new ForbiddenException("You can only approve reservations for your own accommodations"); + } + + // Can only approve PENDING reservations + if (reservation.getStatus() != ReservationStatus.PENDING) { + throw new InvalidReservationException( + "Only pending reservations can be approved. Current status: " + reservation.getStatus() + ); + } + + // Approve the reservation + reservation.setStatus(ReservationStatus.APPROVED); + reservationRepository.save(reservation); + log.info("Host {} approved reservation {}", userContext.userId(), id); + + // Auto-reject overlapping pending reservations + List overlappingPending = reservationRepository.findOverlappingPending( reservation.getAccommodationId(), reservation.getStartDate(), reservation.getEndDate(), - reservation.getGuestCount() + reservation.getId() ); - String accommodationName = accommodationInfo.valid() ? accommodationInfo.accommodationName() : "Unknown Accommodation"; - eventPublisher.publishReservationCancelled(reservation, accommodationName); + for (Reservation overlapping : overlappingPending) { + overlapping.setStatus(ReservationStatus.REJECTED); + reservationRepository.save(overlapping); + log.info("Auto-rejected overlapping reservation {} due to approval of reservation {}", + overlapping.getId(), id); + } + + // Fetch accommodation name and publish notification + String accommodationName = fetchAccommodationName(reservation); + eventPublisher.publishReservationResponse(reservation, accommodationName, true); + + return reservationMapper.toResponse(reservation); + } + + @Transactional + public ReservationResponse rejectReservation(UUID id, UserContext userContext) { + Reservation reservation = findReservationOrThrow(id); + + // Only the host who owns this reservation can reject it + if (!reservation.getHostId().equals(userContext.userId())) { + throw new ForbiddenException("You can only reject reservations for your own accommodations"); + } + + // Can only reject PENDING reservations + if (reservation.getStatus() != ReservationStatus.PENDING) { + throw new InvalidReservationException( + "Only pending reservations can be rejected. Current status: " + reservation.getStatus() + ); + } + + reservation.setStatus(ReservationStatus.REJECTED); + reservationRepository.save(reservation); + log.info("Host {} rejected reservation {}", userContext.userId(), id); + + // Fetch accommodation name and publish notification + String accommodationName = fetchAccommodationName(reservation); + eventPublisher.publishReservationResponse(reservation, accommodationName, false); + + return reservationMapper.toResponse(reservation); } // === Helper Methods === @@ -195,4 +271,13 @@ private void validateAccessToReservation(Reservation reservation, UserContext us } } + private String fetchAccommodationName(Reservation reservation) { + AccommodationValidationResult accommodationInfo = accommodationGrpcClient.validateAndCalculatePrice( + reservation.getAccommodationId(), + reservation.getStartDate(), + reservation.getEndDate(), + reservation.getGuestCount() + ); + return accommodationInfo.valid() ? accommodationInfo.accommodationName() : "Unknown Accommodation"; + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a254766..be18197 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -51,3 +51,4 @@ spring.rabbitmq.password=${RABBITMQ_PASSWORD:devoops123} rabbitmq.exchange.notification=notification.exchange rabbitmq.routing-key.reservation-created=notification.reservation.created rabbitmq.routing-key.reservation-cancelled=notification.reservation.cancelled +rabbitmq.routing-key.reservation-response=notification.reservation.response diff --git a/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java b/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java index 2aef842..f7c4464 100644 --- a/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java +++ b/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java @@ -4,6 +4,7 @@ import com.devoops.reservation.config.UserContext; import com.devoops.reservation.config.UserContextResolver; import com.devoops.reservation.dto.response.ReservationResponse; +import com.devoops.reservation.dto.response.ReservationWithGuestInfoResponse; import com.devoops.reservation.entity.ReservationStatus; import com.devoops.reservation.exception.ForbiddenException; import com.devoops.reservation.exception.GlobalExceptionHandler; @@ -74,6 +75,28 @@ private ReservationResponse createResponse() { ); } + private ReservationResponse createApprovedResponse() { + return new ReservationResponse( + RESERVATION_ID, ACCOMMODATION_ID, GUEST_ID, HOST_ID, + LocalDate.now().plusDays(10), LocalDate.now().plusDays(15), + 2, new BigDecimal("1000.00"), ReservationStatus.APPROVED, + LocalDateTime.now(), LocalDateTime.now() + ); + } + + private ReservationResponse createRejectedResponse() { + return new ReservationResponse( + RESERVATION_ID, ACCOMMODATION_ID, GUEST_ID, HOST_ID, + LocalDate.now().plusDays(10), LocalDate.now().plusDays(15), + 2, new BigDecimal("1000.00"), ReservationStatus.REJECTED, + LocalDateTime.now(), LocalDateTime.now() + ); + } + + private ReservationWithGuestInfoResponse createResponseWithGuestInfo() { + return ReservationWithGuestInfoResponse.from(createResponse(), 2L); + } + private Map validCreateRequest() { return Map.of( "accommodationId", ACCOMMODATION_ID.toString(), @@ -261,16 +284,17 @@ void getByGuest_WithHostRole_Returns403() throws Exception { class GetByHostEndpoint { @Test - @DisplayName("Returns 200 with list") + @DisplayName("Returns 200 with list including guest cancellation count") void getByHost_Returns200WithList() throws Exception { - when(reservationService.getByHostId(any(UserContext.class))) - .thenReturn(List.of(createResponse())); + when(reservationService.getByHostIdWithGuestInfo(any(UserContext.class))) + .thenReturn(List.of(createResponseWithGuestInfo())); mockMvc.perform(get("/api/reservation/host") .header("X-User-Id", HOST_ID.toString()) .header("X-User-Role", "HOST")) .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].id").value(RESERVATION_ID.toString())); + .andExpect(jsonPath("$[0].id").value(RESERVATION_ID.toString())) + .andExpect(jsonPath("$[0].guestCancellationCount").value(2)); } @Test @@ -418,4 +442,134 @@ void cancel_WithHostRole_Returns403() throws Exception { .andExpect(status().isForbidden()); } } + + @Nested + @DisplayName("PUT /api/reservation/{id}/approve") + class ApproveEndpoint { + + @Test + @DisplayName("With valid request returns 200") + void approve_WithValidRequest_Returns200() throws Exception { + when(reservationService.approveReservation(eq(RESERVATION_ID), any(UserContext.class))) + .thenReturn(createApprovedResponse()); + + mockMvc.perform(put("/api/reservation/{id}/approve", RESERVATION_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(RESERVATION_ID.toString())) + .andExpect(jsonPath("$.status").value("APPROVED")); + } + + @Test + @DisplayName("With GUEST role returns 403") + void approve_WithGuestRole_Returns403() throws Exception { + mockMvc.perform(put("/api/reservation/{id}/approve", RESERVATION_ID) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With non-existing ID returns 404") + void approve_WithNonExistingId_Returns404() throws Exception { + UUID id = UUID.randomUUID(); + when(reservationService.approveReservation(eq(id), any(UserContext.class))) + .thenThrow(new ReservationNotFoundException("Not found")); + + mockMvc.perform(put("/api/reservation/{id}/approve", id) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("With wrong host returns 403") + void approve_WithWrongHost_Returns403() throws Exception { + when(reservationService.approveReservation(eq(RESERVATION_ID), any(UserContext.class))) + .thenThrow(new ForbiddenException("Not the host")); + + mockMvc.perform(put("/api/reservation/{id}/approve", RESERVATION_ID) + .header("X-User-Id", UUID.randomUUID().toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With non-pending status returns 400") + void approve_WithNonPendingStatus_Returns400() throws Exception { + when(reservationService.approveReservation(eq(RESERVATION_ID), any(UserContext.class))) + .thenThrow(new InvalidReservationException("Only pending reservations can be approved")); + + mockMvc.perform(put("/api/reservation/{id}/approve", RESERVATION_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("PUT /api/reservation/{id}/reject") + class RejectEndpoint { + + @Test + @DisplayName("With valid request returns 200") + void reject_WithValidRequest_Returns200() throws Exception { + when(reservationService.rejectReservation(eq(RESERVATION_ID), any(UserContext.class))) + .thenReturn(createRejectedResponse()); + + mockMvc.perform(put("/api/reservation/{id}/reject", RESERVATION_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(RESERVATION_ID.toString())) + .andExpect(jsonPath("$.status").value("REJECTED")); + } + + @Test + @DisplayName("With GUEST role returns 403") + void reject_WithGuestRole_Returns403() throws Exception { + mockMvc.perform(put("/api/reservation/{id}/reject", RESERVATION_ID) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With non-existing ID returns 404") + void reject_WithNonExistingId_Returns404() throws Exception { + UUID id = UUID.randomUUID(); + when(reservationService.rejectReservation(eq(id), any(UserContext.class))) + .thenThrow(new ReservationNotFoundException("Not found")); + + mockMvc.perform(put("/api/reservation/{id}/reject", id) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("With wrong host returns 403") + void reject_WithWrongHost_Returns403() throws Exception { + when(reservationService.rejectReservation(eq(RESERVATION_ID), any(UserContext.class))) + .thenThrow(new ForbiddenException("Not the host")); + + mockMvc.perform(put("/api/reservation/{id}/reject", RESERVATION_ID) + .header("X-User-Id", UUID.randomUUID().toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With non-pending status returns 400") + void reject_WithNonPendingStatus_Returns400() throws Exception { + when(reservationService.rejectReservation(eq(RESERVATION_ID), any(UserContext.class))) + .thenThrow(new InvalidReservationException("Only pending reservations can be rejected")); + + mockMvc.perform(put("/api/reservation/{id}/reject", RESERVATION_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isBadRequest()); + } + } } diff --git a/src/test/java/com/devoops/reservation/service/ReservationEventPublisherServiceTest.java b/src/test/java/com/devoops/reservation/service/ReservationEventPublisherServiceTest.java new file mode 100644 index 0000000..810939b --- /dev/null +++ b/src/test/java/com/devoops/reservation/service/ReservationEventPublisherServiceTest.java @@ -0,0 +1,161 @@ +package com.devoops.reservation.service; + +import com.devoops.reservation.dto.message.ReservationResponseMessage; +import com.devoops.reservation.entity.Reservation; +import com.devoops.reservation.entity.ReservationStatus; +import com.devoops.reservation.grpc.UserGrpcClient; +import com.devoops.reservation.grpc.UserSummaryResult; +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.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ReservationEventPublisherServiceTest { + + @Mock + private RabbitTemplate rabbitTemplate; + + @Mock + private UserGrpcClient userGrpcClient; + + @InjectMocks + private ReservationEventPublisherService eventPublisher; + + @Captor + private ArgumentCaptor messageCaptor; + + private static final UUID GUEST_ID = UUID.randomUUID(); + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID RESERVATION_ID = UUID.randomUUID(); + private static final String NOTIFICATION_EXCHANGE = "notification.exchange"; + private static final String RESPONSE_ROUTING_KEY = "notification.reservation.response"; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(eventPublisher, "notificationExchange", NOTIFICATION_EXCHANGE); + ReflectionTestUtils.setField(eventPublisher, "reservationResponseRoutingKey", RESPONSE_ROUTING_KEY); + } + + private Reservation createReservation() { + return Reservation.builder() + .id(RESERVATION_ID) + .accommodationId(UUID.randomUUID()) + .guestId(GUEST_ID) + .hostId(HOST_ID) + .startDate(LocalDate.now().plusDays(10)) + .endDate(LocalDate.now().plusDays(15)) + .guestCount(2) + .totalPrice(new BigDecimal("1000.00")) + .status(ReservationStatus.PENDING) + .build(); + } + + private UserSummaryResult createGuestSummary() { + return new UserSummaryResult(true, GUEST_ID, "guest@example.com", "John", "Doe", "GUEST"); + } + + private UserSummaryResult createHostSummary() { + return new UserSummaryResult(true, HOST_ID, "host@example.com", "Jane", "Smith", "HOST"); + } + + @Nested + @DisplayName("PublishReservationResponse") + class PublishReservationResponseTests { + + @Test + @DisplayName("With approval publishes APPROVED message") + void publishReservationResponse_WithApproval_PublishesApprovedMessage() { + var reservation = createReservation(); + var guestSummary = createGuestSummary(); + var hostSummary = createHostSummary(); + + when(userGrpcClient.getUserSummary(GUEST_ID)).thenReturn(guestSummary); + when(userGrpcClient.getUserSummary(HOST_ID)).thenReturn(hostSummary); + + eventPublisher.publishReservationResponse(reservation, "Beach House", true); + + verify(rabbitTemplate).convertAndSend( + eq(NOTIFICATION_EXCHANGE), + eq(RESPONSE_ROUTING_KEY), + messageCaptor.capture() + ); + + ReservationResponseMessage message = messageCaptor.getValue(); + assertThat(message.userId()).isEqualTo(GUEST_ID); + assertThat(message.userEmail()).isEqualTo("guest@example.com"); + assertThat(message.hostName()).isEqualTo("Jane Smith"); + assertThat(message.accommodationName()).isEqualTo("Beach House"); + assertThat(message.status()).isEqualTo(ReservationResponseMessage.ReservationResponseStatus.APPROVED); + assertThat(message.checkIn()).isEqualTo(reservation.getStartDate()); + assertThat(message.checkOut()).isEqualTo(reservation.getEndDate()); + } + + @Test + @DisplayName("With rejection publishes DECLINED message") + void publishReservationResponse_WithRejection_PublishesDeclinedMessage() { + var reservation = createReservation(); + var guestSummary = createGuestSummary(); + var hostSummary = createHostSummary(); + + when(userGrpcClient.getUserSummary(GUEST_ID)).thenReturn(guestSummary); + when(userGrpcClient.getUserSummary(HOST_ID)).thenReturn(hostSummary); + + eventPublisher.publishReservationResponse(reservation, "Mountain Cabin", false); + + verify(rabbitTemplate).convertAndSend( + eq(NOTIFICATION_EXCHANGE), + eq(RESPONSE_ROUTING_KEY), + messageCaptor.capture() + ); + + ReservationResponseMessage message = messageCaptor.getValue(); + assertThat(message.status()).isEqualTo(ReservationResponseMessage.ReservationResponseStatus.DECLINED); + } + + @Test + @DisplayName("With missing guest skips publishing") + void publishReservationResponse_WithMissingGuest_SkipsPublishing() { + var reservation = createReservation(); + var notFoundSummary = new UserSummaryResult(false, null, null, null, null, null); + + when(userGrpcClient.getUserSummary(GUEST_ID)).thenReturn(notFoundSummary); + + eventPublisher.publishReservationResponse(reservation, "Beach House", true); + + verify(rabbitTemplate, never()).convertAndSend(any(), any(), any(Object.class)); + } + + @Test + @DisplayName("With missing host skips publishing") + void publishReservationResponse_WithMissingHost_SkipsPublishing() { + var reservation = createReservation(); + var guestSummary = createGuestSummary(); + var notFoundSummary = new UserSummaryResult(false, null, null, null, null, null); + + when(userGrpcClient.getUserSummary(GUEST_ID)).thenReturn(guestSummary); + when(userGrpcClient.getUserSummary(HOST_ID)).thenReturn(notFoundSummary); + + eventPublisher.publishReservationResponse(reservation, "Beach House", true); + + verify(rabbitTemplate, never()).convertAndSend(any(), any(), any(Object.class)); + } + } +} diff --git a/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java b/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java index 5903b73..8e098cc 100644 --- a/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java +++ b/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java @@ -3,6 +3,7 @@ import com.devoops.reservation.config.UserContext; import com.devoops.reservation.dto.request.CreateReservationRequest; import com.devoops.reservation.dto.response.ReservationResponse; +import com.devoops.reservation.dto.response.ReservationWithGuestInfoResponse; import com.devoops.reservation.entity.Reservation; import com.devoops.reservation.entity.ReservationStatus; import com.devoops.reservation.exception.AccommodationNotFoundException; @@ -498,4 +499,202 @@ void cancelReservation_WithHostTryingToCancel_ThrowsForbiddenException() { .isInstanceOf(ForbiddenException.class); } } + + @Nested + @DisplayName("ApproveReservation") + class ApproveReservationTests { + + @Test + @DisplayName("With valid pending reservation approves successfully") + void approveReservation_WithValidPending_ApprovesSuccessfully() { + var reservation = createReservation(); + var response = createResponse(); + var accommodationResult = new AccommodationValidationResult( + true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL", "Test Accommodation" + ); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + when(reservationRepository.findOverlappingPending(any(), any(), any(), any())) + .thenReturn(List.of()); + when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) + .thenReturn(accommodationResult); + when(reservationMapper.toResponse(reservation)).thenReturn(response); + + ReservationResponse result = reservationService.approveReservation(RESERVATION_ID, HOST_CONTEXT); + + assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.APPROVED); + verify(reservationRepository).save(reservation); + verify(eventPublisher).publishReservationResponse(reservation, "Test Accommodation", true); + } + + @Test + @DisplayName("With overlapping pending reservations auto-rejects them") + void approveReservation_WithOverlappingPending_AutoRejectsThem() { + var reservation = createReservation(); + var overlapping1 = Reservation.builder() + .id(UUID.randomUUID()) + .accommodationId(ACCOMMODATION_ID) + .guestId(UUID.randomUUID()) + .hostId(HOST_ID) + .startDate(LocalDate.now().plusDays(12)) + .endDate(LocalDate.now().plusDays(14)) + .status(ReservationStatus.PENDING) + .build(); + var overlapping2 = Reservation.builder() + .id(UUID.randomUUID()) + .accommodationId(ACCOMMODATION_ID) + .guestId(UUID.randomUUID()) + .hostId(HOST_ID) + .startDate(LocalDate.now().plusDays(11)) + .endDate(LocalDate.now().plusDays(13)) + .status(ReservationStatus.PENDING) + .build(); + var accommodationResult = new AccommodationValidationResult( + true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL", "Test Accommodation" + ); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + when(reservationRepository.findOverlappingPending(any(), any(), any(), any())) + .thenReturn(List.of(overlapping1, overlapping2)); + when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) + .thenReturn(accommodationResult); + when(reservationMapper.toResponse(reservation)).thenReturn(createResponse()); + + reservationService.approveReservation(RESERVATION_ID, HOST_CONTEXT); + + assertThat(overlapping1.getStatus()).isEqualTo(ReservationStatus.REJECTED); + assertThat(overlapping2.getStatus()).isEqualTo(ReservationStatus.REJECTED); + verify(reservationRepository, times(3)).save(any()); // main + 2 overlapping + } + + @Test + @DisplayName("With wrong host throws ForbiddenException") + void approveReservation_WithWrongHost_ThrowsForbiddenException() { + var reservation = createReservation(); + var otherHost = new UserContext(UUID.randomUUID(), "HOST"); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.approveReservation(RESERVATION_ID, otherHost)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("only approve reservations for your own accommodations"); + } + + @Test + @DisplayName("With non-pending status throws InvalidReservationException") + void approveReservation_WithNonPendingStatus_ThrowsInvalidReservationException() { + var reservation = createReservation(); + reservation.setStatus(ReservationStatus.APPROVED); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.approveReservation(RESERVATION_ID, HOST_CONTEXT)) + .isInstanceOf(InvalidReservationException.class) + .hasMessageContaining("Only pending reservations can be approved"); + } + + @Test + @DisplayName("With non-existing ID throws ReservationNotFoundException") + void approveReservation_WithNonExistingId_ThrowsReservationNotFoundException() { + UUID id = UUID.randomUUID(); + when(reservationRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> reservationService.approveReservation(id, HOST_CONTEXT)) + .isInstanceOf(ReservationNotFoundException.class); + } + } + + @Nested + @DisplayName("RejectReservation") + class RejectReservationTests { + + @Test + @DisplayName("With valid pending reservation rejects successfully") + void rejectReservation_WithValidPending_RejectsSuccessfully() { + var reservation = createReservation(); + var response = createResponse(); + var accommodationResult = new AccommodationValidationResult( + true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL", "Test Accommodation" + ); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) + .thenReturn(accommodationResult); + when(reservationMapper.toResponse(reservation)).thenReturn(response); + + ReservationResponse result = reservationService.rejectReservation(RESERVATION_ID, HOST_CONTEXT); + + assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.REJECTED); + verify(reservationRepository).save(reservation); + verify(eventPublisher).publishReservationResponse(reservation, "Test Accommodation", false); + } + + @Test + @DisplayName("With wrong host throws ForbiddenException") + void rejectReservation_WithWrongHost_ThrowsForbiddenException() { + var reservation = createReservation(); + var otherHost = new UserContext(UUID.randomUUID(), "HOST"); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.rejectReservation(RESERVATION_ID, otherHost)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("only reject reservations for your own accommodations"); + } + + @Test + @DisplayName("With non-pending status throws InvalidReservationException") + void rejectReservation_WithNonPendingStatus_ThrowsInvalidReservationException() { + var reservation = createReservation(); + reservation.setStatus(ReservationStatus.CANCELLED); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.rejectReservation(RESERVATION_ID, HOST_CONTEXT)) + .isInstanceOf(InvalidReservationException.class) + .hasMessageContaining("Only pending reservations can be rejected"); + } + + @Test + @DisplayName("With non-existing ID throws ReservationNotFoundException") + void rejectReservation_WithNonExistingId_ThrowsReservationNotFoundException() { + UUID id = UUID.randomUUID(); + when(reservationRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> reservationService.rejectReservation(id, HOST_CONTEXT)) + .isInstanceOf(ReservationNotFoundException.class); + } + } + + @Nested + @DisplayName("GetByHostIdWithGuestInfo") + class GetByHostIdWithGuestInfoTests { + + @Test + @DisplayName("Returns reservations with cancellation counts") + void getByHostIdWithGuestInfo_ReturnsReservationsWithCancellationCounts() { + var reservation = createReservation(); + var response = createResponse(); + + when(reservationRepository.findByHostId(HOST_ID)).thenReturn(List.of(reservation)); + when(reservationMapper.toResponse(reservation)).thenReturn(response); + when(reservationRepository.countByGuestIdAndStatus(GUEST_ID, ReservationStatus.CANCELLED)) + .thenReturn(3L); + + List result = reservationService.getByHostIdWithGuestInfo(HOST_CONTEXT); + + assertThat(result).hasSize(1); + assertThat(result.get(0).guestCancellationCount()).isEqualTo(3L); + } + + @Test + @DisplayName("With no reservations returns empty list") + void getByHostIdWithGuestInfo_WithNoReservations_ReturnsEmptyList() { + when(reservationRepository.findByHostId(HOST_ID)).thenReturn(List.of()); + + List result = reservationService.getByHostIdWithGuestInfo(HOST_CONTEXT); + + assertThat(result).isEmpty(); + } + } }