From 4a284b7c9c85712f49cf05fdaed540fe3744c62f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20=C4=8Cuturi=C4=87?= Date: Wed, 14 Jan 2026 20:42:42 +0100 Subject: [PATCH 01/19] Added Test controller and endpoint --- build.gradle.kts | 4 ++-- .../reservation/ReservationApplication.java | 3 ++- .../reservation/controller/TestController.java | 16 ++++++++++++++++ src/main/resources/application.properties | 1 + 4 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/devoops/reservation/controller/TestController.java diff --git a/build.gradle.kts b/build.gradle.kts index 29ce4d1..7d74b16 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,12 +20,12 @@ repositories { dependencies { implementation("org.springframework.boot:spring-boot-starter-flyway") - implementation("org.springframework.boot:spring-boot-starter-security") +// implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-webmvc") 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") +// testImplementation("org.springframework.boot:spring-boot-starter-security-test") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } diff --git a/src/main/java/com/devoops/reservation/ReservationApplication.java b/src/main/java/com/devoops/reservation/ReservationApplication.java index d41a4cc..32c892e 100644 --- a/src/main/java/com/devoops/reservation/ReservationApplication.java +++ b/src/main/java/com/devoops/reservation/ReservationApplication.java @@ -2,8 +2,9 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; -@SpringBootApplication +@SpringBootApplication(exclude = DataSourceAutoConfiguration.class) public class ReservationApplication { public static void main(String[] args) { 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..e33579b --- /dev/null +++ b/src/main/java/com/devoops/reservation/controller/TestController.java @@ -0,0 +1,16 @@ +package com.devoops.reservation.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("api/reservation") +public class TestController { + + @GetMapping("test") + public String test() { + return "Reservation Service is up and running!"; + } + +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 70b4561..de33ef9 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,2 @@ spring.application.name=reservation +server.port=8085 From d68e6abd67e65536eaa791eb074445a4d590586b Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Sat, 17 Jan 2026 16:02:32 +0100 Subject: [PATCH 02/19] feat: Add Dockerfile --- .gitignore | 5 ++++- Dockerfile | 12 ++++++++++++ gradlew | 0 3 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 Dockerfile mode change 100644 => 100755 gradlew diff --git a/.gitignore b/.gitignore index df0b53a..eac1f4d 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,7 @@ out/ .vscode/ ### Mac ### -.DS_Store \ No newline at end of file +.DS_Store + +### Environment variables ### +*.env \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e7440a4 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +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 + +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 From 5574edee13616ab1de56d28621257834e7b2be2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Sat, 17 Jan 2026 23:12:53 +0100 Subject: [PATCH 03/19] feature/elk-logging-integration --- build.gradle.kts | 1 + .../controller/TestController.java | 24 +++++- src/main/resources/application.properties | 6 ++ src/main/resources/logback-spring.xml | 75 +++++++++++++++++++ src/test/resources/logback-test.xml | 18 +++++ 5 files changed, 122 insertions(+), 2 deletions(-) create mode 100644 src/main/resources/logback-spring.xml create mode 100644 src/test/resources/logback-test.xml diff --git a/build.gradle.kts b/build.gradle.kts index 7d74b16..26f1bf6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -22,6 +22,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-flyway") // implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-webmvc") + implementation("net.logstash.logback:logstash-logback-encoder:8.0") implementation("org.flywaydb:flyway-database-postgresql") runtimeOnly("org.postgresql:postgresql") testImplementation("org.springframework.boot:spring-boot-starter-flyway-test") diff --git a/src/main/java/com/devoops/reservation/controller/TestController.java b/src/main/java/com/devoops/reservation/controller/TestController.java index e33579b..832914e 100644 --- a/src/main/java/com/devoops/reservation/controller/TestController.java +++ b/src/main/java/com/devoops/reservation/controller/TestController.java @@ -1,16 +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() { - return "Reservation Service is up and running!"; - } + 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/resources/application.properties b/src/main/resources/application.properties index de33ef9..d0df4a5 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,2 +1,8 @@ spring.application.name=reservation server.port=8085 + +# Logging configuration +logging.logstash.host=${LOGSTASH_HOST:localhost:5000} +logging.level.root=INFO +logging.level.com.devoops=DEBUG +logging.level.org.springframework.web=INFO 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/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 + + + + + + + + + + + + + From 05712c8d3b43b737c5485ddd26c96e8cade83ed2 Mon Sep 17 00:00:00 2001 From: Dusan Date: Sat, 17 Jan 2026 22:04:37 +0100 Subject: [PATCH 04/19] feat: Add zipkin tracing --- build.gradle.kts | 7 +++++++ src/main/resources/application.properties | 5 +++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index 26f1bf6..eccafec 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,6 +24,13 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-webmvc") implementation("net.logstash.logback:logstash-logback-encoder:8.0") implementation("org.flywaydb:flyway-database-postgresql") + implementation("org.springframework.boot:spring-boot-starter-actuator") + //zipkin(tracing) + 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") + runtimeOnly("org.postgresql:postgresql") testImplementation("org.springframework.boot:spring-boot-starter-flyway-test") // testImplementation("org.springframework.boot:spring-boot-starter-security-test") diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index d0df4a5..0a64876 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,8 +1,9 @@ spring.application.name=reservation -server.port=8085 - +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 +management.tracing.sampling.probability=1.0 +management.tracing.export.zipkin.endpoint=${ZIPKIN_HOST:http://zipkin}:${ZIPKIN_PORT:9411}/api/v2/spans From 953df9fe4e6a302606dd8a8cd989b92f343e4aa7 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Sat, 31 Jan 2026 16:22:10 +0100 Subject: [PATCH 05/19] fix: Remove local environment variables from gitignore. --- .gitignore | 3 --- environment/.local.env | 4 ++++ 2 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 environment/.local.env diff --git a/.gitignore b/.gitignore index eac1f4d..070c2bf 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,3 @@ out/ ### Mac ### .DS_Store - -### Environment variables ### -*.env \ No newline at end of file diff --git a/environment/.local.env b/environment/.local.env new file mode 100644 index 0000000..c23275d --- /dev/null +++ b/environment/.local.env @@ -0,0 +1,4 @@ +SERVER_PORT=8080 +LOGSTASH_HOST=logstash:5000 +ZIPKIN_HOST=zipkin +ZIPKIN_PORT=9411 \ No newline at end of file From 2333b83375d2e59dabb953b3f53d5c7cd3be82ec Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Sat, 31 Jan 2026 16:29:36 +0100 Subject: [PATCH 06/19] fix: Fix zipkin endpoint in application.properties. --- src/main/resources/application.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 0a64876..9ed0687 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -6,4 +6,4 @@ logging.level.root=INFO logging.level.com.devoops=DEBUG logging.level.org.springframework.web=INFO management.tracing.sampling.probability=1.0 -management.tracing.export.zipkin.endpoint=${ZIPKIN_HOST:http://zipkin}:${ZIPKIN_PORT:9411}/api/v2/spans +management.tracing.export.zipkin.endpoint=http://${ZIPKIN_HOST:zipkin}:${ZIPKIN_PORT:9411}/api/v2/spans From 6d69be6090b5222f929e2f1adaaa44f4b1c12361 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:43:58 +0100 Subject: [PATCH 07/19] feat: Add Prometheus Integration --- build.gradle.kts | 1 + src/main/resources/application.properties | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/build.gradle.kts b/build.gradle.kts index eccafec..7345d7b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { implementation("net.logstash.logback:logstash-logback-encoder:8.0") implementation("org.flywaydb:flyway-database-postgresql") implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("io.micrometer:micrometer-registry-prometheus") //zipkin(tracing) implementation("org.springframework.boot:spring-boot-micrometer-tracing-brave") implementation("org.springframework.boot:spring-boot-starter-zipkin") diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 9ed0687..322ce93 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -7,3 +7,8 @@ logging.level.com.devoops=DEBUG logging.level.org.springframework.web=INFO 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,prometheus,metrics +management.endpoint.health.show-details=always +management.prometheus.metrics.export.enabled=true From 405de949d292539b7f6637594dd06c7319b099cc Mon Sep 17 00:00:00 2001 From: Dusan Date: Mon, 9 Feb 2026 23:10:12 +0100 Subject: [PATCH 08/19] feat: Add CI Pipeline --- .github/workflows/ci.yml | 92 ++++++++++++++++++++++++++++++++++ .github/workflows/pr-check.yml | 27 ++++++++++ 2 files changed, 119 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/pr-check.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..96a97f3 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,92 @@ +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: Run tests + run: ./gradlew test + + - 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: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f + + - name: Log in to Docker Hub + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 + with: + username: ${{ env.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - 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 + uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 + with: + context: . + push: true + tags: | + ${{ env.DOCKERHUB_USERNAME }}/devoops-reservation-service:${{ steps.version.outputs.tag }} + ${{ env.DOCKERHUB_USERNAME }}/devoops-reservation-service:latest + cache-from: type=gha + cache-to: type=gha,mode=max \ 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 From 6a9acdd203f4882acde3bb215be1b972324f903f Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:35:38 +0100 Subject: [PATCH 09/19] feat: Add basic reservation CRUD. Enabled reservation creation, deleting pending reservations, and reservation fetching. --- build.gradle.kts | 56 +++++-- environment/.local.env | 6 +- .../reservation/ReservationApplication.java | 3 +- .../reservation/config/RequireRole.java | 12 ++ .../config/RoleAuthorizationInterceptor.java | 49 ++++++ .../reservation/config/UserContext.java | 5 + .../config/UserContextResolver.java | 40 +++++ .../devoops/reservation/config/WebConfig.java | 26 +++ .../controller/ReservationController.java | 63 ++++++++ .../dto/request/CreateReservationRequest.java | 23 +++ .../dto/response/ReservationResponse.java | 22 +++ .../reservation/entity/BaseEntity.java | 36 +++++ .../reservation/entity/Reservation.java | 52 ++++++ .../reservation/entity/ReservationStatus.java | 8 + .../exception/ForbiddenException.java | 7 + .../exception/GlobalExceptionHandler.java | 53 ++++++ .../InvalidReservationException.java | 7 + .../ReservationNotFoundException.java | 7 + .../exception/UnauthorizedException.java | 7 + .../reservation/mapper/ReservationMapper.java | 27 ++++ .../repository/ReservationRepository.java | 64 ++++++++ .../service/ReservationService.java | 152 ++++++++++++++++++ src/main/resources/application.properties | 19 ++- .../db/migration/V1__init_schema.sql | 30 ++++ 24 files changed, 757 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/devoops/reservation/config/RequireRole.java create mode 100644 src/main/java/com/devoops/reservation/config/RoleAuthorizationInterceptor.java create mode 100644 src/main/java/com/devoops/reservation/config/UserContext.java create mode 100644 src/main/java/com/devoops/reservation/config/UserContextResolver.java create mode 100644 src/main/java/com/devoops/reservation/config/WebConfig.java create mode 100644 src/main/java/com/devoops/reservation/controller/ReservationController.java create mode 100644 src/main/java/com/devoops/reservation/dto/request/CreateReservationRequest.java create mode 100644 src/main/java/com/devoops/reservation/dto/response/ReservationResponse.java create mode 100644 src/main/java/com/devoops/reservation/entity/BaseEntity.java create mode 100644 src/main/java/com/devoops/reservation/entity/Reservation.java create mode 100644 src/main/java/com/devoops/reservation/entity/ReservationStatus.java create mode 100644 src/main/java/com/devoops/reservation/exception/ForbiddenException.java create mode 100644 src/main/java/com/devoops/reservation/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/devoops/reservation/exception/InvalidReservationException.java create mode 100644 src/main/java/com/devoops/reservation/exception/ReservationNotFoundException.java create mode 100644 src/main/java/com/devoops/reservation/exception/UnauthorizedException.java create mode 100644 src/main/java/com/devoops/reservation/mapper/ReservationMapper.java create mode 100644 src/main/java/com/devoops/reservation/repository/ReservationRepository.java create mode 100644 src/main/java/com/devoops/reservation/service/ReservationService.java create mode 100644 src/main/resources/db/migration/V1__init_schema.sql diff --git a/build.gradle.kts b/build.gradle.kts index 7345d7b..1555ad9 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ plugins { java + jacoco id("org.springframework.boot") version "4.0.1" id("io.spring.dependency-management") version "1.1.7" } @@ -19,26 +20,55 @@ repositories { } 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("net.logstash.logback:logstash-logback-encoder:8.0") - implementation("org.flywaydb:flyway-database-postgresql") - implementation("org.springframework.boot:spring-boot-starter-actuator") - implementation("io.micrometer:micrometer-registry-prometheus") - //zipkin(tracing) - 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") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-validation") + // 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") + + // 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") + testCompileOnly("org.projectlombok:lombok") + testAnnotationProcessor("org.projectlombok:lombok") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } 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 index c23275d..ba3bcc2 100644 --- a/environment/.local.env +++ b/environment/.local.env @@ -1,4 +1,8 @@ SERVER_PORT=8080 LOGSTASH_HOST=logstash:5000 ZIPKIN_HOST=zipkin -ZIPKIN_PORT=9411 \ No newline at end of file +ZIPKIN_PORT=9411 +POSTGRES_HOST=devoops-postgres +POSTGRES_PORT=5432 +DB_USERNAME=reservation-service +DB_PASSWORD=reservation-service-pass \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/ReservationApplication.java b/src/main/java/com/devoops/reservation/ReservationApplication.java index 32c892e..d41a4cc 100644 --- a/src/main/java/com/devoops/reservation/ReservationApplication.java +++ b/src/main/java/com/devoops/reservation/ReservationApplication.java @@ -2,9 +2,8 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; -@SpringBootApplication(exclude = DataSourceAutoConfiguration.class) +@SpringBootApplication public class ReservationApplication { public static void main(String[] args) { 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..64c9232 --- /dev/null +++ b/src/main/java/com/devoops/reservation/controller/ReservationController.java @@ -0,0 +1,63 @@ +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.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.getByHostId(userContext)); + } + + + @DeleteMapping("/{id}") + @RequireRole("GUEST") + public ResponseEntity delete( + @PathVariable UUID id, + UserContext userContext) { + reservationService.deleteRequest(id, userContext); + return ResponseEntity.noContent().build(); + } +} 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..03c9ebf --- /dev/null +++ b/src/main/java/com/devoops/reservation/dto/response/ReservationResponse.java @@ -0,0 +1,22 @@ +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, + UUID guestId, + UUID hostId, + 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/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/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..8a048e6 --- /dev/null +++ b/src/main/java/com/devoops/reservation/exception/GlobalExceptionHandler.java @@ -0,0 +1,53 @@ +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(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/mapper/ReservationMapper.java b/src/main/java/com/devoops/reservation/mapper/ReservationMapper.java new file mode 100644 index 0000000..acc1c8e --- /dev/null +++ b/src/main/java/com/devoops/reservation/mapper/ReservationMapper.java @@ -0,0 +1,27 @@ +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); + + ReservationResponse toResponse(Reservation reservation); + + List toResponseList(List reservations); +} \ 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..9c378ab --- /dev/null +++ b/src/main/java/com/devoops/reservation/repository/ReservationRepository.java @@ -0,0 +1,64 @@ +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); +} 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..77c2ec5 --- /dev/null +++ b/src/main/java/com/devoops/reservation/service/ReservationService.java @@ -0,0 +1,152 @@ +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.entity.Reservation; +import com.devoops.reservation.entity.ReservationStatus; +import com.devoops.reservation.exception.ForbiddenException; +import com.devoops.reservation.exception.InvalidReservationException; +import com.devoops.reservation.exception.ReservationNotFoundException; +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.math.BigDecimal; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ReservationService { + + private final ReservationRepository reservationRepository; + private final ReservationMapper reservationMapper; + + @Transactional + public ReservationResponse create(CreateReservationRequest request, UserContext userContext) { + // Validate dates + validateDates(request.startDate(), request.endDate()); + + // TODO: Call Accommodation Service via gRPC to: + // 1. Validate accommodation exists + // 2. Get hostId + // 3. Validate guest count within min/max capacity + // 4. Validate dates within availability periods + // 5. Calculate totalPrice from pricing rules + UUID hostId = UUID.randomUUID(); // Placeholder - get from Accommodation Service + BigDecimal totalPrice = calculatePlaceholderPrice(request); // Placeholder - get from Accommodation Service + + // 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(totalPrice); + reservation.setStatus(ReservationStatus.PENDING); + + reservation = reservationRepository.saveAndFlush(reservation); + log.info("Created reservation {} for guest {} at accommodation {}", + reservation.getId(), userContext.userId(), request.accommodationId()); + + // TODO: Publish event to Notification Service via RabbitMQ + // notifyHost(reservation); + + return reservationMapper.toResponse(reservation); + } + + @Transactional(readOnly = true) + public ReservationResponse getById(UUID id, UserContext userContext) { + Reservation reservation = findReservationOrThrow(id); + validateAccessToReservation(reservation, userContext); + return reservationMapper.toResponse(reservation); + } + + @Transactional(readOnly = true) + public List getByGuestId(UserContext userContext) { + List reservations = reservationRepository.findByGuestId(userContext.userId()); + return reservationMapper.toResponseList(reservations); + } + + @Transactional(readOnly = true) + public List getByHostId(UserContext userContext) { + List reservations = reservationRepository.findByHostId(userContext.userId()); + return reservationMapper.toResponseList(reservations); + } + + @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); + + // TODO: Publish event to Notification Service via RabbitMQ + } + + // === 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"); + } + } + + /** + * Placeholder price calculation. + * TODO: Replace with actual pricing calculation from Accommodation Service via gRPC. + * This calculates price based on number of nights and guest count. + */ + private BigDecimal calculatePlaceholderPrice(CreateReservationRequest request) { + long nights = ChronoUnit.DAYS.between(request.startDate(), request.endDate()); + // Placeholder: $100 per night * guest count + return BigDecimal.valueOf(100) + .multiply(BigDecimal.valueOf(nights)) + .multiply(BigDecimal.valueOf(request.guestCount())); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 322ce93..1260034 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,14 +1,31 @@ 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,prometheus,metrics +management.endpoints.web.exposure.include=health,info,prometheus management.endpoint.health.show-details=always management.prometheus.metrics.export.enabled=true 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 From 48f5a84d027bbd3e3e1cbc5931384465a3975fea Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Mon, 16 Feb 2026 13:58:20 +0100 Subject: [PATCH 10/19] feat: Add unit and integration tests for basic reservation CRUD. --- build.gradle.kts | 3 + .../ReservationApplicationTests.java | 24 ++ .../controller/ReservationControllerTest.java | 347 ++++++++++++++++++ .../ReservationIntegrationTest.java | 326 ++++++++++++++++ .../service/ReservationServiceTest.java | 335 +++++++++++++++++ .../resources/application-test.properties | 7 + 6 files changed, 1042 insertions(+) create mode 100644 src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java create mode 100644 src/test/java/com/devoops/reservation/integration/ReservationIntegrationTest.java create mode 100644 src/test/java/com/devoops/reservation/service/ReservationServiceTest.java create mode 100644 src/test/resources/application-test.properties diff --git a/build.gradle.kts b/build.gradle.kts index 1555ad9..82b27ef 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -56,6 +56,9 @@ dependencies { 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") 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..6b326d9 --- /dev/null +++ b/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java @@ -0,0 +1,347 @@ +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.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, GUEST_ID, HOST_ID, + LocalDate.now().plusDays(10), LocalDate.now().plusDays(15), + 2, new BigDecimal("1000.00"), ReservationStatus.PENDING, + LocalDateTime.now(), LocalDateTime.now() + ); + } + + 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") + void getByHost_Returns200WithList() throws Exception { + when(reservationService.getByHostId(any(UserContext.class))) + .thenReturn(List.of(createResponse())); + + 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())); + } + + @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()); + } + } +} 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..1e100d4 --- /dev/null +++ b/src/test/java/com/devoops/reservation/integration/ReservationIntegrationTest.java @@ -0,0 +1,326 @@ +package com.devoops.reservation.integration; + +import com.fasterxml.jackson.databind.ObjectMapper; +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.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.time.LocalDate; +import java.util.Map; +import java.util.UUID; + +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; + + 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"; + + @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/ReservationServiceTest.java b/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java new file mode 100644 index 0000000..4f0d014 --- /dev/null +++ b/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java @@ -0,0 +1,335 @@ +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.entity.Reservation; +import com.devoops.reservation.entity.ReservationStatus; +import com.devoops.reservation.exception.ForbiddenException; +import com.devoops.reservation.exception.InvalidReservationException; +import com.devoops.reservation.exception.ReservationNotFoundException; +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.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ReservationServiceTest { + + @Mock + private ReservationRepository reservationRepository; + + @Mock + private ReservationMapper reservationMapper; + + @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, GUEST_ID, HOST_ID, + LocalDate.now().plusDays(10), LocalDate.now().plusDays(15), + 2, new BigDecimal("1000.00"), ReservationStatus.PENDING, + LocalDateTime.now(), LocalDateTime.now() + ); + } + + 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(); + + when(reservationRepository.findOverlappingApproved(any(), any(), any())) + .thenReturn(List.of()); + when(reservationMapper.toEntity(request)).thenReturn(reservation); + when(reservationRepository.saveAndFlush(reservation)).thenReturn(reservation); + when(reservationMapper.toResponse(reservation)).thenReturn(response); + + 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); + } + + @Test + @DisplayName("With overlapping approved reservation throws InvalidReservationException") + void create_WithOverlappingApproved_ThrowsInvalidReservationException() { + var request = createRequest(); + var existingReservation = createReservation(); + existingReservation.setStatus(ReservationStatus.APPROVED); + + when(reservationRepository.findOverlappingApproved(any(), any(), any())) + .thenReturn(List.of(existingReservation)); + + assertThatThrownBy(() -> reservationService.create(request, GUEST_CONTEXT)) + .isInstanceOf(InvalidReservationException.class) + .hasMessageContaining("overlap"); + } + + @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(); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + when(reservationMapper.toResponse(reservation)).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(); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + when(reservationMapper.toResponse(reservation)).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 reservations = List.of(createReservation()); + var responses = List.of(createResponse()); + + when(reservationRepository.findByGuestId(GUEST_ID)).thenReturn(reservations); + when(reservationMapper.toResponseList(reservations)).thenReturn(responses); + + 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()); + when(reservationMapper.toResponseList(List.of())).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 reservations = List.of(createReservation()); + var responses = List.of(createResponse()); + + when(reservationRepository.findByHostId(HOST_ID)).thenReturn(reservations); + when(reservationMapper.toResponseList(reservations)).thenReturn(responses); + + 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()); + when(reservationMapper.toResponseList(List.of())).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); + } + } +} 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 From 4734906c408074b13c9a150e045afae5e148f4af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20=C4=8Cuturi=C4=87?= Date: Tue, 17 Feb 2026 13:34:12 +0100 Subject: [PATCH 11/19] feat: Add gRPC config and endpoint for overlapping --- Dockerfile | 1 + build.gradle.kts | 30 +++++++++++++ environment/.local.env | 3 +- .../grpc/ReservationGrpcService.java | 44 +++++++++++++++++++ src/main/proto/reservation_internal.proto | 20 +++++++++ src/main/resources/application.properties | 3 ++ 6 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/devoops/reservation/grpc/ReservationGrpcService.java create mode 100644 src/main/proto/reservation_internal.proto diff --git a/Dockerfile b/Dockerfile index e7440a4..ff10050 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,5 +8,6 @@ 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 82b27ef..4ccc8b4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +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" @@ -19,6 +22,8 @@ repositories { mavenCentral() } +val grpcVersion = "1.68.0" + dependencies { // Web and Core implementation("org.springframework.boot:spring-boot-starter-webmvc") @@ -40,6 +45,13 @@ dependencies { annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3") annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0") + // gRPC Server + implementation("net.devh:grpc-server-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") @@ -64,6 +76,24 @@ dependencies { 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) diff --git a/environment/.local.env b/environment/.local.env index ba3bcc2..6b790fb 100644 --- a/environment/.local.env +++ b/environment/.local.env @@ -5,4 +5,5 @@ ZIPKIN_PORT=9411 POSTGRES_HOST=devoops-postgres POSTGRES_PORT=5432 DB_USERNAME=reservation-service -DB_PASSWORD=reservation-service-pass \ No newline at end of file +DB_PASSWORD=reservation-service-pass +GRPC_PORT=9090 \ No newline at end of file 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..32b9e35 --- /dev/null +++ b/src/main/java/com/devoops/reservation/grpc/ReservationGrpcService.java @@ -0,0 +1,44 @@ +package com.devoops.reservation.grpc; + +import com.devoops.reservation.entity.Reservation; +import com.devoops.reservation.grpc.proto.CheckReservationsExistRequest; +import com.devoops.reservation.grpc.proto.CheckReservationsExistResponse; +import com.devoops.reservation.grpc.proto.ReservationInternalServiceGrpc; +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(); + } +} diff --git a/src/main/proto/reservation_internal.proto b/src/main/proto/reservation_internal.proto new file mode 100644 index 0000000..89d4e01 --- /dev/null +++ b/src/main/proto/reservation_internal.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +package reservation; + +option java_multiple_files = true; +option java_package = "com.devoops.reservation.grpc.proto"; + +service ReservationInternalService { + rpc CheckReservationsExist(CheckReservationsExistRequest) returns (CheckReservationsExistResponse); +} + +message CheckReservationsExistRequest { + string accommodation_id = 1; + string start_date = 2; + string end_date = 3; +} + +message CheckReservationsExistResponse { + bool has_reservations = 1; +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1260034..3dfb8ea 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -29,3 +29,6 @@ management.tracing.export.zipkin.endpoint=http://${ZIPKIN_HOST:zipkin}:${ZIPKIN_ 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} From 78e9e15192669d1435b964c4f446da7230b9d9d2 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Tue, 17 Feb 2026 19:00:50 +0100 Subject: [PATCH 12/19] feat: Add reservation request validation and reservation cancellation. --- build.gradle.kts | 5 +- environment/.local.env | 14 +- .../reservation/config/RabbitMQConfig.java | 29 ++++ .../controller/ReservationController.java | 9 + .../message/ReservationCancelledMessage.java | 14 ++ .../message/ReservationCreatedMessage.java | 18 ++ .../AccommodationNotFoundException.java | 8 + .../exception/GlobalExceptionHandler.java | 5 + .../grpc/AccommodationGrpcClient.java | 63 +++++++ .../grpc/AccommodationValidationResult.java | 18 ++ .../ReservationEventPublisherService.java | 61 +++++++ .../service/ReservationService.java | 91 +++++++--- src/main/proto/accommodation_internal.proto | 26 +++ src/main/resources/application.properties | 15 ++ .../controller/ReservationControllerTest.java | 74 ++++++++ .../service/ReservationServiceTest.java | 161 ++++++++++++++++++ 16 files changed, 582 insertions(+), 29 deletions(-) create mode 100644 src/main/java/com/devoops/reservation/config/RabbitMQConfig.java create mode 100644 src/main/java/com/devoops/reservation/dto/message/ReservationCancelledMessage.java create mode 100644 src/main/java/com/devoops/reservation/dto/message/ReservationCreatedMessage.java create mode 100644 src/main/java/com/devoops/reservation/exception/AccommodationNotFoundException.java create mode 100644 src/main/java/com/devoops/reservation/grpc/AccommodationGrpcClient.java create mode 100644 src/main/java/com/devoops/reservation/grpc/AccommodationValidationResult.java create mode 100644 src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java create mode 100644 src/main/proto/accommodation_internal.proto diff --git a/build.gradle.kts b/build.gradle.kts index 4ccc8b4..5ad4a3f 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -29,6 +29,8 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-webmvc") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-amqp") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") // Database implementation("org.springframework.boot:spring-boot-starter-data-jpa") @@ -45,8 +47,9 @@ dependencies { annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3") annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0") - // gRPC Server + // gRPC Server and Client implementation("net.devh:grpc-server-spring-boot-starter:3.1.0.RELEASE") + implementation("net.devh:grpc-client-spring-boot-starter:3.1.0.RELEASE") implementation("io.grpc:grpc-protobuf:$grpcVersion") implementation("io.grpc:grpc-stub:$grpcVersion") implementation("io.grpc:grpc-netty-shaded:$grpcVersion") diff --git a/environment/.local.env b/environment/.local.env index 6b790fb..38cc133 100644 --- a/environment/.local.env +++ b/environment/.local.env @@ -6,4 +6,16 @@ POSTGRES_HOST=devoops-postgres POSTGRES_PORT=5432 DB_USERNAME=reservation-service DB_PASSWORD=reservation-service-pass -GRPC_PORT=9090 \ No newline at end of file + +# gRPC Server +GRPC_PORT=9090 + +# gRPC Client +ACCOMMODATION_GRPC_HOST=devoops-accommodation-service +ACCOMMODATION_GRPC_PORT=9090 + +# RabbitMQ +RABBITMQ_HOST=devoops-rabbitmq +RABBITMQ_PORT=5672 +RABBITMQ_USERNAME=devoops +RABBITMQ_PASSWORD=devoops123 \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/config/RabbitMQConfig.java b/src/main/java/com/devoops/reservation/config/RabbitMQConfig.java new file mode 100644 index 0000000..e54da3d --- /dev/null +++ b/src/main/java/com/devoops/reservation/config/RabbitMQConfig.java @@ -0,0 +1,29 @@ +package com.devoops.reservation.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.amqp.core.TopicExchange; +import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; +import org.springframework.amqp.support.converter.MessageConverter; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class RabbitMQConfig { + + @Value("${rabbitmq.exchange.notification}") + private String notificationExchange; + + @Bean + public TopicExchange notificationExchange() { + return new TopicExchange(notificationExchange); + } + + @Bean + public MessageConverter jsonMessageConverter() { + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + return new Jackson2JsonMessageConverter(objectMapper); + } +} diff --git a/src/main/java/com/devoops/reservation/controller/ReservationController.java b/src/main/java/com/devoops/reservation/controller/ReservationController.java index 64c9232..ff4505f 100644 --- a/src/main/java/com/devoops/reservation/controller/ReservationController.java +++ b/src/main/java/com/devoops/reservation/controller/ReservationController.java @@ -60,4 +60,13 @@ public ResponseEntity delete( reservationService.deleteRequest(id, userContext); return ResponseEntity.noContent().build(); } + + @PostMapping("/{id}/cancel") + @RequireRole("GUEST") + public ResponseEntity cancel( + @PathVariable UUID id, + UserContext userContext) { + reservationService.cancelReservation(id, userContext); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/com/devoops/reservation/dto/message/ReservationCancelledMessage.java b/src/main/java/com/devoops/reservation/dto/message/ReservationCancelledMessage.java new file mode 100644 index 0000000..8258e46 --- /dev/null +++ b/src/main/java/com/devoops/reservation/dto/message/ReservationCancelledMessage.java @@ -0,0 +1,14 @@ +package com.devoops.reservation.dto.message; + +import java.time.LocalDate; +import java.util.UUID; + +public record ReservationCancelledMessage( + UUID reservationId, + UUID accommodationId, + UUID guestId, + UUID hostId, + LocalDate startDate, + LocalDate endDate +) { +} diff --git a/src/main/java/com/devoops/reservation/dto/message/ReservationCreatedMessage.java b/src/main/java/com/devoops/reservation/dto/message/ReservationCreatedMessage.java new file mode 100644 index 0000000..c92d0f1 --- /dev/null +++ b/src/main/java/com/devoops/reservation/dto/message/ReservationCreatedMessage.java @@ -0,0 +1,18 @@ +package com.devoops.reservation.dto.message; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +public record ReservationCreatedMessage( + UUID reservationId, + UUID accommodationId, + UUID guestId, + UUID hostId, + LocalDate startDate, + LocalDate endDate, + int guestCount, + BigDecimal totalPrice, + String status +) { +} diff --git a/src/main/java/com/devoops/reservation/exception/AccommodationNotFoundException.java b/src/main/java/com/devoops/reservation/exception/AccommodationNotFoundException.java new file mode 100644 index 0000000..15cdadc --- /dev/null +++ b/src/main/java/com/devoops/reservation/exception/AccommodationNotFoundException.java @@ -0,0 +1,8 @@ +package com.devoops.reservation.exception; + +public class AccommodationNotFoundException extends RuntimeException { + + public AccommodationNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/devoops/reservation/exception/GlobalExceptionHandler.java b/src/main/java/com/devoops/reservation/exception/GlobalExceptionHandler.java index 8a048e6..c873523 100644 --- a/src/main/java/com/devoops/reservation/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/devoops/reservation/exception/GlobalExceptionHandler.java @@ -18,6 +18,11 @@ public ProblemDetail handleNotFound(ReservationNotFoundException ex) { return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); } + @ExceptionHandler(AccommodationNotFoundException.class) + public ProblemDetail handleAccommodationNotFound(AccommodationNotFoundException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); + } + @ExceptionHandler(UnauthorizedException.class) public ProblemDetail handleUnauthorized(UnauthorizedException ex) { return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage()); diff --git a/src/main/java/com/devoops/reservation/grpc/AccommodationGrpcClient.java b/src/main/java/com/devoops/reservation/grpc/AccommodationGrpcClient.java new file mode 100644 index 0000000..c97d670 --- /dev/null +++ b/src/main/java/com/devoops/reservation/grpc/AccommodationGrpcClient.java @@ -0,0 +1,63 @@ +package com.devoops.reservation.grpc; + +import com.devoops.reservation.grpc.proto.accommodation.AccommodationInternalServiceGrpc; +import com.devoops.reservation.grpc.proto.accommodation.ReservationValidationRequest; +import com.devoops.reservation.grpc.proto.accommodation.ReservationValidationResponse; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.client.inject.GrpcClient; +import org.springframework.stereotype.Component; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +@Component +@Slf4j +public class AccommodationGrpcClient { + + @GrpcClient("accommodation-service") + private AccommodationInternalServiceGrpc.AccommodationInternalServiceBlockingStub accommodationStub; + + public AccommodationValidationResult validateAndCalculatePrice( + UUID accommodationId, + LocalDate startDate, + LocalDate endDate, + int guestCount) { + + log.debug("Calling accommodation service for validation: accommodationId={}, dates={} to {}, guests={}", + accommodationId, startDate, endDate, guestCount); + + ReservationValidationRequest request = ReservationValidationRequest.newBuilder() + .setAccommodationId(accommodationId.toString()) + .setStartDate(startDate.toString()) + .setEndDate(endDate.toString()) + .setGuestCount(guestCount) + .build(); + + ReservationValidationResponse response = accommodationStub.validateAndCalculatePrice(request); + + log.debug("Received validation response: valid={}, errorCode={}", response.getValid(), response.getErrorCode()); + + if (!response.getValid()) { + return new AccommodationValidationResult( + false, + response.getErrorCode(), + response.getErrorMessage(), + null, + null, + null, + null + ); + } + + return new AccommodationValidationResult( + true, + null, + null, + UUID.fromString(response.getHostId()), + new BigDecimal(response.getTotalPrice()), + response.getPricingMode(), + response.getApprovalMode() + ); + } +} diff --git a/src/main/java/com/devoops/reservation/grpc/AccommodationValidationResult.java b/src/main/java/com/devoops/reservation/grpc/AccommodationValidationResult.java new file mode 100644 index 0000000..92cdfa9 --- /dev/null +++ b/src/main/java/com/devoops/reservation/grpc/AccommodationValidationResult.java @@ -0,0 +1,18 @@ +package com.devoops.reservation.grpc; + +import java.math.BigDecimal; +import java.util.UUID; + +public record AccommodationValidationResult( + boolean valid, + String errorCode, + String errorMessage, + UUID hostId, + BigDecimal totalPrice, + String pricingMode, + String approvalMode +) { + public boolean isAutoApproval() { + return "AUTOMATIC".equals(approvalMode); + } +} diff --git a/src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java b/src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java new file mode 100644 index 0000000..cf9809e --- /dev/null +++ b/src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java @@ -0,0 +1,61 @@ +package com.devoops.reservation.service; + +import com.devoops.reservation.dto.message.ReservationCancelledMessage; +import com.devoops.reservation.dto.message.ReservationCreatedMessage; +import com.devoops.reservation.entity.Reservation; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ReservationEventPublisherService { + + private final RabbitTemplate rabbitTemplate; + + @Value("${rabbitmq.exchange.notification}") + private String notificationExchange; + + @Value("${rabbitmq.routing-key.reservation-created}") + private String reservationCreatedRoutingKey; + + @Value("${rabbitmq.routing-key.reservation-cancelled}") + private String reservationCancelledRoutingKey; + + public void publishReservationCreated(Reservation reservation) { + ReservationCreatedMessage message = new ReservationCreatedMessage( + reservation.getId(), + reservation.getAccommodationId(), + reservation.getGuestId(), + reservation.getHostId(), + reservation.getStartDate(), + reservation.getEndDate(), + reservation.getGuestCount(), + reservation.getTotalPrice(), + reservation.getStatus().name() + ); + + log.info("Publishing reservation created event: reservationId={}, status={}", + reservation.getId(), reservation.getStatus()); + + rabbitTemplate.convertAndSend(notificationExchange, reservationCreatedRoutingKey, message); + } + + public void publishReservationCancelled(Reservation reservation) { + ReservationCancelledMessage message = new ReservationCancelledMessage( + reservation.getId(), + reservation.getAccommodationId(), + reservation.getGuestId(), + reservation.getHostId(), + reservation.getStartDate(), + reservation.getEndDate() + ); + + log.info("Publishing reservation cancelled event: reservationId={}", reservation.getId()); + + rabbitTemplate.convertAndSend(notificationExchange, reservationCancelledRoutingKey, message); + } +} diff --git a/src/main/java/com/devoops/reservation/service/ReservationService.java b/src/main/java/com/devoops/reservation/service/ReservationService.java index 77c2ec5..a33bb42 100644 --- a/src/main/java/com/devoops/reservation/service/ReservationService.java +++ b/src/main/java/com/devoops/reservation/service/ReservationService.java @@ -5,9 +5,12 @@ import com.devoops.reservation.dto.response.ReservationResponse; import com.devoops.reservation.entity.Reservation; import com.devoops.reservation.entity.ReservationStatus; +import com.devoops.reservation.exception.AccommodationNotFoundException; import com.devoops.reservation.exception.ForbiddenException; import com.devoops.reservation.exception.InvalidReservationException; import com.devoops.reservation.exception.ReservationNotFoundException; +import com.devoops.reservation.grpc.AccommodationGrpcClient; +import com.devoops.reservation.grpc.AccommodationValidationResult; import com.devoops.reservation.mapper.ReservationMapper; import com.devoops.reservation.repository.ReservationRepository; import lombok.RequiredArgsConstructor; @@ -15,9 +18,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.math.BigDecimal; import java.time.LocalDate; -import java.time.temporal.ChronoUnit; import java.util.List; import java.util.UUID; @@ -28,20 +29,30 @@ public class ReservationService { private final ReservationRepository reservationRepository; private final ReservationMapper reservationMapper; + private final AccommodationGrpcClient accommodationGrpcClient; + private final ReservationEventPublisherService eventPublisher; @Transactional public ReservationResponse create(CreateReservationRequest request, UserContext userContext) { // Validate dates validateDates(request.startDate(), request.endDate()); - // TODO: Call Accommodation Service via gRPC to: - // 1. Validate accommodation exists - // 2. Get hostId - // 3. Validate guest count within min/max capacity - // 4. Validate dates within availability periods - // 5. Calculate totalPrice from pricing rules - UUID hostId = UUID.randomUUID(); // Placeholder - get from Accommodation Service - BigDecimal totalPrice = calculatePlaceholderPrice(request); // Placeholder - get from Accommodation Service + // Call Accommodation Service via gRPC to validate and calculate price + AccommodationValidationResult validationResult = accommodationGrpcClient.validateAndCalculatePrice( + request.accommodationId(), + request.startDate(), + request.endDate(), + request.guestCount() + ); + + if (!validationResult.valid()) { + if ("ACCOMMODATION_NOT_FOUND".equals(validationResult.errorCode())) { + throw new AccommodationNotFoundException(validationResult.errorMessage()); + } + throw new InvalidReservationException(validationResult.errorMessage()); + } + + UUID hostId = validationResult.hostId(); // Check for overlapping approved reservations List overlapping = reservationRepository.findOverlappingApproved( @@ -59,15 +70,22 @@ public ReservationResponse create(CreateReservationRequest request, UserContext Reservation reservation = reservationMapper.toEntity(request); reservation.setGuestId(userContext.userId()); reservation.setHostId(hostId); - reservation.setTotalPrice(totalPrice); - reservation.setStatus(ReservationStatus.PENDING); + reservation.setTotalPrice(validationResult.totalPrice()); + + // Handle auto-approval mode + if (validationResult.isAutoApproval()) { + reservation.setStatus(ReservationStatus.APPROVED); + log.info("Auto-approving reservation for accommodation {} (AUTOMATIC approval mode)", + request.accommodationId()); + } else { + reservation.setStatus(ReservationStatus.PENDING); + } reservation = reservationRepository.saveAndFlush(reservation); log.info("Created reservation {} for guest {} at accommodation {}", reservation.getId(), userContext.userId(), request.accommodationId()); - // TODO: Publish event to Notification Service via RabbitMQ - // notifyHost(reservation); + eventPublisher.publishReservationCreated(reservation); return reservationMapper.toResponse(reservation); } @@ -110,8 +128,39 @@ public void deleteRequest(UUID id, UserContext userContext) { reservation.setDeleted(true); reservationRepository.save(reservation); log.info("Guest {} deleted reservation request {}", userContext.userId(), id); + } + + @Transactional + public void cancelReservation(UUID id, UserContext userContext) { + Reservation reservation = findReservationOrThrow(id); + + // Only the guest who created the reservation can cancel it + if (!reservation.getGuestId().equals(userContext.userId())) { + throw new ForbiddenException("You can only cancel your own reservations"); + } - // TODO: Publish event to Notification Service via RabbitMQ + // Can only cancel APPROVED reservations (PENDING uses deleteRequest) + if (reservation.getStatus() != ReservationStatus.APPROVED) { + throw new InvalidReservationException( + "Only approved reservations can be cancelled. Use delete for pending requests. Current status: " + reservation.getStatus() + ); + } + + // Must be at least 1 day before startDate + LocalDate today = LocalDate.now(); + LocalDate cancellationDeadline = reservation.getStartDate().minusDays(1); + + if (!today.isBefore(cancellationDeadline)) { + throw new InvalidReservationException( + "Reservations can only be cancelled at least 1 day before the start date" + ); + } + + reservation.setStatus(ReservationStatus.CANCELLED); + reservationRepository.save(reservation); + log.info("Guest {} cancelled reservation {}", userContext.userId(), id); + + eventPublisher.publishReservationCancelled(reservation); } // === Helper Methods === @@ -137,16 +186,4 @@ private void validateAccessToReservation(Reservation reservation, UserContext us } } - /** - * Placeholder price calculation. - * TODO: Replace with actual pricing calculation from Accommodation Service via gRPC. - * This calculates price based on number of nights and guest count. - */ - private BigDecimal calculatePlaceholderPrice(CreateReservationRequest request) { - long nights = ChronoUnit.DAYS.between(request.startDate(), request.endDate()); - // Placeholder: $100 per night * guest count - return BigDecimal.valueOf(100) - .multiply(BigDecimal.valueOf(nights)) - .multiply(BigDecimal.valueOf(request.guestCount())); - } } diff --git a/src/main/proto/accommodation_internal.proto b/src/main/proto/accommodation_internal.proto new file mode 100644 index 0000000..7f8bb38 --- /dev/null +++ b/src/main/proto/accommodation_internal.proto @@ -0,0 +1,26 @@ +syntax = "proto3"; +package accommodation; + +option java_multiple_files = true; +option java_package = "com.devoops.reservation.grpc.proto.accommodation"; + +service AccommodationInternalService { + rpc ValidateAndCalculatePrice(ReservationValidationRequest) returns (ReservationValidationResponse); +} + +message ReservationValidationRequest { + string accommodation_id = 1; + string start_date = 2; + string end_date = 3; + int32 guest_count = 4; +} + +message ReservationValidationResponse { + bool valid = 1; + string error_code = 2; + string error_message = 3; + string host_id = 4; + string total_price = 5; + string pricing_mode = 6; + string approval_mode = 7; +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3dfb8ea..1e9a00c 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -32,3 +32,18 @@ management.prometheus.metrics.export.enabled=true # gRPC Server grpc.server.port=${GRPC_PORT:9090} + +# gRPC Client +grpc.client.accommodation-service.address=static://${ACCOMMODATION_GRPC_HOST:devoops-accommodation-service}:${ACCOMMODATION_GRPC_PORT:9090} +grpc.client.accommodation-service.negotiationType=plaintext + +# RabbitMQ +spring.rabbitmq.host=${RABBITMQ_HOST:devoops-rabbitmq} +spring.rabbitmq.port=${RABBITMQ_PORT:5672} +spring.rabbitmq.username=${RABBITMQ_USERNAME:devoops} +spring.rabbitmq.password=${RABBITMQ_PASSWORD:devoops123} + +# RabbitMQ routing +rabbitmq.exchange.notification=notification.exchange +rabbitmq.routing-key.reservation-created=notification.reservation.created +rabbitmq.routing-key.reservation-cancelled=notification.reservation.cancelled diff --git a/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java b/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java index 6b326d9..2aef842 100644 --- a/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java +++ b/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java @@ -344,4 +344,78 @@ void delete_WithHostRole_Returns403() throws Exception { .andExpect(status().isForbidden()); } } + + @Nested + @DisplayName("POST /api/reservation/{id}/cancel") + class CancelEndpoint { + + @Test + @DisplayName("With valid request returns 204") + void cancel_WithValidRequest_Returns204() throws Exception { + doNothing().when(reservationService).cancelReservation(eq(RESERVATION_ID), any(UserContext.class)); + + mockMvc.perform(post("/api/reservation/{id}/cancel", RESERVATION_ID) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("With non-existing ID returns 404") + void cancel_WithNonExistingId_Returns404() throws Exception { + UUID id = UUID.randomUUID(); + doThrow(new ReservationNotFoundException("Not found")) + .when(reservationService).cancelReservation(eq(id), any(UserContext.class)); + + mockMvc.perform(post("/api/reservation/{id}/cancel", id) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("With wrong owner returns 403") + void cancel_WithWrongOwner_Returns403() throws Exception { + doThrow(new ForbiddenException("Not the owner")) + .when(reservationService).cancelReservation(eq(RESERVATION_ID), any(UserContext.class)); + + mockMvc.perform(post("/api/reservation/{id}/cancel", RESERVATION_ID) + .header("X-User-Id", UUID.randomUUID().toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With non-approved status returns 400") + void cancel_WithNonApprovedStatus_Returns400() throws Exception { + doThrow(new InvalidReservationException("Only approved reservations can be cancelled")) + .when(reservationService).cancelReservation(eq(RESERVATION_ID), any(UserContext.class)); + + mockMvc.perform(post("/api/reservation/{id}/cancel", RESERVATION_ID) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("With less than 1 day before start returns 400") + void cancel_WithTooLate_Returns400() throws Exception { + doThrow(new InvalidReservationException("at least 1 day before")) + .when(reservationService).cancelReservation(eq(RESERVATION_ID), any(UserContext.class)); + + mockMvc.perform(post("/api/reservation/{id}/cancel", RESERVATION_ID) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("With HOST role returns 403") + void cancel_WithHostRole_Returns403() throws Exception { + mockMvc.perform(post("/api/reservation/{id}/cancel", RESERVATION_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isForbidden()); + } + } } diff --git a/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java b/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java index 4f0d014..4bd258f 100644 --- a/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java +++ b/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java @@ -5,9 +5,12 @@ import com.devoops.reservation.dto.response.ReservationResponse; import com.devoops.reservation.entity.Reservation; import com.devoops.reservation.entity.ReservationStatus; +import com.devoops.reservation.exception.AccommodationNotFoundException; import com.devoops.reservation.exception.ForbiddenException; import com.devoops.reservation.exception.InvalidReservationException; import com.devoops.reservation.exception.ReservationNotFoundException; +import com.devoops.reservation.grpc.AccommodationGrpcClient; +import com.devoops.reservation.grpc.AccommodationValidationResult; import com.devoops.reservation.mapper.ReservationMapper; import com.devoops.reservation.repository.ReservationRepository; import org.junit.jupiter.api.DisplayName; @@ -28,6 +31,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -39,6 +43,12 @@ class ReservationServiceTest { @Mock private ReservationMapper reservationMapper; + @Mock + private AccommodationGrpcClient accommodationGrpcClient; + + @Mock + private ReservationEventPublisherService eventPublisher; + @InjectMocks private ReservationService reservationService; @@ -91,7 +101,12 @@ void create_WithValidRequest_ReturnsReservationResponse() { var request = createRequest(); var reservation = createReservation(); var response = createResponse(); + var validationResult = new AccommodationValidationResult( + true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL" + ); + when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) + .thenReturn(validationResult); when(reservationRepository.findOverlappingApproved(any(), any(), any())) .thenReturn(List.of()); when(reservationMapper.toEntity(request)).thenReturn(reservation); @@ -104,6 +119,7 @@ void create_WithValidRequest_ReturnsReservationResponse() { assertThat(reservation.getGuestId()).isEqualTo(GUEST_ID); assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.PENDING); verify(reservationRepository).saveAndFlush(reservation); + verify(eventPublisher).publishReservationCreated(reservation); } @Test @@ -112,7 +128,12 @@ void create_WithOverlappingApproved_ThrowsInvalidReservationException() { var request = createRequest(); var existingReservation = createReservation(); existingReservation.setStatus(ReservationStatus.APPROVED); + var validationResult = new AccommodationValidationResult( + true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL" + ); + when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) + .thenReturn(validationResult); when(reservationRepository.findOverlappingApproved(any(), any(), any())) .thenReturn(List.of(existingReservation)); @@ -121,6 +142,62 @@ void create_WithOverlappingApproved_ThrowsInvalidReservationException() { .hasMessageContaining("overlap"); } + @Test + @DisplayName("With auto-approval mode auto-approves reservation") + void create_WithAutoApprovalMode_AutoApprovesReservation() { + var request = createRequest(); + var reservation = createReservation(); + var response = createResponse(); + var validationResult = new AccommodationValidationResult( + true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "AUTOMATIC" + ); + + when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) + .thenReturn(validationResult); + when(reservationRepository.findOverlappingApproved(any(), any(), any())) + .thenReturn(List.of()); + when(reservationMapper.toEntity(request)).thenReturn(reservation); + when(reservationRepository.saveAndFlush(reservation)).thenReturn(reservation); + when(reservationMapper.toResponse(reservation)).thenReturn(response); + + reservationService.create(request, GUEST_CONTEXT); + + assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.APPROVED); + verify(reservationRepository).saveAndFlush(reservation); + } + + @Test + @DisplayName("With accommodation not found throws AccommodationNotFoundException") + void create_WithAccommodationNotFound_ThrowsAccommodationNotFoundException() { + var request = createRequest(); + var validationResult = new AccommodationValidationResult( + false, "ACCOMMODATION_NOT_FOUND", "Accommodation not found", null, null, null, null + ); + + when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) + .thenReturn(validationResult); + + assertThatThrownBy(() -> reservationService.create(request, GUEST_CONTEXT)) + .isInstanceOf(AccommodationNotFoundException.class) + .hasMessageContaining("Accommodation not found"); + } + + @Test + @DisplayName("With invalid guest count throws InvalidReservationException") + void create_WithInvalidGuestCount_ThrowsInvalidReservationException() { + var request = createRequest(); + var validationResult = new AccommodationValidationResult( + false, "GUEST_COUNT_INVALID", "Guest count must be between 1 and 4", null, null, null, null + ); + + when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) + .thenReturn(validationResult); + + assertThatThrownBy(() -> reservationService.create(request, GUEST_CONTEXT)) + .isInstanceOf(InvalidReservationException.class) + .hasMessageContaining("Guest count"); + } + @Test @DisplayName("With end date before start date throws InvalidReservationException") void create_WithEndDateBeforeStartDate_ThrowsInvalidReservationException() { @@ -332,4 +409,88 @@ void deleteRequest_WithHostTryingToDelete_ThrowsForbiddenException() { .isInstanceOf(ForbiddenException.class); } } + + @Nested + @DisplayName("CancelReservation") + class CancelReservationTests { + + @Test + @DisplayName("With valid approved reservation cancels successfully") + void cancelReservation_WithValidApproved_CancelsSuccessfully() { + var reservation = createReservation(); + reservation.setStatus(ReservationStatus.APPROVED); + reservation.setStartDate(LocalDate.now().plusDays(10)); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + reservationService.cancelReservation(RESERVATION_ID, GUEST_CONTEXT); + + assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.CANCELLED); + verify(reservationRepository).save(reservation); + verify(eventPublisher).publishReservationCancelled(reservation); + } + + @Test + @DisplayName("With wrong owner throws ForbiddenException") + void cancelReservation_WithWrongOwner_ThrowsForbiddenException() { + var reservation = createReservation(); + reservation.setStatus(ReservationStatus.APPROVED); + var otherUser = new UserContext(UUID.randomUUID(), "GUEST"); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.cancelReservation(RESERVATION_ID, otherUser)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("only cancel your own"); + } + + @Test + @DisplayName("With pending status throws InvalidReservationException") + void cancelReservation_WithPendingStatus_ThrowsInvalidReservationException() { + var reservation = createReservation(); + reservation.setStatus(ReservationStatus.PENDING); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.cancelReservation(RESERVATION_ID, GUEST_CONTEXT)) + .isInstanceOf(InvalidReservationException.class) + .hasMessageContaining("Only approved reservations"); + } + + @Test + @DisplayName("With less than 1 day before start throws InvalidReservationException") + void cancelReservation_WithLessThanOneDayBefore_ThrowsInvalidReservationException() { + var reservation = createReservation(); + reservation.setStatus(ReservationStatus.APPROVED); + reservation.setStartDate(LocalDate.now()); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.cancelReservation(RESERVATION_ID, GUEST_CONTEXT)) + .isInstanceOf(InvalidReservationException.class) + .hasMessageContaining("at least 1 day before"); + } + + @Test + @DisplayName("With non-existing ID throws ReservationNotFoundException") + void cancelReservation_WithNonExistingId_ThrowsReservationNotFoundException() { + UUID id = UUID.randomUUID(); + when(reservationRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> reservationService.cancelReservation(id, GUEST_CONTEXT)) + .isInstanceOf(ReservationNotFoundException.class); + } + + @Test + @DisplayName("Host cannot cancel guest's reservation") + void cancelReservation_WithHostTryingToCancel_ThrowsForbiddenException() { + var reservation = createReservation(); + reservation.setStatus(ReservationStatus.APPROVED); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.cancelReservation(RESERVATION_ID, HOST_CONTEXT)) + .isInstanceOf(ForbiddenException.class); + } + } } From baa3d2149e25a53eb2fb6145ad04a855f2805ee6 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Tue, 17 Feb 2026 19:34:43 +0100 Subject: [PATCH 13/19] feat: Add user and accommodation info fetching. --- .../message/ReservationCancelledMessage.java | 13 ++-- .../message/ReservationCreatedMessage.java | 16 +++-- .../grpc/AccommodationGrpcClient.java | 4 +- .../grpc/AccommodationValidationResult.java | 3 +- .../reservation/grpc/UserGrpcClient.java | 50 ++++++++++++++++ .../reservation/grpc/UserSummaryResult.java | 16 +++++ .../ReservationEventPublisherService.java | 59 ++++++++++++++----- .../service/ReservationService.java | 13 +++- src/main/proto/accommodation_internal.proto | 1 + src/main/proto/user_internal.proto | 22 +++++++ src/main/resources/application.properties | 6 +- .../ReservationIntegrationTest.java | 47 +++++++++++++++ .../service/ReservationServiceTest.java | 19 +++--- 13 files changed, 227 insertions(+), 42 deletions(-) create mode 100644 src/main/java/com/devoops/reservation/grpc/UserGrpcClient.java create mode 100644 src/main/java/com/devoops/reservation/grpc/UserSummaryResult.java create mode 100644 src/main/proto/user_internal.proto diff --git a/src/main/java/com/devoops/reservation/dto/message/ReservationCancelledMessage.java b/src/main/java/com/devoops/reservation/dto/message/ReservationCancelledMessage.java index 8258e46..135cb1e 100644 --- a/src/main/java/com/devoops/reservation/dto/message/ReservationCancelledMessage.java +++ b/src/main/java/com/devoops/reservation/dto/message/ReservationCancelledMessage.java @@ -4,11 +4,12 @@ import java.util.UUID; public record ReservationCancelledMessage( - UUID reservationId, - UUID accommodationId, - UUID guestId, - UUID hostId, - LocalDate startDate, - LocalDate endDate + UUID userId, + String userEmail, + String guestName, + String accommodationName, + LocalDate checkIn, + LocalDate checkOut, + String reason ) { } diff --git a/src/main/java/com/devoops/reservation/dto/message/ReservationCreatedMessage.java b/src/main/java/com/devoops/reservation/dto/message/ReservationCreatedMessage.java index c92d0f1..eb6fc17 100644 --- a/src/main/java/com/devoops/reservation/dto/message/ReservationCreatedMessage.java +++ b/src/main/java/com/devoops/reservation/dto/message/ReservationCreatedMessage.java @@ -5,14 +5,12 @@ import java.util.UUID; public record ReservationCreatedMessage( - UUID reservationId, - UUID accommodationId, - UUID guestId, - UUID hostId, - LocalDate startDate, - LocalDate endDate, - int guestCount, - BigDecimal totalPrice, - String status + UUID userId, + String userEmail, + String guestName, + String accommodationName, + LocalDate checkIn, + LocalDate checkOut, + BigDecimal totalPrice ) { } diff --git a/src/main/java/com/devoops/reservation/grpc/AccommodationGrpcClient.java b/src/main/java/com/devoops/reservation/grpc/AccommodationGrpcClient.java index c97d670..08730b9 100644 --- a/src/main/java/com/devoops/reservation/grpc/AccommodationGrpcClient.java +++ b/src/main/java/com/devoops/reservation/grpc/AccommodationGrpcClient.java @@ -46,6 +46,7 @@ public AccommodationValidationResult validateAndCalculatePrice( null, null, null, + null, null ); } @@ -57,7 +58,8 @@ public AccommodationValidationResult validateAndCalculatePrice( UUID.fromString(response.getHostId()), new BigDecimal(response.getTotalPrice()), response.getPricingMode(), - response.getApprovalMode() + response.getApprovalMode(), + response.getAccommodationName() ); } } diff --git a/src/main/java/com/devoops/reservation/grpc/AccommodationValidationResult.java b/src/main/java/com/devoops/reservation/grpc/AccommodationValidationResult.java index 92cdfa9..e783932 100644 --- a/src/main/java/com/devoops/reservation/grpc/AccommodationValidationResult.java +++ b/src/main/java/com/devoops/reservation/grpc/AccommodationValidationResult.java @@ -10,7 +10,8 @@ public record AccommodationValidationResult( UUID hostId, BigDecimal totalPrice, String pricingMode, - String approvalMode + String approvalMode, + String accommodationName ) { public boolean isAutoApproval() { return "AUTOMATIC".equals(approvalMode); diff --git a/src/main/java/com/devoops/reservation/grpc/UserGrpcClient.java b/src/main/java/com/devoops/reservation/grpc/UserGrpcClient.java new file mode 100644 index 0000000..92859a7 --- /dev/null +++ b/src/main/java/com/devoops/reservation/grpc/UserGrpcClient.java @@ -0,0 +1,50 @@ +package com.devoops.reservation.grpc; + +import com.devoops.reservation.grpc.proto.user.GetUserSummaryRequest; +import com.devoops.reservation.grpc.proto.user.GetUserSummaryResponse; +import com.devoops.reservation.grpc.proto.user.UserInternalServiceGrpc; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.client.inject.GrpcClient; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +@Slf4j +public class UserGrpcClient { + + @GrpcClient("user-service") + private UserInternalServiceGrpc.UserInternalServiceBlockingStub userStub; + + public UserSummaryResult getUserSummary(UUID userId) { + log.debug("Calling user service for user summary: userId={}", userId); + + GetUserSummaryRequest request = GetUserSummaryRequest.newBuilder() + .setUserId(userId.toString()) + .build(); + + GetUserSummaryResponse response = userStub.getUserSummary(request); + + log.debug("Received user summary response: found={}", response.getFound()); + + if (!response.getFound()) { + return new UserSummaryResult( + false, + null, + null, + null, + null, + null + ); + } + + return new UserSummaryResult( + true, + UUID.fromString(response.getUserId()), + response.getEmail(), + response.getFirstName(), + response.getLastName(), + response.getRole() + ); + } +} diff --git a/src/main/java/com/devoops/reservation/grpc/UserSummaryResult.java b/src/main/java/com/devoops/reservation/grpc/UserSummaryResult.java new file mode 100644 index 0000000..6b1e0d4 --- /dev/null +++ b/src/main/java/com/devoops/reservation/grpc/UserSummaryResult.java @@ -0,0 +1,16 @@ +package com.devoops.reservation.grpc; + +import java.util.UUID; + +public record UserSummaryResult( + boolean found, + UUID userId, + String email, + String firstName, + String lastName, + String role +) { + public String getFullName() { + return firstName + " " + lastName; + } +} diff --git a/src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java b/src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java index cf9809e..f92c0ac 100644 --- a/src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java +++ b/src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java @@ -3,6 +3,8 @@ import com.devoops.reservation.dto.message.ReservationCancelledMessage; import com.devoops.reservation.dto.message.ReservationCreatedMessage; import com.devoops.reservation.entity.Reservation; +import com.devoops.reservation.grpc.UserGrpcClient; +import com.devoops.reservation.grpc.UserSummaryResult; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.rabbit.core.RabbitTemplate; @@ -15,6 +17,7 @@ public class ReservationEventPublisherService { private final RabbitTemplate rabbitTemplate; + private final UserGrpcClient userGrpcClient; @Value("${rabbitmq.exchange.notification}") private String notificationExchange; @@ -25,36 +28,62 @@ public class ReservationEventPublisherService { @Value("${rabbitmq.routing-key.reservation-cancelled}") private String reservationCancelledRoutingKey; - public void publishReservationCreated(Reservation reservation) { + public void publishReservationCreated(Reservation reservation, String accommodationName) { + UserSummaryResult hostSummary = userGrpcClient.getUserSummary(reservation.getHostId()); + UserSummaryResult guestSummary = userGrpcClient.getUserSummary(reservation.getGuestId()); + + if (!hostSummary.found()) { + log.warn("Host not found for reservation {}, skipping notification", reservation.getId()); + return; + } + + if (!guestSummary.found()) { + log.warn("Guest not found for reservation {}, skipping notification", reservation.getId()); + return; + } + ReservationCreatedMessage message = new ReservationCreatedMessage( - reservation.getId(), - reservation.getAccommodationId(), - reservation.getGuestId(), reservation.getHostId(), + hostSummary.email(), + guestSummary.getFullName(), + accommodationName, reservation.getStartDate(), reservation.getEndDate(), - reservation.getGuestCount(), - reservation.getTotalPrice(), - reservation.getStatus().name() + reservation.getTotalPrice() ); - log.info("Publishing reservation created event: reservationId={}, status={}", - reservation.getId(), reservation.getStatus()); + log.info("Publishing reservation created event: reservationId={}, hostEmail={}, guestName={}", + reservation.getId(), hostSummary.email(), guestSummary.getFullName()); rabbitTemplate.convertAndSend(notificationExchange, reservationCreatedRoutingKey, message); } - public void publishReservationCancelled(Reservation reservation) { + public void publishReservationCancelled(Reservation reservation, String accommodationName) { + UserSummaryResult hostSummary = userGrpcClient.getUserSummary(reservation.getHostId()); + UserSummaryResult guestSummary = userGrpcClient.getUserSummary(reservation.getGuestId()); + + if (!hostSummary.found()) { + log.warn("Host not found for reservation {}, skipping notification", reservation.getId()); + return; + } + + if (!guestSummary.found()) { + log.warn("Guest not found for reservation {}, skipping notification", reservation.getId()); + return; + } + ReservationCancelledMessage message = new ReservationCancelledMessage( - reservation.getId(), - reservation.getAccommodationId(), - reservation.getGuestId(), reservation.getHostId(), + hostSummary.email(), + guestSummary.getFullName(), + accommodationName, reservation.getStartDate(), - reservation.getEndDate() + reservation.getEndDate(), + "Guest cancelled the reservation" ); - log.info("Publishing reservation cancelled event: reservationId={}", reservation.getId()); + log.info("Publishing reservation cancelled event: reservationId={}, hostEmail={}, guestName={}", + reservation.getId(), hostSummary.email(), guestSummary.getFullName()); rabbitTemplate.convertAndSend(notificationExchange, reservationCancelledRoutingKey, message); } diff --git a/src/main/java/com/devoops/reservation/service/ReservationService.java b/src/main/java/com/devoops/reservation/service/ReservationService.java index a33bb42..8e807dc 100644 --- a/src/main/java/com/devoops/reservation/service/ReservationService.java +++ b/src/main/java/com/devoops/reservation/service/ReservationService.java @@ -85,7 +85,7 @@ public ReservationResponse create(CreateReservationRequest request, UserContext log.info("Created reservation {} for guest {} at accommodation {}", reservation.getId(), userContext.userId(), request.accommodationId()); - eventPublisher.publishReservationCreated(reservation); + eventPublisher.publishReservationCreated(reservation, validationResult.accommodationName()); return reservationMapper.toResponse(reservation); } @@ -160,7 +160,16 @@ public void cancelReservation(UUID id, UserContext userContext) { reservationRepository.save(reservation); log.info("Guest {} cancelled reservation {}", userContext.userId(), id); - eventPublisher.publishReservationCancelled(reservation); + // Fetch accommodation name for notification + AccommodationValidationResult accommodationInfo = accommodationGrpcClient.validateAndCalculatePrice( + reservation.getAccommodationId(), + reservation.getStartDate(), + reservation.getEndDate(), + reservation.getGuestCount() + ); + String accommodationName = accommodationInfo.valid() ? accommodationInfo.accommodationName() : "Unknown Accommodation"; + + eventPublisher.publishReservationCancelled(reservation, accommodationName); } // === Helper Methods === diff --git a/src/main/proto/accommodation_internal.proto b/src/main/proto/accommodation_internal.proto index 7f8bb38..0489750 100644 --- a/src/main/proto/accommodation_internal.proto +++ b/src/main/proto/accommodation_internal.proto @@ -23,4 +23,5 @@ message ReservationValidationResponse { string total_price = 5; string pricing_mode = 6; string approval_mode = 7; + string accommodation_name = 8; } diff --git a/src/main/proto/user_internal.proto b/src/main/proto/user_internal.proto new file mode 100644 index 0000000..ce2fd57 --- /dev/null +++ b/src/main/proto/user_internal.proto @@ -0,0 +1,22 @@ +syntax = "proto3"; +package user; + +option java_multiple_files = true; +option java_package = "com.devoops.reservation.grpc.proto.user"; + +service UserInternalService { + rpc GetUserSummary(GetUserSummaryRequest) returns (GetUserSummaryResponse); +} + +message GetUserSummaryRequest { + string user_id = 1; +} + +message GetUserSummaryResponse { + bool found = 1; + string user_id = 2; + string email = 3; + string first_name = 4; + string last_name = 5; + string role = 6; +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1e9a00c..a254766 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -33,10 +33,14 @@ management.prometheus.metrics.export.enabled=true # gRPC Server grpc.server.port=${GRPC_PORT:9090} -# gRPC Client +# gRPC Client - Accommodation Service grpc.client.accommodation-service.address=static://${ACCOMMODATION_GRPC_HOST:devoops-accommodation-service}:${ACCOMMODATION_GRPC_PORT:9090} grpc.client.accommodation-service.negotiationType=plaintext +# gRPC Client - User Service +grpc.client.user-service.address=static://${USER_GRPC_HOST:devoops-user-service}:${USER_GRPC_PORT:9090} +grpc.client.user-service.negotiationType=plaintext + # RabbitMQ spring.rabbitmq.host=${RABBITMQ_HOST:devoops-rabbitmq} spring.rabbitmq.port=${RABBITMQ_PORT:5672} diff --git a/src/test/java/com/devoops/reservation/integration/ReservationIntegrationTest.java b/src/test/java/com/devoops/reservation/integration/ReservationIntegrationTest.java index 1e100d4..b3d6f62 100644 --- a/src/test/java/com/devoops/reservation/integration/ReservationIntegrationTest.java +++ b/src/test/java/com/devoops/reservation/integration/ReservationIntegrationTest.java @@ -1,6 +1,11 @@ package com.devoops.reservation.integration; +import com.devoops.reservation.grpc.AccommodationGrpcClient; +import com.devoops.reservation.grpc.AccommodationValidationResult; +import com.devoops.reservation.grpc.UserGrpcClient; +import com.devoops.reservation.grpc.UserSummaryResult; import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; @@ -9,16 +14,22 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.testcontainers.containers.PostgreSQLContainer; import org.testcontainers.junit.jupiter.Container; import org.testcontainers.junit.jupiter.Testcontainers; +import java.math.BigDecimal; import java.time.LocalDate; import java.util.Map; import java.util.UUID; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.when; + import static org.hamcrest.Matchers.*; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; @@ -39,6 +50,15 @@ class ReservationIntegrationTest { @Autowired private MockMvc mockMvc; + @MockitoBean + private AccommodationGrpcClient accommodationGrpcClient; + + @MockitoBean + private UserGrpcClient userGrpcClient; + + @MockitoBean + private RabbitTemplate rabbitTemplate; + private final ObjectMapper objectMapper = new ObjectMapper(); private static String reservationId; @@ -49,6 +69,33 @@ class ReservationIntegrationTest { private static final String BASE_PATH = "/api/reservation"; + @BeforeEach + void setUpMocks() { + AccommodationValidationResult validResult = new AccommodationValidationResult( + true, + null, + null, + HOST_ID, + new BigDecimal("500.00"), + "PER_ACCOMMODATION", + "MANUAL", + "Test Accommodation" + ); + when(accommodationGrpcClient.validateAndCalculatePrice(any(UUID.class), any(LocalDate.class), any(LocalDate.class), anyInt())) + .thenReturn(validResult); + + UserSummaryResult hostSummary = new UserSummaryResult( + true, + HOST_ID, + "host@example.com", + "Test", + "Host", + "HOST" + ); + when(userGrpcClient.getUserSummary(any(UUID.class))) + .thenReturn(hostSummary); + } + @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", postgres::getJdbcUrl); diff --git a/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java b/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java index 4bd258f..5903b73 100644 --- a/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java +++ b/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java @@ -102,7 +102,7 @@ void create_WithValidRequest_ReturnsReservationResponse() { var reservation = createReservation(); var response = createResponse(); var validationResult = new AccommodationValidationResult( - true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL" + true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL", "Test Accommodation" ); when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) @@ -119,7 +119,7 @@ true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL" assertThat(reservation.getGuestId()).isEqualTo(GUEST_ID); assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.PENDING); verify(reservationRepository).saveAndFlush(reservation); - verify(eventPublisher).publishReservationCreated(reservation); + verify(eventPublisher).publishReservationCreated(reservation, "Test Accommodation"); } @Test @@ -129,7 +129,7 @@ void create_WithOverlappingApproved_ThrowsInvalidReservationException() { var existingReservation = createReservation(); existingReservation.setStatus(ReservationStatus.APPROVED); var validationResult = new AccommodationValidationResult( - true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL" + true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL", "Test Accommodation" ); when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) @@ -149,7 +149,7 @@ void create_WithAutoApprovalMode_AutoApprovesReservation() { var reservation = createReservation(); var response = createResponse(); var validationResult = new AccommodationValidationResult( - true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "AUTOMATIC" + true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "AUTOMATIC", "Test Accommodation" ); when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) @@ -171,7 +171,7 @@ true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "AUTOMATIC" void create_WithAccommodationNotFound_ThrowsAccommodationNotFoundException() { var request = createRequest(); var validationResult = new AccommodationValidationResult( - false, "ACCOMMODATION_NOT_FOUND", "Accommodation not found", null, null, null, null + false, "ACCOMMODATION_NOT_FOUND", "Accommodation not found", null, null, null, null, null ); when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) @@ -187,7 +187,7 @@ void create_WithAccommodationNotFound_ThrowsAccommodationNotFoundException() { void create_WithInvalidGuestCount_ThrowsInvalidReservationException() { var request = createRequest(); var validationResult = new AccommodationValidationResult( - false, "GUEST_COUNT_INVALID", "Guest count must be between 1 and 4", null, null, null, null + false, "GUEST_COUNT_INVALID", "Guest count must be between 1 and 4", null, null, null, null, null ); when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) @@ -420,14 +420,19 @@ void cancelReservation_WithValidApproved_CancelsSuccessfully() { var reservation = createReservation(); reservation.setStatus(ReservationStatus.APPROVED); reservation.setStartDate(LocalDate.now().plusDays(10)); + var accommodationResult = new AccommodationValidationResult( + true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL", "Test Accommodation" + ); when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) + .thenReturn(accommodationResult); reservationService.cancelReservation(RESERVATION_ID, GUEST_CONTEXT); assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.CANCELLED); verify(reservationRepository).save(reservation); - verify(eventPublisher).publishReservationCancelled(reservation); + verify(eventPublisher).publishReservationCancelled(reservation, "Test Accommodation"); } @Test From 436c936780dd7171443466d8bf6ff476385c4721 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:03:54 +0100 Subject: [PATCH 14/19] feat: Add reservation approval. --- .../controller/ReservationController.java | 21 +- .../message/ReservationResponseMessage.java | 18 ++ .../ReservationWithGuestInfoResponse.java | 40 ++++ .../ReservationEventPublisherService.java | 38 ++++ .../service/ReservationService.java | 95 ++++++++- src/main/resources/application.properties | 1 + .../controller/ReservationControllerTest.java | 162 +++++++++++++- .../ReservationEventPublisherServiceTest.java | 161 ++++++++++++++ .../service/ReservationServiceTest.java | 199 ++++++++++++++++++ 9 files changed, 724 insertions(+), 11 deletions(-) create mode 100644 src/main/java/com/devoops/reservation/dto/message/ReservationResponseMessage.java create mode 100644 src/main/java/com/devoops/reservation/dto/response/ReservationWithGuestInfoResponse.java create mode 100644 src/test/java/com/devoops/reservation/service/ReservationEventPublisherServiceTest.java diff --git a/src/main/java/com/devoops/reservation/controller/ReservationController.java b/src/main/java/com/devoops/reservation/controller/ReservationController.java index ff4505f..8c47b2d 100644 --- a/src/main/java/com/devoops/reservation/controller/ReservationController.java +++ b/src/main/java/com/devoops/reservation/controller/ReservationController.java @@ -4,6 +4,7 @@ import com.devoops.reservation.config.UserContext; import com.devoops.reservation.dto.request.CreateReservationRequest; import com.devoops.reservation.dto.response.ReservationResponse; +import com.devoops.reservation.dto.response.ReservationWithGuestInfoResponse; import com.devoops.reservation.service.ReservationService; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -47,8 +48,8 @@ public ResponseEntity> getByGuest(UserContext userCont @GetMapping("/host") @RequireRole("HOST") - public ResponseEntity> getByHost(UserContext userContext) { - return ResponseEntity.ok(reservationService.getByHostId(userContext)); + public ResponseEntity> getByHost(UserContext userContext) { + return ResponseEntity.ok(reservationService.getByHostIdWithGuestInfo(userContext)); } @@ -69,4 +70,20 @@ public ResponseEntity cancel( reservationService.cancelReservation(id, userContext); return ResponseEntity.noContent().build(); } + + @PutMapping("/{id}/approve") + @RequireRole("HOST") + public ResponseEntity approve( + @PathVariable UUID id, + UserContext userContext) { + return ResponseEntity.ok(reservationService.approveReservation(id, userContext)); + } + + @PutMapping("/{id}/reject") + @RequireRole("HOST") + public ResponseEntity reject( + @PathVariable UUID id, + UserContext userContext) { + return ResponseEntity.ok(reservationService.rejectReservation(id, userContext)); + } } diff --git a/src/main/java/com/devoops/reservation/dto/message/ReservationResponseMessage.java b/src/main/java/com/devoops/reservation/dto/message/ReservationResponseMessage.java new file mode 100644 index 0000000..7c54186 --- /dev/null +++ b/src/main/java/com/devoops/reservation/dto/message/ReservationResponseMessage.java @@ -0,0 +1,18 @@ +package com.devoops.reservation.dto.message; + +import java.time.LocalDate; +import java.util.UUID; + +public record ReservationResponseMessage( + UUID userId, + String userEmail, + String hostName, + String accommodationName, + ReservationResponseStatus status, + LocalDate checkIn, + LocalDate checkOut +) { + public enum ReservationResponseStatus { + APPROVED, DECLINED + } +} diff --git a/src/main/java/com/devoops/reservation/dto/response/ReservationWithGuestInfoResponse.java b/src/main/java/com/devoops/reservation/dto/response/ReservationWithGuestInfoResponse.java new file mode 100644 index 0000000..6bf9f3c --- /dev/null +++ b/src/main/java/com/devoops/reservation/dto/response/ReservationWithGuestInfoResponse.java @@ -0,0 +1,40 @@ +package com.devoops.reservation.dto.response; + +import com.devoops.reservation.entity.ReservationStatus; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +public record ReservationWithGuestInfoResponse( + UUID id, + UUID accommodationId, + UUID guestId, + UUID hostId, + LocalDate startDate, + LocalDate endDate, + int guestCount, + BigDecimal totalPrice, + ReservationStatus status, + LocalDateTime createdAt, + LocalDateTime updatedAt, + long guestCancellationCount +) { + public static ReservationWithGuestInfoResponse from(ReservationResponse response, long cancellationCount) { + return new ReservationWithGuestInfoResponse( + response.id(), + response.accommodationId(), + response.guestId(), + response.hostId(), + response.startDate(), + response.endDate(), + response.guestCount(), + response.totalPrice(), + response.status(), + response.createdAt(), + response.updatedAt(), + cancellationCount + ); + } +} diff --git a/src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java b/src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java index f92c0ac..f2d6d43 100644 --- a/src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java +++ b/src/main/java/com/devoops/reservation/service/ReservationEventPublisherService.java @@ -2,6 +2,7 @@ import com.devoops.reservation.dto.message.ReservationCancelledMessage; import com.devoops.reservation.dto.message.ReservationCreatedMessage; +import com.devoops.reservation.dto.message.ReservationResponseMessage; import com.devoops.reservation.entity.Reservation; import com.devoops.reservation.grpc.UserGrpcClient; import com.devoops.reservation.grpc.UserSummaryResult; @@ -28,6 +29,9 @@ public class ReservationEventPublisherService { @Value("${rabbitmq.routing-key.reservation-cancelled}") private String reservationCancelledRoutingKey; + @Value("${rabbitmq.routing-key.reservation-response}") + private String reservationResponseRoutingKey; + public void publishReservationCreated(Reservation reservation, String accommodationName) { UserSummaryResult hostSummary = userGrpcClient.getUserSummary(reservation.getHostId()); UserSummaryResult guestSummary = userGrpcClient.getUserSummary(reservation.getGuestId()); @@ -87,4 +91,38 @@ public void publishReservationCancelled(Reservation reservation, String accommod rabbitTemplate.convertAndSend(notificationExchange, reservationCancelledRoutingKey, message); } + + public void publishReservationResponse(Reservation reservation, String accommodationName, boolean approved) { + UserSummaryResult guestSummary = userGrpcClient.getUserSummary(reservation.getGuestId()); + UserSummaryResult hostSummary = userGrpcClient.getUserSummary(reservation.getHostId()); + + if (!guestSummary.found()) { + log.warn("Guest not found for reservation {}, skipping notification", reservation.getId()); + return; + } + + if (!hostSummary.found()) { + log.warn("Host not found for reservation {}, skipping notification", reservation.getId()); + return; + } + + ReservationResponseMessage.ReservationResponseStatus status = approved + ? ReservationResponseMessage.ReservationResponseStatus.APPROVED + : ReservationResponseMessage.ReservationResponseStatus.DECLINED; + + ReservationResponseMessage message = new ReservationResponseMessage( + reservation.getGuestId(), + guestSummary.email(), + hostSummary.getFullName(), + accommodationName, + status, + reservation.getStartDate(), + reservation.getEndDate() + ); + + log.info("Publishing reservation response event: reservationId={}, guestEmail={}, status={}", + reservation.getId(), guestSummary.email(), status); + + rabbitTemplate.convertAndSend(notificationExchange, reservationResponseRoutingKey, message); + } } diff --git a/src/main/java/com/devoops/reservation/service/ReservationService.java b/src/main/java/com/devoops/reservation/service/ReservationService.java index 8e807dc..3379bce 100644 --- a/src/main/java/com/devoops/reservation/service/ReservationService.java +++ b/src/main/java/com/devoops/reservation/service/ReservationService.java @@ -3,6 +3,7 @@ import com.devoops.reservation.config.UserContext; import com.devoops.reservation.dto.request.CreateReservationRequest; import com.devoops.reservation.dto.response.ReservationResponse; +import com.devoops.reservation.dto.response.ReservationWithGuestInfoResponse; import com.devoops.reservation.entity.Reservation; import com.devoops.reservation.entity.ReservationStatus; import com.devoops.reservation.exception.AccommodationNotFoundException; @@ -109,6 +110,19 @@ public List getByHostId(UserContext userContext) { return reservationMapper.toResponseList(reservations); } + @Transactional(readOnly = true) + public List getByHostIdWithGuestInfo(UserContext userContext) { + List reservations = reservationRepository.findByHostId(userContext.userId()); + return reservations.stream() + .map(reservation -> { + ReservationResponse response = reservationMapper.toResponse(reservation); + long cancellationCount = reservationRepository.countByGuestIdAndStatus( + reservation.getGuestId(), ReservationStatus.CANCELLED); + return ReservationWithGuestInfoResponse.from(response, cancellationCount); + }) + .toList(); + } + @Transactional public void deleteRequest(UUID id, UserContext userContext) { Reservation reservation = findReservationOrThrow(id); @@ -160,16 +174,78 @@ public void cancelReservation(UUID id, UserContext userContext) { reservationRepository.save(reservation); log.info("Guest {} cancelled reservation {}", userContext.userId(), id); - // Fetch accommodation name for notification - AccommodationValidationResult accommodationInfo = accommodationGrpcClient.validateAndCalculatePrice( + String accommodationName = fetchAccommodationName(reservation); + eventPublisher.publishReservationCancelled(reservation, accommodationName); + } + + @Transactional + public ReservationResponse approveReservation(UUID id, UserContext userContext) { + Reservation reservation = findReservationOrThrow(id); + + // Only the host who owns this reservation can approve it + if (!reservation.getHostId().equals(userContext.userId())) { + throw new ForbiddenException("You can only approve reservations for your own accommodations"); + } + + // Can only approve PENDING reservations + if (reservation.getStatus() != ReservationStatus.PENDING) { + throw new InvalidReservationException( + "Only pending reservations can be approved. Current status: " + reservation.getStatus() + ); + } + + // Approve the reservation + reservation.setStatus(ReservationStatus.APPROVED); + reservationRepository.save(reservation); + log.info("Host {} approved reservation {}", userContext.userId(), id); + + // Auto-reject overlapping pending reservations + List overlappingPending = reservationRepository.findOverlappingPending( reservation.getAccommodationId(), reservation.getStartDate(), reservation.getEndDate(), - reservation.getGuestCount() + reservation.getId() ); - String accommodationName = accommodationInfo.valid() ? accommodationInfo.accommodationName() : "Unknown Accommodation"; - eventPublisher.publishReservationCancelled(reservation, accommodationName); + for (Reservation overlapping : overlappingPending) { + overlapping.setStatus(ReservationStatus.REJECTED); + reservationRepository.save(overlapping); + log.info("Auto-rejected overlapping reservation {} due to approval of reservation {}", + overlapping.getId(), id); + } + + // Fetch accommodation name and publish notification + String accommodationName = fetchAccommodationName(reservation); + eventPublisher.publishReservationResponse(reservation, accommodationName, true); + + return reservationMapper.toResponse(reservation); + } + + @Transactional + public ReservationResponse rejectReservation(UUID id, UserContext userContext) { + Reservation reservation = findReservationOrThrow(id); + + // Only the host who owns this reservation can reject it + if (!reservation.getHostId().equals(userContext.userId())) { + throw new ForbiddenException("You can only reject reservations for your own accommodations"); + } + + // Can only reject PENDING reservations + if (reservation.getStatus() != ReservationStatus.PENDING) { + throw new InvalidReservationException( + "Only pending reservations can be rejected. Current status: " + reservation.getStatus() + ); + } + + reservation.setStatus(ReservationStatus.REJECTED); + reservationRepository.save(reservation); + log.info("Host {} rejected reservation {}", userContext.userId(), id); + + // Fetch accommodation name and publish notification + String accommodationName = fetchAccommodationName(reservation); + eventPublisher.publishReservationResponse(reservation, accommodationName, false); + + return reservationMapper.toResponse(reservation); } // === Helper Methods === @@ -195,4 +271,13 @@ private void validateAccessToReservation(Reservation reservation, UserContext us } } + private String fetchAccommodationName(Reservation reservation) { + AccommodationValidationResult accommodationInfo = accommodationGrpcClient.validateAndCalculatePrice( + reservation.getAccommodationId(), + reservation.getStartDate(), + reservation.getEndDate(), + reservation.getGuestCount() + ); + return accommodationInfo.valid() ? accommodationInfo.accommodationName() : "Unknown Accommodation"; + } } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a254766..be18197 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -51,3 +51,4 @@ spring.rabbitmq.password=${RABBITMQ_PASSWORD:devoops123} rabbitmq.exchange.notification=notification.exchange rabbitmq.routing-key.reservation-created=notification.reservation.created rabbitmq.routing-key.reservation-cancelled=notification.reservation.cancelled +rabbitmq.routing-key.reservation-response=notification.reservation.response diff --git a/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java b/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java index 2aef842..f7c4464 100644 --- a/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java +++ b/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java @@ -4,6 +4,7 @@ import com.devoops.reservation.config.UserContext; import com.devoops.reservation.config.UserContextResolver; import com.devoops.reservation.dto.response.ReservationResponse; +import com.devoops.reservation.dto.response.ReservationWithGuestInfoResponse; import com.devoops.reservation.entity.ReservationStatus; import com.devoops.reservation.exception.ForbiddenException; import com.devoops.reservation.exception.GlobalExceptionHandler; @@ -74,6 +75,28 @@ private ReservationResponse createResponse() { ); } + private ReservationResponse createApprovedResponse() { + return new ReservationResponse( + RESERVATION_ID, ACCOMMODATION_ID, GUEST_ID, HOST_ID, + LocalDate.now().plusDays(10), LocalDate.now().plusDays(15), + 2, new BigDecimal("1000.00"), ReservationStatus.APPROVED, + LocalDateTime.now(), LocalDateTime.now() + ); + } + + private ReservationResponse createRejectedResponse() { + return new ReservationResponse( + RESERVATION_ID, ACCOMMODATION_ID, GUEST_ID, HOST_ID, + LocalDate.now().plusDays(10), LocalDate.now().plusDays(15), + 2, new BigDecimal("1000.00"), ReservationStatus.REJECTED, + LocalDateTime.now(), LocalDateTime.now() + ); + } + + private ReservationWithGuestInfoResponse createResponseWithGuestInfo() { + return ReservationWithGuestInfoResponse.from(createResponse(), 2L); + } + private Map validCreateRequest() { return Map.of( "accommodationId", ACCOMMODATION_ID.toString(), @@ -261,16 +284,17 @@ void getByGuest_WithHostRole_Returns403() throws Exception { class GetByHostEndpoint { @Test - @DisplayName("Returns 200 with list") + @DisplayName("Returns 200 with list including guest cancellation count") void getByHost_Returns200WithList() throws Exception { - when(reservationService.getByHostId(any(UserContext.class))) - .thenReturn(List.of(createResponse())); + when(reservationService.getByHostIdWithGuestInfo(any(UserContext.class))) + .thenReturn(List.of(createResponseWithGuestInfo())); mockMvc.perform(get("/api/reservation/host") .header("X-User-Id", HOST_ID.toString()) .header("X-User-Role", "HOST")) .andExpect(status().isOk()) - .andExpect(jsonPath("$[0].id").value(RESERVATION_ID.toString())); + .andExpect(jsonPath("$[0].id").value(RESERVATION_ID.toString())) + .andExpect(jsonPath("$[0].guestCancellationCount").value(2)); } @Test @@ -418,4 +442,134 @@ void cancel_WithHostRole_Returns403() throws Exception { .andExpect(status().isForbidden()); } } + + @Nested + @DisplayName("PUT /api/reservation/{id}/approve") + class ApproveEndpoint { + + @Test + @DisplayName("With valid request returns 200") + void approve_WithValidRequest_Returns200() throws Exception { + when(reservationService.approveReservation(eq(RESERVATION_ID), any(UserContext.class))) + .thenReturn(createApprovedResponse()); + + mockMvc.perform(put("/api/reservation/{id}/approve", RESERVATION_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(RESERVATION_ID.toString())) + .andExpect(jsonPath("$.status").value("APPROVED")); + } + + @Test + @DisplayName("With GUEST role returns 403") + void approve_WithGuestRole_Returns403() throws Exception { + mockMvc.perform(put("/api/reservation/{id}/approve", RESERVATION_ID) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With non-existing ID returns 404") + void approve_WithNonExistingId_Returns404() throws Exception { + UUID id = UUID.randomUUID(); + when(reservationService.approveReservation(eq(id), any(UserContext.class))) + .thenThrow(new ReservationNotFoundException("Not found")); + + mockMvc.perform(put("/api/reservation/{id}/approve", id) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("With wrong host returns 403") + void approve_WithWrongHost_Returns403() throws Exception { + when(reservationService.approveReservation(eq(RESERVATION_ID), any(UserContext.class))) + .thenThrow(new ForbiddenException("Not the host")); + + mockMvc.perform(put("/api/reservation/{id}/approve", RESERVATION_ID) + .header("X-User-Id", UUID.randomUUID().toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With non-pending status returns 400") + void approve_WithNonPendingStatus_Returns400() throws Exception { + when(reservationService.approveReservation(eq(RESERVATION_ID), any(UserContext.class))) + .thenThrow(new InvalidReservationException("Only pending reservations can be approved")); + + mockMvc.perform(put("/api/reservation/{id}/approve", RESERVATION_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("PUT /api/reservation/{id}/reject") + class RejectEndpoint { + + @Test + @DisplayName("With valid request returns 200") + void reject_WithValidRequest_Returns200() throws Exception { + when(reservationService.rejectReservation(eq(RESERVATION_ID), any(UserContext.class))) + .thenReturn(createRejectedResponse()); + + mockMvc.perform(put("/api/reservation/{id}/reject", RESERVATION_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(RESERVATION_ID.toString())) + .andExpect(jsonPath("$.status").value("REJECTED")); + } + + @Test + @DisplayName("With GUEST role returns 403") + void reject_WithGuestRole_Returns403() throws Exception { + mockMvc.perform(put("/api/reservation/{id}/reject", RESERVATION_ID) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With non-existing ID returns 404") + void reject_WithNonExistingId_Returns404() throws Exception { + UUID id = UUID.randomUUID(); + when(reservationService.rejectReservation(eq(id), any(UserContext.class))) + .thenThrow(new ReservationNotFoundException("Not found")); + + mockMvc.perform(put("/api/reservation/{id}/reject", id) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("With wrong host returns 403") + void reject_WithWrongHost_Returns403() throws Exception { + when(reservationService.rejectReservation(eq(RESERVATION_ID), any(UserContext.class))) + .thenThrow(new ForbiddenException("Not the host")); + + mockMvc.perform(put("/api/reservation/{id}/reject", RESERVATION_ID) + .header("X-User-Id", UUID.randomUUID().toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With non-pending status returns 400") + void reject_WithNonPendingStatus_Returns400() throws Exception { + when(reservationService.rejectReservation(eq(RESERVATION_ID), any(UserContext.class))) + .thenThrow(new InvalidReservationException("Only pending reservations can be rejected")); + + mockMvc.perform(put("/api/reservation/{id}/reject", RESERVATION_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isBadRequest()); + } + } } diff --git a/src/test/java/com/devoops/reservation/service/ReservationEventPublisherServiceTest.java b/src/test/java/com/devoops/reservation/service/ReservationEventPublisherServiceTest.java new file mode 100644 index 0000000..810939b --- /dev/null +++ b/src/test/java/com/devoops/reservation/service/ReservationEventPublisherServiceTest.java @@ -0,0 +1,161 @@ +package com.devoops.reservation.service; + +import com.devoops.reservation.dto.message.ReservationResponseMessage; +import com.devoops.reservation.entity.Reservation; +import com.devoops.reservation.entity.ReservationStatus; +import com.devoops.reservation.grpc.UserGrpcClient; +import com.devoops.reservation.grpc.UserSummaryResult; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ReservationEventPublisherServiceTest { + + @Mock + private RabbitTemplate rabbitTemplate; + + @Mock + private UserGrpcClient userGrpcClient; + + @InjectMocks + private ReservationEventPublisherService eventPublisher; + + @Captor + private ArgumentCaptor messageCaptor; + + private static final UUID GUEST_ID = UUID.randomUUID(); + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID RESERVATION_ID = UUID.randomUUID(); + private static final String NOTIFICATION_EXCHANGE = "notification.exchange"; + private static final String RESPONSE_ROUTING_KEY = "notification.reservation.response"; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(eventPublisher, "notificationExchange", NOTIFICATION_EXCHANGE); + ReflectionTestUtils.setField(eventPublisher, "reservationResponseRoutingKey", RESPONSE_ROUTING_KEY); + } + + private Reservation createReservation() { + return Reservation.builder() + .id(RESERVATION_ID) + .accommodationId(UUID.randomUUID()) + .guestId(GUEST_ID) + .hostId(HOST_ID) + .startDate(LocalDate.now().plusDays(10)) + .endDate(LocalDate.now().plusDays(15)) + .guestCount(2) + .totalPrice(new BigDecimal("1000.00")) + .status(ReservationStatus.PENDING) + .build(); + } + + private UserSummaryResult createGuestSummary() { + return new UserSummaryResult(true, GUEST_ID, "guest@example.com", "John", "Doe", "GUEST"); + } + + private UserSummaryResult createHostSummary() { + return new UserSummaryResult(true, HOST_ID, "host@example.com", "Jane", "Smith", "HOST"); + } + + @Nested + @DisplayName("PublishReservationResponse") + class PublishReservationResponseTests { + + @Test + @DisplayName("With approval publishes APPROVED message") + void publishReservationResponse_WithApproval_PublishesApprovedMessage() { + var reservation = createReservation(); + var guestSummary = createGuestSummary(); + var hostSummary = createHostSummary(); + + when(userGrpcClient.getUserSummary(GUEST_ID)).thenReturn(guestSummary); + when(userGrpcClient.getUserSummary(HOST_ID)).thenReturn(hostSummary); + + eventPublisher.publishReservationResponse(reservation, "Beach House", true); + + verify(rabbitTemplate).convertAndSend( + eq(NOTIFICATION_EXCHANGE), + eq(RESPONSE_ROUTING_KEY), + messageCaptor.capture() + ); + + ReservationResponseMessage message = messageCaptor.getValue(); + assertThat(message.userId()).isEqualTo(GUEST_ID); + assertThat(message.userEmail()).isEqualTo("guest@example.com"); + assertThat(message.hostName()).isEqualTo("Jane Smith"); + assertThat(message.accommodationName()).isEqualTo("Beach House"); + assertThat(message.status()).isEqualTo(ReservationResponseMessage.ReservationResponseStatus.APPROVED); + assertThat(message.checkIn()).isEqualTo(reservation.getStartDate()); + assertThat(message.checkOut()).isEqualTo(reservation.getEndDate()); + } + + @Test + @DisplayName("With rejection publishes DECLINED message") + void publishReservationResponse_WithRejection_PublishesDeclinedMessage() { + var reservation = createReservation(); + var guestSummary = createGuestSummary(); + var hostSummary = createHostSummary(); + + when(userGrpcClient.getUserSummary(GUEST_ID)).thenReturn(guestSummary); + when(userGrpcClient.getUserSummary(HOST_ID)).thenReturn(hostSummary); + + eventPublisher.publishReservationResponse(reservation, "Mountain Cabin", false); + + verify(rabbitTemplate).convertAndSend( + eq(NOTIFICATION_EXCHANGE), + eq(RESPONSE_ROUTING_KEY), + messageCaptor.capture() + ); + + ReservationResponseMessage message = messageCaptor.getValue(); + assertThat(message.status()).isEqualTo(ReservationResponseMessage.ReservationResponseStatus.DECLINED); + } + + @Test + @DisplayName("With missing guest skips publishing") + void publishReservationResponse_WithMissingGuest_SkipsPublishing() { + var reservation = createReservation(); + var notFoundSummary = new UserSummaryResult(false, null, null, null, null, null); + + when(userGrpcClient.getUserSummary(GUEST_ID)).thenReturn(notFoundSummary); + + eventPublisher.publishReservationResponse(reservation, "Beach House", true); + + verify(rabbitTemplate, never()).convertAndSend(any(), any(), any(Object.class)); + } + + @Test + @DisplayName("With missing host skips publishing") + void publishReservationResponse_WithMissingHost_SkipsPublishing() { + var reservation = createReservation(); + var guestSummary = createGuestSummary(); + var notFoundSummary = new UserSummaryResult(false, null, null, null, null, null); + + when(userGrpcClient.getUserSummary(GUEST_ID)).thenReturn(guestSummary); + when(userGrpcClient.getUserSummary(HOST_ID)).thenReturn(notFoundSummary); + + eventPublisher.publishReservationResponse(reservation, "Beach House", true); + + verify(rabbitTemplate, never()).convertAndSend(any(), any(), any(Object.class)); + } + } +} diff --git a/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java b/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java index 5903b73..8e098cc 100644 --- a/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java +++ b/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java @@ -3,6 +3,7 @@ import com.devoops.reservation.config.UserContext; import com.devoops.reservation.dto.request.CreateReservationRequest; import com.devoops.reservation.dto.response.ReservationResponse; +import com.devoops.reservation.dto.response.ReservationWithGuestInfoResponse; import com.devoops.reservation.entity.Reservation; import com.devoops.reservation.entity.ReservationStatus; import com.devoops.reservation.exception.AccommodationNotFoundException; @@ -498,4 +499,202 @@ void cancelReservation_WithHostTryingToCancel_ThrowsForbiddenException() { .isInstanceOf(ForbiddenException.class); } } + + @Nested + @DisplayName("ApproveReservation") + class ApproveReservationTests { + + @Test + @DisplayName("With valid pending reservation approves successfully") + void approveReservation_WithValidPending_ApprovesSuccessfully() { + var reservation = createReservation(); + var response = createResponse(); + var accommodationResult = new AccommodationValidationResult( + true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL", "Test Accommodation" + ); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + when(reservationRepository.findOverlappingPending(any(), any(), any(), any())) + .thenReturn(List.of()); + when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) + .thenReturn(accommodationResult); + when(reservationMapper.toResponse(reservation)).thenReturn(response); + + ReservationResponse result = reservationService.approveReservation(RESERVATION_ID, HOST_CONTEXT); + + assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.APPROVED); + verify(reservationRepository).save(reservation); + verify(eventPublisher).publishReservationResponse(reservation, "Test Accommodation", true); + } + + @Test + @DisplayName("With overlapping pending reservations auto-rejects them") + void approveReservation_WithOverlappingPending_AutoRejectsThem() { + var reservation = createReservation(); + var overlapping1 = Reservation.builder() + .id(UUID.randomUUID()) + .accommodationId(ACCOMMODATION_ID) + .guestId(UUID.randomUUID()) + .hostId(HOST_ID) + .startDate(LocalDate.now().plusDays(12)) + .endDate(LocalDate.now().plusDays(14)) + .status(ReservationStatus.PENDING) + .build(); + var overlapping2 = Reservation.builder() + .id(UUID.randomUUID()) + .accommodationId(ACCOMMODATION_ID) + .guestId(UUID.randomUUID()) + .hostId(HOST_ID) + .startDate(LocalDate.now().plusDays(11)) + .endDate(LocalDate.now().plusDays(13)) + .status(ReservationStatus.PENDING) + .build(); + var accommodationResult = new AccommodationValidationResult( + true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL", "Test Accommodation" + ); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + when(reservationRepository.findOverlappingPending(any(), any(), any(), any())) + .thenReturn(List.of(overlapping1, overlapping2)); + when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) + .thenReturn(accommodationResult); + when(reservationMapper.toResponse(reservation)).thenReturn(createResponse()); + + reservationService.approveReservation(RESERVATION_ID, HOST_CONTEXT); + + assertThat(overlapping1.getStatus()).isEqualTo(ReservationStatus.REJECTED); + assertThat(overlapping2.getStatus()).isEqualTo(ReservationStatus.REJECTED); + verify(reservationRepository, times(3)).save(any()); // main + 2 overlapping + } + + @Test + @DisplayName("With wrong host throws ForbiddenException") + void approveReservation_WithWrongHost_ThrowsForbiddenException() { + var reservation = createReservation(); + var otherHost = new UserContext(UUID.randomUUID(), "HOST"); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.approveReservation(RESERVATION_ID, otherHost)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("only approve reservations for your own accommodations"); + } + + @Test + @DisplayName("With non-pending status throws InvalidReservationException") + void approveReservation_WithNonPendingStatus_ThrowsInvalidReservationException() { + var reservation = createReservation(); + reservation.setStatus(ReservationStatus.APPROVED); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.approveReservation(RESERVATION_ID, HOST_CONTEXT)) + .isInstanceOf(InvalidReservationException.class) + .hasMessageContaining("Only pending reservations can be approved"); + } + + @Test + @DisplayName("With non-existing ID throws ReservationNotFoundException") + void approveReservation_WithNonExistingId_ThrowsReservationNotFoundException() { + UUID id = UUID.randomUUID(); + when(reservationRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> reservationService.approveReservation(id, HOST_CONTEXT)) + .isInstanceOf(ReservationNotFoundException.class); + } + } + + @Nested + @DisplayName("RejectReservation") + class RejectReservationTests { + + @Test + @DisplayName("With valid pending reservation rejects successfully") + void rejectReservation_WithValidPending_RejectsSuccessfully() { + var reservation = createReservation(); + var response = createResponse(); + var accommodationResult = new AccommodationValidationResult( + true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL", "Test Accommodation" + ); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) + .thenReturn(accommodationResult); + when(reservationMapper.toResponse(reservation)).thenReturn(response); + + ReservationResponse result = reservationService.rejectReservation(RESERVATION_ID, HOST_CONTEXT); + + assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.REJECTED); + verify(reservationRepository).save(reservation); + verify(eventPublisher).publishReservationResponse(reservation, "Test Accommodation", false); + } + + @Test + @DisplayName("With wrong host throws ForbiddenException") + void rejectReservation_WithWrongHost_ThrowsForbiddenException() { + var reservation = createReservation(); + var otherHost = new UserContext(UUID.randomUUID(), "HOST"); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.rejectReservation(RESERVATION_ID, otherHost)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("only reject reservations for your own accommodations"); + } + + @Test + @DisplayName("With non-pending status throws InvalidReservationException") + void rejectReservation_WithNonPendingStatus_ThrowsInvalidReservationException() { + var reservation = createReservation(); + reservation.setStatus(ReservationStatus.CANCELLED); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.rejectReservation(RESERVATION_ID, HOST_CONTEXT)) + .isInstanceOf(InvalidReservationException.class) + .hasMessageContaining("Only pending reservations can be rejected"); + } + + @Test + @DisplayName("With non-existing ID throws ReservationNotFoundException") + void rejectReservation_WithNonExistingId_ThrowsReservationNotFoundException() { + UUID id = UUID.randomUUID(); + when(reservationRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> reservationService.rejectReservation(id, HOST_CONTEXT)) + .isInstanceOf(ReservationNotFoundException.class); + } + } + + @Nested + @DisplayName("GetByHostIdWithGuestInfo") + class GetByHostIdWithGuestInfoTests { + + @Test + @DisplayName("Returns reservations with cancellation counts") + void getByHostIdWithGuestInfo_ReturnsReservationsWithCancellationCounts() { + var reservation = createReservation(); + var response = createResponse(); + + when(reservationRepository.findByHostId(HOST_ID)).thenReturn(List.of(reservation)); + when(reservationMapper.toResponse(reservation)).thenReturn(response); + when(reservationRepository.countByGuestIdAndStatus(GUEST_ID, ReservationStatus.CANCELLED)) + .thenReturn(3L); + + List result = reservationService.getByHostIdWithGuestInfo(HOST_CONTEXT); + + assertThat(result).hasSize(1); + assertThat(result.get(0).guestCancellationCount()).isEqualTo(3L); + } + + @Test + @DisplayName("With no reservations returns empty list") + void getByHostIdWithGuestInfo_WithNoReservations_ReturnsEmptyList() { + when(reservationRepository.findByHostId(HOST_ID)).thenReturn(List.of()); + + List result = reservationService.getByHostIdWithGuestInfo(HOST_CONTEXT); + + assertThat(result).isEmpty(); + } + } } From e972326941729d2534ca425b9c9f777be4862834 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Fri, 20 Feb 2026 20:27:09 +0100 Subject: [PATCH 15/19] feat: Check if user can be deleted. --- .../dto/response/ReservationResponse.java | 3 + .../ReservationWithGuestInfoResponse.java | 6 ++ .../grpc/ReservationGrpcService.java | 54 ++++++++++++- .../reservation/grpc/UserGrpcClient.java | 6 +- .../reservation/grpc/UserSummaryResult.java | 6 +- .../reservation/mapper/ReservationMapper.java | 8 +- .../repository/ReservationRepository.java | 30 +++++++ .../service/ReservationService.java | 35 ++++++-- src/main/proto/reservation_internal.proto | 16 ++++ src/main/proto/user_internal.proto | 1 + .../controller/ReservationControllerTest.java | 9 ++- .../ReservationIntegrationTest.java | 3 +- .../ReservationEventPublisherServiceTest.java | 8 +- .../service/ReservationServiceTest.java | 80 ++++++++++++++----- 14 files changed, 223 insertions(+), 42 deletions(-) diff --git a/src/main/java/com/devoops/reservation/dto/response/ReservationResponse.java b/src/main/java/com/devoops/reservation/dto/response/ReservationResponse.java index 03c9ebf..9af3e75 100644 --- a/src/main/java/com/devoops/reservation/dto/response/ReservationResponse.java +++ b/src/main/java/com/devoops/reservation/dto/response/ReservationResponse.java @@ -10,8 +10,11 @@ public record ReservationResponse( UUID id, UUID accommodationId, + String accommodationName, UUID guestId, + String guestName, UUID hostId, + String hostName, LocalDate startDate, LocalDate endDate, int guestCount, diff --git a/src/main/java/com/devoops/reservation/dto/response/ReservationWithGuestInfoResponse.java b/src/main/java/com/devoops/reservation/dto/response/ReservationWithGuestInfoResponse.java index 6bf9f3c..af72b53 100644 --- a/src/main/java/com/devoops/reservation/dto/response/ReservationWithGuestInfoResponse.java +++ b/src/main/java/com/devoops/reservation/dto/response/ReservationWithGuestInfoResponse.java @@ -10,8 +10,11 @@ public record ReservationWithGuestInfoResponse( UUID id, UUID accommodationId, + String accommodationName, UUID guestId, + String guestName, UUID hostId, + String hostName, LocalDate startDate, LocalDate endDate, int guestCount, @@ -25,8 +28,11 @@ public static ReservationWithGuestInfoResponse from(ReservationResponse response return new ReservationWithGuestInfoResponse( response.id(), response.accommodationId(), + response.accommodationName(), response.guestId(), + response.guestName(), response.hostId(), + response.hostName(), response.startDate(), response.endDate(), response.guestCount(), diff --git a/src/main/java/com/devoops/reservation/grpc/ReservationGrpcService.java b/src/main/java/com/devoops/reservation/grpc/ReservationGrpcService.java index 32b9e35..090b30b 100644 --- a/src/main/java/com/devoops/reservation/grpc/ReservationGrpcService.java +++ b/src/main/java/com/devoops/reservation/grpc/ReservationGrpcService.java @@ -1,9 +1,7 @@ package com.devoops.reservation.grpc; import com.devoops.reservation.entity.Reservation; -import com.devoops.reservation.grpc.proto.CheckReservationsExistRequest; -import com.devoops.reservation.grpc.proto.CheckReservationsExistResponse; -import com.devoops.reservation.grpc.proto.ReservationInternalServiceGrpc; +import com.devoops.reservation.grpc.proto.*; import com.devoops.reservation.repository.ReservationRepository; import io.grpc.stub.StreamObserver; import lombok.RequiredArgsConstructor; @@ -41,4 +39,54 @@ public void checkReservationsExist(CheckReservationsExistRequest request, 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(); + } } diff --git a/src/main/java/com/devoops/reservation/grpc/UserGrpcClient.java b/src/main/java/com/devoops/reservation/grpc/UserGrpcClient.java index 92859a7..0d98471 100644 --- a/src/main/java/com/devoops/reservation/grpc/UserGrpcClient.java +++ b/src/main/java/com/devoops/reservation/grpc/UserGrpcClient.java @@ -34,7 +34,8 @@ public UserSummaryResult getUserSummary(UUID userId) { null, null, null, - null + null, + false ); } @@ -44,7 +45,8 @@ public UserSummaryResult getUserSummary(UUID userId) { response.getEmail(), response.getFirstName(), response.getLastName(), - response.getRole() + 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 index 6b1e0d4..1fdd3a2 100644 --- a/src/main/java/com/devoops/reservation/grpc/UserSummaryResult.java +++ b/src/main/java/com/devoops/reservation/grpc/UserSummaryResult.java @@ -8,9 +8,11 @@ public record UserSummaryResult( String email, String firstName, String lastName, - String role + String role, + boolean isDeleted ) { public String getFullName() { - return firstName + " " + lastName; + 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 index acc1c8e..e02a8ab 100644 --- a/src/main/java/com/devoops/reservation/mapper/ReservationMapper.java +++ b/src/main/java/com/devoops/reservation/mapper/ReservationMapper.java @@ -21,7 +21,13 @@ public interface ReservationMapper { @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); - List toResponseList(List reservations); + @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 index 9c378ab..cbff34c 100644 --- a/src/main/java/com/devoops/reservation/repository/ReservationRepository.java +++ b/src/main/java/com/devoops/reservation/repository/ReservationRepository.java @@ -61,4 +61,34 @@ List findOverlappingPending( * 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 + ); } diff --git a/src/main/java/com/devoops/reservation/service/ReservationService.java b/src/main/java/com/devoops/reservation/service/ReservationService.java index 3379bce..8283f1b 100644 --- a/src/main/java/com/devoops/reservation/service/ReservationService.java +++ b/src/main/java/com/devoops/reservation/service/ReservationService.java @@ -12,6 +12,8 @@ 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; @@ -31,6 +33,7 @@ public class ReservationService { private final ReservationRepository reservationRepository; private final ReservationMapper reservationMapper; private final AccommodationGrpcClient accommodationGrpcClient; + private final UserGrpcClient userGrpcClient; private final ReservationEventPublisherService eventPublisher; @Transactional @@ -88,26 +91,26 @@ public ReservationResponse create(CreateReservationRequest request, UserContext eventPublisher.publishReservationCreated(reservation, validationResult.accommodationName()); - return reservationMapper.toResponse(reservation); + return toResponseWithNames(reservation); } @Transactional(readOnly = true) public ReservationResponse getById(UUID id, UserContext userContext) { Reservation reservation = findReservationOrThrow(id); validateAccessToReservation(reservation, userContext); - return reservationMapper.toResponse(reservation); + return toResponseWithNames(reservation); } @Transactional(readOnly = true) public List getByGuestId(UserContext userContext) { List reservations = reservationRepository.findByGuestId(userContext.userId()); - return reservationMapper.toResponseList(reservations); + return toResponseListWithNames(reservations); } @Transactional(readOnly = true) public List getByHostId(UserContext userContext) { List reservations = reservationRepository.findByHostId(userContext.userId()); - return reservationMapper.toResponseList(reservations); + return toResponseListWithNames(reservations); } @Transactional(readOnly = true) @@ -115,7 +118,7 @@ public List getByHostIdWithGuestInfo(UserConte List reservations = reservationRepository.findByHostId(userContext.userId()); return reservations.stream() .map(reservation -> { - ReservationResponse response = reservationMapper.toResponse(reservation); + ReservationResponse response = toResponseWithNames(reservation); long cancellationCount = reservationRepository.countByGuestIdAndStatus( reservation.getGuestId(), ReservationStatus.CANCELLED); return ReservationWithGuestInfoResponse.from(response, cancellationCount); @@ -218,7 +221,7 @@ public ReservationResponse approveReservation(UUID id, UserContext userContext) String accommodationName = fetchAccommodationName(reservation); eventPublisher.publishReservationResponse(reservation, accommodationName, true); - return reservationMapper.toResponse(reservation); + return toResponseWithNames(reservation); } @Transactional @@ -245,7 +248,7 @@ public ReservationResponse rejectReservation(UUID id, UserContext userContext) { String accommodationName = fetchAccommodationName(reservation); eventPublisher.publishReservationResponse(reservation, accommodationName, false); - return reservationMapper.toResponse(reservation); + return toResponseWithNames(reservation); } // === Helper Methods === @@ -280,4 +283,22 @@ private String fetchAccommodationName(Reservation reservation) { ); 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/reservation_internal.proto b/src/main/proto/reservation_internal.proto index 89d4e01..ffe94f5 100644 --- a/src/main/proto/reservation_internal.proto +++ b/src/main/proto/reservation_internal.proto @@ -7,6 +7,8 @@ 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); } message CheckReservationsExistRequest { @@ -18,3 +20,17 @@ message CheckReservationsExistRequest { message CheckReservationsExistResponse { bool has_reservations = 1; } + +message CheckGuestDeletionRequest { + string guest_id = 1; +} + +message CheckHostDeletionRequest { + string host_id = 1; +} + +message CheckDeletionResponse { + bool can_be_deleted = 1; + string reason = 2; + int32 active_reservation_count = 3; +} diff --git a/src/main/proto/user_internal.proto b/src/main/proto/user_internal.proto index ce2fd57..e826a96 100644 --- a/src/main/proto/user_internal.proto +++ b/src/main/proto/user_internal.proto @@ -19,4 +19,5 @@ message GetUserSummaryResponse { string first_name = 4; string last_name = 5; string role = 6; + bool is_deleted = 7; } diff --git a/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java b/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java index f7c4464..7458894 100644 --- a/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java +++ b/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java @@ -68,7 +68,8 @@ void setUp() { private ReservationResponse createResponse() { return new ReservationResponse( - RESERVATION_ID, ACCOMMODATION_ID, GUEST_ID, HOST_ID, + 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() @@ -77,7 +78,8 @@ private ReservationResponse createResponse() { private ReservationResponse createApprovedResponse() { return new ReservationResponse( - RESERVATION_ID, ACCOMMODATION_ID, GUEST_ID, HOST_ID, + 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() @@ -86,7 +88,8 @@ private ReservationResponse createApprovedResponse() { private ReservationResponse createRejectedResponse() { return new ReservationResponse( - RESERVATION_ID, ACCOMMODATION_ID, GUEST_ID, HOST_ID, + 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() diff --git a/src/test/java/com/devoops/reservation/integration/ReservationIntegrationTest.java b/src/test/java/com/devoops/reservation/integration/ReservationIntegrationTest.java index b3d6f62..fec4f3a 100644 --- a/src/test/java/com/devoops/reservation/integration/ReservationIntegrationTest.java +++ b/src/test/java/com/devoops/reservation/integration/ReservationIntegrationTest.java @@ -90,7 +90,8 @@ void setUpMocks() { "host@example.com", "Test", "Host", - "HOST" + "HOST", + false ); when(userGrpcClient.getUserSummary(any(UUID.class))) .thenReturn(hostSummary); diff --git a/src/test/java/com/devoops/reservation/service/ReservationEventPublisherServiceTest.java b/src/test/java/com/devoops/reservation/service/ReservationEventPublisherServiceTest.java index 810939b..a1a203b 100644 --- a/src/test/java/com/devoops/reservation/service/ReservationEventPublisherServiceTest.java +++ b/src/test/java/com/devoops/reservation/service/ReservationEventPublisherServiceTest.java @@ -69,11 +69,11 @@ private Reservation createReservation() { } private UserSummaryResult createGuestSummary() { - return new UserSummaryResult(true, GUEST_ID, "guest@example.com", "John", "Doe", "GUEST"); + 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"); + return new UserSummaryResult(true, HOST_ID, "host@example.com", "Jane", "Smith", "HOST", false); } @Nested @@ -134,7 +134,7 @@ void publishReservationResponse_WithRejection_PublishesDeclinedMessage() { @DisplayName("With missing guest skips publishing") void publishReservationResponse_WithMissingGuest_SkipsPublishing() { var reservation = createReservation(); - var notFoundSummary = new UserSummaryResult(false, null, null, null, null, null); + var notFoundSummary = new UserSummaryResult(false, null, null, null, null, null, false); when(userGrpcClient.getUserSummary(GUEST_ID)).thenReturn(notFoundSummary); @@ -148,7 +148,7 @@ void publishReservationResponse_WithMissingGuest_SkipsPublishing() { void publishReservationResponse_WithMissingHost_SkipsPublishing() { var reservation = createReservation(); var guestSummary = createGuestSummary(); - var notFoundSummary = new UserSummaryResult(false, null, null, null, null, null); + 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); diff --git a/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java b/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java index 8e098cc..315ca2d 100644 --- a/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java +++ b/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java @@ -12,6 +12,8 @@ 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; @@ -31,8 +33,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) @@ -47,6 +48,9 @@ class ReservationServiceTest { @Mock private AccommodationGrpcClient accommodationGrpcClient; + @Mock + private UserGrpcClient userGrpcClient; + @Mock private ReservationEventPublisherService eventPublisher; @@ -76,13 +80,21 @@ private Reservation createReservation() { private ReservationResponse createResponse() { return new ReservationResponse( - RESERVATION_ID, ACCOMMODATION_ID, GUEST_ID, HOST_ID, + 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, @@ -112,7 +124,8 @@ true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL", "Tes .thenReturn(List.of()); when(reservationMapper.toEntity(request)).thenReturn(reservation); when(reservationRepository.saveAndFlush(reservation)).thenReturn(reservation); - when(reservationMapper.toResponse(reservation)).thenReturn(response); + setupUserMocks(); + when(reservationMapper.toResponseWithNames(eq(reservation), anyString(), anyString(), anyString())).thenReturn(response); ReservationResponse result = reservationService.create(request, GUEST_CONTEXT); @@ -159,7 +172,8 @@ true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "AUTOMATIC", " .thenReturn(List.of()); when(reservationMapper.toEntity(request)).thenReturn(reservation); when(reservationRepository.saveAndFlush(reservation)).thenReturn(reservation); - when(reservationMapper.toResponse(reservation)).thenReturn(response); + setupUserMocks(); + when(reservationMapper.toResponseWithNames(eq(reservation), anyString(), anyString(), anyString())).thenReturn(response); reservationService.create(request, GUEST_CONTEXT); @@ -240,9 +254,14 @@ class GetByIdTests { 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(reservationMapper.toResponse(reservation)).thenReturn(response); + 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); @@ -254,9 +273,14 @@ void getById_WithExistingIdAndGuestAccess_ReturnsReservationResponse() { 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(reservationMapper.toResponse(reservation)).thenReturn(response); + 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); @@ -293,11 +317,17 @@ class GetByGuestIdTests { @Test @DisplayName("With existing guest returns reservation list") void getByGuestId_WithExistingGuest_ReturnsReservationList() { - var reservations = List.of(createReservation()); - var responses = List.of(createResponse()); + 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(reservationMapper.toResponseList(reservations)).thenReturn(responses); + 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); @@ -308,7 +338,6 @@ void getByGuestId_WithExistingGuest_ReturnsReservationList() { @DisplayName("With no reservations returns empty list") void getByGuestId_WithNoReservations_ReturnsEmptyList() { when(reservationRepository.findByGuestId(GUEST_ID)).thenReturn(List.of()); - when(reservationMapper.toResponseList(List.of())).thenReturn(List.of()); List result = reservationService.getByGuestId(GUEST_CONTEXT); @@ -323,11 +352,17 @@ class GetByHostIdTests { @Test @DisplayName("With existing host returns reservation list") void getByHostId_WithExistingHost_ReturnsReservationList() { - var reservations = List.of(createReservation()); - var responses = List.of(createResponse()); + 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(reservationMapper.toResponseList(reservations)).thenReturn(responses); + 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); @@ -338,7 +373,6 @@ void getByHostId_WithExistingHost_ReturnsReservationList() { @DisplayName("With no reservations returns empty list") void getByHostId_WithNoReservations_ReturnsEmptyList() { when(reservationRepository.findByHostId(HOST_ID)).thenReturn(List.of()); - when(reservationMapper.toResponseList(List.of())).thenReturn(List.of()); List result = reservationService.getByHostId(HOST_CONTEXT); @@ -518,7 +552,8 @@ true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL", "Tes .thenReturn(List.of()); when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) .thenReturn(accommodationResult); - when(reservationMapper.toResponse(reservation)).thenReturn(response); + setupUserMocks(); + when(reservationMapper.toResponseWithNames(eq(reservation), anyString(), anyString(), anyString())).thenReturn(response); ReservationResponse result = reservationService.approveReservation(RESERVATION_ID, HOST_CONTEXT); @@ -558,7 +593,8 @@ true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL", "Tes .thenReturn(List.of(overlapping1, overlapping2)); when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) .thenReturn(accommodationResult); - when(reservationMapper.toResponse(reservation)).thenReturn(createResponse()); + setupUserMocks(); + when(reservationMapper.toResponseWithNames(eq(reservation), anyString(), anyString(), anyString())).thenReturn(createResponse()); reservationService.approveReservation(RESERVATION_ID, HOST_CONTEXT); @@ -620,7 +656,8 @@ true, null, null, HOST_ID, new BigDecimal("1000.00"), "PER_UNIT", "MANUAL", "Tes when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); when(accommodationGrpcClient.validateAndCalculatePrice(any(), any(), any(), anyInt())) .thenReturn(accommodationResult); - when(reservationMapper.toResponse(reservation)).thenReturn(response); + setupUserMocks(); + when(reservationMapper.toResponseWithNames(eq(reservation), anyString(), anyString(), anyString())).thenReturn(response); ReservationResponse result = reservationService.rejectReservation(RESERVATION_ID, HOST_CONTEXT); @@ -675,9 +712,14 @@ class GetByHostIdWithGuestInfoTests { 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(reservationMapper.toResponse(reservation)).thenReturn(response); + 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); From d699ead7716d644737ffc81bdf8f9ac0f050a43e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Sat, 21 Feb 2026 20:50:32 +0100 Subject: [PATCH 16/19] feat: Add rating eligibility gRPC and queries Add a CheckRatingEligibility RPC to the internal proto, implement the gRPC handler, and add repository queries to support it. The proto defines request/response messages for guest_id, target_id and target_type and returns eligible + reason. ReservationGrpcService.checkRatingEligibility parses IDs, checks whether the target is a HOST or accommodation, logs the check, and uses new repository methods to count completed (APPROVED, endDate < today) stays; eligibility is true when at least one completed stay is found. Two JPQL methods were added to ReservationRepository: countCompletedStaysAtAccommodation and countCompletedStaysWithHost, both counting approved past reservations for the guest against an accommodation or host respectively. --- .../grpc/ReservationGrpcService.java | 23 +++++++++++++ .../repository/ReservationRepository.java | 34 +++++++++++++++++++ src/main/proto/reservation_internal.proto | 12 +++++++ 3 files changed, 69 insertions(+) diff --git a/src/main/java/com/devoops/reservation/grpc/ReservationGrpcService.java b/src/main/java/com/devoops/reservation/grpc/ReservationGrpcService.java index 090b30b..37c24a0 100644 --- a/src/main/java/com/devoops/reservation/grpc/ReservationGrpcService.java +++ b/src/main/java/com/devoops/reservation/grpc/ReservationGrpcService.java @@ -89,4 +89,27 @@ public void checkHostCanBeDeleted(CheckHostDeletionRequest request, 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/repository/ReservationRepository.java b/src/main/java/com/devoops/reservation/repository/ReservationRepository.java index cbff34c..f27e129 100644 --- a/src/main/java/com/devoops/reservation/repository/ReservationRepository.java +++ b/src/main/java/com/devoops/reservation/repository/ReservationRepository.java @@ -91,4 +91,38 @@ 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/proto/reservation_internal.proto b/src/main/proto/reservation_internal.proto index ffe94f5..334b35f 100644 --- a/src/main/proto/reservation_internal.proto +++ b/src/main/proto/reservation_internal.proto @@ -9,6 +9,7 @@ service ReservationInternalService { rpc CheckReservationsExist(CheckReservationsExistRequest) returns (CheckReservationsExistResponse); rpc CheckGuestCanBeDeleted(CheckGuestDeletionRequest) returns (CheckDeletionResponse); rpc CheckHostCanBeDeleted(CheckHostDeletionRequest) returns (CheckDeletionResponse); + rpc CheckRatingEligibility(CheckRatingEligibilityRequest) returns (CheckRatingEligibilityResponse); } message CheckReservationsExistRequest { @@ -34,3 +35,14 @@ message CheckDeletionResponse { 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; +} From 0adce149e4fe5b0ed3160c7f6d8aeaa34b368605 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Mon, 23 Feb 2026 18:59:49 +0100 Subject: [PATCH 17/19] fix: Removed duplicate tests from pipeline --- .github/workflows/ci.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96a97f3..eeaa500 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,9 +32,6 @@ jobs: - name: Build with Gradle run: ./gradlew clean build - - name: Run tests - run: ./gradlew test - - name: Upload build artifact uses: actions/upload-artifact@v4 with: From 456d72f9f0faa36b256206a1b8268212683f5975 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:27:40 +0100 Subject: [PATCH 18/19] feat: Add Helm values. --- environment/helm/values.yaml | 55 ++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 environment/helm/values.yaml 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" From db5957c29a294d78a383c6f89f5deecba536d90d Mon Sep 17 00:00:00 2001 From: Dusan Date: Tue, 24 Feb 2026 22:09:56 +0100 Subject: [PATCH 19/19] feat: Build image with Kaniko --- .github/workflows/ci.yml | 35 ++++++++++++++++------------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eeaa500..d1d8de5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,15 +53,6 @@ jobs: name: build-artifact path: build/libs - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f - - - name: Log in to Docker Hub - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 - with: - username: ${{ env.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Generate version tag id: version run: | @@ -77,13 +68,19 @@ jobs: echo "tag=$VERSION_TAG" >> $GITHUB_OUTPUT echo "Generated version tag: $VERSION_TAG" - - name: Build and push Docker image - uses: docker/build-push-action@ca052bb54ab0790a636c9b5f226502c73d547a25 - with: - context: . - push: true - tags: | - ${{ env.DOCKERHUB_USERNAME }}/devoops-reservation-service:${{ steps.version.outputs.tag }} - ${{ env.DOCKERHUB_USERNAME }}/devoops-reservation-service:latest - cache-from: type=gha - cache-to: type=gha,mode=max \ No newline at end of file + - 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