diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f1881cb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,86 @@ +name: CI Pipeline + +on: + push: + branches: + - develop + - main + tags: + - 'v*' + + +env: + DOCKERHUB_USERNAME: threeamigoscoding + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '25' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@0b6dd653ba04f4f93bf581ec31e66cbd7dcb644d + + - name: Build with Gradle + run: ./gradlew clean build + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: build-artifact + path: build/libs/*.jar + retention-days: 1 + + publish: + needs: build + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: build-artifact + path: build/libs + + - name: Generate version tag + id: version + run: | + if [[ $GITHUB_REF == refs/tags/* ]]; then + VERSION_TAG=${GITHUB_REF#refs/tags/v} + elif [[ $GITHUB_REF == refs/heads/main ]]; then + SHORT_SHA=$(git rev-parse --short HEAD) + VERSION_TAG="main-${SHORT_SHA}" + else + SHORT_SHA=$(git rev-parse --short HEAD) + VERSION_TAG="develop-${SHORT_SHA}" + fi + echo "tag=$VERSION_TAG" >> $GITHUB_OUTPUT + echo "Generated version tag: $VERSION_TAG" + + - name: Build and push Docker image with Kaniko + env: + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + run: | + mkdir -p /tmp/kaniko/.docker + echo "{\"auths\":{\"https://index.docker.io/v1/\":{\"auth\":\"$(echo -n ${{ env.DOCKERHUB_USERNAME }}:${DOCKERHUB_TOKEN} | base64)\"}}}" > /tmp/kaniko/.docker/config.json + docker run \ + -v ${{ github.workspace }}:/workspace \ + -v /tmp/kaniko/.docker:/kaniko/.docker \ + gcr.io/kaniko-project/executor:latest \ + --context=/workspace \ + --dockerfile=/workspace/Dockerfile \ + --destination=${{ env.DOCKERHUB_USERNAME }}/devoops-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 diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml new file mode 100644 index 0000000..e6f3777 --- /dev/null +++ b/.github/workflows/pr-check.yml @@ -0,0 +1,27 @@ +name: PR Check + +on: + pull_request: + branches: + - develop + - main + +jobs: + build-and-test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up JDK 25 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '25' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Build with Gradle + run: ./gradlew clean build \ No newline at end of file diff --git a/.gitignore b/.gitignore index df0b53a..070c2bf 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,4 @@ out/ .vscode/ ### Mac ### -.DS_Store \ No newline at end of file +.DS_Store diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..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/build.gradle.kts b/build.gradle.kts index df9551d..fbc6d56 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,7 +1,11 @@ +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" + jacoco } group = "com.devoops" @@ -18,18 +22,80 @@ repositories { mavenCentral() } +val grpcVersion = "1.68.0" + dependencies { - implementation("org.springframework.boot:spring-boot-starter-flyway") - implementation("org.springframework.boot:spring-boot-starter-security") + // Web and Core + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") + + // 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.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") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("net.logstash.logback:logstash-logback-encoder:8.0") + //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") + + // 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") + 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-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") } +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:3.25.5" + } + plugins { + id("grpc") { + artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion" + } + } + generateProtoTasks { + all().forEach { task -> + task.plugins { + id("grpc") + } + } + } +} + tasks.withType { useJUnitPlatform() + finalizedBy(tasks.jacocoTestReport) +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { xml.required = true } } diff --git a/environment/.local.env b/environment/.local.env new file mode 100644 index 0000000..e48bf2f --- /dev/null +++ b/environment/.local.env @@ -0,0 +1,24 @@ +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 + +# 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/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" diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 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/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/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 new file mode 100644 index 0000000..68547d3 --- /dev/null +++ b/src/main/java/com/devoops/rating/controller/RatingController.java @@ -0,0 +1,78 @@ +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.response.RatingsSummaryResponse; +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/rating") +public class RatingController { + + private final RatingService ratingService; + + public RatingController(RatingService ratingService) { + this.ratingService = ratingService; + } + + @PostMapping + @RequireRole("GUEST") + public ResponseEntity createRating( + @Valid @RequestBody CreateRatingRequest request, + UserContext userContext) { + RatingResponse response = ratingService.createRating(request, userContext); + 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) { + RatingsSummaryResponse response = ratingService.getRatingsByTargetId(targetId); + return ResponseEntity.ok(response); + } + + @GetMapping("/guest") + @RequireRole("GUEST") + public ResponseEntity> getRatingsByGuestId(UserContext userContext) { + List responses = ratingService.getRatingsByGuestId(userContext.userId()); + return ResponseEntity.ok(responses); + } + + @PutMapping("/{id}") + @RequireRole("GUEST") + public ResponseEntity updateRating( + @PathVariable UUID id, + @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, UserContext userContext) { + ratingService.deleteRating(id, userContext); + return ResponseEntity.noContent().build(); + } +} 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..86b897f --- /dev/null +++ b/src/main/java/com/devoops/rating/controller/TestController.java @@ -0,0 +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() { + 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"); + } + } +} 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/request/CreateRatingRequest.java b/src/main/java/com/devoops/rating/dto/request/CreateRatingRequest.java new file mode 100644 index 0000000..61d377c --- /dev/null +++ b/src/main/java/com/devoops/rating/dto/request/CreateRatingRequest.java @@ -0,0 +1,22 @@ +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; + +import java.util.UUID; + +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") + 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..5a04545 --- /dev/null +++ b/src/main/java/com/devoops/rating/dto/response/RatingResponse.java @@ -0,0 +1,19 @@ +package com.devoops.rating.dto.response; + +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, + Integer score, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { } + 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/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 new file mode 100644 index 0000000..7b3e0d3 --- /dev/null +++ b/src/main/java/com/devoops/rating/exception/GlobalExceptionHandler.java @@ -0,0 +1,94 @@ +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(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(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<>(); + 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/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); } +} 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/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/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 new file mode 100644 index 0000000..b02b844 --- /dev/null +++ b/src/main/java/com/devoops/rating/mapper/RatingMapper.java @@ -0,0 +1,28 @@ +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) + @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); + + 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..6173ba9 --- /dev/null +++ b/src/main/java/com/devoops/rating/model/Rating.java @@ -0,0 +1,32 @@ +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 RatingTargetType targetType; + + private String guestFirstName; + + private String guestLastName; + + private UUID guestId; + + private Integer score; +} + 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 new file mode 100644 index 0000000..8b09304 --- /dev/null +++ b/src/main/java/com/devoops/rating/repository/RatingRepository.java @@ -0,0 +1,25 @@ +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); + + Optional findByTargetIdAndGuestIdAndIsDeletedFalse(UUID targetId, UUID guestId); + +} + 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 new file mode 100644 index 0000000..f0ea99a --- /dev/null +++ b/src/main/java/com/devoops/rating/service/RatingService.java @@ -0,0 +1,146 @@ +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.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; +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; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; + +@Service +public class RatingService { + + private static final Logger log = LoggerFactory.getLogger(RatingService.class); + + private final RatingRepository ratingRepository; + 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, + RatingEventPublisherService eventPublisher) { + this.ratingRepository = ratingRepository; + this.ratingMapper = ratingMapper; + this.userGrpcClient = userGrpcClient; + this.reservationGrpcClient = reservationGrpcClient; + this.eventPublisher = eventPublisher; + } + + public RatingResponse createRating(CreateRatingRequest request, UserContext userContext) { + 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()); + + Rating rating = ratingMapper.toEntity(request); + rating.setGuestId(userContext.userId()); + rating.setGuestFirstName(userSummary.found() ? userSummary.firstName() : ""); + rating.setGuestLastName(userSummary.found() ? userSummary.lastName() : ""); + + 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); + } + + 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 RatingsSummaryResponse getRatingsByTargetId(UUID targetId) { + log.debug("Fetching ratings for target: {}", 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) { + log.debug("Fetching ratings by guest: {}", guestId); + + return ratingMapper.toResponseList(ratingRepository.findAllByGuestIdAndIsDeletedFalse(guestId)); + } + + 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()); + + Rating updatedRating = ratingRepository.save(rating); + log.info("Updated rating with id: {}", updatedRating.getId()); + + return ratingMapper.toResponse(updatedRating); + } + + 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); + + log.info("Soft deleted rating with id: {}", id); + } +} 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/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/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 1be4abf..43c99d5 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1 +1,47 @@ 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 +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 + +# 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 + +# 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 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/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 new file mode 100644 index 0000000..b0bb18d --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,18 @@ + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + +