diff --git a/src/main/java/com/tripgoapi/application/port/in/CancelBookingUseCase.java b/src/main/java/com/tripgoapi/application/port/in/CancelBookingUseCase.java new file mode 100644 index 0000000..3c84932 --- /dev/null +++ b/src/main/java/com/tripgoapi/application/port/in/CancelBookingUseCase.java @@ -0,0 +1,12 @@ +package com.tripgoapi.application.port.in; + +import com.tripgoapi.domain.model.Booking; + +public interface CancelBookingUseCase { + + /** + * @param requesterId id of the currently authenticated user; enforces that only the + * booking's owner may cancel it + */ + Booking cancelBooking(Long bookingId, Long requesterId); +} diff --git a/src/main/java/com/tripgoapi/application/port/in/CreateBookingCommand.java b/src/main/java/com/tripgoapi/application/port/in/CreateBookingCommand.java new file mode 100644 index 0000000..1aca97f --- /dev/null +++ b/src/main/java/com/tripgoapi/application/port/in/CreateBookingCommand.java @@ -0,0 +1,16 @@ +package com.tripgoapi.application.port.in; + +import java.time.LocalDate; + +public record CreateBookingCommand( + String idempotencyKey, + Long userId, + Long tourId, + LocalDate date, + int adults, + int children, + String contactName, + String contactEmail, + String contactPhone +) { +} diff --git a/src/main/java/com/tripgoapi/application/port/in/CreateBookingUseCase.java b/src/main/java/com/tripgoapi/application/port/in/CreateBookingUseCase.java new file mode 100644 index 0000000..df4c45e --- /dev/null +++ b/src/main/java/com/tripgoapi/application/port/in/CreateBookingUseCase.java @@ -0,0 +1,7 @@ +package com.tripgoapi.application.port.in; + +import com.tripgoapi.domain.model.Booking; + +public interface CreateBookingUseCase { + Booking createBooking(CreateBookingCommand command); +} diff --git a/src/main/java/com/tripgoapi/application/port/in/GetBookingDetailUseCase.java b/src/main/java/com/tripgoapi/application/port/in/GetBookingDetailUseCase.java new file mode 100644 index 0000000..5bce89e --- /dev/null +++ b/src/main/java/com/tripgoapi/application/port/in/GetBookingDetailUseCase.java @@ -0,0 +1,12 @@ +package com.tripgoapi.application.port.in; + +import com.tripgoapi.domain.model.Booking; + +public interface GetBookingDetailUseCase { + + /** + * @param requesterId id of the currently authenticated user; enforces that only the + * booking's owner may view it + */ + Booking getBookingDetail(Long bookingId, Long requesterId); +} diff --git a/src/main/java/com/tripgoapi/application/port/in/GetBookingsUseCase.java b/src/main/java/com/tripgoapi/application/port/in/GetBookingsUseCase.java new file mode 100644 index 0000000..4a872dc --- /dev/null +++ b/src/main/java/com/tripgoapi/application/port/in/GetBookingsUseCase.java @@ -0,0 +1,14 @@ +package com.tripgoapi.application.port.in; + +import com.tripgoapi.domain.model.Booking; +import com.tripgoapi.domain.model.BookingStatus; + +import java.util.List; + +public interface GetBookingsUseCase { + + /** + * @param status optional filter; {@code null} returns bookings in any status + */ + List getBookingsForUser(Long userId, BookingStatus status); +} diff --git a/src/main/java/com/tripgoapi/application/port/out/BookingRepositoryInterface.java b/src/main/java/com/tripgoapi/application/port/out/BookingRepositoryInterface.java new file mode 100644 index 0000000..95cee3a --- /dev/null +++ b/src/main/java/com/tripgoapi/application/port/out/BookingRepositoryInterface.java @@ -0,0 +1,46 @@ +package com.tripgoapi.application.port.out; + +import com.tripgoapi.domain.model.Booking; +import com.tripgoapi.domain.model.BookingStatus; + +import java.util.List; +import java.util.Optional; + +public interface BookingRepositoryInterface { + + /** + * Persists a new booking. The given {@code booking} has no {@code id}/{@code bookingCode} + * yet; the returned instance has both populated. + */ + Booking save(Booking booking); + + /** + * @param status optional filter; {@code null} returns bookings in any status + */ + List findByUserId(Long userId, BookingStatus status); + + /** + * Idempotency keys are client-generated and only unique per submitting user — never look + * this up without also constraining by userId, or one user's retried request could return + * another user's booking. + */ + Optional findByUserIdAndIdempotencyKey(Long userId, String idempotencyKey); + + /** + * Same lookup as {@link #findByUserIdAndIdempotencyKey}, but always runs in a brand new + * transaction. Needed to recover after a failed {@link #save} caused by a concurrent request + * winning the same (userId, idempotencyKey) race: Postgres aborts the whole transaction on a + * unique-constraint violation, so no further statement can run in the caller's transaction + * until it rolls back. + */ + Optional findByUserIdAndIdempotencyKeyInNewTransaction(Long userId, String idempotencyKey); + + Optional findById(Long id); + + /** + * Atomically transitions PENDING/CONFIRMED -> CANCELLED in a single statement, so two + * concurrent cancel requests for the same booking can't both succeed and double-release slots. + * @return true if the transition happened (booking existed and was in a cancellable state) + */ + boolean cancelIfCancellable(Long id); +} diff --git a/src/main/java/com/tripgoapi/application/port/out/TourDepartureRepositoryInterface.java b/src/main/java/com/tripgoapi/application/port/out/TourDepartureRepositoryInterface.java new file mode 100644 index 0000000..800da0a --- /dev/null +++ b/src/main/java/com/tripgoapi/application/port/out/TourDepartureRepositoryInterface.java @@ -0,0 +1,21 @@ +package com.tripgoapi.application.port.out; + +import java.time.LocalDate; +import java.util.Optional; + +public interface TourDepartureRepositoryInterface { + + Optional findDepartureId(Long tourId, LocalDate departureDate); + + /** + * Atomically reserves {@code guestCount} slots on the given departure. + * @return true if there was enough remaining capacity and the reservation succeeded + */ + boolean reserveSlots(Long departureId, int guestCount); + + /** + * Atomically releases {@code guestCount} previously-reserved slots back to the departure + * (e.g. on booking cancellation). + */ + void releaseSlots(Long departureId, int guestCount); +} diff --git a/src/main/java/com/tripgoapi/application/service/CancelBookingService.java b/src/main/java/com/tripgoapi/application/service/CancelBookingService.java new file mode 100644 index 0000000..5a0d464 --- /dev/null +++ b/src/main/java/com/tripgoapi/application/service/CancelBookingService.java @@ -0,0 +1,50 @@ +package com.tripgoapi.application.service; + +import com.tripgoapi.application.port.in.CancelBookingUseCase; +import com.tripgoapi.application.port.out.BookingRepositoryInterface; +import com.tripgoapi.application.port.out.TourDepartureRepositoryInterface; +import com.tripgoapi.domain.exception.BookingAccessDeniedException; +import com.tripgoapi.domain.exception.BookingCancellationNotAllowedException; +import com.tripgoapi.domain.exception.BookingNotFoundException; +import com.tripgoapi.domain.model.Booking; +import com.tripgoapi.domain.model.BookingStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class CancelBookingService implements CancelBookingUseCase { + + private final BookingRepositoryInterface bookingRepository; + private final TourDepartureRepositoryInterface tourDepartureRepository; + + @Override + @Transactional + public Booking cancelBooking(Long bookingId, Long requesterId) { + Booking booking = bookingRepository.findById(bookingId) + .orElseThrow(() -> new BookingNotFoundException(bookingId)); + + if (!booking.userId().equals(requesterId)) { + throw new BookingAccessDeniedException(bookingId); + } + + // Atomic guard: only one of several concurrent cancel requests for this booking can + // flip PENDING/CONFIRMED -> CANCELLED; a losing request gets false here instead of both + // passing a stale in-memory status check and double-releasing slots. + if (!bookingRepository.cancelIfCancellable(bookingId)) { + // Re-read rather than reuse `booking.status()`: that snapshot was taken before + // cancelIfCancellable ran, so for a request that lost the race it would still say + // PENDING/CONFIRMED even though the real, current status is CANCELLED/COMPLETED. + BookingStatus currentStatus = bookingRepository.findById(bookingId) + .orElseThrow(() -> new BookingNotFoundException(bookingId)) + .status(); + throw new BookingCancellationNotAllowedException(currentStatus); + } + + tourDepartureRepository.releaseSlots(booking.departureId(), booking.adults() + booking.children()); + + return bookingRepository.findById(bookingId) + .orElseThrow(() -> new BookingNotFoundException(bookingId)); + } +} diff --git a/src/main/java/com/tripgoapi/application/service/CreateBookingService.java b/src/main/java/com/tripgoapi/application/service/CreateBookingService.java new file mode 100644 index 0000000..7b90520 --- /dev/null +++ b/src/main/java/com/tripgoapi/application/service/CreateBookingService.java @@ -0,0 +1,94 @@ +package com.tripgoapi.application.service; + +import com.tripgoapi.application.port.in.CreateBookingCommand; +import com.tripgoapi.application.port.in.CreateBookingUseCase; +import com.tripgoapi.application.port.out.BookingRepositoryInterface; +import com.tripgoapi.application.port.out.TourDepartureRepositoryInterface; +import com.tripgoapi.application.port.out.TourDetailRepositoryInterface; +import com.tripgoapi.domain.exception.BookingGroupTooLargeException; +import com.tripgoapi.domain.exception.NoAvailableSlotsException; +import com.tripgoapi.domain.exception.TourDepartureNotFoundException; +import com.tripgoapi.domain.exception.TourNotFoundException; +import com.tripgoapi.domain.model.Booking; +import com.tripgoapi.domain.model.TourDetail; +import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.interceptor.TransactionAspectSupport; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.math.BigDecimal; +import java.util.Optional; + +@Service +@RequiredArgsConstructor +public class CreateBookingService implements CreateBookingUseCase { + + private final TourDetailRepositoryInterface tourDetailRepository; + private final TourDepartureRepositoryInterface tourDepartureRepository; + private final BookingRepositoryInterface bookingRepository; + + @Override + @Transactional + public Booking createBooking(CreateBookingCommand command) { + // Idempotency: a retried request (client timeout/double-submit) with the same key + // returns the already-created booking instead of reserving slots a second time. Scoped + // to the caller's userId — the key is client-generated and only unique per user, so an + // unscoped lookup would let user B fetch user A's booking by guessing/colliding a key. + Optional existing = bookingRepository.findByUserIdAndIdempotencyKey(command.userId(), command.idempotencyKey()); + if (existing.isPresent()) { + return existing.get(); + } + + TourDetail tour = tourDetailRepository.findById(command.tourId()) + .orElseThrow(() -> new TourNotFoundException(command.tourId())); + + Long departureId = tourDepartureRepository.findDepartureId(command.tourId(), command.date()) + .orElseThrow(() -> new TourDepartureNotFoundException(command.tourId(), command.date())); + + int guestCount = command.adults() + command.children(); + if (tour.maxGuests() != null && guestCount > tour.maxGuests()) { + throw new BookingGroupTooLargeException(guestCount, tour.maxGuests()); + } + + if (!tourDepartureRepository.reserveSlots(departureId, guestCount)) { + throw new NoAvailableSlotsException(); + } + + BigDecimal unitPrice = tour.discountPrice() != null ? tour.discountPrice() : tour.price(); + BigDecimal totalPrice = unitPrice.multiply(BigDecimal.valueOf(guestCount)); + + Booking booking = Booking.pending( + command.idempotencyKey(), + command.userId(), + tour, + departureId, + command.date(), + command.adults(), + command.children(), + totalPrice, + command.contactName(), + command.contactEmail(), + command.contactPhone() + ); + + try { + return bookingRepository.save(booking); + } catch (DataIntegrityViolationException ex) { + // Lost a double-submit race: a concurrent request with the same (userId, + // idempotencyKey) committed first, and Postgres aborted this transaction on the + // unique-constraint violation — no further statement can run in it. Mark it + // rollback-only (releasing the slots this losing request reserved) and hand the + // client the winner's booking instead of a raw 500, reading it in a fresh transaction + // since this one is now unusable. Guarded: only active when this method actually runs + // behind the @Transactional proxy (always true in production; not the case when a + // unit test calls the service directly with no Spring context). + if (TransactionSynchronizationManager.isActualTransactionActive()) { + TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); + } + return bookingRepository.findByUserIdAndIdempotencyKeyInNewTransaction(command.userId(), command.idempotencyKey()) + .orElseThrow(() -> ex); + } + } +} diff --git a/src/main/java/com/tripgoapi/application/service/GetBookingDetailService.java b/src/main/java/com/tripgoapi/application/service/GetBookingDetailService.java new file mode 100644 index 0000000..c56f997 --- /dev/null +++ b/src/main/java/com/tripgoapi/application/service/GetBookingDetailService.java @@ -0,0 +1,30 @@ +package com.tripgoapi.application.service; + +import com.tripgoapi.application.port.in.GetBookingDetailUseCase; +import com.tripgoapi.application.port.out.BookingRepositoryInterface; +import com.tripgoapi.domain.exception.BookingAccessDeniedException; +import com.tripgoapi.domain.exception.BookingNotFoundException; +import com.tripgoapi.domain.model.Booking; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class GetBookingDetailService implements GetBookingDetailUseCase { + + private final BookingRepositoryInterface bookingRepository; + + @Override + @Transactional(readOnly = true) + public Booking getBookingDetail(Long bookingId, Long requesterId) { + Booking booking = bookingRepository.findById(bookingId) + .orElseThrow(() -> new BookingNotFoundException(bookingId)); + + if (!booking.userId().equals(requesterId)) { + throw new BookingAccessDeniedException(bookingId); + } + + return booking; + } +} diff --git a/src/main/java/com/tripgoapi/application/service/GetBookingsService.java b/src/main/java/com/tripgoapi/application/service/GetBookingsService.java new file mode 100644 index 0000000..c28c8d2 --- /dev/null +++ b/src/main/java/com/tripgoapi/application/service/GetBookingsService.java @@ -0,0 +1,24 @@ +package com.tripgoapi.application.service; + +import com.tripgoapi.application.port.in.GetBookingsUseCase; +import com.tripgoapi.application.port.out.BookingRepositoryInterface; +import com.tripgoapi.domain.model.Booking; +import com.tripgoapi.domain.model.BookingStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class GetBookingsService implements GetBookingsUseCase { + + private final BookingRepositoryInterface bookingRepository; + + @Override + @Transactional(readOnly = true) + public List getBookingsForUser(Long userId, BookingStatus status) { + return bookingRepository.findByUserId(userId, status); + } +} diff --git a/src/main/java/com/tripgoapi/domain/exception/BookingAccessDeniedException.java b/src/main/java/com/tripgoapi/domain/exception/BookingAccessDeniedException.java new file mode 100644 index 0000000..9e8f1c7 --- /dev/null +++ b/src/main/java/com/tripgoapi/domain/exception/BookingAccessDeniedException.java @@ -0,0 +1,8 @@ +package com.tripgoapi.domain.exception; + +public class BookingAccessDeniedException extends ForbiddenException { + + public BookingAccessDeniedException(Long bookingId) { + super("Bạn không có quyền truy cập đơn đặt tour: id=" + bookingId); + } +} diff --git a/src/main/java/com/tripgoapi/domain/exception/BookingCancellationNotAllowedException.java b/src/main/java/com/tripgoapi/domain/exception/BookingCancellationNotAllowedException.java new file mode 100644 index 0000000..9160dc1 --- /dev/null +++ b/src/main/java/com/tripgoapi/domain/exception/BookingCancellationNotAllowedException.java @@ -0,0 +1,10 @@ +package com.tripgoapi.domain.exception; + +import com.tripgoapi.domain.model.BookingStatus; + +public class BookingCancellationNotAllowedException extends ConflictException { + + public BookingCancellationNotAllowedException(BookingStatus currentStatus) { + super("Không thể hủy đơn ở trạng thái: " + currentStatus); + } +} diff --git a/src/main/java/com/tripgoapi/domain/exception/BookingGroupTooLargeException.java b/src/main/java/com/tripgoapi/domain/exception/BookingGroupTooLargeException.java new file mode 100644 index 0000000..11e9c78 --- /dev/null +++ b/src/main/java/com/tripgoapi/domain/exception/BookingGroupTooLargeException.java @@ -0,0 +1,8 @@ +package com.tripgoapi.domain.exception; + +public class BookingGroupTooLargeException extends ConflictException { + + public BookingGroupTooLargeException(int guestCount, int maxGuests) { + super("Số khách (" + guestCount + ") vượt quá giới hạn " + maxGuests + " khách/đơn của tour"); + } +} diff --git a/src/main/java/com/tripgoapi/domain/exception/BookingNotFoundException.java b/src/main/java/com/tripgoapi/domain/exception/BookingNotFoundException.java new file mode 100644 index 0000000..f8e6d85 --- /dev/null +++ b/src/main/java/com/tripgoapi/domain/exception/BookingNotFoundException.java @@ -0,0 +1,8 @@ +package com.tripgoapi.domain.exception; + +public class BookingNotFoundException extends NotFoundException { + + public BookingNotFoundException(Long id) { + super("Booking not found: id=" + id); + } +} diff --git a/src/main/java/com/tripgoapi/domain/exception/ForbiddenException.java b/src/main/java/com/tripgoapi/domain/exception/ForbiddenException.java new file mode 100644 index 0000000..b15b71d --- /dev/null +++ b/src/main/java/com/tripgoapi/domain/exception/ForbiddenException.java @@ -0,0 +1,8 @@ +package com.tripgoapi.domain.exception; + +public abstract class ForbiddenException extends RuntimeException { + + protected ForbiddenException(String message) { + super(message); + } +} diff --git a/src/main/java/com/tripgoapi/domain/exception/InvalidBookingStatusException.java b/src/main/java/com/tripgoapi/domain/exception/InvalidBookingStatusException.java new file mode 100644 index 0000000..07876df --- /dev/null +++ b/src/main/java/com/tripgoapi/domain/exception/InvalidBookingStatusException.java @@ -0,0 +1,8 @@ +package com.tripgoapi.domain.exception; + +public class InvalidBookingStatusException extends UnprocessableException { + + public InvalidBookingStatusException(String status) { + super("status không hợp lệ: " + status); + } +} diff --git a/src/main/java/com/tripgoapi/domain/exception/NoAvailableSlotsException.java b/src/main/java/com/tripgoapi/domain/exception/NoAvailableSlotsException.java new file mode 100644 index 0000000..3f8bb59 --- /dev/null +++ b/src/main/java/com/tripgoapi/domain/exception/NoAvailableSlotsException.java @@ -0,0 +1,8 @@ +package com.tripgoapi.domain.exception; + +public class NoAvailableSlotsException extends ConflictException { + + public NoAvailableSlotsException() { + super("Ngày khởi hành đã hết chỗ"); + } +} diff --git a/src/main/java/com/tripgoapi/domain/exception/TourDepartureNotFoundException.java b/src/main/java/com/tripgoapi/domain/exception/TourDepartureNotFoundException.java new file mode 100644 index 0000000..09f0ce1 --- /dev/null +++ b/src/main/java/com/tripgoapi/domain/exception/TourDepartureNotFoundException.java @@ -0,0 +1,10 @@ +package com.tripgoapi.domain.exception; + +import java.time.LocalDate; + +public class TourDepartureNotFoundException extends NotFoundException { + + public TourDepartureNotFoundException(Long tourId, LocalDate departureDate) { + super("No departure found for tour id=" + tourId + " on date=" + departureDate); + } +} diff --git a/src/main/java/com/tripgoapi/domain/exception/UnprocessableException.java b/src/main/java/com/tripgoapi/domain/exception/UnprocessableException.java new file mode 100644 index 0000000..3875d95 --- /dev/null +++ b/src/main/java/com/tripgoapi/domain/exception/UnprocessableException.java @@ -0,0 +1,8 @@ +package com.tripgoapi.domain.exception; + +public abstract class UnprocessableException extends RuntimeException { + + protected UnprocessableException(String message) { + super(message); + } +} diff --git a/src/main/java/com/tripgoapi/domain/model/Booking.java b/src/main/java/com/tripgoapi/domain/model/Booking.java new file mode 100644 index 0000000..f9b4b8e --- /dev/null +++ b/src/main/java/com/tripgoapi/domain/model/Booking.java @@ -0,0 +1,48 @@ +package com.tripgoapi.domain.model; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.OffsetDateTime; + +public record Booking( + Long id, + String bookingCode, + String idempotencyKey, + Long userId, + Long tourId, + // Denormalized from the tour row, same pattern as departureDate below. + String tourTitle, + String tourSlug, + Integer tourDurationDays, + Long departureId, + LocalDate departureDate, + int adults, + int children, + BigDecimal totalPrice, + BookingStatus status, + String contactName, + String contactEmail, + String contactPhone, + OffsetDateTime createdAt +) { + + public static Booking pending( + String idempotencyKey, + Long userId, + TourDetail tour, + Long departureId, + LocalDate departureDate, + int adults, + int children, + BigDecimal totalPrice, + String contactName, + String contactEmail, + String contactPhone + ) { + return new Booking( + null, null, idempotencyKey, userId, tour.id(), tour.title(), tour.slug(), tour.durationDays(), + departureId, departureDate, adults, children, totalPrice, BookingStatus.PENDING, + contactName, contactEmail, contactPhone, null + ); + } +} diff --git a/src/main/java/com/tripgoapi/domain/model/BookingStatus.java b/src/main/java/com/tripgoapi/domain/model/BookingStatus.java new file mode 100644 index 0000000..aa4f753 --- /dev/null +++ b/src/main/java/com/tripgoapi/domain/model/BookingStatus.java @@ -0,0 +1,8 @@ +package com.tripgoapi.domain.model; + +public enum BookingStatus { + PENDING, + CONFIRMED, + CANCELLED, + COMPLETED +} diff --git a/src/main/java/com/tripgoapi/infrastructure/adapter/in/web/AuthController.java b/src/main/java/com/tripgoapi/infrastructure/adapter/in/web/AuthController.java index a7de0ac..a94f134 100644 --- a/src/main/java/com/tripgoapi/infrastructure/adapter/in/web/AuthController.java +++ b/src/main/java/com/tripgoapi/infrastructure/adapter/in/web/AuthController.java @@ -54,7 +54,7 @@ public class AuthController { @ApiResponse(responseCode = "201", description = "Tạo tài khoản thành công"), @ApiResponse(responseCode = "409", description = "Email đã được đăng ký", content = @Content(schema = @Schema(implementation = ErrorResponse.class))), - @ApiResponse(responseCode = "400", description = "Dữ liệu không hợp lệ", + @ApiResponse(responseCode = "422", description = "Dữ liệu không hợp lệ", content = @Content(schema = @Schema(implementation = ErrorResponse.class))) }) @PostMapping("/register") diff --git a/src/main/java/com/tripgoapi/infrastructure/adapter/in/web/BookingController.java b/src/main/java/com/tripgoapi/infrastructure/adapter/in/web/BookingController.java new file mode 100644 index 0000000..d47df01 --- /dev/null +++ b/src/main/java/com/tripgoapi/infrastructure/adapter/in/web/BookingController.java @@ -0,0 +1,166 @@ +package com.tripgoapi.infrastructure.adapter.in.web; + +import com.tripgoapi.application.port.in.CancelBookingUseCase; +import com.tripgoapi.application.port.in.CreateBookingCommand; +import com.tripgoapi.application.port.in.CreateBookingUseCase; +import com.tripgoapi.application.port.in.GetBookingDetailUseCase; +import com.tripgoapi.application.port.in.GetBookingsUseCase; +import com.tripgoapi.application.port.out.AuthenticatedPrincipal; +import com.tripgoapi.domain.exception.InvalidBookingStatusException; +import com.tripgoapi.domain.model.Booking; +import com.tripgoapi.domain.model.BookingStatus; +import com.tripgoapi.infrastructure.adapter.in.web.dto.ApiResult; +import com.tripgoapi.infrastructure.adapter.in.web.dto.BookingResponse; +import com.tripgoapi.infrastructure.adapter.in.web.dto.CreateBookingRequest; +import com.tripgoapi.infrastructure.adapter.in.web.dto.ErrorResponse; +import com.tripgoapi.infrastructure.mapper.BookingWebMapper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Positive; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/bookings") +@RequiredArgsConstructor +@SecurityRequirement(name = "bearerAuth") +@Tag(name = "Bookings", description = "Đặt tour & xem đơn của người dùng đã đăng nhập") +public class BookingController { + + private final CreateBookingUseCase createBookingUseCase; + private final GetBookingsUseCase getBookingsUseCase; + private final GetBookingDetailUseCase getBookingDetailUseCase; + private final CancelBookingUseCase cancelBookingUseCase; + private final BookingWebMapper bookingWebMapper; + + @Operation( + summary = "Tạo đơn đặt tour", + description = "Server tự tính totalPrice theo giá tour & số khách, tự sinh mã đơn, " + + "và gắn userId từ JWT — không tin totalPrice/userId do client gửi." + ) + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "Đặt tour thành công"), + @ApiResponse(responseCode = "422", description = "Dữ liệu không hợp lệ", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "Không tìm thấy tour hoặc ngày khởi hành", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "Ngày khởi hành đã hết chỗ", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "Thiếu hoặc sai access token", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @PostMapping + public ResponseEntity> createBooking( + @Valid @RequestBody CreateBookingRequest request, + @AuthenticationPrincipal AuthenticatedPrincipal principal + ) { + CreateBookingCommand command = new CreateBookingCommand( + request.idempotencyKey(), + principal.userId(), + request.tourId(), + request.date(), + request.adults(), + request.children(), + request.contact().name(), + request.contact().email(), + request.contact().phone() + ); + Booking booking = createBookingUseCase.createBooking(command); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResult.of(bookingWebMapper.toResponse(booking))); + } + + @Operation( + summary = "Danh sách đơn đặt tour của tôi", + description = "Lọc theo trạng thái qua query, vd: ?status=pending. Bỏ qua nếu không truyền." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Thành công"), + @ApiResponse(responseCode = "422", description = "Giá trị status không hợp lệ", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "401", description = "Thiếu hoặc sai access token", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping + public ApiResult> getBookings( + @Parameter(description = "Lọc theo trạng thái đơn", example = "pending") + @RequestParam(required = false) String status, + @AuthenticationPrincipal AuthenticatedPrincipal principal + ) { + List bookings = getBookingsUseCase.getBookingsForUser(principal.userId(), parseStatus(status)); + return ApiResult.of(bookingWebMapper.toResponseList(bookings)); + } + + @Operation(summary = "Chi tiết đơn đặt tour", description = "Chỉ trả về đơn thuộc về chính người dùng đang đăng nhập.") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Thành công"), + @ApiResponse(responseCode = "401", description = "Thiếu hoặc sai access token", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "403", description = "Đơn không thuộc về người dùng hiện tại", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "Không tìm thấy đơn", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @GetMapping("/{id}") + public ApiResult getBookingDetail( + @Parameter(description = "ID đơn") @PathVariable @Positive(message = "id phải > 0") Long id, + @AuthenticationPrincipal AuthenticatedPrincipal principal + ) { + Booking booking = getBookingDetailUseCase.getBookingDetail(id, principal.userId()); + return ApiResult.of(bookingWebMapper.toResponse(booking)); + } + + @Operation( + summary = "Hủy đơn đặt tour", + description = "Chỉ chủ đơn mới hủy được, và chỉ khi đơn đang ở trạng thái PENDING hoặc CONFIRMED. " + + "Trả lại số chỗ đã giữ cho ngày khởi hành tương ứng." + ) + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "Hủy thành công"), + @ApiResponse(responseCode = "401", description = "Thiếu hoặc sai access token", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "403", description = "Đơn không thuộc về người dùng hiện tại", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "404", description = "Không tìm thấy đơn", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))), + @ApiResponse(responseCode = "409", description = "Đơn đang ở trạng thái không thể hủy", + content = @Content(schema = @Schema(implementation = ErrorResponse.class))) + }) + @PatchMapping("/{id}/cancel") + public ApiResult cancelBooking( + @Parameter(description = "ID đơn") @PathVariable @Positive(message = "id phải > 0") Long id, + @AuthenticationPrincipal AuthenticatedPrincipal principal + ) { + Booking booking = cancelBookingUseCase.cancelBooking(id, principal.userId()); + return ApiResult.of(bookingWebMapper.toResponse(booking)); + } + + private BookingStatus parseStatus(String status) { + if (status == null || status.isBlank()) { + return null; + } + try { + return BookingStatus.valueOf(status.trim().toUpperCase()); + } catch (IllegalArgumentException ex) { + throw new InvalidBookingStatusException(status); + } + } +} diff --git a/src/main/java/com/tripgoapi/infrastructure/adapter/in/web/GlobalExceptionHandler.java b/src/main/java/com/tripgoapi/infrastructure/adapter/in/web/GlobalExceptionHandler.java index deb4575..689f814 100644 --- a/src/main/java/com/tripgoapi/infrastructure/adapter/in/web/GlobalExceptionHandler.java +++ b/src/main/java/com/tripgoapi/infrastructure/adapter/in/web/GlobalExceptionHandler.java @@ -1,9 +1,11 @@ package com.tripgoapi.infrastructure.adapter.in.web; import com.tripgoapi.domain.exception.ConflictException; +import com.tripgoapi.domain.exception.ForbiddenException; import com.tripgoapi.domain.exception.NotFoundException; import com.tripgoapi.domain.exception.TooManyRequestsException; import com.tripgoapi.domain.exception.UnauthorizedException; +import com.tripgoapi.domain.exception.UnprocessableException; import com.tripgoapi.infrastructure.adapter.in.web.dto.ErrorResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintViolation; @@ -42,6 +44,11 @@ public ResponseEntity handleConflict(ConflictException ex, HttpSe return build(HttpStatus.CONFLICT, ex.getMessage(), request); } + @ExceptionHandler(ForbiddenException.class) + public ResponseEntity handleForbidden(ForbiddenException ex, HttpServletRequest request) { + return build(HttpStatus.FORBIDDEN, ex.getMessage(), request); + } + @ExceptionHandler(UnauthorizedException.class) public ResponseEntity handleUnauthorized(UnauthorizedException ex, HttpServletRequest request) { return build(HttpStatus.UNAUTHORIZED, ex.getMessage(), request); @@ -52,6 +59,11 @@ public ResponseEntity handleTooManyRequests(TooManyRequestsExcept return build(HttpStatus.TOO_MANY_REQUESTS, ex.getMessage(), request); } + @ExceptionHandler(UnprocessableException.class) + public ResponseEntity handleUnprocessable(UnprocessableException ex, HttpServletRequest request) { + return build(HttpStatus.UNPROCESSABLE_CONTENT, ex.getMessage(), request); + } + @ExceptionHandler(NoResourceFoundException.class) public ResponseEntity handleNoResourceFound(HttpServletRequest request) { return build(HttpStatus.NOT_FOUND, "No endpoint found for " + request.getRequestURI(), request); @@ -80,10 +92,13 @@ public ResponseEntity handleBadRequest(Exception ex, HttpServletR @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleValidation(MethodArgumentNotValidException ex, HttpServletRequest request) { + // 422 (not 400): the JSON itself is well-formed, it just violates @Valid business rules + // on the request body — distinct from malformed input (bad JSON/type), which the + // handlers below keep at 400. String message = ex.getBindingResult().getFieldErrors().stream() .map(fieldError -> fieldError.getField() + ": " + fieldError.getDefaultMessage()) .collect(Collectors.joining("; ")); - return build(HttpStatus.BAD_REQUEST, message, request); + return build(HttpStatus.UNPROCESSABLE_CONTENT, message, request); } @ExceptionHandler(HandlerMethodValidationException.class) diff --git a/src/main/java/com/tripgoapi/infrastructure/adapter/in/web/dto/BookingResponse.java b/src/main/java/com/tripgoapi/infrastructure/adapter/in/web/dto/BookingResponse.java new file mode 100644 index 0000000..a853c10 --- /dev/null +++ b/src/main/java/com/tripgoapi/infrastructure/adapter/in/web/dto/BookingResponse.java @@ -0,0 +1,21 @@ +package com.tripgoapi.infrastructure.adapter.in.web.dto; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.OffsetDateTime; + +public record BookingResponse( + Long id, + String bookingCode, + TourSummaryResponse tour, + LocalDate departureDate, + int adults, + int children, + BigDecimal totalPrice, + String status, + String contactName, + String contactEmail, + String contactPhone, + OffsetDateTime createdAt +) { +} diff --git a/src/main/java/com/tripgoapi/infrastructure/adapter/in/web/dto/ContactInfo.java b/src/main/java/com/tripgoapi/infrastructure/adapter/in/web/dto/ContactInfo.java new file mode 100644 index 0000000..3113050 --- /dev/null +++ b/src/main/java/com/tripgoapi/infrastructure/adapter/in/web/dto/ContactInfo.java @@ -0,0 +1,22 @@ +package com.tripgoapi.infrastructure.adapter.in.web.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record ContactInfo( + @NotBlank(message = "contact.name không được để trống") + @Size(max = 255, message = "contact.name tối đa 255 ký tự") + String name, + + @NotBlank(message = "contact.email không được để trống") + @Email(message = "contact.email không hợp lệ") + @Size(max = 255, message = "contact.email tối đa 255 ký tự") + String email, + + @NotBlank(message = "contact.phone không được để trống") + @Pattern(regexp = "^(?=.*\\d)[0-9+()\\-\\s]{8,20}$", message = "contact.phone không hợp lệ") + String phone +) { +} diff --git a/src/main/java/com/tripgoapi/infrastructure/adapter/in/web/dto/CreateBookingRequest.java b/src/main/java/com/tripgoapi/infrastructure/adapter/in/web/dto/CreateBookingRequest.java new file mode 100644 index 0000000..7c25ca5 --- /dev/null +++ b/src/main/java/com/tripgoapi/infrastructure/adapter/in/web/dto/CreateBookingRequest.java @@ -0,0 +1,42 @@ +package com.tripgoapi.infrastructure.adapter.in.web.dto; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.FutureOrPresent; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +import java.time.LocalDate; + +// userId is deliberately not a field here — it must come from the authenticated JWT principal, +// never from client input. +public record CreateBookingRequest( + // Client-generated, kept identical across retries of the same create intent (e.g. after + // a timeout) so a resubmitted request returns the original booking instead of creating + // a duplicate and double-reserving slots. + @NotBlank(message = "idempotencyKey không được để trống") + @Size(max = 100, message = "idempotencyKey tối đa 100 ký tự") + String idempotencyKey, + + @NotNull(message = "tourId không được để trống") + @Positive(message = "tourId phải > 0") + Long tourId, + + @NotNull(message = "date không được để trống") + @FutureOrPresent(message = "date phải là hôm nay hoặc trong tương lai") + LocalDate date, + + @Min(value = 1, message = "adults phải >= 1") + int adults, + + @PositiveOrZero(message = "children phải >= 0") + int children, + + @NotNull(message = "contact không được để trống") + @Valid + ContactInfo contact +) { +} diff --git a/src/main/java/com/tripgoapi/infrastructure/adapter/in/web/dto/TourSummaryResponse.java b/src/main/java/com/tripgoapi/infrastructure/adapter/in/web/dto/TourSummaryResponse.java new file mode 100644 index 0000000..6c5af8a --- /dev/null +++ b/src/main/java/com/tripgoapi/infrastructure/adapter/in/web/dto/TourSummaryResponse.java @@ -0,0 +1,9 @@ +package com.tripgoapi.infrastructure.adapter.in.web.dto; + +public record TourSummaryResponse( + Long id, + String title, + String slug, + Integer durationDays +) { +} diff --git a/src/main/java/com/tripgoapi/infrastructure/adapter/out/persistence/BookingPersistenceAdapter.java b/src/main/java/com/tripgoapi/infrastructure/adapter/out/persistence/BookingPersistenceAdapter.java new file mode 100644 index 0000000..4ff7432 --- /dev/null +++ b/src/main/java/com/tripgoapi/infrastructure/adapter/out/persistence/BookingPersistenceAdapter.java @@ -0,0 +1,121 @@ +package com.tripgoapi.infrastructure.adapter.out.persistence; + +import com.tripgoapi.application.port.out.BookingRepositoryInterface; +import com.tripgoapi.domain.model.Booking; +import com.tripgoapi.domain.model.BookingStatus; +import com.tripgoapi.infrastructure.adapter.out.persistence.entity.BookingEntity; +import com.tripgoapi.infrastructure.adapter.out.persistence.entity.TourDepartureEntity; +import com.tripgoapi.infrastructure.adapter.out.persistence.entity.TourEntity; +import com.tripgoapi.infrastructure.adapter.out.persistence.entity.UserEntity; +import com.tripgoapi.infrastructure.adapter.out.persistence.repository.BookingJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Component +@RequiredArgsConstructor +public class BookingPersistenceAdapter implements BookingRepositoryInterface { + + private final BookingJpaRepository bookingJpaRepository; + + @Override + public Booking save(Booking booking) { + BookingEntity entity = BookingEntity.builder() + // Temporary placeholder: booking_code is NOT NULL UNIQUE, and the real code is + // derived from the id Hibernate only assigns once this row is actually inserted. + // Never visible outside this transaction. + .bookingCode(UUID.randomUUID().toString()) + .idempotencyKey(booking.idempotencyKey()) + .user(UserEntity.builder().id(booking.userId()).build()) + .tour(TourEntity.builder().id(booking.tourId()).build()) + .departure(TourDepartureEntity.builder() + .id(booking.departureId()) + .departureDate(booking.departureDate()) + .build()) + .adults(booking.adults()) + .children(booking.children()) + .totalPrice(booking.totalPrice()) + .status(booking.status().name()) + .contactName(booking.contactName()) + .contactEmail(booking.contactEmail()) + .contactPhone(booking.contactPhone()) + .createdAt(OffsetDateTime.now()) + .build(); + + // IDENTITY generation inserts eagerly, so the id is populated right after this call. + BookingEntity inserted = bookingJpaRepository.save(entity); + inserted.setBookingCode(generateBookingCode(inserted.getId(), inserted.getCreatedAt())); + BookingEntity saved = bookingJpaRepository.save(inserted); + + // Built from the input `booking`, not via toDomain(saved): saved.getTour()/.getUser()/ + // .getDeparture() are stub entities holding only the FK id set above, so reading any + // other field off them (e.g. tour title) would silently come back null. + return new Booking( + saved.getId(), saved.getBookingCode(), booking.idempotencyKey(), booking.userId(), booking.tourId(), + booking.tourTitle(), booking.tourSlug(), booking.tourDurationDays(), + booking.departureId(), booking.departureDate(), booking.adults(), booking.children(), + booking.totalPrice(), booking.status(), booking.contactName(), booking.contactEmail(), + booking.contactPhone(), saved.getCreatedAt() + ); + } + + @Override + public List findByUserId(Long userId, BookingStatus status) { + return bookingJpaRepository.findByUserIdWithDeparture(userId, status == null ? null : status.name()) + .stream().map(this::toDomain).toList(); + } + + @Override + public Optional findByUserIdAndIdempotencyKey(Long userId, String idempotencyKey) { + return bookingJpaRepository.findByUserIdAndIdempotencyKeyWithDeparture(userId, idempotencyKey).map(this::toDomain); + } + + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true) + public Optional findByUserIdAndIdempotencyKeyInNewTransaction(Long userId, String idempotencyKey) { + return findByUserIdAndIdempotencyKey(userId, idempotencyKey); + } + + @Override + public Optional findById(Long id) { + return bookingJpaRepository.findByIdWithDeparture(id).map(this::toDomain); + } + + @Override + public boolean cancelIfCancellable(Long id) { + return bookingJpaRepository.cancelIfCancellable(id) > 0; + } + + private String generateBookingCode(Long id, OffsetDateTime createdAt) { + return "TG-%d-%06d".formatted(createdAt.getYear(), id); + } + + private Booking toDomain(BookingEntity entity) { + return new Booking( + entity.getId(), + entity.getBookingCode(), + entity.getIdempotencyKey(), + entity.getUser().getId(), + entity.getTour().getId(), + entity.getTour().getTitle(), + entity.getTour().getSlug(), + entity.getTour().getDurationDays(), + entity.getDeparture().getId(), + entity.getDeparture().getDepartureDate(), + entity.getAdults(), + entity.getChildren(), + entity.getTotalPrice(), + BookingStatus.valueOf(entity.getStatus()), + entity.getContactName(), + entity.getContactEmail(), + entity.getContactPhone(), + entity.getCreatedAt() + ); + } +} diff --git a/src/main/java/com/tripgoapi/infrastructure/adapter/out/persistence/TourDeparturePersistenceAdapter.java b/src/main/java/com/tripgoapi/infrastructure/adapter/out/persistence/TourDeparturePersistenceAdapter.java new file mode 100644 index 0000000..ed264e7 --- /dev/null +++ b/src/main/java/com/tripgoapi/infrastructure/adapter/out/persistence/TourDeparturePersistenceAdapter.java @@ -0,0 +1,33 @@ +package com.tripgoapi.infrastructure.adapter.out.persistence; + +import com.tripgoapi.application.port.out.TourDepartureRepositoryInterface; +import com.tripgoapi.infrastructure.adapter.out.persistence.entity.TourDepartureEntity; +import com.tripgoapi.infrastructure.adapter.out.persistence.repository.TourDepartureJpaRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.time.LocalDate; +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class TourDeparturePersistenceAdapter implements TourDepartureRepositoryInterface { + + private final TourDepartureJpaRepository tourDepartureJpaRepository; + + @Override + public Optional findDepartureId(Long tourId, LocalDate departureDate) { + return tourDepartureJpaRepository.findByTour_IdAndDepartureDate(tourId, departureDate) + .map(TourDepartureEntity::getId); + } + + @Override + public boolean reserveSlots(Long departureId, int guestCount) { + return tourDepartureJpaRepository.reserveSlotsIfAvailable(departureId, guestCount) > 0; + } + + @Override + public void releaseSlots(Long departureId, int guestCount) { + tourDepartureJpaRepository.releaseSlots(departureId, guestCount); + } +} diff --git a/src/main/java/com/tripgoapi/infrastructure/adapter/out/persistence/entity/BookingEntity.java b/src/main/java/com/tripgoapi/infrastructure/adapter/out/persistence/entity/BookingEntity.java new file mode 100644 index 0000000..d2f7b87 --- /dev/null +++ b/src/main/java/com/tripgoapi/infrastructure/adapter/out/persistence/entity/BookingEntity.java @@ -0,0 +1,76 @@ +package com.tripgoapi.infrastructure.adapter.out.persistence.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; + +@Entity +@Table(name = "bookings", uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "idempotency_key"})) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class BookingEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "booking_code", nullable = false, unique = true) + private String bookingCode; + + @Column(name = "idempotency_key", nullable = false) + private String idempotencyKey; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private UserEntity user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "tour_id", nullable = false) + private TourEntity tour; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "departure_id", nullable = false) + private TourDepartureEntity departure; + + @Column(nullable = false) + private int adults; + + @Column(nullable = false) + private int children; + + @Column(name = "total_price", nullable = false) + private BigDecimal totalPrice; + + @Column(nullable = false) + private String status; + + @Column(name = "contact_name") + private String contactName; + + @Column(name = "contact_email") + private String contactEmail; + + @Column(name = "contact_phone") + private String contactPhone; + + @Column(name = "created_at", nullable = false) + private OffsetDateTime createdAt; +} diff --git a/src/main/java/com/tripgoapi/infrastructure/adapter/out/persistence/repository/BookingJpaRepository.java b/src/main/java/com/tripgoapi/infrastructure/adapter/out/persistence/repository/BookingJpaRepository.java new file mode 100644 index 0000000..e54f7af --- /dev/null +++ b/src/main/java/com/tripgoapi/infrastructure/adapter/out/persistence/repository/BookingJpaRepository.java @@ -0,0 +1,30 @@ +package com.tripgoapi.infrastructure.adapter.out.persistence.repository; + +import com.tripgoapi.infrastructure.adapter.out.persistence.entity.BookingEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; + +public interface BookingJpaRepository extends JpaRepository { + + @Query("SELECT b FROM BookingEntity b JOIN FETCH b.departure JOIN FETCH b.tour " + + "WHERE b.user.id = :userId AND (:status IS NULL OR b.status = :status) ORDER BY b.createdAt DESC") + List findByUserIdWithDeparture(@Param("userId") Long userId, @Param("status") String status); + + @Query("SELECT b FROM BookingEntity b JOIN FETCH b.departure JOIN FETCH b.tour " + + "WHERE b.user.id = :userId AND b.idempotencyKey = :idempotencyKey") + Optional findByUserIdAndIdempotencyKeyWithDeparture( + @Param("userId") Long userId, @Param("idempotencyKey") String idempotencyKey); + + @Query("SELECT b FROM BookingEntity b JOIN FETCH b.departure JOIN FETCH b.tour WHERE b.id = :id") + Optional findByIdWithDeparture(@Param("id") Long id); + + @Modifying(clearAutomatically = true) + @Query("UPDATE BookingEntity b SET b.status = 'CANCELLED' " + + "WHERE b.id = :id AND b.status IN ('PENDING', 'CONFIRMED')") + int cancelIfCancellable(@Param("id") Long id); +} diff --git a/src/main/java/com/tripgoapi/infrastructure/adapter/out/persistence/repository/TourDepartureJpaRepository.java b/src/main/java/com/tripgoapi/infrastructure/adapter/out/persistence/repository/TourDepartureJpaRepository.java index e778083..c4656d9 100644 --- a/src/main/java/com/tripgoapi/infrastructure/adapter/out/persistence/repository/TourDepartureJpaRepository.java +++ b/src/main/java/com/tripgoapi/infrastructure/adapter/out/persistence/repository/TourDepartureJpaRepository.java @@ -2,11 +2,33 @@ import com.tripgoapi.infrastructure.adapter.out.persistence.entity.TourDepartureEntity; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +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; public interface TourDepartureJpaRepository extends JpaRepository { List findByTour_IdAndDepartureDateBetweenOrderByDepartureDateAsc( Long tourId, LocalDate from, LocalDate to); + + Optional findByTour_IdAndDepartureDate(Long tourId, LocalDate departureDate); + + // clearAutomatically: this bulk UPDATE bypasses the persistence context, so any + // TourDepartureEntity already loaded in the current transaction (e.g. via + // findByTour_IdAndDepartureDate) would otherwise keep a stale bookedSlots value. + @Modifying(clearAutomatically = true) + @Query("UPDATE TourDepartureEntity d SET d.bookedSlots = d.bookedSlots + :guestCount " + + "WHERE d.id = :departureId AND (d.totalSlots - d.bookedSlots) >= :guestCount") + int reserveSlotsIfAvailable(@Param("departureId") Long departureId, @Param("guestCount") int guestCount); + + // Clamped at 0: defends against bookedSlots going negative should this ever run twice + // for the same booking (e.g. a retried cancel request). + @Modifying(clearAutomatically = true) + @Query("UPDATE TourDepartureEntity d SET d.bookedSlots = " + + "CASE WHEN d.bookedSlots - :guestCount < 0 THEN 0 ELSE d.bookedSlots - :guestCount END " + + "WHERE d.id = :departureId") + void releaseSlots(@Param("departureId") Long departureId, @Param("guestCount") int guestCount); } diff --git a/src/main/java/com/tripgoapi/infrastructure/mapper/BookingWebMapper.java b/src/main/java/com/tripgoapi/infrastructure/mapper/BookingWebMapper.java new file mode 100644 index 0000000..8f583e7 --- /dev/null +++ b/src/main/java/com/tripgoapi/infrastructure/mapper/BookingWebMapper.java @@ -0,0 +1,22 @@ +package com.tripgoapi.infrastructure.mapper; + +import com.tripgoapi.domain.model.Booking; +import com.tripgoapi.infrastructure.adapter.in.web.dto.BookingResponse; +import com.tripgoapi.infrastructure.adapter.in.web.dto.TourSummaryResponse; +import org.mapstruct.Mapper; +import org.mapstruct.Mapping; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface BookingWebMapper { + + @Mapping(target = "tour", source = ".") + BookingResponse toResponse(Booking booking); + + List toResponseList(List bookings); + + default TourSummaryResponse toTourSummary(Booking booking) { + return new TourSummaryResponse(booking.tourId(), booking.tourTitle(), booking.tourSlug(), booking.tourDurationDays()); + } +} diff --git a/src/main/resources/db/migration/V15__create_bookings_table.sql b/src/main/resources/db/migration/V15__create_bookings_table.sql new file mode 100644 index 0000000..1888c36 --- /dev/null +++ b/src/main/resources/db/migration/V15__create_bookings_table.sql @@ -0,0 +1,22 @@ +CREATE TABLE bookings ( + id BIGSERIAL PRIMARY KEY, + booking_code VARCHAR(50) NOT NULL UNIQUE, + idempotency_key VARCHAR(100) NOT NULL, + user_id BIGINT NOT NULL REFERENCES users (id), + tour_id BIGINT NOT NULL REFERENCES tours (id), + departure_id BIGINT NOT NULL REFERENCES tour_departures (id), + adults INT NOT NULL, + children INT NOT NULL DEFAULT 0, + total_price NUMERIC(12, 2) NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' + CHECK (status IN ('PENDING', 'CONFIRMED', 'CANCELLED', 'COMPLETED')), + contact_name VARCHAR(255), + contact_email VARCHAR(255), + contact_phone VARCHAR(20), + created_at TIMESTAMPTZ NOT NULL, + -- Idempotency keys are client-generated and only meaningful per submitting user; a global + -- unique index would let one user's key collide with another user's and leak their booking. + CONSTRAINT uq_bookings_user_idempotency_key UNIQUE (user_id, idempotency_key) +); + +CREATE INDEX idx_bookings_user_id ON bookings (user_id); diff --git a/src/test/java/com/tripgoapi/application/service/CancelBookingServiceTest.java b/src/test/java/com/tripgoapi/application/service/CancelBookingServiceTest.java new file mode 100644 index 0000000..fc3abea --- /dev/null +++ b/src/test/java/com/tripgoapi/application/service/CancelBookingServiceTest.java @@ -0,0 +1,139 @@ +package com.tripgoapi.application.service; + +import com.tripgoapi.application.port.out.BookingRepositoryInterface; +import com.tripgoapi.application.port.out.TourDepartureRepositoryInterface; +import com.tripgoapi.domain.exception.BookingAccessDeniedException; +import com.tripgoapi.domain.exception.BookingCancellationNotAllowedException; +import com.tripgoapi.domain.exception.BookingNotFoundException; +import com.tripgoapi.domain.model.Booking; +import com.tripgoapi.domain.model.BookingStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CancelBookingServiceTest { + + private static final Long BOOKING_ID = 1L; + private static final Long OWNER_ID = 5L; + private static final Long DEPARTURE_ID = 3L; + + @Mock + private BookingRepositoryInterface bookingRepository; + @Mock + private TourDepartureRepositoryInterface tourDepartureRepository; + + private CancelBookingService service; + + private Booking booking(Long userId, BookingStatus status) { + return new Booking( + BOOKING_ID, "TG-2026-000001", "idem-key-1", userId, 2L, "Da Nang Tour", "da-nang-tour", 3, + DEPARTURE_ID, LocalDate.of(2026, 8, 15), + 2, 1, BigDecimal.valueOf(300), status, + "Jane", "jane@example.com", "0900000000", OffsetDateTime.now() + ); + } + + @Test + void bookingNotFound_throwsBookingNotFoundException_neverReleasesSlotsOrCancels() { + service = new CancelBookingService(bookingRepository, tourDepartureRepository); + when(bookingRepository.findById(BOOKING_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.cancelBooking(BOOKING_ID, OWNER_ID)) + .isInstanceOf(BookingNotFoundException.class); + + verify(tourDepartureRepository, never()).releaseSlots(any(), anyInt()); + verify(bookingRepository, never()).cancelIfCancellable(any()); + } + + @Test + void bookingOwnedByAnotherUser_throwsBookingAccessDeniedException_neverReleasesSlotsOrCancels() { + service = new CancelBookingService(bookingRepository, tourDepartureRepository); + when(bookingRepository.findById(BOOKING_ID)).thenReturn(Optional.of(booking(OWNER_ID, BookingStatus.PENDING))); + + assertThatThrownBy(() -> service.cancelBooking(BOOKING_ID, 999L)) + .isInstanceOf(BookingAccessDeniedException.class); + + verify(tourDepartureRepository, never()).releaseSlots(any(), anyInt()); + verify(bookingRepository, never()).cancelIfCancellable(any()); + } + + @Test + void atomicCancelRejected_throwsBookingCancellationNotAllowedException_neverReleasesSlots() { + // Covers both "already cancelled/completed" and "lost a concurrent cancel race": + // the atomic UPDATE ... WHERE status IN (PENDING, CONFIRMED) is the single source of + // truth for whether the transition is allowed, not the in-memory status read earlier. + service = new CancelBookingService(bookingRepository, tourDepartureRepository); + when(bookingRepository.findById(BOOKING_ID)).thenReturn(Optional.of(booking(OWNER_ID, BookingStatus.CANCELLED))); + when(bookingRepository.cancelIfCancellable(BOOKING_ID)).thenReturn(false); + + assertThatThrownBy(() -> service.cancelBooking(BOOKING_ID, OWNER_ID)) + .isInstanceOf(BookingCancellationNotAllowedException.class); + + verify(tourDepartureRepository, never()).releaseSlots(any(), anyInt()); + } + + @Test + void lostConcurrentCancelRace_errorMessageReflectsFreshStatus_notTheStaleSnapshot() { + // Regression: the first findById() read (PENDING) is taken before cancelIfCancellable + // runs; if the atomic UPDATE then loses the race (another request cancelled it first), + // the exception must report the booking's real current status (CANCELLED), not the + // stale PENDING snapshot taken before the race was decided. + service = new CancelBookingService(bookingRepository, tourDepartureRepository); + when(bookingRepository.findById(BOOKING_ID)) + .thenReturn(Optional.of(booking(OWNER_ID, BookingStatus.PENDING))) + .thenReturn(Optional.of(booking(OWNER_ID, BookingStatus.CANCELLED))); + when(bookingRepository.cancelIfCancellable(BOOKING_ID)).thenReturn(false); + + assertThatThrownBy(() -> service.cancelBooking(BOOKING_ID, OWNER_ID)) + .isInstanceOf(BookingCancellationNotAllowedException.class) + .hasMessageContaining("CANCELLED") + .hasMessageNotContaining("PENDING"); + + verify(tourDepartureRepository, never()).releaseSlots(any(), anyInt()); + } + + @Test + void pendingBookingOwnedByRequester_releasesSlots_andReturnsCancelledBooking() { + service = new CancelBookingService(bookingRepository, tourDepartureRepository); + Booking pending = booking(OWNER_ID, BookingStatus.PENDING); + Booking cancelled = booking(OWNER_ID, BookingStatus.CANCELLED); + when(bookingRepository.findById(BOOKING_ID)) + .thenReturn(Optional.of(pending)) + .thenReturn(Optional.of(cancelled)); + when(bookingRepository.cancelIfCancellable(BOOKING_ID)).thenReturn(true); + + Booking result = service.cancelBooking(BOOKING_ID, OWNER_ID); + + assertThat(result.status()).isEqualTo(BookingStatus.CANCELLED); + verify(tourDepartureRepository).releaseSlots(DEPARTURE_ID, 3); + verify(bookingRepository).cancelIfCancellable(BOOKING_ID); + } + + @Test + void confirmedBookingOwnedByRequester_canAlsoBeCancelled() { + service = new CancelBookingService(bookingRepository, tourDepartureRepository); + when(bookingRepository.findById(BOOKING_ID)) + .thenReturn(Optional.of(booking(OWNER_ID, BookingStatus.CONFIRMED))) + .thenReturn(Optional.of(booking(OWNER_ID, BookingStatus.CANCELLED))); + when(bookingRepository.cancelIfCancellable(BOOKING_ID)).thenReturn(true); + + Booking result = service.cancelBooking(BOOKING_ID, OWNER_ID); + + assertThat(result.status()).isEqualTo(BookingStatus.CANCELLED); + } +} diff --git a/src/test/java/com/tripgoapi/application/service/CreateBookingServiceTest.java b/src/test/java/com/tripgoapi/application/service/CreateBookingServiceTest.java new file mode 100644 index 0000000..f949829 --- /dev/null +++ b/src/test/java/com/tripgoapi/application/service/CreateBookingServiceTest.java @@ -0,0 +1,285 @@ +package com.tripgoapi.application.service; + +import com.tripgoapi.application.port.in.CreateBookingCommand; +import com.tripgoapi.application.port.out.BookingRepositoryInterface; +import com.tripgoapi.application.port.out.TourDepartureRepositoryInterface; +import com.tripgoapi.application.port.out.TourDetailRepositoryInterface; +import com.tripgoapi.domain.exception.BookingGroupTooLargeException; +import com.tripgoapi.domain.exception.NoAvailableSlotsException; +import com.tripgoapi.domain.exception.TourDepartureNotFoundException; +import com.tripgoapi.domain.exception.TourNotFoundException; +import com.tripgoapi.domain.model.Booking; +import com.tripgoapi.domain.model.BookingStatus; +import com.tripgoapi.domain.model.TourDetail; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class CreateBookingServiceTest { + + private static final Long TOUR_ID = 1L; + private static final Long DEPARTURE_ID = 42L; + private static final LocalDate DATE = LocalDate.of(2026, 8, 15); + private static final String IDEMPOTENCY_KEY = "idem-key-1"; + + @Mock + private TourDetailRepositoryInterface tourDetailRepository; + @Mock + private TourDepartureRepositoryInterface tourDepartureRepository; + @Mock + private BookingRepositoryInterface bookingRepository; + + private CreateBookingService service; + + @BeforeEach + void setUp() { + // Every call goes through the idempotency check first; default to "not seen before" + // unless a specific test overrides it. + when(bookingRepository.findByUserIdAndIdempotencyKey(anyLong(), anyString())).thenReturn(Optional.empty()); + } + + private CreateBookingService newService() { + return new CreateBookingService(tourDetailRepository, tourDepartureRepository, bookingRepository); + } + + private TourDetail tourDetail(BigDecimal price, BigDecimal discountPrice) { + return tourDetail(price, discountPrice, 20); + } + + private TourDetail tourDetail(BigDecimal price, BigDecimal discountPrice, Integer maxGuests) { + return new TourDetail( + TOUR_ID, "Da Nang Tour", "da-nang-tour", "desc", 1L, "Da Nang", + 3, maxGuests, price, discountPrice, BigDecimal.valueOf(4.5), 10, + List.of(), List.of(), List.of(), List.of(), List.of() + ); + } + + private CreateBookingCommand command(Long userId, int adults, int children) { + return new CreateBookingCommand(IDEMPOTENCY_KEY, userId, TOUR_ID, DATE, adults, children, + "Jane", "jane@example.com", "0900000000"); + } + + @Test + void idempotencyKeyAlreadyUsed_returnsExistingBooking_neverTouchesTourOrDepartureOrSaves() { + service = newService(); + Booking existing = new Booking( + 9L, "TG-2026-000009", IDEMPOTENCY_KEY, 1L, TOUR_ID, "Da Nang Tour", "da-nang-tour", 3, + DEPARTURE_ID, DATE, + 2, 0, BigDecimal.valueOf(200), BookingStatus.PENDING, + "Jane", "jane@example.com", "0900000000", OffsetDateTime.now() + ); + when(bookingRepository.findByUserIdAndIdempotencyKey(1L, IDEMPOTENCY_KEY)).thenReturn(Optional.of(existing)); + + Booking result = service.createBooking(command(1L, 2, 0)); + + assertThat(result).isSameAs(existing); + verifyNoInteractions(tourDetailRepository, tourDepartureRepository); + verify(bookingRepository, never()).save(any()); + } + + @Test + void createBooking_looksUpIdempotencyKeyScopedToTheCallingUser_notGlobally() { + // Regression for the IDOR: the lookup must be scoped by (userId, key) — never just the + // key alone — otherwise one user's client-generated key colliding with another user's + // (e.g. both submitting "1") would return someone else's booking instead of creating a + // new one. + service = newService(); + when(tourDetailRepository.findById(TOUR_ID)).thenReturn(Optional.of(tourDetail(BigDecimal.valueOf(100), null))); + when(tourDepartureRepository.findDepartureId(TOUR_ID, DATE)).thenReturn(Optional.of(DEPARTURE_ID)); + when(tourDepartureRepository.reserveSlots(DEPARTURE_ID, 2)).thenReturn(true); + when(bookingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.createBooking(command(2L, 2, 0)); + + verify(bookingRepository).findByUserIdAndIdempotencyKey(2L, IDEMPOTENCY_KEY); + } + + @Test + void tourNotFound_throwsTourNotFoundException_neverTouchesDepartureOrBooking() { + service = newService(); + when(tourDetailRepository.findById(TOUR_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.createBooking(command(1L, 2, 0))) + .isInstanceOf(TourNotFoundException.class); + + verifyNoInteractions(tourDepartureRepository); + verify(bookingRepository, never()).save(any()); + } + + @Test + void departureNotFound_throwsTourDepartureNotFoundException_neverReservesOrSaves() { + service = newService(); + when(tourDetailRepository.findById(TOUR_ID)).thenReturn(Optional.of(tourDetail(BigDecimal.valueOf(100), null))); + when(tourDepartureRepository.findDepartureId(TOUR_ID, DATE)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.createBooking(command(1L, 2, 0))) + .isInstanceOf(TourDepartureNotFoundException.class); + + verify(tourDepartureRepository, never()).reserveSlots(any(), anyInt()); + verify(bookingRepository, never()).save(any()); + } + + @Test + void groupLargerThanMaxGuests_throwsBookingGroupTooLargeException_neverReservesOrSaves() { + service = newService(); + when(tourDetailRepository.findById(TOUR_ID)).thenReturn(Optional.of(tourDetail(BigDecimal.valueOf(100), null, 5))); + when(tourDepartureRepository.findDepartureId(TOUR_ID, DATE)).thenReturn(Optional.of(DEPARTURE_ID)); + + assertThatThrownBy(() -> service.createBooking(command(1L, 4, 2))) + .isInstanceOf(BookingGroupTooLargeException.class); + + verify(tourDepartureRepository, never()).reserveSlots(any(), anyInt()); + verify(bookingRepository, never()).save(any()); + } + + @Test + void groupSizeWithinMaxGuests_doesNotThrow() { + service = newService(); + when(tourDetailRepository.findById(TOUR_ID)).thenReturn(Optional.of(tourDetail(BigDecimal.valueOf(100), null, 5))); + when(tourDepartureRepository.findDepartureId(TOUR_ID, DATE)).thenReturn(Optional.of(DEPARTURE_ID)); + when(tourDepartureRepository.reserveSlots(DEPARTURE_ID, 5)).thenReturn(true); + when(bookingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + assertThatCode(() -> service.createBooking(command(1L, 5, 0))).doesNotThrowAnyException(); + } + + @Test + void noMaxGuestsConfigured_skipsGroupSizeCheck() { + service = newService(); + when(tourDetailRepository.findById(TOUR_ID)).thenReturn(Optional.of(tourDetail(BigDecimal.valueOf(100), null, null))); + when(tourDepartureRepository.findDepartureId(TOUR_ID, DATE)).thenReturn(Optional.of(DEPARTURE_ID)); + when(tourDepartureRepository.reserveSlots(DEPARTURE_ID, 50)).thenReturn(true); + when(bookingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Booking result = service.createBooking(command(1L, 50, 0)); + + assertThat(result).isNotNull(); + } + + @Test + void noAvailableSlots_throwsNoAvailableSlotsException_neverSavesBooking() { + service = newService(); + when(tourDetailRepository.findById(TOUR_ID)).thenReturn(Optional.of(tourDetail(BigDecimal.valueOf(100), null))); + when(tourDepartureRepository.findDepartureId(TOUR_ID, DATE)).thenReturn(Optional.of(DEPARTURE_ID)); + when(tourDepartureRepository.reserveSlots(DEPARTURE_ID, 2)).thenReturn(false); + + assertThatThrownBy(() -> service.createBooking(command(1L, 2, 0))) + .isInstanceOf(NoAvailableSlotsException.class); + + verify(bookingRepository, never()).save(any()); + } + + @Test + void success_usesRegularPriceWhenNoDiscount_multipliesByGuestCount() { + service = newService(); + when(tourDetailRepository.findById(TOUR_ID)).thenReturn(Optional.of(tourDetail(BigDecimal.valueOf(100), null))); + when(tourDepartureRepository.findDepartureId(TOUR_ID, DATE)).thenReturn(Optional.of(DEPARTURE_ID)); + when(tourDepartureRepository.reserveSlots(DEPARTURE_ID, 3)).thenReturn(true); + when(bookingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Booking result = service.createBooking(command(7L, 2, 1)); + + assertThat(result.totalPrice()).isEqualByComparingTo(BigDecimal.valueOf(300)); + } + + @Test + void success_prefersDiscountPriceOverRegularPrice() { + service = newService(); + when(tourDetailRepository.findById(TOUR_ID)) + .thenReturn(Optional.of(tourDetail(BigDecimal.valueOf(100), BigDecimal.valueOf(80)))); + when(tourDepartureRepository.findDepartureId(TOUR_ID, DATE)).thenReturn(Optional.of(DEPARTURE_ID)); + when(tourDepartureRepository.reserveSlots(DEPARTURE_ID, 2)).thenReturn(true); + when(bookingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + Booking result = service.createBooking(command(7L, 2, 0)); + + assertThat(result.totalPrice()).isEqualByComparingTo(BigDecimal.valueOf(160)); + } + + @Test + void success_savesBookingWithUserIdFromCommand_pendingStatus_andCorrectDepartureId() { + service = newService(); + when(tourDetailRepository.findById(TOUR_ID)).thenReturn(Optional.of(tourDetail(BigDecimal.valueOf(100), null))); + when(tourDepartureRepository.findDepartureId(TOUR_ID, DATE)).thenReturn(Optional.of(DEPARTURE_ID)); + when(tourDepartureRepository.reserveSlots(eq(DEPARTURE_ID), anyInt())).thenReturn(true); + when(bookingRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); + + service.createBooking(command(99L, 1, 0)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(Booking.class); + verify(bookingRepository).save(captor.capture()); + Booking saved = captor.getValue(); + assertThat(saved.userId()).isEqualTo(99L); + assertThat(saved.tourId()).isEqualTo(TOUR_ID); + assertThat(saved.tourTitle()).isEqualTo("Da Nang Tour"); + assertThat(saved.tourSlug()).isEqualTo("da-nang-tour"); + assertThat(saved.tourDurationDays()).isEqualTo(3); + assertThat(saved.departureId()).isEqualTo(DEPARTURE_ID); + assertThat(saved.departureDate()).isEqualTo(DATE); + assertThat(saved.status()).isEqualTo(BookingStatus.PENDING); + assertThat(saved.contactEmail()).isEqualTo("jane@example.com"); + assertThat(saved.idempotencyKey()).isEqualTo(IDEMPOTENCY_KEY); + } + + @Test + void lostDoubleSubmitRace_saveFailsOnUniqueConstraint_returnsWinnerBookingReadInNewTransaction() { + service = newService(); + when(tourDetailRepository.findById(TOUR_ID)).thenReturn(Optional.of(tourDetail(BigDecimal.valueOf(100), null))); + when(tourDepartureRepository.findDepartureId(TOUR_ID, DATE)).thenReturn(Optional.of(DEPARTURE_ID)); + when(tourDepartureRepository.reserveSlots(DEPARTURE_ID, 2)).thenReturn(true); + when(bookingRepository.save(any())).thenThrow(new DataIntegrityViolationException("duplicate key")); + Booking winner = new Booking( + 9L, "TG-2026-000009", IDEMPOTENCY_KEY, 1L, TOUR_ID, "Da Nang Tour", "da-nang-tour", 3, + DEPARTURE_ID, DATE, + 2, 0, BigDecimal.valueOf(200), BookingStatus.PENDING, + "Jane", "jane@example.com", "0900000000", OffsetDateTime.now() + ); + when(bookingRepository.findByUserIdAndIdempotencyKeyInNewTransaction(1L, IDEMPOTENCY_KEY)) + .thenReturn(Optional.of(winner)); + + Booking result = service.createBooking(command(1L, 2, 0)); + + assertThat(result).isSameAs(winner); + } + + @Test + void saveFailsOnUniqueConstraint_butNoBookingFoundByKey_rethrowsOriginalException() { + // Covers the (extremely unlikely) case of the failure being an unrelated unique-constraint + // violation, e.g. a bookingCode UUID collision, not an idempotency-key race: there is no + // winner to return, so the original exception must propagate instead of being swallowed. + service = newService(); + when(tourDetailRepository.findById(TOUR_ID)).thenReturn(Optional.of(tourDetail(BigDecimal.valueOf(100), null))); + when(tourDepartureRepository.findDepartureId(TOUR_ID, DATE)).thenReturn(Optional.of(DEPARTURE_ID)); + when(tourDepartureRepository.reserveSlots(DEPARTURE_ID, 2)).thenReturn(true); + DataIntegrityViolationException ex = new DataIntegrityViolationException("duplicate key"); + when(bookingRepository.save(any())).thenThrow(ex); + when(bookingRepository.findByUserIdAndIdempotencyKeyInNewTransaction(1L, IDEMPOTENCY_KEY)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.createBooking(command(1L, 2, 0))).isSameAs(ex); + } +} diff --git a/src/test/java/com/tripgoapi/application/service/GetBookingDetailServiceTest.java b/src/test/java/com/tripgoapi/application/service/GetBookingDetailServiceTest.java new file mode 100644 index 0000000..2aa1a78 --- /dev/null +++ b/src/test/java/com/tripgoapi/application/service/GetBookingDetailServiceTest.java @@ -0,0 +1,69 @@ +package com.tripgoapi.application.service; + +import com.tripgoapi.application.port.out.BookingRepositoryInterface; +import com.tripgoapi.domain.exception.BookingAccessDeniedException; +import com.tripgoapi.domain.exception.BookingNotFoundException; +import com.tripgoapi.domain.model.Booking; +import com.tripgoapi.domain.model.BookingStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GetBookingDetailServiceTest { + + private static final Long BOOKING_ID = 1L; + private static final Long OWNER_ID = 5L; + + @Mock + private BookingRepositoryInterface bookingRepository; + + private GetBookingDetailService service; + + private Booking bookingOwnedBy(Long userId) { + return new Booking( + BOOKING_ID, "TG-2026-000001", "idem-key-1", userId, 2L, "Da Nang Tour", "da-nang-tour", 3, + 3L, LocalDate.of(2026, 8, 15), + 2, 0, BigDecimal.valueOf(200), BookingStatus.PENDING, + "Jane", "jane@example.com", "0900000000", OffsetDateTime.now() + ); + } + + @Test + void bookingOwnedByRequester_returnsBooking() { + service = new GetBookingDetailService(bookingRepository); + when(bookingRepository.findById(BOOKING_ID)).thenReturn(Optional.of(bookingOwnedBy(OWNER_ID))); + + Booking result = service.getBookingDetail(BOOKING_ID, OWNER_ID); + + assertThat(result.id()).isEqualTo(BOOKING_ID); + } + + @Test + void bookingNotFound_throwsBookingNotFoundException() { + service = new GetBookingDetailService(bookingRepository); + when(bookingRepository.findById(BOOKING_ID)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.getBookingDetail(BOOKING_ID, OWNER_ID)) + .isInstanceOf(BookingNotFoundException.class); + } + + @Test + void bookingOwnedByAnotherUser_throwsBookingAccessDeniedException() { + service = new GetBookingDetailService(bookingRepository); + when(bookingRepository.findById(BOOKING_ID)).thenReturn(Optional.of(bookingOwnedBy(OWNER_ID))); + + assertThatThrownBy(() -> service.getBookingDetail(BOOKING_ID, 999L)) + .isInstanceOf(BookingAccessDeniedException.class); + } +} diff --git a/src/test/java/com/tripgoapi/application/service/GetBookingsServiceTest.java b/src/test/java/com/tripgoapi/application/service/GetBookingsServiceTest.java new file mode 100644 index 0000000..dea7f8c --- /dev/null +++ b/src/test/java/com/tripgoapi/application/service/GetBookingsServiceTest.java @@ -0,0 +1,50 @@ +package com.tripgoapi.application.service; + +import com.tripgoapi.application.port.out.BookingRepositoryInterface; +import com.tripgoapi.domain.model.Booking; +import com.tripgoapi.domain.model.BookingStatus; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GetBookingsServiceTest { + + @Mock + private BookingRepositoryInterface bookingRepository; + + @Test + void getBookingsForUser_delegatesToRepository() { + GetBookingsService service = new GetBookingsService(bookingRepository); + Booking booking = new Booking( + 1L, "TG-2026-000001", "idem-key-1", 5L, 2L, "Da Nang Tour", "da-nang-tour", 3, + 3L, LocalDate.of(2026, 8, 15), + 2, 0, BigDecimal.valueOf(200), BookingStatus.PENDING, + "Jane", "jane@example.com", "0900000000", OffsetDateTime.now() + ); + when(bookingRepository.findByUserId(5L, null)).thenReturn(List.of(booking)); + + List result = service.getBookingsForUser(5L, null); + + assertThat(result).containsExactly(booking); + } + + @Test + void getBookingsForUser_passesStatusFilterThrough() { + GetBookingsService service = new GetBookingsService(bookingRepository); + when(bookingRepository.findByUserId(5L, BookingStatus.CANCELLED)).thenReturn(List.of()); + + service.getBookingsForUser(5L, BookingStatus.CANCELLED); + + org.mockito.Mockito.verify(bookingRepository).findByUserId(5L, BookingStatus.CANCELLED); + } +} diff --git a/src/test/java/com/tripgoapi/infrastructure/adapter/in/web/BookingControllerTest.java b/src/test/java/com/tripgoapi/infrastructure/adapter/in/web/BookingControllerTest.java new file mode 100644 index 0000000..f239ae7 --- /dev/null +++ b/src/test/java/com/tripgoapi/infrastructure/adapter/in/web/BookingControllerTest.java @@ -0,0 +1,359 @@ +package com.tripgoapi.infrastructure.adapter.in.web; + +import com.tripgoapi.application.port.in.CancelBookingUseCase; +import com.tripgoapi.application.port.in.CreateBookingCommand; +import com.tripgoapi.application.port.in.CreateBookingUseCase; +import com.tripgoapi.application.port.in.GetBookingDetailUseCase; +import com.tripgoapi.application.port.in.GetBookingsUseCase; +import com.tripgoapi.application.port.out.AuthenticatedPrincipal; +import com.tripgoapi.domain.exception.BookingAccessDeniedException; +import com.tripgoapi.domain.exception.BookingCancellationNotAllowedException; +import com.tripgoapi.domain.exception.BookingGroupTooLargeException; +import com.tripgoapi.domain.exception.BookingNotFoundException; +import com.tripgoapi.domain.exception.NoAvailableSlotsException; +import com.tripgoapi.domain.exception.TourDepartureNotFoundException; +import com.tripgoapi.domain.exception.TourNotFoundException; +import com.tripgoapi.domain.model.Booking; +import com.tripgoapi.domain.model.BookingStatus; +import com.tripgoapi.domain.model.Role; +import com.tripgoapi.infrastructure.mapper.BookingWebMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mapstruct.factory.Mappers; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver; +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.OffsetDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@ExtendWith(MockitoExtension.class) +class BookingControllerTest { + + private static final Long USER_ID = 99L; + + @Mock + private CreateBookingUseCase createBookingUseCase; + @Mock + private GetBookingsUseCase getBookingsUseCase; + @Mock + private GetBookingDetailUseCase getBookingDetailUseCase; + @Mock + private CancelBookingUseCase cancelBookingUseCase; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + BookingWebMapper bookingWebMapper = Mappers.getMapper(BookingWebMapper.class); + BookingController controller = new BookingController( + createBookingUseCase, getBookingsUseCase, getBookingDetailUseCase, cancelBookingUseCase, bookingWebMapper); + mockMvc = MockMvcBuilders.standaloneSetup(controller) + .setControllerAdvice(new GlobalExceptionHandler()) + .setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()) + .build(); + + // Mirrors exactly what JwtAuthenticationFilter does in production — seeds the + // SecurityContext directly since this standalone MockMvc setup runs no security filters. + AuthenticatedPrincipal principal = new AuthenticatedPrincipal(USER_ID, "jane@example.com", Role.USER); + var authorities = List.of(new SimpleGrantedAuthority("ROLE_USER")); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken(principal, null, authorities)); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + private String validRequestBody() throws Exception { + return """ + { + "idempotencyKey": "idem-key-1", + "tourId": 1, + "date": "2026-08-15", + "adults": 2, + "children": 1, + "contact": { + "name": "Jane", + "email": "jane@example.com", + "phone": "0900000000" + } + } + """; + } + + private Booking sampleBooking() { + return sampleBooking(USER_ID, BookingStatus.PENDING); + } + + private Booking sampleBooking(Long userId, BookingStatus status) { + return new Booking( + 1L, "TG-2026-000001", "idem-key-1", userId, 1L, "Da Nang Tour", "da-nang-tour", 3, 3L, + LocalDate.of(2026, 8, 15), + 2, 1, BigDecimal.valueOf(300), status, + "Jane", "jane@example.com", "0900000000", OffsetDateTime.now() + ); + } + + @Test + void createBooking_missingRequiredFields_returns422_andNeverCallsUseCase() throws Exception { + // Every primitive field present (Jackson would reject a missing int with a raw + // deserialization error, i.e. malformed 400, not the 422 this test is targeting) — + // only Bean Validation rules are violated here: adults, contact.name/email/phone. + String invalidBody = """ + { + "idempotencyKey": "idem-key-1", + "tourId": 1, + "date": "2026-08-15", + "adults": 0, + "children": 0, + "contact": { "name": "", "email": "not-an-email", "phone": "x" } + } + """; + + mockMvc.perform(post("/bookings") + .contentType(MediaType.APPLICATION_JSON) + .content(invalidBody)) + .andExpect(status().isUnprocessableEntity()); + + verifyNoInteractions(createBookingUseCase); + } + + @Test + void createBooking_contactNameOrEmailOverMaxLength_returns422_andNeverCallsUseCase() throws Exception { + // contact_name/contact_email are VARCHAR(255) in the DB — without @Size this would pass + // Bean Validation and only fail at the DB as an opaque 500. + String overlongBody = """ + { + "idempotencyKey": "idem-key-1", + "tourId": 1, + "date": "2026-08-15", + "adults": 2, + "children": 1, + "contact": { "name": "%s", "email": "jane@example.com", "phone": "0900000000" } + } + """.formatted("A".repeat(256)); + + mockMvc.perform(post("/bookings") + .contentType(MediaType.APPLICATION_JSON) + .content(overlongBody)) + .andExpect(status().isUnprocessableEntity()); + + verifyNoInteractions(createBookingUseCase); + } + + @Test + void createBooking_idempotencyKeyOverMaxLength_returns422_andNeverCallsUseCase() throws Exception { + // idempotency_key is VARCHAR(100) in the DB — without @Size this would pass Bean + // Validation and only fail at insert time as a raw, misleading DataIntegrityViolationException. + String overlongBody = """ + { + "idempotencyKey": "%s", + "tourId": 1, + "date": "2026-08-15", + "adults": 2, + "children": 1, + "contact": { "name": "Jane", "email": "jane@example.com", "phone": "0900000000" } + } + """.formatted("k".repeat(101)); + + mockMvc.perform(post("/bookings") + .contentType(MediaType.APPLICATION_JSON) + .content(overlongBody)) + .andExpect(status().isUnprocessableEntity()); + + verifyNoInteractions(createBookingUseCase); + } + + @Test + void createBooking_success_returns201_withBookingBody_andUsesPrincipalUserId_notAnyClientValue() throws Exception { + when(createBookingUseCase.createBooking(any(CreateBookingCommand.class))).thenReturn(sampleBooking()); + + mockMvc.perform(post("/bookings") + .contentType(MediaType.APPLICATION_JSON) + .content(validRequestBody())) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.data.bookingCode").value("TG-2026-000001")) + .andExpect(jsonPath("$.data.totalPrice").value(300)); + + ArgumentCaptor captor = ArgumentCaptor.forClass(CreateBookingCommand.class); + verify(createBookingUseCase).createBooking(captor.capture()); + CreateBookingCommand command = captor.getValue(); + assertThat(command.userId()).isEqualTo(USER_ID); + assertThat(command.idempotencyKey()).isEqualTo("idem-key-1"); + assertThat(command.tourId()).isEqualTo(1L); + assertThat(command.adults()).isEqualTo(2); + assertThat(command.children()).isEqualTo(1); + assertThat(command.contactEmail()).isEqualTo("jane@example.com"); + } + + @Test + void createBooking_tourNotFound_returns404() throws Exception { + when(createBookingUseCase.createBooking(any(CreateBookingCommand.class))) + .thenThrow(new TourNotFoundException(1L)); + + mockMvc.perform(post("/bookings") + .contentType(MediaType.APPLICATION_JSON) + .content(validRequestBody())) + .andExpect(status().isNotFound()); + } + + @Test + void createBooking_departureNotFound_returns404() throws Exception { + when(createBookingUseCase.createBooking(any(CreateBookingCommand.class))) + .thenThrow(new TourDepartureNotFoundException(1L, LocalDate.of(2026, 8, 15))); + + mockMvc.perform(post("/bookings") + .contentType(MediaType.APPLICATION_JSON) + .content(validRequestBody())) + .andExpect(status().isNotFound()); + } + + @Test + void createBooking_noAvailableSlots_returns409() throws Exception { + when(createBookingUseCase.createBooking(any(CreateBookingCommand.class))) + .thenThrow(new NoAvailableSlotsException()); + + mockMvc.perform(post("/bookings") + .contentType(MediaType.APPLICATION_JSON) + .content(validRequestBody())) + .andExpect(status().isConflict()); + } + + @Test + void createBooking_groupTooLarge_returns409() throws Exception { + when(createBookingUseCase.createBooking(any(CreateBookingCommand.class))) + .thenThrow(new BookingGroupTooLargeException(3, 2)); + + mockMvc.perform(post("/bookings") + .contentType(MediaType.APPLICATION_JSON) + .content(validRequestBody())) + .andExpect(status().isConflict()); + } + + @Test + void getBookings_returnsListForCurrentPrincipal_withTourSummary() throws Exception { + when(getBookingsUseCase.getBookingsForUser(eq(USER_ID), isNull())).thenReturn(List.of(sampleBooking())); + + mockMvc.perform(get("/bookings")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].bookingCode").value("TG-2026-000001")) + .andExpect(jsonPath("$.data[0].tour.title").value("Da Nang Tour")) + .andExpect(jsonPath("$.data[0].tour.slug").value("da-nang-tour")); + + verify(getBookingsUseCase).getBookingsForUser(USER_ID, null); + } + + @Test + void getBookings_withStatusQueryParam_parsesAndPassesStatusFilter() throws Exception { + when(getBookingsUseCase.getBookingsForUser(USER_ID, BookingStatus.PENDING)).thenReturn(List.of(sampleBooking())); + + mockMvc.perform(get("/bookings").param("status", "pending")) + .andExpect(status().isOk()); + + verify(getBookingsUseCase).getBookingsForUser(USER_ID, BookingStatus.PENDING); + } + + @Test + void getBookings_withInvalidStatusQueryParam_returns422_andNeverCallsUseCase() throws Exception { + mockMvc.perform(get("/bookings").param("status", "not-a-status")) + .andExpect(status().isUnprocessableEntity()); + + verifyNoInteractions(getBookingsUseCase); + } + + @Test + void getBookings_withBlankStatusQueryParam_treatedAsNoFilter() throws Exception { + when(getBookingsUseCase.getBookingsForUser(eq(USER_ID), isNull())).thenReturn(List.of()); + + mockMvc.perform(get("/bookings").param("status", "")) + .andExpect(status().isOk()); + + verify(getBookingsUseCase).getBookingsForUser(USER_ID, null); + } + + @Test + void getBookingDetail_ownedByCurrentPrincipal_returns200() throws Exception { + when(getBookingDetailUseCase.getBookingDetail(1L, USER_ID)).thenReturn(sampleBooking()); + + mockMvc.perform(get("/bookings/1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.bookingCode").value("TG-2026-000001")); + } + + @Test + void getBookingDetail_notFound_returns404() throws Exception { + when(getBookingDetailUseCase.getBookingDetail(1L, USER_ID)).thenThrow(new BookingNotFoundException(1L)); + + mockMvc.perform(get("/bookings/1")) + .andExpect(status().isNotFound()); + } + + @Test + void getBookingDetail_ownedByAnotherUser_returns403() throws Exception { + when(getBookingDetailUseCase.getBookingDetail(1L, USER_ID)).thenThrow(new BookingAccessDeniedException(1L)); + + mockMvc.perform(get("/bookings/1")) + .andExpect(status().isForbidden()); + } + + @Test + void cancelBooking_ownedByCurrentPrincipal_returns200_withCancelledStatus() throws Exception { + when(cancelBookingUseCase.cancelBooking(1L, USER_ID)) + .thenReturn(sampleBooking(USER_ID, BookingStatus.CANCELLED)); + + mockMvc.perform(patch("/bookings/1/cancel")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.status").value("CANCELLED")); + } + + @Test + void cancelBooking_notFound_returns404() throws Exception { + when(cancelBookingUseCase.cancelBooking(1L, USER_ID)).thenThrow(new BookingNotFoundException(1L)); + + mockMvc.perform(patch("/bookings/1/cancel")) + .andExpect(status().isNotFound()); + } + + @Test + void cancelBooking_ownedByAnotherUser_returns403() throws Exception { + when(cancelBookingUseCase.cancelBooking(1L, USER_ID)).thenThrow(new BookingAccessDeniedException(1L)); + + mockMvc.perform(patch("/bookings/1/cancel")) + .andExpect(status().isForbidden()); + } + + @Test + void cancelBooking_alreadyCancelledOrCompleted_returns409() throws Exception { + when(cancelBookingUseCase.cancelBooking(1L, USER_ID)) + .thenThrow(new BookingCancellationNotAllowedException(BookingStatus.COMPLETED)); + + mockMvc.perform(patch("/bookings/1/cancel")) + .andExpect(status().isConflict()); + } +} diff --git a/src/test/java/com/tripgoapi/infrastructure/adapter/in/web/GlobalExceptionHandlerTest.java b/src/test/java/com/tripgoapi/infrastructure/adapter/in/web/GlobalExceptionHandlerTest.java index ded2a31..3f32833 100644 --- a/src/test/java/com/tripgoapi/infrastructure/adapter/in/web/GlobalExceptionHandlerTest.java +++ b/src/test/java/com/tripgoapi/infrastructure/adapter/in/web/GlobalExceptionHandlerTest.java @@ -1,6 +1,8 @@ package com.tripgoapi.infrastructure.adapter.in.web; +import com.tripgoapi.domain.exception.BookingAccessDeniedException; import com.tripgoapi.domain.exception.EmailAlreadyExistsException; +import com.tripgoapi.domain.exception.InvalidBookingStatusException; import com.tripgoapi.domain.exception.InvalidCredentialsException; import com.tripgoapi.domain.exception.TooManyLoginAttemptsException; import com.tripgoapi.domain.exception.TourNotFoundException; @@ -18,6 +20,10 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.core.MethodParameter; +import org.springframework.validation.BeanPropertyBindingResult; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; @@ -112,6 +118,15 @@ void conflictExceptionReturns409WithDomainMessage() { assertThat(response.getBody().message()).contains("jane@example.com"); } + @Test + void forbiddenExceptionReturns403WithDomainMessage() { + ResponseEntity response = + handler.handleForbidden(new BookingAccessDeniedException(1L), request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(response.getBody().message()).contains("id=1"); + } + @Test void unauthorizedExceptionReturns401WithGenericCredentialsMessage() { ResponseEntity response = handler.handleUnauthorized(new InvalidCredentialsException(), request); @@ -121,6 +136,15 @@ void unauthorizedExceptionReturns401WithGenericCredentialsMessage() { assertThat(response.getBody().message()).isEqualTo("Invalid email or password"); } + @Test + void unprocessableExceptionReturns422WithDomainMessage() { + ResponseEntity response = + handler.handleUnprocessable(new InvalidBookingStatusException("pendign"), request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT); + assertThat(response.getBody().message()).contains("pendign"); + } + @Test void tooManyRequestsExceptionReturns429() { ResponseEntity response = @@ -143,6 +167,26 @@ void constraintViolationReturnsAggregatedFriendlyMessages() { assertThat(response.getBody().message()).isEqualTo("page phải >= 1"); } + @Test + void methodArgumentNotValidReturns422_notMalformed400() throws NoSuchMethodException { + // 422, not 400: the request body is well-formed JSON, it just violates @Valid rules — + // distinct from malformed input (bad JSON/type), which stays 400 elsewhere. + BeanPropertyBindingResult bindingResult = new BeanPropertyBindingResult(new Object(), "request"); + bindingResult.addError(new FieldError("request", "contact.email", "contact.email không hợp lệ")); + MethodParameter methodParameter = + new MethodParameter(GlobalExceptionHandlerTest.class.getDeclaredMethod("dummyMethod", Object.class), 0); + MethodArgumentNotValidException ex = new MethodArgumentNotValidException(methodParameter, bindingResult); + + ResponseEntity response = handler.handleValidation(ex, request); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNPROCESSABLE_CONTENT); + assertThat(response.getBody().message()).contains("contact.email không hợp lệ"); + } + + @SuppressWarnings("unused") + private void dummyMethod(Object arg) { + } + @Test void dataIntegrityViolationReturns409_notAnUnhandled500() { // Defense-in-depth: catches any unique-constraint violation that reaches the web layer diff --git a/src/test/java/com/tripgoapi/infrastructure/adapter/out/persistence/BookingPersistenceAdapterTest.java b/src/test/java/com/tripgoapi/infrastructure/adapter/out/persistence/BookingPersistenceAdapterTest.java new file mode 100644 index 0000000..ddfa462 --- /dev/null +++ b/src/test/java/com/tripgoapi/infrastructure/adapter/out/persistence/BookingPersistenceAdapterTest.java @@ -0,0 +1,255 @@ +package com.tripgoapi.infrastructure.adapter.out.persistence; + +import com.tripgoapi.domain.model.Booking; +import com.tripgoapi.domain.model.BookingStatus; +import com.tripgoapi.infrastructure.adapter.out.persistence.entity.BookingEntity; +import com.tripgoapi.infrastructure.adapter.out.persistence.entity.TourDepartureEntity; +import com.tripgoapi.infrastructure.adapter.out.persistence.entity.TourEntity; +import com.tripgoapi.infrastructure.adapter.out.persistence.entity.UserEntity; +import com.tripgoapi.infrastructure.adapter.out.persistence.repository.BookingJpaRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.OffsetDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BookingPersistenceAdapterTest { + + @Mock + private BookingJpaRepository bookingJpaRepository; + + private BookingPersistenceAdapter adapter; + + private BookingPersistenceAdapter newAdapter() { + return new BookingPersistenceAdapter(bookingJpaRepository); + } + + private Booking newBooking() { + return new Booking( + null, null, "idem-key-1", 5L, 2L, null, null, null, 3L, LocalDate.of(2026, 8, 15), + 2, 1, BigDecimal.valueOf(300), BookingStatus.PENDING, + "Jane", "jane@example.com", "0900000000", null + ); + } + + @Test + void save_insertsThenAssignsFinalCodeDerivedFromGeneratedId() { + adapter = newAdapter(); + // Mimics IDENTITY generation: first save() (id == null) assigns an id; the second + // save() (the code-update) just returns the already-populated entity, like a real + // managed-entity update would. + when(bookingJpaRepository.save(org.mockito.ArgumentMatchers.any(BookingEntity.class))).thenAnswer(invocation -> { + BookingEntity entity = invocation.getArgument(0); + if (entity.getId() == null) { + entity.setId(123L); + } + return entity; + }); + + Booking result = adapter.save(newBooking()); + + assertThat(result.id()).isEqualTo(123L); + assertThat(result.bookingCode()).isEqualTo("TG-%d-000123".formatted(OffsetDateTime.now().getYear())); + verify(bookingJpaRepository, times(2)).save(org.mockito.ArgumentMatchers.any(BookingEntity.class)); + } + + @Test + void save_persistsAllDomainFieldsIntoTheEntity() { + adapter = newAdapter(); + when(bookingJpaRepository.save(org.mockito.ArgumentMatchers.any(BookingEntity.class))).thenAnswer(invocation -> { + BookingEntity entity = invocation.getArgument(0); + if (entity.getId() == null) { + entity.setId(1L); + } + return entity; + }); + + Booking result = adapter.save(newBooking()); + + assertThat(result.userId()).isEqualTo(5L); + assertThat(result.tourId()).isEqualTo(2L); + assertThat(result.departureId()).isEqualTo(3L); + assertThat(result.departureDate()).isEqualTo(LocalDate.of(2026, 8, 15)); + assertThat(result.adults()).isEqualTo(2); + assertThat(result.children()).isEqualTo(1); + assertThat(result.totalPrice()).isEqualByComparingTo(BigDecimal.valueOf(300)); + assertThat(result.status()).isEqualTo(BookingStatus.PENDING); + assertThat(result.contactName()).isEqualTo("Jane"); + assertThat(result.contactEmail()).isEqualTo("jane@example.com"); + assertThat(result.contactPhone()).isEqualTo("0900000000"); + assertThat(result.idempotencyKey()).isEqualTo("idem-key-1"); + } + + @Test + void save_returnsTourSummaryFromInputBooking_notFromTheStubTourEntity() { + // BookingPersistenceAdapter builds the tour association from just an id + // (TourEntity.builder().id(x).build()) to set the FK — it never loads the real row, so + // the returned Booking must carry the summary through from the caller, not re-derive it + // from that stub entity (which would silently come back null for title/slug/duration). + adapter = newAdapter(); + when(bookingJpaRepository.save(org.mockito.ArgumentMatchers.any(BookingEntity.class))).thenAnswer(invocation -> { + BookingEntity entity = invocation.getArgument(0); + if (entity.getId() == null) { + entity.setId(1L); + } + return entity; + }); + Booking withTourSummary = new Booking( + null, null, "idem-key-1", 5L, 2L, "Da Nang Tour", "da-nang-tour", 3, 3L, + LocalDate.of(2026, 8, 15), 2, 1, BigDecimal.valueOf(300), BookingStatus.PENDING, + "Jane", "jane@example.com", "0900000000", null + ); + + Booking result = adapter.save(withTourSummary); + + assertThat(result.tourTitle()).isEqualTo("Da Nang Tour"); + assertThat(result.tourSlug()).isEqualTo("da-nang-tour"); + assertThat(result.tourDurationDays()).isEqualTo(3); + } + + @Test + void findByUserIdAndIdempotencyKey_found_mapsEntity() { + adapter = newAdapter(); + BookingEntity entity = BookingEntity.builder() + .id(1L) + .bookingCode("TG-2026-000001") + .idempotencyKey("idem-key-1") + .user(UserEntity.builder().id(5L).build()) + .tour(TourEntity.builder().id(2L).build()) + .departure(TourDepartureEntity.builder().id(3L).departureDate(LocalDate.of(2026, 8, 15)).build()) + .adults(2).children(0) + .totalPrice(BigDecimal.valueOf(200)) + .status("PENDING") + .createdAt(OffsetDateTime.now()) + .build(); + when(bookingJpaRepository.findByUserIdAndIdempotencyKeyWithDeparture(5L, "idem-key-1")) + .thenReturn(java.util.Optional.of(entity)); + + assertThat(adapter.findByUserIdAndIdempotencyKey(5L, "idem-key-1")) + .map(Booking::bookingCode).contains("TG-2026-000001"); + } + + @Test + void findByUserIdAndIdempotencyKey_notFound_returnsEmpty() { + adapter = newAdapter(); + when(bookingJpaRepository.findByUserIdAndIdempotencyKeyWithDeparture(5L, "unknown")) + .thenReturn(java.util.Optional.empty()); + + assertThat(adapter.findByUserIdAndIdempotencyKey(5L, "unknown")).isEmpty(); + } + + @Test + void findByUserIdAndIdempotencyKeyInNewTransaction_delegatesToTheSameScopedLookup() { + adapter = newAdapter(); + BookingEntity entity = BookingEntity.builder() + .id(1L) + .bookingCode("TG-2026-000001") + .idempotencyKey("idem-key-1") + .user(UserEntity.builder().id(5L).build()) + .tour(TourEntity.builder().id(2L).build()) + .departure(TourDepartureEntity.builder().id(3L).departureDate(LocalDate.of(2026, 8, 15)).build()) + .adults(2).children(0) + .totalPrice(BigDecimal.valueOf(200)) + .status("PENDING") + .createdAt(OffsetDateTime.now()) + .build(); + when(bookingJpaRepository.findByUserIdAndIdempotencyKeyWithDeparture(5L, "idem-key-1")) + .thenReturn(java.util.Optional.of(entity)); + + assertThat(adapter.findByUserIdAndIdempotencyKeyInNewTransaction(5L, "idem-key-1")) + .map(Booking::bookingCode).contains("TG-2026-000001"); + } + + @Test + void findByUserId_mapsEntitiesUsingTheJoinFetchQuery() { + adapter = newAdapter(); + BookingEntity entity = BookingEntity.builder() + .id(1L) + .bookingCode("TG-2026-000001") + .user(UserEntity.builder().id(5L).build()) + .tour(TourEntity.builder().id(2L).title("Da Nang Tour").slug("da-nang-tour").durationDays(3).build()) + .departure(TourDepartureEntity.builder().id(3L).departureDate(LocalDate.of(2026, 8, 15)).build()) + .adults(2).children(0) + .totalPrice(BigDecimal.valueOf(200)) + .status("PENDING") + .contactName("Jane").contactEmail("jane@example.com").contactPhone("0900000000") + .createdAt(OffsetDateTime.now()) + .build(); + when(bookingJpaRepository.findByUserIdWithDeparture(5L, null)).thenReturn(List.of(entity)); + + List result = adapter.findByUserId(5L, null); + + assertThat(result).hasSize(1); + Booking booking = result.get(0); + assertThat(booking.bookingCode()).isEqualTo("TG-2026-000001"); + assertThat(booking.departureDate()).isEqualTo(LocalDate.of(2026, 8, 15)); + assertThat(booking.tourTitle()).isEqualTo("Da Nang Tour"); + assertThat(booking.tourSlug()).isEqualTo("da-nang-tour"); + assertThat(booking.tourDurationDays()).isEqualTo(3); + } + + @Test + void findByUserId_withStatusFilter_passesStatusNameToQuery() { + adapter = newAdapter(); + when(bookingJpaRepository.findByUserIdWithDeparture(5L, "CANCELLED")).thenReturn(List.of()); + + adapter.findByUserId(5L, BookingStatus.CANCELLED); + + verify(bookingJpaRepository).findByUserIdWithDeparture(5L, "CANCELLED"); + } + + @Test + void findById_found_mapsEntity() { + adapter = newAdapter(); + BookingEntity entity = BookingEntity.builder() + .id(1L) + .bookingCode("TG-2026-000001") + .user(UserEntity.builder().id(5L).build()) + .tour(TourEntity.builder().id(2L).build()) + .departure(TourDepartureEntity.builder().id(3L).departureDate(LocalDate.of(2026, 8, 15)).build()) + .adults(2).children(0) + .totalPrice(BigDecimal.valueOf(200)) + .status("PENDING") + .createdAt(OffsetDateTime.now()) + .build(); + when(bookingJpaRepository.findByIdWithDeparture(1L)).thenReturn(java.util.Optional.of(entity)); + + assertThat(adapter.findById(1L)).map(Booking::bookingCode).contains("TG-2026-000001"); + } + + @Test + void findById_notFound_returnsEmpty() { + adapter = newAdapter(); + when(bookingJpaRepository.findByIdWithDeparture(1L)).thenReturn(java.util.Optional.empty()); + + assertThat(adapter.findById(1L)).isEmpty(); + } + + @Test + void cancelIfCancellable_oneRowAffected_returnsTrue() { + adapter = newAdapter(); + when(bookingJpaRepository.cancelIfCancellable(1L)).thenReturn(1); + + assertThat(adapter.cancelIfCancellable(1L)).isTrue(); + } + + @Test + void cancelIfCancellable_zeroRowsAffected_returnsFalse() { + // Zero rows means the WHERE status IN (PENDING, CONFIRMED) predicate didn't match — + // either not found, already cancelled/completed, or lost a concurrent cancel race. + adapter = newAdapter(); + when(bookingJpaRepository.cancelIfCancellable(1L)).thenReturn(0); + + assertThat(adapter.cancelIfCancellable(1L)).isFalse(); + } +} diff --git a/src/test/java/com/tripgoapi/infrastructure/adapter/out/persistence/TourDeparturePersistenceAdapterTest.java b/src/test/java/com/tripgoapi/infrastructure/adapter/out/persistence/TourDeparturePersistenceAdapterTest.java new file mode 100644 index 0000000..1d77007 --- /dev/null +++ b/src/test/java/com/tripgoapi/infrastructure/adapter/out/persistence/TourDeparturePersistenceAdapterTest.java @@ -0,0 +1,74 @@ +package com.tripgoapi.infrastructure.adapter.out.persistence; + +import com.tripgoapi.infrastructure.adapter.out.persistence.entity.TourDepartureEntity; +import com.tripgoapi.infrastructure.adapter.out.persistence.repository.TourDepartureJpaRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class TourDeparturePersistenceAdapterTest { + + private static final Long TOUR_ID = 1L; + private static final LocalDate DATE = LocalDate.of(2026, 8, 15); + + @Mock + private TourDepartureJpaRepository tourDepartureJpaRepository; + + private TourDeparturePersistenceAdapter adapter; + + private TourDeparturePersistenceAdapter newAdapter() { + return new TourDeparturePersistenceAdapter(tourDepartureJpaRepository); + } + + @Test + void findDepartureId_found_returnsId() { + adapter = newAdapter(); + TourDepartureEntity entity = TourDepartureEntity.builder().id(42L).build(); + when(tourDepartureJpaRepository.findByTour_IdAndDepartureDate(TOUR_ID, DATE)).thenReturn(Optional.of(entity)); + + assertThat(adapter.findDepartureId(TOUR_ID, DATE)).contains(42L); + } + + @Test + void findDepartureId_notFound_returnsEmpty() { + adapter = newAdapter(); + when(tourDepartureJpaRepository.findByTour_IdAndDepartureDate(TOUR_ID, DATE)).thenReturn(Optional.empty()); + + assertThat(adapter.findDepartureId(TOUR_ID, DATE)).isEmpty(); + } + + @Test + void reserveSlots_oneRowAffected_returnsTrue() { + adapter = newAdapter(); + when(tourDepartureJpaRepository.reserveSlotsIfAvailable(42L, 3)).thenReturn(1); + + assertThat(adapter.reserveSlots(42L, 3)).isTrue(); + } + + @Test + void reserveSlots_zeroRowsAffected_returnsFalse() { + // Zero rows means the WHERE (total-booked) >= guestCount predicate didn't match — + // not enough remaining capacity. Must not be reported as a success. + adapter = newAdapter(); + when(tourDepartureJpaRepository.reserveSlotsIfAvailable(42L, 3)).thenReturn(0); + + assertThat(adapter.reserveSlots(42L, 3)).isFalse(); + } + + @Test + void releaseSlots_delegatesToRepository() { + adapter = newAdapter(); + + adapter.releaseSlots(42L, 3); + + org.mockito.Mockito.verify(tourDepartureJpaRepository).releaseSlots(42L, 3); + } +}