diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d1d8de5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,86 @@ +name: CI Pipeline + +on: + push: + branches: + - develop + - main + tags: + - 'v*' + + +env: + DOCKERHUB_USERNAME: threeamigoscoding + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '25' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@0b6dd653ba04f4f93bf581ec31e66cbd7dcb644d + + - name: Build with Gradle + run: ./gradlew clean build + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: build-artifact + path: build/libs/*.jar + retention-days: 1 + + publish: + needs: build + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: build-artifact + path: build/libs + + - name: Generate version tag + id: version + run: | + if [[ $GITHUB_REF == refs/tags/* ]]; then + VERSION_TAG=${GITHUB_REF#refs/tags/v} + elif [[ $GITHUB_REF == refs/heads/main ]]; then + SHORT_SHA=$(git rev-parse --short HEAD) + VERSION_TAG="main-${SHORT_SHA}" + else + SHORT_SHA=$(git rev-parse --short HEAD) + VERSION_TAG="develop-${SHORT_SHA}" + fi + echo "tag=$VERSION_TAG" >> $GITHUB_OUTPUT + echo "Generated version tag: $VERSION_TAG" + + - name: Build and push Docker image with Kaniko + env: + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: | + mkdir -p /tmp/kaniko/.docker + echo "{\"auths\":{\"https://index.docker.io/v1/\":{\"auth\":\"$(echo -n ${{ env.DOCKERHUB_USERNAME }}:${DOCKERHUB_TOKEN} | base64)\"}}}" > /tmp/kaniko/.docker/config.json + docker run \ + -v ${{ github.workspace }}:/workspace \ + -v /tmp/kaniko/.docker:/kaniko/.docker \ + gcr.io/kaniko-project/executor:latest \ + --context=/workspace \ + --dockerfile=/workspace/Dockerfile \ + --destination=${{ env.DOCKERHUB_USERNAME }}/devoops-reservation-service:${{ steps.version.outputs.tag }} \ + --destination=${{ env.DOCKERHUB_USERNAME }}/devoops-reservation-service:latest \ + --cache=true \ + --cache-repo=${{ env.DOCKERHUB_USERNAME }}/devoops-reservation-service-cache \ No newline at end of file diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..e6f3777 --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,27 @@ +name: PR Check + +on: + pull_request: + branches: + - develop + - main + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '25' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build with Gradle + run: ./gradlew clean build \ No newline at end of file diff --git a/.gitignore b/.gitignore index df0b53a..070c2bf 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,4 @@ out/ .vscode/ ### Mac ### -.DS_Store \ No newline at end of file +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ff10050 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM eclipse-temurin:25-jre-alpine + +WORKDIR /app + +COPY build/libs/*SNAPSHOT.jar app.jar + +RUN addgroup -S spring && adduser -S spring -G spring +USER spring:spring + +EXPOSE 8080 +EXPOSE 9090 + +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 29ce4d1..5ad4a3f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,11 @@ +import com.google.protobuf.gradle.* + plugins { java + jacoco id("org.springframework.boot") version "4.0.1" id("io.spring.dependency-management") version "1.1.7" + id("com.google.protobuf") version "0.9.4" } group = "com.devoops" @@ -18,18 +22,89 @@ repositories { mavenCentral() } +val grpcVersion = "1.68.0" + dependencies { - implementation("org.springframework.boot:spring-boot-starter-flyway") - implementation("org.springframework.boot:spring-boot-starter-security") + // Web and Core 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") + implementation("org.springframework.boot:spring-boot-starter-flyway") implementation("org.flywaydb:flyway-database-postgresql") runtimeOnly("org.postgresql:postgresql") - testImplementation("org.springframework.boot:spring-boot-starter-flyway-test") - testImplementation("org.springframework.boot:spring-boot-starter-security-test") + + // Lombok + compileOnly("org.projectlombok:lombok") + annotationProcessor("org.projectlombok:lombok") + + // MapStruct + implementation("org.mapstruct:mapstruct:1.6.3") + annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3") + annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0") + + // 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") + compileOnly("javax.annotation:javax.annotation-api:1.3.2") + + // Prometheus + implementation("io.micrometer:micrometer-registry-prometheus") + + // Tracing (Zipkin) + implementation("org.springframework.boot:spring-boot-micrometer-tracing-brave") + implementation("org.springframework.boot:spring-boot-starter-zipkin") + implementation("io.micrometer:micrometer-tracing-bridge-brave") + implementation("io.zipkin.reporter2:zipkin-reporter-brave") + + // Logging + implementation("net.logstash.logback:logstash-logback-encoder:8.0") + + // Test + testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") + testImplementation("org.springframework.boot:spring-boot-starter-flyway-test") + testImplementation("org.testcontainers:junit-jupiter:1.20.4") + testImplementation("org.testcontainers:postgresql:1.20.4") + testImplementation("io.rest-assured:rest-assured:5.5.0") + testCompileOnly("org.projectlombok:lombok") + testAnnotationProcessor("org.projectlombok:lombok") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:3.25.5" + } + plugins { + id("grpc") { + artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion" + } + } + generateProtoTasks { + all().forEach { task -> + task.plugins { + id("grpc") + } + } + } +} + tasks.withType { useJUnitPlatform() + finalizedBy(tasks.jacocoTestReport) +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required = true + } } diff --git a/environment/.local.env b/environment/.local.env new file mode 100644 index 0000000..38cc133 --- /dev/null +++ b/environment/.local.env @@ -0,0 +1,21 @@ +SERVER_PORT=8080 +LOGSTASH_HOST=logstash:5000 +ZIPKIN_HOST=zipkin +ZIPKIN_PORT=9411 +POSTGRES_HOST=devoops-postgres +POSTGRES_PORT=5432 +DB_USERNAME=reservation-service +DB_PASSWORD=reservation-service-pass + +# 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/environment/helm/values.yaml b/environment/helm/values.yaml new file mode 100644 index 0000000..6a4f8d5 --- /dev/null +++ b/environment/helm/values.yaml @@ -0,0 +1,55 @@ +fullnameOverride: devoops-reservation-service +replicaCount: 1 + +image: + registry: docker.io + repository: threeamigoscoding/devoops-reservation-service + tag: "latest" + pullPolicy: IfNotPresent + +service: + type: ClusterIP + httpPort: 8080 + grpc: + enabled: true + port: 9090 + +ingress: + enabled: false + +resources: + requests: + memory: 256Mi + cpu: 250m + limits: + memory: 512Mi + cpu: 1000m + +health: + path: /actuator/health + periodSeconds: 10 + failureThreshold: 3 + startup: + initialDelaySeconds: 10 + periodSeconds: 5 + failureThreshold: 60 + +configData: + JAVA_TOOL_OPTIONS: "-XX:+UseSerialGC -Xms128m -Xmx384m -XX:ActiveProcessorCount=1" + SERVER_PORT: "8080" + LOGSTASH_HOST: "devoops-logstash:5000" + ZIPKIN_HOST: "devoops-jaeger" + ZIPKIN_PORT: "9411" + POSTGRES_HOST: "devoops-postgres" + POSTGRES_PORT: "5432" + GRPC_PORT: "9090" + ACCOMMODATION_GRPC_HOST: "devoops-accommodation-service" + ACCOMMODATION_GRPC_PORT: "9090" + RABBITMQ_HOST: "devoops-rabbitmq" + RABBITMQ_PORT: "5672" + +secretData: + DB_USERNAME: "reservation-service" + DB_PASSWORD: "reservation-service-pass" + RABBITMQ_USERNAME: "devoops" + RABBITMQ_PASSWORD: "devoops123" diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 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/config/RequireRole.java b/src/main/java/com/devoops/reservation/config/RequireRole.java new file mode 100644 index 0000000..5559fd4 --- /dev/null +++ b/src/main/java/com/devoops/reservation/config/RequireRole.java @@ -0,0 +1,12 @@ +package com.devoops.reservation.config; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequireRole { + String[] value(); +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/config/RoleAuthorizationInterceptor.java b/src/main/java/com/devoops/reservation/config/RoleAuthorizationInterceptor.java new file mode 100644 index 0000000..0a11f2c --- /dev/null +++ b/src/main/java/com/devoops/reservation/config/RoleAuthorizationInterceptor.java @@ -0,0 +1,49 @@ +package com.devoops.reservation.config; + +import com.devoops.reservation.exception.ForbiddenException; +import com.devoops.reservation.exception.UnauthorizedException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.Arrays; + +@Component +public class RoleAuthorizationInterceptor implements HandlerInterceptor { + + @Override + public boolean preHandle( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull Object handler + ) { + if (!(handler instanceof HandlerMethod handlerMethod)) { + return true; + } + + RequireRole methodAnnotation = handlerMethod.getMethodAnnotation(RequireRole.class); + RequireRole classAnnotation = handlerMethod.getBeanType().getAnnotation(RequireRole.class); + + RequireRole requireRole = methodAnnotation != null ? methodAnnotation : classAnnotation; + if (requireRole == null) { + return true; + } + + String role = request.getHeader("X-User-Role"); + if (role == null) { + throw new UnauthorizedException("Missing authentication headers"); + } + + boolean hasRole = Arrays.stream(requireRole.value()) + .anyMatch(r -> r.equalsIgnoreCase(role)); + + if (!hasRole) { + throw new ForbiddenException("Insufficient permissions"); + } + + return true; + } +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/config/UserContext.java b/src/main/java/com/devoops/reservation/config/UserContext.java new file mode 100644 index 0000000..d09930c --- /dev/null +++ b/src/main/java/com/devoops/reservation/config/UserContext.java @@ -0,0 +1,5 @@ +package com.devoops.reservation.config; + +import java.util.UUID; + +public record UserContext(UUID userId, String role) {} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/config/UserContextResolver.java b/src/main/java/com/devoops/reservation/config/UserContextResolver.java new file mode 100644 index 0000000..f3a5750 --- /dev/null +++ b/src/main/java/com/devoops/reservation/config/UserContextResolver.java @@ -0,0 +1,40 @@ +package com.devoops.reservation.config; + +import com.devoops.reservation.exception.UnauthorizedException; +import org.jspecify.annotations.NonNull; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.UUID; + +public class UserContextResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return UserContext.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument( + @NonNull MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + String userId = webRequest.getHeader("X-User-Id"); + String role = webRequest.getHeader("X-User-Role"); + + if (userId == null || role == null) { + throw new UnauthorizedException("Missing authentication headers"); + } + + try { + return new UserContext(UUID.fromString(userId), role); + } catch (IllegalArgumentException e) { + throw new UnauthorizedException("Invalid user ID format"); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/config/WebConfig.java b/src/main/java/com/devoops/reservation/config/WebConfig.java new file mode 100644 index 0000000..630d8ef --- /dev/null +++ b/src/main/java/com/devoops/reservation/config/WebConfig.java @@ -0,0 +1,26 @@ +package com.devoops.reservation.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final RoleAuthorizationInterceptor roleAuthorizationInterceptor; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new UserContextResolver()); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(roleAuthorizationInterceptor); + } +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/controller/ReservationController.java b/src/main/java/com/devoops/reservation/controller/ReservationController.java new file mode 100644 index 0000000..8c47b2d --- /dev/null +++ b/src/main/java/com/devoops/reservation/controller/ReservationController.java @@ -0,0 +1,89 @@ +package com.devoops.reservation.controller; + +import com.devoops.reservation.config.RequireRole; +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; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/reservation") +@RequiredArgsConstructor +public class ReservationController { + + private final ReservationService reservationService; + + @PostMapping + @RequireRole("GUEST") + public ResponseEntity create( + @Valid @RequestBody CreateReservationRequest request, + UserContext userContext) { + ReservationResponse response = reservationService.create(request, userContext); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping("/{id}") + @RequireRole({"GUEST", "HOST"}) + public ResponseEntity getById( + @PathVariable UUID id, + UserContext userContext) { + return ResponseEntity.ok(reservationService.getById(id, userContext)); + } + + + @GetMapping("/guest") + @RequireRole("GUEST") + public ResponseEntity> getByGuest(UserContext userContext) { + return ResponseEntity.ok(reservationService.getByGuestId(userContext)); + } + + @GetMapping("/host") + @RequireRole("HOST") + public ResponseEntity> getByHost(UserContext userContext) { + return ResponseEntity.ok(reservationService.getByHostIdWithGuestInfo(userContext)); + } + + + @DeleteMapping("/{id}") + @RequireRole("GUEST") + public ResponseEntity delete( + @PathVariable UUID id, + UserContext userContext) { + 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(); + } + + @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/controller/TestController.java b/src/main/java/com/devoops/reservation/controller/TestController.java new file mode 100644 index 0000000..832914e --- /dev/null +++ b/src/main/java/com/devoops/reservation/controller/TestController.java @@ -0,0 +1,36 @@ +package com.devoops.reservation.controller; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.UUID; + +@RestController +@RequestMapping("api/reservation") +public class TestController { + + private static final Logger logger = LoggerFactory.getLogger(TestController.class); + + @GetMapping("test") + public String test() { + String requestId = UUID.randomUUID().toString(); + MDC.put("requestId", requestId); + + try { + logger.info("Test endpoint called - Reservation Service health check"); + logger.debug("Processing test request with ID: {}", requestId); + + String response = "Reservation Service is up and running!"; + + logger.info("Test endpoint successfully processed request {}", requestId); + return response; + + } finally { + MDC.remove("requestId"); + } + } +} 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..135cb1e --- /dev/null +++ b/src/main/java/com/devoops/reservation/dto/message/ReservationCancelledMessage.java @@ -0,0 +1,15 @@ +package com.devoops.reservation.dto.message; + +import java.time.LocalDate; +import java.util.UUID; + +public record ReservationCancelledMessage( + 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 new file mode 100644 index 0000000..eb6fc17 --- /dev/null +++ b/src/main/java/com/devoops/reservation/dto/message/ReservationCreatedMessage.java @@ -0,0 +1,16 @@ +package com.devoops.reservation.dto.message; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +public record ReservationCreatedMessage( + UUID userId, + String userEmail, + String guestName, + String accommodationName, + LocalDate checkIn, + LocalDate checkOut, + BigDecimal totalPrice +) { +} 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/request/CreateReservationRequest.java b/src/main/java/com/devoops/reservation/dto/request/CreateReservationRequest.java new file mode 100644 index 0000000..e9ec516 --- /dev/null +++ b/src/main/java/com/devoops/reservation/dto/request/CreateReservationRequest.java @@ -0,0 +1,23 @@ +package com.devoops.reservation.dto.request; + +import jakarta.validation.constraints.*; + +import java.time.LocalDate; +import java.util.UUID; + +public record CreateReservationRequest( + @NotNull(message = "Accommodation ID is required") + UUID accommodationId, + + @NotNull(message = "Start date is required") + @FutureOrPresent(message = "Start date must be today or in the future") + LocalDate startDate, + + @NotNull(message = "End date is required") + @Future(message = "End date must be in the future") + LocalDate endDate, + + @NotNull(message = "Guest count is required") + @Min(value = 1, message = "Guest count must be at least 1") + Integer guestCount +) {} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/dto/response/ReservationResponse.java b/src/main/java/com/devoops/reservation/dto/response/ReservationResponse.java new file mode 100644 index 0000000..9af3e75 --- /dev/null +++ b/src/main/java/com/devoops/reservation/dto/response/ReservationResponse.java @@ -0,0 +1,25 @@ +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 ReservationResponse( + UUID id, + UUID accommodationId, + String accommodationName, + UUID guestId, + String guestName, + UUID hostId, + String hostName, + LocalDate startDate, + LocalDate endDate, + int guestCount, + BigDecimal totalPrice, + ReservationStatus status, + LocalDateTime createdAt, + LocalDateTime updatedAt +) {} \ No newline at end of file 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..af72b53 --- /dev/null +++ b/src/main/java/com/devoops/reservation/dto/response/ReservationWithGuestInfoResponse.java @@ -0,0 +1,46 @@ +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, + String accommodationName, + UUID guestId, + String guestName, + UUID hostId, + String hostName, + 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.accommodationName(), + response.guestId(), + response.guestName(), + response.hostId(), + response.hostName(), + response.startDate(), + response.endDate(), + response.guestCount(), + response.totalPrice(), + response.status(), + response.createdAt(), + response.updatedAt(), + cancellationCount + ); + } +} diff --git a/src/main/java/com/devoops/reservation/entity/BaseEntity.java b/src/main/java/com/devoops/reservation/entity/BaseEntity.java new file mode 100644 index 0000000..61be58f --- /dev/null +++ b/src/main/java/com/devoops/reservation/entity/BaseEntity.java @@ -0,0 +1,36 @@ +package com.devoops.reservation.entity; + +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; +import java.util.UUID; + +@MappedSuperclass +@Getter +@Setter +@NoArgsConstructor +@SuperBuilder +public abstract class BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @CreationTimestamp + @Column(updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + private LocalDateTime updatedAt; + + @Builder.Default + @Column(nullable = false) + private boolean isDeleted = false; +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/entity/Reservation.java b/src/main/java/com/devoops/reservation/entity/Reservation.java new file mode 100644 index 0000000..0fca5f5 --- /dev/null +++ b/src/main/java/com/devoops/reservation/entity/Reservation.java @@ -0,0 +1,52 @@ +package com.devoops.reservation.entity; + +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.SQLRestriction; +import org.hibernate.type.SqlTypes; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +@Entity +@Table(name = "reservations") +@SQLRestriction("is_deleted = false") +@Getter +@Setter +@NoArgsConstructor +@SuperBuilder +public class Reservation extends BaseEntity { + + @Column(nullable = false) + private UUID accommodationId; + + @Column(nullable = false) + private UUID guestId; + + @Column(nullable = false) + private UUID hostId; + + @Column(nullable = false) + private LocalDate startDate; + + @Column(nullable = false) + private LocalDate endDate; + + @Column(nullable = false) + private int guestCount; + + @Column(nullable = false, precision = 12, scale = 2) + private BigDecimal totalPrice; + + @Builder.Default + @Enumerated(EnumType.STRING) + @JdbcTypeCode(SqlTypes.NAMED_ENUM) + @Column(nullable = false, columnDefinition = "reservation_status") + private ReservationStatus status = ReservationStatus.PENDING; +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/entity/ReservationStatus.java b/src/main/java/com/devoops/reservation/entity/ReservationStatus.java new file mode 100644 index 0000000..16bb7ca --- /dev/null +++ b/src/main/java/com/devoops/reservation/entity/ReservationStatus.java @@ -0,0 +1,8 @@ +package com.devoops.reservation.entity; + +public enum ReservationStatus { + PENDING, + APPROVED, + REJECTED, + CANCELLED +} \ No newline at end of file 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/ForbiddenException.java b/src/main/java/com/devoops/reservation/exception/ForbiddenException.java new file mode 100644 index 0000000..190e20d --- /dev/null +++ b/src/main/java/com/devoops/reservation/exception/ForbiddenException.java @@ -0,0 +1,7 @@ +package com.devoops.reservation.exception; + +public class ForbiddenException extends RuntimeException { + public ForbiddenException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/exception/GlobalExceptionHandler.java b/src/main/java/com/devoops/reservation/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..c873523 --- /dev/null +++ b/src/main/java/com/devoops/reservation/exception/GlobalExceptionHandler.java @@ -0,0 +1,58 @@ +package com.devoops.reservation.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; +import java.util.stream.Collectors; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(ReservationNotFoundException.class) + 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()); + } + + @ExceptionHandler(ForbiddenException.class) + public ProblemDetail handleForbidden(ForbiddenException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, ex.getMessage()); + } + + @ExceptionHandler(InvalidReservationException.class) + public ProblemDetail handleInvalidReservation(InvalidReservationException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ProblemDetail handleValidation(MethodArgumentNotValidException ex) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed"); + Map fieldErrors = ex.getBindingResult().getFieldErrors().stream() + .collect(Collectors.toMap( + FieldError::getField, + fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid value", + (a, b) -> a + )); + problemDetail.setProperty("fieldErrors", fieldErrors); + return problemDetail; + } + + @ExceptionHandler(IllegalArgumentException.class) + public ProblemDetail handleIllegalArgument(IllegalArgumentException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); + } +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/exception/InvalidReservationException.java b/src/main/java/com/devoops/reservation/exception/InvalidReservationException.java new file mode 100644 index 0000000..e7a55ce --- /dev/null +++ b/src/main/java/com/devoops/reservation/exception/InvalidReservationException.java @@ -0,0 +1,7 @@ +package com.devoops.reservation.exception; + +public class InvalidReservationException extends RuntimeException { + public InvalidReservationException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/exception/ReservationNotFoundException.java b/src/main/java/com/devoops/reservation/exception/ReservationNotFoundException.java new file mode 100644 index 0000000..40e7ec8 --- /dev/null +++ b/src/main/java/com/devoops/reservation/exception/ReservationNotFoundException.java @@ -0,0 +1,7 @@ +package com.devoops.reservation.exception; + +public class ReservationNotFoundException extends RuntimeException { + public ReservationNotFoundException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/exception/UnauthorizedException.java b/src/main/java/com/devoops/reservation/exception/UnauthorizedException.java new file mode 100644 index 0000000..ab26de8 --- /dev/null +++ b/src/main/java/com/devoops/reservation/exception/UnauthorizedException.java @@ -0,0 +1,7 @@ +package com.devoops.reservation.exception; + +public class UnauthorizedException extends RuntimeException { + public UnauthorizedException(String message) { + super(message); + } +} \ No newline at end of file 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..08730b9 --- /dev/null +++ b/src/main/java/com/devoops/reservation/grpc/AccommodationGrpcClient.java @@ -0,0 +1,65 @@ +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, + null + ); + } + + return new AccommodationValidationResult( + true, + null, + null, + UUID.fromString(response.getHostId()), + new BigDecimal(response.getTotalPrice()), + response.getPricingMode(), + 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 new file mode 100644 index 0000000..e783932 --- /dev/null +++ b/src/main/java/com/devoops/reservation/grpc/AccommodationValidationResult.java @@ -0,0 +1,19 @@ +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, + String accommodationName +) { + public boolean isAutoApproval() { + return "AUTOMATIC".equals(approvalMode); + } +} diff --git a/src/main/java/com/devoops/reservation/grpc/ReservationGrpcService.java b/src/main/java/com/devoops/reservation/grpc/ReservationGrpcService.java new file mode 100644 index 0000000..37c24a0 --- /dev/null +++ b/src/main/java/com/devoops/reservation/grpc/ReservationGrpcService.java @@ -0,0 +1,115 @@ +package com.devoops.reservation.grpc; + +import com.devoops.reservation.entity.Reservation; +import com.devoops.reservation.grpc.proto.*; +import com.devoops.reservation.repository.ReservationRepository; +import io.grpc.stub.StreamObserver; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.server.service.GrpcService; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +@GrpcService +@RequiredArgsConstructor +@Slf4j +public class ReservationGrpcService extends ReservationInternalServiceGrpc.ReservationInternalServiceImplBase { + + private final ReservationRepository reservationRepository; + + @Override + public void checkReservationsExist(CheckReservationsExistRequest request, + StreamObserver responseObserver) { + UUID accommodationId = UUID.fromString(request.getAccommodationId()); + LocalDate startDate = LocalDate.parse(request.getStartDate()); + LocalDate endDate = LocalDate.parse(request.getEndDate()); + + log.debug("gRPC: Checking reservations for accommodation {} between {} and {}", + accommodationId, startDate, endDate); + + List overlapping = reservationRepository.findOverlappingApproved( + accommodationId, startDate, endDate); + + CheckReservationsExistResponse response = CheckReservationsExistResponse.newBuilder() + .setHasReservations(!overlapping.isEmpty()) + .build(); + + responseObserver.onNext(response); + responseObserver.onCompleted(); + } + + @Override + public void checkGuestCanBeDeleted(CheckGuestDeletionRequest request, + StreamObserver responseObserver) { + UUID guestId = UUID.fromString(request.getGuestId()); + LocalDate today = LocalDate.now(); + + log.debug("gRPC: Checking if guest {} can be deleted", guestId); + + long activeCount = reservationRepository.countActiveReservationsForGuest(guestId, today); + + CheckDeletionResponse.Builder responseBuilder = CheckDeletionResponse.newBuilder() + .setActiveReservationCount((int) activeCount); + + if (activeCount > 0) { + responseBuilder.setCanBeDeleted(false) + .setReason("Guest has " + activeCount + " active reservation(s)"); + } else { + responseBuilder.setCanBeDeleted(true) + .setReason(""); + } + + responseObserver.onNext(responseBuilder.build()); + responseObserver.onCompleted(); + } + + @Override + public void checkHostCanBeDeleted(CheckHostDeletionRequest request, + StreamObserver responseObserver) { + UUID hostId = UUID.fromString(request.getHostId()); + LocalDate today = LocalDate.now(); + + log.debug("gRPC: Checking if host {} can be deleted", hostId); + + long activeCount = reservationRepository.countActiveReservationsForHost(hostId, today); + + CheckDeletionResponse.Builder responseBuilder = CheckDeletionResponse.newBuilder() + .setActiveReservationCount((int) activeCount); + + if (activeCount > 0) { + responseBuilder.setCanBeDeleted(false) + .setReason("Host has " + activeCount + " active reservation(s) on their accommodations"); + } else { + responseBuilder.setCanBeDeleted(true) + .setReason(""); + } + + responseObserver.onNext(responseBuilder.build()); + responseObserver.onCompleted(); + } + + @Override + public void checkRatingEligibility(CheckRatingEligibilityRequest request, + StreamObserver responseObserver) { + UUID guestId = UUID.fromString(request.getGuestId()); + UUID targetId = UUID.fromString(request.getTargetId()); + LocalDate today = LocalDate.now(); + + log.debug("gRPC: Checking rating eligibility for guest {} targeting {} (type={})", + guestId, targetId, request.getTargetType()); + + long count = "HOST".equals(request.getTargetType()) + ? reservationRepository.countCompletedStaysWithHost(guestId, targetId, today) + : reservationRepository.countCompletedStaysAtAccommodation(guestId, targetId, today); + + CheckRatingEligibilityResponse response = CheckRatingEligibilityResponse.newBuilder() + .setEligible(count > 0) + .setReason(count == 0 ? "No completed past stays found" : "") + .build(); + + responseObserver.onNext(response); + responseObserver.onCompleted(); + } +} 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..0d98471 --- /dev/null +++ b/src/main/java/com/devoops/reservation/grpc/UserGrpcClient.java @@ -0,0 +1,52 @@ +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, + false + ); + } + + return new UserSummaryResult( + true, + UUID.fromString(response.getUserId()), + response.getEmail(), + response.getFirstName(), + response.getLastName(), + response.getRole(), + response.getIsDeleted() + ); + } +} 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..1fdd3a2 --- /dev/null +++ b/src/main/java/com/devoops/reservation/grpc/UserSummaryResult.java @@ -0,0 +1,18 @@ +package com.devoops.reservation.grpc; + +import java.util.UUID; + +public record UserSummaryResult( + boolean found, + UUID userId, + String email, + String firstName, + String lastName, + String role, + boolean isDeleted +) { + public String getFullName() { + String name = firstName + " " + lastName; + return isDeleted ? name + " (Deleted)" : name; + } +} diff --git a/src/main/java/com/devoops/reservation/mapper/ReservationMapper.java b/src/main/java/com/devoops/reservation/mapper/ReservationMapper.java new file mode 100644 index 0000000..e02a8ab --- /dev/null +++ b/src/main/java/com/devoops/reservation/mapper/ReservationMapper.java @@ -0,0 +1,33 @@ +package com.devoops.reservation.mapper; + +import com.devoops.reservation.dto.request.CreateReservationRequest; +import com.devoops.reservation.dto.response.ReservationResponse; +import com.devoops.reservation.entity.Reservation; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface ReservationMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "guestId", ignore = true) + @Mapping(target = "hostId", ignore = true) + @Mapping(target = "totalPrice", ignore = true) + @Mapping(target = "status", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "isDeleted", ignore = true) + Reservation toEntity(CreateReservationRequest request); + + @Mapping(target = "accommodationName", ignore = true) + @Mapping(target = "guestName", ignore = true) + @Mapping(target = "hostName", ignore = true) + ReservationResponse toResponse(Reservation reservation); + + @Mapping(target = "accommodationName", source = "accommodationName") + @Mapping(target = "guestName", source = "guestName") + @Mapping(target = "hostName", source = "hostName") + ReservationResponse toResponseWithNames(Reservation reservation, String accommodationName, String guestName, String hostName); +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/repository/ReservationRepository.java b/src/main/java/com/devoops/reservation/repository/ReservationRepository.java new file mode 100644 index 0000000..f27e129 --- /dev/null +++ b/src/main/java/com/devoops/reservation/repository/ReservationRepository.java @@ -0,0 +1,128 @@ +package com.devoops.reservation.repository; + +import com.devoops.reservation.entity.Reservation; +import com.devoops.reservation.entity.ReservationStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +public interface ReservationRepository extends JpaRepository { + + List findByGuestId(UUID guestId); + + List findByHostId(UUID hostId); + + List findByAccommodationId(UUID accommodationId); + + List findByAccommodationIdAndStatus(UUID accommodationId, ReservationStatus status); + + /** + * Find approved reservations that overlap with the given date range. + * Used to check if dates are available for a new reservation. + */ + @Query(""" + SELECT r FROM Reservation r + WHERE r.accommodationId = :accommodationId + AND r.status = 'APPROVED' + AND r.startDate < :endDate + AND r.endDate > :startDate + """) + List findOverlappingApproved( + @Param("accommodationId") UUID accommodationId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); + + /** + * Find all pending reservations that overlap with the given date range. + * Used when approving a reservation to reject overlapping pending requests. + */ + @Query(""" + SELECT r FROM Reservation r + WHERE r.accommodationId = :accommodationId + AND r.status = 'PENDING' + AND r.id != :excludeId + AND r.startDate < :endDate + AND r.endDate > :startDate + """) + List findOverlappingPending( + @Param("accommodationId") UUID accommodationId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("excludeId") UUID excludeId + ); + + /** + * Count cancelled reservations for a guest. + * Used by hosts when reviewing reservation requests. + */ + long countByGuestIdAndStatus(UUID guestId, ReservationStatus status); + + /** + * Count active reservations for a guest (PENDING or APPROVED with endDate >= today). + * Used to check if guest account can be deleted. + */ + @Query(""" + SELECT COUNT(r) FROM Reservation r + WHERE r.guestId = :guestId + AND r.status IN ('PENDING', 'APPROVED') + AND r.endDate >= :today + """) + long countActiveReservationsForGuest( + @Param("guestId") UUID guestId, + @Param("today") LocalDate today + ); + + /** + * Count active reservations for a host (PENDING or APPROVED with endDate >= today). + * Used to check if host account can be deleted. + */ + @Query(""" + SELECT COUNT(r) FROM Reservation r + WHERE r.hostId = :hostId + AND r.status IN ('PENDING', 'APPROVED') + AND r.endDate >= :today + """) + long countActiveReservationsForHost( + @Param("hostId") UUID hostId, + @Param("today") LocalDate today + ); + + /** + * Count completed stays (APPROVED, end date in past) for a guest at a specific accommodation. + * Used for rating eligibility checks. + */ + @Query(""" + SELECT COUNT(r) FROM Reservation r + WHERE r.guestId = :guestId + AND r.accommodationId = :accommodationId + AND r.status = 'APPROVED' + AND r.endDate < :today + """) + long countCompletedStaysAtAccommodation( + @Param("guestId") UUID guestId, + @Param("accommodationId") UUID accommodationId, + @Param("today") LocalDate today + ); + + /** + * Count completed stays (APPROVED, end date in past) for a guest with a specific host. + * Used for rating eligibility checks. + */ + @Query(""" + SELECT COUNT(r) FROM Reservation r + WHERE r.guestId = :guestId + AND r.hostId = :hostId + AND r.status = 'APPROVED' + AND r.endDate < :today + """) + long countCompletedStaysWithHost( + @Param("guestId") UUID guestId, + @Param("hostId") UUID hostId, + @Param("today") LocalDate today + ); +} 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..f2d6d43 --- /dev/null +++ b/src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java @@ -0,0 +1,128 @@ +package com.devoops.reservation.service; + +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; +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; + private final UserGrpcClient userGrpcClient; + + @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; + + @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()); + + 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.getHostId(), + hostSummary.email(), + guestSummary.getFullName(), + accommodationName, + reservation.getStartDate(), + reservation.getEndDate(), + reservation.getTotalPrice() + ); + + 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, 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.getHostId(), + hostSummary.email(), + guestSummary.getFullName(), + accommodationName, + reservation.getStartDate(), + reservation.getEndDate(), + "Guest cancelled the reservation" + ); + + log.info("Publishing reservation cancelled event: reservationId={}, hostEmail={}, guestName={}", + reservation.getId(), hostSummary.email(), guestSummary.getFullName()); + + 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 new file mode 100644 index 0000000..8283f1b --- /dev/null +++ b/src/main/java/com/devoops/reservation/service/ReservationService.java @@ -0,0 +1,304 @@ +package com.devoops.reservation.service; + +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; +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.grpc.UserGrpcClient; +import com.devoops.reservation.grpc.UserSummaryResult; +import com.devoops.reservation.mapper.ReservationMapper; +import com.devoops.reservation.repository.ReservationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ReservationService { + + private final ReservationRepository reservationRepository; + private final ReservationMapper reservationMapper; + private final AccommodationGrpcClient accommodationGrpcClient; + private final UserGrpcClient userGrpcClient; + private final ReservationEventPublisherService eventPublisher; + + @Transactional + public ReservationResponse create(CreateReservationRequest request, UserContext userContext) { + // Validate dates + validateDates(request.startDate(), request.endDate()); + + // 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( + request.accommodationId(), + request.startDate(), + request.endDate() + ); + + if (!overlapping.isEmpty()) { + throw new InvalidReservationException( + "The selected dates overlap with an existing approved reservation" + ); + } + + Reservation reservation = reservationMapper.toEntity(request); + reservation.setGuestId(userContext.userId()); + reservation.setHostId(hostId); + 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()); + + eventPublisher.publishReservationCreated(reservation, validationResult.accommodationName()); + + return toResponseWithNames(reservation); + } + + @Transactional(readOnly = true) + public ReservationResponse getById(UUID id, UserContext userContext) { + Reservation reservation = findReservationOrThrow(id); + validateAccessToReservation(reservation, userContext); + return toResponseWithNames(reservation); + } + + @Transactional(readOnly = true) + public List getByGuestId(UserContext userContext) { + List reservations = reservationRepository.findByGuestId(userContext.userId()); + return toResponseListWithNames(reservations); + } + + @Transactional(readOnly = true) + public List getByHostId(UserContext userContext) { + List reservations = reservationRepository.findByHostId(userContext.userId()); + return toResponseListWithNames(reservations); + } + + @Transactional(readOnly = true) + public List getByHostIdWithGuestInfo(UserContext userContext) { + List reservations = reservationRepository.findByHostId(userContext.userId()); + return reservations.stream() + .map(reservation -> { + ReservationResponse response = toResponseWithNames(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); + + // Only the guest who created the reservation can delete it + if (!reservation.getGuestId().equals(userContext.userId())) { + throw new ForbiddenException("You can only delete your own reservation requests"); + } + + // Can only delete PENDING requests + if (reservation.getStatus() != ReservationStatus.PENDING) { + throw new InvalidReservationException( + "Only pending reservation requests can be deleted. Current status: " + reservation.getStatus() + ); + } + + 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"); + } + + // 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); + + 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.getId() + ); + + 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 toResponseWithNames(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 toResponseWithNames(reservation); + } + + // === Helper Methods === + + private Reservation findReservationOrThrow(UUID id) { + return reservationRepository.findById(id) + .orElseThrow(() -> new ReservationNotFoundException( + "Reservation not found with id: " + id)); + } + + private void validateDates(LocalDate startDate, LocalDate endDate) { + if (!endDate.isAfter(startDate)) { + throw new InvalidReservationException("End date must be after start date"); + } + } + + private void validateAccessToReservation(Reservation reservation, UserContext userContext) { + boolean isGuest = reservation.getGuestId().equals(userContext.userId()); + boolean isHost = reservation.getHostId().equals(userContext.userId()); + + if (!isGuest && !isHost) { + throw new ForbiddenException("You do not have access to this reservation"); + } + } + + private String fetchAccommodationName(Reservation reservation) { + AccommodationValidationResult accommodationInfo = accommodationGrpcClient.validateAndCalculatePrice( + reservation.getAccommodationId(), + reservation.getStartDate(), + reservation.getEndDate(), + reservation.getGuestCount() + ); + return accommodationInfo.valid() ? accommodationInfo.accommodationName() : "Unknown Accommodation"; + } + + private String fetchUserName(UUID userId) { + UserSummaryResult userSummary = userGrpcClient.getUserSummary(userId); + return userSummary.found() ? userSummary.getFullName() : "Unknown User"; + } + + private ReservationResponse toResponseWithNames(Reservation reservation) { + String accommodationName = fetchAccommodationName(reservation); + String guestName = fetchUserName(reservation.getGuestId()); + String hostName = fetchUserName(reservation.getHostId()); + return reservationMapper.toResponseWithNames(reservation, accommodationName, guestName, hostName); + } + + private List toResponseListWithNames(List reservations) { + return reservations.stream() + .map(this::toResponseWithNames) + .toList(); + } +} diff --git a/src/main/proto/accommodation_internal.proto b/src/main/proto/accommodation_internal.proto new file mode 100644 index 0000000..0489750 --- /dev/null +++ b/src/main/proto/accommodation_internal.proto @@ -0,0 +1,27 @@ +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; + string accommodation_name = 8; +} diff --git a/src/main/proto/reservation_internal.proto b/src/main/proto/reservation_internal.proto new file mode 100644 index 0000000..334b35f --- /dev/null +++ b/src/main/proto/reservation_internal.proto @@ -0,0 +1,48 @@ +syntax = "proto3"; + +package reservation; + +option java_multiple_files = true; +option java_package = "com.devoops.reservation.grpc.proto"; + +service ReservationInternalService { + rpc CheckReservationsExist(CheckReservationsExistRequest) returns (CheckReservationsExistResponse); + rpc CheckGuestCanBeDeleted(CheckGuestDeletionRequest) returns (CheckDeletionResponse); + rpc CheckHostCanBeDeleted(CheckHostDeletionRequest) returns (CheckDeletionResponse); + rpc CheckRatingEligibility(CheckRatingEligibilityRequest) returns (CheckRatingEligibilityResponse); +} + +message CheckReservationsExistRequest { + string accommodation_id = 1; + string start_date = 2; + string end_date = 3; +} + +message CheckReservationsExistResponse { + bool has_reservations = 1; +} + +message CheckGuestDeletionRequest { + string guest_id = 1; +} + +message CheckHostDeletionRequest { + string host_id = 1; +} + +message CheckDeletionResponse { + bool can_be_deleted = 1; + string reason = 2; + int32 active_reservation_count = 3; +} + +message CheckRatingEligibilityRequest { + string guest_id = 1; + string target_id = 2; + string target_type = 3; +} + +message CheckRatingEligibilityResponse { + bool eligible = 1; + string reason = 2; +} diff --git a/src/main/proto/user_internal.proto b/src/main/proto/user_internal.proto new file mode 100644 index 0000000..e826a96 --- /dev/null +++ b/src/main/proto/user_internal.proto @@ -0,0 +1,23 @@ +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; + bool is_deleted = 7; +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 70b4561..be18197 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,54 @@ spring.application.name=reservation +server.port=${SERVER_PORT:8080} + +# Logging configuration +logging.logstash.host=${LOGSTASH_HOST:localhost:5000} +logging.level.root=INFO +logging.level.com.devoops=DEBUG +logging.level.org.springframework.web=INFO + +# Database +spring.datasource.url=jdbc:postgresql://${POSTGRES_HOST:devoops-postgres}:${POSTGRES_PORT:5432}/reservation_db +spring.datasource.username=${DB_USERNAME:reservation-service} +spring.datasource.password=${DB_PASSWORD:reservation-service-pass} +spring.datasource.driver-class-name=org.postgresql.Driver + +# JPA +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect + +# Flyway +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration + +# Tracing configuration +management.tracing.sampling.probability=1.0 +management.tracing.export.zipkin.endpoint=http://${ZIPKIN_HOST:zipkin}:${ZIPKIN_PORT:9411}/api/v2/spans + +# Actuator endpoints for Prometheus metrics +management.endpoints.web.exposure.include=health,info,prometheus +management.endpoint.health.show-details=always +management.prometheus.metrics.export.enabled=true + +# gRPC Server +grpc.server.port=${GRPC_PORT:9090} + +# 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} +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 +rabbitmq.routing-key.reservation-response=notification.reservation.response diff --git a/src/main/resources/db/migration/V1__init_schema.sql b/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 0000000..c38afdf --- /dev/null +++ b/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,30 @@ +-- PostgreSQL named enum for reservation status +CREATE TYPE reservation_status AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED'); + +-- Reservations table +CREATE TABLE reservations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + accommodation_id UUID NOT NULL, + guest_id UUID NOT NULL, + host_id UUID NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + guest_count INTEGER NOT NULL, + total_price NUMERIC(12, 2) NOT NULL, + status reservation_status NOT NULL DEFAULT 'PENDING', + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +-- Indexes for common queries +CREATE INDEX idx_reservations_accommodation_id ON reservations(accommodation_id); +CREATE INDEX idx_reservations_guest_id ON reservations(guest_id); +CREATE INDEX idx_reservations_host_id ON reservations(host_id); +CREATE INDEX idx_reservations_status ON reservations(status); +CREATE INDEX idx_reservations_dates ON reservations(start_date, end_date); + +-- Composite index for overlap queries (only non-deleted reservations) +CREATE INDEX idx_reservations_accommodation_dates + ON reservations(accommodation_id, start_date, end_date) + WHERE is_deleted = false; \ No newline at end of file diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..80d60db --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,75 @@ + + + + + + + + + {"service.name":"${applicationName}","host.name":"${HOSTNAME:-unknown}"} + + @timestamp + message + logger + thread + level + + + + + + + ${logstashHost} + + {"service.name":"${applicationName}","host.name":"${HOSTNAME:-unknown}"} + + @timestamp + message + logger + thread + level + [ignore] + + requestId + userId + + + + + + + 512 + 0 + false + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/java/com/devoops/reservation/ReservationApplicationTests.java b/src/test/java/com/devoops/reservation/ReservationApplicationTests.java index 2eb492f..779c325 100644 --- a/src/test/java/com/devoops/reservation/ReservationApplicationTests.java +++ b/src/test/java/com/devoops/reservation/ReservationApplicationTests.java @@ -2,10 +2,34 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; @SpringBootTest +@Testcontainers +@ActiveProfiles("test") class ReservationApplicationTests { + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("reservation_db_test") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + registry.add("spring.flyway.url", postgres::getJdbcUrl); + registry.add("spring.flyway.user", postgres::getUsername); + registry.add("spring.flyway.password", postgres::getPassword); + } + @Test void contextLoads() { } diff --git a/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java b/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java new file mode 100644 index 0000000..7458894 --- /dev/null +++ b/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java @@ -0,0 +1,578 @@ +package com.devoops.reservation.controller; + +import com.devoops.reservation.config.RoleAuthorizationInterceptor; +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; +import com.devoops.reservation.exception.InvalidReservationException; +import com.devoops.reservation.exception.ReservationNotFoundException; +import com.devoops.reservation.service.ReservationService; +import com.fasterxml.jackson.databind.ObjectMapper; +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class ReservationControllerTest { + + private MockMvc mockMvc; + + @Mock + private ReservationService reservationService; + + @InjectMocks + private ReservationController reservationController; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final UUID GUEST_ID = UUID.randomUUID(); + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID ACCOMMODATION_ID = UUID.randomUUID(); + private static final UUID RESERVATION_ID = UUID.randomUUID(); + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(reservationController) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new UserContextResolver()) + .addInterceptors(new RoleAuthorizationInterceptor()) + .build(); + } + + private ReservationResponse createResponse() { + return new ReservationResponse( + RESERVATION_ID, ACCOMMODATION_ID, "Test Accommodation", + GUEST_ID, "John Doe", HOST_ID, "Jane Host", + LocalDate.now().plusDays(10), LocalDate.now().plusDays(15), + 2, new BigDecimal("1000.00"), ReservationStatus.PENDING, + LocalDateTime.now(), LocalDateTime.now() + ); + } + + private ReservationResponse createApprovedResponse() { + return new ReservationResponse( + RESERVATION_ID, ACCOMMODATION_ID, "Test Accommodation", + GUEST_ID, "John Doe", HOST_ID, "Jane Host", + 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, "Test Accommodation", + GUEST_ID, "John Doe", HOST_ID, "Jane Host", + 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(), + "startDate", LocalDate.now().plusDays(10).toString(), + "endDate", LocalDate.now().plusDays(15).toString(), + "guestCount", 2 + ); + } + + @Nested + @DisplayName("POST /api/reservation") + class CreateEndpoint { + + @Test + @DisplayName("With valid request returns 201") + void create_WithValidRequest_Returns201() throws Exception { + when(reservationService.create(any(), any(UserContext.class))) + .thenReturn(createResponse()); + + mockMvc.perform(post("/api/reservation") + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(RESERVATION_ID.toString())) + .andExpect(jsonPath("$.status").value("PENDING")); + } + + @Test + @DisplayName("With missing auth headers returns 401") + void create_WithMissingAuthHeaders_Returns401() throws Exception { + mockMvc.perform(post("/api/reservation") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("With HOST role returns 403") + void create_WithHostRole_Returns403() throws Exception { + mockMvc.perform(post("/api/reservation") + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With missing accommodationId returns 400") + void create_WithMissingAccommodationId_Returns400() throws Exception { + var request = Map.of( + "startDate", LocalDate.now().plusDays(10).toString(), + "endDate", LocalDate.now().plusDays(15).toString(), + "guestCount", 2 + ); + + mockMvc.perform(post("/api/reservation") + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("With invalid guest count returns 400") + void create_WithInvalidGuestCount_Returns400() throws Exception { + var request = Map.of( + "accommodationId", ACCOMMODATION_ID.toString(), + "startDate", LocalDate.now().plusDays(10).toString(), + "endDate", LocalDate.now().plusDays(15).toString(), + "guestCount", 0 + ); + + mockMvc.perform(post("/api/reservation") + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("With overlapping reservation returns 400") + void create_WithOverlappingReservation_Returns400() throws Exception { + when(reservationService.create(any(), any(UserContext.class))) + .thenThrow(new InvalidReservationException("overlap with an existing approved reservation")); + + mockMvc.perform(post("/api/reservation") + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("GET /api/reservation/{id}") + class GetByIdEndpoint { + + @Test + @DisplayName("With existing ID and guest role returns 200") + void getById_WithExistingIdAndGuestRole_Returns200() throws Exception { + when(reservationService.getById(eq(RESERVATION_ID), any(UserContext.class))) + .thenReturn(createResponse()); + + mockMvc.perform(get("/api/reservation/{id}", RESERVATION_ID) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(RESERVATION_ID.toString())); + } + + @Test + @DisplayName("With existing ID and host role returns 200") + void getById_WithExistingIdAndHostRole_Returns200() throws Exception { + when(reservationService.getById(eq(RESERVATION_ID), any(UserContext.class))) + .thenReturn(createResponse()); + + mockMvc.perform(get("/api/reservation/{id}", RESERVATION_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(RESERVATION_ID.toString())); + } + + @Test + @DisplayName("With non-existing ID returns 404") + void getById_WithNonExistingId_Returns404() throws Exception { + UUID id = UUID.randomUUID(); + when(reservationService.getById(eq(id), any(UserContext.class))) + .thenThrow(new ReservationNotFoundException("Not found")); + + mockMvc.perform(get("/api/reservation/{id}", id) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("With unauthorized user returns 403") + void getById_WithUnauthorizedUser_Returns403() throws Exception { + when(reservationService.getById(eq(RESERVATION_ID), any(UserContext.class))) + .thenThrow(new ForbiddenException("Access denied")); + + mockMvc.perform(get("/api/reservation/{id}", RESERVATION_ID) + .header("X-User-Id", UUID.randomUUID().toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + } + + @Nested + @DisplayName("GET /api/reservation/guest") + class GetByGuestEndpoint { + + @Test + @DisplayName("Returns 200 with list") + void getByGuest_Returns200WithList() throws Exception { + when(reservationService.getByGuestId(any(UserContext.class))) + .thenReturn(List.of(createResponse())); + + mockMvc.perform(get("/api/reservation/guest") + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(RESERVATION_ID.toString())); + } + + @Test + @DisplayName("With HOST role returns 403") + void getByGuest_WithHostRole_Returns403() throws Exception { + mockMvc.perform(get("/api/reservation/guest") + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isForbidden()); + } + } + + @Nested + @DisplayName("GET /api/reservation/host") + class GetByHostEndpoint { + + @Test + @DisplayName("Returns 200 with list including guest cancellation count") + void getByHost_Returns200WithList() throws Exception { + 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].guestCancellationCount").value(2)); + } + + @Test + @DisplayName("With GUEST role returns 403") + void getByHost_WithGuestRole_Returns403() throws Exception { + mockMvc.perform(get("/api/reservation/host") + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + } + + @Nested + @DisplayName("DELETE /api/reservation/{id}") + class DeleteEndpoint { + + @Test + @DisplayName("With valid request returns 204") + void delete_WithValidRequest_Returns204() throws Exception { + doNothing().when(reservationService).deleteRequest(eq(RESERVATION_ID), any(UserContext.class)); + + mockMvc.perform(delete("/api/reservation/{id}", 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 delete_WithNonExistingId_Returns404() throws Exception { + UUID id = UUID.randomUUID(); + doThrow(new ReservationNotFoundException("Not found")) + .when(reservationService).deleteRequest(eq(id), any(UserContext.class)); + + mockMvc.perform(delete("/api/reservation/{id}", id) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("With wrong owner returns 403") + void delete_WithWrongOwner_Returns403() throws Exception { + doThrow(new ForbiddenException("Not the owner")) + .when(reservationService).deleteRequest(eq(RESERVATION_ID), any(UserContext.class)); + + mockMvc.perform(delete("/api/reservation/{id}", RESERVATION_ID) + .header("X-User-Id", UUID.randomUUID().toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With non-pending status returns 400") + void delete_WithNonPendingStatus_Returns400() throws Exception { + doThrow(new InvalidReservationException("Only pending requests can be deleted")) + .when(reservationService).deleteRequest(eq(RESERVATION_ID), any(UserContext.class)); + + mockMvc.perform(delete("/api/reservation/{id}", 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 delete_WithHostRole_Returns403() throws Exception { + mockMvc.perform(delete("/api/reservation/{id}", RESERVATION_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .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()); + } + } + + @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/integration/ReservationIntegrationTest.java b/src/test/java/com/devoops/reservation/integration/ReservationIntegrationTest.java new file mode 100644 index 0000000..fec4f3a --- /dev/null +++ b/src/test/java/com/devoops/reservation/integration/ReservationIntegrationTest.java @@ -0,0 +1,374 @@ +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; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +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.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Testcontainers +@ActiveProfiles("test") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class ReservationIntegrationTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("reservation_db_test") + .withUsername("test") + .withPassword("test"); + + @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; + private static final UUID GUEST_ID = UUID.randomUUID(); + private static final UUID OTHER_GUEST_ID = UUID.randomUUID(); + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID ACCOMMODATION_ID = UUID.randomUUID(); + + 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", + false + ); + when(userGrpcClient.getUserSummary(any(UUID.class))) + .thenReturn(hostSummary); + } + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + registry.add("spring.flyway.url", postgres::getJdbcUrl); + registry.add("spring.flyway.user", postgres::getUsername); + registry.add("spring.flyway.password", postgres::getPassword); + } + + private Map validCreateRequest() { + return Map.of( + "accommodationId", ACCOMMODATION_ID.toString(), + "startDate", LocalDate.now().plusDays(10).toString(), + "endDate", LocalDate.now().plusDays(15).toString(), + "guestCount", 2 + ); + } + + private Map createRequestWithDates(LocalDate start, LocalDate end) { + return Map.of( + "accommodationId", ACCOMMODATION_ID.toString(), + "startDate", start.toString(), + "endDate", end.toString(), + "guestCount", 2 + ); + } + + @Test + @Order(1) + @DisplayName("Create reservation with valid request returns 201") + void create_WithValidRequest_Returns201WithResponse() throws Exception { + MvcResult result = mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accommodationId").value(ACCOMMODATION_ID.toString())) + .andExpect(jsonPath("$.guestId").value(GUEST_ID.toString())) + .andExpect(jsonPath("$.guestCount").value(2)) + .andExpect(jsonPath("$.status").value("PENDING")) + .andExpect(jsonPath("$.id").isNotEmpty()) + .andExpect(jsonPath("$.totalPrice").isNotEmpty()) + .andReturn(); + + reservationId = objectMapper.readTree(result.getResponse().getContentAsString()) + .get("id").asText(); + } + + @Test + @Order(2) + @DisplayName("Create another pending reservation for same dates is allowed") + void create_WithOverlappingPendingReservation_Returns201() throws Exception { + // Multiple pending reservations for overlapping dates should be allowed + mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", OTHER_GUEST_ID.toString()) + .header("X-User-Role", "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value("PENDING")); + } + + @Test + @Order(3) + @DisplayName("Create reservation with missing accommodationId returns 400") + void create_WithMissingAccommodationId_Returns400() throws Exception { + var request = Map.of( + "startDate", LocalDate.now().plusDays(10).toString(), + "endDate", LocalDate.now().plusDays(15).toString(), + "guestCount", 2 + ); + + mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @Order(4) + @DisplayName("Create reservation with end date before start date returns 400") + void create_WithEndDateBeforeStartDate_Returns400() throws Exception { + var request = createRequestWithDates( + LocalDate.now().plusDays(15), + LocalDate.now().plusDays(10) + ); + + mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @Order(5) + @DisplayName("Create reservation with invalid guest count returns 400") + void create_WithInvalidGuestCount_Returns400() throws Exception { + var request = Map.of( + "accommodationId", ACCOMMODATION_ID.toString(), + "startDate", LocalDate.now().plusDays(10).toString(), + "endDate", LocalDate.now().plusDays(15).toString(), + "guestCount", 0 + ); + + mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @Order(6) + @DisplayName("Create reservation without auth headers returns 401") + void create_WithoutAuthHeaders_Returns401() throws Exception { + mockMvc.perform(post(BASE_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isUnauthorized()); + } + + @Test + @Order(7) + @DisplayName("Create reservation with HOST role returns 403") + void create_WithHostRole_Returns403() throws Exception { + mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isForbidden()); + } + + @Test + @Order(8) + @DisplayName("Get by ID with existing ID and guest role returns 200") + void getById_WithExistingIdAndGuestRole_Returns200() throws Exception { + mockMvc.perform(get(BASE_PATH + "/" + reservationId) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(reservationId)) + .andExpect(jsonPath("$.guestId").value(GUEST_ID.toString())); + } + + @Test + @Order(9) + @DisplayName("Get by ID with non-existing ID returns 404") + void getById_WithNonExistingId_Returns404() throws Exception { + mockMvc.perform(get(BASE_PATH + "/" + UUID.randomUUID()) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isNotFound()); + } + + @Test + @Order(10) + @DisplayName("Get by ID with unauthorized user returns 403") + void getById_WithUnauthorizedUser_Returns403() throws Exception { + mockMvc.perform(get(BASE_PATH + "/" + reservationId) + .header("X-User-Id", UUID.randomUUID().toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + + @Test + @Order(11) + @DisplayName("Get by guest returns list of reservations") + void getByGuest_ReturnsListOfReservations() throws Exception { + mockMvc.perform(get(BASE_PATH + "/guest") + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(greaterThanOrEqualTo(1)))); + } + + @Test + @Order(12) + @DisplayName("Get by guest with HOST role returns 403") + void getByGuest_WithHostRole_Returns403() throws Exception { + mockMvc.perform(get(BASE_PATH + "/guest") + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isForbidden()); + } + + @Test + @Order(13) + @DisplayName("Get by host returns list of reservations") + void getByHost_ReturnsListOfReservations() throws Exception { + // Create a reservation where we know the hostId (from placeholder in service) + // The service sets a random hostId, so we get the host from the reservation + MvcResult result = mockMvc.perform(get(BASE_PATH + "/" + reservationId) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isOk()) + .andReturn(); + + String hostId = objectMapper.readTree(result.getResponse().getContentAsString()) + .get("hostId").asText(); + + mockMvc.perform(get(BASE_PATH + "/host") + .header("X-User-Id", hostId) + .header("X-User-Role", "HOST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(greaterThanOrEqualTo(1)))); + } + + @Test + @Order(14) + @DisplayName("Get by host with GUEST role returns 403") + void getByHost_WithGuestRole_Returns403() throws Exception { + mockMvc.perform(get(BASE_PATH + "/host") + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + + @Test + @Order(15) + @DisplayName("Delete reservation with different guest returns 403") + void delete_WithDifferentGuest_Returns403() throws Exception { + mockMvc.perform(delete(BASE_PATH + "/" + reservationId) + .header("X-User-Id", OTHER_GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + + @Test + @Order(16) + @DisplayName("Delete reservation with HOST role returns 403") + void delete_WithHostRole_Returns403() throws Exception { + mockMvc.perform(delete(BASE_PATH + "/" + reservationId) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isForbidden()); + } + + @Test + @Order(17) + @DisplayName("Delete pending reservation with valid owner returns 204") + void delete_WithValidOwner_Returns204() throws Exception { + mockMvc.perform(delete(BASE_PATH + "/" + reservationId) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isNoContent()); + } + + @Test + @Order(18) + @DisplayName("After delete, get by ID returns 404 (soft-delete filters)") + void delete_ThenGetById_Returns404() throws Exception { + mockMvc.perform(get(BASE_PATH + "/" + reservationId) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isNotFound()); + } + + @Test + @Order(19) + @DisplayName("Delete with non-existing ID returns 404") + void delete_WithNonExistingId_Returns404() throws Exception { + mockMvc.perform(delete(BASE_PATH + "/" + UUID.randomUUID()) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isNotFound()); + } +} 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..a1a203b --- /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", false); + } + + private UserSummaryResult createHostSummary() { + return new UserSummaryResult(true, HOST_ID, "host@example.com", "Jane", "Smith", "HOST", false); + } + + @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, false); + + 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, false); + + 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 new file mode 100644 index 0000000..315ca2d --- /dev/null +++ b/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java @@ -0,0 +1,742 @@ +package com.devoops.reservation.service; + +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; +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.grpc.UserGrpcClient; +import com.devoops.reservation.grpc.UserSummaryResult; +import com.devoops.reservation.mapper.ReservationMapper; +import com.devoops.reservation.repository.ReservationRepository; +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.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ReservationServiceTest { + + @Mock + private ReservationRepository reservationRepository; + + @Mock + private ReservationMapper reservationMapper; + + @Mock + private AccommodationGrpcClient accommodationGrpcClient; + + @Mock + private UserGrpcClient userGrpcClient; + + @Mock + private ReservationEventPublisherService eventPublisher; + + @InjectMocks + private ReservationService reservationService; + + private static final UUID GUEST_ID = UUID.randomUUID(); + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID ACCOMMODATION_ID = UUID.randomUUID(); + private static final UUID RESERVATION_ID = UUID.randomUUID(); + private static final UserContext GUEST_CONTEXT = new UserContext(GUEST_ID, "GUEST"); + private static final UserContext HOST_CONTEXT = new UserContext(HOST_ID, "HOST"); + + private Reservation createReservation() { + return Reservation.builder() + .id(RESERVATION_ID) + .accommodationId(ACCOMMODATION_ID) + .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 ReservationResponse createResponse() { + return new ReservationResponse( + RESERVATION_ID, ACCOMMODATION_ID, "Test Accommodation", + GUEST_ID, "John Doe", HOST_ID, "Jane Host", + LocalDate.now().plusDays(10), LocalDate.now().plusDays(15), + 2, new BigDecimal("1000.00"), ReservationStatus.PENDING, + LocalDateTime.now(), LocalDateTime.now() + ); + } + + private void setupUserMocks() { + UserSummaryResult guestSummary = new UserSummaryResult(true, GUEST_ID, "guest@test.com", "John", "Doe", "GUEST", false); + UserSummaryResult hostSummary = new UserSummaryResult(true, HOST_ID, "host@test.com", "Jane", "Host", "HOST", false); + when(userGrpcClient.getUserSummary(GUEST_ID)).thenReturn(guestSummary); + when(userGrpcClient.getUserSummary(HOST_ID)).thenReturn(hostSummary); + } + + private CreateReservationRequest createRequest() { + return new CreateReservationRequest( + ACCOMMODATION_ID, + LocalDate.now().plusDays(10), + LocalDate.now().plusDays(15), + 2 + ); + } + + @Nested + @DisplayName("Create") + class CreateTests { + + @Test + @DisplayName("With valid request returns reservation response") + 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", "Test Accommodation" + ); + + 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); + setupUserMocks(); + when(reservationMapper.toResponseWithNames(eq(reservation), anyString(), anyString(), anyString())).thenReturn(response); + + ReservationResponse result = reservationService.create(request, GUEST_CONTEXT); + + assertThat(result).isEqualTo(response); + assertThat(reservation.getGuestId()).isEqualTo(GUEST_ID); + assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.PENDING); + verify(reservationRepository).saveAndFlush(reservation); + verify(eventPublisher).publishReservationCreated(reservation, "Test Accommodation"); + } + + @Test + @DisplayName("With overlapping approved reservation throws InvalidReservationException") + 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", "Test Accommodation" + ); + + when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) + .thenReturn(validationResult); + when(reservationRepository.findOverlappingApproved(any(), any(), any())) + .thenReturn(List.of(existingReservation)); + + assertThatThrownBy(() -> reservationService.create(request, GUEST_CONTEXT)) + .isInstanceOf(InvalidReservationException.class) + .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", "Test Accommodation" + ); + + 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); + setupUserMocks(); + when(reservationMapper.toResponseWithNames(eq(reservation), anyString(), anyString(), anyString())).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, 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, 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() { + var request = new CreateReservationRequest( + ACCOMMODATION_ID, + LocalDate.now().plusDays(15), + LocalDate.now().plusDays(10), + 2 + ); + + assertThatThrownBy(() -> reservationService.create(request, GUEST_CONTEXT)) + .isInstanceOf(InvalidReservationException.class) + .hasMessageContaining("End date must be after start date"); + } + + @Test + @DisplayName("With same start and end date throws InvalidReservationException") + void create_WithSameStartAndEndDate_ThrowsInvalidReservationException() { + LocalDate sameDate = LocalDate.now().plusDays(10); + var request = new CreateReservationRequest( + ACCOMMODATION_ID, + sameDate, + sameDate, + 2 + ); + + assertThatThrownBy(() -> reservationService.create(request, GUEST_CONTEXT)) + .isInstanceOf(InvalidReservationException.class) + .hasMessageContaining("End date must be after start date"); + } + } + + @Nested + @DisplayName("GetById") + class GetByIdTests { + + @Test + @DisplayName("With existing ID and guest access returns reservation response") + void getById_WithExistingIdAndGuestAccess_ReturnsReservationResponse() { + var reservation = createReservation(); + var response = createResponse(); + var validationResult = 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(validationResult); + setupUserMocks(); + when(reservationMapper.toResponseWithNames(eq(reservation), anyString(), anyString(), anyString())).thenReturn(response); + + ReservationResponse result = reservationService.getById(RESERVATION_ID, GUEST_CONTEXT); + + assertThat(result).isEqualTo(response); + } + + @Test + @DisplayName("With existing ID and host access returns reservation response") + void getById_WithExistingIdAndHostAccess_ReturnsReservationResponse() { + var reservation = createReservation(); + var response = createResponse(); + var validationResult = 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(validationResult); + setupUserMocks(); + when(reservationMapper.toResponseWithNames(eq(reservation), anyString(), anyString(), anyString())).thenReturn(response); + + ReservationResponse result = reservationService.getById(RESERVATION_ID, HOST_CONTEXT); + + assertThat(result).isEqualTo(response); + } + + @Test + @DisplayName("With non-existing ID throws ReservationNotFoundException") + void getById_WithNonExistingId_ThrowsReservationNotFoundException() { + UUID id = UUID.randomUUID(); + when(reservationRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> reservationService.getById(id, GUEST_CONTEXT)) + .isInstanceOf(ReservationNotFoundException.class); + } + + @Test + @DisplayName("With unauthorized user throws ForbiddenException") + void getById_WithUnauthorizedUser_ThrowsForbiddenException() { + var reservation = createReservation(); + var otherUser = new UserContext(UUID.randomUUID(), "GUEST"); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.getById(RESERVATION_ID, otherUser)) + .isInstanceOf(ForbiddenException.class); + } + } + + @Nested + @DisplayName("GetByGuestId") + class GetByGuestIdTests { + + @Test + @DisplayName("With existing guest returns reservation list") + void getByGuestId_WithExistingGuest_ReturnsReservationList() { + var reservation = createReservation(); + var reservations = List.of(reservation); + var response = createResponse(); + var validationResult = new AccommodationValidationResult( + true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL", "Test Accommodation" + ); + + when(reservationRepository.findByGuestId(GUEST_ID)).thenReturn(reservations); + when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())).thenReturn(validationResult); + setupUserMocks(); + when(reservationMapper.toResponseWithNames(eq(reservation), anyString(), anyString(), anyString())).thenReturn(response); + + List result = reservationService.getByGuestId(GUEST_CONTEXT); + + assertThat(result).hasSize(1); + } + + @Test + @DisplayName("With no reservations returns empty list") + void getByGuestId_WithNoReservations_ReturnsEmptyList() { + when(reservationRepository.findByGuestId(GUEST_ID)).thenReturn(List.of()); + + List result = reservationService.getByGuestId(GUEST_CONTEXT); + + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("GetByHostId") + class GetByHostIdTests { + + @Test + @DisplayName("With existing host returns reservation list") + void getByHostId_WithExistingHost_ReturnsReservationList() { + var reservation = createReservation(); + var reservations = List.of(reservation); + var response = createResponse(); + var validationResult = new AccommodationValidationResult( + true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL", "Test Accommodation" + ); + + when(reservationRepository.findByHostId(HOST_ID)).thenReturn(reservations); + when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())).thenReturn(validationResult); + setupUserMocks(); + when(reservationMapper.toResponseWithNames(eq(reservation), anyString(), anyString(), anyString())).thenReturn(response); + + List result = reservationService.getByHostId(HOST_CONTEXT); + + assertThat(result).hasSize(1); + } + + @Test + @DisplayName("With no reservations returns empty list") + void getByHostId_WithNoReservations_ReturnsEmptyList() { + when(reservationRepository.findByHostId(HOST_ID)).thenReturn(List.of()); + + List result = reservationService.getByHostId(HOST_CONTEXT); + + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("DeleteRequest") + class DeleteRequestTests { + + @Test + @DisplayName("With valid owner and pending status soft-deletes reservation") + void deleteRequest_WithValidOwnerAndPending_SoftDeletesReservation() { + var reservation = createReservation(); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + reservationService.deleteRequest(RESERVATION_ID, GUEST_CONTEXT); + + assertThat(reservation.isDeleted()).isTrue(); + verify(reservationRepository).save(reservation); + } + + @Test + @DisplayName("With wrong owner throws ForbiddenException") + void deleteRequest_WithWrongOwner_ThrowsForbiddenException() { + var reservation = createReservation(); + var otherUser = new UserContext(UUID.randomUUID(), "GUEST"); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.deleteRequest(RESERVATION_ID, otherUser)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("only delete your own"); + } + + @Test + @DisplayName("With non-pending status throws InvalidReservationException") + void deleteRequest_WithNonPendingStatus_ThrowsInvalidReservationException() { + var reservation = createReservation(); + reservation.setStatus(ReservationStatus.APPROVED); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.deleteRequest(RESERVATION_ID, GUEST_CONTEXT)) + .isInstanceOf(InvalidReservationException.class) + .hasMessageContaining("Only pending"); + } + + @Test + @DisplayName("With non-existing ID throws ReservationNotFoundException") + void deleteRequest_WithNonExistingId_ThrowsReservationNotFoundException() { + UUID id = UUID.randomUUID(); + when(reservationRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> reservationService.deleteRequest(id, GUEST_CONTEXT)) + .isInstanceOf(ReservationNotFoundException.class); + } + + @Test + @DisplayName("Host cannot delete guest's reservation") + void deleteRequest_WithHostTryingToDelete_ThrowsForbiddenException() { + var reservation = createReservation(); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.deleteRequest(RESERVATION_ID, HOST_CONTEXT)) + .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)); + 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, "Test Accommodation"); + } + + @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); + } + } + + @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); + setupUserMocks(); + when(reservationMapper.toResponseWithNames(eq(reservation), anyString(), anyString(), anyString())).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); + setupUserMocks(); + when(reservationMapper.toResponseWithNames(eq(reservation), anyString(), anyString(), anyString())).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); + setupUserMocks(); + when(reservationMapper.toResponseWithNames(eq(reservation), anyString(), anyString(), anyString())).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(); + var validationResult = new AccommodationValidationResult( + true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL", "Test Accommodation" + ); + + when(reservationRepository.findByHostId(HOST_ID)).thenReturn(List.of(reservation)); + when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())).thenReturn(validationResult); + setupUserMocks(); + when(reservationMapper.toResponseWithNames(eq(reservation), anyString(), anyString(), anyString())).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(); + } + } +} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 0000000..9da01ff --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,7 @@ +spring.application.name=reservation-test + +# Disable tracing in tests +management.tracing.enabled=false + +# Logging +logging.level.com.devoops=DEBUG diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 0000000..8c647eb --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,18 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + +