From 1eb0e16ba8bddf9b6c3d72092a7aa66cdeea302a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20=C4=8Cuturi=C4=87?= Date: Sat, 14 Feb 2026 17:52:02 +0100 Subject: [PATCH 1/2] feat: Configured all notifications --- build.gradle.kts | 70 +++++-- environment/.local.env | 26 ++- .../notification/NotificationApplication.java | 3 +- .../notification/config/MongoConfig.java | 45 +++++ .../notification/config/RabbitMQConfig.java | 172 ++++++++++++++++++ .../notification/config/RequireRole.java | 12 ++ .../config/RoleAuthorizationInterceptor.java | 49 +++++ .../notification/config/UserContext.java | 5 + .../config/UserContextResolver.java | 40 ++++ .../notification/config/WebConfig.java | 26 +++ .../NotificationPreferencesController.java | 37 ++++ .../controller/TestController.java | 100 +++++++--- .../message/AccommodationRatedMessage.java | 31 ++++ .../dto/message/HostRatedMessage.java | 30 +++ .../dto/message/NotificationMessage.java | 26 +++ .../message/ReservationCancelledMessage.java | 34 ++++ .../ReservationRequestCreatedMessage.java | 35 ++++ .../message/ReservationResponseMessage.java | 41 +++++ .../dto/message/UserCreatedMessage.java | 21 +++ .../NotificationPreferencesUpdateRequest.java | 10 + .../NotificationPreferencesResponse.java | 14 ++ .../notification/entity/BaseDocument.java | 31 ++++ .../entity/NotificationPreferences.java | 33 ++++ .../notification/entity/NotificationType.java | 35 ++++ .../notification/entity/Preferences.java | 29 +++ .../exception/ForbiddenException.java | 8 + .../exception/GlobalExceptionHandler.java | 86 +++++++++ .../PreferencesNotFoundException.java | 10 + .../exception/UnauthorizedException.java | 8 + .../mapper/NotificationPreferencesMapper.java | 17 ++ .../NotificationPreferencesRepository.java | 16 ++ .../notification/service/EmailService.java | 106 +++++++++++ .../service/NotificationConsumerService.java | 45 +++++ .../NotificationPreferencesService.java | 85 +++++++++ .../service/NotificationService.java | 37 ++++ .../service/UserEventConsumerService.java | 22 +++ src/main/resources/application.properties | 73 +++++++- .../templates/email/accommodation-rated.html | 58 ++++++ .../resources/templates/email/host-rated.html | 53 ++++++ .../email/reservation-cancelled.html | 66 +++++++ .../email/reservation-request-created.html | 62 +++++++ .../templates/email/reservation-response.html | 89 +++++++++ 42 files changed, 1754 insertions(+), 42 deletions(-) create mode 100644 src/main/java/com/devoops/notification/config/MongoConfig.java create mode 100644 src/main/java/com/devoops/notification/config/RabbitMQConfig.java create mode 100644 src/main/java/com/devoops/notification/config/RequireRole.java create mode 100644 src/main/java/com/devoops/notification/config/RoleAuthorizationInterceptor.java create mode 100644 src/main/java/com/devoops/notification/config/UserContext.java create mode 100644 src/main/java/com/devoops/notification/config/UserContextResolver.java create mode 100644 src/main/java/com/devoops/notification/config/WebConfig.java create mode 100644 src/main/java/com/devoops/notification/controller/NotificationPreferencesController.java create mode 100644 src/main/java/com/devoops/notification/dto/message/AccommodationRatedMessage.java create mode 100644 src/main/java/com/devoops/notification/dto/message/HostRatedMessage.java create mode 100644 src/main/java/com/devoops/notification/dto/message/NotificationMessage.java create mode 100644 src/main/java/com/devoops/notification/dto/message/ReservationCancelledMessage.java create mode 100644 src/main/java/com/devoops/notification/dto/message/ReservationRequestCreatedMessage.java create mode 100644 src/main/java/com/devoops/notification/dto/message/ReservationResponseMessage.java create mode 100644 src/main/java/com/devoops/notification/dto/message/UserCreatedMessage.java create mode 100644 src/main/java/com/devoops/notification/dto/request/NotificationPreferencesUpdateRequest.java create mode 100644 src/main/java/com/devoops/notification/dto/response/NotificationPreferencesResponse.java create mode 100644 src/main/java/com/devoops/notification/entity/BaseDocument.java create mode 100644 src/main/java/com/devoops/notification/entity/NotificationPreferences.java create mode 100644 src/main/java/com/devoops/notification/entity/NotificationType.java create mode 100644 src/main/java/com/devoops/notification/entity/Preferences.java create mode 100644 src/main/java/com/devoops/notification/exception/ForbiddenException.java create mode 100644 src/main/java/com/devoops/notification/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/devoops/notification/exception/PreferencesNotFoundException.java create mode 100644 src/main/java/com/devoops/notification/exception/UnauthorizedException.java create mode 100644 src/main/java/com/devoops/notification/mapper/NotificationPreferencesMapper.java create mode 100644 src/main/java/com/devoops/notification/repository/NotificationPreferencesRepository.java create mode 100644 src/main/java/com/devoops/notification/service/EmailService.java create mode 100644 src/main/java/com/devoops/notification/service/NotificationConsumerService.java create mode 100644 src/main/java/com/devoops/notification/service/NotificationPreferencesService.java create mode 100644 src/main/java/com/devoops/notification/service/NotificationService.java create mode 100644 src/main/java/com/devoops/notification/service/UserEventConsumerService.java create mode 100644 src/main/resources/templates/email/accommodation-rated.html create mode 100644 src/main/resources/templates/email/host-rated.html create mode 100644 src/main/resources/templates/email/reservation-cancelled.html create mode 100644 src/main/resources/templates/email/reservation-request-created.html create mode 100644 src/main/resources/templates/email/reservation-response.html diff --git a/build.gradle.kts b/build.gradle.kts index 24f76ea..03439ab 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ plugins { java + jacoco id("org.springframework.boot") version "4.0.1" id("io.spring.dependency-management") version "1.1.7" } @@ -19,26 +20,67 @@ repositories { } dependencies { - implementation("org.springframework.boot:spring-boot-starter-flyway") - // implementation("org.springframework.boot:spring-boot-starter-security") + // Web and Core + implementation("org.springframework.boot:spring-boot-starter-webmvc") implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-validation") + + // MongoDB + implementation("org.springframework.boot:spring-boot-starter-data-mongodb") + + // RabbitMQ + implementation("org.springframework.boot:spring-boot-starter-amqp") + + // Email + implementation("org.springframework.boot:spring-boot-starter-mail") + implementation("org.springframework.boot:spring-boot-starter-thymeleaf") + + // Prometheus Metrics implementation("io.micrometer:micrometer-registry-prometheus") - implementation("org.springframework.boot:spring-boot-starter-webmvc") - implementation("net.logstash.logback:logstash-logback-encoder:8.0") - implementation("org.flywaydb:flyway-database-postgresql") - //zipkin(tracing) - implementation("org.springframework.boot:spring-boot-micrometer-tracing-brave") - implementation("org.springframework.boot:spring-boot-starter-zipkin") - implementation("io.micrometer:micrometer-tracing-bridge-brave") - implementation("io.zipkin.reporter2:zipkin-reporter-brave") - - runtimeOnly("org.postgresql:postgresql") - testImplementation("org.springframework.boot:spring-boot-starter-flyway-test") - // testImplementation("org.springframework.boot:spring-boot-starter-security-test") + + // Lombok + compileOnly("org.projectlombok:lombok") + annotationProcessor("org.projectlombok:lombok") + + // Null safety annotations + implementation("org.jspecify:jspecify:1.0.0") + + // 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") + + // Tracing (Zipkin) + implementation("org.springframework.boot:spring-boot-micrometer-tracing-brave") + implementation("org.springframework.boot:spring-boot-starter-zipkin") + implementation("io.micrometer:micrometer-tracing-bridge-brave") + implementation("io.zipkin.reporter2:zipkin-reporter-brave") + + // Logging + implementation("net.logstash.logback:logstash-logback-encoder:8.0") + + // Test + testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") + testImplementation("org.springframework.amqp:spring-rabbit-test") + testImplementation("org.testcontainers:junit-jupiter:1.20.4") + testImplementation("org.testcontainers:mongodb:1.20.4") + testImplementation("org.testcontainers:rabbitmq:1.20.4") + testImplementation("com.icegreen:greenmail-junit5:2.0.1") + testCompileOnly("org.projectlombok:lombok") + testAnnotationProcessor("org.projectlombok:lombok") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") } tasks.withType { useJUnitPlatform() + finalizedBy(tasks.jacocoTestReport) +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required = true + } } diff --git a/environment/.local.env b/environment/.local.env index c23275d..670a26a 100644 --- a/environment/.local.env +++ b/environment/.local.env @@ -1,4 +1,28 @@ SERVER_PORT=8080 LOGSTASH_HOST=logstash:5000 ZIPKIN_HOST=zipkin -ZIPKIN_PORT=9411 \ No newline at end of file +ZIPKIN_PORT=9411 + +# MongoDB +MONGODB_HOST=devoops-mongodb +MONGODB_PORT=27017 +MONGODB_USERNAME=devoops +MONGODB_PASSWORD=devoops + +# RabbitMQ +RABBITMQ_HOST=devoops-rabbitmq +RABBITMQ_PORT=5672 +RABBITMQ_USERNAME=devoops +RABBITMQ_PASSWORD=devoops123 +RABBITMQ_VHOST=/ + +# Email (MailHog for local development) +MAIL_HOST=devoops-mailhog +MAIL_PORT=1025 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_FROM=noreply@devoops.com +MAIL_FROM_NAME=DevOops + +# Frontend +FRONTEND_URL=http://localhost:4200 diff --git a/src/main/java/com/devoops/notification/NotificationApplication.java b/src/main/java/com/devoops/notification/NotificationApplication.java index 0753c84..a8ed5c2 100644 --- a/src/main/java/com/devoops/notification/NotificationApplication.java +++ b/src/main/java/com/devoops/notification/NotificationApplication.java @@ -2,9 +2,8 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; -@SpringBootApplication(exclude = DataSourceAutoConfiguration.class) +@SpringBootApplication public class NotificationApplication { public static void main(String[] args) { diff --git a/src/main/java/com/devoops/notification/config/MongoConfig.java b/src/main/java/com/devoops/notification/config/MongoConfig.java new file mode 100644 index 0000000..d8bdfa9 --- /dev/null +++ b/src/main/java/com/devoops/notification/config/MongoConfig.java @@ -0,0 +1,45 @@ +package com.devoops.notification.config; + +import com.mongodb.ConnectionString; +import com.mongodb.MongoClientSettings; +import org.bson.UuidRepresentation; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.mongodb.config.EnableMongoAuditing; + +@Configuration +@EnableMongoAuditing +public class MongoConfig { + +// @Value("${spring.mongodb.host:localhost}") +// private String host; +// +// @Value("${spring.mongodb.port:27017}") +// private int port; +// +// @Value("${spring.mongodb.database:notification_db}") +// private String database; +// +// @Value("${spring.mongodb.username:devoops}") +// private String username; +// +// @Value("${spring.mongodb.password:devoops}") +// private String password; +// +// @Value("${spring.mongodb.authentication-database:admin}") +// private String authDatabase; +// +// @Bean +// public MongoClientSettings mongoClientSettings() { +// String connectionString = String.format( +// "mongodb://%s:%s@%s:%d/%s?authSource=%s", +// username, password, host, port, database, authDatabase +// ); +// +// return MongoClientSettings.builder() +// .applyConnectionString(new ConnectionString(connectionString)) +// .uuidRepresentation(UuidRepresentation.STANDARD) +// .build(); +// } +} diff --git a/src/main/java/com/devoops/notification/config/RabbitMQConfig.java b/src/main/java/com/devoops/notification/config/RabbitMQConfig.java new file mode 100644 index 0000000..6898931 --- /dev/null +++ b/src/main/java/com/devoops/notification/config/RabbitMQConfig.java @@ -0,0 +1,172 @@ +package com.devoops.notification.config; + +import org.springframework.amqp.core.*; +import org.springframework.amqp.rabbit.connection.ConnectionFactory; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +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; + + @Value("${rabbitmq.exchange.dlx}") + private String deadLetterExchange; + + @Value("${rabbitmq.queue.dlq}") + private String deadLetterQueue; + + @Value("${rabbitmq.queue.reservation-created}") + private String reservationCreatedQueue; + + @Value("${rabbitmq.queue.reservation-cancelled}") + private String reservationCancelledQueue; + + @Value("${rabbitmq.queue.host-rated}") + private String hostRatedQueue; + + @Value("${rabbitmq.queue.accommodation-rated}") + private String accommodationRatedQueue; + + @Value("${rabbitmq.queue.reservation-response}") + private String reservationResponseQueue; + + @Value("${rabbitmq.queue.user-created}") + private String userCreatedQueue; + + @Bean + public MessageConverter jsonMessageConverter() { + return new Jackson2JsonMessageConverter(); + } + + @Bean + public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) { + RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory); + rabbitTemplate.setMessageConverter(jsonMessageConverter()); + return rabbitTemplate; + } + + @Bean + public TopicExchange notificationExchange() { + return new TopicExchange(notificationExchange); + } + + @Bean + public DirectExchange deadLetterExchange() { + return new DirectExchange(deadLetterExchange); + } + + @Bean + public Queue deadLetterQueue() { + return QueueBuilder.durable(deadLetterQueue).build(); + } + + @Bean + public Binding deadLetterBinding() { + return BindingBuilder + .bind(deadLetterQueue()) + .to(deadLetterExchange()) + .with("notification.dlq"); + } + + @Bean + public Queue reservationCreatedQueue() { + return QueueBuilder.durable(reservationCreatedQueue) + .withArgument("x-dead-letter-exchange", deadLetterExchange) + .withArgument("x-dead-letter-routing-key", "notification.dlq") + .build(); + } + + @Bean + public Queue reservationCancelledQueue() { + return QueueBuilder.durable(reservationCancelledQueue) + .withArgument("x-dead-letter-exchange", deadLetterExchange) + .withArgument("x-dead-letter-routing-key", "notification.dlq") + .build(); + } + + @Bean + public Queue hostRatedQueue() { + return QueueBuilder.durable(hostRatedQueue) + .withArgument("x-dead-letter-exchange", deadLetterExchange) + .withArgument("x-dead-letter-routing-key", "notification.dlq") + .build(); + } + + @Bean + public Queue accommodationRatedQueue() { + return QueueBuilder.durable(accommodationRatedQueue) + .withArgument("x-dead-letter-exchange", deadLetterExchange) + .withArgument("x-dead-letter-routing-key", "notification.dlq") + .build(); + } + + @Bean + public Queue reservationResponseQueue() { + return QueueBuilder.durable(reservationResponseQueue) + .withArgument("x-dead-letter-exchange", deadLetterExchange) + .withArgument("x-dead-letter-routing-key", "notification.dlq") + .build(); + } + + @Bean + public Binding reservationCreatedBinding() { + return BindingBuilder + .bind(reservationCreatedQueue()) + .to(notificationExchange()) + .with("notification.reservation.created"); + } + + @Bean + public Binding reservationCancelledBinding() { + return BindingBuilder + .bind(reservationCancelledQueue()) + .to(notificationExchange()) + .with("notification.reservation.cancelled"); + } + + @Bean + public Binding hostRatedBinding() { + return BindingBuilder + .bind(hostRatedQueue()) + .to(notificationExchange()) + .with("notification.rating.host"); + } + + @Bean + public Binding accommodationRatedBinding() { + return BindingBuilder + .bind(accommodationRatedQueue()) + .to(notificationExchange()) + .with("notification.rating.accommodation"); + } + + @Bean + public Binding reservationResponseBinding() { + return BindingBuilder + .bind(reservationResponseQueue()) + .to(notificationExchange()) + .with("notification.reservation.response"); + } + + @Bean + public Queue userCreatedQueue() { + return QueueBuilder.durable(userCreatedQueue) + .withArgument("x-dead-letter-exchange", deadLetterExchange) + .withArgument("x-dead-letter-routing-key", "notification.dlq") + .build(); + } + + @Bean + public Binding userCreatedBinding() { + return BindingBuilder + .bind(userCreatedQueue()) + .to(notificationExchange()) + .with("user.created"); + } +} diff --git a/src/main/java/com/devoops/notification/config/RequireRole.java b/src/main/java/com/devoops/notification/config/RequireRole.java new file mode 100644 index 0000000..44b9ad0 --- /dev/null +++ b/src/main/java/com/devoops/notification/config/RequireRole.java @@ -0,0 +1,12 @@ +package com.devoops.notification.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/notification/config/RoleAuthorizationInterceptor.java b/src/main/java/com/devoops/notification/config/RoleAuthorizationInterceptor.java new file mode 100644 index 0000000..d76a143 --- /dev/null +++ b/src/main/java/com/devoops/notification/config/RoleAuthorizationInterceptor.java @@ -0,0 +1,49 @@ +package com.devoops.notification.config; + +import com.devoops.notification.exception.ForbiddenException; +import com.devoops.notification.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/notification/config/UserContext.java b/src/main/java/com/devoops/notification/config/UserContext.java new file mode 100644 index 0000000..699291d --- /dev/null +++ b/src/main/java/com/devoops/notification/config/UserContext.java @@ -0,0 +1,5 @@ +package com.devoops.notification.config; + +import java.util.UUID; + +public record UserContext(UUID userId, String role) { } diff --git a/src/main/java/com/devoops/notification/config/UserContextResolver.java b/src/main/java/com/devoops/notification/config/UserContextResolver.java new file mode 100644 index 0000000..7030140 --- /dev/null +++ b/src/main/java/com/devoops/notification/config/UserContextResolver.java @@ -0,0 +1,40 @@ +package com.devoops.notification.config; + +import com.devoops.notification.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/notification/config/WebConfig.java b/src/main/java/com/devoops/notification/config/WebConfig.java new file mode 100644 index 0000000..32fae9f --- /dev/null +++ b/src/main/java/com/devoops/notification/config/WebConfig.java @@ -0,0 +1,26 @@ +package com.devoops.notification.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/notification/controller/NotificationPreferencesController.java b/src/main/java/com/devoops/notification/controller/NotificationPreferencesController.java new file mode 100644 index 0000000..67e6f43 --- /dev/null +++ b/src/main/java/com/devoops/notification/controller/NotificationPreferencesController.java @@ -0,0 +1,37 @@ +package com.devoops.notification.controller; + +import com.devoops.notification.config.RequireRole; +import com.devoops.notification.config.UserContext; +import com.devoops.notification.dto.request.NotificationPreferencesUpdateRequest; +import com.devoops.notification.dto.response.NotificationPreferencesResponse; +import com.devoops.notification.service.NotificationPreferencesService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@Slf4j +@RestController +@RequestMapping("/api/notification/preferences") +@RequiredArgsConstructor +public class NotificationPreferencesController { + + private final NotificationPreferencesService preferencesService; + + @GetMapping + @RequireRole({"HOST", "GUEST"}) + public ResponseEntity getPreferences(UserContext userContext) { + log.debug("Getting notification preferences for user {}", userContext.userId()); + return ResponseEntity.ok(preferencesService.getPreferences(userContext.userId())); + } + + @PutMapping + @RequireRole({"HOST", "GUEST"}) + public ResponseEntity updatePreferences( + UserContext userContext, + @RequestBody @Valid NotificationPreferencesUpdateRequest request) { + log.debug("Updating notification preferences for user {}", userContext.userId()); + return ResponseEntity.ok(preferencesService.updatePreferences(userContext.userId(), request)); + } +} diff --git a/src/main/java/com/devoops/notification/controller/TestController.java b/src/main/java/com/devoops/notification/controller/TestController.java index 12113ed..87dea01 100644 --- a/src/main/java/com/devoops/notification/controller/TestController.java +++ b/src/main/java/com/devoops/notification/controller/TestController.java @@ -1,36 +1,94 @@ package com.devoops.notification.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 com.devoops.notification.dto.message.*; +import com.devoops.notification.entity.NotificationType; +import com.devoops.notification.service.NotificationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import java.math.BigDecimal; +import java.time.LocalDate; import java.util.UUID; +@Slf4j @RestController -@RequestMapping("api/notification") +@RequestMapping("/api/notification/test") +@RequiredArgsConstructor public class TestController { - private static final Logger logger = LoggerFactory.getLogger(TestController.class); + private final NotificationService notificationService; - @GetMapping("test") - public String test() { - String requestId = UUID.randomUUID().toString(); - MDC.put("requestId", requestId); + @PostMapping("/send") + public ResponseEntity sendTestNotification( + @RequestParam(defaultValue = "test@example.com") String email, + @RequestParam(defaultValue = "RESERVATION_REQUEST_CREATED") String type + ) { + UUID testUserId = UUID.randomUUID(); + NotificationType notificationType = NotificationType.valueOf(type); - try { - logger.info("Test endpoint called - Notification Service health check"); - logger.debug("Processing test request with ID: {}", requestId); + NotificationMessage message = createTestMessage(testUserId, email, notificationType); - String response = "Notification Service is up and running!"; + log.info("Sending test notification: type={}, email={}, userId={}", type, email, testUserId); + notificationService.processNotification(message); - logger.info("Test endpoint successfully processed request {}", requestId); - return response; + return ResponseEntity.ok("Test notification sent to " + email + " (type: " + type + ")"); + } + + @GetMapping("/types") + public ResponseEntity getNotificationTypes() { + return ResponseEntity.ok(NotificationType.values()); + } + + private NotificationMessage createTestMessage(UUID userId, String email, NotificationType type) { + return switch (type) { + case RESERVATION_REQUEST_CREATED -> ReservationRequestCreatedMessage.builder() + .userId(userId) + .userEmail(email) + .guestName("John Doe") + .accommodationName("Cozy Beach House") + .checkIn(LocalDate.of(2024, 3, 15)) + .checkOut(LocalDate.of(2024, 3, 20)) + .totalPrice(new BigDecimal("450.00")) + .build(); + + case RESERVATION_CANCELLED -> ReservationCancelledMessage.builder() + .userId(userId) + .userEmail(email) + .guestName("John Doe") + .accommodationName("Cozy Beach House") + .checkIn(LocalDate.of(2024, 3, 15)) + .checkOut(LocalDate.of(2024, 3, 20)) + .reason("Guest cancelled due to schedule conflict") + .build(); + + case HOST_RATED -> HostRatedMessage.builder() + .userId(userId) + .userEmail(email) + .guestName("Jane Smith") + .rating(5) + .comment("Amazing host! Very helpful and responsive.") + .build(); + + case ACCOMMODATION_RATED -> AccommodationRatedMessage.builder() + .userId(userId) + .userEmail(email) + .guestName("Jane Smith") + .accommodationName("Cozy Beach House") + .rating(4) + .comment("Great location, clean and comfortable.") + .build(); - } finally { - MDC.remove("requestId"); - } + case RESERVATION_RESPONSE -> ReservationResponseMessage.builder() + .userId(userId) + .userEmail(email) + .hostName("Mike Johnson") + .accommodationName("Cozy Beach House") + .status(ReservationResponseMessage.ReservationStatus.APPROVED) + .checkIn(LocalDate.of(2024, 3, 15)) + .checkOut(LocalDate.of(2024, 3, 20)) + .build(); + }; } } diff --git a/src/main/java/com/devoops/notification/dto/message/AccommodationRatedMessage.java b/src/main/java/com/devoops/notification/dto/message/AccommodationRatedMessage.java new file mode 100644 index 0000000..bd1d3cf --- /dev/null +++ b/src/main/java/com/devoops/notification/dto/message/AccommodationRatedMessage.java @@ -0,0 +1,31 @@ +package com.devoops.notification.dto.message; + +import com.devoops.notification.entity.NotificationType; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@SuperBuilder +public class AccommodationRatedMessage extends NotificationMessage { + + private String guestName; + private String accommodationName; + private Integer rating; + private String comment; + + @Override + public NotificationType getType() { + return NotificationType.ACCOMMODATION_RATED; + } + + @Override + public String getSubject() { + return "New Review for " + accommodationName; + } +} diff --git a/src/main/java/com/devoops/notification/dto/message/HostRatedMessage.java b/src/main/java/com/devoops/notification/dto/message/HostRatedMessage.java new file mode 100644 index 0000000..4785c5b --- /dev/null +++ b/src/main/java/com/devoops/notification/dto/message/HostRatedMessage.java @@ -0,0 +1,30 @@ +package com.devoops.notification.dto.message; + +import com.devoops.notification.entity.NotificationType; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@SuperBuilder +public class HostRatedMessage extends NotificationMessage { + + private String guestName; + private Integer rating; + private String comment; + + @Override + public NotificationType getType() { + return NotificationType.HOST_RATED; + } + + @Override + public String getSubject() { + return "You've Received a New Rating!"; + } +} diff --git a/src/main/java/com/devoops/notification/dto/message/NotificationMessage.java b/src/main/java/com/devoops/notification/dto/message/NotificationMessage.java new file mode 100644 index 0000000..e1d3dba --- /dev/null +++ b/src/main/java/com/devoops/notification/dto/message/NotificationMessage.java @@ -0,0 +1,26 @@ +package com.devoops.notification.dto.message; + +import com.devoops.notification.entity.NotificationType; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; + +import java.io.Serializable; +import java.util.UUID; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@SuperBuilder +public abstract class NotificationMessage implements Serializable { + + private UUID userId; + private String userEmail; + + public abstract NotificationType getType(); + + public abstract String getSubject(); +} diff --git a/src/main/java/com/devoops/notification/dto/message/ReservationCancelledMessage.java b/src/main/java/com/devoops/notification/dto/message/ReservationCancelledMessage.java new file mode 100644 index 0000000..f2a6dbb --- /dev/null +++ b/src/main/java/com/devoops/notification/dto/message/ReservationCancelledMessage.java @@ -0,0 +1,34 @@ +package com.devoops.notification.dto.message; + +import com.devoops.notification.entity.NotificationType; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDate; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@SuperBuilder +public class ReservationCancelledMessage extends NotificationMessage { + + private String guestName; + private String accommodationName; + private LocalDate checkIn; + private LocalDate checkOut; + private String reason; + + @Override + public NotificationType getType() { + return NotificationType.RESERVATION_CANCELLED; + } + + @Override + public String getSubject() { + return "Reservation Cancelled - " + accommodationName; + } +} diff --git a/src/main/java/com/devoops/notification/dto/message/ReservationRequestCreatedMessage.java b/src/main/java/com/devoops/notification/dto/message/ReservationRequestCreatedMessage.java new file mode 100644 index 0000000..025a8c3 --- /dev/null +++ b/src/main/java/com/devoops/notification/dto/message/ReservationRequestCreatedMessage.java @@ -0,0 +1,35 @@ +package com.devoops.notification.dto.message; + +import com.devoops.notification.entity.NotificationType; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; + +import java.math.BigDecimal; +import java.time.LocalDate; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@SuperBuilder +public class ReservationRequestCreatedMessage extends NotificationMessage { + + private String guestName; + private String accommodationName; + private LocalDate checkIn; + private LocalDate checkOut; + private BigDecimal totalPrice; + + @Override + public NotificationType getType() { + return NotificationType.RESERVATION_REQUEST_CREATED; + } + + @Override + public String getSubject() { + return "New Reservation Request for " + accommodationName; + } +} diff --git a/src/main/java/com/devoops/notification/dto/message/ReservationResponseMessage.java b/src/main/java/com/devoops/notification/dto/message/ReservationResponseMessage.java new file mode 100644 index 0000000..e39729e --- /dev/null +++ b/src/main/java/com/devoops/notification/dto/message/ReservationResponseMessage.java @@ -0,0 +1,41 @@ +package com.devoops.notification.dto.message; + +import com.devoops.notification.entity.NotificationType; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; + +import java.time.LocalDate; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@SuperBuilder +public class ReservationResponseMessage extends NotificationMessage { + + private String hostName; + private String accommodationName; + private ReservationStatus status; + private LocalDate checkIn; + private LocalDate checkOut; + + public enum ReservationStatus { + APPROVED, + DECLINED + } + + @Override + public NotificationType getType() { + return NotificationType.RESERVATION_RESPONSE; + } + + @Override + public String getSubject() { + return status == ReservationStatus.APPROVED + ? "Your Reservation is Confirmed!" + : "Reservation Request Update"; + } +} diff --git a/src/main/java/com/devoops/notification/dto/message/UserCreatedMessage.java b/src/main/java/com/devoops/notification/dto/message/UserCreatedMessage.java new file mode 100644 index 0000000..ed9a6a1 --- /dev/null +++ b/src/main/java/com/devoops/notification/dto/message/UserCreatedMessage.java @@ -0,0 +1,21 @@ +package com.devoops.notification.dto.message; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.io.Serializable; +import java.util.UUID; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class UserCreatedMessage implements Serializable { + + private UUID userId; + private String userEmail; +} diff --git a/src/main/java/com/devoops/notification/dto/request/NotificationPreferencesUpdateRequest.java b/src/main/java/com/devoops/notification/dto/request/NotificationPreferencesUpdateRequest.java new file mode 100644 index 0000000..d9b1102 --- /dev/null +++ b/src/main/java/com/devoops/notification/dto/request/NotificationPreferencesUpdateRequest.java @@ -0,0 +1,10 @@ +package com.devoops.notification.dto.request; + +public record NotificationPreferencesUpdateRequest( + Boolean reservationRequestCreated, + Boolean reservationCancelled, + Boolean hostRated, + Boolean accommodationRated, + Boolean reservationResponse +) { +} diff --git a/src/main/java/com/devoops/notification/dto/response/NotificationPreferencesResponse.java b/src/main/java/com/devoops/notification/dto/response/NotificationPreferencesResponse.java new file mode 100644 index 0000000..c8b357c --- /dev/null +++ b/src/main/java/com/devoops/notification/dto/response/NotificationPreferencesResponse.java @@ -0,0 +1,14 @@ +package com.devoops.notification.dto.response; + +import java.util.UUID; + +public record NotificationPreferencesResponse( + UUID userId, + String userEmail, + boolean reservationRequestCreated, + boolean reservationCancelled, + boolean hostRated, + boolean accommodationRated, + boolean reservationResponse +) { +} diff --git a/src/main/java/com/devoops/notification/entity/BaseDocument.java b/src/main/java/com/devoops/notification/entity/BaseDocument.java new file mode 100644 index 0000000..58c53e8 --- /dev/null +++ b/src/main/java/com/devoops/notification/entity/BaseDocument.java @@ -0,0 +1,31 @@ +package com.devoops.notification.entity; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.Id; +import org.springframework.data.annotation.LastModifiedDate; + +import java.time.LocalDateTime; + +@Getter +@Setter +@NoArgsConstructor +@SuperBuilder +public abstract class BaseDocument { + + @Id + private String id; + + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + private LocalDateTime updatedAt; + + @Builder.Default + private boolean isDeleted = false; +} diff --git a/src/main/java/com/devoops/notification/entity/NotificationPreferences.java b/src/main/java/com/devoops/notification/entity/NotificationPreferences.java new file mode 100644 index 0000000..82fa0af --- /dev/null +++ b/src/main/java/com/devoops/notification/entity/NotificationPreferences.java @@ -0,0 +1,33 @@ +package com.devoops.notification.entity; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import org.springframework.data.mongodb.core.index.Indexed; +import org.springframework.data.mongodb.core.mapping.Document; + +import java.util.UUID; + +@Getter +@Setter +@NoArgsConstructor +@SuperBuilder +@Document(collection = "notification_preferences") +public class NotificationPreferences extends BaseDocument { + + @Indexed(unique = true) + private UUID userId; + + private String userEmail; + + @Builder.Default + private Preferences preferences = new Preferences(); + + public NotificationPreferences(UUID userId, String userEmail) { + this.userId = userId; + this.userEmail = userEmail; + this.preferences = new Preferences(); + } +} diff --git a/src/main/java/com/devoops/notification/entity/NotificationType.java b/src/main/java/com/devoops/notification/entity/NotificationType.java new file mode 100644 index 0000000..1128dfc --- /dev/null +++ b/src/main/java/com/devoops/notification/entity/NotificationType.java @@ -0,0 +1,35 @@ +package com.devoops.notification.entity; + +public enum NotificationType { + RESERVATION_REQUEST_CREATED("reservationRequestCreated", "New Reservation Request"), + RESERVATION_CANCELLED("reservationCancelled", "Reservation Cancelled"), + HOST_RATED("hostRated", "You've Been Rated"), + ACCOMMODATION_RATED("accommodationRated", "Accommodation Review Received"), + RESERVATION_RESPONSE("reservationResponse", "Reservation Update"); + + private final String preferenceKey; + private final String emailSubject; + + NotificationType(String preferenceKey, String emailSubject) { + this.preferenceKey = preferenceKey; + this.emailSubject = emailSubject; + } + + public String getPreferenceKey() { + return preferenceKey; + } + + public String getEmailSubject() { + return emailSubject; + } + + public String getTemplateName() { + return switch (this) { + case RESERVATION_REQUEST_CREATED -> "reservation-request-created"; + case RESERVATION_CANCELLED -> "reservation-cancelled"; + case HOST_RATED -> "host-rated"; + case ACCOMMODATION_RATED -> "accommodation-rated"; + case RESERVATION_RESPONSE -> "reservation-response"; + }; + } +} diff --git a/src/main/java/com/devoops/notification/entity/Preferences.java b/src/main/java/com/devoops/notification/entity/Preferences.java new file mode 100644 index 0000000..8629cb0 --- /dev/null +++ b/src/main/java/com/devoops/notification/entity/Preferences.java @@ -0,0 +1,29 @@ +package com.devoops.notification.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class Preferences { + + private boolean reservationRequestCreated = true; + private boolean reservationCancelled = true; + private boolean hostRated = true; + private boolean accommodationRated = true; + private boolean reservationResponse = true; + + public boolean isEnabled(NotificationType type) { + return switch (type) { + case RESERVATION_REQUEST_CREATED -> reservationRequestCreated; + case RESERVATION_CANCELLED -> reservationCancelled; + case HOST_RATED -> hostRated; + case ACCOMMODATION_RATED -> accommodationRated; + case RESERVATION_RESPONSE -> reservationResponse; + }; + } +} diff --git a/src/main/java/com/devoops/notification/exception/ForbiddenException.java b/src/main/java/com/devoops/notification/exception/ForbiddenException.java new file mode 100644 index 0000000..d7b4837 --- /dev/null +++ b/src/main/java/com/devoops/notification/exception/ForbiddenException.java @@ -0,0 +1,8 @@ +package com.devoops.notification.exception; + +public class ForbiddenException extends RuntimeException { + + public ForbiddenException(String message) { + super(message); + } +} diff --git a/src/main/java/com/devoops/notification/exception/GlobalExceptionHandler.java b/src/main/java/com/devoops/notification/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..a9b8085 --- /dev/null +++ b/src/main/java/com/devoops/notification/exception/GlobalExceptionHandler.java @@ -0,0 +1,86 @@ +package com.devoops.notification.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestHeaderException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.LocalDateTime; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(PreferencesNotFoundException.class) + public ProblemDetail handlePreferencesNotFound(PreferencesNotFoundException ex) { + log.warn("Preferences not found: {}", ex.getMessage()); + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); + problemDetail.setTitle("Preferences Not Found"); + problemDetail.setProperty("timestamp", LocalDateTime.now()); + return problemDetail; + } + + @ExceptionHandler(UnauthorizedException.class) + public ProblemDetail handleUnauthorized(UnauthorizedException ex) { + log.warn("Unauthorized access: {}", ex.getMessage()); + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage()); + problemDetail.setTitle("Unauthorized"); + problemDetail.setProperty("timestamp", LocalDateTime.now()); + return problemDetail; + } + + @ExceptionHandler(ForbiddenException.class) + public ProblemDetail handleForbidden(ForbiddenException ex) { + log.warn("Forbidden access: {}", ex.getMessage()); + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, ex.getMessage()); + problemDetail.setTitle("Forbidden"); + problemDetail.setProperty("timestamp", LocalDateTime.now()); + return problemDetail; + } + + @ExceptionHandler(MissingRequestHeaderException.class) + public ProblemDetail handleMissingHeader(MissingRequestHeaderException ex) { + log.warn("Missing required header: {}", ex.getHeaderName()); + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( + HttpStatus.BAD_REQUEST, + "Missing required header: " + ex.getHeaderName() + ); + problemDetail.setTitle("Bad Request"); + problemDetail.setProperty("timestamp", LocalDateTime.now()); + return problemDetail; + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ProblemDetail handleValidationErrors(MethodArgumentNotValidException ex) { + Map fieldErrors = ex.getBindingResult().getFieldErrors().stream() + .collect(Collectors.toMap( + FieldError::getField, + fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid value" + )); + log.warn("Validation failed: {}", fieldErrors); + + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed"); + problemDetail.setTitle("Validation Failed"); + problemDetail.setProperty("fieldErrors", fieldErrors); + problemDetail.setProperty("timestamp", LocalDateTime.now()); + return problemDetail; + } + + @ExceptionHandler(Exception.class) + public ProblemDetail handleGenericException(Exception ex) { + log.error("Unexpected error occurred", ex); + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail( + HttpStatus.INTERNAL_SERVER_ERROR, + "An unexpected error occurred" + ); + problemDetail.setTitle("Internal Server Error"); + problemDetail.setProperty("timestamp", LocalDateTime.now()); + return problemDetail; + } +} diff --git a/src/main/java/com/devoops/notification/exception/PreferencesNotFoundException.java b/src/main/java/com/devoops/notification/exception/PreferencesNotFoundException.java new file mode 100644 index 0000000..ffd03f7 --- /dev/null +++ b/src/main/java/com/devoops/notification/exception/PreferencesNotFoundException.java @@ -0,0 +1,10 @@ +package com.devoops.notification.exception; + +import java.util.UUID; + +public class PreferencesNotFoundException extends RuntimeException { + + public PreferencesNotFoundException(UUID userId) { + super("Notification preferences not found for user: " + userId); + } +} diff --git a/src/main/java/com/devoops/notification/exception/UnauthorizedException.java b/src/main/java/com/devoops/notification/exception/UnauthorizedException.java new file mode 100644 index 0000000..e67964b --- /dev/null +++ b/src/main/java/com/devoops/notification/exception/UnauthorizedException.java @@ -0,0 +1,8 @@ +package com.devoops.notification.exception; + +public class UnauthorizedException extends RuntimeException { + + public UnauthorizedException(String message) { + super(message); + } +} diff --git a/src/main/java/com/devoops/notification/mapper/NotificationPreferencesMapper.java b/src/main/java/com/devoops/notification/mapper/NotificationPreferencesMapper.java new file mode 100644 index 0000000..920ce63 --- /dev/null +++ b/src/main/java/com/devoops/notification/mapper/NotificationPreferencesMapper.java @@ -0,0 +1,17 @@ +package com.devoops.notification.mapper; + +import com.devoops.notification.dto.response.NotificationPreferencesResponse; +import com.devoops.notification.entity.NotificationPreferences; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +@Mapper(componentModel = "spring") +public interface NotificationPreferencesMapper { + + @Mapping(target = "reservationRequestCreated", source = "preferences.reservationRequestCreated") + @Mapping(target = "reservationCancelled", source = "preferences.reservationCancelled") + @Mapping(target = "hostRated", source = "preferences.hostRated") + @Mapping(target = "accommodationRated", source = "preferences.accommodationRated") + @Mapping(target = "reservationResponse", source = "preferences.reservationResponse") + NotificationPreferencesResponse toResponse(NotificationPreferences entity); +} diff --git a/src/main/java/com/devoops/notification/repository/NotificationPreferencesRepository.java b/src/main/java/com/devoops/notification/repository/NotificationPreferencesRepository.java new file mode 100644 index 0000000..a25e629 --- /dev/null +++ b/src/main/java/com/devoops/notification/repository/NotificationPreferencesRepository.java @@ -0,0 +1,16 @@ +package com.devoops.notification.repository; + +import com.devoops.notification.entity.NotificationPreferences; +import org.springframework.data.mongodb.repository.MongoRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +import java.util.UUID; + +@Repository +public interface NotificationPreferencesRepository extends MongoRepository { + + Optional findByUserId(UUID userId); + + boolean existsByUserId(UUID userId); +} diff --git a/src/main/java/com/devoops/notification/service/EmailService.java b/src/main/java/com/devoops/notification/service/EmailService.java new file mode 100644 index 0000000..585f5ec --- /dev/null +++ b/src/main/java/com/devoops/notification/service/EmailService.java @@ -0,0 +1,106 @@ +package com.devoops.notification.service; + +import com.devoops.notification.dto.message.*; +import com.devoops.notification.entity.NotificationType; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.stereotype.Service; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +@Slf4j +@Service +@RequiredArgsConstructor +public class EmailService { + + private final JavaMailSender mailSender; + private final TemplateEngine templateEngine; + + @Value("${notification.email.from}") + private String fromEmail; + + @Value("${notification.email.from-name}") + private String fromName; + + @Value("${notification.frontend.url}") + private String frontendUrl; + + public void sendNotificationEmail(NotificationMessage message) { + try { + String subject = message.getSubject(); + String templateName = "email/" + message.getType().getTemplateName(); + + Context context = createContext(message); + String htmlContent = templateEngine.process(templateName, context); + + MimeMessage mimeMessage = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(mimeMessage, true, "UTF-8"); + + helper.setFrom(fromEmail, fromName); + helper.setTo(message.getUserEmail()); + helper.setSubject(subject); + helper.setText(htmlContent, true); + + mailSender.send(mimeMessage); + log.info("Email sent successfully to {} for notification type {}", + message.getUserEmail(), message.getType()); + + } catch (MessagingException e) { + log.error("Failed to send email to {} for notification type {}: {}", + message.getUserEmail(), message.getType(), e.getMessage()); + throw new RuntimeException("Failed to send email", e); + } catch (Exception e) { + log.error("Unexpected error sending email to {}: {}", message.getUserEmail(), e.getMessage()); + throw new RuntimeException("Failed to send email", e); + } + } + + private Context createContext(NotificationMessage message) { + Context context = new Context(); + context.setVariable("frontendUrl", frontendUrl); + context.setVariable("subject", message.getSubject()); + + switch (message) { + case ReservationRequestCreatedMessage msg -> { + context.setVariable("guestName", msg.getGuestName()); + context.setVariable("accommodationName", msg.getAccommodationName()); + context.setVariable("checkIn", msg.getCheckIn()); + context.setVariable("checkOut", msg.getCheckOut()); + context.setVariable("totalPrice", msg.getTotalPrice()); + } + case ReservationCancelledMessage msg -> { + context.setVariable("guestName", msg.getGuestName()); + context.setVariable("accommodationName", msg.getAccommodationName()); + context.setVariable("checkIn", msg.getCheckIn()); + context.setVariable("checkOut", msg.getCheckOut()); + context.setVariable("reason", msg.getReason()); + } + case HostRatedMessage msg -> { + context.setVariable("guestName", msg.getGuestName()); + context.setVariable("rating", msg.getRating()); + context.setVariable("comment", msg.getComment()); + } + case AccommodationRatedMessage msg -> { + context.setVariable("guestName", msg.getGuestName()); + context.setVariable("accommodationName", msg.getAccommodationName()); + context.setVariable("rating", msg.getRating()); + context.setVariable("comment", msg.getComment()); + } + case ReservationResponseMessage msg -> { + context.setVariable("hostName", msg.getHostName()); + context.setVariable("accommodationName", msg.getAccommodationName()); + context.setVariable("status", msg.getStatus().name()); + context.setVariable("checkIn", msg.getCheckIn()); + context.setVariable("checkOut", msg.getCheckOut()); + } + default -> throw new IllegalArgumentException("Unknown message type: " + message.getClass()); + } + + return context; + } +} diff --git a/src/main/java/com/devoops/notification/service/NotificationConsumerService.java b/src/main/java/com/devoops/notification/service/NotificationConsumerService.java new file mode 100644 index 0000000..f311569 --- /dev/null +++ b/src/main/java/com/devoops/notification/service/NotificationConsumerService.java @@ -0,0 +1,45 @@ +package com.devoops.notification.service; + +import com.devoops.notification.dto.message.*; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationConsumerService { + + private final NotificationService notificationService; + + @RabbitListener(queues = "${rabbitmq.queue.reservation-created}") + public void handleReservationCreated(ReservationRequestCreatedMessage message) { + log.info("Received reservation created notification for user {}", message.getUserId()); + notificationService.processNotification(message); + } + + @RabbitListener(queues = "${rabbitmq.queue.reservation-cancelled}") + public void handleReservationCancelled(ReservationCancelledMessage message) { + log.info("Received reservation cancelled notification for user {}", message.getUserId()); + notificationService.processNotification(message); + } + + @RabbitListener(queues = "${rabbitmq.queue.host-rated}") + public void handleHostRated(HostRatedMessage message) { + log.info("Received host rated notification for user {}", message.getUserId()); + notificationService.processNotification(message); + } + + @RabbitListener(queues = "${rabbitmq.queue.accommodation-rated}") + public void handleAccommodationRated(AccommodationRatedMessage message) { + log.info("Received accommodation rated notification for user {}", message.getUserId()); + notificationService.processNotification(message); + } + + @RabbitListener(queues = "${rabbitmq.queue.reservation-response}") + public void handleReservationResponse(ReservationResponseMessage message) { + log.info("Received reservation response notification for user {}", message.getUserId()); + notificationService.processNotification(message); + } +} diff --git a/src/main/java/com/devoops/notification/service/NotificationPreferencesService.java b/src/main/java/com/devoops/notification/service/NotificationPreferencesService.java new file mode 100644 index 0000000..12b4524 --- /dev/null +++ b/src/main/java/com/devoops/notification/service/NotificationPreferencesService.java @@ -0,0 +1,85 @@ +package com.devoops.notification.service; + +import com.devoops.notification.dto.request.NotificationPreferencesUpdateRequest; +import com.devoops.notification.dto.response.NotificationPreferencesResponse; +import com.devoops.notification.entity.NotificationPreferences; +import com.devoops.notification.entity.NotificationType; +import com.devoops.notification.entity.Preferences; +import com.devoops.notification.exception.PreferencesNotFoundException; +import com.devoops.notification.mapper.NotificationPreferencesMapper; +import com.devoops.notification.repository.NotificationPreferencesRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationPreferencesService { + + private final NotificationPreferencesRepository repository; + private final NotificationPreferencesMapper mapper; + + public NotificationPreferencesResponse getPreferences(UUID userId) { + NotificationPreferences preferences = findByUserIdOrThrow(userId); + return mapper.toResponse(preferences); + } + + public NotificationPreferencesResponse initializePreferences(UUID userId, String userEmail) { + if (repository.existsByUserId(userId)) { + log.info("Preferences already exist for user {}, returning existing", userId); + return getPreferences(userId); + } + + NotificationPreferences preferences = new NotificationPreferences(userId, userEmail); + NotificationPreferences saved = repository.save(preferences); + log.info("Initialized notification preferences for user {}", userId); + return mapper.toResponse(saved); + } + + public NotificationPreferencesResponse updatePreferences(UUID userId, NotificationPreferencesUpdateRequest request) { + NotificationPreferences preferences = findByUserIdOrThrow(userId); + Preferences prefs = preferences.getPreferences(); + + if (request.reservationRequestCreated() != null) { + prefs.setReservationRequestCreated(request.reservationRequestCreated()); + } + if (request.reservationCancelled() != null) { + prefs.setReservationCancelled(request.reservationCancelled()); + } + if (request.hostRated() != null) { + prefs.setHostRated(request.hostRated()); + } + if (request.accommodationRated() != null) { + prefs.setAccommodationRated(request.accommodationRated()); + } + if (request.reservationResponse() != null) { + prefs.setReservationResponse(request.reservationResponse()); + } + + NotificationPreferences saved = repository.save(preferences); + log.info("Updated notification preferences for user {}", userId); + return mapper.toResponse(saved); + } + + public boolean isNotificationEnabled(UUID userId, NotificationType type) { + return repository.findByUserId(userId) + .map(prefs -> prefs.getPreferences().isEnabled(type)) + .orElse(false); + } + + public NotificationPreferences getOrCreatePreferences(UUID userId, String userEmail) { + return repository.findByUserId(userId) + .orElseGet(() -> { + NotificationPreferences newPrefs = new NotificationPreferences(userId, userEmail); + return repository.save(newPrefs); + }); + } + + private NotificationPreferences findByUserIdOrThrow(UUID userId) { + return repository.findByUserId(userId) + .orElseThrow(() -> new PreferencesNotFoundException(userId)); + } +} diff --git a/src/main/java/com/devoops/notification/service/NotificationService.java b/src/main/java/com/devoops/notification/service/NotificationService.java new file mode 100644 index 0000000..b99d9e6 --- /dev/null +++ b/src/main/java/com/devoops/notification/service/NotificationService.java @@ -0,0 +1,37 @@ +package com.devoops.notification.service; + +import com.devoops.notification.dto.message.NotificationMessage; +import com.devoops.notification.entity.NotificationPreferences; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NotificationService { + + private final NotificationPreferencesService preferencesService; + private final EmailService emailService; + + public void processNotification(NotificationMessage message) { + log.debug("Processing notification for user {}: type={}", + message.getUserId(), message.getType()); + + NotificationPreferences preferences = preferencesService.getOrCreatePreferences( + message.getUserId(), + message.getUserEmail() + ); + + if (!preferences.getPreferences().isEnabled(message.getType())) { + log.info("Notification type {} is disabled for user {}, skipping email", + message.getType(), message.getUserId()); + return; + } + + emailService.sendNotificationEmail(message); + + log.info("Notification processed successfully for user {}: type={}", + message.getUserId(), message.getType()); + } +} diff --git a/src/main/java/com/devoops/notification/service/UserEventConsumerService.java b/src/main/java/com/devoops/notification/service/UserEventConsumerService.java new file mode 100644 index 0000000..913eb76 --- /dev/null +++ b/src/main/java/com/devoops/notification/service/UserEventConsumerService.java @@ -0,0 +1,22 @@ +package com.devoops.notification.service; + +import com.devoops.notification.dto.message.UserCreatedMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.amqp.rabbit.annotation.RabbitListener; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserEventConsumerService { + + private final NotificationPreferencesService preferencesService; + + @RabbitListener(queues = "${rabbitmq.queue.user-created}") + public void handleUserCreated(UserCreatedMessage message) { + log.info("Received user created event for user {}", message.getUserId()); + preferencesService.initializePreferences(message.getUserId(), message.getUserEmail()); + log.info("Notification preferences initialized for user {}", message.getUserId()); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index c988ee9..a902863 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,14 +1,79 @@ +# Application spring.application.name=notification server.port=${SERVER_PORT:8080} -# Logging configuration + +# MongoDB Configuration +spring.mongodb.host=${MONGODB_HOST:localhost} +spring.mongodb.port=${MONGODB_PORT:27017} +spring.mongodb.database=notification_db +spring.mongodb.username=${MONGODB_USERNAME:devoops} +spring.mongodb.password=${MONGODB_PASSWORD:devoops} +spring.mongodb.authentication-database=admin +spring.mongodb.representation.uuid=standard + +# RabbitMQ Configuration +spring.rabbitmq.host=${RABBITMQ_HOST:localhost} +spring.rabbitmq.port=${RABBITMQ_PORT:5672} +spring.rabbitmq.username=${RABBITMQ_USERNAME:devoops} +spring.rabbitmq.password=${RABBITMQ_PASSWORD:devoops123} +spring.rabbitmq.virtual-host=${RABBITMQ_VHOST:/} + +# RabbitMQ Queue Names +rabbitmq.exchange.notification=notification.exchange +rabbitmq.exchange.dlx=notification.dlx +rabbitmq.queue.dlq=notification.dlq +rabbitmq.queue.reservation-created=notification.reservation.created.queue +rabbitmq.queue.reservation-cancelled=notification.reservation.cancelled.queue +rabbitmq.queue.host-rated=notification.rating.host.queue +rabbitmq.queue.accommodation-rated=notification.rating.accommodation.queue +rabbitmq.queue.reservation-response=notification.reservation.response.queue +rabbitmq.queue.user-created=user.created.queue + +# RabbitMQ Retry Configuration +spring.rabbitmq.listener.simple.retry.enabled=true +spring.rabbitmq.listener.simple.retry.initial-interval=1000 +spring.rabbitmq.listener.simple.retry.max-attempts=3 +spring.rabbitmq.listener.simple.retry.max-interval=10000 +spring.rabbitmq.listener.simple.retry.multiplier=2.0 + +# Email Configuration (SMTP) +spring.mail.host=${MAIL_HOST:localhost} +spring.mail.port=${MAIL_PORT:1025} +spring.mail.username=${MAIL_USERNAME:} +spring.mail.password=${MAIL_PASSWORD:} +spring.mail.properties.mail.smtp.auth=false +spring.mail.properties.mail.smtp.starttls.enable=false +spring.mail.properties.mail.smtp.connectiontimeout=5000 +spring.mail.properties.mail.smtp.timeout=5000 +spring.mail.properties.mail.smtp.writetimeout=5000 + +# Email Sender Configuration +notification.email.from=${MAIL_FROM:noreply@devoops.com} +notification.email.from-name=${MAIL_FROM_NAME:DevOops} + +# Frontend URL (for email links) +notification.frontend.url=${FRONTEND_URL:http://localhost:4200} + +# Thymeleaf +spring.thymeleaf.cache=false +spring.thymeleaf.prefix=classpath:/templates/ +spring.thymeleaf.suffix=.html +spring.thymeleaf.mode=HTML + +# Tracing +management.tracing.sampling.probability=1.0 +management.tracing.export.zipkin.endpoint=http://${ZIPKIN_HOST:zipkin}:${ZIPKIN_PORT:9411}/api/v2/spans + +# Logging 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 +logging.level.org.springframework.amqp=DEBUG -# Actuator endpoints for Prometheus metrics +# Actuator management.endpoints.web.exposure.include=health,prometheus,metrics management.endpoint.health.show-details=always management.prometheus.metrics.export.enabled=true +management.health.rabbit.enabled=true +management.health.mongo.enabled=true diff --git a/src/main/resources/templates/email/accommodation-rated.html b/src/main/resources/templates/email/accommodation-rated.html new file mode 100644 index 0000000..73e8400 --- /dev/null +++ b/src/main/resources/templates/email/accommodation-rated.html @@ -0,0 +1,58 @@ + + + + + + Accommodation Review Received + + + +
+
+

DevOops

+

Accommodation Booking Platform

+
+ +
+

New Review for Your Accommodation!

+

Hello,

+

Your accommodation has received a new review:

+ +
+

Cozy Studio

+

Reviewed by John Doe

+ +
+
+ + +
+

+ 4.0 / 5 +

+
+ +

+ "Nice place" +

+
+ +

+ + View Accommodation + +

+
+ +
+

+ This email was sent by DevOops. You can manage your notification preferences in your profile settings. +

+

+ © 2024 DevOops. All rights reserved. +

+
+
+ + diff --git a/src/main/resources/templates/email/host-rated.html b/src/main/resources/templates/email/host-rated.html new file mode 100644 index 0000000..fe98a0d --- /dev/null +++ b/src/main/resources/templates/email/host-rated.html @@ -0,0 +1,53 @@ + + + + + + You've Been Rated + + + +
+
+

DevOops

+

Accommodation Booking Platform

+
+ +
+

You've Received a New Rating!

+

Hello,

+

A guest has left you a rating:

+ +
+

John Doe

+
+ + +
+

+ 4.5 / 5 +

+

+ "Great host!" +

+
+ +

+ + View My Profile + +

+
+ +
+

+ This email was sent by DevOops. You can manage your notification preferences in your profile settings. +

+

+ © 2024 DevOops. All rights reserved. +

+
+
+ + diff --git a/src/main/resources/templates/email/reservation-cancelled.html b/src/main/resources/templates/email/reservation-cancelled.html new file mode 100644 index 0000000..f999698 --- /dev/null +++ b/src/main/resources/templates/email/reservation-cancelled.html @@ -0,0 +1,66 @@ + + + + + + Reservation Cancelled + + + +
+
+

DevOops

+

Accommodation Booking Platform

+
+ +
+

Reservation Cancelled

+

Hello,

+

A reservation for your accommodation has been cancelled:

+ + + + + + + + + + + + + + + + + + + + + + +
GuestJohn Doe
AccommodationCozy Studio
Check-in2024-03-15
Check-out2024-03-20
ReasonGuest cancelled
+ +

+ The dates are now available for other guests to book. +

+ +

+ + View My Accommodations + +

+
+ +
+

+ This email was sent by DevOops. You can manage your notification preferences in your profile settings. +

+

+ © 2024 DevOops. All rights reserved. +

+
+
+ + diff --git a/src/main/resources/templates/email/reservation-request-created.html b/src/main/resources/templates/email/reservation-request-created.html new file mode 100644 index 0000000..f78cf01 --- /dev/null +++ b/src/main/resources/templates/email/reservation-request-created.html @@ -0,0 +1,62 @@ + + + + + + New Reservation Request + + + +
+
+

DevOops

+

Accommodation Booking Platform

+
+ +
+

New Reservation Request!

+

Hello,

+

You have received a new reservation request for your accommodation:

+ + + + + + + + + + + + + + + + + + + + + + +
GuestJohn Doe
AccommodationCozy Studio
Check-in2024-03-15
Check-out2024-03-20
Total Price$450.00
+ +

+ + View Reservation Request + +

+
+ +
+

+ This email was sent by DevOops. You can manage your notification preferences in your profile settings. +

+

+ © 2024 DevOops. All rights reserved. +

+
+
+ + diff --git a/src/main/resources/templates/email/reservation-response.html b/src/main/resources/templates/email/reservation-response.html new file mode 100644 index 0000000..993d2a2 --- /dev/null +++ b/src/main/resources/templates/email/reservation-response.html @@ -0,0 +1,89 @@ + + + + + + Reservation Update + + + +
+
+

DevOops

+

Accommodation Booking Platform

+
+ +
+

+ Your Reservation is Confirmed! +

+

+ Reservation Request Declined +

+ +

Hello,

+

+ Great news! Your reservation request has been approved by the host. +

+

+ Unfortunately, your reservation request was not approved by the host. +

+ + + + + + + + + + + + + + + + + + + + + + + +
HostJane Smith
AccommodationCozy Studio
Check-in2024-03-15
Check-out2024-03-20
Status + Confirmed + + Declined +
+ +

+ Get ready for your trip! You can view all details of your reservation in your account. +

+

+ Don't worry, there are plenty of other great accommodations available. Try searching for alternatives. +

+ +

+ + View My Reservations + + + Search Accommodations + +

+
+ +
+

+ This email was sent by DevOops. You can manage your notification preferences in your profile settings. +

+

+ © 2024 DevOops. All rights reserved. +

+
+
+ + From a4578173e7f83e91abfd366cefa47c2cb7496fa4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Milo=C5=A1=20=C4=8Cuturi=C4=87?= Date: Sun, 15 Feb 2026 15:22:58 +0100 Subject: [PATCH 2/2] feat: Add unit and integration tests --- build.gradle.kts | 4 + .../notification/config/MongoConfig.java | 35 -- .../notification/config/RabbitMQConfig.java | 10 +- .../NotificationApplicationTests.java | 13 +- .../config/TestContainersConfig.java | 24 ++ ...NotificationPreferencesControllerTest.java | 180 +++++++++ .../controller/TestControllerTest.java | 127 ++++++ .../integration/BaseIntegrationTest.java | 118 ++++++ .../EndToEndNotificationFlowTest.java | 363 ++++++++++++++++++ ...otificationPreferencesIntegrationTest.java | 253 ++++++++++++ .../RabbitMQConsumerIntegrationTest.java | 320 +++++++++++++++ .../service/EmailServiceTest.java | 244 ++++++++++++ .../NotificationConsumerServiceTest.java | 164 ++++++++ .../NotificationPreferencesServiceTest.java | 291 ++++++++++++++ .../service/NotificationServiceTest.java | 115 ++++++ .../service/UserEventConsumerServiceTest.java | 56 +++ .../notification/util/TestDataFactory.java | 138 +++++++ .../resources/application-test.properties | 34 ++ 18 files changed, 2447 insertions(+), 42 deletions(-) create mode 100644 src/test/java/com/devoops/notification/config/TestContainersConfig.java create mode 100644 src/test/java/com/devoops/notification/controller/NotificationPreferencesControllerTest.java create mode 100644 src/test/java/com/devoops/notification/controller/TestControllerTest.java create mode 100644 src/test/java/com/devoops/notification/integration/BaseIntegrationTest.java create mode 100644 src/test/java/com/devoops/notification/integration/EndToEndNotificationFlowTest.java create mode 100644 src/test/java/com/devoops/notification/integration/NotificationPreferencesIntegrationTest.java create mode 100644 src/test/java/com/devoops/notification/integration/RabbitMQConsumerIntegrationTest.java create mode 100644 src/test/java/com/devoops/notification/service/EmailServiceTest.java create mode 100644 src/test/java/com/devoops/notification/service/NotificationConsumerServiceTest.java create mode 100644 src/test/java/com/devoops/notification/service/NotificationPreferencesServiceTest.java create mode 100644 src/test/java/com/devoops/notification/service/NotificationServiceTest.java create mode 100644 src/test/java/com/devoops/notification/service/UserEventConsumerServiceTest.java create mode 100644 src/test/java/com/devoops/notification/util/TestDataFactory.java create mode 100644 src/test/resources/application-test.properties diff --git a/build.gradle.kts b/build.gradle.kts index 03439ab..939022e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -30,6 +30,7 @@ dependencies { // RabbitMQ implementation("org.springframework.boot:spring-boot-starter-amqp") + implementation("com.fasterxml.jackson.datatype:jackson-datatype-jsr310") // Email implementation("org.springframework.boot:spring-boot-starter-mail") @@ -62,11 +63,14 @@ dependencies { // Test testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") + testImplementation("org.springframework.boot:spring-boot-testcontainers") testImplementation("org.springframework.amqp:spring-rabbit-test") testImplementation("org.testcontainers:junit-jupiter:1.20.4") testImplementation("org.testcontainers:mongodb:1.20.4") testImplementation("org.testcontainers:rabbitmq:1.20.4") testImplementation("com.icegreen:greenmail-junit5:2.0.1") + testImplementation("io.rest-assured:rest-assured:5.5.0") + testImplementation("org.awaitility:awaitility:4.2.0") testCompileOnly("org.projectlombok:lombok") testAnnotationProcessor("org.projectlombok:lombok") diff --git a/src/main/java/com/devoops/notification/config/MongoConfig.java b/src/main/java/com/devoops/notification/config/MongoConfig.java index d8bdfa9..daf609c 100644 --- a/src/main/java/com/devoops/notification/config/MongoConfig.java +++ b/src/main/java/com/devoops/notification/config/MongoConfig.java @@ -1,10 +1,5 @@ package com.devoops.notification.config; -import com.mongodb.ConnectionString; -import com.mongodb.MongoClientSettings; -import org.bson.UuidRepresentation; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.mongodb.config.EnableMongoAuditing; @@ -12,34 +7,4 @@ @EnableMongoAuditing public class MongoConfig { -// @Value("${spring.mongodb.host:localhost}") -// private String host; -// -// @Value("${spring.mongodb.port:27017}") -// private int port; -// -// @Value("${spring.mongodb.database:notification_db}") -// private String database; -// -// @Value("${spring.mongodb.username:devoops}") -// private String username; -// -// @Value("${spring.mongodb.password:devoops}") -// private String password; -// -// @Value("${spring.mongodb.authentication-database:admin}") -// private String authDatabase; -// -// @Bean -// public MongoClientSettings mongoClientSettings() { -// String connectionString = String.format( -// "mongodb://%s:%s@%s:%d/%s?authSource=%s", -// username, password, host, port, database, authDatabase -// ); -// -// return MongoClientSettings.builder() -// .applyConnectionString(new ConnectionString(connectionString)) -// .uuidRepresentation(UuidRepresentation.STANDARD) -// .build(); -// } } diff --git a/src/main/java/com/devoops/notification/config/RabbitMQConfig.java b/src/main/java/com/devoops/notification/config/RabbitMQConfig.java index 6898931..1afef4a 100644 --- a/src/main/java/com/devoops/notification/config/RabbitMQConfig.java +++ b/src/main/java/com/devoops/notification/config/RabbitMQConfig.java @@ -1,5 +1,9 @@ package com.devoops.notification.config; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.springframework.amqp.core.*; import org.springframework.amqp.rabbit.connection.ConnectionFactory; import org.springframework.amqp.rabbit.core.RabbitTemplate; @@ -41,7 +45,11 @@ public class RabbitMQConfig { @Bean public MessageConverter jsonMessageConverter() { - return new Jackson2JsonMessageConverter(); + ObjectMapper objectMapper = new ObjectMapper(); + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + return new Jackson2JsonMessageConverter(objectMapper); } @Bean diff --git a/src/test/java/com/devoops/notification/NotificationApplicationTests.java b/src/test/java/com/devoops/notification/NotificationApplicationTests.java index 0a9f8bc..443a152 100644 --- a/src/test/java/com/devoops/notification/NotificationApplicationTests.java +++ b/src/test/java/com/devoops/notification/NotificationApplicationTests.java @@ -1,13 +1,14 @@ package com.devoops.notification; +import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; -@SpringBootTest +@DisplayName("Notification Application Smoke Tests") class NotificationApplicationTests { - @Test - void contextLoads() { - } - + @Test + @DisplayName("Context loads successfully") + void contextLoads() { + // Smoke test - actual context loading is tested in integration tests + } } diff --git a/src/test/java/com/devoops/notification/config/TestContainersConfig.java b/src/test/java/com/devoops/notification/config/TestContainersConfig.java new file mode 100644 index 0000000..84dad5c --- /dev/null +++ b/src/test/java/com/devoops/notification/config/TestContainersConfig.java @@ -0,0 +1,24 @@ +package com.devoops.notification.config; + +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.context.annotation.Bean; +import org.testcontainers.containers.MongoDBContainer; +import org.testcontainers.containers.RabbitMQContainer; + +@TestConfiguration(proxyBeanMethods = false) +public class TestContainersConfig { + + @Bean + @ServiceConnection + MongoDBContainer mongoDBContainer() { + return new MongoDBContainer("mongo:7-jammy"); + } + + @Bean + @ServiceConnection + RabbitMQContainer rabbitMQContainer() { + return new RabbitMQContainer("rabbitmq:3.13-management-alpine") + .withUser("test", "test"); + } +} diff --git a/src/test/java/com/devoops/notification/controller/NotificationPreferencesControllerTest.java b/src/test/java/com/devoops/notification/controller/NotificationPreferencesControllerTest.java new file mode 100644 index 0000000..872b6ac --- /dev/null +++ b/src/test/java/com/devoops/notification/controller/NotificationPreferencesControllerTest.java @@ -0,0 +1,180 @@ +package com.devoops.notification.controller; + +import com.devoops.notification.config.UserContext; +import com.devoops.notification.config.UserContextResolver; +import com.devoops.notification.dto.request.NotificationPreferencesUpdateRequest; +import com.devoops.notification.dto.response.NotificationPreferencesResponse; +import com.devoops.notification.exception.GlobalExceptionHandler; +import com.devoops.notification.exception.PreferencesNotFoundException; +import com.devoops.notification.service.NotificationPreferencesService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("NotificationPreferencesController Tests") +class NotificationPreferencesControllerTest { + + @Mock + private NotificationPreferencesService preferencesService; + + @InjectMocks + private NotificationPreferencesController controller; + + private MockMvc mockMvc; + private ObjectMapper objectMapper; + private UUID testUserId; + private String testUserEmail; + private NotificationPreferencesResponse testResponse; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + testUserId = UUID.randomUUID(); + testUserEmail = "test@example.com"; + + testResponse = new NotificationPreferencesResponse( + testUserId, + testUserEmail, + true, true, true, true, true + ); + + // Create a custom argument resolver that provides UserContext + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new TestUserContextResolver(testUserId)) + .build(); + } + + @Nested + @DisplayName("GET /api/notification/preferences") + class GetPreferencesTests { + + @Test + @DisplayName("Should return preferences when user exists") + void getPreferences_WhenUserExists_ReturnsPreferences() throws Exception { + // Given + when(preferencesService.getPreferences(testUserId)).thenReturn(testResponse); + + // When/Then + mockMvc.perform(get("/api/notification/preferences") + .header("X-User-Id", testUserId.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.userId").value(testUserId.toString())) + .andExpect(jsonPath("$.userEmail").value(testUserEmail)) + .andExpect(jsonPath("$.reservationRequestCreated").value(true)) + .andExpect(jsonPath("$.reservationCancelled").value(true)) + .andExpect(jsonPath("$.hostRated").value(true)) + .andExpect(jsonPath("$.accommodationRated").value(true)) + .andExpect(jsonPath("$.reservationResponse").value(true)); + } + + @Test + @DisplayName("Should return 404 when user not found") + void getPreferences_WhenUserNotFound_Returns404() throws Exception { + // Given + when(preferencesService.getPreferences(testUserId)) + .thenThrow(new PreferencesNotFoundException(testUserId)); + + // When/Then + mockMvc.perform(get("/api/notification/preferences") + .header("X-User-Id", testUserId.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.title").value("Preferences Not Found")); + } + } + + @Nested + @DisplayName("PUT /api/notification/preferences") + class UpdatePreferencesTests { + + @Test + @DisplayName("Should update preferences successfully") + void updatePreferences_WithValidRequest_ReturnsUpdated() throws Exception { + // Given + NotificationPreferencesUpdateRequest request = new NotificationPreferencesUpdateRequest( + false, false, true, true, true + ); + NotificationPreferencesResponse updatedResponse = new NotificationPreferencesResponse( + testUserId, testUserEmail, false, false, true, true, true + ); + when(preferencesService.updatePreferences(eq(testUserId), any(NotificationPreferencesUpdateRequest.class))) + .thenReturn(updatedResponse); + + // When/Then + mockMvc.perform(put("/api/notification/preferences") + .header("X-User-Id", testUserId.toString()) + .header("X-User-Role", "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.reservationRequestCreated").value(false)) + .andExpect(jsonPath("$.reservationCancelled").value(false)); + } + + @Test + @DisplayName("Should return 404 when user not found") + void updatePreferences_WhenUserNotFound_Returns404() throws Exception { + // Given + NotificationPreferencesUpdateRequest request = new NotificationPreferencesUpdateRequest( + false, false, false, false, false + ); + when(preferencesService.updatePreferences(eq(testUserId), any(NotificationPreferencesUpdateRequest.class))) + .thenThrow(new PreferencesNotFoundException(testUserId)); + + // When/Then + mockMvc.perform(put("/api/notification/preferences") + .header("X-User-Id", testUserId.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isNotFound()); + } + } + + /** + * Test helper to provide UserContext in MockMvc tests + */ + private static class TestUserContextResolver implements org.springframework.web.method.support.HandlerMethodArgumentResolver { + private final UUID userId; + + TestUserContextResolver(UUID userId) { + this.userId = userId; + } + + @Override + public boolean supportsParameter(org.springframework.core.MethodParameter parameter) { + return parameter.getParameterType().equals(UserContext.class); + } + + @Override + public Object resolveArgument(org.springframework.core.MethodParameter parameter, + org.springframework.web.method.support.ModelAndViewContainer mavContainer, + org.springframework.web.context.request.NativeWebRequest webRequest, + org.springframework.web.bind.support.WebDataBinderFactory binderFactory) { + String role = webRequest.getHeader("X-User-Role"); + return new UserContext(userId, role != null ? role : "HOST"); + } + } +} diff --git a/src/test/java/com/devoops/notification/controller/TestControllerTest.java b/src/test/java/com/devoops/notification/controller/TestControllerTest.java new file mode 100644 index 0000000..b42eb64 --- /dev/null +++ b/src/test/java/com/devoops/notification/controller/TestControllerTest.java @@ -0,0 +1,127 @@ +package com.devoops.notification.controller; + +import com.devoops.notification.entity.NotificationType; +import com.devoops.notification.exception.GlobalExceptionHandler; +import com.devoops.notification.service.NotificationService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("TestController Tests") +class TestControllerTest { + + @Mock + private NotificationService notificationService; + + @InjectMocks + private TestController controller; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .build(); + } + + @Nested + @DisplayName("POST /api/notification/test/send") + class SendTestNotificationTests { + + @Test + @DisplayName("Should send test notification with default parameters") + void sendTestNotification_WithDefaults_SendsNotification() throws Exception { + // Given + doNothing().when(notificationService).processNotification(any()); + + // When/Then + mockMvc.perform(post("/api/notification/test/send")) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("Test notification sent to test@example.com"))); + + verify(notificationService).processNotification(any()); + } + + @Test + @DisplayName("Should send test notification with custom email") + void sendTestNotification_WithCustomEmail_SendsNotification() throws Exception { + // Given + doNothing().when(notificationService).processNotification(any()); + + // When/Then + mockMvc.perform(post("/api/notification/test/send") + .param("email", "custom@example.com")) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("custom@example.com"))); + } + + @Test + @DisplayName("Should send test notification with custom type") + void sendTestNotification_WithCustomType_SendsNotification() throws Exception { + // Given + doNothing().when(notificationService).processNotification(any()); + + // When/Then + mockMvc.perform(post("/api/notification/test/send") + .param("type", "HOST_RATED")) + .andExpect(status().isOk()) + .andExpect(content().string(org.hamcrest.Matchers.containsString("HOST_RATED"))); + } + + @Test + @DisplayName("Should send all notification types") + void sendTestNotification_AllTypes_SendsNotification() throws Exception { + // Given + doNothing().when(notificationService).processNotification(any()); + + for (NotificationType type : NotificationType.values()) { + // When/Then + mockMvc.perform(post("/api/notification/test/send") + .param("type", type.name())) + .andExpect(status().isOk()); + } + } + + @Test + @DisplayName("Should return 400 for invalid notification type") + void sendTestNotification_WithInvalidType_Returns400() throws Exception { + // When/Then + mockMvc.perform(post("/api/notification/test/send") + .param("type", "INVALID_TYPE")) + .andExpect(status().isInternalServerError()); + } + } + + @Nested + @DisplayName("GET /api/notification/test/types") + class GetNotificationTypesTests { + + @Test + @DisplayName("Should return all notification types") + void getNotificationTypes_ReturnsAllTypes() throws Exception { + // When/Then + mockMvc.perform(get("/api/notification/test/types")) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$.length()").value(NotificationType.values().length)); + } + } +} diff --git a/src/test/java/com/devoops/notification/integration/BaseIntegrationTest.java b/src/test/java/com/devoops/notification/integration/BaseIntegrationTest.java new file mode 100644 index 0000000..95a16a4 --- /dev/null +++ b/src/test/java/com/devoops/notification/integration/BaseIntegrationTest.java @@ -0,0 +1,118 @@ +package com.devoops.notification.integration; + +import com.devoops.notification.config.TestContainersConfig; +import com.devoops.notification.repository.NotificationPreferencesRepository; +import com.icegreen.greenmail.configuration.GreenMailConfiguration; +import com.icegreen.greenmail.util.GreenMail; +import com.icegreen.greenmail.util.ServerSetup; +import org.junit.jupiter.api.BeforeEach; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.context.annotation.Import; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.http.client.ClientHttpResponse; +import org.springframework.web.client.DefaultResponseErrorHandler; +import org.springframework.web.client.RestTemplate; + +import java.io.IOException; +import java.net.ServerSocket; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Import(TestContainersConfig.class) +@ActiveProfiles("test") +public abstract class BaseIntegrationTest { + + private static volatile GreenMail greenMail; + private static volatile int smtpPort; + + static { + smtpPort = findAvailablePort(); + ServerSetup serverSetup = new ServerSetup(smtpPort, "localhost", ServerSetup.PROTOCOL_SMTP); + greenMail = new GreenMail(serverSetup); + greenMail.withConfiguration(GreenMailConfiguration.aConfig().withDisabledAuthentication()); + greenMail.start(); + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + if (greenMail != null) { + greenMail.stop(); + } + })); + } + + @LocalServerPort + protected int port; + + protected RestTemplate restTemplate = createRestTemplate(); + + @Autowired + protected RabbitTemplate rabbitTemplate; + + @Autowired + protected NotificationPreferencesRepository preferencesRepository; + + private static RestTemplate createRestTemplate() { + RestTemplate template = new RestTemplate(); + template.setErrorHandler(new DefaultResponseErrorHandler() { + @Override + public boolean hasError(ClientHttpResponse response) throws IOException { + return false; + } + }); + return template; + } + + @DynamicPropertySource + static void configureMailProperties(DynamicPropertyRegistry registry) { + registry.add("spring.mail.host", () -> "localhost"); + registry.add("spring.mail.port", () -> smtpPort); + System.out.println("Configured mail port: " + smtpPort); + } + + @BeforeEach + void setUp() { + preferencesRepository.deleteAll(); + greenMail.reset(); + } + + protected GreenMail getGreenMail() { + return greenMail; + } + + protected String getBaseUrl() { + return "http://localhost:" + port; + } + + protected ResponseEntity get(String path, HttpHeaders headers, Class responseType) { + HttpEntity entity = new HttpEntity<>(headers); + return restTemplate.exchange(getBaseUrl() + path, HttpMethod.GET, entity, responseType); + } + + protected ResponseEntity put(String path, Object body, HttpHeaders headers, Class responseType) { + HttpEntity entity = new HttpEntity<>(body, headers); + return restTemplate.exchange(getBaseUrl() + path, HttpMethod.PUT, entity, responseType); + } + + protected HttpHeaders createHeaders(String userId, String role) { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-User-Id", userId); + headers.set("X-User-Role", role); + headers.set("Content-Type", "application/json"); + return headers; + } + + private static int findAvailablePort() { + try (ServerSocket socket = new ServerSocket(0)) { + socket.setReuseAddress(true); + return socket.getLocalPort(); + } catch (IOException e) { + throw new RuntimeException("Could not find available port", e); + } + } +} diff --git a/src/test/java/com/devoops/notification/integration/EndToEndNotificationFlowTest.java b/src/test/java/com/devoops/notification/integration/EndToEndNotificationFlowTest.java new file mode 100644 index 0000000..56e9d9e --- /dev/null +++ b/src/test/java/com/devoops/notification/integration/EndToEndNotificationFlowTest.java @@ -0,0 +1,363 @@ +package com.devoops.notification.integration; + +import com.devoops.notification.dto.message.*; +import com.devoops.notification.dto.request.NotificationPreferencesUpdateRequest; +import com.devoops.notification.dto.response.NotificationPreferencesResponse; +import com.devoops.notification.entity.NotificationPreferences; +import com.devoops.notification.util.TestDataFactory; +import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@DisplayName("End-to-End Notification Flow Tests") +class EndToEndNotificationFlowTest extends BaseIntegrationTest { + + private static final String NOTIFICATION_EXCHANGE = "notification.exchange"; + private static final String PREFERENCES_PATH = "/api/notification/preferences"; + + @Nested + @DisplayName("Complete User Flow") + class CompleteUserFlow { + + @Test + @DisplayName("User created -> Preferences initialized -> Reservation notification -> Email sent") + void shouldCompleteUserCreationToNotificationFlow() { + UUID userId = UUID.randomUUID(); + String userEmail = "newuser@example.com"; + + UserCreatedMessage userCreatedMessage = UserCreatedMessage.builder() + .userId(userId) + .userEmail(userEmail) + .build(); + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "user.created", userCreatedMessage); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(preferencesRepository.findByUserId(userId)).isPresent() + ); + + HttpHeaders headers = createHeaders(userId.toString(), "HOST"); + ResponseEntity response = get( + PREFERENCES_PATH, + headers, + NotificationPreferencesResponse.class + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().userId()).isEqualTo(userId); + assertThat(response.getBody().userEmail()).isEqualTo(userEmail); + assertThat(response.getBody().reservationRequestCreated()).isTrue(); + + ReservationRequestCreatedMessage reservationMessage = ReservationRequestCreatedMessage.builder() + .userId(userId) + .userEmail(userEmail) + .guestName("Test Guest") + .accommodationName("Test Accommodation") + .checkIn(LocalDate.now().plusDays(7)) + .checkOut(LocalDate.now().plusDays(14)) + .totalPrice(new BigDecimal("500.00")) + .build(); + + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "notification.reservation.created", reservationMessage); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + MimeMessage[] receivedMessages = getGreenMail().getReceivedMessages(); + assertThat(receivedMessages).hasSize(1); + assertThat(receivedMessages[0].getAllRecipients()[0].toString()).isEqualTo(userEmail); + }); + } + + @Test + @DisplayName("Update preferences via API -> Verify notifications respect new settings") + void shouldRespectUpdatedPreferences() { + UUID userId = UUID.randomUUID(); + String userEmail = "updatetest@example.com"; + + preferencesRepository.save(TestDataFactory.createPreferences(userId, userEmail)); + + NotificationPreferencesUpdateRequest updateRequest = new NotificationPreferencesUpdateRequest( + false, true, true, true, true + ); + + HttpHeaders headers = createHeaders(userId.toString(), "HOST"); + ResponseEntity updateResponse = put( + PREFERENCES_PATH, + updateRequest, + headers, + NotificationPreferencesResponse.class + ); + + assertThat(updateResponse.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(updateResponse.getBody()).isNotNull(); + assertThat(updateResponse.getBody().reservationRequestCreated()).isFalse(); + + ReservationRequestCreatedMessage message = TestDataFactory.createReservationRequestCreatedMessage( + userId, userEmail + ); + + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "notification.reservation.created", message); + + await().pollDelay(1, TimeUnit.SECONDS).atMost(3, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(getGreenMail().getReceivedMessages()).isEmpty() + ); + + ReservationCancelledMessage cancelMessage = TestDataFactory.createReservationCancelledMessage( + userId, userEmail + ); + + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "notification.reservation.cancelled", cancelMessage); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + MimeMessage[] receivedMessages = getGreenMail().getReceivedMessages(); + assertThat(receivedMessages).hasSize(1); + assertThat(receivedMessages[0].getSubject()).contains("Cancelled"); + }); + } + } + + @Nested + @DisplayName("Multiple Notifications for Same User") + class MultipleNotificationsForSameUser { + + @Test + @DisplayName("Should handle multiple notification types for same user") + void shouldHandleMultipleNotificationTypesForSameUser() { + UUID userId = UUID.randomUUID(); + String userEmail = "multinotif@example.com"; + + preferencesRepository.save(TestDataFactory.createPreferences(userId, userEmail)); + + ReservationRequestCreatedMessage reservationMsg = TestDataFactory.createReservationRequestCreatedMessage( + userId, userEmail + ); + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "notification.reservation.created", reservationMsg); + + HostRatedMessage ratingMsg = TestDataFactory.createHostRatedMessage(userId, userEmail); + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "notification.rating.host", ratingMsg); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + MimeMessage[] receivedMessages = getGreenMail().getReceivedMessages(); + assertThat(receivedMessages).hasSize(2); + }); + } + + @Test + @DisplayName("Should send multiple emails sequentially") + void shouldSendMultipleEmailsSequentially() { + UUID userId = UUID.randomUUID(); + String userEmail = "sequential@example.com"; + + preferencesRepository.save(TestDataFactory.createPreferences(userId, userEmail)); + + for (int i = 0; i < 3; i++) { + ReservationRequestCreatedMessage message = ReservationRequestCreatedMessage.builder() + .userId(userId) + .userEmail(userEmail) + .guestName("Guest " + i) + .accommodationName("Accommodation " + i) + .checkIn(LocalDate.now().plusDays(7 + i)) + .checkOut(LocalDate.now().plusDays(14 + i)) + .totalPrice(new BigDecimal((100 * (i + 1)) + ".00")) + .build(); + + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "notification.reservation.created", message); + } + + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + MimeMessage[] receivedMessages = getGreenMail().getReceivedMessages(); + assertThat(receivedMessages).hasSize(3); + }); + } + } + + @Nested + @DisplayName("Concurrent Users") + class ConcurrentUsers { + + @Test + @DisplayName("Should handle notifications for multiple concurrent users") + void shouldHandleNotificationsForMultipleConcurrentUsers() { + int numberOfUsers = 5; + UUID[] userIds = new UUID[numberOfUsers]; + String[] userEmails = new String[numberOfUsers]; + + for (int i = 0; i < numberOfUsers; i++) { + userIds[i] = UUID.randomUUID(); + userEmails[i] = "user" + i + "@example.com"; + preferencesRepository.save(TestDataFactory.createPreferences(userIds[i], userEmails[i])); + } + + for (int i = 0; i < numberOfUsers; i++) { + ReservationRequestCreatedMessage message = TestDataFactory.createReservationRequestCreatedMessage( + userIds[i], userEmails[i] + ); + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "notification.reservation.created", message); + } + + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + MimeMessage[] receivedMessages = getGreenMail().getReceivedMessages(); + assertThat(receivedMessages).hasSize(numberOfUsers); + }); + + MimeMessage[] receivedMessages = getGreenMail().getReceivedMessages(); + for (int i = 0; i < numberOfUsers; i++) { + final String expectedEmail = userEmails[i]; + boolean found = false; + for (MimeMessage msg : receivedMessages) { + try { + if (msg.getAllRecipients()[0].toString().equals(expectedEmail)) { + found = true; + break; + } + } catch (Exception e) { + // ignore + } + } + assertThat(found).as("Email should be sent to " + expectedEmail).isTrue(); + } + } + + @Test + @DisplayName("Should handle mixed enabled/disabled preferences across users") + void shouldHandleMixedPreferencesAcrossUsers() { + UUID enabledUserId = UUID.randomUUID(); + UUID disabledUserId = UUID.randomUUID(); + String enabledUserEmail = "enabled@example.com"; + String disabledUserEmail = "disabled@example.com"; + + preferencesRepository.save(TestDataFactory.createPreferences(enabledUserId, enabledUserEmail)); + + NotificationPreferences disabledPrefs = TestDataFactory.createPreferencesWithAllDisabled( + disabledUserId, disabledUserEmail + ); + preferencesRepository.save(disabledPrefs); + + rabbitTemplate.convertAndSend( + NOTIFICATION_EXCHANGE, + "notification.reservation.created", + TestDataFactory.createReservationRequestCreatedMessage(enabledUserId, enabledUserEmail) + ); + + rabbitTemplate.convertAndSend( + NOTIFICATION_EXCHANGE, + "notification.reservation.created", + TestDataFactory.createReservationRequestCreatedMessage(disabledUserId, disabledUserEmail) + ); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + MimeMessage[] receivedMessages = getGreenMail().getReceivedMessages(); + assertThat(receivedMessages).hasSize(1); + assertThat(receivedMessages[0].getAllRecipients()[0].toString()).isEqualTo(enabledUserEmail); + }); + } + } + + @Nested + @DisplayName("Notification Types Coverage") + class NotificationTypesCoverage { + + @Test + @DisplayName("Should process all notification types in sequence") + void shouldProcessAllNotificationTypesInSequence() { + UUID userId = UUID.randomUUID(); + String userEmail = "alltypes@example.com"; + + preferencesRepository.save(TestDataFactory.createPreferences(userId, userEmail)); + + rabbitTemplate.convertAndSend( + NOTIFICATION_EXCHANGE, + "notification.reservation.created", + TestDataFactory.createReservationRequestCreatedMessage(userId, userEmail) + ); + + rabbitTemplate.convertAndSend( + NOTIFICATION_EXCHANGE, + "notification.reservation.cancelled", + TestDataFactory.createReservationCancelledMessage(userId, userEmail) + ); + + rabbitTemplate.convertAndSend( + NOTIFICATION_EXCHANGE, + "notification.rating.host", + TestDataFactory.createHostRatedMessage(userId, userEmail) + ); + + rabbitTemplate.convertAndSend( + NOTIFICATION_EXCHANGE, + "notification.rating.accommodation", + TestDataFactory.createAccommodationRatedMessage(userId, userEmail) + ); + + rabbitTemplate.convertAndSend( + NOTIFICATION_EXCHANGE, + "notification.reservation.response", + TestDataFactory.createReservationResponseMessage( + userId, userEmail, ReservationResponseMessage.ReservationStatus.APPROVED + ) + ); + + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + MimeMessage[] receivedMessages = getGreenMail().getReceivedMessages(); + assertThat(receivedMessages).hasSize(5); + }); + } + } + + @Nested + @DisplayName("Preferences Initialization and Update Flow") + class PreferencesInitializationAndUpdateFlow { + + @Test + @DisplayName("Should initialize preferences and allow immediate update") + void shouldInitializePreferencesAndAllowImmediateUpdate() { + UUID userId = UUID.randomUUID(); + String userEmail = "initupdate@example.com"; + + UserCreatedMessage userCreatedMessage = UserCreatedMessage.builder() + .userId(userId) + .userEmail(userEmail) + .build(); + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "user.created", userCreatedMessage); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(preferencesRepository.findByUserId(userId)).isPresent() + ); + + NotificationPreferencesUpdateRequest updateRequest = new NotificationPreferencesUpdateRequest( + false, false, false, false, false + ); + + HttpHeaders headers = createHeaders(userId.toString(), "GUEST"); + ResponseEntity response = put( + PREFERENCES_PATH, + updateRequest, + headers, + NotificationPreferencesResponse.class + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().reservationRequestCreated()).isFalse(); + assertThat(response.getBody().reservationResponse()).isFalse(); + + NotificationPreferences updated = preferencesRepository.findByUserId(userId).orElseThrow(); + assertThat(updated.getPreferences().isReservationRequestCreated()).isFalse(); + assertThat(updated.getPreferences().isReservationCancelled()).isFalse(); + assertThat(updated.getPreferences().isHostRated()).isFalse(); + assertThat(updated.getPreferences().isAccommodationRated()).isFalse(); + assertThat(updated.getPreferences().isReservationResponse()).isFalse(); + } + } +} diff --git a/src/test/java/com/devoops/notification/integration/NotificationPreferencesIntegrationTest.java b/src/test/java/com/devoops/notification/integration/NotificationPreferencesIntegrationTest.java new file mode 100644 index 0000000..101c409 --- /dev/null +++ b/src/test/java/com/devoops/notification/integration/NotificationPreferencesIntegrationTest.java @@ -0,0 +1,253 @@ +package com.devoops.notification.integration; + +import com.devoops.notification.dto.request.NotificationPreferencesUpdateRequest; +import com.devoops.notification.dto.response.NotificationPreferencesResponse; +import com.devoops.notification.entity.NotificationPreferences; +import com.devoops.notification.util.TestDataFactory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("Notification Preferences API Integration Tests") +class NotificationPreferencesIntegrationTest extends BaseIntegrationTest { + + private static final String PREFERENCES_PATH = "/api/notification/preferences"; + + @Nested + @DisplayName("GET /preferences") + class GetPreferences { + + @Test + @DisplayName("Should return preferences for HOST user") + void shouldReturnPreferencesForHost() { + preferencesRepository.save(TestDataFactory.createDefaultPreferences()); + + HttpHeaders headers = createHeaders( + TestDataFactory.DEFAULT_USER_ID.toString(), + "HOST" + ); + + ResponseEntity response = get( + PREFERENCES_PATH, + headers, + NotificationPreferencesResponse.class + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().userId()).isEqualTo(TestDataFactory.DEFAULT_USER_ID); + assertThat(response.getBody().userEmail()).isEqualTo(TestDataFactory.DEFAULT_USER_EMAIL); + assertThat(response.getBody().reservationRequestCreated()).isTrue(); + assertThat(response.getBody().reservationCancelled()).isTrue(); + assertThat(response.getBody().hostRated()).isTrue(); + assertThat(response.getBody().accommodationRated()).isTrue(); + assertThat(response.getBody().reservationResponse()).isTrue(); + } + + @Test + @DisplayName("Should return preferences for GUEST user") + void shouldReturnPreferencesForGuest() { + preferencesRepository.save(TestDataFactory.createDefaultPreferences()); + + HttpHeaders headers = createHeaders( + TestDataFactory.DEFAULT_USER_ID.toString(), + "GUEST" + ); + + ResponseEntity response = get( + PREFERENCES_PATH, + headers, + NotificationPreferencesResponse.class + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().userId()).isEqualTo(TestDataFactory.DEFAULT_USER_ID); + } + + @Test + @DisplayName("Should return 401 when X-User-Role header is missing") + void shouldReturn401WhenRoleHeaderMissing() { + HttpHeaders headers = new HttpHeaders(); + headers.set("X-User-Id", TestDataFactory.DEFAULT_USER_ID.toString()); + + ResponseEntity response = get(PREFERENCES_PATH, headers, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + @DisplayName("Should return 403 for invalid role") + void shouldReturn403ForInvalidRole() { + HttpHeaders headers = createHeaders( + TestDataFactory.DEFAULT_USER_ID.toString(), + "ADMIN" + ); + + ResponseEntity response = get(PREFERENCES_PATH, headers, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + } + + @Test + @DisplayName("Should return 404 when preferences not found") + void shouldReturn404WhenNotFound() { + UUID nonExistentUserId = UUID.randomUUID(); + + HttpHeaders headers = createHeaders(nonExistentUserId.toString(), "HOST"); + + ResponseEntity response = get(PREFERENCES_PATH, headers, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + assertThat(response.getBody()).contains(nonExistentUserId.toString()); + } + } + + @Nested + @DisplayName("PUT /preferences") + class UpdatePreferences { + + @Test + @DisplayName("Should update all preferences") + void shouldUpdateAllPreferences() { + preferencesRepository.save(TestDataFactory.createDefaultPreferences()); + + NotificationPreferencesUpdateRequest request = new NotificationPreferencesUpdateRequest( + false, false, false, false, false + ); + + HttpHeaders headers = createHeaders( + TestDataFactory.DEFAULT_USER_ID.toString(), + "HOST" + ); + + ResponseEntity response = put( + PREFERENCES_PATH, + request, + headers, + NotificationPreferencesResponse.class + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().reservationRequestCreated()).isFalse(); + assertThat(response.getBody().reservationCancelled()).isFalse(); + assertThat(response.getBody().hostRated()).isFalse(); + assertThat(response.getBody().accommodationRated()).isFalse(); + assertThat(response.getBody().reservationResponse()).isFalse(); + + NotificationPreferences updated = preferencesRepository + .findByUserId(TestDataFactory.DEFAULT_USER_ID) + .orElseThrow(); + + assertThat(updated.getPreferences().isReservationRequestCreated()).isFalse(); + assertThat(updated.getPreferences().isReservationCancelled()).isFalse(); + assertThat(updated.getPreferences().isHostRated()).isFalse(); + assertThat(updated.getPreferences().isAccommodationRated()).isFalse(); + assertThat(updated.getPreferences().isReservationResponse()).isFalse(); + } + + @Test + @DisplayName("Should partially update preferences") + void shouldPartiallyUpdatePreferences() { + preferencesRepository.save(TestDataFactory.createDefaultPreferences()); + + NotificationPreferencesUpdateRequest request = new NotificationPreferencesUpdateRequest( + false, null, null, null, false + ); + + HttpHeaders headers = createHeaders( + TestDataFactory.DEFAULT_USER_ID.toString(), + "GUEST" + ); + + ResponseEntity response = put( + PREFERENCES_PATH, + request, + headers, + NotificationPreferencesResponse.class + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertThat(response.getBody()).isNotNull(); + assertThat(response.getBody().reservationRequestCreated()).isFalse(); + assertThat(response.getBody().reservationCancelled()).isTrue(); + assertThat(response.getBody().hostRated()).isTrue(); + assertThat(response.getBody().accommodationRated()).isTrue(); + assertThat(response.getBody().reservationResponse()).isFalse(); + } + + @Test + @DisplayName("Should return 401 when unauthorized") + void shouldReturn401WhenUnauthorized() { + NotificationPreferencesUpdateRequest request = new NotificationPreferencesUpdateRequest( + false, false, false, false, false + ); + + HttpHeaders headers = new HttpHeaders(); + headers.set("X-User-Id", TestDataFactory.DEFAULT_USER_ID.toString()); + headers.set("Content-Type", "application/json"); + + ResponseEntity response = put(PREFERENCES_PATH, request, headers, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test + @DisplayName("Should return 404 when preferences not found") + void shouldReturn404WhenNotFound() { + UUID nonExistentUserId = UUID.randomUUID(); + + NotificationPreferencesUpdateRequest request = new NotificationPreferencesUpdateRequest( + false, false, false, false, false + ); + + HttpHeaders headers = createHeaders(nonExistentUserId.toString(), "HOST"); + + ResponseEntity response = put(PREFERENCES_PATH, request, headers, String.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } + + @Test + @DisplayName("Should verify persistence after update") + void shouldVerifyPersistenceAfterUpdate() { + preferencesRepository.save(TestDataFactory.createDefaultPreferences()); + + NotificationPreferencesUpdateRequest request = new NotificationPreferencesUpdateRequest( + false, true, false, true, false + ); + + HttpHeaders headers = createHeaders( + TestDataFactory.DEFAULT_USER_ID.toString(), + "HOST" + ); + + ResponseEntity response = put( + PREFERENCES_PATH, + request, + headers, + NotificationPreferencesResponse.class + ); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + + NotificationPreferences persisted = preferencesRepository + .findByUserId(TestDataFactory.DEFAULT_USER_ID) + .orElseThrow(); + + assertThat(persisted.getPreferences().isReservationRequestCreated()).isFalse(); + assertThat(persisted.getPreferences().isReservationCancelled()).isTrue(); + assertThat(persisted.getPreferences().isHostRated()).isFalse(); + assertThat(persisted.getPreferences().isAccommodationRated()).isTrue(); + assertThat(persisted.getPreferences().isReservationResponse()).isFalse(); + } + } +} diff --git a/src/test/java/com/devoops/notification/integration/RabbitMQConsumerIntegrationTest.java b/src/test/java/com/devoops/notification/integration/RabbitMQConsumerIntegrationTest.java new file mode 100644 index 0000000..81fa8f6 --- /dev/null +++ b/src/test/java/com/devoops/notification/integration/RabbitMQConsumerIntegrationTest.java @@ -0,0 +1,320 @@ +package com.devoops.notification.integration; + +import com.devoops.notification.dto.message.*; +import com.devoops.notification.entity.NotificationPreferences; +import com.devoops.notification.util.TestDataFactory; +import jakarta.mail.internet.MimeMessage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@DisplayName("RabbitMQ Consumer Integration Tests") +class RabbitMQConsumerIntegrationTest extends BaseIntegrationTest { + + private static final String NOTIFICATION_EXCHANGE = "notification.exchange"; + + @Nested + @DisplayName("User Created Consumer") + class UserCreatedConsumer { + + @Test + @DisplayName("Should initialize preferences when user created message received") + void shouldInitializePreferencesWhenUserCreated() { + UserCreatedMessage message = TestDataFactory.createUserCreatedMessage(); + + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "user.created", message); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(preferencesRepository.findByUserId(TestDataFactory.DEFAULT_USER_ID)) + .isPresent() + .hasValueSatisfying(prefs -> { + assertThat(prefs.getUserEmail()).isEqualTo(TestDataFactory.DEFAULT_USER_EMAIL); + assertThat(prefs.getPreferences().isReservationRequestCreated()).isTrue(); + assertThat(prefs.getPreferences().isReservationCancelled()).isTrue(); + assertThat(prefs.getPreferences().isHostRated()).isTrue(); + assertThat(prefs.getPreferences().isAccommodationRated()).isTrue(); + assertThat(prefs.getPreferences().isReservationResponse()).isTrue(); + }); + }); + } + + @Test + @DisplayName("Should not duplicate preferences on re-send") + void shouldNotDuplicatePreferencesOnResend() { + UserCreatedMessage message = TestDataFactory.createUserCreatedMessage(); + + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "user.created", message); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(preferencesRepository.findByUserId(TestDataFactory.DEFAULT_USER_ID)).isPresent() + ); + + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "user.created", message); + + await().pollDelay(1, TimeUnit.SECONDS).atMost(3, TimeUnit.SECONDS).untilAsserted(() -> { + long count = preferencesRepository.findAll().stream() + .filter(p -> p.getUserId().equals(TestDataFactory.DEFAULT_USER_ID)) + .count(); + assertThat(count).isEqualTo(1); + }); + } + } + + @Nested + @DisplayName("Reservation Request Created Consumer") + class ReservationRequestCreatedConsumer { + + @Test + @DisplayName("Should send email when notification enabled") + void shouldSendEmailWhenNotificationEnabled() { + preferencesRepository.save(TestDataFactory.createDefaultPreferences()); + + ReservationRequestCreatedMessage message = TestDataFactory.createReservationRequestCreatedMessage(); + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "notification.reservation.created", message); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + MimeMessage[] receivedMessages = getGreenMail().getReceivedMessages(); + assertThat(receivedMessages).hasSize(1); + assertThat(receivedMessages[0].getSubject()) + .contains("New Reservation Request"); + assertThat(receivedMessages[0].getAllRecipients()[0].toString()) + .isEqualTo(TestDataFactory.DEFAULT_USER_EMAIL); + }); + } + + @Test + @DisplayName("Should not send email when notification disabled") + void shouldNotSendEmailWhenNotificationDisabled() { + NotificationPreferences prefs = TestDataFactory.createPreferencesWithAllDisabled( + TestDataFactory.DEFAULT_USER_ID, + TestDataFactory.DEFAULT_USER_EMAIL + ); + preferencesRepository.save(prefs); + + ReservationRequestCreatedMessage message = TestDataFactory.createReservationRequestCreatedMessage(); + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "notification.reservation.created", message); + + await().pollDelay(1, TimeUnit.SECONDS).atMost(3, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(getGreenMail().getReceivedMessages()).isEmpty() + ); + } + + @Test + @DisplayName("Should create preferences if missing and send email") + void shouldCreatePreferencesIfMissingAndSendEmail() { + ReservationRequestCreatedMessage message = TestDataFactory.createReservationRequestCreatedMessage(); + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "notification.reservation.created", message); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(preferencesRepository.findByUserId(TestDataFactory.DEFAULT_USER_ID)).isPresent(); + assertThat(getGreenMail().getReceivedMessages()).hasSize(1); + }); + } + } + + @Nested + @DisplayName("Reservation Cancelled Consumer") + class ReservationCancelledConsumer { + + @Test + @DisplayName("Should send cancellation email when enabled") + void shouldSendCancellationEmailWhenEnabled() { + preferencesRepository.save(TestDataFactory.createDefaultPreferences()); + + ReservationCancelledMessage message = TestDataFactory.createReservationCancelledMessage(); + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "notification.reservation.cancelled", message); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + MimeMessage[] receivedMessages = getGreenMail().getReceivedMessages(); + assertThat(receivedMessages).hasSize(1); + assertThat(receivedMessages[0].getSubject()) + .contains("Reservation Cancelled"); + }); + } + + @Test + @DisplayName("Should not send email when disabled") + void shouldNotSendEmailWhenDisabled() { + NotificationPreferences prefs = TestDataFactory.createDefaultPreferences(); + prefs.getPreferences().setReservationCancelled(false); + preferencesRepository.save(prefs); + + ReservationCancelledMessage message = TestDataFactory.createReservationCancelledMessage(); + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "notification.reservation.cancelled", message); + + await().pollDelay(1, TimeUnit.SECONDS).atMost(3, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(getGreenMail().getReceivedMessages()).isEmpty() + ); + } + } + + @Nested + @DisplayName("Host Rated Consumer") + class HostRatedConsumer { + + @Test + @DisplayName("Should send host rating notification email") + void shouldSendHostRatingNotificationEmail() { + preferencesRepository.save(TestDataFactory.createDefaultPreferences()); + + HostRatedMessage message = TestDataFactory.createHostRatedMessage(); + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "notification.rating.host", message); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + MimeMessage[] receivedMessages = getGreenMail().getReceivedMessages(); + assertThat(receivedMessages).hasSize(1); + assertThat(receivedMessages[0].getSubject()) + .contains("Rating"); + }); + } + + @Test + @DisplayName("Should not send email when host rated notification disabled") + void shouldNotSendEmailWhenHostRatedDisabled() { + NotificationPreferences prefs = TestDataFactory.createDefaultPreferences(); + prefs.getPreferences().setHostRated(false); + preferencesRepository.save(prefs); + + HostRatedMessage message = TestDataFactory.createHostRatedMessage(); + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "notification.rating.host", message); + + await().pollDelay(1, TimeUnit.SECONDS).atMost(3, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(getGreenMail().getReceivedMessages()).isEmpty() + ); + } + } + + @Nested + @DisplayName("Accommodation Rated Consumer") + class AccommodationRatedConsumer { + + @Test + @DisplayName("Should send accommodation rating notification email") + void shouldSendAccommodationRatingNotificationEmail() { + preferencesRepository.save(TestDataFactory.createDefaultPreferences()); + + AccommodationRatedMessage message = TestDataFactory.createAccommodationRatedMessage(); + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "notification.rating.accommodation", message); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + MimeMessage[] receivedMessages = getGreenMail().getReceivedMessages(); + assertThat(receivedMessages).hasSize(1); + assertThat(receivedMessages[0].getSubject()) + .contains("Review"); + }); + } + + @Test + @DisplayName("Should not send email when accommodation rated notification disabled") + void shouldNotSendEmailWhenAccommodationRatedDisabled() { + NotificationPreferences prefs = TestDataFactory.createDefaultPreferences(); + prefs.getPreferences().setAccommodationRated(false); + preferencesRepository.save(prefs); + + AccommodationRatedMessage message = TestDataFactory.createAccommodationRatedMessage(); + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "notification.rating.accommodation", message); + + await().pollDelay(1, TimeUnit.SECONDS).atMost(3, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(getGreenMail().getReceivedMessages()).isEmpty() + ); + } + } + + @Nested + @DisplayName("Reservation Response Consumer") + class ReservationResponseConsumer { + + @Test + @DisplayName("Should send approval email for approved reservation") + void shouldSendApprovalEmailForApprovedReservation() { + preferencesRepository.save(TestDataFactory.createDefaultPreferences()); + + ReservationResponseMessage message = TestDataFactory.createApprovedReservationResponseMessage(); + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "notification.reservation.response", message); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + MimeMessage[] receivedMessages = getGreenMail().getReceivedMessages(); + assertThat(receivedMessages).hasSize(1); + assertThat(receivedMessages[0].getSubject()) + .contains("Confirmed"); + }); + } + + @Test + @DisplayName("Should send decline email for declined reservation") + void shouldSendDeclineEmailForDeclinedReservation() { + preferencesRepository.save(TestDataFactory.createDefaultPreferences()); + + ReservationResponseMessage message = TestDataFactory.createDeclinedReservationResponseMessage(); + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "notification.reservation.response", message); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + MimeMessage[] receivedMessages = getGreenMail().getReceivedMessages(); + assertThat(receivedMessages).hasSize(1); + assertThat(receivedMessages[0].getSubject()) + .contains("Update"); + }); + } + + @Test + @DisplayName("Should not send email when reservation response notification disabled") + void shouldNotSendEmailWhenReservationResponseDisabled() { + NotificationPreferences prefs = TestDataFactory.createDefaultPreferences(); + prefs.getPreferences().setReservationResponse(false); + preferencesRepository.save(prefs); + + ReservationResponseMessage message = TestDataFactory.createApprovedReservationResponseMessage(); + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "notification.reservation.response", message); + + await().pollDelay(1, TimeUnit.SECONDS).atMost(3, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(getGreenMail().getReceivedMessages()).isEmpty() + ); + } + } + + @Nested + @DisplayName("Multiple Users") + class MultipleUsers { + + @Test + @DisplayName("Should handle notifications for different users independently") + void shouldHandleNotificationsForDifferentUsersIndependently() { + UUID user1Id = UUID.randomUUID(); + UUID user2Id = UUID.randomUUID(); + String user1Email = "user1@example.com"; + String user2Email = "user2@example.com"; + + NotificationPreferences user1Prefs = TestDataFactory.createPreferences(user1Id, user1Email); + user1Prefs.getPreferences().setReservationRequestCreated(true); + + NotificationPreferences user2Prefs = TestDataFactory.createPreferences(user2Id, user2Email); + user2Prefs.getPreferences().setReservationRequestCreated(false); + + preferencesRepository.save(user1Prefs); + preferencesRepository.save(user2Prefs); + + ReservationRequestCreatedMessage message1 = TestDataFactory.createReservationRequestCreatedMessage( + user1Id, user1Email + ); + ReservationRequestCreatedMessage message2 = TestDataFactory.createReservationRequestCreatedMessage( + user2Id, user2Email + ); + + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "notification.reservation.created", message1); + rabbitTemplate.convertAndSend(NOTIFICATION_EXCHANGE, "notification.reservation.created", message2); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + MimeMessage[] receivedMessages = getGreenMail().getReceivedMessages(); + assertThat(receivedMessages).hasSize(1); + assertThat(receivedMessages[0].getAllRecipients()[0].toString()) + .isEqualTo(user1Email); + }); + } + } +} diff --git a/src/test/java/com/devoops/notification/service/EmailServiceTest.java b/src/test/java/com/devoops/notification/service/EmailServiceTest.java new file mode 100644 index 0000000..7ecc540 --- /dev/null +++ b/src/test/java/com/devoops/notification/service/EmailServiceTest.java @@ -0,0 +1,244 @@ +package com.devoops.notification.service; + +import com.devoops.notification.dto.message.*; +import jakarta.mail.internet.MimeMessage; +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.mail.javamail.JavaMailSender; +import org.springframework.test.util.ReflectionTestUtils; +import org.thymeleaf.TemplateEngine; +import org.thymeleaf.context.Context; + +import java.math.BigDecimal; +import java.time.LocalDate; +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.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("EmailService Tests") +class EmailServiceTest { + + @Mock + private JavaMailSender mailSender; + + @Mock + private TemplateEngine templateEngine; + + @Mock + private MimeMessage mimeMessage; + + @InjectMocks + private EmailService emailService; + + private UUID testUserId; + private String testUserEmail; + + @BeforeEach + void setUp() { + testUserId = UUID.randomUUID(); + testUserEmail = "test@example.com"; + + ReflectionTestUtils.setField(emailService, "fromEmail", "noreply@devoops.com"); + ReflectionTestUtils.setField(emailService, "fromName", "DevOops"); + ReflectionTestUtils.setField(emailService, "frontendUrl", "http://localhost:4200"); + } + + @Nested + @DisplayName("sendNotificationEmail") + class SendNotificationEmailTests { + + @Test + @DisplayName("Should send reservation request created email") + void sendNotificationEmail_ReservationRequestCreated_SendsEmail() { + // Given + ReservationRequestCreatedMessage message = ReservationRequestCreatedMessage.builder() + .userId(testUserId) + .userEmail(testUserEmail) + .guestName("John Doe") + .accommodationName("Cozy Beach House") + .checkIn(LocalDate.of(2024, 3, 15)) + .checkOut(LocalDate.of(2024, 3, 20)) + .totalPrice(new BigDecimal("450.00")) + .build(); + + when(mailSender.createMimeMessage()).thenReturn(mimeMessage); + when(templateEngine.process(eq("email/reservation-request-created"), any(Context.class))) + .thenReturn("Test Email"); + + // When + emailService.sendNotificationEmail(message); + + // Then + verify(mailSender).createMimeMessage(); + verify(templateEngine).process(eq("email/reservation-request-created"), any(Context.class)); + verify(mailSender).send(mimeMessage); + } + + @Test + @DisplayName("Should send reservation cancelled email") + void sendNotificationEmail_ReservationCancelled_SendsEmail() { + // Given + ReservationCancelledMessage message = ReservationCancelledMessage.builder() + .userId(testUserId) + .userEmail(testUserEmail) + .guestName("John Doe") + .accommodationName("Cozy Beach House") + .checkIn(LocalDate.of(2024, 3, 15)) + .checkOut(LocalDate.of(2024, 3, 20)) + .reason("Guest cancelled") + .build(); + + when(mailSender.createMimeMessage()).thenReturn(mimeMessage); + when(templateEngine.process(eq("email/reservation-cancelled"), any(Context.class))) + .thenReturn("Test Email"); + + // When + emailService.sendNotificationEmail(message); + + // Then + verify(templateEngine).process(eq("email/reservation-cancelled"), any(Context.class)); + verify(mailSender).send(mimeMessage); + } + + @Test + @DisplayName("Should send host rated email") + void sendNotificationEmail_HostRated_SendsEmail() { + // Given + HostRatedMessage message = HostRatedMessage.builder() + .userId(testUserId) + .userEmail(testUserEmail) + .guestName("Jane Smith") + .rating(5) + .comment("Amazing host!") + .build(); + + when(mailSender.createMimeMessage()).thenReturn(mimeMessage); + when(templateEngine.process(eq("email/host-rated"), any(Context.class))) + .thenReturn("Test Email"); + + // When + emailService.sendNotificationEmail(message); + + // Then + verify(templateEngine).process(eq("email/host-rated"), any(Context.class)); + verify(mailSender).send(mimeMessage); + } + + @Test + @DisplayName("Should send accommodation rated email") + void sendNotificationEmail_AccommodationRated_SendsEmail() { + // Given + AccommodationRatedMessage message = AccommodationRatedMessage.builder() + .userId(testUserId) + .userEmail(testUserEmail) + .guestName("Jane Smith") + .accommodationName("Cozy Beach House") + .rating(4) + .comment("Great place!") + .build(); + + when(mailSender.createMimeMessage()).thenReturn(mimeMessage); + when(templateEngine.process(eq("email/accommodation-rated"), any(Context.class))) + .thenReturn("Test Email"); + + // When + emailService.sendNotificationEmail(message); + + // Then + verify(templateEngine).process(eq("email/accommodation-rated"), any(Context.class)); + verify(mailSender).send(mimeMessage); + } + + @Test + @DisplayName("Should send reservation response email") + void sendNotificationEmail_ReservationResponse_SendsEmail() { + // Given + ReservationResponseMessage message = ReservationResponseMessage.builder() + .userId(testUserId) + .userEmail(testUserEmail) + .hostName("Mike Johnson") + .accommodationName("Cozy Beach House") + .status(ReservationResponseMessage.ReservationStatus.APPROVED) + .checkIn(LocalDate.of(2024, 3, 15)) + .checkOut(LocalDate.of(2024, 3, 20)) + .build(); + + when(mailSender.createMimeMessage()).thenReturn(mimeMessage); + when(templateEngine.process(eq("email/reservation-response"), any(Context.class))) + .thenReturn("Test Email"); + + // When + emailService.sendNotificationEmail(message); + + // Then + verify(templateEngine).process(eq("email/reservation-response"), any(Context.class)); + verify(mailSender).send(mimeMessage); + } + + @Test + @DisplayName("Should include correct template variables in context") + void sendNotificationEmail_SetsCorrectTemplateVariables() { + // Given + ReservationRequestCreatedMessage message = ReservationRequestCreatedMessage.builder() + .userId(testUserId) + .userEmail(testUserEmail) + .guestName("John Doe") + .accommodationName("Cozy Beach House") + .checkIn(LocalDate.of(2024, 3, 15)) + .checkOut(LocalDate.of(2024, 3, 20)) + .totalPrice(new BigDecimal("450.00")) + .build(); + + when(mailSender.createMimeMessage()).thenReturn(mimeMessage); + ArgumentCaptor contextCaptor = ArgumentCaptor.forClass(Context.class); + when(templateEngine.process(eq("email/reservation-request-created"), contextCaptor.capture())) + .thenReturn("Test Email"); + + // When + emailService.sendNotificationEmail(message); + + // Then + Context capturedContext = contextCaptor.getValue(); + assertThat(capturedContext.getVariable("guestName")).isEqualTo("John Doe"); + assertThat(capturedContext.getVariable("accommodationName")).isEqualTo("Cozy Beach House"); + assertThat(capturedContext.getVariable("frontendUrl")).isEqualTo("http://localhost:4200"); + } + + @Test + @DisplayName("Should throw exception when mail sending fails") + void sendNotificationEmail_WhenMailFails_ThrowsException() { + // Given + ReservationRequestCreatedMessage message = ReservationRequestCreatedMessage.builder() + .userId(testUserId) + .userEmail(testUserEmail) + .guestName("John Doe") + .accommodationName("Cozy Beach House") + .checkIn(LocalDate.of(2024, 3, 15)) + .checkOut(LocalDate.of(2024, 3, 20)) + .totalPrice(new BigDecimal("450.00")) + .build(); + + when(mailSender.createMimeMessage()).thenReturn(mimeMessage); + when(templateEngine.process(anyString(), any(Context.class))) + .thenReturn("Test Email"); + doThrow(new RuntimeException("SMTP error")).when(mailSender).send(any(MimeMessage.class)); + + // When/Then + assertThatThrownBy(() -> emailService.sendNotificationEmail(message)) + .isInstanceOf(RuntimeException.class); + } + } +} diff --git a/src/test/java/com/devoops/notification/service/NotificationConsumerServiceTest.java b/src/test/java/com/devoops/notification/service/NotificationConsumerServiceTest.java new file mode 100644 index 0000000..4ce3c78 --- /dev/null +++ b/src/test/java/com/devoops/notification/service/NotificationConsumerServiceTest.java @@ -0,0 +1,164 @@ +package com.devoops.notification.service; + +import com.devoops.notification.dto.message.*; +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 java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("NotificationConsumerService Tests") +class NotificationConsumerServiceTest { + + @Mock + private NotificationService notificationService; + + @InjectMocks + private NotificationConsumerService consumerService; + + private UUID testUserId; + private String testUserEmail; + + @BeforeEach + void setUp() { + testUserId = UUID.randomUUID(); + testUserEmail = "test@example.com"; + } + + @Nested + @DisplayName("handleReservationCreated") + class HandleReservationCreatedTests { + + @Test + @DisplayName("Should process reservation created message") + void handleReservationCreated_ProcessesMessage() { + // Given + ReservationRequestCreatedMessage message = ReservationRequestCreatedMessage.builder() + .userId(testUserId) + .userEmail(testUserEmail) + .guestName("John Doe") + .accommodationName("Cozy Beach House") + .checkIn(LocalDate.of(2024, 3, 15)) + .checkOut(LocalDate.of(2024, 3, 20)) + .totalPrice(new BigDecimal("450.00")) + .build(); + + // When + consumerService.handleReservationCreated(message); + + // Then + verify(notificationService).processNotification(message); + } + } + + @Nested + @DisplayName("handleReservationCancelled") + class HandleReservationCancelledTests { + + @Test + @DisplayName("Should process reservation cancelled message") + void handleReservationCancelled_ProcessesMessage() { + // Given + ReservationCancelledMessage message = ReservationCancelledMessage.builder() + .userId(testUserId) + .userEmail(testUserEmail) + .guestName("John Doe") + .accommodationName("Cozy Beach House") + .checkIn(LocalDate.of(2024, 3, 15)) + .checkOut(LocalDate.of(2024, 3, 20)) + .reason("Guest cancelled") + .build(); + + // When + consumerService.handleReservationCancelled(message); + + // Then + verify(notificationService).processNotification(message); + } + } + + @Nested + @DisplayName("handleHostRated") + class HandleHostRatedTests { + + @Test + @DisplayName("Should process host rated message") + void handleHostRated_ProcessesMessage() { + // Given + HostRatedMessage message = HostRatedMessage.builder() + .userId(testUserId) + .userEmail(testUserEmail) + .guestName("Jane Smith") + .rating(5) + .comment("Amazing host!") + .build(); + + // When + consumerService.handleHostRated(message); + + // Then + verify(notificationService).processNotification(message); + } + } + + @Nested + @DisplayName("handleAccommodationRated") + class HandleAccommodationRatedTests { + + @Test + @DisplayName("Should process accommodation rated message") + void handleAccommodationRated_ProcessesMessage() { + // Given + AccommodationRatedMessage message = AccommodationRatedMessage.builder() + .userId(testUserId) + .userEmail(testUserEmail) + .guestName("Jane Smith") + .accommodationName("Cozy Beach House") + .rating(4) + .comment("Great place!") + .build(); + + // When + consumerService.handleAccommodationRated(message); + + // Then + verify(notificationService).processNotification(message); + } + } + + @Nested + @DisplayName("handleReservationResponse") + class HandleReservationResponseTests { + + @Test + @DisplayName("Should process reservation response message") + void handleReservationResponse_ProcessesMessage() { + // Given + ReservationResponseMessage message = ReservationResponseMessage.builder() + .userId(testUserId) + .userEmail(testUserEmail) + .hostName("Mike Johnson") + .accommodationName("Cozy Beach House") + .status(ReservationResponseMessage.ReservationStatus.APPROVED) + .checkIn(LocalDate.of(2024, 3, 15)) + .checkOut(LocalDate.of(2024, 3, 20)) + .build(); + + // When + consumerService.handleReservationResponse(message); + + // Then + verify(notificationService).processNotification(message); + } + } +} diff --git a/src/test/java/com/devoops/notification/service/NotificationPreferencesServiceTest.java b/src/test/java/com/devoops/notification/service/NotificationPreferencesServiceTest.java new file mode 100644 index 0000000..6440264 --- /dev/null +++ b/src/test/java/com/devoops/notification/service/NotificationPreferencesServiceTest.java @@ -0,0 +1,291 @@ +package com.devoops.notification.service; + +import com.devoops.notification.dto.request.NotificationPreferencesUpdateRequest; +import com.devoops.notification.dto.response.NotificationPreferencesResponse; +import com.devoops.notification.entity.NotificationPreferences; +import com.devoops.notification.entity.NotificationType; +import com.devoops.notification.entity.Preferences; +import com.devoops.notification.exception.PreferencesNotFoundException; +import com.devoops.notification.mapper.NotificationPreferencesMapper; +import com.devoops.notification.repository.NotificationPreferencesRepository; +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 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("NotificationPreferencesService Tests") +class NotificationPreferencesServiceTest { + + @Mock + private NotificationPreferencesRepository repository; + + @Mock + private NotificationPreferencesMapper mapper; + + @InjectMocks + private NotificationPreferencesService service; + + private UUID testUserId; + private String testUserEmail; + private NotificationPreferences testPreferences; + private NotificationPreferencesResponse testResponse; + + @BeforeEach + void setUp() { + testUserId = UUID.randomUUID(); + testUserEmail = "test@example.com"; + + Preferences prefs = new Preferences(); + prefs.setReservationRequestCreated(true); + prefs.setReservationCancelled(true); + prefs.setHostRated(true); + prefs.setAccommodationRated(true); + prefs.setReservationResponse(true); + + testPreferences = new NotificationPreferences(testUserId, testUserEmail); + testPreferences.setPreferences(prefs); + + testResponse = new NotificationPreferencesResponse( + testUserId, + testUserEmail, + true, true, true, true, true + ); + } + + @Nested + @DisplayName("getPreferences") + class GetPreferencesTests { + + @Test + @DisplayName("Should return preferences when user exists") + void getPreferences_WhenUserExists_ReturnsPreferences() { + // Given + when(repository.findByUserId(testUserId)).thenReturn(Optional.of(testPreferences)); + when(mapper.toResponse(testPreferences)).thenReturn(testResponse); + + // When + NotificationPreferencesResponse result = service.getPreferences(testUserId); + + // Then + assertThat(result).isNotNull(); + assertThat(result.userId()).isEqualTo(testUserId); + assertThat(result.userEmail()).isEqualTo(testUserEmail); + assertThat(result.reservationRequestCreated()).isTrue(); + verify(repository).findByUserId(testUserId); + verify(mapper).toResponse(testPreferences); + } + + @Test + @DisplayName("Should throw exception when user not found") + void getPreferences_WhenUserNotFound_ThrowsException() { + // Given + when(repository.findByUserId(testUserId)).thenReturn(Optional.empty()); + + // When/Then + assertThatThrownBy(() -> service.getPreferences(testUserId)) + .isInstanceOf(PreferencesNotFoundException.class) + .hasMessageContaining(testUserId.toString()); + verify(repository).findByUserId(testUserId); + verify(mapper, never()).toResponse(any()); + } + } + + @Nested + @DisplayName("initializePreferences") + class InitializePreferencesTests { + + @Test + @DisplayName("Should create new preferences when user does not exist") + void initializePreferences_WhenUserNotExists_CreatesNew() { + // Given + when(repository.existsByUserId(testUserId)).thenReturn(false); + when(repository.save(any(NotificationPreferences.class))).thenReturn(testPreferences); + when(mapper.toResponse(testPreferences)).thenReturn(testResponse); + + // When + NotificationPreferencesResponse result = service.initializePreferences(testUserId, testUserEmail); + + // Then + assertThat(result).isNotNull(); + assertThat(result.userId()).isEqualTo(testUserId); + verify(repository).existsByUserId(testUserId); + verify(repository).save(any(NotificationPreferences.class)); + verify(mapper).toResponse(any()); + } + + @Test + @DisplayName("Should return existing preferences when user already exists") + void initializePreferences_WhenUserExists_ReturnsExisting() { + // Given + when(repository.existsByUserId(testUserId)).thenReturn(true); + when(repository.findByUserId(testUserId)).thenReturn(Optional.of(testPreferences)); + when(mapper.toResponse(testPreferences)).thenReturn(testResponse); + + // When + NotificationPreferencesResponse result = service.initializePreferences(testUserId, testUserEmail); + + // Then + assertThat(result).isNotNull(); + verify(repository).existsByUserId(testUserId); + verify(repository, never()).save(any()); + verify(repository).findByUserId(testUserId); + } + } + + @Nested + @DisplayName("updatePreferences") + class UpdatePreferencesTests { + + @Test + @DisplayName("Should update all preferences when all fields provided") + void updatePreferences_WithAllFields_UpdatesAll() { + // Given + NotificationPreferencesUpdateRequest request = new NotificationPreferencesUpdateRequest( + false, false, false, false, false + ); + when(repository.findByUserId(testUserId)).thenReturn(Optional.of(testPreferences)); + when(repository.save(testPreferences)).thenReturn(testPreferences); + when(mapper.toResponse(testPreferences)).thenReturn(new NotificationPreferencesResponse( + testUserId, testUserEmail, false, false, false, false, false + )); + + // When + NotificationPreferencesResponse result = service.updatePreferences(testUserId, request); + + // Then + assertThat(result).isNotNull(); + assertThat(testPreferences.getPreferences().isReservationRequestCreated()).isFalse(); + assertThat(testPreferences.getPreferences().isReservationCancelled()).isFalse(); + verify(repository).save(testPreferences); + } + + @Test + @DisplayName("Should update only provided fields") + void updatePreferences_WithPartialFields_UpdatesOnlyProvided() { + // Given + NotificationPreferencesUpdateRequest request = new NotificationPreferencesUpdateRequest( + false, null, null, null, null + ); + when(repository.findByUserId(testUserId)).thenReturn(Optional.of(testPreferences)); + when(repository.save(testPreferences)).thenReturn(testPreferences); + when(mapper.toResponse(testPreferences)).thenReturn(testResponse); + + // When + service.updatePreferences(testUserId, request); + + // Then + assertThat(testPreferences.getPreferences().isReservationRequestCreated()).isFalse(); + assertThat(testPreferences.getPreferences().isReservationCancelled()).isTrue(); // unchanged + verify(repository).save(testPreferences); + } + + @Test + @DisplayName("Should throw exception when user not found") + void updatePreferences_WhenUserNotFound_ThrowsException() { + // Given + NotificationPreferencesUpdateRequest request = new NotificationPreferencesUpdateRequest( + false, false, false, false, false + ); + when(repository.findByUserId(testUserId)).thenReturn(Optional.empty()); + + // When/Then + assertThatThrownBy(() -> service.updatePreferences(testUserId, request)) + .isInstanceOf(PreferencesNotFoundException.class); + verify(repository, never()).save(any()); + } + } + + @Nested + @DisplayName("isNotificationEnabled") + class IsNotificationEnabledTests { + + @Test + @DisplayName("Should return true when notification type is enabled") + void isNotificationEnabled_WhenEnabled_ReturnsTrue() { + // Given + when(repository.findByUserId(testUserId)).thenReturn(Optional.of(testPreferences)); + + // When + boolean result = service.isNotificationEnabled(testUserId, NotificationType.RESERVATION_REQUEST_CREATED); + + // Then + assertThat(result).isTrue(); + } + + @Test + @DisplayName("Should return false when notification type is disabled") + void isNotificationEnabled_WhenDisabled_ReturnsFalse() { + // Given + testPreferences.getPreferences().setReservationRequestCreated(false); + when(repository.findByUserId(testUserId)).thenReturn(Optional.of(testPreferences)); + + // When + boolean result = service.isNotificationEnabled(testUserId, NotificationType.RESERVATION_REQUEST_CREATED); + + // Then + assertThat(result).isFalse(); + } + + @Test + @DisplayName("Should return false when user not found") + void isNotificationEnabled_WhenUserNotFound_ReturnsFalse() { + // Given + when(repository.findByUserId(testUserId)).thenReturn(Optional.empty()); + + // When + boolean result = service.isNotificationEnabled(testUserId, NotificationType.RESERVATION_REQUEST_CREATED); + + // Then + assertThat(result).isFalse(); + } + } + + @Nested + @DisplayName("getOrCreatePreferences") + class GetOrCreatePreferencesTests { + + @Test + @DisplayName("Should return existing preferences when found") + void getOrCreatePreferences_WhenExists_ReturnsExisting() { + // Given + when(repository.findByUserId(testUserId)).thenReturn(Optional.of(testPreferences)); + + // When + NotificationPreferences result = service.getOrCreatePreferences(testUserId, testUserEmail); + + // Then + assertThat(result).isEqualTo(testPreferences); + verify(repository, never()).save(any()); + } + + @Test + @DisplayName("Should create new preferences when not found") + void getOrCreatePreferences_WhenNotExists_CreatesNew() { + // Given + when(repository.findByUserId(testUserId)).thenReturn(Optional.empty()); + when(repository.save(any(NotificationPreferences.class))).thenAnswer(i -> i.getArgument(0)); + + // When + NotificationPreferences result = service.getOrCreatePreferences(testUserId, testUserEmail); + + // Then + assertThat(result).isNotNull(); + assertThat(result.getUserId()).isEqualTo(testUserId); + assertThat(result.getUserEmail()).isEqualTo(testUserEmail); + verify(repository).save(any(NotificationPreferences.class)); + } + } +} diff --git a/src/test/java/com/devoops/notification/service/NotificationServiceTest.java b/src/test/java/com/devoops/notification/service/NotificationServiceTest.java new file mode 100644 index 0000000..0913cd1 --- /dev/null +++ b/src/test/java/com/devoops/notification/service/NotificationServiceTest.java @@ -0,0 +1,115 @@ +package com.devoops.notification.service; + +import com.devoops.notification.dto.message.ReservationRequestCreatedMessage; +import com.devoops.notification.entity.NotificationPreferences; +import com.devoops.notification.entity.NotificationType; +import com.devoops.notification.entity.Preferences; +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 java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("NotificationService Tests") +class NotificationServiceTest { + + @Mock + private NotificationPreferencesService preferencesService; + + @Mock + private EmailService emailService; + + @InjectMocks + private NotificationService service; + + private UUID testUserId; + private String testUserEmail; + private NotificationPreferences testPreferences; + private ReservationRequestCreatedMessage testMessage; + + @BeforeEach + void setUp() { + testUserId = UUID.randomUUID(); + testUserEmail = "test@example.com"; + + Preferences prefs = new Preferences(); + prefs.setReservationRequestCreated(true); + prefs.setReservationCancelled(true); + prefs.setHostRated(true); + prefs.setAccommodationRated(true); + prefs.setReservationResponse(true); + + testPreferences = new NotificationPreferences(testUserId, testUserEmail); + testPreferences.setPreferences(prefs); + + testMessage = ReservationRequestCreatedMessage.builder() + .userId(testUserId) + .userEmail(testUserEmail) + .guestName("John Doe") + .accommodationName("Test Accommodation") + .checkIn(LocalDate.of(2024, 3, 15)) + .checkOut(LocalDate.of(2024, 3, 20)) + .totalPrice(new BigDecimal("450.00")) + .build(); + } + + @Nested + @DisplayName("processNotification") + class ProcessNotificationTests { + + @Test + @DisplayName("Should send email when notification type is enabled") + void processNotification_WhenEnabled_SendsEmail() { + // Given + when(preferencesService.getOrCreatePreferences(testUserId, testUserEmail)) + .thenReturn(testPreferences); + + // When + service.processNotification(testMessage); + + // Then + verify(preferencesService).getOrCreatePreferences(testUserId, testUserEmail); + verify(emailService).sendNotificationEmail(testMessage); + } + + @Test + @DisplayName("Should not send email when notification type is disabled") + void processNotification_WhenDisabled_DoesNotSendEmail() { + // Given + testPreferences.getPreferences().setReservationRequestCreated(false); + when(preferencesService.getOrCreatePreferences(testUserId, testUserEmail)) + .thenReturn(testPreferences); + + // When + service.processNotification(testMessage); + + // Then + verify(preferencesService).getOrCreatePreferences(testUserId, testUserEmail); + verify(emailService, never()).sendNotificationEmail(any()); + } + + @Test + @DisplayName("Should create preferences if they don't exist") + void processNotification_WhenNoPreferences_CreatesPreferences() { + // Given + when(preferencesService.getOrCreatePreferences(testUserId, testUserEmail)) + .thenReturn(testPreferences); + + // When + service.processNotification(testMessage); + + // Then + verify(preferencesService).getOrCreatePreferences(testUserId, testUserEmail); + } + } +} diff --git a/src/test/java/com/devoops/notification/service/UserEventConsumerServiceTest.java b/src/test/java/com/devoops/notification/service/UserEventConsumerServiceTest.java new file mode 100644 index 0000000..d2b462e --- /dev/null +++ b/src/test/java/com/devoops/notification/service/UserEventConsumerServiceTest.java @@ -0,0 +1,56 @@ +package com.devoops.notification.service; + +import com.devoops.notification.dto.message.UserCreatedMessage; +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 java.util.UUID; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UserEventConsumerService Tests") +class UserEventConsumerServiceTest { + + @Mock + private NotificationPreferencesService preferencesService; + + @InjectMocks + private UserEventConsumerService consumerService; + + private UUID testUserId; + private String testUserEmail; + + @BeforeEach + void setUp() { + testUserId = UUID.randomUUID(); + testUserEmail = "newuser@example.com"; + } + + @Nested + @DisplayName("handleUserCreated") + class HandleUserCreatedTests { + + @Test + @DisplayName("Should initialize preferences for new user") + void handleUserCreated_InitializesPreferences() { + // Given + UserCreatedMessage message = UserCreatedMessage.builder() + .userId(testUserId) + .userEmail(testUserEmail) + .build(); + + // When + consumerService.handleUserCreated(message); + + // Then + verify(preferencesService).initializePreferences(testUserId, testUserEmail); + } + } +} diff --git a/src/test/java/com/devoops/notification/util/TestDataFactory.java b/src/test/java/com/devoops/notification/util/TestDataFactory.java new file mode 100644 index 0000000..87f07cf --- /dev/null +++ b/src/test/java/com/devoops/notification/util/TestDataFactory.java @@ -0,0 +1,138 @@ +package com.devoops.notification.util; + +import com.devoops.notification.dto.message.*; +import com.devoops.notification.entity.NotificationPreferences; +import com.devoops.notification.entity.Preferences; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +public final class TestDataFactory { + + public static final UUID DEFAULT_USER_ID = UUID.fromString("11111111-1111-1111-1111-111111111111"); + public static final String DEFAULT_USER_EMAIL = "test@example.com"; + public static final String DEFAULT_GUEST_NAME = "John Doe"; + public static final String DEFAULT_HOST_NAME = "Jane Host"; + public static final String DEFAULT_ACCOMMODATION_NAME = "Cozy Beach House"; + + private TestDataFactory() { + } + + public static NotificationPreferences createDefaultPreferences() { + return createPreferences(DEFAULT_USER_ID, DEFAULT_USER_EMAIL); + } + + public static NotificationPreferences createPreferences(UUID userId, String userEmail) { + NotificationPreferences prefs = new NotificationPreferences(userId, userEmail); + return prefs; + } + + public static NotificationPreferences createPreferencesWithAllDisabled(UUID userId, String userEmail) { + NotificationPreferences prefs = new NotificationPreferences(userId, userEmail); + Preferences preferences = prefs.getPreferences(); + preferences.setReservationRequestCreated(false); + preferences.setReservationCancelled(false); + preferences.setHostRated(false); + preferences.setAccommodationRated(false); + preferences.setReservationResponse(false); + return prefs; + } + + public static UserCreatedMessage createUserCreatedMessage() { + return createUserCreatedMessage(DEFAULT_USER_ID, DEFAULT_USER_EMAIL); + } + + public static UserCreatedMessage createUserCreatedMessage(UUID userId, String userEmail) { + return UserCreatedMessage.builder() + .userId(userId) + .userEmail(userEmail) + .build(); + } + + public static ReservationRequestCreatedMessage createReservationRequestCreatedMessage() { + return createReservationRequestCreatedMessage(DEFAULT_USER_ID, DEFAULT_USER_EMAIL); + } + + public static ReservationRequestCreatedMessage createReservationRequestCreatedMessage(UUID userId, String userEmail) { + return ReservationRequestCreatedMessage.builder() + .userId(userId) + .userEmail(userEmail) + .guestName(DEFAULT_GUEST_NAME) + .accommodationName(DEFAULT_ACCOMMODATION_NAME) + .checkIn(LocalDate.now().plusDays(7)) + .checkOut(LocalDate.now().plusDays(14)) + .totalPrice(new BigDecimal("750.00")) + .build(); + } + + public static ReservationCancelledMessage createReservationCancelledMessage() { + return createReservationCancelledMessage(DEFAULT_USER_ID, DEFAULT_USER_EMAIL); + } + + public static ReservationCancelledMessage createReservationCancelledMessage(UUID userId, String userEmail) { + return ReservationCancelledMessage.builder() + .userId(userId) + .userEmail(userEmail) + .guestName(DEFAULT_GUEST_NAME) + .accommodationName(DEFAULT_ACCOMMODATION_NAME) + .checkIn(LocalDate.now().plusDays(7)) + .checkOut(LocalDate.now().plusDays(14)) + .reason("Change of plans") + .build(); + } + + public static HostRatedMessage createHostRatedMessage() { + return createHostRatedMessage(DEFAULT_USER_ID, DEFAULT_USER_EMAIL); + } + + public static HostRatedMessage createHostRatedMessage(UUID userId, String userEmail) { + return HostRatedMessage.builder() + .userId(userId) + .userEmail(userEmail) + .guestName(DEFAULT_GUEST_NAME) + .rating(5) + .comment("Excellent host! Very friendly and helpful.") + .build(); + } + + public static AccommodationRatedMessage createAccommodationRatedMessage() { + return createAccommodationRatedMessage(DEFAULT_USER_ID, DEFAULT_USER_EMAIL); + } + + public static AccommodationRatedMessage createAccommodationRatedMessage(UUID userId, String userEmail) { + return AccommodationRatedMessage.builder() + .userId(userId) + .userEmail(userEmail) + .guestName(DEFAULT_GUEST_NAME) + .accommodationName(DEFAULT_ACCOMMODATION_NAME) + .rating(4) + .comment("Great place, clean and comfortable!") + .build(); + } + + public static ReservationResponseMessage createReservationResponseMessage(ReservationResponseMessage.ReservationStatus status) { + return createReservationResponseMessage(DEFAULT_USER_ID, DEFAULT_USER_EMAIL, status); + } + + public static ReservationResponseMessage createReservationResponseMessage( + UUID userId, String userEmail, ReservationResponseMessage.ReservationStatus status) { + return ReservationResponseMessage.builder() + .userId(userId) + .userEmail(userEmail) + .hostName(DEFAULT_HOST_NAME) + .accommodationName(DEFAULT_ACCOMMODATION_NAME) + .status(status) + .checkIn(LocalDate.now().plusDays(7)) + .checkOut(LocalDate.now().plusDays(14)) + .build(); + } + + public static ReservationResponseMessage createApprovedReservationResponseMessage() { + return createReservationResponseMessage(ReservationResponseMessage.ReservationStatus.APPROVED); + } + + public static ReservationResponseMessage createDeclinedReservationResponseMessage() { + return createReservationResponseMessage(ReservationResponseMessage.ReservationStatus.DECLINED); + } +} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 0000000..c3e33ab --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,34 @@ +# Test Configuration +spring.application.name=notification-test + +# Disable tracing for faster tests +management.tracing.enabled=false + +# MongoDB - will be overridden by TestContainers in integration tests +# Using standard Spring Data MongoDB properties for tests +spring.data.mongodb.uri=mongodb://localhost:27017/notification_test_db +spring.data.mongodb.uuid-representation=standard + +# RabbitMQ - will be overridden by TestContainers +spring.rabbitmq.host=localhost +spring.rabbitmq.port=5672 + +# Email Configuration (will be overridden by GreenMail) +spring.mail.host=localhost +spring.mail.port=3025 +notification.email.from=test@devoops.com +notification.email.from-name=DevOops Test + +# Frontend URL +notification.frontend.url=http://localhost:4200 + +# Thymeleaf +spring.thymeleaf.cache=false + +# Logging +logging.level.root=WARN +logging.level.com.devoops=DEBUG + +# Disable health checks that require external services +management.health.rabbit.enabled=false +management.health.mongo.enabled=false