From ac30817a2365e294a2d9fb2a453bd22182f73c45 Mon Sep 17 00:00:00 2001 From: Swapan Pramanick Date: Fri, 2 Apr 2021 12:51:43 +0200 Subject: [PATCH] Evaluation Article: A quick and practical example of Hexagonal Architecture in Java --- .../hexagonal/HexagonalSpringApplication.java | 20 +++ .../adapter/BookingPersistenceAdapter.java | 18 +++ .../adapter/RestAPIEndpointAdapter.java | 38 ++++++ .../adapter/TheatreServiceAdapter.java | 31 +++++ .../adapter/WalletServiceAdapter.java | 23 ++++ .../baeldung/hexagonal/domain/Booking.java | 84 +++++++++++++ .../service/CustomerWalletService.java | 8 ++ .../service/MockCustomerWalletService.java | 11 ++ .../external/service/MockTheatreService.java | 21 ++++ .../external/service/TheatreService.java | 24 ++++ .../port/BookingPersistencePort.java | 7 ++ .../hexagonal/port/BookingServicePort.java | 73 +++++++++++ .../hexagonal/port/TheatreServicePort.java | 9 ++ .../hexagonal/port/WalletServicePort.java | 5 + .../repository/BookingRepository.java | 8 ++ .../repository/MockBookingRepository.java | 11 ++ .../hexagonal/usecase/BookTicketUseCase.java | 67 ++++++++++ .../RestAPIEndpointAdapterUnitTest.java | 88 +++++++++++++ .../usecase/BookTicketUseCaseUnitTest.java | 119 ++++++++++++++++++ 19 files changed, 665 insertions(+) create mode 100644 ddd/src/main/java/com/baeldung/hexagonal/HexagonalSpringApplication.java create mode 100644 ddd/src/main/java/com/baeldung/hexagonal/adapter/BookingPersistenceAdapter.java create mode 100644 ddd/src/main/java/com/baeldung/hexagonal/adapter/RestAPIEndpointAdapter.java create mode 100644 ddd/src/main/java/com/baeldung/hexagonal/adapter/TheatreServiceAdapter.java create mode 100644 ddd/src/main/java/com/baeldung/hexagonal/adapter/WalletServiceAdapter.java create mode 100644 ddd/src/main/java/com/baeldung/hexagonal/domain/Booking.java create mode 100644 ddd/src/main/java/com/baeldung/hexagonal/external/service/CustomerWalletService.java create mode 100644 ddd/src/main/java/com/baeldung/hexagonal/external/service/MockCustomerWalletService.java create mode 100644 ddd/src/main/java/com/baeldung/hexagonal/external/service/MockTheatreService.java create mode 100644 ddd/src/main/java/com/baeldung/hexagonal/external/service/TheatreService.java create mode 100644 ddd/src/main/java/com/baeldung/hexagonal/port/BookingPersistencePort.java create mode 100644 ddd/src/main/java/com/baeldung/hexagonal/port/BookingServicePort.java create mode 100644 ddd/src/main/java/com/baeldung/hexagonal/port/TheatreServicePort.java create mode 100644 ddd/src/main/java/com/baeldung/hexagonal/port/WalletServicePort.java create mode 100644 ddd/src/main/java/com/baeldung/hexagonal/repository/BookingRepository.java create mode 100644 ddd/src/main/java/com/baeldung/hexagonal/repository/MockBookingRepository.java create mode 100644 ddd/src/main/java/com/baeldung/hexagonal/usecase/BookTicketUseCase.java create mode 100644 ddd/src/test/java/com/baeldung/hexagonal/adapter/RestAPIEndpointAdapterUnitTest.java create mode 100644 ddd/src/test/java/com/baeldung/hexagonal/usecase/BookTicketUseCaseUnitTest.java diff --git a/ddd/src/main/java/com/baeldung/hexagonal/HexagonalSpringApplication.java b/ddd/src/main/java/com/baeldung/hexagonal/HexagonalSpringApplication.java new file mode 100644 index 0000000000..c679d459f0 --- /dev/null +++ b/ddd/src/main/java/com/baeldung/hexagonal/HexagonalSpringApplication.java @@ -0,0 +1,20 @@ +package com.baeldung.hexagonal; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.cassandra.CassandraAutoConfiguration; +import org.springframework.boot.autoconfigure.data.mongo.MongoDataAutoConfiguration; +import org.springframework.boot.autoconfigure.mongo.MongoAutoConfiguration; + + +@SpringBootApplication(exclude={ + CassandraAutoConfiguration.class, + MongoDataAutoConfiguration.class, + MongoAutoConfiguration.class +}) +public class HexagonalSpringApplication { + + public static void main(final String[] args) { + SpringApplication.run(HexagonalSpringApplication.class, args); + } +} diff --git a/ddd/src/main/java/com/baeldung/hexagonal/adapter/BookingPersistenceAdapter.java b/ddd/src/main/java/com/baeldung/hexagonal/adapter/BookingPersistenceAdapter.java new file mode 100644 index 0000000000..e6d4dbbe36 --- /dev/null +++ b/ddd/src/main/java/com/baeldung/hexagonal/adapter/BookingPersistenceAdapter.java @@ -0,0 +1,18 @@ +package com.baeldung.hexagonal.adapter; + +import com.baeldung.hexagonal.domain.Booking; +import com.baeldung.hexagonal.port.BookingPersistencePort; +import com.baeldung.hexagonal.repository.BookingRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +@Component +public class BookingPersistenceAdapter implements BookingPersistencePort { + + @Autowired + private BookingRepository bookingRepository; + + public boolean persist(Booking booking) { + return bookingRepository.save(booking); + } +} diff --git a/ddd/src/main/java/com/baeldung/hexagonal/adapter/RestAPIEndpointAdapter.java b/ddd/src/main/java/com/baeldung/hexagonal/adapter/RestAPIEndpointAdapter.java new file mode 100644 index 0000000000..7c35f302c0 --- /dev/null +++ b/ddd/src/main/java/com/baeldung/hexagonal/adapter/RestAPIEndpointAdapter.java @@ -0,0 +1,38 @@ +package com.baeldung.hexagonal.adapter; + +import com.baeldung.hexagonal.port.BookingServicePort; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import static com.baeldung.hexagonal.port.BookingServicePort.BookingRequest; +import static com.baeldung.hexagonal.port.BookingServicePort.BookingResponse; +import static com.baeldung.hexagonal.port.BookingServicePort.BookingResponse.*; + +@RestController +public class RestAPIEndpointAdapter { + + private BookingServicePort bookingServicePort; + + @Autowired + public RestAPIEndpointAdapter(BookingServicePort bookingServicePort) { + this.bookingServicePort = bookingServicePort; + } + + @PostMapping(path = "/booking") + public ResponseEntity createBooking(@RequestBody BookingRequest request) { + BookingResponse response = bookingServicePort.book(request); + + if (response.getStatusCode() == SEAT_NOT_AVAILABLE + || response.getStatusCode() == PAYMENT_FAILED){ + return new ResponseEntity(response, HttpStatus.PRECONDITION_FAILED); + } else if (response.getStatusCode() == UNKNOWN_ERROR) { + return new ResponseEntity(response, HttpStatus.INTERNAL_SERVER_ERROR); + } + + return new ResponseEntity(response, HttpStatus.CREATED); + } +} diff --git a/ddd/src/main/java/com/baeldung/hexagonal/adapter/TheatreServiceAdapter.java b/ddd/src/main/java/com/baeldung/hexagonal/adapter/TheatreServiceAdapter.java new file mode 100644 index 0000000000..d644f5eee0 --- /dev/null +++ b/ddd/src/main/java/com/baeldung/hexagonal/adapter/TheatreServiceAdapter.java @@ -0,0 +1,31 @@ +package com.baeldung.hexagonal.adapter; + +import com.baeldung.hexagonal.external.service.TheatreService; +import com.baeldung.hexagonal.port.TheatreServicePort; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.Set; + +@Component +public class TheatreServiceAdapter implements TheatreServicePort { + + @Autowired + private TheatreService theatreService; + + public Optional reserveSeats(String theatreId, String movieShowId, Set seats) { + ResponseEntity response = theatreService.postReservation(theatreId, movieShowId, seats); + if (response.getStatusCode() == HttpStatus.CREATED) { + return Optional.of(response.getBody().getId()); + } + return Optional.empty(); + } + + public boolean releaseSeats(String resrevationId) { + ResponseEntity response = theatreService.deleteReservation(resrevationId); + return response.getStatusCode() == HttpStatus.NO_CONTENT; + } +} diff --git a/ddd/src/main/java/com/baeldung/hexagonal/adapter/WalletServiceAdapter.java b/ddd/src/main/java/com/baeldung/hexagonal/adapter/WalletServiceAdapter.java new file mode 100644 index 0000000000..930b9c1cf6 --- /dev/null +++ b/ddd/src/main/java/com/baeldung/hexagonal/adapter/WalletServiceAdapter.java @@ -0,0 +1,23 @@ +package com.baeldung.hexagonal.adapter; + +import com.baeldung.hexagonal.external.service.CustomerWalletService; +import com.baeldung.hexagonal.port.WalletServicePort; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; + +@Component +public class WalletServiceAdapter implements WalletServicePort { + + private final CustomerWalletService customerWalletService; + + @Autowired + public WalletServiceAdapter(CustomerWalletService customerWalletService) { + this.customerWalletService = customerWalletService; + } + + public boolean debit(String customerId, Double amount) { + HttpStatus response = customerWalletService.postDebit(customerId, amount); + return response == HttpStatus.CREATED; + } +} diff --git a/ddd/src/main/java/com/baeldung/hexagonal/domain/Booking.java b/ddd/src/main/java/com/baeldung/hexagonal/domain/Booking.java new file mode 100644 index 0000000000..34e3f3b81b --- /dev/null +++ b/ddd/src/main/java/com/baeldung/hexagonal/domain/Booking.java @@ -0,0 +1,84 @@ +package com.baeldung.hexagonal.domain; + +import java.util.Set; + +public class Booking { + private String bookingId; + private String movieShowId; + private String theatreId; + private String customerId; + private Set seats; + private Double amount; + private Status status; + + public enum Status { + INITIAL, SUCCESS, FAILURE + } + + public Booking( + String bookingId, String movieShowId, String theatreId, String customerId, Set seats, Double amount, Status status) { + this.bookingId = bookingId; + this.movieShowId = movieShowId; + this.theatreId = theatreId; + this.customerId = customerId; + this.seats = seats; + this.amount = amount; + this.status = Status.INITIAL; + } + + public String getMovieShowId() { + return movieShowId; + } + + public String getTheatreId() { + return theatreId; + } + + public Set getSeats() { + return seats; + } + + public String getCustomerId() { + return customerId; + } + + public String getBookingId() { + return bookingId; + } + + public Double getAmount() { + return amount; + } + + public Status getStatus() { + return status; + } + + public void setBookingId(String bookingId) { + this.bookingId = bookingId; + } + + public void setMovieShowId(String movieShowId) { + this.movieShowId = movieShowId; + } + + public void setTheatreId(String theatreId) { + this.theatreId = theatreId; + } + + public void setCustomerId(String customerId) { + this.customerId = customerId; + } + + public void setSeats(Set seats) { + this.seats = seats; + } + + public void setAmount(Double amount) { + this.amount = amount; + } + + public void setStatus(Status status) { + this.status = status; + } +} diff --git a/ddd/src/main/java/com/baeldung/hexagonal/external/service/CustomerWalletService.java b/ddd/src/main/java/com/baeldung/hexagonal/external/service/CustomerWalletService.java new file mode 100644 index 0000000000..102cef788e --- /dev/null +++ b/ddd/src/main/java/com/baeldung/hexagonal/external/service/CustomerWalletService.java @@ -0,0 +1,8 @@ +package com.baeldung.hexagonal.external.service; + +import org.springframework.http.HttpStatus; + +public interface CustomerWalletService { + + HttpStatus postDebit(String customerId, Double amount); +} diff --git a/ddd/src/main/java/com/baeldung/hexagonal/external/service/MockCustomerWalletService.java b/ddd/src/main/java/com/baeldung/hexagonal/external/service/MockCustomerWalletService.java new file mode 100644 index 0000000000..4a76368b19 --- /dev/null +++ b/ddd/src/main/java/com/baeldung/hexagonal/external/service/MockCustomerWalletService.java @@ -0,0 +1,11 @@ +package com.baeldung.hexagonal.external.service; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +@Service +public class MockCustomerWalletService implements CustomerWalletService { + public HttpStatus postDebit(String customerId, Double amount) { + return HttpStatus.CREATED; + } +} diff --git a/ddd/src/main/java/com/baeldung/hexagonal/external/service/MockTheatreService.java b/ddd/src/main/java/com/baeldung/hexagonal/external/service/MockTheatreService.java new file mode 100644 index 0000000000..333cbee889 --- /dev/null +++ b/ddd/src/main/java/com/baeldung/hexagonal/external/service/MockTheatreService.java @@ -0,0 +1,21 @@ +package com.baeldung.hexagonal.external.service; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; + +import java.util.Set; +import java.util.UUID; + +@Service +public class MockTheatreService implements TheatreService { + + public ResponseEntity postReservation(String theatreId, String movieShowId, Set seats) { + return new ResponseEntity( + new Reservation(UUID.randomUUID().toString()), HttpStatus.CREATED); + } + + public ResponseEntity deleteReservation(String reservationId) { + return new ResponseEntity(HttpStatus.NO_CONTENT); + } +} diff --git a/ddd/src/main/java/com/baeldung/hexagonal/external/service/TheatreService.java b/ddd/src/main/java/com/baeldung/hexagonal/external/service/TheatreService.java new file mode 100644 index 0000000000..48f0c5e15d --- /dev/null +++ b/ddd/src/main/java/com/baeldung/hexagonal/external/service/TheatreService.java @@ -0,0 +1,24 @@ +package com.baeldung.hexagonal.external.service; + +import org.springframework.http.ResponseEntity; + +import java.util.Set; + +public interface TheatreService { + + ResponseEntity postReservation(String theatreId, String movieShowId, Set seats); + + ResponseEntity deleteReservation(String reservationId); + + class Reservation { + private final String id; + + public Reservation(String id) { + this.id = id; + } + + public String getId() { + return id; + } + } +} diff --git a/ddd/src/main/java/com/baeldung/hexagonal/port/BookingPersistencePort.java b/ddd/src/main/java/com/baeldung/hexagonal/port/BookingPersistencePort.java new file mode 100644 index 0000000000..0deffb42e0 --- /dev/null +++ b/ddd/src/main/java/com/baeldung/hexagonal/port/BookingPersistencePort.java @@ -0,0 +1,7 @@ +package com.baeldung.hexagonal.port; + +import com.baeldung.hexagonal.domain.Booking; + +public interface BookingPersistencePort { + boolean persist(Booking booking); +} diff --git a/ddd/src/main/java/com/baeldung/hexagonal/port/BookingServicePort.java b/ddd/src/main/java/com/baeldung/hexagonal/port/BookingServicePort.java new file mode 100644 index 0000000000..3b444df4f6 --- /dev/null +++ b/ddd/src/main/java/com/baeldung/hexagonal/port/BookingServicePort.java @@ -0,0 +1,73 @@ +package com.baeldung.hexagonal.port; + +import java.util.Set; + +public interface BookingServicePort { + + BookingResponse book(BookingRequest request); + + class BookingRequest { + private String movieShowId; + private String customerId; + private String theatreId; + private Set seats; + private Double amount; + + public String getMovieShowId() { + return movieShowId; + } + + public void setMovieShowId(String movieShowId) { + this.movieShowId = movieShowId; + } + + public String getCustomerId() { + return customerId; + } + + public void setCustomerId(String customerId) { + this.customerId = customerId; + } + + public String getTheatreId() { + return theatreId; + } + + public void setTheatreId(String theatreId) { + this.theatreId = theatreId; + } + + public Set getSeats() { + return seats; + } + + public void setSeats(Set seats) { + this.seats = seats; + } + + public Double getAmount() { + return amount; + } + + public void setAmount(Double amount) { + this.amount = amount; + } + } + + class BookingResponse { + public static final int SUCCESS = 0; + public static final int SEAT_NOT_AVAILABLE = 1; + public static final int PAYMENT_FAILED = 2; + public static final int UNKNOWN_ERROR = 3; + + private final int statusCode; + + public BookingResponse(int statusCode) { + this.statusCode = statusCode; + } + + public int getStatusCode() { + return statusCode; + } + } +} diff --git a/ddd/src/main/java/com/baeldung/hexagonal/port/TheatreServicePort.java b/ddd/src/main/java/com/baeldung/hexagonal/port/TheatreServicePort.java new file mode 100644 index 0000000000..dac517c724 --- /dev/null +++ b/ddd/src/main/java/com/baeldung/hexagonal/port/TheatreServicePort.java @@ -0,0 +1,9 @@ +package com.baeldung.hexagonal.port; + +import java.util.Optional; +import java.util.Set; + +public interface TheatreServicePort { + Optional reserveSeats(String theatreId, String movieShowId, Set seats); + boolean releaseSeats(String resrevationId); +} diff --git a/ddd/src/main/java/com/baeldung/hexagonal/port/WalletServicePort.java b/ddd/src/main/java/com/baeldung/hexagonal/port/WalletServicePort.java new file mode 100644 index 0000000000..102bb619e8 --- /dev/null +++ b/ddd/src/main/java/com/baeldung/hexagonal/port/WalletServicePort.java @@ -0,0 +1,5 @@ +package com.baeldung.hexagonal.port; + +public interface WalletServicePort { + boolean debit(String customerId, Double amount); +} diff --git a/ddd/src/main/java/com/baeldung/hexagonal/repository/BookingRepository.java b/ddd/src/main/java/com/baeldung/hexagonal/repository/BookingRepository.java new file mode 100644 index 0000000000..c56ffe1308 --- /dev/null +++ b/ddd/src/main/java/com/baeldung/hexagonal/repository/BookingRepository.java @@ -0,0 +1,8 @@ +package com.baeldung.hexagonal.repository; + +import com.baeldung.hexagonal.domain.Booking; + +public interface BookingRepository { + + boolean save(Booking booking); +} diff --git a/ddd/src/main/java/com/baeldung/hexagonal/repository/MockBookingRepository.java b/ddd/src/main/java/com/baeldung/hexagonal/repository/MockBookingRepository.java new file mode 100644 index 0000000000..d378cff0af --- /dev/null +++ b/ddd/src/main/java/com/baeldung/hexagonal/repository/MockBookingRepository.java @@ -0,0 +1,11 @@ +package com.baeldung.hexagonal.repository; + +import com.baeldung.hexagonal.domain.Booking; +import org.springframework.stereotype.Repository; + +@Repository +public class MockBookingRepository implements BookingRepository { + public boolean save(Booking booking) { + return true; + } +} diff --git a/ddd/src/main/java/com/baeldung/hexagonal/usecase/BookTicketUseCase.java b/ddd/src/main/java/com/baeldung/hexagonal/usecase/BookTicketUseCase.java new file mode 100644 index 0000000000..7d9db9724e --- /dev/null +++ b/ddd/src/main/java/com/baeldung/hexagonal/usecase/BookTicketUseCase.java @@ -0,0 +1,67 @@ +package com.baeldung.hexagonal.usecase; + +import com.baeldung.hexagonal.domain.Booking; +import com.baeldung.hexagonal.port.BookingPersistencePort; +import com.baeldung.hexagonal.port.BookingServicePort; +import com.baeldung.hexagonal.port.TheatreServicePort; +import com.baeldung.hexagonal.port.WalletServicePort; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.Optional; +import java.util.UUID; + +import static com.baeldung.hexagonal.port.BookingServicePort.BookingResponse.*; + +@Component +public class BookTicketUseCase implements BookingServicePort { + + private BookingPersistencePort bookingPersistencePort; + private TheatreServicePort theatreServicePort; + private WalletServicePort walletServicePort; + + @Autowired + public BookTicketUseCase(BookingPersistencePort bookingPersistencePort, TheatreServicePort theatreServicePort, WalletServicePort walletServicePort) { + this.bookingPersistencePort = bookingPersistencePort; + this.theatreServicePort = theatreServicePort; + this.walletServicePort = walletServicePort; + } + + public BookingResponse book(BookingRequest request) { + + Booking booking = new Booking( + UUID.randomUUID().toString(), + request.getMovieShowId(), + request.getTheatreId(), + request.getCustomerId(), + request.getSeats(), + request.getAmount(), + Booking.Status.INITIAL); + + if (!bookingPersistencePort.persist(booking)) { + return new BookingResponse(UNKNOWN_ERROR); + } + + Optional reservationIdOptional = theatreServicePort.reserveSeats( + booking.getTheatreId(), + booking.getMovieShowId(), + booking.getSeats()); + if (!reservationIdOptional.isPresent()) { + booking.setStatus(Booking.Status.FAILURE); + bookingPersistencePort.persist(booking); + return new BookingResponse(SEAT_NOT_AVAILABLE); + } + + if (!walletServicePort.debit(booking.getCustomerId(), booking.getAmount())) { + reservationIdOptional.ifPresent(reservationId -> theatreServicePort.releaseSeats(reservationId)); + booking.setStatus(Booking.Status.FAILURE); + bookingPersistencePort.persist(booking); + return new BookingResponse(PAYMENT_FAILED); + } + + booking.setStatus(Booking.Status.SUCCESS); + bookingPersistencePort.persist(booking); + + return new BookingResponse(SUCCESS); + } +} diff --git a/ddd/src/test/java/com/baeldung/hexagonal/adapter/RestAPIEndpointAdapterUnitTest.java b/ddd/src/test/java/com/baeldung/hexagonal/adapter/RestAPIEndpointAdapterUnitTest.java new file mode 100644 index 0000000000..76d853fdda --- /dev/null +++ b/ddd/src/test/java/com/baeldung/hexagonal/adapter/RestAPIEndpointAdapterUnitTest.java @@ -0,0 +1,88 @@ +package com.baeldung.hexagonal.adapter; + +import com.baeldung.hexagonal.port.BookingServicePort; +import com.baeldung.hexagonal.port.BookingServicePort.BookingRequest; +import com.baeldung.hexagonal.port.BookingServicePort.BookingResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +import java.util.Arrays; +import java.util.HashSet; + +import static com.baeldung.hexagonal.port.BookingServicePort.BookingResponse.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class RestAPIEndpointAdapterUnitTest { + + private BookingServicePort bookingServicePort; + private RestAPIEndpointAdapter restAPIEndpointAdapter; + + @BeforeEach + void setUp() { + bookingServicePort = mock(BookingServicePort.class); + restAPIEndpointAdapter = new RestAPIEndpointAdapter(bookingServicePort); + } + + private BookingServicePort.BookingRequest getBookingRequest() { + BookingServicePort.BookingRequest request = new BookingServicePort.BookingRequest(); + request.setTheatreId("theatre-id"); + request.setMovieShowId("movie-show-id"); + request.setCustomerId("customer-id"); + request.setSeats(new HashSet<>(Arrays.asList("A1", "A2"))); + request.setAmount(100.00); + return request; + } + + @Test + void whenBookingServicePortReturnsUnknownError_thenReturnInternalServerError() { + BookingServicePort.BookingRequest request = getBookingRequest(); + when(bookingServicePort.book(any(BookingRequest.class))).thenReturn(new BookingResponse(UNKNOWN_ERROR)); + + ResponseEntity response = restAPIEndpointAdapter.createBooking(request); + + verify(bookingServicePort).book(any(BookingRequest.class)); + assertNotNull(response); + assertEquals(HttpStatus.INTERNAL_SERVER_ERROR, response.getStatusCode()); + } + + @Test + void whenBookingServicePortReturnsSeatUnavailable_thenReturnPreconditionFailed() { + BookingServicePort.BookingRequest request = getBookingRequest(); + when(bookingServicePort.book(any(BookingRequest.class))).thenReturn(new BookingResponse(SEAT_NOT_AVAILABLE)); + + ResponseEntity response = restAPIEndpointAdapter.createBooking(request); + + verify(bookingServicePort).book(any(BookingRequest.class)); + assertNotNull(response); + assertEquals(HttpStatus.PRECONDITION_FAILED, response.getStatusCode()); + } + + @Test + void whenBookingServicePortReturnsPaymentFailed_thenReturnPreconditionFailed() { + BookingServicePort.BookingRequest request = getBookingRequest(); + when(bookingServicePort.book(any(BookingRequest.class))).thenReturn(new BookingResponse(PAYMENT_FAILED)); + + ResponseEntity response = restAPIEndpointAdapter.createBooking(request); + + verify(bookingServicePort).book(any(BookingRequest.class)); + assertNotNull(response); + assertEquals(HttpStatus.PRECONDITION_FAILED, response.getStatusCode()); + } + + @Test + void whenBookingServicePortReturnsSuccess_thenReturnCreated() { + BookingServicePort.BookingRequest request = getBookingRequest(); + when(bookingServicePort.book(any(BookingRequest.class))).thenReturn(new BookingResponse(SUCCESS)); + + ResponseEntity response = restAPIEndpointAdapter.createBooking(request); + + verify(bookingServicePort).book(any(BookingRequest.class)); + assertNotNull(response); + assertEquals(HttpStatus.CREATED, response.getStatusCode()); + } +} \ No newline at end of file diff --git a/ddd/src/test/java/com/baeldung/hexagonal/usecase/BookTicketUseCaseUnitTest.java b/ddd/src/test/java/com/baeldung/hexagonal/usecase/BookTicketUseCaseUnitTest.java new file mode 100644 index 0000000000..eec2b65609 --- /dev/null +++ b/ddd/src/test/java/com/baeldung/hexagonal/usecase/BookTicketUseCaseUnitTest.java @@ -0,0 +1,119 @@ +package com.baeldung.hexagonal.usecase; + +import com.baeldung.hexagonal.domain.Booking; +import com.baeldung.hexagonal.port.BookingPersistencePort; +import com.baeldung.hexagonal.port.BookingServicePort; +import com.baeldung.hexagonal.port.TheatreServicePort; +import com.baeldung.hexagonal.port.WalletServicePort; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class BookTicketUseCaseUnitTest { + + private BookingPersistencePort bookingPersistencePort; + private TheatreServicePort theatreServicePort; + private WalletServicePort walletServicePort; + private BookTicketUseCase bookTicketUseCase; + + @BeforeEach + void setUp() { + bookingPersistencePort = mock(BookingPersistencePort.class); + theatreServicePort = mock(TheatreServicePort.class); + walletServicePort = mock(WalletServicePort.class); + bookTicketUseCase = new BookTicketUseCase(bookingPersistencePort, theatreServicePort, walletServicePort); + } + + private BookingServicePort.BookingRequest getBookingRequest() { + BookingServicePort.BookingRequest request = new BookingServicePort.BookingRequest(); + request.setTheatreId("theatre-id"); + request.setMovieShowId("movie-show-id"); + request.setCustomerId("customer-id"); + request.setSeats(new HashSet<>(Arrays.asList("A1", "A2"))); + request.setAmount(100.00); + return request; + } + + private Booking getBooking(BookingServicePort.BookingRequest request) { + return new Booking( + "booking-id", + request.getMovieShowId(), + request.getTheatreId(), + request.getCustomerId(), + request.getSeats(), + request.getAmount(), + Booking.Status.INITIAL); + } + + @Test + void whenErrorInInitialPersistence_thenReturnUnknownError() { + BookingServicePort.BookingRequest request = getBookingRequest(); + Booking booking = getBooking(request); + when(bookingPersistencePort.persist(any(Booking.class))).thenReturn(false); + BookingServicePort.BookingResponse response = bookTicketUseCase.book(request); + + verify(bookingPersistencePort, times(1)).persist(any(Booking.class)); + assertNotNull(response); + assertEquals(BookingServicePort.BookingResponse.UNKNOWN_ERROR, response.getStatusCode()); + } + + @Test + void whenErrorInReserveSeats_thenReturnSeatNotAvailable() { + BookingServicePort.BookingRequest request = getBookingRequest(); + Booking booking = getBooking(request); + when(bookingPersistencePort.persist(any(Booking.class))).thenReturn(true); + when(theatreServicePort.reserveSeats(booking.getTheatreId(), booking.getMovieShowId(), booking.getSeats())) + .thenReturn(Optional.empty()); + BookingServicePort.BookingResponse response = bookTicketUseCase.book(request); + + verify(bookingPersistencePort, times(2)).persist(any(Booking.class)); + verify(theatreServicePort).reserveSeats(any(String.class), any(String.class), any(HashSet.class)); + assertNotNull(response); + assertEquals(BookingServicePort.BookingResponse.SEAT_NOT_AVAILABLE, response.getStatusCode()); + } + + @Test + void whenErrorInWalletDebit_thenReturnPaymentFailed() { + BookingServicePort.BookingRequest request = getBookingRequest(); + Booking booking = getBooking(request); + when(bookingPersistencePort.persist(any(Booking.class))).thenReturn(true); + when(theatreServicePort.reserveSeats(booking.getTheatreId(), booking.getMovieShowId(), booking.getSeats())) + .thenReturn(Optional.of("reservation-id")); + when(walletServicePort.debit(booking.getCustomerId(), booking.getAmount())) + .thenReturn(false); + BookingServicePort.BookingResponse response = bookTicketUseCase.book(request); + + verify(bookingPersistencePort, times(2)).persist(any(Booking.class)); + verify(theatreServicePort).reserveSeats(any(String.class), any(String.class), any(HashSet.class)); + verify(walletServicePort).debit(any(String.class), any(Double.class)); + verify(theatreServicePort).releaseSeats(any(String.class)); + assertNotNull(response); + assertEquals(BookingServicePort.BookingResponse.PAYMENT_FAILED, response.getStatusCode()); + } + + @Test + void whenNoErrorInAnyPorts_thenReturnSuccess() { + BookingServicePort.BookingRequest request = getBookingRequest(); + Booking booking = getBooking(request); + when(bookingPersistencePort.persist(any(Booking.class))).thenReturn(true); + when(theatreServicePort.reserveSeats(booking.getTheatreId(), booking.getMovieShowId(), booking.getSeats())) + .thenReturn(Optional.of("reservation-id")); + when(walletServicePort.debit(booking.getCustomerId(), booking.getAmount())) + .thenReturn(true); + BookingServicePort.BookingResponse response = bookTicketUseCase.book(request); + + verify(bookingPersistencePort, times(2)).persist(any(Booking.class)); + verify(theatreServicePort).reserveSeats(any(String.class), any(String.class), any(HashSet.class)); + verify(walletServicePort).debit(any(String.class), any(Double.class)); + assertNotNull(response); + assertEquals(BookingServicePort.BookingResponse.SUCCESS, response.getStatusCode()); + } +} \ No newline at end of file