From d59aea95a2b61ba4b21faf252b78d7618611fd06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Wed, 14 Jan 2026 20:14:01 +0100 Subject: [PATCH 01/17] Add TestController and update application config Introduced a TestController with a test endpoint to verify service availability. Disabled Spring Security dependencies, added Actuator, excluded DataSource auto-configuration, and set the server port to 8084. --- build.gradle.kts | 5 +++-- .../com/devoops/rating/RatingApplication.java | 3 ++- .../devoops/rating/controller/TestController.java | 15 +++++++++++++++ src/main/resources/application.properties | 1 + 4 files changed, 21 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/devoops/rating/controller/TestController.java diff --git a/build.gradle.kts b/build.gradle.kts index df9551d..413ddaa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,12 +20,13 @@ 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-actuator") 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/rating/RatingApplication.java b/src/main/java/com/devoops/rating/RatingApplication.java index 41053ae..f0e90b9 100644 --- a/src/main/java/com/devoops/rating/RatingApplication.java +++ b/src/main/java/com/devoops/rating/RatingApplication.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 RatingApplication { public static void main(String[] args) { diff --git a/src/main/java/com/devoops/rating/controller/TestController.java b/src/main/java/com/devoops/rating/controller/TestController.java new file mode 100644 index 0000000..296c69f --- /dev/null +++ b/src/main/java/com/devoops/rating/controller/TestController.java @@ -0,0 +1,15 @@ +package com.devoops.rating.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/rating") +public class TestController { + + @GetMapping("test") + public String test() { + return "Rating Service is up and running!"; + } +} \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1be4abf..4dcb3dc 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,2 @@ spring.application.name=rating +server.port=8084 \ No newline at end of file From d50b70086ed64f77a04927a14a17ec692b8b8513 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Sat, 17 Jan 2026 16:02:04 +0100 Subject: [PATCH 02/17] 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 d1c378046cc2021f18b6947615f29c7c70d3d698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Sat, 17 Jan 2026 23:05:39 +0100 Subject: [PATCH 03/17] feat: Add Logstash logging integration and enhance logging Introduced logstash-logback-encoder dependency and added Logback configuration files for structured logging and Logstash integration. Enhanced TestController with request-scoped logging using MDC and improved log messages. Updated application properties for logging levels and Logstash host configuration. --- build.gradle.kts | 1 + .../rating/controller/TestController.java | 25 ++++++- src/main/resources/application.properties | 7 +- src/main/resources/logback-spring.xml | 75 +++++++++++++++++++ src/test/resources/logback-test.xml | 18 +++++ 5 files changed, 123 insertions(+), 3 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 413ddaa..c683959 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { // implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-actuator") 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/rating/controller/TestController.java b/src/main/java/com/devoops/rating/controller/TestController.java index 296c69f..86b897f 100644 --- a/src/main/java/com/devoops/rating/controller/TestController.java +++ b/src/main/java/com/devoops/rating/controller/TestController.java @@ -1,15 +1,36 @@ package com.devoops.rating.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/rating") public class TestController { + private static final Logger logger = LoggerFactory.getLogger(TestController.class); + @GetMapping("test") public String test() { - return "Rating Service is up and running!"; + String requestId = UUID.randomUUID().toString(); + MDC.put("requestId", requestId); + + try { + logger.info("Test endpoint called - Rating Service health check"); + logger.debug("Processing test request with ID: {}", requestId); + + String response = "Rating Service is up and running!"; + + logger.info("Test endpoint successfully processed request {}", requestId); + return response; + + } finally { + MDC.remove("requestId"); + } } -} \ No newline at end of file +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 4dcb3dc..c02da75 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,2 +1,7 @@ spring.application.name=rating -server.port=8084 \ No newline at end of file +server.port=8084 +# 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..d2e6da5 --- /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 26ca698b18ffc190a5558db4a40f7a0658993fd3 Mon Sep 17 00:00:00 2001 From: Dusan Date: Sat, 17 Jan 2026 22:05:30 +0100 Subject: [PATCH 04/17] feat: Add zipkin tracing --- build.gradle.kts | 8 +++++++- src/main/resources/application.properties | 4 +++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index c683959..03c0c12 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -25,7 +25,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") - runtimeOnly("org.postgresql:postgresql") + //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") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index c02da75..7467939 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,7 +1,9 @@ spring.application.name=rating -server.port=8084 +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 d385cc46f558faf71b26ba2f0c8577d473239d27 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Sat, 31 Jan 2026 16:21:02 +0100 Subject: [PATCH 05/17] 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..a3dc869 --- /dev/null +++ b/environment/.local.env @@ -0,0 +1,4 @@ +SERVER_PORT=8080 +LOGSTASH_HOST=logstash:5000 +ZIPKIN_HOST=zipkin +ZIPKIN_PORT=9411 From 446c77b40543f3f4c03273450cd3bce4c04a53bd Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Sat, 31 Jan 2026 16:29:16 +0100 Subject: [PATCH 06/17] 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 7467939..3a3c9f2 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 f405f8054ee904d033c4a9f9a17ffc5001b20cc8 Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Sat, 7 Feb 2026 17:43:41 +0100 Subject: [PATCH 07/17] 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 03c0c12..06c2892 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-actuator") + implementation("io.micrometer:micrometer-registry-prometheus") implementation("org.springframework.boot:spring-boot-starter-webmvc") implementation("net.logstash.logback:logstash-logback-encoder:8.0") implementation("org.flywaydb:flyway-database-postgresql") diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 3a3c9f2..a4386c7 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 9c8f505b2f367b6d0f589503f15d0fa2031bf728 Mon Sep 17 00:00:00 2001 From: Dusan Date: Mon, 9 Feb 2026 23:12:20 +0100 Subject: [PATCH 08/17] 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..2e43c6f --- /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-rating-service:${{ steps.version.outputs.tag }} + ${{ env.DOCKERHUB_USERNAME }}/devoops-rating-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 5d49ffea61428e8e6da41d2db9c274f9959bf94d Mon Sep 17 00:00:00 2001 From: Dusan Date: Mon, 16 Feb 2026 18:18:59 +0100 Subject: [PATCH 09/17] feat: Add Rating CRUD with MongoDb --- build.gradle.kts | 17 ++- environment/.local.env | 6 + .../com/devoops/rating/RatingApplication.java | 3 +- .../devoops/rating/config/MongoConfig.java | 10 ++ .../rating/controller/RatingController.java | 71 ++++++++++++ .../dto/request/CreateRatingRequest.java | 29 +++++ .../dto/request/UpdateRatingRequest.java | 14 +++ .../rating/dto/response/RatingResponse.java | 16 +++ .../exception/GlobalExceptionHandler.java | 64 +++++++++++ .../exception/RatingNotFoundException.java | 11 ++ .../devoops/rating/mapper/RatingMapper.java | 25 +++++ .../devoops/rating/model/BaseDocument.java | 44 ++++++++ .../java/com/devoops/rating/model/Rating.java | 30 +++++ .../rating/repository/RatingRepository.java | 23 ++++ .../devoops/rating/service/RatingService.java | 26 +++++ .../rating/service/RatingServiceImpl.java | 104 ++++++++++++++++++ src/main/resources/application.properties | 10 ++ 17 files changed, 497 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/devoops/rating/config/MongoConfig.java create mode 100644 src/main/java/com/devoops/rating/controller/RatingController.java create mode 100644 src/main/java/com/devoops/rating/dto/request/CreateRatingRequest.java create mode 100644 src/main/java/com/devoops/rating/dto/request/UpdateRatingRequest.java create mode 100644 src/main/java/com/devoops/rating/dto/response/RatingResponse.java create mode 100644 src/main/java/com/devoops/rating/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/devoops/rating/exception/RatingNotFoundException.java create mode 100644 src/main/java/com/devoops/rating/mapper/RatingMapper.java create mode 100644 src/main/java/com/devoops/rating/model/BaseDocument.java create mode 100644 src/main/java/com/devoops/rating/model/Rating.java create mode 100644 src/main/java/com/devoops/rating/repository/RatingRepository.java create mode 100644 src/main/java/com/devoops/rating/service/RatingService.java create mode 100644 src/main/java/com/devoops/rating/service/RatingServiceImpl.java diff --git a/build.gradle.kts b/build.gradle.kts index 06c2892..88ccae7 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -19,21 +19,30 @@ repositories { } dependencies { - implementation("org.springframework.boot:spring-boot-starter-flyway") + // MongoDB + implementation("org.springframework.boot:spring-boot-starter-data-mongodb") + + // 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") + // implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("io.micrometer:micrometer-registry-prometheus") implementation("org.springframework.boot:spring-boot-starter-webmvc") + implementation("org.springframework.boot:spring-boot-starter-validation") implementation("net.logstash.logback:logstash-logback-encoder:8.0") - implementation("org.flywaydb:flyway-database-postgresql") //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") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/environment/.local.env b/environment/.local.env index a3dc869..1b704a2 100644 --- a/environment/.local.env +++ b/environment/.local.env @@ -2,3 +2,9 @@ SERVER_PORT=8080 LOGSTASH_HOST=logstash:5000 ZIPKIN_HOST=zipkin ZIPKIN_PORT=9411 + +# MongoDB +MONGODB_HOST=devoops-mongodb +MONGODB_PORT=27017 +MONGODB_USERNAME=devoops +MONGODB_PASSWORD=devoops diff --git a/src/main/java/com/devoops/rating/RatingApplication.java b/src/main/java/com/devoops/rating/RatingApplication.java index f0e90b9..41053ae 100644 --- a/src/main/java/com/devoops/rating/RatingApplication.java +++ b/src/main/java/com/devoops/rating/RatingApplication.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 RatingApplication { public static void main(String[] args) { diff --git a/src/main/java/com/devoops/rating/config/MongoConfig.java b/src/main/java/com/devoops/rating/config/MongoConfig.java new file mode 100644 index 0000000..6695581 --- /dev/null +++ b/src/main/java/com/devoops/rating/config/MongoConfig.java @@ -0,0 +1,10 @@ +package com.devoops.rating.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mongodb.config.EnableMongoAuditing; + +@Configuration +@EnableMongoAuditing +public class MongoConfig { + +} diff --git a/src/main/java/com/devoops/rating/controller/RatingController.java b/src/main/java/com/devoops/rating/controller/RatingController.java new file mode 100644 index 0000000..14735e5 --- /dev/null +++ b/src/main/java/com/devoops/rating/controller/RatingController.java @@ -0,0 +1,71 @@ +package com.devoops.rating.controller; + +import com.devoops.rating.dto.request.CreateRatingRequest; +import com.devoops.rating.dto.response.RatingResponse; +import com.devoops.rating.dto.request.UpdateRatingRequest; +import com.devoops.rating.service.RatingService; +import jakarta.validation.Valid; +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/v1/ratings") +public class RatingController { + + private final RatingService ratingService; + + public RatingController(RatingService ratingService) { + this.ratingService = ratingService; + } + + @PostMapping + public ResponseEntity createRating(@Valid @RequestBody CreateRatingRequest request) { + RatingResponse response = ratingService.createRating(request); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping("/{id}") + public ResponseEntity getRatingById(@PathVariable UUID id) { + RatingResponse response = ratingService.getRatingById(id); + return ResponseEntity.ok(response); + } + + @GetMapping + public ResponseEntity> getAllRatings() { + List responses = ratingService.getAllRatings(); + return ResponseEntity.ok(responses); + } + + @GetMapping("/target/{targetId}") + public ResponseEntity> getRatingsByTargetId(@PathVariable UUID targetId) { + List responses = ratingService.getRatingsByTargetId(targetId); + return ResponseEntity.ok(responses); + } + + + + @GetMapping("/guest/{guestId}") + public ResponseEntity> getRatingsByGuestId(@PathVariable UUID guestId) { + List responses = ratingService.getRatingsByGuestId(guestId); + return ResponseEntity.ok(responses); + } + + @PutMapping("/{id}") + public ResponseEntity updateRating( + @PathVariable UUID id, + @Valid @RequestBody UpdateRatingRequest request) { + RatingResponse response = ratingService.updateRating(id, request); + return ResponseEntity.ok(response); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteRating(@PathVariable UUID id) { + ratingService.deleteRating(id); + return ResponseEntity.noContent().build(); + } +} + diff --git a/src/main/java/com/devoops/rating/dto/request/CreateRatingRequest.java b/src/main/java/com/devoops/rating/dto/request/CreateRatingRequest.java new file mode 100644 index 0000000..ebb6657 --- /dev/null +++ b/src/main/java/com/devoops/rating/dto/request/CreateRatingRequest.java @@ -0,0 +1,29 @@ +package com.devoops.rating.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.util.UUID; + +public record CreateRatingRequest( + @NotNull(message = "Target ID is required") + UUID targetId, + + @NotBlank(message = "Guest first name is required") + String guestFirstName, + + @NotBlank(message = "Guest last name is required") + String guestLastName, + + @NotNull(message = "Guest ID is required") + UUID guestId, + + @NotNull(message = "Score is required") + @Min(value = 1, message = "Score must be at least 1") + @Max(value = 5, message = "Score must be at most 5") + Integer score +) { +} + diff --git a/src/main/java/com/devoops/rating/dto/request/UpdateRatingRequest.java b/src/main/java/com/devoops/rating/dto/request/UpdateRatingRequest.java new file mode 100644 index 0000000..05c7732 --- /dev/null +++ b/src/main/java/com/devoops/rating/dto/request/UpdateRatingRequest.java @@ -0,0 +1,14 @@ +package com.devoops.rating.dto.request; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +public record UpdateRatingRequest( + @NotNull(message = "Score is required") + @Min(value = 1, message = "Score must be at least 1") + @Max(value = 5, message = "Score must be at most 5") + Integer score +) { +} + diff --git a/src/main/java/com/devoops/rating/dto/response/RatingResponse.java b/src/main/java/com/devoops/rating/dto/response/RatingResponse.java new file mode 100644 index 0000000..4b481fe --- /dev/null +++ b/src/main/java/com/devoops/rating/dto/response/RatingResponse.java @@ -0,0 +1,16 @@ +package com.devoops.rating.dto.response; + +import java.time.LocalDateTime; +import java.util.UUID; + +public record RatingResponse( + UUID id, + UUID targetId, + String guestFirstName, + String guestLastName, + UUID guestId, + Integer score, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { } + diff --git a/src/main/java/com/devoops/rating/exception/GlobalExceptionHandler.java b/src/main/java/com/devoops/rating/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..688c52f --- /dev/null +++ b/src/main/java/com/devoops/rating/exception/GlobalExceptionHandler.java @@ -0,0 +1,64 @@ +package com.devoops.rating.exception; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.time.Instant; +import java.util.HashMap; +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(RatingNotFoundException.class) + public ResponseEntity handleRatingNotFound(RatingNotFoundException ex) { + ErrorResponse error = new ErrorResponse( + HttpStatus.NOT_FOUND.value(), + ex.getMessage(), + Instant.now() + ); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationErrors(MethodArgumentNotValidException ex) { + Map errors = new HashMap<>(); + ex.getBindingResult().getAllErrors().forEach(error -> { + String fieldName = ((FieldError) error).getField(); + String errorMessage = error.getDefaultMessage(); + errors.put(fieldName, errorMessage); + }); + + ValidationErrorResponse response = new ValidationErrorResponse( + HttpStatus.BAD_REQUEST.value(), + "Validation failed", + errors, + Instant.now() + ); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception ex) { + log.error("Unexpected error occurred: ", ex); + ErrorResponse error = new ErrorResponse( + HttpStatus.INTERNAL_SERVER_ERROR.value(), + ex.getMessage(), + Instant.now() + ); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(error); + } + + public record ErrorResponse(int status, String message, Instant timestamp) {} + + public record ValidationErrorResponse(int status, String message, Map errors, Instant timestamp) {} +} + diff --git a/src/main/java/com/devoops/rating/exception/RatingNotFoundException.java b/src/main/java/com/devoops/rating/exception/RatingNotFoundException.java new file mode 100644 index 0000000..859fe72 --- /dev/null +++ b/src/main/java/com/devoops/rating/exception/RatingNotFoundException.java @@ -0,0 +1,11 @@ +package com.devoops.rating.exception; + +import java.util.UUID; + +public class RatingNotFoundException extends RuntimeException { + + public RatingNotFoundException(UUID id) { + super("Rating not found with id: " + id); + } +} + diff --git a/src/main/java/com/devoops/rating/mapper/RatingMapper.java b/src/main/java/com/devoops/rating/mapper/RatingMapper.java new file mode 100644 index 0000000..516199a --- /dev/null +++ b/src/main/java/com/devoops/rating/mapper/RatingMapper.java @@ -0,0 +1,25 @@ +package com.devoops.rating.mapper; + +import com.devoops.rating.dto.request.CreateRatingRequest; +import com.devoops.rating.dto.response.RatingResponse; +import com.devoops.rating.model.Rating; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface RatingMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "isDeleted", ignore = true) + Rating toEntity(CreateRatingRequest request); + + RatingResponse toResponse(Rating rating); + + List toResponseList(List ratings); +} + diff --git a/src/main/java/com/devoops/rating/model/BaseDocument.java b/src/main/java/com/devoops/rating/model/BaseDocument.java new file mode 100644 index 0000000..a3e4097 --- /dev/null +++ b/src/main/java/com/devoops/rating/model/BaseDocument.java @@ -0,0 +1,44 @@ +package com.devoops.rating.model; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.annotation.Transient; +import org.springframework.data.domain.Persistable; +import org.springframework.data.mongodb.core.mapping.MongoId; +import org.springframework.data.mongodb.core.mapping.FieldType; + +import java.time.LocalDateTime; +import java.util.UUID; + +@Data +@NoArgsConstructor +@AllArgsConstructor +@SuperBuilder +public abstract class BaseDocument implements Persistable { + + @Id + @Builder.Default + private UUID id = UUID.randomUUID(); + + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; + + @Builder.Default + private boolean isDeleted = false; + + @Override + @Transient + public boolean isNew() { + return createdAt == null; + } +} + diff --git a/src/main/java/com/devoops/rating/model/Rating.java b/src/main/java/com/devoops/rating/model/Rating.java new file mode 100644 index 0000000..7851216 --- /dev/null +++ b/src/main/java/com/devoops/rating/model/Rating.java @@ -0,0 +1,30 @@ +package com.devoops.rating.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; +import lombok.experimental.SuperBuilder; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.UUID; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +@AllArgsConstructor +@SuperBuilder +@Document(collection = "ratings") +public class Rating extends BaseDocument { + + private UUID targetId; + + private String guestFirstName; + + private String guestLastName; + + private UUID guestId; + + private Integer score; +} + diff --git a/src/main/java/com/devoops/rating/repository/RatingRepository.java b/src/main/java/com/devoops/rating/repository/RatingRepository.java new file mode 100644 index 0000000..64539e0 --- /dev/null +++ b/src/main/java/com/devoops/rating/repository/RatingRepository.java @@ -0,0 +1,23 @@ +package com.devoops.rating.repository; + +import com.devoops.rating.model.Rating; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface RatingRepository extends MongoRepository { + + Optional findByIdAndIsDeletedFalse(UUID id); + + List findAllByIsDeletedFalse(); + + List findAllByTargetIdAndIsDeletedFalse(UUID targetId); + + List findAllByGuestIdAndIsDeletedFalse(UUID guestId); + +} + diff --git a/src/main/java/com/devoops/rating/service/RatingService.java b/src/main/java/com/devoops/rating/service/RatingService.java new file mode 100644 index 0000000..bf3fc66 --- /dev/null +++ b/src/main/java/com/devoops/rating/service/RatingService.java @@ -0,0 +1,26 @@ +package com.devoops.rating.service; + +import com.devoops.rating.dto.request.CreateRatingRequest; +import com.devoops.rating.dto.response.RatingResponse; +import com.devoops.rating.dto.request.UpdateRatingRequest; + +import java.util.List; +import java.util.UUID; + +public interface RatingService { + + RatingResponse createRating(CreateRatingRequest request); + + RatingResponse getRatingById(UUID id); + + List getAllRatings(); + + List getRatingsByTargetId(UUID targetId); + + List getRatingsByGuestId(UUID guestId); + + RatingResponse updateRating(UUID id, UpdateRatingRequest request); + + void deleteRating(UUID id); +} + diff --git a/src/main/java/com/devoops/rating/service/RatingServiceImpl.java b/src/main/java/com/devoops/rating/service/RatingServiceImpl.java new file mode 100644 index 0000000..88e19cd --- /dev/null +++ b/src/main/java/com/devoops/rating/service/RatingServiceImpl.java @@ -0,0 +1,104 @@ +package com.devoops.rating.service; + +import com.devoops.rating.dto.request.CreateRatingRequest; +import com.devoops.rating.dto.response.RatingResponse; +import com.devoops.rating.dto.request.UpdateRatingRequest; +import com.devoops.rating.exception.RatingNotFoundException; +import com.devoops.rating.mapper.RatingMapper; +import com.devoops.rating.model.Rating; +import com.devoops.rating.repository.RatingRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Service +public class RatingServiceImpl implements RatingService { + + private static final Logger log = LoggerFactory.getLogger(RatingServiceImpl.class); + + private final RatingRepository ratingRepository; + private final RatingMapper ratingMapper; + + public RatingServiceImpl(RatingRepository ratingRepository, RatingMapper ratingMapper) { + this.ratingRepository = ratingRepository; + this.ratingMapper = ratingMapper; + } + + @Override + public RatingResponse createRating(CreateRatingRequest request) { + log.debug("Creating new rating for target: {}", request.targetId()); + + Rating rating = ratingMapper.toEntity(request); + + Rating savedRating = ratingRepository.save(rating); + log.info("Created rating with id: {}", savedRating.getId()); + + return ratingMapper.toResponse(savedRating); + } + + @Override + public RatingResponse getRatingById(UUID id) { + log.debug("Fetching rating with id: {}", id); + + Rating rating = ratingRepository.findByIdAndIsDeletedFalse(id) + .orElseThrow(() -> new RatingNotFoundException(id)); + + return ratingMapper.toResponse(rating); + } + + @Override + public List getAllRatings() { + log.debug("Fetching all ratings"); + + return ratingMapper.toResponseList(ratingRepository.findAllByIsDeletedFalse()); + } + + @Override + public List getRatingsByTargetId(UUID targetId) { + log.debug("Fetching ratings for target: {}", targetId); + + return ratingMapper.toResponseList(ratingRepository.findAllByTargetIdAndIsDeletedFalse(targetId)); + } + + @Override + public List getRatingsByGuestId(UUID guestId) { + log.debug("Fetching ratings by guest: {}", guestId); + + return ratingMapper.toResponseList(ratingRepository.findAllByGuestIdAndIsDeletedFalse(guestId)); + } + + @Override + public RatingResponse updateRating(UUID id, UpdateRatingRequest request) { + log.debug("Updating rating with id: {}", id); + + Rating rating = ratingRepository.findByIdAndIsDeletedFalse(id) + .orElseThrow(() -> new RatingNotFoundException(id)); + + rating.setScore(request.score()); + rating.setUpdatedAt(LocalDateTime.now()); + + Rating updatedRating = ratingRepository.save(rating); + log.info("Updated rating with id: {}", updatedRating.getId()); + + return ratingMapper.toResponse(updatedRating); + } + + @Override + public void deleteRating(UUID id) { + log.debug("Soft deleting rating with id: {}", id); + + Rating rating = ratingRepository.findByIdAndIsDeletedFalse(id) + .orElseThrow(() -> new RatingNotFoundException(id)); + + rating.setDeleted(true); + rating.setUpdatedAt(LocalDateTime.now()); + ratingRepository.save(rating); + + log.info("Soft deleted rating with id: {}", id); + } +} + diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a4386c7..a431b4d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,5 +1,15 @@ spring.application.name=rating server.port=${SERVER_PORT:8080} + +# MongoDB Configuration +spring.mongodb.host=${MONGODB_HOST:devoops-mongodb} +spring.mongodb.port=${MONGODB_PORT:27017} +spring.mongodb.database=rating_db +spring.mongodb.username=${MONGODB_USERNAME:devoops} +spring.mongodb.password=${MONGODB_PASSWORD:devoops} +spring.mongodb.authentication-database=admin +spring.mongodb.representation.uuid=standard + # Logging configuration logging.logstash.host=${LOGSTASH_HOST:localhost:5000} logging.level.root=INFO From 08ed924a9dbb5cf2b897e85807231fe110f0b168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Sat, 21 Feb 2026 19:06:56 +0100 Subject: [PATCH 10/17] feat: Add role-based auth and user context support Introduce role-based authorization and request-scoped user context. Adds @RequireRole annotation and RoleAuthorizationInterceptor to enforce roles from the X-User-Role header (throws Unauthorized/Forbidden as needed). Adds UserContext record and UserContextResolver to inject a validated UserContext (from X-User-Id and X-User-Role) into controller methods. Registers the resolver and interceptor in WebConfig. Protects RatingController endpoints with @RequireRole("GUEST"). Adds UnauthorizedException and ForbiddenException and corresponding handlers in GlobalExceptionHandler to return 401/403 responses. --- .../devoops/rating/config/RequireRole.java | 12 +++++ .../config/RoleAuthorizationInterceptor.java | 50 +++++++++++++++++++ .../devoops/rating/config/UserContext.java | 5 ++ .../rating/config/UserContextResolver.java | 41 +++++++++++++++ .../com/devoops/rating/config/WebConfig.java | 27 ++++++++++ .../rating/controller/RatingController.java | 7 ++- .../rating/exception/ForbiddenException.java | 5 ++ .../exception/GlobalExceptionHandler.java | 20 ++++++++ .../exception/UnauthorizedException.java | 5 ++ 9 files changed, 170 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/devoops/rating/config/RequireRole.java create mode 100644 src/main/java/com/devoops/rating/config/RoleAuthorizationInterceptor.java create mode 100644 src/main/java/com/devoops/rating/config/UserContext.java create mode 100644 src/main/java/com/devoops/rating/config/UserContextResolver.java create mode 100644 src/main/java/com/devoops/rating/config/WebConfig.java create mode 100644 src/main/java/com/devoops/rating/exception/ForbiddenException.java create mode 100644 src/main/java/com/devoops/rating/exception/UnauthorizedException.java diff --git a/src/main/java/com/devoops/rating/config/RequireRole.java b/src/main/java/com/devoops/rating/config/RequireRole.java new file mode 100644 index 0000000..28b1ddf --- /dev/null +++ b/src/main/java/com/devoops/rating/config/RequireRole.java @@ -0,0 +1,12 @@ +package com.devoops.rating.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(); +} diff --git a/src/main/java/com/devoops/rating/config/RoleAuthorizationInterceptor.java b/src/main/java/com/devoops/rating/config/RoleAuthorizationInterceptor.java new file mode 100644 index 0000000..388acc8 --- /dev/null +++ b/src/main/java/com/devoops/rating/config/RoleAuthorizationInterceptor.java @@ -0,0 +1,50 @@ +package com.devoops.rating.config; + +import com.devoops.rating.exception.ForbiddenException; +import com.devoops.rating.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; + } +} diff --git a/src/main/java/com/devoops/rating/config/UserContext.java b/src/main/java/com/devoops/rating/config/UserContext.java new file mode 100644 index 0000000..5a66ccc --- /dev/null +++ b/src/main/java/com/devoops/rating/config/UserContext.java @@ -0,0 +1,5 @@ +package com.devoops.rating.config; + +import java.util.UUID; + +public record UserContext(UUID userId, String role) { } diff --git a/src/main/java/com/devoops/rating/config/UserContextResolver.java b/src/main/java/com/devoops/rating/config/UserContextResolver.java new file mode 100644 index 0000000..2d1be2b --- /dev/null +++ b/src/main/java/com/devoops/rating/config/UserContextResolver.java @@ -0,0 +1,41 @@ +package com.devoops.rating.config; + +import com.devoops.rating.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"); + } + } +} diff --git a/src/main/java/com/devoops/rating/config/WebConfig.java b/src/main/java/com/devoops/rating/config/WebConfig.java new file mode 100644 index 0000000..c667919 --- /dev/null +++ b/src/main/java/com/devoops/rating/config/WebConfig.java @@ -0,0 +1,27 @@ +package com.devoops.rating.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); + } + +} diff --git a/src/main/java/com/devoops/rating/controller/RatingController.java b/src/main/java/com/devoops/rating/controller/RatingController.java index 14735e5..0f2b8d3 100644 --- a/src/main/java/com/devoops/rating/controller/RatingController.java +++ b/src/main/java/com/devoops/rating/controller/RatingController.java @@ -1,5 +1,6 @@ package com.devoops.rating.controller; +import com.devoops.rating.config.RequireRole; import com.devoops.rating.dto.request.CreateRatingRequest; import com.devoops.rating.dto.response.RatingResponse; import com.devoops.rating.dto.request.UpdateRatingRequest; @@ -23,6 +24,7 @@ public RatingController(RatingService ratingService) { } @PostMapping + @RequireRole("GUEST") public ResponseEntity createRating(@Valid @RequestBody CreateRatingRequest request) { RatingResponse response = ratingService.createRating(request); return ResponseEntity.status(HttpStatus.CREATED).body(response); @@ -46,15 +48,15 @@ public ResponseEntity> getRatingsByTargetId(@PathVariable U return ResponseEntity.ok(responses); } - - @GetMapping("/guest/{guestId}") + @RequireRole("GUEST") public ResponseEntity> getRatingsByGuestId(@PathVariable UUID guestId) { List responses = ratingService.getRatingsByGuestId(guestId); return ResponseEntity.ok(responses); } @PutMapping("/{id}") + @RequireRole("GUEST") public ResponseEntity updateRating( @PathVariable UUID id, @Valid @RequestBody UpdateRatingRequest request) { @@ -63,6 +65,7 @@ public ResponseEntity updateRating( } @DeleteMapping("/{id}") + @RequireRole("GUEST") public ResponseEntity deleteRating(@PathVariable UUID id) { ratingService.deleteRating(id); return ResponseEntity.noContent().build(); diff --git a/src/main/java/com/devoops/rating/exception/ForbiddenException.java b/src/main/java/com/devoops/rating/exception/ForbiddenException.java new file mode 100644 index 0000000..db5cfb2 --- /dev/null +++ b/src/main/java/com/devoops/rating/exception/ForbiddenException.java @@ -0,0 +1,5 @@ +package com.devoops.rating.exception; + +public class ForbiddenException extends RuntimeException { + public ForbiddenException(String message) { super(message); } +} diff --git a/src/main/java/com/devoops/rating/exception/GlobalExceptionHandler.java b/src/main/java/com/devoops/rating/exception/GlobalExceptionHandler.java index 688c52f..1f2f4e2 100644 --- a/src/main/java/com/devoops/rating/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/devoops/rating/exception/GlobalExceptionHandler.java @@ -28,6 +28,26 @@ public ResponseEntity handleRatingNotFound(RatingNotFoundExceptio return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); } + @ExceptionHandler(UnauthorizedException.class) + public ResponseEntity handleUnauthorized(UnauthorizedException ex) { + ErrorResponse error = new ErrorResponse( + HttpStatus.UNAUTHORIZED.value(), + ex.getMessage(), + Instant.now() + ); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error); + } + + @ExceptionHandler(ForbiddenException.class) + public ResponseEntity handleForbidden(ForbiddenException ex) { + ErrorResponse error = new ErrorResponse( + HttpStatus.FORBIDDEN.value(), + ex.getMessage(), + Instant.now() + ); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error); + } + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleValidationErrors(MethodArgumentNotValidException ex) { Map errors = new HashMap<>(); diff --git a/src/main/java/com/devoops/rating/exception/UnauthorizedException.java b/src/main/java/com/devoops/rating/exception/UnauthorizedException.java new file mode 100644 index 0000000..c97f7fc --- /dev/null +++ b/src/main/java/com/devoops/rating/exception/UnauthorizedException.java @@ -0,0 +1,5 @@ +package com.devoops.rating.exception; + +public class UnauthorizedException extends RuntimeException { + public UnauthorizedException(String message) { super(message); } +} From d2da1af85e5e9111201da758e960fe29a2a7fcdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Sat, 21 Feb 2026 19:23:11 +0100 Subject: [PATCH 11/17] feat: Add gRPC user client and integrate into ratings Introduce gRPC integration with the user service and refactor rating service to use it. Adds protobuf/grpc plugin and dependencies to build.gradle.kts and a user_internal.proto definition, plus UserGrpcClient and UserSummaryResult to call the user service. RatingService was refactored into a @Service class (old RatingServiceImpl removed) to fetch user summaries, populate guest metadata, and perform CRUD with soft-delete; controller updated to accept UserContext and pass it through; CreateRatingRequest DTO trimmed of guest fields and mapper updated to ignore guest-related entity fields. Also add gRPC client configuration to application.properties and environment variables for USER_GRPC_HOST/PORT. --- build.gradle.kts | 30 +++++ environment/.local.env | 4 + .../rating/controller/RatingController.java | 14 ++- .../dto/request/CreateRatingRequest.java | 11 -- .../devoops/rating/grpc/UserGrpcClient.java | 44 ++++++++ .../rating/grpc/UserSummaryResult.java | 13 +++ .../devoops/rating/mapper/RatingMapper.java | 4 +- .../devoops/rating/service/RatingService.java | 98 +++++++++++++++-- .../rating/service/RatingServiceImpl.java | 104 ------------------ src/main/proto/user_internal.proto | 23 ++++ src/main/resources/application.properties | 4 + 11 files changed, 218 insertions(+), 131 deletions(-) create mode 100644 src/main/java/com/devoops/rating/grpc/UserGrpcClient.java create mode 100644 src/main/java/com/devoops/rating/grpc/UserSummaryResult.java delete mode 100644 src/main/java/com/devoops/rating/service/RatingServiceImpl.java create mode 100644 src/main/proto/user_internal.proto diff --git a/build.gradle.kts b/build.gradle.kts index 88ccae7..487e0e3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,10 @@ +import com.google.protobuf.gradle.* + plugins { java id("org.springframework.boot") version "4.0.1" id("io.spring.dependency-management") version "1.1.7" + id("com.google.protobuf") version "0.9.4" } group = "com.devoops" @@ -18,6 +21,8 @@ repositories { mavenCentral() } +val grpcVersion = "1.68.0" + dependencies { // MongoDB implementation("org.springframework.boot:spring-boot-starter-data-mongodb") @@ -43,11 +48,36 @@ dependencies { implementation("io.micrometer:micrometer-tracing-bridge-brave") implementation("io.zipkin.reporter2:zipkin-reporter-brave") + // gRPC Client + implementation("net.devh:grpc-client-spring-boot-starter:3.1.0.RELEASE") + implementation("io.grpc:grpc-protobuf:$grpcVersion") + implementation("io.grpc:grpc-stub:$grpcVersion") + implementation("io.grpc:grpc-netty-shaded:$grpcVersion") + compileOnly("javax.annotation:javax.annotation-api:1.3.2") + // 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") } +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() } diff --git a/environment/.local.env b/environment/.local.env index 1b704a2..5d0364e 100644 --- a/environment/.local.env +++ b/environment/.local.env @@ -8,3 +8,7 @@ MONGODB_HOST=devoops-mongodb MONGODB_PORT=27017 MONGODB_USERNAME=devoops MONGODB_PASSWORD=devoops + +# gRPC +USER_GRPC_HOST=devoops-user-service +USER_GRPC_PORT=9090 diff --git a/src/main/java/com/devoops/rating/controller/RatingController.java b/src/main/java/com/devoops/rating/controller/RatingController.java index 0f2b8d3..83b1eaf 100644 --- a/src/main/java/com/devoops/rating/controller/RatingController.java +++ b/src/main/java/com/devoops/rating/controller/RatingController.java @@ -1,6 +1,7 @@ package com.devoops.rating.controller; import com.devoops.rating.config.RequireRole; +import com.devoops.rating.config.UserContext; import com.devoops.rating.dto.request.CreateRatingRequest; import com.devoops.rating.dto.response.RatingResponse; import com.devoops.rating.dto.request.UpdateRatingRequest; @@ -25,8 +26,10 @@ public RatingController(RatingService ratingService) { @PostMapping @RequireRole("GUEST") - public ResponseEntity createRating(@Valid @RequestBody CreateRatingRequest request) { - RatingResponse response = ratingService.createRating(request); + public ResponseEntity createRating( + @Valid @RequestBody CreateRatingRequest request, + UserContext userContext) { + RatingResponse response = ratingService.createRating(request, userContext); return ResponseEntity.status(HttpStatus.CREATED).body(response); } @@ -48,10 +51,10 @@ public ResponseEntity> getRatingsByTargetId(@PathVariable U return ResponseEntity.ok(responses); } - @GetMapping("/guest/{guestId}") + @GetMapping("/guest") @RequireRole("GUEST") - public ResponseEntity> getRatingsByGuestId(@PathVariable UUID guestId) { - List responses = ratingService.getRatingsByGuestId(guestId); + public ResponseEntity> getRatingsByGuestId(UserContext userContext) { + List responses = ratingService.getRatingsByGuestId(userContext.userId()); return ResponseEntity.ok(responses); } @@ -71,4 +74,3 @@ public ResponseEntity deleteRating(@PathVariable UUID id) { return ResponseEntity.noContent().build(); } } - diff --git a/src/main/java/com/devoops/rating/dto/request/CreateRatingRequest.java b/src/main/java/com/devoops/rating/dto/request/CreateRatingRequest.java index ebb6657..1c36836 100644 --- a/src/main/java/com/devoops/rating/dto/request/CreateRatingRequest.java +++ b/src/main/java/com/devoops/rating/dto/request/CreateRatingRequest.java @@ -2,7 +2,6 @@ import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import java.util.UUID; @@ -11,19 +10,9 @@ public record CreateRatingRequest( @NotNull(message = "Target ID is required") UUID targetId, - @NotBlank(message = "Guest first name is required") - String guestFirstName, - - @NotBlank(message = "Guest last name is required") - String guestLastName, - - @NotNull(message = "Guest ID is required") - UUID guestId, - @NotNull(message = "Score is required") @Min(value = 1, message = "Score must be at least 1") @Max(value = 5, message = "Score must be at most 5") Integer score ) { } - diff --git a/src/main/java/com/devoops/rating/grpc/UserGrpcClient.java b/src/main/java/com/devoops/rating/grpc/UserGrpcClient.java new file mode 100644 index 0000000..d3c5181 --- /dev/null +++ b/src/main/java/com/devoops/rating/grpc/UserGrpcClient.java @@ -0,0 +1,44 @@ +package com.devoops.rating.grpc; + +import com.devoops.rating.grpc.proto.user.GetUserSummaryRequest; +import com.devoops.rating.grpc.proto.user.GetUserSummaryResponse; +import com.devoops.rating.grpc.proto.user.UserInternalServiceGrpc; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.client.inject.GrpcClient; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +@Slf4j +public class UserGrpcClient { + + @GrpcClient("user-service") + private UserInternalServiceGrpc.UserInternalServiceBlockingStub userStub; + + public UserSummaryResult getUserSummary(UUID userId) { + log.debug("Calling user service for user summary: userId={}", userId); + + GetUserSummaryRequest request = GetUserSummaryRequest.newBuilder() + .setUserId(userId.toString()) + .build(); + + GetUserSummaryResponse response = userStub.getUserSummary(request); + + log.debug("Received user summary response: found={}", response.getFound()); + + if (!response.getFound()) { + return new UserSummaryResult(false, null, null, null, null, null, false); + } + + return new UserSummaryResult( + true, + UUID.fromString(response.getUserId()), + response.getEmail(), + response.getFirstName(), + response.getLastName(), + response.getRole(), + response.getIsDeleted() + ); + } +} diff --git a/src/main/java/com/devoops/rating/grpc/UserSummaryResult.java b/src/main/java/com/devoops/rating/grpc/UserSummaryResult.java new file mode 100644 index 0000000..1e16fd3 --- /dev/null +++ b/src/main/java/com/devoops/rating/grpc/UserSummaryResult.java @@ -0,0 +1,13 @@ +package com.devoops.rating.grpc; + +import java.util.UUID; + +public record UserSummaryResult( + boolean found, + UUID userId, + String email, + String firstName, + String lastName, + String role, + boolean isDeleted +) {} diff --git a/src/main/java/com/devoops/rating/mapper/RatingMapper.java b/src/main/java/com/devoops/rating/mapper/RatingMapper.java index 516199a..c10ec48 100644 --- a/src/main/java/com/devoops/rating/mapper/RatingMapper.java +++ b/src/main/java/com/devoops/rating/mapper/RatingMapper.java @@ -16,10 +16,12 @@ public interface RatingMapper { @Mapping(target = "createdAt", ignore = true) @Mapping(target = "updatedAt", ignore = true) @Mapping(target = "isDeleted", ignore = true) + @Mapping(target = "guestId", ignore = true) + @Mapping(target = "guestFirstName", ignore = true) + @Mapping(target = "guestLastName", ignore = true) Rating toEntity(CreateRatingRequest request); RatingResponse toResponse(Rating rating); List toResponseList(List ratings); } - diff --git a/src/main/java/com/devoops/rating/service/RatingService.java b/src/main/java/com/devoops/rating/service/RatingService.java index bf3fc66..63f757d 100644 --- a/src/main/java/com/devoops/rating/service/RatingService.java +++ b/src/main/java/com/devoops/rating/service/RatingService.java @@ -1,26 +1,106 @@ package com.devoops.rating.service; +import com.devoops.rating.config.UserContext; import com.devoops.rating.dto.request.CreateRatingRequest; import com.devoops.rating.dto.response.RatingResponse; import com.devoops.rating.dto.request.UpdateRatingRequest; +import com.devoops.rating.exception.RatingNotFoundException; +import com.devoops.rating.grpc.UserGrpcClient; +import com.devoops.rating.grpc.UserSummaryResult; +import com.devoops.rating.mapper.RatingMapper; +import com.devoops.rating.model.Rating; +import com.devoops.rating.repository.RatingRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import java.time.LocalDateTime; import java.util.List; import java.util.UUID; -public interface RatingService { +@Service +public class RatingService { - RatingResponse createRating(CreateRatingRequest request); + private static final Logger log = LoggerFactory.getLogger(RatingService.class); - RatingResponse getRatingById(UUID id); + private final RatingRepository ratingRepository; + private final RatingMapper ratingMapper; + private final UserGrpcClient userGrpcClient; - List getAllRatings(); + public RatingService(RatingRepository ratingRepository, RatingMapper ratingMapper, UserGrpcClient userGrpcClient) { + this.ratingRepository = ratingRepository; + this.ratingMapper = ratingMapper; + this.userGrpcClient = userGrpcClient; + } - List getRatingsByTargetId(UUID targetId); + public RatingResponse createRating(CreateRatingRequest request, UserContext userContext) { + log.debug("Creating new rating for target: {}", request.targetId()); - List getRatingsByGuestId(UUID guestId); + UserSummaryResult userSummary = userGrpcClient.getUserSummary(userContext.userId()); - RatingResponse updateRating(UUID id, UpdateRatingRequest request); + Rating rating = ratingMapper.toEntity(request); + rating.setGuestId(userContext.userId()); + rating.setGuestFirstName(userSummary.found() ? userSummary.firstName() : ""); + rating.setGuestLastName(userSummary.found() ? userSummary.lastName() : ""); - void deleteRating(UUID id); -} + Rating savedRating = ratingRepository.save(rating); + log.info("Created rating with id: {}", savedRating.getId()); + + return ratingMapper.toResponse(savedRating); + } + + public RatingResponse getRatingById(UUID id) { + log.debug("Fetching rating with id: {}", id); + + Rating rating = ratingRepository.findByIdAndIsDeletedFalse(id) + .orElseThrow(() -> new RatingNotFoundException(id)); + + return ratingMapper.toResponse(rating); + } + + public List getAllRatings() { + log.debug("Fetching all ratings"); + + return ratingMapper.toResponseList(ratingRepository.findAllByIsDeletedFalse()); + } + + public List getRatingsByTargetId(UUID targetId) { + log.debug("Fetching ratings for target: {}", targetId); + + return ratingMapper.toResponseList(ratingRepository.findAllByTargetIdAndIsDeletedFalse(targetId)); + } + + public List getRatingsByGuestId(UUID guestId) { + log.debug("Fetching ratings by guest: {}", guestId); + return ratingMapper.toResponseList(ratingRepository.findAllByGuestIdAndIsDeletedFalse(guestId)); + } + + public RatingResponse updateRating(UUID id, UpdateRatingRequest request) { + log.debug("Updating rating with id: {}", id); + + Rating rating = ratingRepository.findByIdAndIsDeletedFalse(id) + .orElseThrow(() -> new RatingNotFoundException(id)); + + rating.setScore(request.score()); + rating.setUpdatedAt(LocalDateTime.now()); + + Rating updatedRating = ratingRepository.save(rating); + log.info("Updated rating with id: {}", updatedRating.getId()); + + return ratingMapper.toResponse(updatedRating); + } + + public void deleteRating(UUID id) { + log.debug("Soft deleting rating with id: {}", id); + + Rating rating = ratingRepository.findByIdAndIsDeletedFalse(id) + .orElseThrow(() -> new RatingNotFoundException(id)); + + rating.setDeleted(true); + rating.setUpdatedAt(LocalDateTime.now()); + ratingRepository.save(rating); + + log.info("Soft deleted rating with id: {}", id); + } +} diff --git a/src/main/java/com/devoops/rating/service/RatingServiceImpl.java b/src/main/java/com/devoops/rating/service/RatingServiceImpl.java deleted file mode 100644 index 88e19cd..0000000 --- a/src/main/java/com/devoops/rating/service/RatingServiceImpl.java +++ /dev/null @@ -1,104 +0,0 @@ -package com.devoops.rating.service; - -import com.devoops.rating.dto.request.CreateRatingRequest; -import com.devoops.rating.dto.response.RatingResponse; -import com.devoops.rating.dto.request.UpdateRatingRequest; -import com.devoops.rating.exception.RatingNotFoundException; -import com.devoops.rating.mapper.RatingMapper; -import com.devoops.rating.model.Rating; -import com.devoops.rating.repository.RatingRepository; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; -import java.util.List; -import java.util.UUID; - -@Service -public class RatingServiceImpl implements RatingService { - - private static final Logger log = LoggerFactory.getLogger(RatingServiceImpl.class); - - private final RatingRepository ratingRepository; - private final RatingMapper ratingMapper; - - public RatingServiceImpl(RatingRepository ratingRepository, RatingMapper ratingMapper) { - this.ratingRepository = ratingRepository; - this.ratingMapper = ratingMapper; - } - - @Override - public RatingResponse createRating(CreateRatingRequest request) { - log.debug("Creating new rating for target: {}", request.targetId()); - - Rating rating = ratingMapper.toEntity(request); - - Rating savedRating = ratingRepository.save(rating); - log.info("Created rating with id: {}", savedRating.getId()); - - return ratingMapper.toResponse(savedRating); - } - - @Override - public RatingResponse getRatingById(UUID id) { - log.debug("Fetching rating with id: {}", id); - - Rating rating = ratingRepository.findByIdAndIsDeletedFalse(id) - .orElseThrow(() -> new RatingNotFoundException(id)); - - return ratingMapper.toResponse(rating); - } - - @Override - public List getAllRatings() { - log.debug("Fetching all ratings"); - - return ratingMapper.toResponseList(ratingRepository.findAllByIsDeletedFalse()); - } - - @Override - public List getRatingsByTargetId(UUID targetId) { - log.debug("Fetching ratings for target: {}", targetId); - - return ratingMapper.toResponseList(ratingRepository.findAllByTargetIdAndIsDeletedFalse(targetId)); - } - - @Override - public List getRatingsByGuestId(UUID guestId) { - log.debug("Fetching ratings by guest: {}", guestId); - - return ratingMapper.toResponseList(ratingRepository.findAllByGuestIdAndIsDeletedFalse(guestId)); - } - - @Override - public RatingResponse updateRating(UUID id, UpdateRatingRequest request) { - log.debug("Updating rating with id: {}", id); - - Rating rating = ratingRepository.findByIdAndIsDeletedFalse(id) - .orElseThrow(() -> new RatingNotFoundException(id)); - - rating.setScore(request.score()); - rating.setUpdatedAt(LocalDateTime.now()); - - Rating updatedRating = ratingRepository.save(rating); - log.info("Updated rating with id: {}", updatedRating.getId()); - - return ratingMapper.toResponse(updatedRating); - } - - @Override - public void deleteRating(UUID id) { - log.debug("Soft deleting rating with id: {}", id); - - Rating rating = ratingRepository.findByIdAndIsDeletedFalse(id) - .orElseThrow(() -> new RatingNotFoundException(id)); - - rating.setDeleted(true); - rating.setUpdatedAt(LocalDateTime.now()); - ratingRepository.save(rating); - - log.info("Soft deleted rating with id: {}", id); - } -} - diff --git a/src/main/proto/user_internal.proto b/src/main/proto/user_internal.proto new file mode 100644 index 0000000..6905b06 --- /dev/null +++ b/src/main/proto/user_internal.proto @@ -0,0 +1,23 @@ +syntax = "proto3"; +package user; + +option java_multiple_files = true; +option java_package = "com.devoops.rating.grpc.proto.user"; + +service UserInternalService { + rpc GetUserSummary(GetUserSummaryRequest) returns (GetUserSummaryResponse); +} + +message GetUserSummaryRequest { + string user_id = 1; +} + +message GetUserSummaryResponse { + bool found = 1; + string user_id = 2; + string email = 3; + string first_name = 4; + string last_name = 5; + string role = 6; + bool is_deleted = 7; +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a431b4d..7dacc38 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -22,3 +22,7 @@ management.tracing.export.zipkin.endpoint=http://${ZIPKIN_HOST:zipkin}:${ZIPKIN_ management.endpoints.web.exposure.include=health,prometheus,metrics management.endpoint.health.show-details=always management.prometheus.metrics.export.enabled=true + +# 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 From 450f1ced3d0332331f62fd8ffe5632c397de04d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Sat, 21 Feb 2026 20:49:13 +0100 Subject: [PATCH 12/17] feat: Enforce reservation eligibility and add target type Add support for rating target types and reservation-based eligibility checks. Introduces RatingTargetType enum, adds targetType to CreateRatingRequest and Rating entity, and maps it in RatingMapper. Integrates a ReservationGrpcClient and proto (reservation_internal.proto) to verify if a guest can rate a target; service now checks eligibility and prevents duplicate ratings (throws ConflictException) and unauthorized updates/deletes (throws ForbiddenException). Adds RatingsSummaryResponse and updates controller endpoints to return summaries and pass UserContext. Adds repository lookup for existing guest-target ratings, a ConflictException class and handler, a ReservationEligibilityResult record, and gRPC client config in application.properties. Also includes minor controller path change and related method signature updates. --- .../rating/controller/RatingController.java | 18 ++++---- .../dto/request/CreateRatingRequest.java | 4 ++ .../dto/response/RatingsSummaryResponse.java | 9 ++++ .../rating/exception/ConflictException.java | 5 +++ .../exception/GlobalExceptionHandler.java | 10 +++++ .../grpc/ReservationEligibilityResult.java | 3 ++ .../rating/grpc/ReservationGrpcClient.java | 36 ++++++++++++++++ .../devoops/rating/mapper/RatingMapper.java | 1 + .../java/com/devoops/rating/model/Rating.java | 2 + .../rating/model/RatingTargetType.java | 6 +++ .../rating/repository/RatingRepository.java | 2 + .../devoops/rating/service/RatingService.java | 42 ++++++++++++++++--- src/main/proto/reservation_internal.proto | 19 +++++++++ src/main/resources/application.properties | 4 ++ 14 files changed, 147 insertions(+), 14 deletions(-) create mode 100644 src/main/java/com/devoops/rating/dto/response/RatingsSummaryResponse.java create mode 100644 src/main/java/com/devoops/rating/exception/ConflictException.java create mode 100644 src/main/java/com/devoops/rating/grpc/ReservationEligibilityResult.java create mode 100644 src/main/java/com/devoops/rating/grpc/ReservationGrpcClient.java create mode 100644 src/main/java/com/devoops/rating/model/RatingTargetType.java create mode 100644 src/main/proto/reservation_internal.proto diff --git a/src/main/java/com/devoops/rating/controller/RatingController.java b/src/main/java/com/devoops/rating/controller/RatingController.java index 83b1eaf..68547d3 100644 --- a/src/main/java/com/devoops/rating/controller/RatingController.java +++ b/src/main/java/com/devoops/rating/controller/RatingController.java @@ -4,6 +4,7 @@ import com.devoops.rating.config.UserContext; import com.devoops.rating.dto.request.CreateRatingRequest; import com.devoops.rating.dto.response.RatingResponse; +import com.devoops.rating.dto.response.RatingsSummaryResponse; import com.devoops.rating.dto.request.UpdateRatingRequest; import com.devoops.rating.service.RatingService; import jakarta.validation.Valid; @@ -15,7 +16,7 @@ import java.util.UUID; @RestController -@RequestMapping("/api/v1/ratings") +@RequestMapping("/api/rating") public class RatingController { private final RatingService ratingService; @@ -46,9 +47,9 @@ public ResponseEntity> getAllRatings() { } @GetMapping("/target/{targetId}") - public ResponseEntity> getRatingsByTargetId(@PathVariable UUID targetId) { - List responses = ratingService.getRatingsByTargetId(targetId); - return ResponseEntity.ok(responses); + public ResponseEntity getRatingsByTargetId(@PathVariable UUID targetId) { + RatingsSummaryResponse response = ratingService.getRatingsByTargetId(targetId); + return ResponseEntity.ok(response); } @GetMapping("/guest") @@ -62,15 +63,16 @@ public ResponseEntity> getRatingsByGuestId(UserContext user @RequireRole("GUEST") public ResponseEntity updateRating( @PathVariable UUID id, - @Valid @RequestBody UpdateRatingRequest request) { - RatingResponse response = ratingService.updateRating(id, request); + @Valid @RequestBody UpdateRatingRequest request, + UserContext userContext) { + RatingResponse response = ratingService.updateRating(id, request, userContext); return ResponseEntity.ok(response); } @DeleteMapping("/{id}") @RequireRole("GUEST") - public ResponseEntity deleteRating(@PathVariable UUID id) { - ratingService.deleteRating(id); + public ResponseEntity deleteRating(@PathVariable UUID id, UserContext userContext) { + ratingService.deleteRating(id, userContext); return ResponseEntity.noContent().build(); } } diff --git a/src/main/java/com/devoops/rating/dto/request/CreateRatingRequest.java b/src/main/java/com/devoops/rating/dto/request/CreateRatingRequest.java index 1c36836..61d377c 100644 --- a/src/main/java/com/devoops/rating/dto/request/CreateRatingRequest.java +++ b/src/main/java/com/devoops/rating/dto/request/CreateRatingRequest.java @@ -1,5 +1,6 @@ package com.devoops.rating.dto.request; +import com.devoops.rating.model.RatingTargetType; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; @@ -10,6 +11,9 @@ public record CreateRatingRequest( @NotNull(message = "Target ID is required") UUID targetId, + @NotNull(message = "Target type is required") + RatingTargetType targetType, + @NotNull(message = "Score is required") @Min(value = 1, message = "Score must be at least 1") @Max(value = 5, message = "Score must be at most 5") diff --git a/src/main/java/com/devoops/rating/dto/response/RatingsSummaryResponse.java b/src/main/java/com/devoops/rating/dto/response/RatingsSummaryResponse.java new file mode 100644 index 0000000..16c1c8e --- /dev/null +++ b/src/main/java/com/devoops/rating/dto/response/RatingsSummaryResponse.java @@ -0,0 +1,9 @@ +package com.devoops.rating.dto.response; + +import java.util.List; + +public record RatingsSummaryResponse( + List ratings, + double averageScore, + int totalCount +) {} diff --git a/src/main/java/com/devoops/rating/exception/ConflictException.java b/src/main/java/com/devoops/rating/exception/ConflictException.java new file mode 100644 index 0000000..e4301b3 --- /dev/null +++ b/src/main/java/com/devoops/rating/exception/ConflictException.java @@ -0,0 +1,5 @@ +package com.devoops.rating.exception; + +public class ConflictException extends RuntimeException { + public ConflictException(String message) { super(message); } +} diff --git a/src/main/java/com/devoops/rating/exception/GlobalExceptionHandler.java b/src/main/java/com/devoops/rating/exception/GlobalExceptionHandler.java index 1f2f4e2..7b3e0d3 100644 --- a/src/main/java/com/devoops/rating/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/devoops/rating/exception/GlobalExceptionHandler.java @@ -48,6 +48,16 @@ public ResponseEntity handleForbidden(ForbiddenException ex) { return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error); } + @ExceptionHandler(ConflictException.class) + public ResponseEntity handleConflict(ConflictException ex) { + ErrorResponse error = new ErrorResponse( + HttpStatus.CONFLICT.value(), + ex.getMessage(), + Instant.now() + ); + return ResponseEntity.status(HttpStatus.CONFLICT).body(error); + } + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleValidationErrors(MethodArgumentNotValidException ex) { Map errors = new HashMap<>(); diff --git a/src/main/java/com/devoops/rating/grpc/ReservationEligibilityResult.java b/src/main/java/com/devoops/rating/grpc/ReservationEligibilityResult.java new file mode 100644 index 0000000..478deb4 --- /dev/null +++ b/src/main/java/com/devoops/rating/grpc/ReservationEligibilityResult.java @@ -0,0 +1,3 @@ +package com.devoops.rating.grpc; + +public record ReservationEligibilityResult(boolean eligible, String reason) {} diff --git a/src/main/java/com/devoops/rating/grpc/ReservationGrpcClient.java b/src/main/java/com/devoops/rating/grpc/ReservationGrpcClient.java new file mode 100644 index 0000000..5b27e28 --- /dev/null +++ b/src/main/java/com/devoops/rating/grpc/ReservationGrpcClient.java @@ -0,0 +1,36 @@ +package com.devoops.rating.grpc; + +import com.devoops.rating.grpc.proto.reservation.CheckRatingEligibilityRequest; +import com.devoops.rating.grpc.proto.reservation.CheckRatingEligibilityResponse; +import com.devoops.rating.grpc.proto.reservation.ReservationInternalServiceGrpc; +import com.devoops.rating.model.RatingTargetType; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.client.inject.GrpcClient; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +@Slf4j +public class ReservationGrpcClient { + + @GrpcClient("reservation-service") + private ReservationInternalServiceGrpc.ReservationInternalServiceBlockingStub reservationStub; + + public ReservationEligibilityResult checkRatingEligibility(UUID guestId, UUID targetId, RatingTargetType targetType) { + log.debug("Calling reservation service for rating eligibility: guestId={}, targetId={}, targetType={}", + guestId, targetId, targetType); + + CheckRatingEligibilityRequest request = CheckRatingEligibilityRequest.newBuilder() + .setGuestId(guestId.toString()) + .setTargetId(targetId.toString()) + .setTargetType(targetType.name()) + .build(); + + CheckRatingEligibilityResponse response = reservationStub.checkRatingEligibility(request); + + log.debug("Received eligibility response: eligible={}", response.getEligible()); + + return new ReservationEligibilityResult(response.getEligible(), response.getReason()); + } +} diff --git a/src/main/java/com/devoops/rating/mapper/RatingMapper.java b/src/main/java/com/devoops/rating/mapper/RatingMapper.java index c10ec48..b02b844 100644 --- a/src/main/java/com/devoops/rating/mapper/RatingMapper.java +++ b/src/main/java/com/devoops/rating/mapper/RatingMapper.java @@ -19,6 +19,7 @@ public interface RatingMapper { @Mapping(target = "guestId", ignore = true) @Mapping(target = "guestFirstName", ignore = true) @Mapping(target = "guestLastName", ignore = true) + @Mapping(target = "targetType", source = "targetType") Rating toEntity(CreateRatingRequest request); RatingResponse toResponse(Rating rating); diff --git a/src/main/java/com/devoops/rating/model/Rating.java b/src/main/java/com/devoops/rating/model/Rating.java index 7851216..6173ba9 100644 --- a/src/main/java/com/devoops/rating/model/Rating.java +++ b/src/main/java/com/devoops/rating/model/Rating.java @@ -19,6 +19,8 @@ public class Rating extends BaseDocument { private UUID targetId; + private RatingTargetType targetType; + private String guestFirstName; private String guestLastName; diff --git a/src/main/java/com/devoops/rating/model/RatingTargetType.java b/src/main/java/com/devoops/rating/model/RatingTargetType.java new file mode 100644 index 0000000..00d037d --- /dev/null +++ b/src/main/java/com/devoops/rating/model/RatingTargetType.java @@ -0,0 +1,6 @@ +package com.devoops.rating.model; + +public enum RatingTargetType { + HOST, + ACCOMMODATION +} diff --git a/src/main/java/com/devoops/rating/repository/RatingRepository.java b/src/main/java/com/devoops/rating/repository/RatingRepository.java index 64539e0..8b09304 100644 --- a/src/main/java/com/devoops/rating/repository/RatingRepository.java +++ b/src/main/java/com/devoops/rating/repository/RatingRepository.java @@ -19,5 +19,7 @@ public interface RatingRepository extends MongoRepository { List findAllByGuestIdAndIsDeletedFalse(UUID guestId); + Optional findByTargetIdAndGuestIdAndIsDeletedFalse(UUID targetId, UUID guestId); + } diff --git a/src/main/java/com/devoops/rating/service/RatingService.java b/src/main/java/com/devoops/rating/service/RatingService.java index 63f757d..c923e50 100644 --- a/src/main/java/com/devoops/rating/service/RatingService.java +++ b/src/main/java/com/devoops/rating/service/RatingService.java @@ -3,8 +3,13 @@ import com.devoops.rating.config.UserContext; import com.devoops.rating.dto.request.CreateRatingRequest; import com.devoops.rating.dto.response.RatingResponse; +import com.devoops.rating.dto.response.RatingsSummaryResponse; import com.devoops.rating.dto.request.UpdateRatingRequest; +import com.devoops.rating.exception.ConflictException; +import com.devoops.rating.exception.ForbiddenException; import com.devoops.rating.exception.RatingNotFoundException; +import com.devoops.rating.grpc.ReservationGrpcClient; +import com.devoops.rating.grpc.ReservationEligibilityResult; import com.devoops.rating.grpc.UserGrpcClient; import com.devoops.rating.grpc.UserSummaryResult; import com.devoops.rating.mapper.RatingMapper; @@ -26,15 +31,29 @@ public class RatingService { private final RatingRepository ratingRepository; private final RatingMapper ratingMapper; private final UserGrpcClient userGrpcClient; + private final ReservationGrpcClient reservationGrpcClient; - public RatingService(RatingRepository ratingRepository, RatingMapper ratingMapper, UserGrpcClient userGrpcClient) { + public RatingService(RatingRepository ratingRepository, RatingMapper ratingMapper, + UserGrpcClient userGrpcClient, ReservationGrpcClient reservationGrpcClient) { this.ratingRepository = ratingRepository; this.ratingMapper = ratingMapper; this.userGrpcClient = userGrpcClient; + this.reservationGrpcClient = reservationGrpcClient; } public RatingResponse createRating(CreateRatingRequest request, UserContext userContext) { - log.debug("Creating new rating for target: {}", request.targetId()); + log.debug("Creating new rating for target: {} (type={})", request.targetId(), request.targetType()); + + ReservationEligibilityResult eligibility = reservationGrpcClient.checkRatingEligibility( + userContext.userId(), request.targetId(), request.targetType()); + if (!eligibility.eligible()) { + throw new ForbiddenException("Not eligible to rate: " + eligibility.reason()); + } + + ratingRepository.findByTargetIdAndGuestIdAndIsDeletedFalse(request.targetId(), userContext.userId()) + .ifPresent(_ -> { + throw new ConflictException("You have already rated this target"); + }); UserSummaryResult userSummary = userGrpcClient.getUserSummary(userContext.userId()); @@ -64,10 +83,13 @@ public List getAllRatings() { return ratingMapper.toResponseList(ratingRepository.findAllByIsDeletedFalse()); } - public List getRatingsByTargetId(UUID targetId) { + public RatingsSummaryResponse getRatingsByTargetId(UUID targetId) { log.debug("Fetching ratings for target: {}", targetId); - return ratingMapper.toResponseList(ratingRepository.findAllByTargetIdAndIsDeletedFalse(targetId)); + List ratings = ratingRepository.findAllByTargetIdAndIsDeletedFalse(targetId); + List responseList = ratingMapper.toResponseList(ratings); + double avg = responseList.stream().mapToInt(RatingResponse::score).average().orElse(0.0); + return new RatingsSummaryResponse(responseList, avg, responseList.size()); } public List getRatingsByGuestId(UUID guestId) { @@ -76,12 +98,16 @@ public List getRatingsByGuestId(UUID guestId) { return ratingMapper.toResponseList(ratingRepository.findAllByGuestIdAndIsDeletedFalse(guestId)); } - public RatingResponse updateRating(UUID id, UpdateRatingRequest request) { + public RatingResponse updateRating(UUID id, UpdateRatingRequest request, UserContext userContext) { log.debug("Updating rating with id: {}", id); Rating rating = ratingRepository.findByIdAndIsDeletedFalse(id) .orElseThrow(() -> new RatingNotFoundException(id)); + if (!rating.getGuestId().equals(userContext.userId())) { + throw new ForbiddenException("You are not allowed to update this rating"); + } + rating.setScore(request.score()); rating.setUpdatedAt(LocalDateTime.now()); @@ -91,12 +117,16 @@ public RatingResponse updateRating(UUID id, UpdateRatingRequest request) { return ratingMapper.toResponse(updatedRating); } - public void deleteRating(UUID id) { + public void deleteRating(UUID id, UserContext userContext) { log.debug("Soft deleting rating with id: {}", id); Rating rating = ratingRepository.findByIdAndIsDeletedFalse(id) .orElseThrow(() -> new RatingNotFoundException(id)); + if (!rating.getGuestId().equals(userContext.userId())) { + throw new ForbiddenException("You are not allowed to delete this rating"); + } + rating.setDeleted(true); rating.setUpdatedAt(LocalDateTime.now()); ratingRepository.save(rating); diff --git a/src/main/proto/reservation_internal.proto b/src/main/proto/reservation_internal.proto new file mode 100644 index 0000000..049ee5d --- /dev/null +++ b/src/main/proto/reservation_internal.proto @@ -0,0 +1,19 @@ +syntax = "proto3"; +package reservation; +option java_multiple_files = true; +option java_package = "com.devoops.rating.grpc.proto.reservation"; + +service ReservationInternalService { + rpc CheckRatingEligibility(CheckRatingEligibilityRequest) returns (CheckRatingEligibilityResponse); +} + +message CheckRatingEligibilityRequest { + string guest_id = 1; + string target_id = 2; + string target_type = 3; +} + +message CheckRatingEligibilityResponse { + bool eligible = 1; + string reason = 2; +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 7dacc38..cb151da 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -26,3 +26,7 @@ management.prometheus.metrics.export.enabled=true # 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 + +# gRPC Client - Reservation Service +grpc.client.reservation-service.address=static://${RESERVATION_GRPC_HOST:devoops-reservation-service}:${RESERVATION_GRPC_PORT:9090} +grpc.client.reservation-service.negotiationType=plaintext From 9a512152f3ab5143e8a274a87d6e520c0fa2f872 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Sat, 21 Feb 2026 22:10:34 +0100 Subject: [PATCH 13/17] feat: Add RabbitMQ notifications and accommodation gRPC Introduce RabbitMQ-based notification publishing and an accommodation gRPC client. Added RabbitMQConfig (exchange + JSON converter), message DTOs (HostRatedMessage, AccommodationRatedMessage), and RatingEventPublisherService to publish host/accommodation rated events via RabbitTemplate. Added accommodation gRPC proto and client (AccommodationGrpcClient + AccommodationSummaryResult) and wired accommodation client into service flow. Update RatingService to inject and invoke the event publisher after saving ratings and add targetType to RatingResponse. Also add RabbitMQ and accommodation gRPC settings to application.properties and local env, and include jackson-datatype-jsr310 and spring-boot-starter-amqp dependencies in build.gradle.kts. These changes enable sending notifications to hosts when ratings are created. --- build.gradle.kts | 6 ++ environment/.local.env | 10 +++ .../devoops/rating/config/RabbitMQConfig.java | 29 ++++++ .../message/AccommodationRatedMessage.java | 5 ++ .../rating/dto/message/HostRatedMessage.java | 5 ++ .../rating/dto/response/RatingResponse.java | 3 + .../rating/grpc/AccommodationGrpcClient.java | 40 +++++++++ .../grpc/AccommodationSummaryResult.java | 5 ++ .../service/RatingEventPublisherService.java | 89 +++++++++++++++++++ .../devoops/rating/service/RatingService.java | 12 ++- src/main/proto/accommodation_internal.proto | 49 ++++++++++ src/main/resources/application.properties | 15 ++++ 12 files changed, 267 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/devoops/rating/config/RabbitMQConfig.java create mode 100644 src/main/java/com/devoops/rating/dto/message/AccommodationRatedMessage.java create mode 100644 src/main/java/com/devoops/rating/dto/message/HostRatedMessage.java create mode 100644 src/main/java/com/devoops/rating/grpc/AccommodationGrpcClient.java create mode 100644 src/main/java/com/devoops/rating/grpc/AccommodationSummaryResult.java create mode 100644 src/main/java/com/devoops/rating/service/RatingEventPublisherService.java create mode 100644 src/main/proto/accommodation_internal.proto diff --git a/build.gradle.kts b/build.gradle.kts index 487e0e3..f7a2baf 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -24,6 +24,9 @@ repositories { val grpcVersion = "1.68.0" dependencies { + // Web and Core + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") + // MongoDB implementation("org.springframework.boot:spring-boot-starter-data-mongodb") @@ -48,6 +51,9 @@ dependencies { implementation("io.micrometer:micrometer-tracing-bridge-brave") implementation("io.zipkin.reporter2:zipkin-reporter-brave") + // RabbitMQ + implementation("org.springframework.boot:spring-boot-starter-amqp") + // gRPC Client implementation("net.devh:grpc-client-spring-boot-starter:3.1.0.RELEASE") implementation("io.grpc:grpc-protobuf:$grpcVersion") diff --git a/environment/.local.env b/environment/.local.env index 5d0364e..e48bf2f 100644 --- a/environment/.local.env +++ b/environment/.local.env @@ -12,3 +12,13 @@ MONGODB_PASSWORD=devoops # gRPC USER_GRPC_HOST=devoops-user-service USER_GRPC_PORT=9090 +RESERVATION_GRPC_HOST=devoops-reservation-service +RESERVATION_GRPC_PORT=9090 +ACCOMMODATION_GRPC_HOST=devoops-accommodation-service +ACCOMMODATION_GRPC_PORT=9090 + +# RabbitMQ +RABBITMQ_HOST=devoops-rabbitmq +RABBITMQ_PORT=5672 +RABBITMQ_USERNAME=devoops +RABBITMQ_PASSWORD=devoops123 diff --git a/src/main/java/com/devoops/rating/config/RabbitMQConfig.java b/src/main/java/com/devoops/rating/config/RabbitMQConfig.java new file mode 100644 index 0000000..7b60a79 --- /dev/null +++ b/src/main/java/com/devoops/rating/config/RabbitMQConfig.java @@ -0,0 +1,29 @@ +package com.devoops.rating.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/rating/dto/message/AccommodationRatedMessage.java b/src/main/java/com/devoops/rating/dto/message/AccommodationRatedMessage.java new file mode 100644 index 0000000..7b7fb6e --- /dev/null +++ b/src/main/java/com/devoops/rating/dto/message/AccommodationRatedMessage.java @@ -0,0 +1,5 @@ +package com.devoops.rating.dto.message; + +import java.util.UUID; + +public record AccommodationRatedMessage(UUID userId, String userEmail, String guestName, String accommodationName, Integer rating, String comment) {} diff --git a/src/main/java/com/devoops/rating/dto/message/HostRatedMessage.java b/src/main/java/com/devoops/rating/dto/message/HostRatedMessage.java new file mode 100644 index 0000000..f324c25 --- /dev/null +++ b/src/main/java/com/devoops/rating/dto/message/HostRatedMessage.java @@ -0,0 +1,5 @@ +package com.devoops.rating.dto.message; + +import java.util.UUID; + +public record HostRatedMessage(UUID userId, String userEmail, String guestName, Integer rating, String comment) {} diff --git a/src/main/java/com/devoops/rating/dto/response/RatingResponse.java b/src/main/java/com/devoops/rating/dto/response/RatingResponse.java index 4b481fe..5a04545 100644 --- a/src/main/java/com/devoops/rating/dto/response/RatingResponse.java +++ b/src/main/java/com/devoops/rating/dto/response/RatingResponse.java @@ -3,9 +3,12 @@ import java.time.LocalDateTime; import java.util.UUID; +import com.devoops.rating.model.RatingTargetType; + public record RatingResponse( UUID id, UUID targetId, + RatingTargetType targetType, String guestFirstName, String guestLastName, UUID guestId, diff --git a/src/main/java/com/devoops/rating/grpc/AccommodationGrpcClient.java b/src/main/java/com/devoops/rating/grpc/AccommodationGrpcClient.java new file mode 100644 index 0000000..c17b127 --- /dev/null +++ b/src/main/java/com/devoops/rating/grpc/AccommodationGrpcClient.java @@ -0,0 +1,40 @@ +package com.devoops.rating.grpc; + +import com.devoops.rating.grpc.proto.accommodation.AccommodationSummaryRequest; +import com.devoops.rating.grpc.proto.accommodation.AccommodationSummaryResponse; +import com.devoops.rating.grpc.proto.accommodation.AccommodationInternalServiceGrpc; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.client.inject.GrpcClient; +import org.springframework.stereotype.Component; + +import java.util.UUID; + +@Component +@Slf4j +public class AccommodationGrpcClient { + + @GrpcClient("accommodation-service") + private AccommodationInternalServiceGrpc.AccommodationInternalServiceBlockingStub accommodationStub; + + public AccommodationSummaryResult getAccommodationSummary(UUID accommodationId) { + log.debug("Calling accommodation service for summary: accommodationId={}", accommodationId); + + AccommodationSummaryRequest request = AccommodationSummaryRequest.newBuilder() + .setAccommodationId(accommodationId.toString()) + .build(); + + AccommodationSummaryResponse response = accommodationStub.getAccommodationSummary(request); + + log.debug("Received accommodation summary response: found={}", response.getFound()); + + if (!response.getFound()) { + return new AccommodationSummaryResult(false, null, null); + } + + return new AccommodationSummaryResult( + true, + response.getAccommodationName(), + UUID.fromString(response.getHostId()) + ); + } +} diff --git a/src/main/java/com/devoops/rating/grpc/AccommodationSummaryResult.java b/src/main/java/com/devoops/rating/grpc/AccommodationSummaryResult.java new file mode 100644 index 0000000..fc27d7d --- /dev/null +++ b/src/main/java/com/devoops/rating/grpc/AccommodationSummaryResult.java @@ -0,0 +1,5 @@ +package com.devoops.rating.grpc; + +import java.util.UUID; + +public record AccommodationSummaryResult(boolean found, String name, UUID hostId) {} diff --git a/src/main/java/com/devoops/rating/service/RatingEventPublisherService.java b/src/main/java/com/devoops/rating/service/RatingEventPublisherService.java new file mode 100644 index 0000000..f0f3fec --- /dev/null +++ b/src/main/java/com/devoops/rating/service/RatingEventPublisherService.java @@ -0,0 +1,89 @@ +package com.devoops.rating.service; + +import com.devoops.rating.dto.message.AccommodationRatedMessage; +import com.devoops.rating.dto.message.HostRatedMessage; +import com.devoops.rating.grpc.AccommodationGrpcClient; +import com.devoops.rating.grpc.AccommodationSummaryResult; +import com.devoops.rating.grpc.UserGrpcClient; +import com.devoops.rating.grpc.UserSummaryResult; +import com.devoops.rating.model.Rating; +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 RatingEventPublisherService { + + private final RabbitTemplate rabbitTemplate; + private final UserGrpcClient userGrpcClient; + private final AccommodationGrpcClient accommodationGrpcClient; + + @Value("${rabbitmq.exchange.notification}") + private String exchange; + + @Value("${rabbitmq.routing-key.host-rated}") + private String hostRatedKey; + + @Value("${rabbitmq.routing-key.accommodation-rated}") + private String accommodationRatedKey; + + public void publishHostRated(Rating rating) { + UserSummaryResult hostSummary = userGrpcClient.getUserSummary(rating.getTargetId()); + + if (!hostSummary.found()) { + log.warn("Host not found for rating {}, skipping notification", rating.getId()); + return; + } + + String guestName = rating.getGuestFirstName() + " " + rating.getGuestLastName(); + + HostRatedMessage message = new HostRatedMessage( + rating.getTargetId(), + hostSummary.email(), + guestName, + rating.getScore(), + null + ); + + log.info("Publishing host rated event: ratingId={}, hostEmail={}, guestName={}", + rating.getId(), hostSummary.email(), guestName); + + rabbitTemplate.convertAndSend(exchange, hostRatedKey, message); + } + + public void publishAccommodationRated(Rating rating) { + AccommodationSummaryResult accommodationSummary = accommodationGrpcClient.getAccommodationSummary(rating.getTargetId()); + + if (!accommodationSummary.found()) { + log.warn("Accommodation not found for rating {}, skipping notification", rating.getId()); + return; + } + + UserSummaryResult hostSummary = userGrpcClient.getUserSummary(accommodationSummary.hostId()); + + if (!hostSummary.found()) { + log.warn("Host not found for accommodation rating {}, skipping notification", rating.getId()); + return; + } + + String guestName = rating.getGuestFirstName() + " " + rating.getGuestLastName(); + + AccommodationRatedMessage message = new AccommodationRatedMessage( + accommodationSummary.hostId(), + hostSummary.email(), + guestName, + accommodationSummary.name(), + rating.getScore(), + null + ); + + log.info("Publishing accommodation rated event: ratingId={}, hostEmail={}, accommodationName={}", + rating.getId(), hostSummary.email(), accommodationSummary.name()); + + rabbitTemplate.convertAndSend(exchange, accommodationRatedKey, message); + } +} diff --git a/src/main/java/com/devoops/rating/service/RatingService.java b/src/main/java/com/devoops/rating/service/RatingService.java index c923e50..f0ea99a 100644 --- a/src/main/java/com/devoops/rating/service/RatingService.java +++ b/src/main/java/com/devoops/rating/service/RatingService.java @@ -14,6 +14,7 @@ import com.devoops.rating.grpc.UserSummaryResult; import com.devoops.rating.mapper.RatingMapper; import com.devoops.rating.model.Rating; +import com.devoops.rating.model.RatingTargetType; import com.devoops.rating.repository.RatingRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -32,13 +33,16 @@ public class RatingService { private final RatingMapper ratingMapper; private final UserGrpcClient userGrpcClient; private final ReservationGrpcClient reservationGrpcClient; + private final RatingEventPublisherService eventPublisher; public RatingService(RatingRepository ratingRepository, RatingMapper ratingMapper, - UserGrpcClient userGrpcClient, ReservationGrpcClient reservationGrpcClient) { + UserGrpcClient userGrpcClient, ReservationGrpcClient reservationGrpcClient, + RatingEventPublisherService eventPublisher) { this.ratingRepository = ratingRepository; this.ratingMapper = ratingMapper; this.userGrpcClient = userGrpcClient; this.reservationGrpcClient = reservationGrpcClient; + this.eventPublisher = eventPublisher; } public RatingResponse createRating(CreateRatingRequest request, UserContext userContext) { @@ -65,6 +69,12 @@ public RatingResponse createRating(CreateRatingRequest request, UserContext user Rating savedRating = ratingRepository.save(rating); log.info("Created rating with id: {}", savedRating.getId()); + if (request.targetType() == RatingTargetType.HOST) { + eventPublisher.publishHostRated(savedRating); + } else if (request.targetType() == RatingTargetType.ACCOMMODATION) { + eventPublisher.publishAccommodationRated(savedRating); + } + return ratingMapper.toResponse(savedRating); } diff --git a/src/main/proto/accommodation_internal.proto b/src/main/proto/accommodation_internal.proto new file mode 100644 index 0000000..89caa04 --- /dev/null +++ b/src/main/proto/accommodation_internal.proto @@ -0,0 +1,49 @@ +syntax = "proto3"; +package accommodation; + +option java_multiple_files = true; +option java_package = "com.devoops.rating.grpc.proto.accommodation"; + +service AccommodationInternalService { + rpc ValidateAndCalculatePrice(ReservationValidationRequest) returns (ReservationValidationResponse); + rpc DeleteAccommodationsByHost(DeleteByHostRequest) returns (DeleteByHostResponse); + rpc GetAccommodationSummary(AccommodationSummaryRequest) returns (AccommodationSummaryResponse); +} + +message ReservationValidationRequest { + string accommodation_id = 1; + string start_date = 2; + string end_date = 3; + int32 guest_count = 4; +} + +message ReservationValidationResponse { + bool valid = 1; + string error_code = 2; + string error_message = 3; + string host_id = 4; + string total_price = 5; + string pricing_mode = 6; + string approval_mode = 7; + string accommodation_name = 8; +} + +message DeleteByHostRequest { + string host_id = 1; +} + +message DeleteByHostResponse { + bool success = 1; + int32 deleted_count = 2; + string error_message = 3; +} + +message AccommodationSummaryRequest { + string accommodation_id = 1; +} + +message AccommodationSummaryResponse { + bool found = 1; + string accommodation_name = 2; + string host_id = 3; +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index cb151da..43c99d5 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -30,3 +30,18 @@ grpc.client.user-service.negotiationType=plaintext # gRPC Client - Reservation Service grpc.client.reservation-service.address=static://${RESERVATION_GRPC_HOST:devoops-reservation-service}:${RESERVATION_GRPC_PORT:9090} grpc.client.reservation-service.negotiationType=plaintext + +# 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 + +# 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.host-rated=notification.rating.host +rabbitmq.routing-key.accommodation-rated=notification.rating.accommodation From e74c9d65d64b33cdd8425cdccf54dc967a0c393b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Sun, 22 Feb 2026 14:21:24 +0100 Subject: [PATCH 14/17] feat: Add tests (unit & integration) and enable JaCoCo Add comprehensive test coverage and test build config: - New tests: RatingControllerTest, RatingServiceTest, RatingEventPublisherServiceTest (unit tests) and RatingIntegrationTest (integration tests using Testcontainers + MongoDB). - Update RatingApplicationTests: activate "test" profile and mock RatingEventPublisherService to avoid real publishing during tests. - Add src/test/resources/application-test.properties to configure test profile (disable tracing, exclude RabbitAutoConfiguration, and provide static gRPC addresses for mocked clients). - Update build.gradle.kts: add jacoco plugin and jacocoTestReport task, add test dependencies (spring-boot-starter-test, webmvc-test, Testcontainers for JUnit/MongoDB), and add Lombok test compile/annotation-processor entries; ensure tests use JUnit Platform. - Minor update to logback-test.xml. These changes enable isolated unit testing, ordered integration tests backed by a transient MongoDB container, and generation of JaCoCo XML coverage reports for CI. --- build.gradle.kts | 12 + .../rating/RatingApplicationTests.java | 12 +- .../controller/RatingControllerTest.java | 449 ++++++++++++++++ .../integration/RatingIntegrationTest.java | 288 ++++++++++ .../RatingEventPublisherServiceTest.java | 175 ++++++ .../rating/service/RatingServiceTest.java | 496 ++++++++++++++++++ .../resources/application-test.properties | 25 + src/test/resources/logback-test.xml | 4 +- 8 files changed, 1456 insertions(+), 5 deletions(-) create mode 100644 src/test/java/com/devoops/rating/controller/RatingControllerTest.java create mode 100644 src/test/java/com/devoops/rating/integration/RatingIntegrationTest.java create mode 100644 src/test/java/com/devoops/rating/service/RatingEventPublisherServiceTest.java create mode 100644 src/test/java/com/devoops/rating/service/RatingServiceTest.java create mode 100644 src/test/resources/application-test.properties diff --git a/build.gradle.kts b/build.gradle.kts index f7a2baf..fbc6d56 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,6 +5,7 @@ plugins { 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" + jacoco } group = "com.devoops" @@ -62,7 +63,12 @@ dependencies { compileOnly("javax.annotation:javax.annotation-api:1.3.2") // testImplementation("org.springframework.boot:spring-boot-starter-security-test") + testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") + testImplementation("org.testcontainers:junit-jupiter:1.20.4") + testImplementation("org.testcontainers:mongodb:1.20.4") + testCompileOnly("org.projectlombok:lombok") + testAnnotationProcessor("org.projectlombok:lombok") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } @@ -86,4 +92,10 @@ protobuf { tasks.withType { useJUnitPlatform() + finalizedBy(tasks.jacocoTestReport) +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { xml.required = true } } diff --git a/src/test/java/com/devoops/rating/RatingApplicationTests.java b/src/test/java/com/devoops/rating/RatingApplicationTests.java index 5ff1df5..ea66635 100644 --- a/src/test/java/com/devoops/rating/RatingApplicationTests.java +++ b/src/test/java/com/devoops/rating/RatingApplicationTests.java @@ -1,13 +1,19 @@ package com.devoops.rating; +import com.devoops.rating.service.RatingEventPublisherService; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; @SpringBootTest +@ActiveProfiles("test") class RatingApplicationTests { - @Test - void contextLoads() { - } + @MockitoBean + RatingEventPublisherService ratingEventPublisherService; + @Test + void contextLoads() { + } } diff --git a/src/test/java/com/devoops/rating/controller/RatingControllerTest.java b/src/test/java/com/devoops/rating/controller/RatingControllerTest.java new file mode 100644 index 0000000..7c64306 --- /dev/null +++ b/src/test/java/com/devoops/rating/controller/RatingControllerTest.java @@ -0,0 +1,449 @@ +package com.devoops.rating.controller; + +import com.devoops.rating.config.RoleAuthorizationInterceptor; +import com.devoops.rating.config.UserContextResolver; +import com.devoops.rating.dto.request.CreateRatingRequest; +import com.devoops.rating.dto.request.UpdateRatingRequest; +import com.devoops.rating.dto.response.RatingResponse; +import com.devoops.rating.dto.response.RatingsSummaryResponse; +import com.devoops.rating.exception.ConflictException; +import com.devoops.rating.exception.ForbiddenException; +import com.devoops.rating.exception.GlobalExceptionHandler; +import com.devoops.rating.exception.RatingNotFoundException; +import com.devoops.rating.model.RatingTargetType; +import com.devoops.rating.service.RatingService; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +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.http.converter.json.MappingJackson2HttpMessageConverter; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RatingController Unit Tests") +class RatingControllerTest { + + @Mock + private RatingService ratingService; + + @InjectMocks + private RatingController controller; + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + + private static final String USER_ID_HEADER = "X-User-Id"; + private static final String USER_ROLE_HEADER = "X-User-Role"; + private static final UUID GUEST_ID = UUID.randomUUID(); + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new UserContextResolver()) + .addInterceptors(new RoleAuthorizationInterceptor()) + .setMessageConverters(new MappingJackson2HttpMessageConverter(objectMapper)) + .build(); + } + + private RatingResponse buildResponse(UUID id, UUID targetId, RatingTargetType type, UUID guestId, int score) { + return new RatingResponse(id, targetId, type, "John", "Doe", guestId, score, + LocalDateTime.now(), LocalDateTime.now()); + } + + // ================================================================ + // POST /api/rating + // ================================================================ + + @Nested + @DisplayName("POST /api/rating") + class CreateRating { + + @Test + @DisplayName("valid GUEST request returns 201") + void createRating_ValidRequest_Returns201() throws Exception { + UUID targetId = UUID.randomUUID(); + CreateRatingRequest request = new CreateRatingRequest(targetId, RatingTargetType.HOST, 5); + RatingResponse response = buildResponse(UUID.randomUUID(), targetId, RatingTargetType.HOST, GUEST_ID, 5); + + when(ratingService.createRating(any(), any())).thenReturn(response); + + mockMvc.perform(post("/api/rating") + .header(USER_ID_HEADER, GUEST_ID.toString()) + .header(USER_ROLE_HEADER, "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.score").value(5)) + .andExpect(jsonPath("$.targetId").value(targetId.toString())); + } + + @Test + @DisplayName("missing auth headers returns 401") + void createRating_MissingHeaders_Returns401() throws Exception { + CreateRatingRequest request = new CreateRatingRequest(UUID.randomUUID(), RatingTargetType.HOST, 5); + + mockMvc.perform(post("/api/rating") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("HOST role returns 403") + void createRating_HostRole_Returns403() throws Exception { + CreateRatingRequest request = new CreateRatingRequest(UUID.randomUUID(), RatingTargetType.HOST, 5); + + mockMvc.perform(post("/api/rating") + .header(USER_ID_HEADER, GUEST_ID.toString()) + .header(USER_ROLE_HEADER, "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("null targetId returns 400") + void createRating_NullTargetId_Returns400() throws Exception { + CreateRatingRequest request = new CreateRatingRequest(null, RatingTargetType.HOST, 5); + + mockMvc.perform(post("/api/rating") + .header(USER_ID_HEADER, GUEST_ID.toString()) + .header(USER_ROLE_HEADER, "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("score=0 returns 400") + void createRating_ScoreZero_Returns400() throws Exception { + CreateRatingRequest request = new CreateRatingRequest(UUID.randomUUID(), RatingTargetType.HOST, 0); + + mockMvc.perform(post("/api/rating") + .header(USER_ID_HEADER, GUEST_ID.toString()) + .header(USER_ROLE_HEADER, "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("ConflictException from service returns 409") + void createRating_ConflictException_Returns409() throws Exception { + CreateRatingRequest request = new CreateRatingRequest(UUID.randomUUID(), RatingTargetType.HOST, 5); + + when(ratingService.createRating(any(), any())).thenThrow(new ConflictException("Already rated")); + + mockMvc.perform(post("/api/rating") + .header(USER_ID_HEADER, GUEST_ID.toString()) + .header(USER_ROLE_HEADER, "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()); + } + + @Test + @DisplayName("ForbiddenException from service returns 403") + void createRating_ForbiddenException_Returns403() throws Exception { + CreateRatingRequest request = new CreateRatingRequest(UUID.randomUUID(), RatingTargetType.HOST, 5); + + when(ratingService.createRating(any(), any())).thenThrow(new ForbiddenException("Not eligible")); + + mockMvc.perform(post("/api/rating") + .header(USER_ID_HEADER, GUEST_ID.toString()) + .header(USER_ROLE_HEADER, "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + } + + // ================================================================ + // GET /api/rating/{id} + // ================================================================ + + @Nested + @DisplayName("GET /api/rating/{id}") + class GetRatingById { + + @Test + @DisplayName("existing id returns 200") + void getRatingById_ExistingId_Returns200() throws Exception { + UUID id = UUID.randomUUID(); + UUID targetId = UUID.randomUUID(); + RatingResponse response = buildResponse(id, targetId, RatingTargetType.HOST, GUEST_ID, 4); + + when(ratingService.getRatingById(id)).thenReturn(response); + + mockMvc.perform(get("/api/rating/{id}", id)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(id.toString())) + .andExpect(jsonPath("$.score").value(4)); + } + + @Test + @DisplayName("non-existing id returns 404") + void getRatingById_NonExistingId_Returns404() throws Exception { + UUID id = UUID.randomUUID(); + + when(ratingService.getRatingById(id)).thenThrow(new RatingNotFoundException(id)); + + mockMvc.perform(get("/api/rating/{id}", id)) + .andExpect(status().isNotFound()); + } + } + + // ================================================================ + // GET /api/rating + // ================================================================ + + @Nested + @DisplayName("GET /api/rating") + class GetAllRatings { + + @Test + @DisplayName("returns 200 with list of ratings") + void getAllRatings_Returns200() throws Exception { + List responses = List.of( + buildResponse(UUID.randomUUID(), UUID.randomUUID(), RatingTargetType.HOST, GUEST_ID, 5), + buildResponse(UUID.randomUUID(), UUID.randomUUID(), RatingTargetType.ACCOMMODATION, GUEST_ID, 3) + ); + + when(ratingService.getAllRatings()).thenReturn(responses); + + mockMvc.perform(get("/api/rating")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(2)); + } + } + + // ================================================================ + // GET /api/rating/target/{targetId} + // ================================================================ + + @Nested + @DisplayName("GET /api/rating/target/{targetId}") + class GetRatingsByTargetId { + + @Test + @DisplayName("returns 200 with RatingsSummaryResponse") + void getRatingsByTargetId_Returns200() throws Exception { + UUID targetId = UUID.randomUUID(); + List ratings = List.of( + buildResponse(UUID.randomUUID(), targetId, RatingTargetType.HOST, GUEST_ID, 4) + ); + RatingsSummaryResponse summary = new RatingsSummaryResponse(ratings, 4.0, 1); + + when(ratingService.getRatingsByTargetId(targetId)).thenReturn(summary); + + mockMvc.perform(get("/api/rating/target/{targetId}", targetId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.averageScore").value(4.0)) + .andExpect(jsonPath("$.totalCount").value(1)) + .andExpect(jsonPath("$.ratings.length()").value(1)); + } + } + + // ================================================================ + // GET /api/rating/guest + // ================================================================ + + @Nested + @DisplayName("GET /api/rating/guest") + class GetRatingsByGuestId { + + @Test + @DisplayName("GUEST role returns 200 with list") + void getRatingsByGuestId_GuestRole_Returns200() throws Exception { + List responses = List.of( + buildResponse(UUID.randomUUID(), UUID.randomUUID(), RatingTargetType.HOST, GUEST_ID, 5) + ); + + when(ratingService.getRatingsByGuestId(GUEST_ID)).thenReturn(responses); + + mockMvc.perform(get("/api/rating/guest") + .header(USER_ID_HEADER, GUEST_ID.toString()) + .header(USER_ROLE_HEADER, "GUEST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(1)); + } + + @Test + @DisplayName("missing headers returns 401") + void getRatingsByGuestId_MissingHeaders_Returns401() throws Exception { + mockMvc.perform(get("/api/rating/guest")) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("HOST role returns 403") + void getRatingsByGuestId_HostRole_Returns403() throws Exception { + mockMvc.perform(get("/api/rating/guest") + .header(USER_ID_HEADER, UUID.randomUUID().toString()) + .header(USER_ROLE_HEADER, "HOST")) + .andExpect(status().isForbidden()); + } + } + + // ================================================================ + // PUT /api/rating/{id} + // ================================================================ + + @Nested + @DisplayName("PUT /api/rating/{id}") + class UpdateRating { + + @Test + @DisplayName("valid update returns 200 with updated score") + void updateRating_Valid_Returns200() throws Exception { + UUID id = UUID.randomUUID(); + UpdateRatingRequest request = new UpdateRatingRequest(3); + RatingResponse response = buildResponse(id, UUID.randomUUID(), RatingTargetType.HOST, GUEST_ID, 3); + + when(ratingService.updateRating(eq(id), any(), any())).thenReturn(response); + + mockMvc.perform(put("/api/rating/{id}", id) + .header(USER_ID_HEADER, GUEST_ID.toString()) + .header(USER_ROLE_HEADER, "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.score").value(3)); + } + + @Test + @DisplayName("missing headers returns 401") + void updateRating_MissingHeaders_Returns401() throws Exception { + UUID id = UUID.randomUUID(); + UpdateRatingRequest request = new UpdateRatingRequest(3); + + mockMvc.perform(put("/api/rating/{id}", id) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("HOST role returns 403") + void updateRating_HostRole_Returns403() throws Exception { + UUID id = UUID.randomUUID(); + UpdateRatingRequest request = new UpdateRatingRequest(3); + + mockMvc.perform(put("/api/rating/{id}", id) + .header(USER_ID_HEADER, UUID.randomUUID().toString()) + .header(USER_ROLE_HEADER, "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("ForbiddenException from service returns 403") + void updateRating_ForbiddenFromService_Returns403() throws Exception { + UUID id = UUID.randomUUID(); + UpdateRatingRequest request = new UpdateRatingRequest(3); + + when(ratingService.updateRating(eq(id), any(), any())) + .thenThrow(new ForbiddenException("Not your rating")); + + mockMvc.perform(put("/api/rating/{id}", id) + .header(USER_ID_HEADER, GUEST_ID.toString()) + .header(USER_ROLE_HEADER, "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("rating not found returns 404") + void updateRating_NotFound_Returns404() throws Exception { + UUID id = UUID.randomUUID(); + UpdateRatingRequest request = new UpdateRatingRequest(3); + + when(ratingService.updateRating(eq(id), any(), any())) + .thenThrow(new RatingNotFoundException(id)); + + mockMvc.perform(put("/api/rating/{id}", id) + .header(USER_ID_HEADER, GUEST_ID.toString()) + .header(USER_ROLE_HEADER, "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()); + } + } + + // ================================================================ + // DELETE /api/rating/{id} + // ================================================================ + + @Nested + @DisplayName("DELETE /api/rating/{id}") + class DeleteRating { + + @Test + @DisplayName("valid owner returns 204") + void deleteRating_ValidOwner_Returns204() throws Exception { + UUID id = UUID.randomUUID(); + + doNothing().when(ratingService).deleteRating(eq(id), any()); + + mockMvc.perform(delete("/api/rating/{id}", id) + .header(USER_ID_HEADER, GUEST_ID.toString()) + .header(USER_ROLE_HEADER, "GUEST")) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("missing headers returns 401") + void deleteRating_MissingHeaders_Returns401() throws Exception { + UUID id = UUID.randomUUID(); + + mockMvc.perform(delete("/api/rating/{id}", id)) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("HOST role returns 403") + void deleteRating_HostRole_Returns403() throws Exception { + UUID id = UUID.randomUUID(); + + mockMvc.perform(delete("/api/rating/{id}", id) + .header(USER_ID_HEADER, UUID.randomUUID().toString()) + .header(USER_ROLE_HEADER, "HOST")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("rating not found returns 404") + void deleteRating_NotFound_Returns404() throws Exception { + UUID id = UUID.randomUUID(); + + doThrow(new RatingNotFoundException(id)).when(ratingService).deleteRating(eq(id), any()); + + mockMvc.perform(delete("/api/rating/{id}", id) + .header(USER_ID_HEADER, GUEST_ID.toString()) + .header(USER_ROLE_HEADER, "GUEST")) + .andExpect(status().isNotFound()); + } + } +} diff --git a/src/test/java/com/devoops/rating/integration/RatingIntegrationTest.java b/src/test/java/com/devoops/rating/integration/RatingIntegrationTest.java new file mode 100644 index 0000000..2626bda --- /dev/null +++ b/src/test/java/com/devoops/rating/integration/RatingIntegrationTest.java @@ -0,0 +1,288 @@ +package com.devoops.rating.integration; + +import com.devoops.rating.dto.request.CreateRatingRequest; +import com.devoops.rating.dto.request.UpdateRatingRequest; +import com.devoops.rating.grpc.AccommodationGrpcClient; +import com.devoops.rating.grpc.ReservationEligibilityResult; +import com.devoops.rating.grpc.ReservationGrpcClient; +import com.devoops.rating.grpc.UserGrpcClient; +import com.devoops.rating.grpc.UserSummaryResult; +import com.devoops.rating.model.RatingTargetType; +import com.devoops.rating.service.RatingEventPublisherService; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.hamcrest.Matchers; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; +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 RatingIntegrationTest { + + @Container + static MongoDBContainer mongodb = new MongoDBContainer("mongo:7.0"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.mongodb.uri", mongodb::getConnectionString); + } + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private UserGrpcClient userGrpcClient; + + @MockitoBean + private ReservationGrpcClient reservationGrpcClient; + + @MockitoBean + private AccommodationGrpcClient accommodationGrpcClient; + + @MockitoBean + private RatingEventPublisherService ratingEventPublisherService; + + private final ObjectMapper objectMapper = new ObjectMapper().registerModule(new JavaTimeModule()); + + private static final String USER_ID_HEADER = "X-User-Id"; + private static final String USER_ROLE_HEADER = "X-User-Role"; + private static final UUID GUEST_ID = UUID.randomUUID(); + private static final UUID TARGET_ID = UUID.randomUUID(); + + /** Shared across ordered tests — set in @Order(1), used by later tests. */ + private static UUID createdRatingId; + + // ---- helpers ---- + + private void mockEligible() { + when(reservationGrpcClient.checkRatingEligibility(any(), any(), any())) + .thenReturn(new ReservationEligibilityResult(true, "eligible")); + } + + private void mockGuestSummary() { + when(userGrpcClient.getUserSummary(GUEST_ID)) + .thenReturn(new UserSummaryResult(true, GUEST_ID, "guest@example.com", "John", "Doe", "GUEST", false)); + } + + // ================================================================ + // Tests (ordered — MongoDB state flows from test 1 onwards) + // ================================================================ + + @Test + @Order(1) + @DisplayName("1. createRating – valid request returns 201 and stores rating") + void createRating_ValidRequest_Returns201() throws Exception { + mockEligible(); + mockGuestSummary(); + doNothing().when(ratingEventPublisherService).publishHostRated(any()); + + CreateRatingRequest request = new CreateRatingRequest(TARGET_ID, RatingTargetType.HOST, 4); + + String body = mockMvc.perform(post("/api/rating") + .header(USER_ID_HEADER, GUEST_ID.toString()) + .header(USER_ROLE_HEADER, "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").exists()) + .andExpect(jsonPath("$.score").value(4)) + .andExpect(jsonPath("$.targetId").value(TARGET_ID.toString())) + .andExpect(jsonPath("$.guestId").value(GUEST_ID.toString())) + .andReturn().getResponse().getContentAsString(); + + createdRatingId = UUID.fromString(objectMapper.readTree(body).get("id").asText()); + verify(ratingEventPublisherService).publishHostRated(any()); + } + + @Test + @Order(2) + @DisplayName("2. createRating – duplicate by same guest returns 409") + void createRating_DuplicateByGuest_Returns409() throws Exception { + mockEligible(); + + CreateRatingRequest request = new CreateRatingRequest(TARGET_ID, RatingTargetType.HOST, 5); + + mockMvc.perform(post("/api/rating") + .header(USER_ID_HEADER, GUEST_ID.toString()) + .header(USER_ROLE_HEADER, "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()); + } + + @Test + @Order(3) + @DisplayName("3. createRating – no auth headers returns 401") + void createRating_NoAuthHeaders_Returns401() throws Exception { + CreateRatingRequest request = new CreateRatingRequest(UUID.randomUUID(), RatingTargetType.HOST, 5); + + mockMvc.perform(post("/api/rating") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()); + } + + @Test + @Order(4) + @DisplayName("4. createRating – HOST role returns 403") + void createRating_HostRole_Returns403() throws Exception { + CreateRatingRequest request = new CreateRatingRequest(UUID.randomUUID(), RatingTargetType.HOST, 5); + + mockMvc.perform(post("/api/rating") + .header(USER_ID_HEADER, UUID.randomUUID().toString()) + .header(USER_ROLE_HEADER, "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @Order(5) + @DisplayName("5. createRating – ineligible guest returns 403") + void createRating_IneligibleGuest_Returns403() throws Exception { + UUID differentTarget = UUID.randomUUID(); + when(reservationGrpcClient.checkRatingEligibility(any(), eq(differentTarget), any())) + .thenReturn(new ReservationEligibilityResult(false, "No past reservation")); + + CreateRatingRequest request = new CreateRatingRequest(differentTarget, RatingTargetType.HOST, 5); + + mockMvc.perform(post("/api/rating") + .header(USER_ID_HEADER, GUEST_ID.toString()) + .header(USER_ROLE_HEADER, "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @Order(6) + @DisplayName("6. createRating – score=6 returns 400") + void createRating_InvalidScore_Returns400() throws Exception { + CreateRatingRequest request = new CreateRatingRequest(UUID.randomUUID(), RatingTargetType.HOST, 6); + + mockMvc.perform(post("/api/rating") + .header(USER_ID_HEADER, GUEST_ID.toString()) + .header(USER_ROLE_HEADER, "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @Order(7) + @DisplayName("7. getRatingById – existing id returns 200 with correct score") + void getRatingById_ExistingId_Returns200() throws Exception { + mockMvc.perform(get("/api/rating/{id}", createdRatingId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(createdRatingId.toString())) + .andExpect(jsonPath("$.score").value(4)); + } + + @Test + @Order(8) + @DisplayName("8. getRatingById – random UUID returns 404") + void getRatingById_NonExistingId_Returns404() throws Exception { + mockMvc.perform(get("/api/rating/{id}", UUID.randomUUID())) + .andExpect(status().isNotFound()); + } + + @Test + @Order(9) + @DisplayName("9. getAllRatings – returns list with at least one entry") + void getAllRatings_Returns200WithList() throws Exception { + mockMvc.perform(get("/api/rating")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(Matchers.greaterThanOrEqualTo(1))); + } + + @Test + @Order(10) + @DisplayName("10. getRatingsByTargetId – returns summary with correct count and average") + void getRatingsByTargetId_Returns200WithSummary() throws Exception { + mockMvc.perform(get("/api/rating/target/{targetId}", TARGET_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.totalCount").value(1)) + .andExpect(jsonPath("$.averageScore").value(4.0)) + .andExpect(jsonPath("$.ratings.length()").value(1)); + } + + @Test + @Order(11) + @DisplayName("11. getRatingsByGuestId – returns guest's ratings") + void getRatingsByGuestId_Returns200WithList() throws Exception { + mockMvc.perform(get("/api/rating/guest") + .header(USER_ID_HEADER, GUEST_ID.toString()) + .header(USER_ROLE_HEADER, "GUEST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.length()").value(Matchers.greaterThanOrEqualTo(1))); + } + + @Test + @Order(12) + @DisplayName("12. updateRating – valid owner updates score to 2") + void updateRating_ValidRequest_Returns200() throws Exception { + UpdateRatingRequest request = new UpdateRatingRequest(2); + + mockMvc.perform(put("/api/rating/{id}", createdRatingId) + .header(USER_ID_HEADER, GUEST_ID.toString()) + .header(USER_ROLE_HEADER, "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.score").value(2)); + } + + @Test + @Order(13) + @DisplayName("13. updateRating – different guest returns 403") + void updateRating_DifferentGuest_Returns403() throws Exception { + UpdateRatingRequest request = new UpdateRatingRequest(1); + + mockMvc.perform(put("/api/rating/{id}", createdRatingId) + .header(USER_ID_HEADER, UUID.randomUUID().toString()) + .header(USER_ROLE_HEADER, "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @Order(14) + @DisplayName("14. deleteRating – valid owner returns 204") + void deleteRating_ValidOwner_Returns204() throws Exception { + mockMvc.perform(delete("/api/rating/{id}", createdRatingId) + .header(USER_ID_HEADER, GUEST_ID.toString()) + .header(USER_ROLE_HEADER, "GUEST")) + .andExpect(status().isNoContent()); + } + + @Test + @Order(15) + @DisplayName("15. getRatingById after delete returns 404 (soft-delete verified)") + void deleteRating_ThenGetById_Returns404() throws Exception { + mockMvc.perform(get("/api/rating/{id}", createdRatingId)) + .andExpect(status().isNotFound()); + } +} diff --git a/src/test/java/com/devoops/rating/service/RatingEventPublisherServiceTest.java b/src/test/java/com/devoops/rating/service/RatingEventPublisherServiceTest.java new file mode 100644 index 0000000..95397fa --- /dev/null +++ b/src/test/java/com/devoops/rating/service/RatingEventPublisherServiceTest.java @@ -0,0 +1,175 @@ +package com.devoops.rating.service; + +import com.devoops.rating.dto.message.AccommodationRatedMessage; +import com.devoops.rating.dto.message.HostRatedMessage; +import com.devoops.rating.grpc.AccommodationGrpcClient; +import com.devoops.rating.grpc.AccommodationSummaryResult; +import com.devoops.rating.grpc.UserGrpcClient; +import com.devoops.rating.grpc.UserSummaryResult; +import com.devoops.rating.model.Rating; +import com.devoops.rating.model.RatingTargetType; +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.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.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RatingEventPublisherService Unit Tests") +class RatingEventPublisherServiceTest { + + @Mock private RabbitTemplate rabbitTemplate; + @Mock private UserGrpcClient userGrpcClient; + @Mock private AccommodationGrpcClient accommodationGrpcClient; + + @InjectMocks + private RatingEventPublisherService publisherService; + + private static final String EXCHANGE = "notification.exchange"; + private static final String HOST_RATED_KEY = "notification.rating.host"; + private static final String ACCOMMODATION_RATED_KEY = "notification.rating.accommodation"; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(publisherService, "exchange", EXCHANGE); + ReflectionTestUtils.setField(publisherService, "hostRatedKey", HOST_RATED_KEY); + ReflectionTestUtils.setField(publisherService, "accommodationRatedKey", ACCOMMODATION_RATED_KEY); + } + + private Rating buildRating(UUID targetId, RatingTargetType type, String firstName, String lastName, int score) { + Rating r = new Rating(); + r.setId(UUID.randomUUID()); + r.setTargetId(targetId); + r.setTargetType(type); + r.setGuestFirstName(firstName); + r.setGuestLastName(lastName); + r.setScore(score); + return r; + } + + private UserSummaryResult hostFound(UUID userId, String email) { + return new UserSummaryResult(true, userId, email, "Host", "User", "HOST", false); + } + + // ================================================================ + // publishHostRated + // ================================================================ + + @Nested + @DisplayName("publishHostRated") + class PublishHostRated { + + @Test + @DisplayName("host found publishes correct HostRatedMessage") + void publishHostRated_HostFound_PublishesCorrectMessage() { + UUID hostId = UUID.randomUUID(); + Rating rating = buildRating(hostId, RatingTargetType.HOST, "Jane", "Doe", 4); + + when(userGrpcClient.getUserSummary(hostId)).thenReturn(hostFound(hostId, "host@example.com")); + + publisherService.publishHostRated(rating); + + ArgumentCaptor captor = ArgumentCaptor.forClass(HostRatedMessage.class); + verify(rabbitTemplate).convertAndSend(eq(EXCHANGE), eq(HOST_RATED_KEY), captor.capture()); + + HostRatedMessage msg = captor.getValue(); + assertThat(msg.userId()).isEqualTo(hostId); + assertThat(msg.userEmail()).isEqualTo("host@example.com"); + assertThat(msg.guestName()).isEqualTo("Jane Doe"); + assertThat(msg.rating()).isEqualTo(4); + assertThat(msg.comment()).isNull(); + } + + @Test + @DisplayName("host not found logs warning and does not publish") + void publishHostRated_HostNotFound_LogsWarningAndDoesNotPublish() { + UUID hostId = UUID.randomUUID(); + Rating rating = buildRating(hostId, RatingTargetType.HOST, "Jane", "Doe", 4); + + when(userGrpcClient.getUserSummary(hostId)) + .thenReturn(new UserSummaryResult(false, null, null, null, null, null, false)); + + publisherService.publishHostRated(rating); + + verify(rabbitTemplate, never()).convertAndSend(any(), any(), any(Object.class)); + } + } + + // ================================================================ + // publishAccommodationRated + // ================================================================ + + @Nested + @DisplayName("publishAccommodationRated") + class PublishAccommodationRated { + + @Test + @DisplayName("all found publishes correct AccommodationRatedMessage") + void publishAccommodationRated_AllFound_PublishesCorrectMessage() { + UUID accommodationId = UUID.randomUUID(); + UUID hostId = UUID.randomUUID(); + Rating rating = buildRating(accommodationId, RatingTargetType.ACCOMMODATION, "Bob", "Smith", 5); + + when(accommodationGrpcClient.getAccommodationSummary(accommodationId)) + .thenReturn(new AccommodationSummaryResult(true, "Sunny Villa", hostId)); + when(userGrpcClient.getUserSummary(hostId)) + .thenReturn(hostFound(hostId, "host@example.com")); + + publisherService.publishAccommodationRated(rating); + + ArgumentCaptor captor = ArgumentCaptor.forClass(AccommodationRatedMessage.class); + verify(rabbitTemplate).convertAndSend(eq(EXCHANGE), eq(ACCOMMODATION_RATED_KEY), captor.capture()); + + AccommodationRatedMessage msg = captor.getValue(); + assertThat(msg.userId()).isEqualTo(hostId); + assertThat(msg.userEmail()).isEqualTo("host@example.com"); + assertThat(msg.guestName()).isEqualTo("Bob Smith"); + assertThat(msg.accommodationName()).isEqualTo("Sunny Villa"); + assertThat(msg.rating()).isEqualTo(5); + assertThat(msg.comment()).isNull(); + } + + @Test + @DisplayName("accommodation not found logs warning and does not publish") + void publishAccommodationRated_AccommodationNotFound_LogsWarningAndDoesNotPublish() { + UUID accommodationId = UUID.randomUUID(); + Rating rating = buildRating(accommodationId, RatingTargetType.ACCOMMODATION, "Bob", "Smith", 5); + + when(accommodationGrpcClient.getAccommodationSummary(accommodationId)) + .thenReturn(new AccommodationSummaryResult(false, null, null)); + + publisherService.publishAccommodationRated(rating); + + verify(rabbitTemplate, never()).convertAndSend(any(), any(), any(Object.class)); + } + + @Test + @DisplayName("accommodation found but host not found does not publish") + void publishAccommodationRated_HostNotFound_LogsWarningAndDoesNotPublish() { + UUID accommodationId = UUID.randomUUID(); + UUID hostId = UUID.randomUUID(); + Rating rating = buildRating(accommodationId, RatingTargetType.ACCOMMODATION, "Bob", "Smith", 5); + + when(accommodationGrpcClient.getAccommodationSummary(accommodationId)) + .thenReturn(new AccommodationSummaryResult(true, "Sunny Villa", hostId)); + when(userGrpcClient.getUserSummary(hostId)) + .thenReturn(new UserSummaryResult(false, null, null, null, null, null, false)); + + publisherService.publishAccommodationRated(rating); + + verify(rabbitTemplate, never()).convertAndSend(any(), any(), any(Object.class)); + } + } +} diff --git a/src/test/java/com/devoops/rating/service/RatingServiceTest.java b/src/test/java/com/devoops/rating/service/RatingServiceTest.java new file mode 100644 index 0000000..d63e991 --- /dev/null +++ b/src/test/java/com/devoops/rating/service/RatingServiceTest.java @@ -0,0 +1,496 @@ +package com.devoops.rating.service; + +import com.devoops.rating.config.UserContext; +import com.devoops.rating.dto.request.CreateRatingRequest; +import com.devoops.rating.dto.request.UpdateRatingRequest; +import com.devoops.rating.dto.response.RatingResponse; +import com.devoops.rating.dto.response.RatingsSummaryResponse; +import com.devoops.rating.exception.ConflictException; +import com.devoops.rating.exception.ForbiddenException; +import com.devoops.rating.exception.RatingNotFoundException; +import com.devoops.rating.grpc.ReservationEligibilityResult; +import com.devoops.rating.grpc.ReservationGrpcClient; +import com.devoops.rating.grpc.UserGrpcClient; +import com.devoops.rating.grpc.UserSummaryResult; +import com.devoops.rating.mapper.RatingMapper; +import com.devoops.rating.model.Rating; +import com.devoops.rating.model.RatingTargetType; +import com.devoops.rating.repository.RatingRepository; +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.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) +@DisplayName("RatingService Unit Tests") +class RatingServiceTest { + + @Mock private RatingRepository ratingRepository; + @Mock private RatingMapper ratingMapper; + @Mock private UserGrpcClient userGrpcClient; + @Mock private ReservationGrpcClient reservationGrpcClient; + @Mock private RatingEventPublisherService eventPublisher; + + @InjectMocks + private RatingService ratingService; + + // ---- helpers ---- + + private Rating buildRating(UUID id, UUID targetId, RatingTargetType type, UUID guestId, int score) { + Rating r = new Rating(); + r.setId(id); + r.setTargetId(targetId); + r.setTargetType(type); + r.setGuestId(guestId); + r.setGuestFirstName("John"); + r.setGuestLastName("Doe"); + r.setScore(score); + return r; + } + + private RatingResponse buildResponse(Rating r) { + return new RatingResponse(r.getId(), r.getTargetId(), r.getTargetType(), + r.getGuestFirstName(), r.getGuestLastName(), r.getGuestId(), + r.getScore(), LocalDateTime.now(), LocalDateTime.now()); + } + + private UserSummaryResult userFound(UUID userId, String firstName, String lastName) { + return new UserSummaryResult(true, userId, "user@example.com", firstName, lastName, "GUEST", false); + } + + // ================================================================ + // createRating + // ================================================================ + + @Nested + @DisplayName("createRating") + class CreateRating { + + @Test + @DisplayName("eligible guest returns RatingResponse") + void createRating_EligibleGuest_ReturnsRatingResponse() { + UUID guestId = UUID.randomUUID(); + UUID targetId = UUID.randomUUID(); + CreateRatingRequest request = new CreateRatingRequest(targetId, RatingTargetType.HOST, 5); + UserContext userContext = new UserContext(guestId, "GUEST"); + + Rating entity = buildRating(UUID.randomUUID(), targetId, RatingTargetType.HOST, null, 5); + RatingResponse expectedResponse = buildResponse(entity); + + when(reservationGrpcClient.checkRatingEligibility(guestId, targetId, RatingTargetType.HOST)) + .thenReturn(new ReservationEligibilityResult(true, "eligible")); + when(ratingRepository.findByTargetIdAndGuestIdAndIsDeletedFalse(targetId, guestId)) + .thenReturn(Optional.empty()); + when(userGrpcClient.getUserSummary(guestId)).thenReturn(userFound(guestId, "John", "Doe")); + when(ratingMapper.toEntity(request)).thenReturn(entity); + when(ratingRepository.save(entity)).thenReturn(entity); + when(ratingMapper.toResponse(entity)).thenReturn(expectedResponse); + + RatingResponse result = ratingService.createRating(request, userContext); + + assertThat(result).isEqualTo(expectedResponse); + assertThat(entity.getGuestId()).isEqualTo(guestId); + verify(ratingRepository).save(entity); + } + + @Test + @DisplayName("ineligible guest throws ForbiddenException") + void createRating_IneligibleGuest_ThrowsForbiddenException() { + UUID guestId = UUID.randomUUID(); + UUID targetId = UUID.randomUUID(); + CreateRatingRequest request = new CreateRatingRequest(targetId, RatingTargetType.HOST, 5); + UserContext userContext = new UserContext(guestId, "GUEST"); + + when(reservationGrpcClient.checkRatingEligibility(guestId, targetId, RatingTargetType.HOST)) + .thenReturn(new ReservationEligibilityResult(false, "No completed reservation")); + + assertThatThrownBy(() -> ratingService.createRating(request, userContext)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + @DisplayName("duplicate rating by same guest throws ConflictException") + void createRating_DuplicateRatingByGuest_ThrowsConflictException() { + UUID guestId = UUID.randomUUID(); + UUID targetId = UUID.randomUUID(); + CreateRatingRequest request = new CreateRatingRequest(targetId, RatingTargetType.HOST, 5); + UserContext userContext = new UserContext(guestId, "GUEST"); + + when(reservationGrpcClient.checkRatingEligibility(guestId, targetId, RatingTargetType.HOST)) + .thenReturn(new ReservationEligibilityResult(true, "eligible")); + when(ratingRepository.findByTargetIdAndGuestIdAndIsDeletedFalse(targetId, guestId)) + .thenReturn(Optional.of(new Rating())); + + assertThatThrownBy(() -> ratingService.createRating(request, userContext)) + .isInstanceOf(ConflictException.class); + } + + @Test + @DisplayName("HOST target type calls publishHostRated, not publishAccommodationRated") + void createRating_HostTarget_PublishesHostRatedEvent() { + UUID guestId = UUID.randomUUID(); + UUID targetId = UUID.randomUUID(); + CreateRatingRequest request = new CreateRatingRequest(targetId, RatingTargetType.HOST, 4); + UserContext userContext = new UserContext(guestId, "GUEST"); + + Rating entity = buildRating(UUID.randomUUID(), targetId, RatingTargetType.HOST, null, 4); + + when(reservationGrpcClient.checkRatingEligibility(guestId, targetId, RatingTargetType.HOST)) + .thenReturn(new ReservationEligibilityResult(true, "eligible")); + when(ratingRepository.findByTargetIdAndGuestIdAndIsDeletedFalse(targetId, guestId)) + .thenReturn(Optional.empty()); + when(userGrpcClient.getUserSummary(guestId)).thenReturn(userFound(guestId, "John", "Doe")); + when(ratingMapper.toEntity(request)).thenReturn(entity); + when(ratingRepository.save(entity)).thenReturn(entity); + when(ratingMapper.toResponse(entity)).thenReturn(buildResponse(entity)); + + ratingService.createRating(request, userContext); + + verify(eventPublisher).publishHostRated(entity); + verify(eventPublisher, never()).publishAccommodationRated(any()); + } + + @Test + @DisplayName("ACCOMMODATION target type calls publishAccommodationRated, not publishHostRated") + void createRating_AccommodationTarget_PublishesAccommodationRatedEvent() { + UUID guestId = UUID.randomUUID(); + UUID targetId = UUID.randomUUID(); + CreateRatingRequest request = new CreateRatingRequest(targetId, RatingTargetType.ACCOMMODATION, 3); + UserContext userContext = new UserContext(guestId, "GUEST"); + + Rating entity = buildRating(UUID.randomUUID(), targetId, RatingTargetType.ACCOMMODATION, null, 3); + + when(reservationGrpcClient.checkRatingEligibility(guestId, targetId, RatingTargetType.ACCOMMODATION)) + .thenReturn(new ReservationEligibilityResult(true, "eligible")); + when(ratingRepository.findByTargetIdAndGuestIdAndIsDeletedFalse(targetId, guestId)) + .thenReturn(Optional.empty()); + when(userGrpcClient.getUserSummary(guestId)).thenReturn(userFound(guestId, "John", "Doe")); + when(ratingMapper.toEntity(request)).thenReturn(entity); + when(ratingRepository.save(entity)).thenReturn(entity); + when(ratingMapper.toResponse(entity)).thenReturn(buildResponse(entity)); + + ratingService.createRating(request, userContext); + + verify(eventPublisher).publishAccommodationRated(entity); + verify(eventPublisher, never()).publishHostRated(any()); + } + + @Test + @DisplayName("sets guestId, guestFirstName, guestLastName from UserContext and UserSummary") + void createRating_SetsGuestIdAndNameFromUserContext() { + UUID guestId = UUID.randomUUID(); + UUID targetId = UUID.randomUUID(); + CreateRatingRequest request = new CreateRatingRequest(targetId, RatingTargetType.HOST, 5); + UserContext userContext = new UserContext(guestId, "GUEST"); + + Rating entity = new Rating(); + + when(reservationGrpcClient.checkRatingEligibility(guestId, targetId, RatingTargetType.HOST)) + .thenReturn(new ReservationEligibilityResult(true, "eligible")); + when(ratingRepository.findByTargetIdAndGuestIdAndIsDeletedFalse(targetId, guestId)) + .thenReturn(Optional.empty()); + when(userGrpcClient.getUserSummary(guestId)) + .thenReturn(new UserSummaryResult(true, guestId, "alice@example.com", "Alice", "Smith", "GUEST", false)); + when(ratingMapper.toEntity(request)).thenReturn(entity); + when(ratingRepository.save(entity)).thenReturn(entity); + when(ratingMapper.toResponse(entity)).thenReturn(buildResponse(entity)); + + ratingService.createRating(request, userContext); + + assertThat(entity.getGuestId()).isEqualTo(guestId); + assertThat(entity.getGuestFirstName()).isEqualTo("Alice"); + assertThat(entity.getGuestLastName()).isEqualTo("Smith"); + } + } + + // ================================================================ + // getRatingById + // ================================================================ + + @Nested + @DisplayName("getRatingById") + class GetRatingById { + + @Test + @DisplayName("existing id returns RatingResponse") + void getRatingById_ExistingId_ReturnsResponse() { + UUID id = UUID.randomUUID(); + Rating rating = buildRating(id, UUID.randomUUID(), RatingTargetType.HOST, UUID.randomUUID(), 4); + RatingResponse response = buildResponse(rating); + + when(ratingRepository.findByIdAndIsDeletedFalse(id)).thenReturn(Optional.of(rating)); + when(ratingMapper.toResponse(rating)).thenReturn(response); + + RatingResponse result = ratingService.getRatingById(id); + + assertThat(result).isEqualTo(response); + } + + @Test + @DisplayName("non-existing id throws RatingNotFoundException") + void getRatingById_NonExistingId_ThrowsRatingNotFoundException() { + UUID id = UUID.randomUUID(); + + when(ratingRepository.findByIdAndIsDeletedFalse(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> ratingService.getRatingById(id)) + .isInstanceOf(RatingNotFoundException.class); + } + } + + // ================================================================ + // getAllRatings + // ================================================================ + + @Nested + @DisplayName("getAllRatings") + class GetAllRatings { + + @Test + @DisplayName("returns mapped list of all ratings") + void getAllRatings_ReturnsAllRatings() { + Rating r1 = buildRating(UUID.randomUUID(), UUID.randomUUID(), RatingTargetType.HOST, UUID.randomUUID(), 5); + Rating r2 = buildRating(UUID.randomUUID(), UUID.randomUUID(), RatingTargetType.ACCOMMODATION, UUID.randomUUID(), 3); + List ratings = List.of(r1, r2); + List responses = List.of(buildResponse(r1), buildResponse(r2)); + + when(ratingRepository.findAllByIsDeletedFalse()).thenReturn(ratings); + when(ratingMapper.toResponseList(ratings)).thenReturn(responses); + + List result = ratingService.getAllRatings(); + + assertThat(result).hasSize(2).isEqualTo(responses); + } + + @Test + @DisplayName("empty repository returns empty list") + void getAllRatings_WhenEmpty_ReturnsEmptyList() { + when(ratingRepository.findAllByIsDeletedFalse()).thenReturn(List.of()); + when(ratingMapper.toResponseList(List.of())).thenReturn(List.of()); + + assertThat(ratingService.getAllRatings()).isEmpty(); + } + } + + // ================================================================ + // getRatingsByTargetId + // ================================================================ + + @Nested + @DisplayName("getRatingsByTargetId") + class GetRatingsByTargetId { + + @Test + @DisplayName("returns correct summary with list and count") + void getRatingsByTargetId_ReturnsCorrectSummary() { + UUID targetId = UUID.randomUUID(); + UUID guestId = UUID.randomUUID(); + Rating r = buildRating(UUID.randomUUID(), targetId, RatingTargetType.HOST, guestId, 4); + List ratings = List.of(r); + List responses = List.of(buildResponse(r)); + + when(ratingRepository.findAllByTargetIdAndIsDeletedFalse(targetId)).thenReturn(ratings); + when(ratingMapper.toResponseList(ratings)).thenReturn(responses); + + RatingsSummaryResponse result = ratingService.getRatingsByTargetId(targetId); + + assertThat(result.ratings()).isEqualTo(responses); + assertThat(result.totalCount()).isEqualTo(1); + assertThat(result.averageScore()).isEqualTo(4.0); + } + + @Test + @DisplayName("no ratings returns zero average and count") + void getRatingsByTargetId_WithNoRatings_ReturnsZeroAverage() { + UUID targetId = UUID.randomUUID(); + + when(ratingRepository.findAllByTargetIdAndIsDeletedFalse(targetId)).thenReturn(List.of()); + when(ratingMapper.toResponseList(List.of())).thenReturn(List.of()); + + RatingsSummaryResponse result = ratingService.getRatingsByTargetId(targetId); + + assertThat(result.ratings()).isEmpty(); + assertThat(result.totalCount()).isEqualTo(0); + assertThat(result.averageScore()).isEqualTo(0.0); + } + + @Test + @DisplayName("scores [5, 3, 4] produce average 4.0") + void getRatingsByTargetId_ComputesCorrectAverage() { + UUID targetId = UUID.randomUUID(); + UUID g1 = UUID.randomUUID(), g2 = UUID.randomUUID(), g3 = UUID.randomUUID(); + Rating r1 = buildRating(UUID.randomUUID(), targetId, RatingTargetType.HOST, g1, 5); + Rating r2 = buildRating(UUID.randomUUID(), targetId, RatingTargetType.HOST, g2, 3); + Rating r3 = buildRating(UUID.randomUUID(), targetId, RatingTargetType.HOST, g3, 4); + List ratings = List.of(r1, r2, r3); + List responses = List.of( + new RatingResponse(r1.getId(), targetId, RatingTargetType.HOST, "A", "B", g1, 5, null, null), + new RatingResponse(r2.getId(), targetId, RatingTargetType.HOST, "C", "D", g2, 3, null, null), + new RatingResponse(r3.getId(), targetId, RatingTargetType.HOST, "E", "F", g3, 4, null, null) + ); + + when(ratingRepository.findAllByTargetIdAndIsDeletedFalse(targetId)).thenReturn(ratings); + when(ratingMapper.toResponseList(ratings)).thenReturn(responses); + + RatingsSummaryResponse result = ratingService.getRatingsByTargetId(targetId); + + assertThat(result.averageScore()).isEqualTo(4.0); + assertThat(result.totalCount()).isEqualTo(3); + } + } + + // ================================================================ + // getRatingsByGuestId + // ================================================================ + + @Nested + @DisplayName("getRatingsByGuestId") + class GetRatingsByGuestId { + + @Test + @DisplayName("returns ratings for the given guest") + void getRatingsByGuestId_ReturnsGuestRatings() { + UUID guestId = UUID.randomUUID(); + Rating r = buildRating(UUID.randomUUID(), UUID.randomUUID(), RatingTargetType.HOST, guestId, 5); + List ratings = List.of(r); + List responses = List.of(buildResponse(r)); + + when(ratingRepository.findAllByGuestIdAndIsDeletedFalse(guestId)).thenReturn(ratings); + when(ratingMapper.toResponseList(ratings)).thenReturn(responses); + + assertThat(ratingService.getRatingsByGuestId(guestId)).isEqualTo(responses); + } + + @Test + @DisplayName("no ratings for guest returns empty list") + void getRatingsByGuestId_WhenNoRatings_ReturnsEmptyList() { + UUID guestId = UUID.randomUUID(); + + when(ratingRepository.findAllByGuestIdAndIsDeletedFalse(guestId)).thenReturn(List.of()); + when(ratingMapper.toResponseList(List.of())).thenReturn(List.of()); + + assertThat(ratingService.getRatingsByGuestId(guestId)).isEmpty(); + } + } + + // ================================================================ + // updateRating + // ================================================================ + + @Nested + @DisplayName("updateRating") + class UpdateRating { + + @Test + @DisplayName("owner updates score and gets updated response") + void updateRating_ByOwner_ReturnsUpdatedResponse() { + UUID id = UUID.randomUUID(); + UUID guestId = UUID.randomUUID(); + UserContext userContext = new UserContext(guestId, "GUEST"); + UpdateRatingRequest request = new UpdateRatingRequest(3); + Rating rating = buildRating(id, UUID.randomUUID(), RatingTargetType.HOST, guestId, 5); + Rating savedRating = buildRating(id, rating.getTargetId(), RatingTargetType.HOST, guestId, 3); + RatingResponse response = buildResponse(savedRating); + + when(ratingRepository.findByIdAndIsDeletedFalse(id)).thenReturn(Optional.of(rating)); + when(ratingRepository.save(rating)).thenReturn(savedRating); + when(ratingMapper.toResponse(savedRating)).thenReturn(response); + + RatingResponse result = ratingService.updateRating(id, request, userContext); + + assertThat(result).isEqualTo(response); + assertThat(rating.getScore()).isEqualTo(3); + } + + @Test + @DisplayName("wrong owner throws ForbiddenException") + void updateRating_ByWrongOwner_ThrowsForbiddenException() { + UUID id = UUID.randomUUID(); + UUID guestId = UUID.randomUUID(); + UserContext userContext = new UserContext(UUID.randomUUID(), "GUEST"); + UpdateRatingRequest request = new UpdateRatingRequest(3); + Rating rating = buildRating(id, UUID.randomUUID(), RatingTargetType.HOST, guestId, 5); + + when(ratingRepository.findByIdAndIsDeletedFalse(id)).thenReturn(Optional.of(rating)); + + assertThatThrownBy(() -> ratingService.updateRating(id, request, userContext)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + @DisplayName("non-existing id throws RatingNotFoundException") + void updateRating_NonExistingId_ThrowsRatingNotFoundException() { + UUID id = UUID.randomUUID(); + UserContext userContext = new UserContext(UUID.randomUUID(), "GUEST"); + + when(ratingRepository.findByIdAndIsDeletedFalse(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> ratingService.updateRating(id, new UpdateRatingRequest(3), userContext)) + .isInstanceOf(RatingNotFoundException.class); + } + } + + // ================================================================ + // deleteRating + // ================================================================ + + @Nested + @DisplayName("deleteRating") + class DeleteRating { + + @Test + @DisplayName("owner soft-deletes rating") + void deleteRating_ByOwner_SoftDeletesRating() { + UUID id = UUID.randomUUID(); + UUID guestId = UUID.randomUUID(); + UserContext userContext = new UserContext(guestId, "GUEST"); + Rating rating = buildRating(id, UUID.randomUUID(), RatingTargetType.HOST, guestId, 5); + + when(ratingRepository.findByIdAndIsDeletedFalse(id)).thenReturn(Optional.of(rating)); + + ratingService.deleteRating(id, userContext); + + assertThat(rating.isDeleted()).isTrue(); + verify(ratingRepository).save(rating); + } + + @Test + @DisplayName("wrong owner throws ForbiddenException") + void deleteRating_ByWrongOwner_ThrowsForbiddenException() { + UUID id = UUID.randomUUID(); + UUID guestId = UUID.randomUUID(); + UserContext userContext = new UserContext(UUID.randomUUID(), "GUEST"); + Rating rating = buildRating(id, UUID.randomUUID(), RatingTargetType.HOST, guestId, 5); + + when(ratingRepository.findByIdAndIsDeletedFalse(id)).thenReturn(Optional.of(rating)); + + assertThatThrownBy(() -> ratingService.deleteRating(id, userContext)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + @DisplayName("non-existing id throws RatingNotFoundException") + void deleteRating_NonExistingId_ThrowsRatingNotFoundException() { + UUID id = UUID.randomUUID(); + UserContext userContext = new UserContext(UUID.randomUUID(), "GUEST"); + + when(ratingRepository.findByIdAndIsDeletedFalse(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> ratingService.deleteRating(id, userContext)) + .isInstanceOf(RatingNotFoundException.class); + } + } +} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 0000000..0c34d7b --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,25 @@ +spring.application.name=rating-test + +# Disable tracing in tests +management.tracing.export.enabled=false + +# Disable RabbitMQ autoconfiguration (RatingEventPublisherService is mocked in tests) +spring.autoconfigure.exclude=org.springframework.boot.amqp.autoconfigure.RabbitAutoConfiguration + +# gRPC client stubs (won't connect; clients are mocked with @MockitoBean) +grpc.client.user-service.address=static://localhost:9090 +grpc.client.user-service.negotiationType=plaintext +grpc.client.reservation-service.address=static://localhost:9090 +grpc.client.reservation-service.negotiationType=plaintext +grpc.client.accommodation-service.address=static://localhost:9090 +grpc.client.accommodation-service.negotiationType=plaintext + +# RabbitMQ routing keys (required by RabbitMQConfig @Value fields even when the broker is absent) +rabbitmq.exchange.notification=notification.exchange +rabbitmq.routing-key.host-rated=notification.rating.host +rabbitmq.routing-key.accommodation-rated=notification.rating.accommodation + +# MongoDB placeholder URI (overridden by @DynamicPropertySource in integration tests) +spring.mongodb.uri=mongodb://localhost:27017/rating_test + +logging.level.com.devoops=DEBUG diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml index 8c647eb..b0bb18d 100644 --- a/src/test/resources/logback-test.xml +++ b/src/test/resources/logback-test.xml @@ -13,6 +13,6 @@ - - + + From e66da562b86cd8a2ba9b5a7c107558432713c039 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marko=20Jano=C5=A1evi=C4=87?= Date: Mon, 23 Feb 2026 18:59:06 +0100 Subject: [PATCH 15/17] 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 2e43c6f..e6353cc 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 133e0dc6e5137ae4397ba836aa25b008b97a16db Mon Sep 17 00:00:00 2001 From: lukaDjordjevic01 <96748944+lukaDjordjevic01@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:27:03 +0100 Subject: [PATCH 16/17] feat: Add Helm values. --- environment/helm/values.yaml | 47 ++++++++++++++++++++++++++++++++++++ 1 file changed, 47 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..09a0716 --- /dev/null +++ b/environment/helm/values.yaml @@ -0,0 +1,47 @@ +fullnameOverride: devoops-rating-service +replicaCount: 1 + +image: + registry: docker.io + repository: threeamigoscoding/devoops-rating-service + tag: "latest" + pullPolicy: IfNotPresent + +service: + type: ClusterIP + httpPort: 8080 + grpc: + enabled: false + +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" + MONGODB_HOST: "devoops-mongodb" + MONGODB_PORT: "27017" + +secretData: + MONGODB_USERNAME: "devoops" + MONGODB_PASSWORD: "devoops" From a52e860136cfb665b33a334f6e8c7221caa148e3 Mon Sep 17 00:00:00 2001 From: Dusan Date: Tue, 24 Feb 2026 22:07:33 +0100 Subject: [PATCH 17/17] 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 e6353cc..f1881cb 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-rating-service:${{ steps.version.outputs.tag }} - ${{ env.DOCKERHUB_USERNAME }}/devoops-rating-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-rating-service:${{ steps.version.outputs.tag }} \ + --destination=${{ env.DOCKERHUB_USERNAME }}/devoops-rating-service:latest \ + --cache=true \ + --cache-repo=${{ env.DOCKERHUB_USERNAME }}/devoops-rating-service-cache \ No newline at end of file