diff --git a/build.gradle.kts b/build.gradle.kts index 7345d7b..82b27ef 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,58 @@ repositories { } dependencies { - implementation("org.springframework.boot:spring-boot-starter-flyway") -// implementation("org.springframework.boot:spring-boot-starter-security") + // Web and Core implementation("org.springframework.boot:spring-boot-starter-webmvc") - implementation("net.logstash.logback:logstash-logback-encoder:8.0") - implementation("org.flywaydb:flyway-database-postgresql") - implementation("org.springframework.boot:spring-boot-starter-actuator") - implementation("io.micrometer:micrometer-registry-prometheus") - //zipkin(tracing) - implementation("org.springframework.boot:spring-boot-micrometer-tracing-brave") - implementation("org.springframework.boot:spring-boot-starter-zipkin") - implementation("io.micrometer:micrometer-tracing-bridge-brave") - implementation("io.zipkin.reporter2:zipkin-reporter-brave") + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-validation") + // Database + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-flyway") + implementation("org.flywaydb:flyway-database-postgresql") runtimeOnly("org.postgresql:postgresql") - testImplementation("org.springframework.boot:spring-boot-starter-flyway-test") -// testImplementation("org.springframework.boot:spring-boot-starter-security-test") + + // Lombok + compileOnly("org.projectlombok:lombok") + annotationProcessor("org.projectlombok:lombok") + + // MapStruct + implementation("org.mapstruct:mapstruct:1.6.3") + annotationProcessor("org.mapstruct:mapstruct-processor:1.6.3") + annotationProcessor("org.projectlombok:lombok-mapstruct-binding:0.2.0") + + // Prometheus + implementation("io.micrometer:micrometer-registry-prometheus") + + // Tracing (Zipkin) + implementation("org.springframework.boot:spring-boot-micrometer-tracing-brave") + implementation("org.springframework.boot:spring-boot-starter-zipkin") + implementation("io.micrometer:micrometer-tracing-bridge-brave") + implementation("io.zipkin.reporter2:zipkin-reporter-brave") + + // Logging + implementation("net.logstash.logback:logstash-logback-encoder:8.0") + + // Test + testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") + testImplementation("org.springframework.boot:spring-boot-starter-flyway-test") + testImplementation("org.testcontainers:junit-jupiter:1.20.4") + testImplementation("org.testcontainers:postgresql:1.20.4") + testImplementation("io.rest-assured:rest-assured:5.5.0") + testCompileOnly("org.projectlombok:lombok") + testAnnotationProcessor("org.projectlombok:lombok") testRuntimeOnly("org.junit.platform:junit-platform-launcher") } tasks.withType { useJUnitPlatform() + finalizedBy(tasks.jacocoTestReport) +} + +tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required = true + } } diff --git a/environment/.local.env b/environment/.local.env index c23275d..ba3bcc2 100644 --- a/environment/.local.env +++ b/environment/.local.env @@ -1,4 +1,8 @@ SERVER_PORT=8080 LOGSTASH_HOST=logstash:5000 ZIPKIN_HOST=zipkin -ZIPKIN_PORT=9411 \ No newline at end of file +ZIPKIN_PORT=9411 +POSTGRES_HOST=devoops-postgres +POSTGRES_PORT=5432 +DB_USERNAME=reservation-service +DB_PASSWORD=reservation-service-pass \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/ReservationApplication.java b/src/main/java/com/devoops/reservation/ReservationApplication.java index 32c892e..d41a4cc 100644 --- a/src/main/java/com/devoops/reservation/ReservationApplication.java +++ b/src/main/java/com/devoops/reservation/ReservationApplication.java @@ -2,9 +2,8 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.boot.jdbc.autoconfigure.DataSourceAutoConfiguration; -@SpringBootApplication(exclude = DataSourceAutoConfiguration.class) +@SpringBootApplication public class ReservationApplication { public static void main(String[] args) { diff --git a/src/main/java/com/devoops/reservation/config/RequireRole.java b/src/main/java/com/devoops/reservation/config/RequireRole.java new file mode 100644 index 0000000..5559fd4 --- /dev/null +++ b/src/main/java/com/devoops/reservation/config/RequireRole.java @@ -0,0 +1,12 @@ +package com.devoops.reservation.config; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target({ElementType.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface RequireRole { + String[] value(); +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/config/RoleAuthorizationInterceptor.java b/src/main/java/com/devoops/reservation/config/RoleAuthorizationInterceptor.java new file mode 100644 index 0000000..0a11f2c --- /dev/null +++ b/src/main/java/com/devoops/reservation/config/RoleAuthorizationInterceptor.java @@ -0,0 +1,49 @@ +package com.devoops.reservation.config; + +import com.devoops.reservation.exception.ForbiddenException; +import com.devoops.reservation.exception.UnauthorizedException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.NonNull; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.Arrays; + +@Component +public class RoleAuthorizationInterceptor implements HandlerInterceptor { + + @Override + public boolean preHandle( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull Object handler + ) { + if (!(handler instanceof HandlerMethod handlerMethod)) { + return true; + } + + RequireRole methodAnnotation = handlerMethod.getMethodAnnotation(RequireRole.class); + RequireRole classAnnotation = handlerMethod.getBeanType().getAnnotation(RequireRole.class); + + RequireRole requireRole = methodAnnotation != null ? methodAnnotation : classAnnotation; + if (requireRole == null) { + return true; + } + + String role = request.getHeader("X-User-Role"); + if (role == null) { + throw new UnauthorizedException("Missing authentication headers"); + } + + boolean hasRole = Arrays.stream(requireRole.value()) + .anyMatch(r -> r.equalsIgnoreCase(role)); + + if (!hasRole) { + throw new ForbiddenException("Insufficient permissions"); + } + + return true; + } +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/config/UserContext.java b/src/main/java/com/devoops/reservation/config/UserContext.java new file mode 100644 index 0000000..d09930c --- /dev/null +++ b/src/main/java/com/devoops/reservation/config/UserContext.java @@ -0,0 +1,5 @@ +package com.devoops.reservation.config; + +import java.util.UUID; + +public record UserContext(UUID userId, String role) {} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/config/UserContextResolver.java b/src/main/java/com/devoops/reservation/config/UserContextResolver.java new file mode 100644 index 0000000..f3a5750 --- /dev/null +++ b/src/main/java/com/devoops/reservation/config/UserContextResolver.java @@ -0,0 +1,40 @@ +package com.devoops.reservation.config; + +import com.devoops.reservation.exception.UnauthorizedException; +import org.jspecify.annotations.NonNull; +import org.springframework.core.MethodParameter; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; + +import java.util.UUID; + +public class UserContextResolver implements HandlerMethodArgumentResolver { + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return UserContext.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument( + @NonNull MethodParameter parameter, + ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, + WebDataBinderFactory binderFactory + ) { + String userId = webRequest.getHeader("X-User-Id"); + String role = webRequest.getHeader("X-User-Role"); + + if (userId == null || role == null) { + throw new UnauthorizedException("Missing authentication headers"); + } + + try { + return new UserContext(UUID.fromString(userId), role); + } catch (IllegalArgumentException e) { + throw new UnauthorizedException("Invalid user ID format"); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/config/WebConfig.java b/src/main/java/com/devoops/reservation/config/WebConfig.java new file mode 100644 index 0000000..630d8ef --- /dev/null +++ b/src/main/java/com/devoops/reservation/config/WebConfig.java @@ -0,0 +1,26 @@ +package com.devoops.reservation.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final RoleAuthorizationInterceptor roleAuthorizationInterceptor; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(new UserContextResolver()); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(roleAuthorizationInterceptor); + } +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/controller/ReservationController.java b/src/main/java/com/devoops/reservation/controller/ReservationController.java new file mode 100644 index 0000000..64c9232 --- /dev/null +++ b/src/main/java/com/devoops/reservation/controller/ReservationController.java @@ -0,0 +1,63 @@ +package com.devoops.reservation.controller; + +import com.devoops.reservation.config.RequireRole; +import com.devoops.reservation.config.UserContext; +import com.devoops.reservation.dto.request.CreateReservationRequest; +import com.devoops.reservation.dto.response.ReservationResponse; +import com.devoops.reservation.service.ReservationService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.UUID; + +@RestController +@RequestMapping("/api/reservation") +@RequiredArgsConstructor +public class ReservationController { + + private final ReservationService reservationService; + + @PostMapping + @RequireRole("GUEST") + public ResponseEntity create( + @Valid @RequestBody CreateReservationRequest request, + UserContext userContext) { + ReservationResponse response = reservationService.create(request, userContext); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping("/{id}") + @RequireRole({"GUEST", "HOST"}) + public ResponseEntity getById( + @PathVariable UUID id, + UserContext userContext) { + return ResponseEntity.ok(reservationService.getById(id, userContext)); + } + + + @GetMapping("/guest") + @RequireRole("GUEST") + public ResponseEntity> getByGuest(UserContext userContext) { + return ResponseEntity.ok(reservationService.getByGuestId(userContext)); + } + + @GetMapping("/host") + @RequireRole("HOST") + public ResponseEntity> getByHost(UserContext userContext) { + return ResponseEntity.ok(reservationService.getByHostId(userContext)); + } + + + @DeleteMapping("/{id}") + @RequireRole("GUEST") + public ResponseEntity delete( + @PathVariable UUID id, + UserContext userContext) { + reservationService.deleteRequest(id, userContext); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/devoops/reservation/dto/request/CreateReservationRequest.java b/src/main/java/com/devoops/reservation/dto/request/CreateReservationRequest.java new file mode 100644 index 0000000..e9ec516 --- /dev/null +++ b/src/main/java/com/devoops/reservation/dto/request/CreateReservationRequest.java @@ -0,0 +1,23 @@ +package com.devoops.reservation.dto.request; + +import jakarta.validation.constraints.*; + +import java.time.LocalDate; +import java.util.UUID; + +public record CreateReservationRequest( + @NotNull(message = "Accommodation ID is required") + UUID accommodationId, + + @NotNull(message = "Start date is required") + @FutureOrPresent(message = "Start date must be today or in the future") + LocalDate startDate, + + @NotNull(message = "End date is required") + @Future(message = "End date must be in the future") + LocalDate endDate, + + @NotNull(message = "Guest count is required") + @Min(value = 1, message = "Guest count must be at least 1") + Integer guestCount +) {} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/dto/response/ReservationResponse.java b/src/main/java/com/devoops/reservation/dto/response/ReservationResponse.java new file mode 100644 index 0000000..03c9ebf --- /dev/null +++ b/src/main/java/com/devoops/reservation/dto/response/ReservationResponse.java @@ -0,0 +1,22 @@ +package com.devoops.reservation.dto.response; + +import com.devoops.reservation.entity.ReservationStatus; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +public record ReservationResponse( + UUID id, + UUID accommodationId, + UUID guestId, + UUID hostId, + LocalDate startDate, + LocalDate endDate, + int guestCount, + BigDecimal totalPrice, + ReservationStatus status, + LocalDateTime createdAt, + LocalDateTime updatedAt +) {} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/entity/BaseEntity.java b/src/main/java/com/devoops/reservation/entity/BaseEntity.java new file mode 100644 index 0000000..61be58f --- /dev/null +++ b/src/main/java/com/devoops/reservation/entity/BaseEntity.java @@ -0,0 +1,36 @@ +package com.devoops.reservation.entity; + +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; +import java.util.UUID; + +@MappedSuperclass +@Getter +@Setter +@NoArgsConstructor +@SuperBuilder +public abstract class BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @CreationTimestamp + @Column(updatable = false) + private LocalDateTime createdAt; + + @UpdateTimestamp + private LocalDateTime updatedAt; + + @Builder.Default + @Column(nullable = false) + private boolean isDeleted = false; +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/entity/Reservation.java b/src/main/java/com/devoops/reservation/entity/Reservation.java new file mode 100644 index 0000000..0fca5f5 --- /dev/null +++ b/src/main/java/com/devoops/reservation/entity/Reservation.java @@ -0,0 +1,52 @@ +package com.devoops.reservation.entity; + +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.annotations.SQLRestriction; +import org.hibernate.type.SqlTypes; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +@Entity +@Table(name = "reservations") +@SQLRestriction("is_deleted = false") +@Getter +@Setter +@NoArgsConstructor +@SuperBuilder +public class Reservation extends BaseEntity { + + @Column(nullable = false) + private UUID accommodationId; + + @Column(nullable = false) + private UUID guestId; + + @Column(nullable = false) + private UUID hostId; + + @Column(nullable = false) + private LocalDate startDate; + + @Column(nullable = false) + private LocalDate endDate; + + @Column(nullable = false) + private int guestCount; + + @Column(nullable = false, precision = 12, scale = 2) + private BigDecimal totalPrice; + + @Builder.Default + @Enumerated(EnumType.STRING) + @JdbcTypeCode(SqlTypes.NAMED_ENUM) + @Column(nullable = false, columnDefinition = "reservation_status") + private ReservationStatus status = ReservationStatus.PENDING; +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/entity/ReservationStatus.java b/src/main/java/com/devoops/reservation/entity/ReservationStatus.java new file mode 100644 index 0000000..16bb7ca --- /dev/null +++ b/src/main/java/com/devoops/reservation/entity/ReservationStatus.java @@ -0,0 +1,8 @@ +package com.devoops.reservation.entity; + +public enum ReservationStatus { + PENDING, + APPROVED, + REJECTED, + CANCELLED +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/exception/ForbiddenException.java b/src/main/java/com/devoops/reservation/exception/ForbiddenException.java new file mode 100644 index 0000000..190e20d --- /dev/null +++ b/src/main/java/com/devoops/reservation/exception/ForbiddenException.java @@ -0,0 +1,7 @@ +package com.devoops.reservation.exception; + +public class ForbiddenException extends RuntimeException { + public ForbiddenException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/exception/GlobalExceptionHandler.java b/src/main/java/com/devoops/reservation/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..8a048e6 --- /dev/null +++ b/src/main/java/com/devoops/reservation/exception/GlobalExceptionHandler.java @@ -0,0 +1,53 @@ +package com.devoops.reservation.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ProblemDetail; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; +import java.util.stream.Collectors; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(ReservationNotFoundException.class) + public ProblemDetail handleNotFound(ReservationNotFoundException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); + } + + @ExceptionHandler(UnauthorizedException.class) + public ProblemDetail handleUnauthorized(UnauthorizedException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED, ex.getMessage()); + } + + @ExceptionHandler(ForbiddenException.class) + public ProblemDetail handleForbidden(ForbiddenException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, ex.getMessage()); + } + + @ExceptionHandler(InvalidReservationException.class) + public ProblemDetail handleInvalidReservation(InvalidReservationException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ProblemDetail handleValidation(MethodArgumentNotValidException ex) { + ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Validation failed"); + Map fieldErrors = ex.getBindingResult().getFieldErrors().stream() + .collect(Collectors.toMap( + FieldError::getField, + fe -> fe.getDefaultMessage() != null ? fe.getDefaultMessage() : "Invalid value", + (a, b) -> a + )); + problemDetail.setProperty("fieldErrors", fieldErrors); + return problemDetail; + } + + @ExceptionHandler(IllegalArgumentException.class) + public ProblemDetail handleIllegalArgument(IllegalArgumentException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, ex.getMessage()); + } +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/exception/InvalidReservationException.java b/src/main/java/com/devoops/reservation/exception/InvalidReservationException.java new file mode 100644 index 0000000..e7a55ce --- /dev/null +++ b/src/main/java/com/devoops/reservation/exception/InvalidReservationException.java @@ -0,0 +1,7 @@ +package com.devoops.reservation.exception; + +public class InvalidReservationException extends RuntimeException { + public InvalidReservationException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/exception/ReservationNotFoundException.java b/src/main/java/com/devoops/reservation/exception/ReservationNotFoundException.java new file mode 100644 index 0000000..40e7ec8 --- /dev/null +++ b/src/main/java/com/devoops/reservation/exception/ReservationNotFoundException.java @@ -0,0 +1,7 @@ +package com.devoops.reservation.exception; + +public class ReservationNotFoundException extends RuntimeException { + public ReservationNotFoundException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/exception/UnauthorizedException.java b/src/main/java/com/devoops/reservation/exception/UnauthorizedException.java new file mode 100644 index 0000000..ab26de8 --- /dev/null +++ b/src/main/java/com/devoops/reservation/exception/UnauthorizedException.java @@ -0,0 +1,7 @@ +package com.devoops.reservation.exception; + +public class UnauthorizedException extends RuntimeException { + public UnauthorizedException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/mapper/ReservationMapper.java b/src/main/java/com/devoops/reservation/mapper/ReservationMapper.java new file mode 100644 index 0000000..acc1c8e --- /dev/null +++ b/src/main/java/com/devoops/reservation/mapper/ReservationMapper.java @@ -0,0 +1,27 @@ +package com.devoops.reservation.mapper; + +import com.devoops.reservation.dto.request.CreateReservationRequest; +import com.devoops.reservation.dto.response.ReservationResponse; +import com.devoops.reservation.entity.Reservation; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface ReservationMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "guestId", ignore = true) + @Mapping(target = "hostId", ignore = true) + @Mapping(target = "totalPrice", ignore = true) + @Mapping(target = "status", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "isDeleted", ignore = true) + Reservation toEntity(CreateReservationRequest request); + + ReservationResponse toResponse(Reservation reservation); + + List toResponseList(List reservations); +} \ No newline at end of file diff --git a/src/main/java/com/devoops/reservation/repository/ReservationRepository.java b/src/main/java/com/devoops/reservation/repository/ReservationRepository.java new file mode 100644 index 0000000..9c378ab --- /dev/null +++ b/src/main/java/com/devoops/reservation/repository/ReservationRepository.java @@ -0,0 +1,64 @@ +package com.devoops.reservation.repository; + +import com.devoops.reservation.entity.Reservation; +import com.devoops.reservation.entity.ReservationStatus; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +public interface ReservationRepository extends JpaRepository { + + List findByGuestId(UUID guestId); + + List findByHostId(UUID hostId); + + List findByAccommodationId(UUID accommodationId); + + List findByAccommodationIdAndStatus(UUID accommodationId, ReservationStatus status); + + /** + * Find approved reservations that overlap with the given date range. + * Used to check if dates are available for a new reservation. + */ + @Query(""" + SELECT r FROM Reservation r + WHERE r.accommodationId = :accommodationId + AND r.status = 'APPROVED' + AND r.startDate < :endDate + AND r.endDate > :startDate + """) + List findOverlappingApproved( + @Param("accommodationId") UUID accommodationId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); + + /** + * Find all pending reservations that overlap with the given date range. + * Used when approving a reservation to reject overlapping pending requests. + */ + @Query(""" + SELECT r FROM Reservation r + WHERE r.accommodationId = :accommodationId + AND r.status = 'PENDING' + AND r.id != :excludeId + AND r.startDate < :endDate + AND r.endDate > :startDate + """) + List findOverlappingPending( + @Param("accommodationId") UUID accommodationId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("excludeId") UUID excludeId + ); + + /** + * Count cancelled reservations for a guest. + * Used by hosts when reviewing reservation requests. + */ + long countByGuestIdAndStatus(UUID guestId, ReservationStatus status); +} diff --git a/src/main/java/com/devoops/reservation/service/ReservationService.java b/src/main/java/com/devoops/reservation/service/ReservationService.java new file mode 100644 index 0000000..77c2ec5 --- /dev/null +++ b/src/main/java/com/devoops/reservation/service/ReservationService.java @@ -0,0 +1,152 @@ +package com.devoops.reservation.service; + +import com.devoops.reservation.config.UserContext; +import com.devoops.reservation.dto.request.CreateReservationRequest; +import com.devoops.reservation.dto.response.ReservationResponse; +import com.devoops.reservation.entity.Reservation; +import com.devoops.reservation.entity.ReservationStatus; +import com.devoops.reservation.exception.ForbiddenException; +import com.devoops.reservation.exception.InvalidReservationException; +import com.devoops.reservation.exception.ReservationNotFoundException; +import com.devoops.reservation.mapper.ReservationMapper; +import com.devoops.reservation.repository.ReservationRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class ReservationService { + + private final ReservationRepository reservationRepository; + private final ReservationMapper reservationMapper; + + @Transactional + public ReservationResponse create(CreateReservationRequest request, UserContext userContext) { + // Validate dates + validateDates(request.startDate(), request.endDate()); + + // TODO: Call Accommodation Service via gRPC to: + // 1. Validate accommodation exists + // 2. Get hostId + // 3. Validate guest count within min/max capacity + // 4. Validate dates within availability periods + // 5. Calculate totalPrice from pricing rules + UUID hostId = UUID.randomUUID(); // Placeholder - get from Accommodation Service + BigDecimal totalPrice = calculatePlaceholderPrice(request); // Placeholder - get from Accommodation Service + + // Check for overlapping approved reservations + List overlapping = reservationRepository.findOverlappingApproved( + request.accommodationId(), + request.startDate(), + request.endDate() + ); + + if (!overlapping.isEmpty()) { + throw new InvalidReservationException( + "The selected dates overlap with an existing approved reservation" + ); + } + + Reservation reservation = reservationMapper.toEntity(request); + reservation.setGuestId(userContext.userId()); + reservation.setHostId(hostId); + reservation.setTotalPrice(totalPrice); + reservation.setStatus(ReservationStatus.PENDING); + + reservation = reservationRepository.saveAndFlush(reservation); + log.info("Created reservation {} for guest {} at accommodation {}", + reservation.getId(), userContext.userId(), request.accommodationId()); + + // TODO: Publish event to Notification Service via RabbitMQ + // notifyHost(reservation); + + return reservationMapper.toResponse(reservation); + } + + @Transactional(readOnly = true) + public ReservationResponse getById(UUID id, UserContext userContext) { + Reservation reservation = findReservationOrThrow(id); + validateAccessToReservation(reservation, userContext); + return reservationMapper.toResponse(reservation); + } + + @Transactional(readOnly = true) + public List getByGuestId(UserContext userContext) { + List reservations = reservationRepository.findByGuestId(userContext.userId()); + return reservationMapper.toResponseList(reservations); + } + + @Transactional(readOnly = true) + public List getByHostId(UserContext userContext) { + List reservations = reservationRepository.findByHostId(userContext.userId()); + return reservationMapper.toResponseList(reservations); + } + + @Transactional + public void deleteRequest(UUID id, UserContext userContext) { + Reservation reservation = findReservationOrThrow(id); + + // Only the guest who created the reservation can delete it + if (!reservation.getGuestId().equals(userContext.userId())) { + throw new ForbiddenException("You can only delete your own reservation requests"); + } + + // Can only delete PENDING requests + if (reservation.getStatus() != ReservationStatus.PENDING) { + throw new InvalidReservationException( + "Only pending reservation requests can be deleted. Current status: " + reservation.getStatus() + ); + } + + reservation.setDeleted(true); + reservationRepository.save(reservation); + log.info("Guest {} deleted reservation request {}", userContext.userId(), id); + + // TODO: Publish event to Notification Service via RabbitMQ + } + + // === Helper Methods === + + private Reservation findReservationOrThrow(UUID id) { + return reservationRepository.findById(id) + .orElseThrow(() -> new ReservationNotFoundException( + "Reservation not found with id: " + id)); + } + + private void validateDates(LocalDate startDate, LocalDate endDate) { + if (!endDate.isAfter(startDate)) { + throw new InvalidReservationException("End date must be after start date"); + } + } + + private void validateAccessToReservation(Reservation reservation, UserContext userContext) { + boolean isGuest = reservation.getGuestId().equals(userContext.userId()); + boolean isHost = reservation.getHostId().equals(userContext.userId()); + + if (!isGuest && !isHost) { + throw new ForbiddenException("You do not have access to this reservation"); + } + } + + /** + * Placeholder price calculation. + * TODO: Replace with actual pricing calculation from Accommodation Service via gRPC. + * This calculates price based on number of nights and guest count. + */ + private BigDecimal calculatePlaceholderPrice(CreateReservationRequest request) { + long nights = ChronoUnit.DAYS.between(request.startDate(), request.endDate()); + // Placeholder: $100 per night * guest count + return BigDecimal.valueOf(100) + .multiply(BigDecimal.valueOf(nights)) + .multiply(BigDecimal.valueOf(request.guestCount())); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 322ce93..1260034 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,14 +1,31 @@ spring.application.name=reservation server.port=${SERVER_PORT:8080} + # Logging configuration logging.logstash.host=${LOGSTASH_HOST:localhost:5000} logging.level.root=INFO logging.level.com.devoops=DEBUG logging.level.org.springframework.web=INFO + +# Database +spring.datasource.url=jdbc:postgresql://${POSTGRES_HOST:devoops-postgres}:${POSTGRES_PORT:5432}/reservation_db +spring.datasource.username=${DB_USERNAME:reservation-service} +spring.datasource.password=${DB_PASSWORD:reservation-service-pass} +spring.datasource.driver-class-name=org.postgresql.Driver + +# JPA +spring.jpa.hibernate.ddl-auto=validate +spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect + +# Flyway +spring.flyway.enabled=true +spring.flyway.locations=classpath:db/migration + +# Tracing configuration management.tracing.sampling.probability=1.0 management.tracing.export.zipkin.endpoint=http://${ZIPKIN_HOST:zipkin}:${ZIPKIN_PORT:9411}/api/v2/spans # Actuator endpoints for Prometheus metrics -management.endpoints.web.exposure.include=health,prometheus,metrics +management.endpoints.web.exposure.include=health,info,prometheus management.endpoint.health.show-details=always management.prometheus.metrics.export.enabled=true diff --git a/src/main/resources/db/migration/V1__init_schema.sql b/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 0000000..c38afdf --- /dev/null +++ b/src/main/resources/db/migration/V1__init_schema.sql @@ -0,0 +1,30 @@ +-- PostgreSQL named enum for reservation status +CREATE TYPE reservation_status AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'CANCELLED'); + +-- Reservations table +CREATE TABLE reservations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + accommodation_id UUID NOT NULL, + guest_id UUID NOT NULL, + host_id UUID NOT NULL, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + guest_count INTEGER NOT NULL, + total_price NUMERIC(12, 2) NOT NULL, + status reservation_status NOT NULL DEFAULT 'PENDING', + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now() +); + +-- Indexes for common queries +CREATE INDEX idx_reservations_accommodation_id ON reservations(accommodation_id); +CREATE INDEX idx_reservations_guest_id ON reservations(guest_id); +CREATE INDEX idx_reservations_host_id ON reservations(host_id); +CREATE INDEX idx_reservations_status ON reservations(status); +CREATE INDEX idx_reservations_dates ON reservations(start_date, end_date); + +-- Composite index for overlap queries (only non-deleted reservations) +CREATE INDEX idx_reservations_accommodation_dates + ON reservations(accommodation_id, start_date, end_date) + WHERE is_deleted = false; \ No newline at end of file diff --git a/src/test/java/com/devoops/reservation/ReservationApplicationTests.java b/src/test/java/com/devoops/reservation/ReservationApplicationTests.java index 2eb492f..779c325 100644 --- a/src/test/java/com/devoops/reservation/ReservationApplicationTests.java +++ b/src/test/java/com/devoops/reservation/ReservationApplicationTests.java @@ -2,10 +2,34 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; @SpringBootTest +@Testcontainers +@ActiveProfiles("test") class ReservationApplicationTests { + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("reservation_db_test") + .withUsername("test") + .withPassword("test"); + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + registry.add("spring.flyway.url", postgres::getJdbcUrl); + registry.add("spring.flyway.user", postgres::getUsername); + registry.add("spring.flyway.password", postgres::getPassword); + } + @Test void contextLoads() { } diff --git a/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java b/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java new file mode 100644 index 0000000..6b326d9 --- /dev/null +++ b/src/test/java/com/devoops/reservation/controller/ReservationControllerTest.java @@ -0,0 +1,347 @@ +package com.devoops.reservation.controller; + +import com.devoops.reservation.config.RoleAuthorizationInterceptor; +import com.devoops.reservation.config.UserContext; +import com.devoops.reservation.config.UserContextResolver; +import com.devoops.reservation.dto.response.ReservationResponse; +import com.devoops.reservation.entity.ReservationStatus; +import com.devoops.reservation.exception.ForbiddenException; +import com.devoops.reservation.exception.GlobalExceptionHandler; +import com.devoops.reservation.exception.InvalidReservationException; +import com.devoops.reservation.exception.ReservationNotFoundException; +import com.devoops.reservation.service.ReservationService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@ExtendWith(MockitoExtension.class) +class ReservationControllerTest { + + private MockMvc mockMvc; + + @Mock + private ReservationService reservationService; + + @InjectMocks + private ReservationController reservationController; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final UUID GUEST_ID = UUID.randomUUID(); + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID ACCOMMODATION_ID = UUID.randomUUID(); + private static final UUID RESERVATION_ID = UUID.randomUUID(); + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(reservationController) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new UserContextResolver()) + .addInterceptors(new RoleAuthorizationInterceptor()) + .build(); + } + + private ReservationResponse createResponse() { + return new ReservationResponse( + RESERVATION_ID, ACCOMMODATION_ID, GUEST_ID, HOST_ID, + LocalDate.now().plusDays(10), LocalDate.now().plusDays(15), + 2, new BigDecimal("1000.00"), ReservationStatus.PENDING, + LocalDateTime.now(), LocalDateTime.now() + ); + } + + private Map validCreateRequest() { + return Map.of( + "accommodationId", ACCOMMODATION_ID.toString(), + "startDate", LocalDate.now().plusDays(10).toString(), + "endDate", LocalDate.now().plusDays(15).toString(), + "guestCount", 2 + ); + } + + @Nested + @DisplayName("POST /api/reservation") + class CreateEndpoint { + + @Test + @DisplayName("With valid request returns 201") + void create_WithValidRequest_Returns201() throws Exception { + when(reservationService.create(any(), any(UserContext.class))) + .thenReturn(createResponse()); + + mockMvc.perform(post("/api/reservation") + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.id").value(RESERVATION_ID.toString())) + .andExpect(jsonPath("$.status").value("PENDING")); + } + + @Test + @DisplayName("With missing auth headers returns 401") + void create_WithMissingAuthHeaders_Returns401() throws Exception { + mockMvc.perform(post("/api/reservation") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("With HOST role returns 403") + void create_WithHostRole_Returns403() throws Exception { + mockMvc.perform(post("/api/reservation") + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With missing accommodationId returns 400") + void create_WithMissingAccommodationId_Returns400() throws Exception { + var request = Map.of( + "startDate", LocalDate.now().plusDays(10).toString(), + "endDate", LocalDate.now().plusDays(15).toString(), + "guestCount", 2 + ); + + mockMvc.perform(post("/api/reservation") + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("With invalid guest count returns 400") + void create_WithInvalidGuestCount_Returns400() throws Exception { + var request = Map.of( + "accommodationId", ACCOMMODATION_ID.toString(), + "startDate", LocalDate.now().plusDays(10).toString(), + "endDate", LocalDate.now().plusDays(15).toString(), + "guestCount", 0 + ); + + mockMvc.perform(post("/api/reservation") + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("With overlapping reservation returns 400") + void create_WithOverlappingReservation_Returns400() throws Exception { + when(reservationService.create(any(), any(UserContext.class))) + .thenThrow(new InvalidReservationException("overlap with an existing approved reservation")); + + mockMvc.perform(post("/api/reservation") + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("GET /api/reservation/{id}") + class GetByIdEndpoint { + + @Test + @DisplayName("With existing ID and guest role returns 200") + void getById_WithExistingIdAndGuestRole_Returns200() throws Exception { + when(reservationService.getById(eq(RESERVATION_ID), any(UserContext.class))) + .thenReturn(createResponse()); + + mockMvc.perform(get("/api/reservation/{id}", RESERVATION_ID) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(RESERVATION_ID.toString())); + } + + @Test + @DisplayName("With existing ID and host role returns 200") + void getById_WithExistingIdAndHostRole_Returns200() throws Exception { + when(reservationService.getById(eq(RESERVATION_ID), any(UserContext.class))) + .thenReturn(createResponse()); + + mockMvc.perform(get("/api/reservation/{id}", RESERVATION_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(RESERVATION_ID.toString())); + } + + @Test + @DisplayName("With non-existing ID returns 404") + void getById_WithNonExistingId_Returns404() throws Exception { + UUID id = UUID.randomUUID(); + when(reservationService.getById(eq(id), any(UserContext.class))) + .thenThrow(new ReservationNotFoundException("Not found")); + + mockMvc.perform(get("/api/reservation/{id}", id) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("With unauthorized user returns 403") + void getById_WithUnauthorizedUser_Returns403() throws Exception { + when(reservationService.getById(eq(RESERVATION_ID), any(UserContext.class))) + .thenThrow(new ForbiddenException("Access denied")); + + mockMvc.perform(get("/api/reservation/{id}", RESERVATION_ID) + .header("X-User-Id", UUID.randomUUID().toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + } + + @Nested + @DisplayName("GET /api/reservation/guest") + class GetByGuestEndpoint { + + @Test + @DisplayName("Returns 200 with list") + void getByGuest_Returns200WithList() throws Exception { + when(reservationService.getByGuestId(any(UserContext.class))) + .thenReturn(List.of(createResponse())); + + mockMvc.perform(get("/api/reservation/guest") + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(RESERVATION_ID.toString())); + } + + @Test + @DisplayName("With HOST role returns 403") + void getByGuest_WithHostRole_Returns403() throws Exception { + mockMvc.perform(get("/api/reservation/guest") + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isForbidden()); + } + } + + @Nested + @DisplayName("GET /api/reservation/host") + class GetByHostEndpoint { + + @Test + @DisplayName("Returns 200 with list") + void getByHost_Returns200WithList() throws Exception { + when(reservationService.getByHostId(any(UserContext.class))) + .thenReturn(List.of(createResponse())); + + mockMvc.perform(get("/api/reservation/host") + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(RESERVATION_ID.toString())); + } + + @Test + @DisplayName("With GUEST role returns 403") + void getByHost_WithGuestRole_Returns403() throws Exception { + mockMvc.perform(get("/api/reservation/host") + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + } + + @Nested + @DisplayName("DELETE /api/reservation/{id}") + class DeleteEndpoint { + + @Test + @DisplayName("With valid request returns 204") + void delete_WithValidRequest_Returns204() throws Exception { + doNothing().when(reservationService).deleteRequest(eq(RESERVATION_ID), any(UserContext.class)); + + mockMvc.perform(delete("/api/reservation/{id}", RESERVATION_ID) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("With non-existing ID returns 404") + void delete_WithNonExistingId_Returns404() throws Exception { + UUID id = UUID.randomUUID(); + doThrow(new ReservationNotFoundException("Not found")) + .when(reservationService).deleteRequest(eq(id), any(UserContext.class)); + + mockMvc.perform(delete("/api/reservation/{id}", id) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("With wrong owner returns 403") + void delete_WithWrongOwner_Returns403() throws Exception { + doThrow(new ForbiddenException("Not the owner")) + .when(reservationService).deleteRequest(eq(RESERVATION_ID), any(UserContext.class)); + + mockMvc.perform(delete("/api/reservation/{id}", RESERVATION_ID) + .header("X-User-Id", UUID.randomUUID().toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With non-pending status returns 400") + void delete_WithNonPendingStatus_Returns400() throws Exception { + doThrow(new InvalidReservationException("Only pending requests can be deleted")) + .when(reservationService).deleteRequest(eq(RESERVATION_ID), any(UserContext.class)); + + mockMvc.perform(delete("/api/reservation/{id}", RESERVATION_ID) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isBadRequest()); + } + + @Test + @DisplayName("With HOST role returns 403") + void delete_WithHostRole_Returns403() throws Exception { + mockMvc.perform(delete("/api/reservation/{id}", RESERVATION_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isForbidden()); + } + } +} diff --git a/src/test/java/com/devoops/reservation/integration/ReservationIntegrationTest.java b/src/test/java/com/devoops/reservation/integration/ReservationIntegrationTest.java new file mode 100644 index 0000000..1e100d4 --- /dev/null +++ b/src/test/java/com/devoops/reservation/integration/ReservationIntegrationTest.java @@ -0,0 +1,326 @@ +package com.devoops.reservation.integration; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.time.LocalDate; +import java.util.Map; +import java.util.UUID; + +import static org.hamcrest.Matchers.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@SpringBootTest +@AutoConfigureMockMvc +@Testcontainers +@ActiveProfiles("test") +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +class ReservationIntegrationTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("reservation_db_test") + .withUsername("test") + .withPassword("test"); + + @Autowired + private MockMvc mockMvc; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static String reservationId; + private static final UUID GUEST_ID = UUID.randomUUID(); + private static final UUID OTHER_GUEST_ID = UUID.randomUUID(); + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID ACCOMMODATION_ID = UUID.randomUUID(); + + private static final String BASE_PATH = "/api/reservation"; + + @DynamicPropertySource + static void configureProperties(DynamicPropertyRegistry registry) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + registry.add("spring.flyway.url", postgres::getJdbcUrl); + registry.add("spring.flyway.user", postgres::getUsername); + registry.add("spring.flyway.password", postgres::getPassword); + } + + private Map validCreateRequest() { + return Map.of( + "accommodationId", ACCOMMODATION_ID.toString(), + "startDate", LocalDate.now().plusDays(10).toString(), + "endDate", LocalDate.now().plusDays(15).toString(), + "guestCount", 2 + ); + } + + private Map createRequestWithDates(LocalDate start, LocalDate end) { + return Map.of( + "accommodationId", ACCOMMODATION_ID.toString(), + "startDate", start.toString(), + "endDate", end.toString(), + "guestCount", 2 + ); + } + + @Test + @Order(1) + @DisplayName("Create reservation with valid request returns 201") + void create_WithValidRequest_Returns201WithResponse() throws Exception { + MvcResult result = mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accommodationId").value(ACCOMMODATION_ID.toString())) + .andExpect(jsonPath("$.guestId").value(GUEST_ID.toString())) + .andExpect(jsonPath("$.guestCount").value(2)) + .andExpect(jsonPath("$.status").value("PENDING")) + .andExpect(jsonPath("$.id").isNotEmpty()) + .andExpect(jsonPath("$.totalPrice").isNotEmpty()) + .andReturn(); + + reservationId = objectMapper.readTree(result.getResponse().getContentAsString()) + .get("id").asText(); + } + + @Test + @Order(2) + @DisplayName("Create another pending reservation for same dates is allowed") + void create_WithOverlappingPendingReservation_Returns201() throws Exception { + // Multiple pending reservations for overlapping dates should be allowed + mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", OTHER_GUEST_ID.toString()) + .header("X-User-Role", "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.status").value("PENDING")); + } + + @Test + @Order(3) + @DisplayName("Create reservation with missing accommodationId returns 400") + void create_WithMissingAccommodationId_Returns400() throws Exception { + var request = Map.of( + "startDate", LocalDate.now().plusDays(10).toString(), + "endDate", LocalDate.now().plusDays(15).toString(), + "guestCount", 2 + ); + + mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @Order(4) + @DisplayName("Create reservation with end date before start date returns 400") + void create_WithEndDateBeforeStartDate_Returns400() throws Exception { + var request = createRequestWithDates( + LocalDate.now().plusDays(15), + LocalDate.now().plusDays(10) + ); + + mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @Order(5) + @DisplayName("Create reservation with invalid guest count returns 400") + void create_WithInvalidGuestCount_Returns400() throws Exception { + var request = Map.of( + "accommodationId", ACCOMMODATION_ID.toString(), + "startDate", LocalDate.now().plusDays(10).toString(), + "endDate", LocalDate.now().plusDays(15).toString(), + "guestCount", 0 + ); + + mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + @Order(6) + @DisplayName("Create reservation without auth headers returns 401") + void create_WithoutAuthHeaders_Returns401() throws Exception { + mockMvc.perform(post(BASE_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isUnauthorized()); + } + + @Test + @Order(7) + @DisplayName("Create reservation with HOST role returns 403") + void create_WithHostRole_Returns403() throws Exception { + mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isForbidden()); + } + + @Test + @Order(8) + @DisplayName("Get by ID with existing ID and guest role returns 200") + void getById_WithExistingIdAndGuestRole_Returns200() throws Exception { + mockMvc.perform(get(BASE_PATH + "/" + reservationId) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(reservationId)) + .andExpect(jsonPath("$.guestId").value(GUEST_ID.toString())); + } + + @Test + @Order(9) + @DisplayName("Get by ID with non-existing ID returns 404") + void getById_WithNonExistingId_Returns404() throws Exception { + mockMvc.perform(get(BASE_PATH + "/" + UUID.randomUUID()) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isNotFound()); + } + + @Test + @Order(10) + @DisplayName("Get by ID with unauthorized user returns 403") + void getById_WithUnauthorizedUser_Returns403() throws Exception { + mockMvc.perform(get(BASE_PATH + "/" + reservationId) + .header("X-User-Id", UUID.randomUUID().toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + + @Test + @Order(11) + @DisplayName("Get by guest returns list of reservations") + void getByGuest_ReturnsListOfReservations() throws Exception { + mockMvc.perform(get(BASE_PATH + "/guest") + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(greaterThanOrEqualTo(1)))); + } + + @Test + @Order(12) + @DisplayName("Get by guest with HOST role returns 403") + void getByGuest_WithHostRole_Returns403() throws Exception { + mockMvc.perform(get(BASE_PATH + "/guest") + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isForbidden()); + } + + @Test + @Order(13) + @DisplayName("Get by host returns list of reservations") + void getByHost_ReturnsListOfReservations() throws Exception { + // Create a reservation where we know the hostId (from placeholder in service) + // The service sets a random hostId, so we get the host from the reservation + MvcResult result = mockMvc.perform(get(BASE_PATH + "/" + reservationId) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isOk()) + .andReturn(); + + String hostId = objectMapper.readTree(result.getResponse().getContentAsString()) + .get("hostId").asText(); + + mockMvc.perform(get(BASE_PATH + "/host") + .header("X-User-Id", hostId) + .header("X-User-Role", "HOST")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(greaterThanOrEqualTo(1)))); + } + + @Test + @Order(14) + @DisplayName("Get by host with GUEST role returns 403") + void getByHost_WithGuestRole_Returns403() throws Exception { + mockMvc.perform(get(BASE_PATH + "/host") + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + + @Test + @Order(15) + @DisplayName("Delete reservation with different guest returns 403") + void delete_WithDifferentGuest_Returns403() throws Exception { + mockMvc.perform(delete(BASE_PATH + "/" + reservationId) + .header("X-User-Id", OTHER_GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isForbidden()); + } + + @Test + @Order(16) + @DisplayName("Delete reservation with HOST role returns 403") + void delete_WithHostRole_Returns403() throws Exception { + mockMvc.perform(delete(BASE_PATH + "/" + reservationId) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isForbidden()); + } + + @Test + @Order(17) + @DisplayName("Delete pending reservation with valid owner returns 204") + void delete_WithValidOwner_Returns204() throws Exception { + mockMvc.perform(delete(BASE_PATH + "/" + reservationId) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isNoContent()); + } + + @Test + @Order(18) + @DisplayName("After delete, get by ID returns 404 (soft-delete filters)") + void delete_ThenGetById_Returns404() throws Exception { + mockMvc.perform(get(BASE_PATH + "/" + reservationId) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isNotFound()); + } + + @Test + @Order(19) + @DisplayName("Delete with non-existing ID returns 404") + void delete_WithNonExistingId_Returns404() throws Exception { + mockMvc.perform(delete(BASE_PATH + "/" + UUID.randomUUID()) + .header("X-User-Id", GUEST_ID.toString()) + .header("X-User-Role", "GUEST")) + .andExpect(status().isNotFound()); + } +} diff --git a/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java b/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java new file mode 100644 index 0000000..4f0d014 --- /dev/null +++ b/src/test/java/com/devoops/reservation/service/ReservationServiceTest.java @@ -0,0 +1,335 @@ +package com.devoops.reservation.service; + +import com.devoops.reservation.config.UserContext; +import com.devoops.reservation.dto.request.CreateReservationRequest; +import com.devoops.reservation.dto.response.ReservationResponse; +import com.devoops.reservation.entity.Reservation; +import com.devoops.reservation.entity.ReservationStatus; +import com.devoops.reservation.exception.ForbiddenException; +import com.devoops.reservation.exception.InvalidReservationException; +import com.devoops.reservation.exception.ReservationNotFoundException; +import com.devoops.reservation.mapper.ReservationMapper; +import com.devoops.reservation.repository.ReservationRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class ReservationServiceTest { + + @Mock + private ReservationRepository reservationRepository; + + @Mock + private ReservationMapper reservationMapper; + + @InjectMocks + private ReservationService reservationService; + + private static final UUID GUEST_ID = UUID.randomUUID(); + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID ACCOMMODATION_ID = UUID.randomUUID(); + private static final UUID RESERVATION_ID = UUID.randomUUID(); + private static final UserContext GUEST_CONTEXT = new UserContext(GUEST_ID, "GUEST"); + private static final UserContext HOST_CONTEXT = new UserContext(HOST_ID, "HOST"); + + private Reservation createReservation() { + return Reservation.builder() + .id(RESERVATION_ID) + .accommodationId(ACCOMMODATION_ID) + .guestId(GUEST_ID) + .hostId(HOST_ID) + .startDate(LocalDate.now().plusDays(10)) + .endDate(LocalDate.now().plusDays(15)) + .guestCount(2) + .totalPrice(new BigDecimal("1000.00")) + .status(ReservationStatus.PENDING) + .build(); + } + + private ReservationResponse createResponse() { + return new ReservationResponse( + RESERVATION_ID, ACCOMMODATION_ID, GUEST_ID, HOST_ID, + LocalDate.now().plusDays(10), LocalDate.now().plusDays(15), + 2, new BigDecimal("1000.00"), ReservationStatus.PENDING, + LocalDateTime.now(), LocalDateTime.now() + ); + } + + private CreateReservationRequest createRequest() { + return new CreateReservationRequest( + ACCOMMODATION_ID, + LocalDate.now().plusDays(10), + LocalDate.now().plusDays(15), + 2 + ); + } + + @Nested + @DisplayName("Create") + class CreateTests { + + @Test + @DisplayName("With valid request returns reservation response") + void create_WithValidRequest_ReturnsReservationResponse() { + var request = createRequest(); + var reservation = createReservation(); + var response = createResponse(); + + when(reservationRepository.findOverlappingApproved(any(), any(), any())) + .thenReturn(List.of()); + when(reservationMapper.toEntity(request)).thenReturn(reservation); + when(reservationRepository.saveAndFlush(reservation)).thenReturn(reservation); + when(reservationMapper.toResponse(reservation)).thenReturn(response); + + ReservationResponse result = reservationService.create(request, GUEST_CONTEXT); + + assertThat(result).isEqualTo(response); + assertThat(reservation.getGuestId()).isEqualTo(GUEST_ID); + assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.PENDING); + verify(reservationRepository).saveAndFlush(reservation); + } + + @Test + @DisplayName("With overlapping approved reservation throws InvalidReservationException") + void create_WithOverlappingApproved_ThrowsInvalidReservationException() { + var request = createRequest(); + var existingReservation = createReservation(); + existingReservation.setStatus(ReservationStatus.APPROVED); + + when(reservationRepository.findOverlappingApproved(any(), any(), any())) + .thenReturn(List.of(existingReservation)); + + assertThatThrownBy(() -> reservationService.create(request, GUEST_CONTEXT)) + .isInstanceOf(InvalidReservationException.class) + .hasMessageContaining("overlap"); + } + + @Test + @DisplayName("With end date before start date throws InvalidReservationException") + void create_WithEndDateBeforeStartDate_ThrowsInvalidReservationException() { + var request = new CreateReservationRequest( + ACCOMMODATION_ID, + LocalDate.now().plusDays(15), + LocalDate.now().plusDays(10), + 2 + ); + + assertThatThrownBy(() -> reservationService.create(request, GUEST_CONTEXT)) + .isInstanceOf(InvalidReservationException.class) + .hasMessageContaining("End date must be after start date"); + } + + @Test + @DisplayName("With same start and end date throws InvalidReservationException") + void create_WithSameStartAndEndDate_ThrowsInvalidReservationException() { + LocalDate sameDate = LocalDate.now().plusDays(10); + var request = new CreateReservationRequest( + ACCOMMODATION_ID, + sameDate, + sameDate, + 2 + ); + + assertThatThrownBy(() -> reservationService.create(request, GUEST_CONTEXT)) + .isInstanceOf(InvalidReservationException.class) + .hasMessageContaining("End date must be after start date"); + } + } + + @Nested + @DisplayName("GetById") + class GetByIdTests { + + @Test + @DisplayName("With existing ID and guest access returns reservation response") + void getById_WithExistingIdAndGuestAccess_ReturnsReservationResponse() { + var reservation = createReservation(); + var response = createResponse(); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + when(reservationMapper.toResponse(reservation)).thenReturn(response); + + ReservationResponse result = reservationService.getById(RESERVATION_ID, GUEST_CONTEXT); + + assertThat(result).isEqualTo(response); + } + + @Test + @DisplayName("With existing ID and host access returns reservation response") + void getById_WithExistingIdAndHostAccess_ReturnsReservationResponse() { + var reservation = createReservation(); + var response = createResponse(); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + when(reservationMapper.toResponse(reservation)).thenReturn(response); + + ReservationResponse result = reservationService.getById(RESERVATION_ID, HOST_CONTEXT); + + assertThat(result).isEqualTo(response); + } + + @Test + @DisplayName("With non-existing ID throws ReservationNotFoundException") + void getById_WithNonExistingId_ThrowsReservationNotFoundException() { + UUID id = UUID.randomUUID(); + when(reservationRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> reservationService.getById(id, GUEST_CONTEXT)) + .isInstanceOf(ReservationNotFoundException.class); + } + + @Test + @DisplayName("With unauthorized user throws ForbiddenException") + void getById_WithUnauthorizedUser_ThrowsForbiddenException() { + var reservation = createReservation(); + var otherUser = new UserContext(UUID.randomUUID(), "GUEST"); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.getById(RESERVATION_ID, otherUser)) + .isInstanceOf(ForbiddenException.class); + } + } + + @Nested + @DisplayName("GetByGuestId") + class GetByGuestIdTests { + + @Test + @DisplayName("With existing guest returns reservation list") + void getByGuestId_WithExistingGuest_ReturnsReservationList() { + var reservations = List.of(createReservation()); + var responses = List.of(createResponse()); + + when(reservationRepository.findByGuestId(GUEST_ID)).thenReturn(reservations); + when(reservationMapper.toResponseList(reservations)).thenReturn(responses); + + List result = reservationService.getByGuestId(GUEST_CONTEXT); + + assertThat(result).hasSize(1); + } + + @Test + @DisplayName("With no reservations returns empty list") + void getByGuestId_WithNoReservations_ReturnsEmptyList() { + when(reservationRepository.findByGuestId(GUEST_ID)).thenReturn(List.of()); + when(reservationMapper.toResponseList(List.of())).thenReturn(List.of()); + + List result = reservationService.getByGuestId(GUEST_CONTEXT); + + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("GetByHostId") + class GetByHostIdTests { + + @Test + @DisplayName("With existing host returns reservation list") + void getByHostId_WithExistingHost_ReturnsReservationList() { + var reservations = List.of(createReservation()); + var responses = List.of(createResponse()); + + when(reservationRepository.findByHostId(HOST_ID)).thenReturn(reservations); + when(reservationMapper.toResponseList(reservations)).thenReturn(responses); + + List result = reservationService.getByHostId(HOST_CONTEXT); + + assertThat(result).hasSize(1); + } + + @Test + @DisplayName("With no reservations returns empty list") + void getByHostId_WithNoReservations_ReturnsEmptyList() { + when(reservationRepository.findByHostId(HOST_ID)).thenReturn(List.of()); + when(reservationMapper.toResponseList(List.of())).thenReturn(List.of()); + + List result = reservationService.getByHostId(HOST_CONTEXT); + + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("DeleteRequest") + class DeleteRequestTests { + + @Test + @DisplayName("With valid owner and pending status soft-deletes reservation") + void deleteRequest_WithValidOwnerAndPending_SoftDeletesReservation() { + var reservation = createReservation(); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + reservationService.deleteRequest(RESERVATION_ID, GUEST_CONTEXT); + + assertThat(reservation.isDeleted()).isTrue(); + verify(reservationRepository).save(reservation); + } + + @Test + @DisplayName("With wrong owner throws ForbiddenException") + void deleteRequest_WithWrongOwner_ThrowsForbiddenException() { + var reservation = createReservation(); + var otherUser = new UserContext(UUID.randomUUID(), "GUEST"); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.deleteRequest(RESERVATION_ID, otherUser)) + .isInstanceOf(ForbiddenException.class) + .hasMessageContaining("only delete your own"); + } + + @Test + @DisplayName("With non-pending status throws InvalidReservationException") + void deleteRequest_WithNonPendingStatus_ThrowsInvalidReservationException() { + var reservation = createReservation(); + reservation.setStatus(ReservationStatus.APPROVED); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.deleteRequest(RESERVATION_ID, GUEST_CONTEXT)) + .isInstanceOf(InvalidReservationException.class) + .hasMessageContaining("Only pending"); + } + + @Test + @DisplayName("With non-existing ID throws ReservationNotFoundException") + void deleteRequest_WithNonExistingId_ThrowsReservationNotFoundException() { + UUID id = UUID.randomUUID(); + when(reservationRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> reservationService.deleteRequest(id, GUEST_CONTEXT)) + .isInstanceOf(ReservationNotFoundException.class); + } + + @Test + @DisplayName("Host cannot delete guest's reservation") + void deleteRequest_WithHostTryingToDelete_ThrowsForbiddenException() { + var reservation = createReservation(); + + when(reservationRepository.findById(RESERVATION_ID)).thenReturn(Optional.of(reservation)); + + assertThatThrownBy(() -> reservationService.deleteRequest(RESERVATION_ID, HOST_CONTEXT)) + .isInstanceOf(ForbiddenException.class); + } + } +} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 0000000..9da01ff --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,7 @@ +spring.application.name=reservation-test + +# Disable tracing in tests +management.tracing.enabled=false + +# Logging +logging.level.com.devoops=DEBUG