From 510d32e2b8f5317d8c6599d095d36af379859733 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Tue, 17 Feb 2026 19:00:50 +0100 Subject: [PATCH 1/2] feat: Add reservation request validation and reservation cancellation. --- build.gradle.kts | 5 +- environment/.local.env | 14 +- .../reservation/config/RabbitMQConfig.java | 29 ++++ .../controller/ReservationController.java | 9 + .../message/ReservationCancelledMessage.java | 14 ++ .../message/ReservationCreatedMessage.java | 18 ++ .../AccommodationNotFoundException.java | 8 + .../exception/GlobalExceptionHandler.java | 5 + .../grpc/AccommodationGrpcClient.java | 63 +++++++ .../grpc/AccommodationValidationResult.java | 18 ++ .../ReservationEventPublisherService.java | 61 +++++++ .../service/ReservationService.java | 91 +++++++--- src/main/proto/accommodation_internal.proto | 26 +++ src/main/resources/application.properties | 15 ++ .../controller/ReservationControllerTest.java | 74 ++++++++ .../service/ReservationServiceTest.java | 161 ++++++++++++++++++ 16 files changed, 582 insertions(+), 29 deletions(-) create mode 100644 src/main/java/com/devoops/reservation/config/RabbitMQConfig.java create mode 100644 src/main/java/com/devoops/reservation/dto/message/ReservationCancelledMessage.java create mode 100644 src/main/java/com/devoops/reservation/dto/message/ReservationCreatedMessage.java create mode 100644 src/main/java/com/devoops/reservation/exception/AccommodationNotFoundException.java create mode 100644 src/main/java/com/devoops/reservation/grpc/AccommodationGrpcClient.java create mode 100644 src/main/java/com/devoops/reservation/grpc/AccommodationValidationResult.java create mode 100644 src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java create mode 100644 src/main/proto/accommodation_internal.proto diff --git a/build.gradle.kts b/build.gradle.kts index 4ccc8b4..5ad4a3f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,6 +29,8 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-webmvc") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-amqp") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") // Database implementation("org.springframework.boot:spring-boot-starter-data-jpa") @@ -45,8 +47,9 @@ dependencies { annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3") annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0") - // gRPC Server + // gRPC Server and Client implementation("net.devh:grpc-server-spring-boot-starter:3.1.0.RELEASE") + implementation("net.devh:grpc-client-spring-boot-starter:3.1.0.RELEASE") implementation("io.grpc:grpc-protobuf:$grpcVersion") implementation("io.grpc:grpc-stub:$grpcVersion") implementation("io.grpc:grpc-netty-shaded:$grpcVersion") diff --git a/environment/.local.env b/environment/.local.env index 6b790fb..38cc133 100644 --- a/environment/.local.env +++ b/environment/.local.env @@ -6,4 +6,16 @@ POSTGRES_HOST=devoops-postgres POSTGRES_PORT=5432 DB_USERNAME=reservation-service DB_PASSWORD=reservation-service-pass -GRPC_PORT=9090 \ No newline at end of file + +# gRPC Server +GRPC_PORT=9090 + +# gRPC Client +ACCOMMODATION_GRPC_HOST=devoops-accommodation-service +ACCOMMODATION_GRPC_PORT=9090 + +# RabbitMQ +RABBITMQ_HOST=devoops-rabbitmq +RABBITMQ_PORT=5672 +RABBITMQ_USERNAME=devoops +RABBITMQ_PASSWORD=devoops123 \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/config/RabbitMQConfig.java b/src/main/java/com/devoops/reservation/config/RabbitMQConfig.java new file mode 100644 index 0000000..e54da3d --- /dev/null +++ b/src/main/java/com/devoops/reservation/config/RabbitMQConfig.java @@ -0,0 +1,29 @@ +package com.devoops.reservation.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.amqp.core.TopicExchange; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RabbitMQConfig { + + @Value("${rabbitmq.exchange.notification}") + private String notificationExchange; + + @Bean + public TopicExchange notificationExchange() { + return new TopicExchange(notificationExchange); + } + + @Bean + public MessageConverter jsonMessageConverter() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + return new Jackson2JsonMessageConverter(objectMapper); + } +} diff --git a/src/main/java/com/devoops/reservation/controller/ReservationController.java b/src/main/java/com/devoops/reservation/controller/ReservationController.java index 64c9232..ff4505f 100644 --- a/src/main/java/com/devoops/reservation/controller/ReservationController.java +++ b/src/main/java/com/devoops/reservation/controller/ReservationController.java @@ -60,4 +60,13 @@ public ResponseEntity delete( reservationService.deleteRequest(id, userContext); return ResponseEntity.noContent().build(); } + + @PostMapping("/{id}/cancel") + @RequireRole("GUEST") + public ResponseEntity cancel( + @PathVariable UUID id, + UserContext userContext) { + reservationService.cancelReservation(id, userContext); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/com/devoops/reservation/dto/message/ReservationCancelledMessage.java b/src/main/java/com/devoops/reservation/dto/message/ReservationCancelledMessage.java new file mode 100644 index 0000000..8258e46 --- /dev/null +++ b/src/main/java/com/devoops/reservation/dto/message/ReservationCancelledMessage.java @@ -0,0 +1,14 @@ +package com.devoops.reservation.dto.message; + +import java.time.LocalDate; +import java.util.UUID; + +public record ReservationCancelledMessage( + UUID reservationId, + UUID accommodationId, + UUID guestId, + UUID hostId, + LocalDate startDate, + LocalDate endDate +) { +} diff --git a/src/main/java/com/devoops/reservation/dto/message/ReservationCreatedMessage.java b/src/main/java/com/devoops/reservation/dto/message/ReservationCreatedMessage.java new file mode 100644 index 0000000..c92d0f1 --- /dev/null +++ b/src/main/java/com/devoops/reservation/dto/message/ReservationCreatedMessage.java @@ -0,0 +1,18 @@ +package com.devoops.reservation.dto.message; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +public record ReservationCreatedMessage( + UUID reservationId, + UUID accommodationId, + UUID guestId, + UUID hostId, + LocalDate startDate, + LocalDate endDate, + int guestCount, + BigDecimal totalPrice, + String status +) { +} diff --git a/src/main/java/com/devoops/reservation/exception/AccommodationNotFoundException.java b/src/main/java/com/devoops/reservation/exception/AccommodationNotFoundException.java new file mode 100644 index 0000000..15cdadc --- /dev/null +++ b/src/main/java/com/devoops/reservation/exception/AccommodationNotFoundException.java @@ -0,0 +1,8 @@ +package com.devoops.reservation.exception; + +public class AccommodationNotFoundException extends RuntimeException { + + public AccommodationNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/devoops/reservation/exception/GlobalExceptionHandler.java b/src/main/java/com/devoops/reservation/exception/GlobalExceptionHandler.java index 8a048e6..c873523 100644 --- a/src/main/java/com/devoops/reservation/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/devoops/reservation/exception/GlobalExceptionHandler.java @@ -18,6 +18,11 @@ public ProblemDetail handleNotFound(ReservationNotFoundException ex) { return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); } + @ExceptionHandler(AccommodationNotFoundException.class) + public ProblemDetail handleAccommodationNotFound(AccommodationNotFoundException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); + } + @ExceptionHandler(UnauthorizedException.class) public ProblemDetail handleUnauthorized(UnauthorizedException ex) { return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage()); diff --git a/src/main/java/com/devoops/reservation/grpc/AccommodationGrpcClient.java b/src/main/java/com/devoops/reservation/grpc/AccommodationGrpcClient.java new file mode 100644 index 0000000..c97d670 --- /dev/null +++ b/src/main/java/com/devoops/reservation/grpc/AccommodationGrpcClient.java @@ -0,0 +1,63 @@ +package com.devoops.reservation.grpc; + +import com.devoops.reservation.grpc.proto.accommodation.AccommodationInternalServiceGrpc; +import com.devoops.reservation.grpc.proto.accommodation.ReservationValidationRequest; +import com.devoops.reservation.grpc.proto.accommodation.ReservationValidationResponse; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.client.inject.GrpcClient; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +@Component +@Slf4j +public class AccommodationGrpcClient { + + @GrpcClient("accommodation-service") + private AccommodationInternalServiceGrpc.AccommodationInternalServiceBlockingStub accommodationStub; + + public AccommodationValidationResult validateAndCalculatePrice( + UUID accommodationId, + LocalDate startDate, + LocalDate endDate, + int guestCount) { + + log.debug("Calling accommodation service for validation: accommodationId={}, dates={} to {}, guests={}", + accommodationId, startDate, endDate, guestCount); + + ReservationValidationRequest request = ReservationValidationRequest.newBuilder() + .setAccommodationId(accommodationId.toString()) + .setStartDate(startDate.toString()) + .setEndDate(endDate.toString()) + .setGuestCount(guestCount) + .build(); + + ReservationValidationResponse response = accommodationStub.validateAndCalculatePrice(request); + + log.debug("Received validation response: valid={}, errorCode={}", response.getValid(), response.getErrorCode()); + + if (!response.getValid()) { + return new AccommodationValidationResult( + false, + response.getErrorCode(), + response.getErrorMessage(), + null, + null, + null, + null + ); + } + + return new AccommodationValidationResult( + true, + null, + null, + UUID.fromString(response.getHostId()), + new BigDecimal(response.getTotalPrice()), + response.getPricingMode(), + response.getApprovalMode() + ); + } +} diff --git a/src/main/java/com/devoops/reservation/grpc/AccommodationValidationResult.java b/src/main/java/com/devoops/reservation/grpc/AccommodationValidationResult.java new file mode 100644 index 0000000..92cdfa9 --- /dev/null +++ b/src/main/java/com/devoops/reservation/grpc/AccommodationValidationResult.java @@ -0,0 +1,18 @@ +package com.devoops.reservation.grpc; + +import java.math.BigDecimal; +import java.util.UUID; + +public record AccommodationValidationResult( + boolean valid, + String errorCode, + String errorMessage, + UUID hostId, + BigDecimal totalPrice, + String pricingMode, + String approvalMode +) { + public boolean isAutoApproval() { + return "AUTOMATIC".equals(approvalMode); + } +} diff --git a/src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java b/src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java new file mode 100644 index 0000000..cf9809e --- /dev/null +++ b/src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java @@ -0,0 +1,61 @@ +package com.devoops.reservation.service; + +import com.devoops.reservation.dto.message.ReservationCancelledMessage; +import com.devoops.reservation.dto.message.ReservationCreatedMessage; +import com.devoops.reservation.entity.Reservation; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ReservationEventPublisherService { + + private final RabbitTemplate rabbitTemplate; + + @Value("${rabbitmq.exchange.notification}") + private String notificationExchange; + + @Value("${rabbitmq.routing-key.reservation-created}") + private String reservationCreatedRoutingKey; + + @Value("${rabbitmq.routing-key.reservation-cancelled}") + private String reservationCancelledRoutingKey; + + public void publishReservationCreated(Reservation reservation) { + ReservationCreatedMessage message = new ReservationCreatedMessage( + reservation.getId(), + reservation.getAccommodationId(), + reservation.getGuestId(), + reservation.getHostId(), + reservation.getStartDate(), + reservation.getEndDate(), + reservation.getGuestCount(), + reservation.getTotalPrice(), + reservation.getStatus().name() + ); + + log.info("Publishing reservation created event: reservationId={}, status={}", + reservation.getId(), reservation.getStatus()); + + rabbitTemplate.convertAndSend(notificationExchange, reservationCreatedRoutingKey, message); + } + + public void publishReservationCancelled(Reservation reservation) { + ReservationCancelledMessage message = new ReservationCancelledMessage( + reservation.getId(), + reservation.getAccommodationId(), + reservation.getGuestId(), + reservation.getHostId(), + reservation.getStartDate(), + reservation.getEndDate() + ); + + log.info("Publishing reservation cancelled event: reservationId={}", reservation.getId()); + + rabbitTemplate.convertAndSend(notificationExchange, reservationCancelledRoutingKey, message); + } +} diff --git a/src/main/java/com/devoops/reservation/service/ReservationService.java b/src/main/java/com/devoops/reservation/service/ReservationService.java index 77c2ec5..a33bb42 100644 --- a/src/main/java/com/devoops/reservation/service/ReservationService.java +++ b/src/main/java/com/devoops/reservation/service/ReservationService.java @@ -5,9 +5,12 @@ import com.devoops.reservation.dto.response.ReservationResponse; import com.devoops.reservation.entity.Reservation; import com.devoops.reservation.entity.ReservationStatus; +import com.devoops.reservation.exception.AccommodationNotFoundException; import com.devoops.reservation.exception.ForbiddenException; import com.devoops.reservation.exception.InvalidReservationException; import com.devoops.reservation.exception.ReservationNotFoundException; +import com.devoops.reservation.grpc.AccommodationGrpcClient; +import com.devoops.reservation.grpc.AccommodationValidationResult; import com.devoops.reservation.mapper.ReservationMapper; import com.devoops.reservation.repository.ReservationRepository; import lombok.RequiredArgsConstructor; @@ -15,9 +18,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.math.BigDecimal; import java.time.LocalDate; -import java.time.temporal.ChronoUnit; import java.util.List; import java.util.UUID; @@ -28,20 +29,30 @@ public class ReservationService { private final ReservationRepository reservationRepository; private final ReservationMapper reservationMapper; + private final AccommodationGrpcClient accommodationGrpcClient; + private final ReservationEventPublisherService eventPublisher; @Transactional public ReservationResponse create(CreateReservationRequest request, UserContext userContext) { // Validate dates validateDates(request.startDate(), request.endDate()); - // TODO: Call Accommodation Service via gRPC to: - // 1. Validate accommodation exists - // 2. Get hostId - // 3. Validate guest count within min/max capacity - // 4. Validate dates within availability periods - // 5. Calculate totalPrice from pricing rules - UUID hostId = UUID.randomUUID(); // Placeholder - get from Accommodation Service - BigDecimal totalPrice = calculatePlaceholderPrice(request); // Placeholder - get from Accommodation Service + // Call Accommodation Service via gRPC to validate and calculate price + AccommodationValidationResult validationResult = accommodationGrpcClient.validateAndCalculatePrice( + request.accommodationId(), + request.startDate(), + request.endDate(), + request.guestCount() + ); + + if (!validationResult.valid()) { + if ("ACCOMMODATION_NOT_FOUND".equals(validationResult.errorCode())) { + throw new AccommodationNotFoundException(validationResult.errorMessage()); + } + throw new InvalidReservationException(validationResult.errorMessage()); + } + + UUID hostId = validationResult.hostId(); // Check for overlapping approved reservations List overlapping = reservationRepository.findOverlappingApproved( @@ -59,15 +70,22 @@ public ReservationResponse create(CreateReservationRequest request, UserContext Reservation reservation = reservationMapper.toEntity(request); reservation.setGuestId(userContext.userId()); reservation.setHostId(hostId); - reservation.setTotalPrice(totalPrice); - reservation.setStatus(ReservationStatus.PENDING); + reservation.setTotalPrice(validationResult.totalPrice()); + + // Handle auto-approval mode + if (validationResult.isAutoApproval()) { + reservation.setStatus(ReservationStatus.APPROVED); + log.info("Auto-approving reservation for accommodation {} (AUTOMATIC approval mode)", + request.accommodationId()); + } else { + reservation.setStatus(ReservationStatus.PENDING); + } reservation = reservationRepository.saveAndFlush(reservation); log.info("Created reservation {} for guest {} at accommodation {}", reservation.getId(), userContext.userId(), request.accommodationId()); - // TODO: Publish event to Notification Service via RabbitMQ - // notifyHost(reservation); + eventPublisher.publishReservationCreated(reservation); return reservationMapper.toResponse(reservation); } @@ -110,8 +128,39 @@ public void deleteRequest(UUID id, UserContext userContext) { reservation.setDeleted(true); reservationRepository.save(reservation); log.info("Guest {} deleted reservation request {}", userContext.userId(), id); + } + + @Transactional + public void cancelReservation(UUID id, UserContext userContext) { + Reservation reservation = findReservationOrThrow(id); + + // Only the guest who created the reservation can cancel it + if (!reservation.getGuestId().equals(userContext.userId())) { + throw new ForbiddenException("You can only cancel your own reservations"); + } - // TODO: Publish event to Notification Service via RabbitMQ + // Can only cancel APPROVED reservations (PENDING uses deleteRequest) + if (reservation.getStatus() != ReservationStatus.APPROVED) { + throw new InvalidReservationException( + "Only approved reservations can be cancelled. Use delete for pending requests. Current status: " + reservation.getStatus() + ); + } + + // Must be at least 1 day before startDate + LocalDate today = LocalDate.now(); + LocalDate cancellationDeadline = reservation.getStartDate().minusDays(1); + + if (!today.isBefore(cancellationDeadline)) { + throw new InvalidReservationException( + "Reservations can only be cancelled at least 1 day before the start date" + ); + } + + reservation.setStatus(ReservationStatus.CANCELLED); + reservationRepository.save(reservation); + log.info("Guest {} cancelled reservation {}", userContext.userId(), id); + + eventPublisher.publishReservationCancelled(reservation); } // === Helper Methods === @@ -137,16 +186,4 @@ private void validateAccessToReservation(Reservation reservation, UserContext us } } - /** - * Placeholder price calculation. - * TODO: Replace with actual pricing calculation from Accommodation Service via gRPC. - * This calculates price based on number of nights and guest count. - */ - private BigDecimal calculatePlaceholderPrice(CreateReservationRequest request) { - long nights = ChronoUnit.DAYS.between(request.startDate(), request.endDate()); - // Placeholder: $100 per night * guest count - return BigDecimal.valueOf(100) - .multiply(BigDecimal.valueOf(nights)) - .multiply(BigDecimal.valueOf(request.guestCount())); - } } diff --git a/src/main/proto/accommodation_internal.proto b/src/main/proto/accommodation_internal.proto new file mode 100644 index 0000000..7f8bb38 --- /dev/null +++ b/src/main/proto/accommodation_internal.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; +package accommodation; + +option java_multiple_files = true; +option java_package = "com.devoops.reservation.grpc.proto.accommodation"; + +service AccommodationInternalService { + rpc ValidateAndCalculatePrice(ReservationValidationRequest) returns (ReservationValidationResponse); +} + +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; +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3dfb8ea..1e9a00c 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -32,3 +32,18 @@ management.prometheus.metrics.export.enabled=true # gRPC Server grpc.server.port=${GRPC_PORT:9090} + +# gRPC Client +grpc.client.accommodation-service.address=static://${ACCOMMODATION_GRPC_HOST:devoops-accommodation-service}:${ACCOMMODATION_GRPC_PORT:9090} +grpc.client.accommodation-service.negotiationType=plaintext + +# RabbitMQ +spring.rabbitmq.host=${RABBITMQ_HOST:devoops-rabbitmq} +spring.rabbitmq.port=${RABBITMQ_PORT:5672} +spring.rabbitmq.username=${RABBITMQ_USERNAME:devoops} +spring.rabbitmq.password=${RABBITMQ_PASSWORD:devoops123} + +# RabbitMQ routing +rabbitmq.exchange.notification=notification.exchange +rabbitmq.routing-key.reservation-created=notification.reservation.created +rabbitmq.routing-key.reservation-cancelled=notification.reservation.cancelled diff --git a/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java b/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java index 6b326d9..2aef842 100644 --- a/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java +++ b/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java @@ -344,4 +344,78 @@ void delete_WithHostRole_Returns403() throws Exception { .andExpect(status().isForbidden()); } } + + @Nested + @DisplayName("POST /api/reservation/{id}/cancel") + class CancelEndpoint { + + @Test + @DisplayName("With valid request returns 204") + void cancel_WithValidRequest_Returns204() throws Exception { + doNothing().when(reservationService).cancelReservation(eq(RESERVATION_ID), any(UserContext.class)); + + mockMvc.perform(post("/api/reservation/{id}/cancel", RESERVATION_ID) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("With non-existing ID returns 404") + void cancel_WithNonExistingId_Returns404() throws Exception { + UUID id = UUID.randomUUID(); + doThrow(new ReservationNotFoundException("Not found")) + .when(reservationService).cancelReservation(eq(id), any(UserContext.class)); + + mockMvc.perform(post("/api/reservation/{id}/cancel", id) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("With wrong owner returns 403") + void cancel_WithWrongOwner_Returns403() throws Exception { + doThrow(new ForbiddenException("Not the owner")) + .when(reservationService).cancelReservation(eq(RESERVATION_ID), any(UserContext.class)); + + mockMvc.perform(post("/api/reservation/{id}/cancel", RESERVATION_ID) + .header("X-User-Id", UUID.randomUUID().toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With non-approved status returns 400") + void cancel_WithNonApprovedStatus_Returns400() throws Exception { + doThrow(new InvalidReservationException("Only approved reservations can be cancelled")) + .when(reservationService).cancelReservation(eq(RESERVATION_ID), any(UserContext.class)); + + mockMvc.perform(post("/api/reservation/{id}/cancel", RESERVATION_ID) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("With less than 1 day before start returns 400") + void cancel_WithTooLate_Returns400() throws Exception { + doThrow(new InvalidReservationException("at least 1 day before")) + .when(reservationService).cancelReservation(eq(RESERVATION_ID), any(UserContext.class)); + + mockMvc.perform(post("/api/reservation/{id}/cancel", RESERVATION_ID) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("With HOST role returns 403") + void cancel_WithHostRole_Returns403() throws Exception { + mockMvc.perform(post("/api/reservation/{id}/cancel", RESERVATION_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isForbidden()); + } + } } diff --git a/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java b/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java index 4f0d014..4bd258f 100644 --- a/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java +++ b/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java @@ -5,9 +5,12 @@ import com.devoops.reservation.dto.response.ReservationResponse; import com.devoops.reservation.entity.Reservation; import com.devoops.reservation.entity.ReservationStatus; +import com.devoops.reservation.exception.AccommodationNotFoundException; import com.devoops.reservation.exception.ForbiddenException; import com.devoops.reservation.exception.InvalidReservationException; import com.devoops.reservation.exception.ReservationNotFoundException; +import com.devoops.reservation.grpc.AccommodationGrpcClient; +import com.devoops.reservation.grpc.AccommodationValidationResult; import com.devoops.reservation.mapper.ReservationMapper; import com.devoops.reservation.repository.ReservationRepository; import org.junit.jupiter.api.DisplayName; @@ -28,6 +31,7 @@ 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.ArgumentMatchers.anyInt; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -39,6 +43,12 @@ class ReservationServiceTest { @Mock private ReservationMapper reservationMapper; + @Mock + private AccommodationGrpcClient accommodationGrpcClient; + + @Mock + private ReservationEventPublisherService eventPublisher; + @InjectMocks private ReservationService reservationService; @@ -91,7 +101,12 @@ void create_WithValidRequest_ReturnsReservationResponse() { var request = createRequest(); var reservation = createReservation(); var response = createResponse(); + var validationResult = new AccommodationValidationResult( + true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL" + ); + when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) + .thenReturn(validationResult); when(reservationRepository.findOverlappingApproved(any(), any(), any())) .thenReturn(List.of()); when(reservationMapper.toEntity(request)).thenReturn(reservation); @@ -104,6 +119,7 @@ void create_WithValidRequest_ReturnsReservationResponse() { assertThat(reservation.getGuestId()).isEqualTo(GUEST_ID); assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.PENDING); verify(reservationRepository).saveAndFlush(reservation); + verify(eventPublisher).publishReservationCreated(reservation); } @Test @@ -112,7 +128,12 @@ void create_WithOverlappingApproved_ThrowsInvalidReservationException() { var request = createRequest(); var existingReservation = createReservation(); existingReservation.setStatus(ReservationStatus.APPROVED); + var validationResult = new AccommodationValidationResult( + true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL" + ); + when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) + .thenReturn(validationResult); when(reservationRepository.findOverlappingApproved(any(), any(), any())) .thenReturn(List.of(existingReservation)); @@ -121,6 +142,62 @@ void create_WithOverlappingApproved_ThrowsInvalidReservationException() { .hasMessageContaining("overlap"); } + @Test + @DisplayName("With auto-approval mode auto-approves reservation") + void create_WithAutoApprovalMode_AutoApprovesReservation() { + var request = createRequest(); + var reservation = createReservation(); + var response = createResponse(); + var validationResult = new AccommodationValidationResult( + true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "AUTOMATIC" + ); + + when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) + .thenReturn(validationResult); + when(reservationRepository.findOverlappingApproved(any(), any(), any())) + .thenReturn(List.of()); + when(reservationMapper.toEntity(request)).thenReturn(reservation); + when(reservationRepository.saveAndFlush(reservation)).thenReturn(reservation); + when(reservationMapper.toResponse(reservation)).thenReturn(response); + + reservationService.create(request, GUEST_CONTEXT); + + assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.APPROVED); + verify(reservationRepository).saveAndFlush(reservation); + } + + @Test + @DisplayName("With accommodation not found throws AccommodationNotFoundException") + void create_WithAccommodationNotFound_ThrowsAccommodationNotFoundException() { + var request = createRequest(); + var validationResult = new AccommodationValidationResult( + false, "ACCOMMODATION_NOT_FOUND", "Accommodation not found", null, null, null, null + ); + + when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) + .thenReturn(validationResult); + + assertThatThrownBy(() -> reservationService.create(request, GUEST_CONTEXT)) + .isInstanceOf(AccommodationNotFoundException.class) + .hasMessageContaining("Accommodation not found"); + } + + @Test + @DisplayName("With invalid guest count throws InvalidReservationException") + void create_WithInvalidGuestCount_ThrowsInvalidReservationException() { + var request = createRequest(); + var validationResult = new AccommodationValidationResult( + false, "GUEST_COUNT_INVALID", "Guest count must be between 1 and 4", null, null, null, null + ); + + when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) + .thenReturn(validationResult); + + assertThatThrownBy(() -> reservationService.create(request, GUEST_CONTEXT)) + .isInstanceOf(InvalidReservationException.class) + .hasMessageContaining("Guest count"); + } + @Test @DisplayName("With end date before start date throws InvalidReservationException") void create_WithEndDateBeforeStartDate_ThrowsInvalidReservationException() { @@ -332,4 +409,88 @@ void deleteRequest_WithHostTryingToDelete_ThrowsForbiddenException() { .isInstanceOf(ForbiddenException.class); } } + + @Nested + @DisplayName("CancelReservation") + class CancelReservationTests { + + @Test + @DisplayName("With valid approved reservation cancels successfully") + void cancelReservation_WithValidApproved_CancelsSuccessfully() { + var reservation = createReservation(); + reservation.setStatus(ReservationStatus.APPROVED); + reservation.setStartDate(LocalDate.now().plusDays(10)); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + reservationService.cancelReservation(RESERVATION_ID, GUEST_CONTEXT); + + assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.CANCELLED); + verify(reservationRepository).save(reservation); + verify(eventPublisher).publishReservationCancelled(reservation); + } + + @Test + @DisplayName("With wrong owner throws ForbiddenException") + void cancelReservation_WithWrongOwner_ThrowsForbiddenException() { + var reservation = createReservation(); + reservation.setStatus(ReservationStatus.APPROVED); + var otherUser = new UserContext(UUID.randomUUID(), "GUEST"); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.cancelReservation(RESERVATION_ID, otherUser)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("only cancel your own"); + } + + @Test + @DisplayName("With pending status throws InvalidReservationException") + void cancelReservation_WithPendingStatus_ThrowsInvalidReservationException() { + var reservation = createReservation(); + reservation.setStatus(ReservationStatus.PENDING); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.cancelReservation(RESERVATION_ID, GUEST_CONTEXT)) + .isInstanceOf(InvalidReservationException.class) + .hasMessageContaining("Only approved reservations"); + } + + @Test + @DisplayName("With less than 1 day before start throws InvalidReservationException") + void cancelReservation_WithLessThanOneDayBefore_ThrowsInvalidReservationException() { + var reservation = createReservation(); + reservation.setStatus(ReservationStatus.APPROVED); + reservation.setStartDate(LocalDate.now()); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.cancelReservation(RESERVATION_ID, GUEST_CONTEXT)) + .isInstanceOf(InvalidReservationException.class) + .hasMessageContaining("at least 1 day before"); + } + + @Test + @DisplayName("With non-existing ID throws ReservationNotFoundException") + void cancelReservation_WithNonExistingId_ThrowsReservationNotFoundException() { + UUID id = UUID.randomUUID(); + when(reservationRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> reservationService.cancelReservation(id, GUEST_CONTEXT)) + .isInstanceOf(ReservationNotFoundException.class); + } + + @Test + @DisplayName("Host cannot cancel guest's reservation") + void cancelReservation_WithHostTryingToCancel_ThrowsForbiddenException() { + var reservation = createReservation(); + reservation.setStatus(ReservationStatus.APPROVED); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.cancelReservation(RESERVATION_ID, HOST_CONTEXT)) + .isInstanceOf(ForbiddenException.class); + } + } } From c444376c2e4d02cb9511aff5e3fe7290270a0900 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Tue, 17 Feb 2026 19:34:43 +0100 Subject: [PATCH 2/2] feat: Add user and accommodation info fetching. --- .../message/ReservationCancelledMessage.java | 13 ++-- .../message/ReservationCreatedMessage.java | 16 +++-- .../grpc/AccommodationGrpcClient.java | 4 +- .../grpc/AccommodationValidationResult.java | 3 +- .../reservation/grpc/UserGrpcClient.java | 50 ++++++++++++++++ .../reservation/grpc/UserSummaryResult.java | 16 +++++ .../ReservationEventPublisherService.java | 59 ++++++++++++++----- .../service/ReservationService.java | 13 +++- src/main/proto/accommodation_internal.proto | 1 + src/main/proto/user_internal.proto | 22 +++++++ src/main/resources/application.properties | 6 +- .../ReservationIntegrationTest.java | 47 +++++++++++++++ .../service/ReservationServiceTest.java | 19 +++--- 13 files changed, 227 insertions(+), 42 deletions(-) create mode 100644 src/main/java/com/devoops/reservation/grpc/UserGrpcClient.java create mode 100644 src/main/java/com/devoops/reservation/grpc/UserSummaryResult.java create mode 100644 src/main/proto/user_internal.proto diff --git a/src/main/java/com/devoops/reservation/dto/message/ReservationCancelledMessage.java b/src/main/java/com/devoops/reservation/dto/message/ReservationCancelledMessage.java index 8258e46..135cb1e 100644 --- a/src/main/java/com/devoops/reservation/dto/message/ReservationCancelledMessage.java +++ b/src/main/java/com/devoops/reservation/dto/message/ReservationCancelledMessage.java @@ -4,11 +4,12 @@ import java.util.UUID; public record ReservationCancelledMessage( - UUID reservationId, - UUID accommodationId, - UUID guestId, - UUID hostId, - LocalDate startDate, - LocalDate endDate + UUID userId, + String userEmail, + String guestName, + String accommodationName, + LocalDate checkIn, + LocalDate checkOut, + String reason ) { } diff --git a/src/main/java/com/devoops/reservation/dto/message/ReservationCreatedMessage.java b/src/main/java/com/devoops/reservation/dto/message/ReservationCreatedMessage.java index c92d0f1..eb6fc17 100644 --- a/src/main/java/com/devoops/reservation/dto/message/ReservationCreatedMessage.java +++ b/src/main/java/com/devoops/reservation/dto/message/ReservationCreatedMessage.java @@ -5,14 +5,12 @@ import java.util.UUID; public record ReservationCreatedMessage( - UUID reservationId, - UUID accommodationId, - UUID guestId, - UUID hostId, - LocalDate startDate, - LocalDate endDate, - int guestCount, - BigDecimal totalPrice, - String status + UUID userId, + String userEmail, + String guestName, + String accommodationName, + LocalDate checkIn, + LocalDate checkOut, + BigDecimal totalPrice ) { } diff --git a/src/main/java/com/devoops/reservation/grpc/AccommodationGrpcClient.java b/src/main/java/com/devoops/reservation/grpc/AccommodationGrpcClient.java index c97d670..08730b9 100644 --- a/src/main/java/com/devoops/reservation/grpc/AccommodationGrpcClient.java +++ b/src/main/java/com/devoops/reservation/grpc/AccommodationGrpcClient.java @@ -46,6 +46,7 @@ public AccommodationValidationResult validateAndCalculatePrice( null, null, null, + null, null ); } @@ -57,7 +58,8 @@ public AccommodationValidationResult validateAndCalculatePrice( UUID.fromString(response.getHostId()), new BigDecimal(response.getTotalPrice()), response.getPricingMode(), - response.getApprovalMode() + response.getApprovalMode(), + response.getAccommodationName() ); } } diff --git a/src/main/java/com/devoops/reservation/grpc/AccommodationValidationResult.java b/src/main/java/com/devoops/reservation/grpc/AccommodationValidationResult.java index 92cdfa9..e783932 100644 --- a/src/main/java/com/devoops/reservation/grpc/AccommodationValidationResult.java +++ b/src/main/java/com/devoops/reservation/grpc/AccommodationValidationResult.java @@ -10,7 +10,8 @@ public record AccommodationValidationResult( UUID hostId, BigDecimal totalPrice, String pricingMode, - String approvalMode + String approvalMode, + String accommodationName ) { public boolean isAutoApproval() { return "AUTOMATIC".equals(approvalMode); diff --git a/src/main/java/com/devoops/reservation/grpc/UserGrpcClient.java b/src/main/java/com/devoops/reservation/grpc/UserGrpcClient.java new file mode 100644 index 0000000..92859a7 --- /dev/null +++ b/src/main/java/com/devoops/reservation/grpc/UserGrpcClient.java @@ -0,0 +1,50 @@ +package com.devoops.reservation.grpc; + +import com.devoops.reservation.grpc.proto.user.GetUserSummaryRequest; +import com.devoops.reservation.grpc.proto.user.GetUserSummaryResponse; +import com.devoops.reservation.grpc.proto.user.UserInternalServiceGrpc; +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 UserGrpcClient { + + @GrpcClient("user-service") + private UserInternalServiceGrpc.UserInternalServiceBlockingStub userStub; + + public UserSummaryResult getUserSummary(UUID userId) { + log.debug("Calling user service for user summary: userId={}", userId); + + GetUserSummaryRequest request = GetUserSummaryRequest.newBuilder() + .setUserId(userId.toString()) + .build(); + + GetUserSummaryResponse response = userStub.getUserSummary(request); + + log.debug("Received user summary response: found={}", response.getFound()); + + if (!response.getFound()) { + return new UserSummaryResult( + false, + null, + null, + null, + null, + null + ); + } + + return new UserSummaryResult( + true, + UUID.fromString(response.getUserId()), + response.getEmail(), + response.getFirstName(), + response.getLastName(), + response.getRole() + ); + } +} diff --git a/src/main/java/com/devoops/reservation/grpc/UserSummaryResult.java b/src/main/java/com/devoops/reservation/grpc/UserSummaryResult.java new file mode 100644 index 0000000..6b1e0d4 --- /dev/null +++ b/src/main/java/com/devoops/reservation/grpc/UserSummaryResult.java @@ -0,0 +1,16 @@ +package com.devoops.reservation.grpc; + +import java.util.UUID; + +public record UserSummaryResult( + boolean found, + UUID userId, + String email, + String firstName, + String lastName, + String role +) { + public String getFullName() { + return firstName + " " + lastName; + } +} diff --git a/src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java b/src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java index cf9809e..f92c0ac 100644 --- a/src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java +++ b/src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java @@ -3,6 +3,8 @@ import com.devoops.reservation.dto.message.ReservationCancelledMessage; import com.devoops.reservation.dto.message.ReservationCreatedMessage; import com.devoops.reservation.entity.Reservation; +import com.devoops.reservation.grpc.UserGrpcClient; +import com.devoops.reservation.grpc.UserSummaryResult; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.rabbit.core.RabbitTemplate; @@ -15,6 +17,7 @@ public class ReservationEventPublisherService { private final RabbitTemplate rabbitTemplate; + private final UserGrpcClient userGrpcClient; @Value("${rabbitmq.exchange.notification}") private String notificationExchange; @@ -25,36 +28,62 @@ public class ReservationEventPublisherService { @Value("${rabbitmq.routing-key.reservation-cancelled}") private String reservationCancelledRoutingKey; - public void publishReservationCreated(Reservation reservation) { + public void publishReservationCreated(Reservation reservation, String accommodationName) { + UserSummaryResult hostSummary = userGrpcClient.getUserSummary(reservation.getHostId()); + UserSummaryResult guestSummary = userGrpcClient.getUserSummary(reservation.getGuestId()); + + if (!hostSummary.found()) { + log.warn("Host not found for reservation {}, skipping notification", reservation.getId()); + return; + } + + if (!guestSummary.found()) { + log.warn("Guest not found for reservation {}, skipping notification", reservation.getId()); + return; + } + ReservationCreatedMessage message = new ReservationCreatedMessage( - reservation.getId(), - reservation.getAccommodationId(), - reservation.getGuestId(), reservation.getHostId(), + hostSummary.email(), + guestSummary.getFullName(), + accommodationName, reservation.getStartDate(), reservation.getEndDate(), - reservation.getGuestCount(), - reservation.getTotalPrice(), - reservation.getStatus().name() + reservation.getTotalPrice() ); - log.info("Publishing reservation created event: reservationId={}, status={}", - reservation.getId(), reservation.getStatus()); + log.info("Publishing reservation created event: reservationId={}, hostEmail={}, guestName={}", + reservation.getId(), hostSummary.email(), guestSummary.getFullName()); rabbitTemplate.convertAndSend(notificationExchange, reservationCreatedRoutingKey, message); } - public void publishReservationCancelled(Reservation reservation) { + public void publishReservationCancelled(Reservation reservation, String accommodationName) { + UserSummaryResult hostSummary = userGrpcClient.getUserSummary(reservation.getHostId()); + UserSummaryResult guestSummary = userGrpcClient.getUserSummary(reservation.getGuestId()); + + if (!hostSummary.found()) { + log.warn("Host not found for reservation {}, skipping notification", reservation.getId()); + return; + } + + if (!guestSummary.found()) { + log.warn("Guest not found for reservation {}, skipping notification", reservation.getId()); + return; + } + ReservationCancelledMessage message = new ReservationCancelledMessage( - reservation.getId(), - reservation.getAccommodationId(), - reservation.getGuestId(), reservation.getHostId(), + hostSummary.email(), + guestSummary.getFullName(), + accommodationName, reservation.getStartDate(), - reservation.getEndDate() + reservation.getEndDate(), + "Guest cancelled the reservation" ); - log.info("Publishing reservation cancelled event: reservationId={}", reservation.getId()); + log.info("Publishing reservation cancelled event: reservationId={}, hostEmail={}, guestName={}", + reservation.getId(), hostSummary.email(), guestSummary.getFullName()); rabbitTemplate.convertAndSend(notificationExchange, reservationCancelledRoutingKey, message); } diff --git a/src/main/java/com/devoops/reservation/service/ReservationService.java b/src/main/java/com/devoops/reservation/service/ReservationService.java index a33bb42..8e807dc 100644 --- a/src/main/java/com/devoops/reservation/service/ReservationService.java +++ b/src/main/java/com/devoops/reservation/service/ReservationService.java @@ -85,7 +85,7 @@ public ReservationResponse create(CreateReservationRequest request, UserContext log.info("Created reservation {} for guest {} at accommodation {}", reservation.getId(), userContext.userId(), request.accommodationId()); - eventPublisher.publishReservationCreated(reservation); + eventPublisher.publishReservationCreated(reservation, validationResult.accommodationName()); return reservationMapper.toResponse(reservation); } @@ -160,7 +160,16 @@ public void cancelReservation(UUID id, UserContext userContext) { reservationRepository.save(reservation); log.info("Guest {} cancelled reservation {}", userContext.userId(), id); - eventPublisher.publishReservationCancelled(reservation); + // Fetch accommodation name for notification + AccommodationValidationResult accommodationInfo = accommodationGrpcClient.validateAndCalculatePrice( + reservation.getAccommodationId(), + reservation.getStartDate(), + reservation.getEndDate(), + reservation.getGuestCount() + ); + String accommodationName = accommodationInfo.valid() ? accommodationInfo.accommodationName() : "Unknown Accommodation"; + + eventPublisher.publishReservationCancelled(reservation, accommodationName); } // === Helper Methods === diff --git a/src/main/proto/accommodation_internal.proto b/src/main/proto/accommodation_internal.proto index 7f8bb38..0489750 100644 --- a/src/main/proto/accommodation_internal.proto +++ b/src/main/proto/accommodation_internal.proto @@ -23,4 +23,5 @@ message ReservationValidationResponse { string total_price = 5; string pricing_mode = 6; string approval_mode = 7; + string accommodation_name = 8; } diff --git a/src/main/proto/user_internal.proto b/src/main/proto/user_internal.proto new file mode 100644 index 0000000..ce2fd57 --- /dev/null +++ b/src/main/proto/user_internal.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; +package user; + +option java_multiple_files = true; +option java_package = "com.devoops.reservation.grpc.proto.user"; + +service UserInternalService { + rpc GetUserSummary(GetUserSummaryRequest) returns (GetUserSummaryResponse); +} + +message GetUserSummaryRequest { + string user_id = 1; +} + +message GetUserSummaryResponse { + bool found = 1; + string user_id = 2; + string email = 3; + string first_name = 4; + string last_name = 5; + string role = 6; +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1e9a00c..a254766 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -33,10 +33,14 @@ management.prometheus.metrics.export.enabled=true # gRPC Server grpc.server.port=${GRPC_PORT:9090} -# gRPC Client +# gRPC Client - Accommodation Service grpc.client.accommodation-service.address=static://${ACCOMMODATION_GRPC_HOST:devoops-accommodation-service}:${ACCOMMODATION_GRPC_PORT:9090} grpc.client.accommodation-service.negotiationType=plaintext +# gRPC Client - User Service +grpc.client.user-service.address=static://${USER_GRPC_HOST:devoops-user-service}:${USER_GRPC_PORT:9090} +grpc.client.user-service.negotiationType=plaintext + # RabbitMQ spring.rabbitmq.host=${RABBITMQ_HOST:devoops-rabbitmq} spring.rabbitmq.port=${RABBITMQ_PORT:5672} diff --git a/src/test/java/com/devoops/reservation/integration/ReservationIntegrationTest.java b/src/test/java/com/devoops/reservation/integration/ReservationIntegrationTest.java index 1e100d4..b3d6f62 100644 --- a/src/test/java/com/devoops/reservation/integration/ReservationIntegrationTest.java +++ b/src/test/java/com/devoops/reservation/integration/ReservationIntegrationTest.java @@ -1,6 +1,11 @@ package com.devoops.reservation.integration; +import com.devoops.reservation.grpc.AccommodationGrpcClient; +import com.devoops.reservation.grpc.AccommodationValidationResult; +import com.devoops.reservation.grpc.UserGrpcClient; +import com.devoops.reservation.grpc.UserSummaryResult; import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; @@ -9,16 +14,22 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import java.math.BigDecimal; import java.time.LocalDate; import java.util.Map; import java.util.UUID; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.when; + import static org.hamcrest.Matchers.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -39,6 +50,15 @@ class ReservationIntegrationTest { @Autowired private MockMvc mockMvc; + @MockitoBean + private AccommodationGrpcClient accommodationGrpcClient; + + @MockitoBean + private UserGrpcClient userGrpcClient; + + @MockitoBean + private RabbitTemplate rabbitTemplate; + private final ObjectMapper objectMapper = new ObjectMapper(); private static String reservationId; @@ -49,6 +69,33 @@ class ReservationIntegrationTest { private static final String BASE_PATH = "/api/reservation"; + @BeforeEach + void setUpMocks() { + AccommodationValidationResult validResult = new AccommodationValidationResult( + true, + null, + null, + HOST_ID, + new BigDecimal("500.00"), + "PER_ACCOMMODATION", + "MANUAL", + "Test Accommodation" + ); + when(accommodationGrpcClient.validateAndCalculatePrice(any(UUID.class), any(LocalDate.class), any(LocalDate.class), anyInt())) + .thenReturn(validResult); + + UserSummaryResult hostSummary = new UserSummaryResult( + true, + HOST_ID, + "host@example.com", + "Test", + "Host", + "HOST" + ); + when(userGrpcClient.getUserSummary(any(UUID.class))) + .thenReturn(hostSummary); + } + @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); diff --git a/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java b/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java index 4bd258f..5903b73 100644 --- a/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java +++ b/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java @@ -102,7 +102,7 @@ void create_WithValidRequest_ReturnsReservationResponse() { var reservation = createReservation(); var response = createResponse(); var validationResult = new AccommodationValidationResult( - true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL" + true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL", "Test Accommodation" ); when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) @@ -119,7 +119,7 @@ true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL" assertThat(reservation.getGuestId()).isEqualTo(GUEST_ID); assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.PENDING); verify(reservationRepository).saveAndFlush(reservation); - verify(eventPublisher).publishReservationCreated(reservation); + verify(eventPublisher).publishReservationCreated(reservation, "Test Accommodation"); } @Test @@ -129,7 +129,7 @@ void create_WithOverlappingApproved_ThrowsInvalidReservationException() { var existingReservation = createReservation(); existingReservation.setStatus(ReservationStatus.APPROVED); var validationResult = new AccommodationValidationResult( - true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL" + true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL", "Test Accommodation" ); when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) @@ -149,7 +149,7 @@ void create_WithAutoApprovalMode_AutoApprovesReservation() { var reservation = createReservation(); var response = createResponse(); var validationResult = new AccommodationValidationResult( - true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "AUTOMATIC" + true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "AUTOMATIC", "Test Accommodation" ); when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) @@ -171,7 +171,7 @@ true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "AUTOMATIC" void create_WithAccommodationNotFound_ThrowsAccommodationNotFoundException() { var request = createRequest(); var validationResult = new AccommodationValidationResult( - false, "ACCOMMODATION_NOT_FOUND", "Accommodation not found", null, null, null, null + false, "ACCOMMODATION_NOT_FOUND", "Accommodation not found", null, null, null, null, null ); when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) @@ -187,7 +187,7 @@ void create_WithAccommodationNotFound_ThrowsAccommodationNotFoundException() { void create_WithInvalidGuestCount_ThrowsInvalidReservationException() { var request = createRequest(); var validationResult = new AccommodationValidationResult( - false, "GUEST_COUNT_INVALID", "Guest count must be between 1 and 4", null, null, null, null + false, "GUEST_COUNT_INVALID", "Guest count must be between 1 and 4", null, null, null, null, null ); when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) @@ -420,14 +420,19 @@ void cancelReservation_WithValidApproved_CancelsSuccessfully() { var reservation = createReservation(); reservation.setStatus(ReservationStatus.APPROVED); reservation.setStartDate(LocalDate.now().plusDays(10)); + 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); reservationService.cancelReservation(RESERVATION_ID, GUEST_CONTEXT); assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.CANCELLED); verify(reservationRepository).save(reservation); - verify(eventPublisher).publishReservationCancelled(reservation); + verify(eventPublisher).publishReservationCancelled(reservation, "Test Accommodation"); } @Test