diff --git a/build.gradle b/build.gradle index 64e130a..57ddf3e 100644 --- a/build.gradle +++ b/build.gradle @@ -35,6 +35,7 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testImplementation 'org.wiremock:wiremock-standalone:3.9.1' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } diff --git a/src/main/java/com/getourguide/interview/GetYourGuideApplication.java b/src/main/java/com/getourguide/interview/GetYourGuideApplication.java index c80cf8c..6131042 100644 --- a/src/main/java/com/getourguide/interview/GetYourGuideApplication.java +++ b/src/main/java/com/getourguide/interview/GetYourGuideApplication.java @@ -2,8 +2,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication +@EnableScheduling public class GetYourGuideApplication { public static void main(String[] args) { diff --git a/src/main/java/com/getourguide/interview/entity/Order.java b/src/main/java/com/getourguide/interview/entity/Order.java new file mode 100644 index 0000000..a8847f0 --- /dev/null +++ b/src/main/java/com/getourguide/interview/entity/Order.java @@ -0,0 +1,40 @@ +package com.getourguide.interview.entity; + +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 lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.ToString; +import org.hibernate.annotations.NotFound; +import org.hibernate.annotations.NotFoundAction; + +@Getter +@Setter +@ToString +@AllArgsConstructor +@Entity +@Table(schema = "getyourguide", name = "orders") +@NoArgsConstructor +@EqualsAndHashCode +public class Order { + @Id + @GeneratedValue(strategy = GenerationType.AUTO) + private Long id; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "activity_id") + @NotFound(action = NotFoundAction.IGNORE) + @ToString.Exclude + private Activity activity; + private String bookingReference; + private double price; + private String status; +} diff --git a/src/main/java/com/getourguide/interview/repository/OrderRepository.java b/src/main/java/com/getourguide/interview/repository/OrderRepository.java new file mode 100644 index 0000000..27a239a --- /dev/null +++ b/src/main/java/com/getourguide/interview/repository/OrderRepository.java @@ -0,0 +1,9 @@ +package com.getourguide.interview.repository; + +import com.getourguide.interview.entity.Order; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface OrderRepository extends JpaRepository { +} diff --git a/src/main/java/com/getourguide/interview/service/CheckoutService.java b/src/main/java/com/getourguide/interview/service/CheckoutService.java new file mode 100644 index 0000000..6901859 --- /dev/null +++ b/src/main/java/com/getourguide/interview/service/CheckoutService.java @@ -0,0 +1,61 @@ +package com.getourguide.interview.service; + +import com.getourguide.interview.entity.Order; +import com.getourguide.interview.repository.OrderRepository; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import lombok.AllArgsConstructor; +import lombok.SneakyThrows; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@AllArgsConstructor +public class CheckoutService { + + private final OrderRepository orderRepository; + + @Transactional + public void checkout(Long orderId) { + Order order = orderRepository.findById(orderId).orElseThrow(); + order.setStatus("RESERVED"); + orderRepository.save(order); + + chargeCustomer(order); + sendConfirmationEmail(order); + } + + @SneakyThrows + void chargeCustomer(Order order) { + + String body = """ + {"orderId": %d, "amount": %s} + """.formatted(order.getId(), order.getPrice()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8081/payments/charge")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + + HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + } + + @SneakyThrows + void sendConfirmationEmail(Order order) { + + String body = """ + {"orderId": %d, "bookingReference": "%s"} + """.formatted(order.getId(), order.getBookingReference()); + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create("http://localhost:8081/emails/confirmation")) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + + HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + } +} diff --git a/src/main/resources/db/migration/V1.0.3__create_orders_table.sql b/src/main/resources/db/migration/V1.0.3__create_orders_table.sql new file mode 100644 index 0000000..4713026 --- /dev/null +++ b/src/main/resources/db/migration/V1.0.3__create_orders_table.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS getyourguide.orders ( + id LONG PRIMARY KEY, + activity_id LONG, + booking_reference VARCHAR(64), + price FLOAT, + status VARCHAR(32) +); + +INSERT INTO getyourguide.orders (id, activity_id, booking_reference, price, status) VALUES +(1, 25651, 'BR-0001', 14, 'PENDING'), +(2, 6960, 'BR-0002', 21, 'PENDING'), +(3, 26823, 'BR-0003', 41, 'PENDING'); diff --git a/src/test/java/com/getourguide/interview/service/CheckoutServiceIntegrationTest.java b/src/test/java/com/getourguide/interview/service/CheckoutServiceIntegrationTest.java new file mode 100644 index 0000000..b63dbf8 --- /dev/null +++ b/src/test/java/com/getourguide/interview/service/CheckoutServiceIntegrationTest.java @@ -0,0 +1,47 @@ +package com.getourguide.interview.service; + +import com.getourguide.interview.entity.Order; +import com.getourguide.interview.repository.OrderRepository; +import com.github.tomakehurst.wiremock.WireMockServer; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.post; +import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@SpringBootTest +class CheckoutServiceIntegrationTest { + + private static WireMockServer wireMock; + + @Autowired private CheckoutService checkoutService; + @Autowired private OrderRepository orderRepository; + + @BeforeAll + static void startWireMock() { + wireMock = new WireMockServer(wireMockConfig().port(8081)); + wireMock.start(); + wireMock.stubFor(post(urlEqualTo("/payments/charge")).willReturn(aResponse().withStatus(200))); + wireMock.stubFor(post(urlEqualTo("/emails/confirmation")).willReturn(aResponse().withStatus(200))); + } + + @AfterAll + static void stopWireMock() { + wireMock.stop(); + } + + @Test + void checkoutReservesOrder() { + Order order = orderRepository.findAll().getFirst(); + + checkoutService.checkout(order.getId()); + + assertEquals("RESERVED", orderRepository.findById(order.getId()).orElseThrow().getStatus()); + } +}