diff --git a/build.gradle.kts b/build.gradle.kts index 34840c0..d226bb3 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,8 +1,11 @@ +import com.google.protobuf.gradle.* + plugins { java jacoco id("org.springframework.boot") version "4.0.1" id("io.spring.dependency-management") version "1.1.7" + id("com.google.protobuf") version "0.9.4" } group = "com.devoops" @@ -19,6 +22,8 @@ repositories { mavenCentral() } +val grpcVersion = "1.68.0" + dependencies { // Web and Core implementation("org.springframework.boot:spring-boot-starter-webmvc") @@ -47,6 +52,13 @@ dependencies { // MinIO S3-compatible object storage implementation("io.minio:minio:8.5.7") + // gRPC Client + implementation("net.devh:grpc-client-spring-boot-starter:3.1.0.RELEASE") + implementation("io.grpc:grpc-protobuf:$grpcVersion") + implementation("io.grpc:grpc-stub:$grpcVersion") + implementation("io.grpc:grpc-netty-shaded:$grpcVersion") + compileOnly("javax.annotation:javax.annotation-api:1.3.2") + // Tracing (Zipkin) implementation("org.springframework.boot:spring-boot-micrometer-tracing-brave") implementation("org.springframework.boot:spring-boot-starter-zipkin") @@ -69,6 +81,24 @@ dependencies { testRuntimeOnly("org.junit.platform:junit-platform-launcher") } +protobuf { + protoc { + artifact = "com.google.protobuf:protoc:3.25.5" + } + plugins { + id("grpc") { + artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion" + } + } + generateProtoTasks { + all().forEach { task -> + task.plugins { + id("grpc") + } + } + } +} + tasks.withType { useJUnitPlatform() finalizedBy(tasks.jacocoTestReport) diff --git a/environment/.local.env b/environment/.local.env index 09a8f4c..375ba1e 100644 --- a/environment/.local.env +++ b/environment/.local.env @@ -12,3 +12,7 @@ MINIO_ENDPOINT=http://devoops-minio:9000 MINIO_ACCESS_KEY=devoops MINIO_SECRET_KEY=devoops123 MINIO_BUCKET=accommodation-photos + +# gRPC +RESERVATION_GRPC_HOST=devoops-reservation-service +RESERVATION_GRPC_PORT=9090 diff --git a/src/main/java/com/devoops/accommodation/controller/AvailabilityPeriodController.java b/src/main/java/com/devoops/accommodation/controller/AvailabilityPeriodController.java new file mode 100644 index 0000000..b4c2150 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/controller/AvailabilityPeriodController.java @@ -0,0 +1,67 @@ +package com.devoops.accommodation.controller; + +import com.devoops.accommodation.config.RequireRole; +import com.devoops.accommodation.config.UserContext; +import com.devoops.accommodation.dto.request.CreateAvailabilityPeriodRequest; +import com.devoops.accommodation.dto.request.UpdateAvailabilityPeriodRequest; +import com.devoops.accommodation.dto.response.AvailabilityPeriodResponse; +import com.devoops.accommodation.service.AvailabilityPeriodService; +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/accommodation/{accommodationId}/availability") +@RequiredArgsConstructor +public class AvailabilityPeriodController { + + private final AvailabilityPeriodService availabilityPeriodService; + + @PostMapping + @RequireRole("HOST") + public ResponseEntity create( + @PathVariable UUID accommodationId, + @Valid @RequestBody CreateAvailabilityPeriodRequest request, + UserContext userContext) { + AvailabilityPeriodResponse response = availabilityPeriodService.create(accommodationId, request, userContext); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + @GetMapping + public ResponseEntity> getByAccommodationId( + @PathVariable UUID accommodationId) { + return ResponseEntity.ok(availabilityPeriodService.getByAccommodationId(accommodationId)); + } + + @GetMapping("/{periodId}") + public ResponseEntity getById( + @PathVariable UUID accommodationId, + @PathVariable UUID periodId) { + return ResponseEntity.ok(availabilityPeriodService.getById(accommodationId, periodId)); + } + + @PutMapping("/{periodId}") + @RequireRole("HOST") + public ResponseEntity update( + @PathVariable UUID accommodationId, + @PathVariable UUID periodId, + @Valid @RequestBody UpdateAvailabilityPeriodRequest request, + UserContext userContext) { + return ResponseEntity.ok(availabilityPeriodService.update(accommodationId, periodId, request, userContext)); + } + + @DeleteMapping("/{periodId}") + @RequireRole("HOST") + public ResponseEntity delete( + @PathVariable UUID accommodationId, + @PathVariable UUID periodId, + UserContext userContext) { + availabilityPeriodService.delete(accommodationId, periodId, userContext); + return ResponseEntity.noContent().build(); + } +} diff --git a/src/main/java/com/devoops/accommodation/dto/request/CreateAvailabilityPeriodRequest.java b/src/main/java/com/devoops/accommodation/dto/request/CreateAvailabilityPeriodRequest.java new file mode 100644 index 0000000..f459543 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/dto/request/CreateAvailabilityPeriodRequest.java @@ -0,0 +1,13 @@ +package com.devoops.accommodation.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public record CreateAvailabilityPeriodRequest( + @NotNull(message = "Start date is required") LocalDate startDate, + @NotNull(message = "End date is required") LocalDate endDate, + @NotNull(message = "Price per day is required") @Positive(message = "Price per day must be positive") BigDecimal pricePerDay +) {} diff --git a/src/main/java/com/devoops/accommodation/dto/request/UpdateAvailabilityPeriodRequest.java b/src/main/java/com/devoops/accommodation/dto/request/UpdateAvailabilityPeriodRequest.java new file mode 100644 index 0000000..a95d280 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/dto/request/UpdateAvailabilityPeriodRequest.java @@ -0,0 +1,12 @@ +package com.devoops.accommodation.dto.request; + +import jakarta.validation.constraints.Positive; + +import java.math.BigDecimal; +import java.time.LocalDate; + +public record UpdateAvailabilityPeriodRequest( + LocalDate startDate, + LocalDate endDate, + @Positive(message = "Price per day must be positive") BigDecimal pricePerDay +) {} diff --git a/src/main/java/com/devoops/accommodation/dto/response/AvailabilityPeriodResponse.java b/src/main/java/com/devoops/accommodation/dto/response/AvailabilityPeriodResponse.java new file mode 100644 index 0000000..7289bfa --- /dev/null +++ b/src/main/java/com/devoops/accommodation/dto/response/AvailabilityPeriodResponse.java @@ -0,0 +1,16 @@ +package com.devoops.accommodation.dto.response; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.UUID; + +public record AvailabilityPeriodResponse( + UUID id, + UUID accommodationId, + LocalDate startDate, + LocalDate endDate, + BigDecimal pricePerDay, + LocalDateTime createdAt, + LocalDateTime updatedAt +) {} diff --git a/src/main/java/com/devoops/accommodation/entity/AvailabilityPeriod.java b/src/main/java/com/devoops/accommodation/entity/AvailabilityPeriod.java new file mode 100644 index 0000000..b9bd867 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/entity/AvailabilityPeriod.java @@ -0,0 +1,36 @@ +package com.devoops.accommodation.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; +import org.hibernate.annotations.SQLRestriction; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +@Entity +@Table(name = "availability_periods") +@SQLRestriction("is_deleted = false") +@Getter +@Setter +@NoArgsConstructor +@SuperBuilder +public class AvailabilityPeriod extends BaseEntity { + + @Column(name = "accommodation_id", nullable = false) + private UUID accommodationId; + + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @Column(name = "end_date", nullable = false) + private LocalDate endDate; + + @Column(name = "price_per_day", nullable = false, precision = 12, scale = 2) + private BigDecimal pricePerDay; +} diff --git a/src/main/java/com/devoops/accommodation/exception/AvailabilityPeriodNotFoundException.java b/src/main/java/com/devoops/accommodation/exception/AvailabilityPeriodNotFoundException.java new file mode 100644 index 0000000..686b6fc --- /dev/null +++ b/src/main/java/com/devoops/accommodation/exception/AvailabilityPeriodNotFoundException.java @@ -0,0 +1,7 @@ +package com.devoops.accommodation.exception; + +public class AvailabilityPeriodNotFoundException extends RuntimeException { + public AvailabilityPeriodNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/devoops/accommodation/exception/GlobalExceptionHandler.java b/src/main/java/com/devoops/accommodation/exception/GlobalExceptionHandler.java index 2f48f7e..c66ec8b 100644 --- a/src/main/java/com/devoops/accommodation/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/devoops/accommodation/exception/GlobalExceptionHandler.java @@ -71,4 +71,19 @@ public ProblemDetail handleInvalidContentType(InvalidContentTypeException ex) { public ProblemDetail handleMaxUploadSize(MaxUploadSizeExceededException ex) { return ProblemDetail.forStatusAndDetail(HttpStatus.PAYLOAD_TOO_LARGE, "File size exceeds the maximum allowed limit"); } + + @ExceptionHandler(AvailabilityPeriodNotFoundException.class) + public ProblemDetail handleAvailabilityPeriodNotFound(AvailabilityPeriodNotFoundException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.NOT_FOUND, ex.getMessage()); + } + + @ExceptionHandler(OverlappingAvailabilityPeriodException.class) + public ProblemDetail handleOverlappingAvailabilityPeriod(OverlappingAvailabilityPeriodException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage()); + } + + @ExceptionHandler(ReservationConflictException.class) + public ProblemDetail handleReservationConflict(ReservationConflictException ex) { + return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage()); + } } diff --git a/src/main/java/com/devoops/accommodation/exception/OverlappingAvailabilityPeriodException.java b/src/main/java/com/devoops/accommodation/exception/OverlappingAvailabilityPeriodException.java new file mode 100644 index 0000000..a1f9b4a --- /dev/null +++ b/src/main/java/com/devoops/accommodation/exception/OverlappingAvailabilityPeriodException.java @@ -0,0 +1,7 @@ +package com.devoops.accommodation.exception; + +public class OverlappingAvailabilityPeriodException extends RuntimeException { + public OverlappingAvailabilityPeriodException(String message) { + super(message); + } +} diff --git a/src/main/java/com/devoops/accommodation/exception/ReservationConflictException.java b/src/main/java/com/devoops/accommodation/exception/ReservationConflictException.java new file mode 100644 index 0000000..7553aa7 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/exception/ReservationConflictException.java @@ -0,0 +1,7 @@ +package com.devoops.accommodation.exception; + +public class ReservationConflictException extends RuntimeException { + public ReservationConflictException(String message) { + super(message); + } +} diff --git a/src/main/java/com/devoops/accommodation/grpc/ReservationGrpcClient.java b/src/main/java/com/devoops/accommodation/grpc/ReservationGrpcClient.java new file mode 100644 index 0000000..f743ba7 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/grpc/ReservationGrpcClient.java @@ -0,0 +1,33 @@ +package com.devoops.accommodation.grpc; + +import com.devoops.accommodation.grpc.proto.CheckReservationsExistRequest; +import com.devoops.accommodation.grpc.proto.CheckReservationsExistResponse; +import com.devoops.accommodation.grpc.proto.ReservationInternalServiceGrpc; +import lombok.extern.slf4j.Slf4j; +import net.devh.boot.grpc.client.inject.GrpcClient; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.UUID; + +@Component +@Slf4j +public class ReservationGrpcClient { + + @GrpcClient("reservation-service") + private ReservationInternalServiceGrpc.ReservationInternalServiceBlockingStub reservationStub; + + public boolean hasApprovedReservations(UUID accommodationId, LocalDate startDate, LocalDate endDate) { + log.debug("gRPC: Checking approved reservations for accommodation {} between {} and {}", + accommodationId, startDate, endDate); + + CheckReservationsExistRequest request = CheckReservationsExistRequest.newBuilder() + .setAccommodationId(accommodationId.toString()) + .setStartDate(startDate.toString()) + .setEndDate(endDate.toString()) + .build(); + + CheckReservationsExistResponse response = reservationStub.checkReservationsExist(request); + return response.getHasReservations(); + } +} diff --git a/src/main/java/com/devoops/accommodation/mapper/AvailabilityPeriodMapper.java b/src/main/java/com/devoops/accommodation/mapper/AvailabilityPeriodMapper.java new file mode 100644 index 0000000..2587e61 --- /dev/null +++ b/src/main/java/com/devoops/accommodation/mapper/AvailabilityPeriodMapper.java @@ -0,0 +1,24 @@ +package com.devoops.accommodation.mapper; + +import com.devoops.accommodation.dto.request.CreateAvailabilityPeriodRequest; +import com.devoops.accommodation.dto.response.AvailabilityPeriodResponse; +import com.devoops.accommodation.entity.AvailabilityPeriod; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface AvailabilityPeriodMapper { + + @Mapping(target = "id", ignore = true) + @Mapping(target = "accommodationId", ignore = true) + @Mapping(target = "createdAt", ignore = true) + @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "isDeleted", ignore = true) + AvailabilityPeriod toEntity(CreateAvailabilityPeriodRequest request); + + AvailabilityPeriodResponse toResponse(AvailabilityPeriod period); + + List toResponseList(List periods); +} diff --git a/src/main/java/com/devoops/accommodation/repository/AvailabilityPeriodRepository.java b/src/main/java/com/devoops/accommodation/repository/AvailabilityPeriodRepository.java new file mode 100644 index 0000000..39d629e --- /dev/null +++ b/src/main/java/com/devoops/accommodation/repository/AvailabilityPeriodRepository.java @@ -0,0 +1,44 @@ +package com.devoops.accommodation.repository; + +import com.devoops.accommodation.entity.AvailabilityPeriod; +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.Optional; +import java.util.UUID; + +public interface AvailabilityPeriodRepository extends JpaRepository { + + List findByAccommodationIdOrderByStartDateAsc(UUID accommodationId); + + Optional findByIdAndAccommodationId(UUID id, UUID accommodationId); + + @Query(""" + SELECT ap FROM AvailabilityPeriod ap + WHERE ap.accommodationId = :accommodationId + AND ap.startDate < :endDate + AND ap.endDate > :startDate + """) + List findOverlapping( + @Param("accommodationId") UUID accommodationId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate + ); + + @Query(""" + SELECT ap FROM AvailabilityPeriod ap + WHERE ap.accommodationId = :accommodationId + AND ap.id != :excludeId + AND ap.startDate < :endDate + AND ap.endDate > :startDate + """) + List findOverlappingExcluding( + @Param("accommodationId") UUID accommodationId, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate, + @Param("excludeId") UUID excludeId + ); +} diff --git a/src/main/java/com/devoops/accommodation/service/AvailabilityPeriodService.java b/src/main/java/com/devoops/accommodation/service/AvailabilityPeriodService.java new file mode 100644 index 0000000..6d2749a --- /dev/null +++ b/src/main/java/com/devoops/accommodation/service/AvailabilityPeriodService.java @@ -0,0 +1,153 @@ +package com.devoops.accommodation.service; + +import com.devoops.accommodation.config.UserContext; +import com.devoops.accommodation.dto.request.CreateAvailabilityPeriodRequest; +import com.devoops.accommodation.dto.request.UpdateAvailabilityPeriodRequest; +import com.devoops.accommodation.dto.response.AvailabilityPeriodResponse; +import com.devoops.accommodation.entity.Accommodation; +import com.devoops.accommodation.entity.AvailabilityPeriod; +import com.devoops.accommodation.exception.*; +import com.devoops.accommodation.grpc.ReservationGrpcClient; +import com.devoops.accommodation.mapper.AvailabilityPeriodMapper; +import com.devoops.accommodation.repository.AccommodationRepository; +import com.devoops.accommodation.repository.AvailabilityPeriodRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AvailabilityPeriodService { + + private final AvailabilityPeriodRepository availabilityPeriodRepository; + private final AccommodationRepository accommodationRepository; + private final AvailabilityPeriodMapper availabilityPeriodMapper; + private final ReservationGrpcClient reservationGrpcClient; + + @Transactional + public AvailabilityPeriodResponse create(UUID accommodationId, CreateAvailabilityPeriodRequest request, + UserContext userContext) { + Accommodation accommodation = findAccommodationOrThrow(accommodationId); + validateOwnership(accommodation, userContext); + validateDates(request.startDate(), request.endDate()); + + List overlapping = availabilityPeriodRepository.findOverlapping( + accommodationId, request.startDate(), request.endDate()); + if (!overlapping.isEmpty()) { + throw new OverlappingAvailabilityPeriodException( + "The availability period overlaps with an existing period"); + } + + AvailabilityPeriod period = availabilityPeriodMapper.toEntity(request); + period.setAccommodationId(accommodationId); + + period = availabilityPeriodRepository.saveAndFlush(period); + log.info("Created availability period {} for accommodation {}", period.getId(), accommodationId); + return availabilityPeriodMapper.toResponse(period); + } + + @Transactional(readOnly = true) + public List getByAccommodationId(UUID accommodationId) { + findAccommodationOrThrow(accommodationId); + List periods = availabilityPeriodRepository + .findByAccommodationIdOrderByStartDateAsc(accommodationId); + return availabilityPeriodMapper.toResponseList(periods); + } + + @Transactional(readOnly = true) + public AvailabilityPeriodResponse getById(UUID accommodationId, UUID periodId) { + AvailabilityPeriod period = findPeriodOrThrow(accommodationId, periodId); + return availabilityPeriodMapper.toResponse(period); + } + + @Transactional + public AvailabilityPeriodResponse update(UUID accommodationId, UUID periodId, + UpdateAvailabilityPeriodRequest request, + UserContext userContext) { + Accommodation accommodation = findAccommodationOrThrow(accommodationId); + validateOwnership(accommodation, userContext); + + AvailabilityPeriod period = findPeriodOrThrow(accommodationId, periodId); + + // Check for approved reservations in the CURRENT period dates before allowing changes + if (reservationGrpcClient.hasApprovedReservations(accommodationId, + period.getStartDate(), period.getEndDate())) { + throw new ReservationConflictException( + "Cannot modify availability period: approved reservations exist in this date range"); + } + + // Apply partial updates + if (request.startDate() != null) { + period.setStartDate(request.startDate()); + } + if (request.endDate() != null) { + period.setEndDate(request.endDate()); + } + if (request.pricePerDay() != null) { + period.setPricePerDay(request.pricePerDay()); + } + + validateDates(period.getStartDate(), period.getEndDate()); + + // Validate new dates don't overlap with other periods + List overlapping = availabilityPeriodRepository.findOverlappingExcluding( + accommodationId, period.getStartDate(), period.getEndDate(), periodId); + if (!overlapping.isEmpty()) { + throw new OverlappingAvailabilityPeriodException( + "The updated availability period overlaps with an existing period"); + } + + period = availabilityPeriodRepository.saveAndFlush(period); + log.info("Updated availability period {} for accommodation {}", periodId, accommodationId); + return availabilityPeriodMapper.toResponse(period); + } + + @Transactional + public void delete(UUID accommodationId, UUID periodId, UserContext userContext) { + Accommodation accommodation = findAccommodationOrThrow(accommodationId); + validateOwnership(accommodation, userContext); + + AvailabilityPeriod period = findPeriodOrThrow(accommodationId, periodId); + + // Check for approved reservations before allowing deletion + if (reservationGrpcClient.hasApprovedReservations(accommodationId, + period.getStartDate(), period.getEndDate())) { + throw new ReservationConflictException( + "Cannot delete availability period: approved reservations exist in this date range"); + } + + period.setDeleted(true); + availabilityPeriodRepository.save(period); + log.info("Deleted availability period {} for accommodation {}", periodId, accommodationId); + } + + private Accommodation findAccommodationOrThrow(UUID accommodationId) { + return accommodationRepository.findById(accommodationId) + .orElseThrow(() -> new AccommodationNotFoundException( + "Accommodation not found with id: " + accommodationId)); + } + + private AvailabilityPeriod findPeriodOrThrow(UUID accommodationId, UUID periodId) { + return availabilityPeriodRepository.findByIdAndAccommodationId(periodId, accommodationId) + .orElseThrow(() -> new AvailabilityPeriodNotFoundException( + "Availability period not found with id: " + periodId)); + } + + private void validateOwnership(Accommodation accommodation, UserContext userContext) { + if (!accommodation.getHostId().equals(userContext.userId())) { + throw new ForbiddenException("You are not the owner of this accommodation"); + } + } + + private void validateDates(LocalDate startDate, LocalDate endDate) { + if (!endDate.isAfter(startDate)) { + throw new IllegalArgumentException("End date must be after start date"); + } + } +} diff --git a/src/main/proto/reservation_internal.proto b/src/main/proto/reservation_internal.proto new file mode 100644 index 0000000..6af4d68 --- /dev/null +++ b/src/main/proto/reservation_internal.proto @@ -0,0 +1,20 @@ +syntax = "proto3"; + +package reservation; + +option java_multiple_files = true; +option java_package = "com.devoops.accommodation.grpc.proto"; + +service ReservationInternalService { + rpc CheckReservationsExist(CheckReservationsExistRequest) returns (CheckReservationsExistResponse); +} + +message CheckReservationsExistRequest { + string accommodation_id = 1; + string start_date = 2; + string end_date = 3; +} + +message CheckReservationsExistResponse { + bool has_reservations = 1; +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index f7053cc..e63e04c 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -44,3 +44,7 @@ minio.bucket=${MINIO_BUCKET:accommodation-photos} # Photo configuration app.photo.max-photos-per-accommodation=20 app.photo.allowed-content-types=image/jpeg,image/png,image/webp + +# gRPC Client +grpc.client.reservation-service.address=static://${RESERVATION_GRPC_HOST:devoops-reservation-service}:${RESERVATION_GRPC_PORT:9090} +grpc.client.reservation-service.negotiationType=plaintext diff --git a/src/main/resources/db/migration/V5__create_availability_periods_table.sql b/src/main/resources/db/migration/V5__create_availability_periods_table.sql new file mode 100644 index 0000000..2a6b295 --- /dev/null +++ b/src/main/resources/db/migration/V5__create_availability_periods_table.sql @@ -0,0 +1,19 @@ +CREATE TABLE availability_periods ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + accommodation_id UUID NOT NULL REFERENCES accommodations(id) ON DELETE CASCADE, + start_date DATE NOT NULL, + end_date DATE NOT NULL, + price_per_day NUMERIC(12, 2) NOT NULL, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT now(), + updated_at TIMESTAMP NOT NULL DEFAULT now(), + + CONSTRAINT chk_availability_dates CHECK (end_date > start_date), + CONSTRAINT chk_price_positive CHECK (price_per_day > 0) +); + +CREATE INDEX idx_availability_periods_accommodation_id ON availability_periods(accommodation_id); +CREATE INDEX idx_availability_periods_dates ON availability_periods(start_date, end_date); +CREATE INDEX idx_availability_periods_accommodation_dates + ON availability_periods(accommodation_id, start_date, end_date) + WHERE is_deleted = false; diff --git a/src/test/java/com/devoops/accommodation/controller/AvailabilityPeriodControllerTest.java b/src/test/java/com/devoops/accommodation/controller/AvailabilityPeriodControllerTest.java new file mode 100644 index 0000000..a24694c --- /dev/null +++ b/src/test/java/com/devoops/accommodation/controller/AvailabilityPeriodControllerTest.java @@ -0,0 +1,298 @@ +package com.devoops.accommodation.controller; + +import com.devoops.accommodation.config.RoleAuthorizationInterceptor; +import com.devoops.accommodation.config.UserContext; +import com.devoops.accommodation.config.UserContextResolver; +import com.devoops.accommodation.dto.response.AvailabilityPeriodResponse; +import com.devoops.accommodation.exception.*; +import com.devoops.accommodation.service.AvailabilityPeriodService; +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 AvailabilityPeriodControllerTest { + + private MockMvc mockMvc; + + @Mock + private AvailabilityPeriodService availabilityPeriodService; + + @InjectMocks + private AvailabilityPeriodController availabilityPeriodController; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID ACCOMMODATION_ID = UUID.randomUUID(); + private static final UUID PERIOD_ID = UUID.randomUUID(); + private static final String BASE_PATH = "/api/accommodation/" + ACCOMMODATION_ID + "/availability"; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(availabilityPeriodController) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new UserContextResolver()) + .addInterceptors(new RoleAuthorizationInterceptor()) + .build(); + } + + private AvailabilityPeriodResponse createResponse() { + return new AvailabilityPeriodResponse( + PERIOD_ID, ACCOMMODATION_ID, + LocalDate.of(2026, 6, 1), LocalDate.of(2026, 6, 30), + new BigDecimal("100.00"), + LocalDateTime.now(), LocalDateTime.now() + ); + } + + private Map validCreateRequest() { + return Map.of( + "startDate", "2026-06-01", + "endDate", "2026-06-30", + "pricePerDay", 100.00 + ); + } + + @Nested + @DisplayName("POST /api/accommodation/{accommodationId}/availability") + class CreateEndpoint { + + @Test + @DisplayName("With valid request returns 201") + void create_WithValidRequest_Returns201() throws Exception { + when(availabilityPeriodService.create(eq(ACCOMMODATION_ID), any(), any(UserContext.class))) + .thenReturn(createResponse()); + + 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().isCreated()) + .andExpect(jsonPath("$.id").value(PERIOD_ID.toString())) + .andExpect(jsonPath("$.accommodationId").value(ACCOMMODATION_ID.toString())); + } + + @Test + @DisplayName("With missing auth headers returns 401") + void create_WithMissingAuth_Returns401() throws Exception { + mockMvc.perform(post(BASE_PATH) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isUnauthorized()); + } + + @Test + @DisplayName("With GUEST role returns 403") + void create_WithGuestRole_Returns403() throws Exception { + mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "GUEST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(validCreateRequest()))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With missing required field returns 400") + void create_WithMissingField_Returns400() throws Exception { + var request = Map.of( + "startDate", "2026-06-01", + "endDate", "2026-06-30" + // missing pricePerDay + ); + + mockMvc.perform(post(BASE_PATH) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + } + + @Nested + @DisplayName("GET /api/accommodation/{accommodationId}/availability") + class GetAllEndpoint { + + @Test + @DisplayName("Returns 200 with list") + void getAll_Returns200WithList() throws Exception { + when(availabilityPeriodService.getByAccommodationId(ACCOMMODATION_ID)) + .thenReturn(List.of(createResponse())); + + mockMvc.perform(get(BASE_PATH)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(PERIOD_ID.toString())); + } + + @Test + @DisplayName("With non-existing accommodation returns 404") + void getAll_WithNonExisting_Returns404() throws Exception { + when(availabilityPeriodService.getByAccommodationId(ACCOMMODATION_ID)) + .thenThrow(new AccommodationNotFoundException("Not found")); + + mockMvc.perform(get(BASE_PATH)) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("GET /api/accommodation/{accommodationId}/availability/{periodId}") + class GetByIdEndpoint { + + @Test + @DisplayName("With existing ID returns 200") + void getById_Returns200() throws Exception { + when(availabilityPeriodService.getById(ACCOMMODATION_ID, PERIOD_ID)) + .thenReturn(createResponse()); + + mockMvc.perform(get(BASE_PATH + "/" + PERIOD_ID)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(PERIOD_ID.toString())); + } + + @Test + @DisplayName("With non-existing ID returns 404") + void getById_WithNonExisting_Returns404() throws Exception { + UUID missingId = UUID.randomUUID(); + when(availabilityPeriodService.getById(ACCOMMODATION_ID, missingId)) + .thenThrow(new AvailabilityPeriodNotFoundException("Not found")); + + mockMvc.perform(get(BASE_PATH + "/" + missingId)) + .andExpect(status().isNotFound()); + } + } + + @Nested + @DisplayName("PUT /api/accommodation/{accommodationId}/availability/{periodId}") + class UpdateEndpoint { + + @Test + @DisplayName("With valid request returns 200") + void update_WithValidRequest_Returns200() throws Exception { + when(availabilityPeriodService.update(eq(ACCOMMODATION_ID), eq(PERIOD_ID), any(), any(UserContext.class))) + .thenReturn(createResponse()); + + var request = Map.of("pricePerDay", 150.00); + + mockMvc.perform(put(BASE_PATH + "/" + PERIOD_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()); + } + + @Test + @DisplayName("With wrong owner returns 403") + void update_WithWrongOwner_Returns403() throws Exception { + when(availabilityPeriodService.update(eq(ACCOMMODATION_ID), eq(PERIOD_ID), any(), any(UserContext.class))) + .thenThrow(new ForbiddenException("Not the owner")); + + var request = Map.of("pricePerDay", 150.00); + + mockMvc.perform(put(BASE_PATH + "/" + PERIOD_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With reservation conflict returns 409") + void update_WithReservationConflict_Returns409() throws Exception { + when(availabilityPeriodService.update(eq(ACCOMMODATION_ID), eq(PERIOD_ID), any(), any(UserContext.class))) + .thenThrow(new ReservationConflictException("Reservations exist")); + + var request = Map.of("pricePerDay", 150.00); + + mockMvc.perform(put(BASE_PATH + "/" + PERIOD_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()); + } + } + + @Nested + @DisplayName("DELETE /api/accommodation/{accommodationId}/availability/{periodId}") + class DeleteEndpoint { + + @Test + @DisplayName("With valid request returns 204") + void delete_WithValidRequest_Returns204() throws Exception { + doNothing().when(availabilityPeriodService) + .delete(eq(ACCOMMODATION_ID), eq(PERIOD_ID), any(UserContext.class)); + + mockMvc.perform(delete(BASE_PATH + "/" + PERIOD_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isNoContent()); + } + + @Test + @DisplayName("With wrong owner returns 403") + void delete_WithWrongOwner_Returns403() throws Exception { + doThrow(new ForbiddenException("Not the owner")) + .when(availabilityPeriodService).delete(eq(ACCOMMODATION_ID), eq(PERIOD_ID), any(UserContext.class)); + + mockMvc.perform(delete(BASE_PATH + "/" + PERIOD_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("With non-existing period returns 404") + void delete_WithNonExisting_Returns404() throws Exception { + UUID missingId = UUID.randomUUID(); + doThrow(new AvailabilityPeriodNotFoundException("Not found")) + .when(availabilityPeriodService).delete(eq(ACCOMMODATION_ID), eq(missingId), any(UserContext.class)); + + mockMvc.perform(delete(BASE_PATH + "/" + missingId) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("With reservation conflict returns 409") + void delete_WithReservationConflict_Returns409() throws Exception { + doThrow(new ReservationConflictException("Reservations exist")) + .when(availabilityPeriodService).delete(eq(ACCOMMODATION_ID), eq(PERIOD_ID), any(UserContext.class)); + + mockMvc.perform(delete(BASE_PATH + "/" + PERIOD_ID) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isConflict()); + } + } +} diff --git a/src/test/java/com/devoops/accommodation/integration/AvailabilityPeriodIntegrationTest.java b/src/test/java/com/devoops/accommodation/integration/AvailabilityPeriodIntegrationTest.java new file mode 100644 index 0000000..0e3c3bc --- /dev/null +++ b/src/test/java/com/devoops/accommodation/integration/AvailabilityPeriodIntegrationTest.java @@ -0,0 +1,289 @@ +package com.devoops.accommodation.integration; + +import com.devoops.accommodation.grpc.ReservationGrpcClient; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +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.MinIOContainer; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.Map; +import java.util.UUID; + +import static org.hamcrest.Matchers.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +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 AvailabilityPeriodIntegrationTest { + + @Container + static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName("accommodation_db_test") + .withUsername("test") + .withPassword("test"); + + @Container + static MinIOContainer minio = new MinIOContainer("minio/minio:RELEASE.2024-01-31T20-20-33Z"); + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private ReservationGrpcClient reservationGrpcClient; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private static String accommodationId; + private static String periodId; + private static String secondPeriodId; + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID OTHER_HOST_ID = UUID.randomUUID(); + + @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); + + registry.add("minio.endpoint", minio::getS3URL); + registry.add("minio.access-key", minio::getUserName); + registry.add("minio.secret-key", minio::getPassword); + registry.add("minio.bucket", () -> "test-accommodation-photos"); + } + + @Test + @Order(1) + @DisplayName("Setup: Create accommodation for availability tests") + void setup_CreateAccommodation() throws Exception { + var request = Map.of( + "name", "Availability Test Apartment", + "address", "123 Availability St", + "minGuests", 1, + "maxGuests", 4, + "pricingMode", "PER_GUEST", + "approvalMode", "MANUAL" + ); + + MvcResult result = mockMvc.perform(post("/api/accommodation") + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + accommodationId = objectMapper.readTree(result.getResponse().getContentAsString()) + .get("id").asText(); + } + + private String basePath() { + return "/api/accommodation/" + accommodationId + "/availability"; + } + + @Test + @Order(2) + @DisplayName("Create availability period returns 201") + void create_WithValidRequest_Returns201() throws Exception { + var request = Map.of( + "startDate", "2026-06-01", + "endDate", "2026-06-30", + "pricePerDay", 100.00 + ); + + MvcResult result = mockMvc.perform(post(basePath()) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.accommodationId").value(accommodationId)) + .andExpect(jsonPath("$.startDate").value("2026-06-01")) + .andExpect(jsonPath("$.endDate").value("2026-06-30")) + .andExpect(jsonPath("$.pricePerDay").value(100.00)) + .andExpect(jsonPath("$.id").isNotEmpty()) + .andReturn(); + + periodId = objectMapper.readTree(result.getResponse().getContentAsString()) + .get("id").asText(); + } + + @Test + @Order(3) + @DisplayName("Create non-overlapping period returns 201") + void create_NonOverlappingPeriod_Returns201() throws Exception { + var request = Map.of( + "startDate", "2026-07-01", + "endDate", "2026-07-31", + "pricePerDay", 120.00 + ); + + MvcResult result = mockMvc.perform(post(basePath()) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andReturn(); + + secondPeriodId = objectMapper.readTree(result.getResponse().getContentAsString()) + .get("id").asText(); + } + + @Test + @Order(4) + @DisplayName("Create overlapping period returns 409") + void create_OverlappingPeriod_Returns409() throws Exception { + var request = Map.of( + "startDate", "2026-06-15", + "endDate", "2026-07-15", + "pricePerDay", 110.00 + ); + + mockMvc.perform(post(basePath()) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()); + } + + @Test + @Order(5) + @DisplayName("Get all periods returns sorted list") + void getAll_ReturnsSortedList() throws Exception { + mockMvc.perform(get(basePath())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(2))) + .andExpect(jsonPath("$[0].startDate").value("2026-06-01")) + .andExpect(jsonPath("$[1].startDate").value("2026-07-01")); + } + + @Test + @Order(6) + @DisplayName("Get period by ID returns 200") + void getById_Returns200() throws Exception { + mockMvc.perform(get(basePath() + "/" + periodId)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.id").value(periodId)) + .andExpect(jsonPath("$.pricePerDay").value(100.00)); + } + + @Test + @Order(7) + @DisplayName("Get non-existing period returns 404") + void getById_NonExisting_Returns404() throws Exception { + mockMvc.perform(get(basePath() + "/" + UUID.randomUUID())) + .andExpect(status().isNotFound()); + } + + @Test + @Order(8) + @DisplayName("Update period with no reservations returns 200") + void update_WithNoReservations_Returns200() throws Exception { + when(reservationGrpcClient.hasApprovedReservations(any(), any(), any())).thenReturn(false); + + var request = Map.of("pricePerDay", 150.00); + + mockMvc.perform(put(basePath() + "/" + periodId) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.pricePerDay").value(150.00)) + .andExpect(jsonPath("$.startDate").value("2026-06-01")); + } + + @Test + @Order(9) + @DisplayName("Update period by non-owner returns 403") + void update_ByNonOwner_Returns403() throws Exception { + var request = Map.of("pricePerDay", 200.00); + + mockMvc.perform(put(basePath() + "/" + periodId) + .header("X-User-Id", OTHER_HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isForbidden()); + } + + @Test + @Order(10) + @DisplayName("Update period with approved reservations returns 409") + void update_WithReservations_Returns409() throws Exception { + when(reservationGrpcClient.hasApprovedReservations(any(), any(), any())).thenReturn(true); + + var request = Map.of("pricePerDay", 200.00); + + mockMvc.perform(put(basePath() + "/" + periodId) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()); + } + + @Test + @Order(11) + @DisplayName("Delete period with no reservations returns 204") + void delete_WithNoReservations_Returns204() throws Exception { + when(reservationGrpcClient.hasApprovedReservations(any(), any(), any())).thenReturn(false); + + mockMvc.perform(delete(basePath() + "/" + secondPeriodId) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isNoContent()); + } + + @Test + @Order(12) + @DisplayName("After delete, get by ID returns 404 (soft-delete)") + void afterDelete_GetById_Returns404() throws Exception { + mockMvc.perform(get(basePath() + "/" + secondPeriodId)) + .andExpect(status().isNotFound()); + } + + @Test + @Order(13) + @DisplayName("After delete, get all returns only non-deleted periods") + void afterDelete_GetAll_ReturnsOnlyNonDeleted() throws Exception { + mockMvc.perform(get(basePath())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$", hasSize(1))) + .andExpect(jsonPath("$[0].id").value(periodId)); + } + + @Test + @Order(14) + @DisplayName("Delete period with approved reservations returns 409") + void delete_WithReservations_Returns409() throws Exception { + when(reservationGrpcClient.hasApprovedReservations(any(), any(), any())).thenReturn(true); + + mockMvc.perform(delete(basePath() + "/" + periodId) + .header("X-User-Id", HOST_ID.toString()) + .header("X-User-Role", "HOST")) + .andExpect(status().isConflict()); + } +} diff --git a/src/test/java/com/devoops/accommodation/service/AvailabilityPeriodServiceTest.java b/src/test/java/com/devoops/accommodation/service/AvailabilityPeriodServiceTest.java new file mode 100644 index 0000000..2b1fb18 --- /dev/null +++ b/src/test/java/com/devoops/accommodation/service/AvailabilityPeriodServiceTest.java @@ -0,0 +1,388 @@ +package com.devoops.accommodation.service; + +import com.devoops.accommodation.config.UserContext; +import com.devoops.accommodation.dto.request.CreateAvailabilityPeriodRequest; +import com.devoops.accommodation.dto.request.UpdateAvailabilityPeriodRequest; +import com.devoops.accommodation.dto.response.AvailabilityPeriodResponse; +import com.devoops.accommodation.entity.Accommodation; +import com.devoops.accommodation.entity.ApprovalMode; +import com.devoops.accommodation.entity.AvailabilityPeriod; +import com.devoops.accommodation.entity.PricingMode; +import com.devoops.accommodation.exception.*; +import com.devoops.accommodation.grpc.ReservationGrpcClient; +import com.devoops.accommodation.mapper.AvailabilityPeriodMapper; +import com.devoops.accommodation.repository.AccommodationRepository; +import com.devoops.accommodation.repository.AvailabilityPeriodRepository; +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 AvailabilityPeriodServiceTest { + + @Mock + private AvailabilityPeriodRepository availabilityPeriodRepository; + + @Mock + private AccommodationRepository accommodationRepository; + + @Mock + private AvailabilityPeriodMapper availabilityPeriodMapper; + + @Mock + private ReservationGrpcClient reservationGrpcClient; + + @InjectMocks + private AvailabilityPeriodService availabilityPeriodService; + + private static final UUID HOST_ID = UUID.randomUUID(); + private static final UUID ACCOMMODATION_ID = UUID.randomUUID(); + private static final UUID PERIOD_ID = UUID.randomUUID(); + private static final UserContext HOST_CONTEXT = new UserContext(HOST_ID, "HOST"); + private static final LocalDate START_DATE = LocalDate.of(2026, 6, 1); + private static final LocalDate END_DATE = LocalDate.of(2026, 6, 30); + + private Accommodation createAccommodation() { + return Accommodation.builder() + .id(ACCOMMODATION_ID) + .hostId(HOST_ID) + .name("Test Apartment") + .address("123 Test St") + .minGuests(1) + .maxGuests(4) + .pricingMode(PricingMode.PER_GUEST) + .approvalMode(ApprovalMode.MANUAL) + .build(); + } + + private AvailabilityPeriod createPeriod() { + return AvailabilityPeriod.builder() + .id(PERIOD_ID) + .accommodationId(ACCOMMODATION_ID) + .startDate(START_DATE) + .endDate(END_DATE) + .pricePerDay(new BigDecimal("100.00")) + .build(); + } + + private AvailabilityPeriodResponse createResponse() { + return new AvailabilityPeriodResponse( + PERIOD_ID, ACCOMMODATION_ID, START_DATE, END_DATE, + new BigDecimal("100.00"), LocalDateTime.now(), LocalDateTime.now() + ); + } + + @Nested + @DisplayName("Create") + class CreateTests { + + @Test + @DisplayName("With valid request returns availability period response") + void create_WithValidRequest_ReturnsResponse() { + var request = new CreateAvailabilityPeriodRequest(START_DATE, END_DATE, new BigDecimal("100.00")); + var accommodation = createAccommodation(); + var period = createPeriod(); + var response = createResponse(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(availabilityPeriodRepository.findOverlapping(ACCOMMODATION_ID, START_DATE, END_DATE)) + .thenReturn(List.of()); + when(availabilityPeriodMapper.toEntity(request)).thenReturn(period); + when(availabilityPeriodRepository.saveAndFlush(period)).thenReturn(period); + when(availabilityPeriodMapper.toResponse(period)).thenReturn(response); + + AvailabilityPeriodResponse result = availabilityPeriodService.create(ACCOMMODATION_ID, request, HOST_CONTEXT); + + assertThat(result).isEqualTo(response); + verify(availabilityPeriodRepository).saveAndFlush(period); + } + + @Test + @DisplayName("With non-existing accommodation throws AccommodationNotFoundException") + void create_WithNonExistingAccommodation_ThrowsNotFound() { + var request = new CreateAvailabilityPeriodRequest(START_DATE, END_DATE, new BigDecimal("100.00")); + UUID id = UUID.randomUUID(); + + when(accommodationRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> availabilityPeriodService.create(id, request, HOST_CONTEXT)) + .isInstanceOf(AccommodationNotFoundException.class); + } + + @Test + @DisplayName("With wrong owner throws ForbiddenException") + void create_WithWrongOwner_ThrowsForbidden() { + var request = new CreateAvailabilityPeriodRequest(START_DATE, END_DATE, new BigDecimal("100.00")); + var accommodation = createAccommodation(); + var otherUser = new UserContext(UUID.randomUUID(), "HOST"); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + + assertThatThrownBy(() -> availabilityPeriodService.create(ACCOMMODATION_ID, request, otherUser)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + @DisplayName("With invalid dates throws IllegalArgumentException") + void create_WithInvalidDates_ThrowsIllegalArgument() { + var request = new CreateAvailabilityPeriodRequest(END_DATE, START_DATE, new BigDecimal("100.00")); + var accommodation = createAccommodation(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + + assertThatThrownBy(() -> availabilityPeriodService.create(ACCOMMODATION_ID, request, HOST_CONTEXT)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("End date must be after start date"); + } + + @Test + @DisplayName("With overlapping period throws OverlappingAvailabilityPeriodException") + void create_WithOverlappingPeriod_ThrowsOverlapping() { + var request = new CreateAvailabilityPeriodRequest(START_DATE, END_DATE, new BigDecimal("100.00")); + var accommodation = createAccommodation(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(availabilityPeriodRepository.findOverlapping(ACCOMMODATION_ID, START_DATE, END_DATE)) + .thenReturn(List.of(createPeriod())); + + assertThatThrownBy(() -> availabilityPeriodService.create(ACCOMMODATION_ID, request, HOST_CONTEXT)) + .isInstanceOf(OverlappingAvailabilityPeriodException.class); + } + } + + @Nested + @DisplayName("GetByAccommodationId") + class GetByAccommodationIdTests { + + @Test + @DisplayName("Returns sorted list of periods") + void getByAccommodationId_ReturnsSortedList() { + var accommodation = createAccommodation(); + var periods = List.of(createPeriod()); + var responses = List.of(createResponse()); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(availabilityPeriodRepository.findByAccommodationIdOrderByStartDateAsc(ACCOMMODATION_ID)) + .thenReturn(periods); + when(availabilityPeriodMapper.toResponseList(periods)).thenReturn(responses); + + List result = availabilityPeriodService.getByAccommodationId(ACCOMMODATION_ID); + + assertThat(result).hasSize(1); + } + + @Test + @DisplayName("With non-existing accommodation throws AccommodationNotFoundException") + void getByAccommodationId_WithNonExisting_ThrowsNotFound() { + UUID id = UUID.randomUUID(); + when(accommodationRepository.findById(id)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> availabilityPeriodService.getByAccommodationId(id)) + .isInstanceOf(AccommodationNotFoundException.class); + } + } + + @Nested + @DisplayName("Update") + class UpdateTests { + + @Test + @DisplayName("With valid request returns updated response") + void update_WithValidRequest_ReturnsUpdatedResponse() { + var request = new UpdateAvailabilityPeriodRequest(null, null, new BigDecimal("150.00")); + var accommodation = createAccommodation(); + var period = createPeriod(); + var response = createResponse(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(availabilityPeriodRepository.findByIdAndAccommodationId(PERIOD_ID, ACCOMMODATION_ID)) + .thenReturn(Optional.of(period)); + when(reservationGrpcClient.hasApprovedReservations(ACCOMMODATION_ID, START_DATE, END_DATE)) + .thenReturn(false); + when(availabilityPeriodRepository.findOverlappingExcluding(ACCOMMODATION_ID, START_DATE, END_DATE, PERIOD_ID)) + .thenReturn(List.of()); + when(availabilityPeriodRepository.saveAndFlush(period)).thenReturn(period); + when(availabilityPeriodMapper.toResponse(period)).thenReturn(response); + + AvailabilityPeriodResponse result = availabilityPeriodService.update( + ACCOMMODATION_ID, PERIOD_ID, request, HOST_CONTEXT); + + assertThat(result).isEqualTo(response); + assertThat(period.getPricePerDay()).isEqualByComparingTo(new BigDecimal("150.00")); + } + + @Test + @DisplayName("With partial date update applies only provided fields") + void update_WithPartialDates_AppliesOnlyProvided() { + LocalDate newEndDate = LocalDate.of(2026, 7, 15); + var request = new UpdateAvailabilityPeriodRequest(null, newEndDate, null); + var accommodation = createAccommodation(); + var period = createPeriod(); + var response = createResponse(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(availabilityPeriodRepository.findByIdAndAccommodationId(PERIOD_ID, ACCOMMODATION_ID)) + .thenReturn(Optional.of(period)); + when(reservationGrpcClient.hasApprovedReservations(ACCOMMODATION_ID, START_DATE, END_DATE)) + .thenReturn(false); + when(availabilityPeriodRepository.findOverlappingExcluding(ACCOMMODATION_ID, START_DATE, newEndDate, PERIOD_ID)) + .thenReturn(List.of()); + when(availabilityPeriodRepository.saveAndFlush(period)).thenReturn(period); + when(availabilityPeriodMapper.toResponse(period)).thenReturn(response); + + availabilityPeriodService.update(ACCOMMODATION_ID, PERIOD_ID, request, HOST_CONTEXT); + + assertThat(period.getStartDate()).isEqualTo(START_DATE); + assertThat(period.getEndDate()).isEqualTo(newEndDate); + } + + @Test + @DisplayName("With wrong owner throws ForbiddenException") + void update_WithWrongOwner_ThrowsForbidden() { + var request = new UpdateAvailabilityPeriodRequest(null, null, new BigDecimal("150.00")); + var accommodation = createAccommodation(); + var otherUser = new UserContext(UUID.randomUUID(), "HOST"); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + + assertThatThrownBy(() -> availabilityPeriodService.update( + ACCOMMODATION_ID, PERIOD_ID, request, otherUser)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + @DisplayName("With non-existing period throws AvailabilityPeriodNotFoundException") + void update_WithNonExistingPeriod_ThrowsNotFound() { + var request = new UpdateAvailabilityPeriodRequest(null, null, new BigDecimal("150.00")); + var accommodation = createAccommodation(); + UUID missingPeriodId = UUID.randomUUID(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(availabilityPeriodRepository.findByIdAndAccommodationId(missingPeriodId, ACCOMMODATION_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> availabilityPeriodService.update( + ACCOMMODATION_ID, missingPeriodId, request, HOST_CONTEXT)) + .isInstanceOf(AvailabilityPeriodNotFoundException.class); + } + + @Test + @DisplayName("With existing reservations throws ReservationConflictException") + void update_WithExistingReservations_ThrowsConflict() { + var request = new UpdateAvailabilityPeriodRequest(null, null, new BigDecimal("150.00")); + var accommodation = createAccommodation(); + var period = createPeriod(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(availabilityPeriodRepository.findByIdAndAccommodationId(PERIOD_ID, ACCOMMODATION_ID)) + .thenReturn(Optional.of(period)); + when(reservationGrpcClient.hasApprovedReservations(ACCOMMODATION_ID, START_DATE, END_DATE)) + .thenReturn(true); + + assertThatThrownBy(() -> availabilityPeriodService.update( + ACCOMMODATION_ID, PERIOD_ID, request, HOST_CONTEXT)) + .isInstanceOf(ReservationConflictException.class); + } + + @Test + @DisplayName("With overlapping period after update throws OverlappingAvailabilityPeriodException") + void update_WithOverlappingPeriod_ThrowsOverlapping() { + var request = new UpdateAvailabilityPeriodRequest(null, null, new BigDecimal("150.00")); + var accommodation = createAccommodation(); + var period = createPeriod(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(availabilityPeriodRepository.findByIdAndAccommodationId(PERIOD_ID, ACCOMMODATION_ID)) + .thenReturn(Optional.of(period)); + when(reservationGrpcClient.hasApprovedReservations(ACCOMMODATION_ID, START_DATE, END_DATE)) + .thenReturn(false); + when(availabilityPeriodRepository.findOverlappingExcluding(ACCOMMODATION_ID, START_DATE, END_DATE, PERIOD_ID)) + .thenReturn(List.of(AvailabilityPeriod.builder().id(UUID.randomUUID()).build())); + + assertThatThrownBy(() -> availabilityPeriodService.update( + ACCOMMODATION_ID, PERIOD_ID, request, HOST_CONTEXT)) + .isInstanceOf(OverlappingAvailabilityPeriodException.class); + } + } + + @Nested + @DisplayName("Delete") + class DeleteTests { + + @Test + @DisplayName("With valid owner soft-deletes period") + void delete_WithValidOwner_SoftDeletesPeriod() { + var accommodation = createAccommodation(); + var period = createPeriod(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(availabilityPeriodRepository.findByIdAndAccommodationId(PERIOD_ID, ACCOMMODATION_ID)) + .thenReturn(Optional.of(period)); + when(reservationGrpcClient.hasApprovedReservations(ACCOMMODATION_ID, START_DATE, END_DATE)) + .thenReturn(false); + + availabilityPeriodService.delete(ACCOMMODATION_ID, PERIOD_ID, HOST_CONTEXT); + + assertThat(period.isDeleted()).isTrue(); + verify(availabilityPeriodRepository).save(period); + } + + @Test + @DisplayName("With wrong owner throws ForbiddenException") + void delete_WithWrongOwner_ThrowsForbidden() { + var accommodation = createAccommodation(); + var otherUser = new UserContext(UUID.randomUUID(), "HOST"); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + + assertThatThrownBy(() -> availabilityPeriodService.delete(ACCOMMODATION_ID, PERIOD_ID, otherUser)) + .isInstanceOf(ForbiddenException.class); + } + + @Test + @DisplayName("With non-existing period throws AvailabilityPeriodNotFoundException") + void delete_WithNonExistingPeriod_ThrowsNotFound() { + var accommodation = createAccommodation(); + UUID missingPeriodId = UUID.randomUUID(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(availabilityPeriodRepository.findByIdAndAccommodationId(missingPeriodId, ACCOMMODATION_ID)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> availabilityPeriodService.delete(ACCOMMODATION_ID, missingPeriodId, HOST_CONTEXT)) + .isInstanceOf(AvailabilityPeriodNotFoundException.class); + } + + @Test + @DisplayName("With existing reservations throws ReservationConflictException") + void delete_WithExistingReservations_ThrowsConflict() { + var accommodation = createAccommodation(); + var period = createPeriod(); + + when(accommodationRepository.findById(ACCOMMODATION_ID)).thenReturn(Optional.of(accommodation)); + when(availabilityPeriodRepository.findByIdAndAccommodationId(PERIOD_ID, ACCOMMODATION_ID)) + .thenReturn(Optional.of(period)); + when(reservationGrpcClient.hasApprovedReservations(ACCOMMODATION_ID, START_DATE, END_DATE)) + .thenReturn(true); + + assertThatThrownBy(() -> availabilityPeriodService.delete(ACCOMMODATION_ID, PERIOD_ID, HOST_CONTEXT)) + .isInstanceOf(ReservationConflictException.class); + } + } +}