From 618dc0e60a7594c929c4a955ca64c5d36e3336b9 Mon Sep 17 00:00:00 2001 From: Nikhil Jasani Date: Sun, 21 Jan 2024 13:34:50 +0000 Subject: [PATCH 1/4] adding orders and shipment db tables --- .../resources/db/migration/V10__add_shipments_table.sql | 6 ++++++ src/main/resources/db/migration/V9__add_orders_table.sql | 6 ++++++ 2 files changed, 12 insertions(+) create mode 100644 src/main/resources/db/migration/V10__add_shipments_table.sql create mode 100644 src/main/resources/db/migration/V9__add_orders_table.sql diff --git a/src/main/resources/db/migration/V10__add_shipments_table.sql b/src/main/resources/db/migration/V10__add_shipments_table.sql new file mode 100644 index 0000000..20f12c4 --- /dev/null +++ b/src/main/resources/db/migration/V10__add_shipments_table.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS organisations_schema.shipments +( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + order_id UUID REFERENCES organisations_schema.orders, + amount MONEY NOT NULL +); diff --git a/src/main/resources/db/migration/V9__add_orders_table.sql b/src/main/resources/db/migration/V9__add_orders_table.sql new file mode 100644 index 0000000..638f548 --- /dev/null +++ b/src/main/resources/db/migration/V9__add_orders_table.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS organisations_schema.orders +( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + organisation_id UUID REFERENCES organisations_schema.organisations, + amount MONEY NOT NULL +); From af69edff6847e7781c05ecfb89ee384f047930ea Mon Sep 17 00:00:00 2001 From: Nikhil Jasani Date: Sun, 21 Jan 2024 14:39:43 +0000 Subject: [PATCH 2/4] adding order processing --- .../orders/data/OrderNotFoundException.kt | 8 + .../orders/data/OrderRepository.kt | 107 +++++++++++ .../data/OrganisationNotFoundException.kt | 8 + .../organisations/orders/domain/Order.kt | 15 ++ .../orders/domain/OrderAmount.kt | 12 ++ .../organisations/orders/domain/OrderId.kt | 8 + .../orders/resource/OrderResource.kt | 116 ++++++++++++ .../orders/service/OrderService.kt | 32 ++++ .../organisations/orders/view/OrderDto.kt | 15 ++ .../organisations/orders/view/OrderRequest.kt | 10 + .../functional/CanStoreAndReadOrderTest.kt | 171 ++++++++++++++++++ .../billie/functional/data/OrderFixtures.kt | 21 +++ 12 files changed, 523 insertions(+) create mode 100644 src/main/kotlin/io/billie/organisations/orders/data/OrderNotFoundException.kt create mode 100644 src/main/kotlin/io/billie/organisations/orders/data/OrderRepository.kt create mode 100644 src/main/kotlin/io/billie/organisations/orders/data/OrganisationNotFoundException.kt create mode 100644 src/main/kotlin/io/billie/organisations/orders/domain/Order.kt create mode 100644 src/main/kotlin/io/billie/organisations/orders/domain/OrderAmount.kt create mode 100644 src/main/kotlin/io/billie/organisations/orders/domain/OrderId.kt create mode 100644 src/main/kotlin/io/billie/organisations/orders/resource/OrderResource.kt create mode 100644 src/main/kotlin/io/billie/organisations/orders/service/OrderService.kt create mode 100644 src/main/kotlin/io/billie/organisations/orders/view/OrderDto.kt create mode 100644 src/main/kotlin/io/billie/organisations/orders/view/OrderRequest.kt create mode 100644 src/test/kotlin/io/billie/functional/CanStoreAndReadOrderTest.kt create mode 100644 src/test/kotlin/io/billie/functional/data/OrderFixtures.kt diff --git a/src/main/kotlin/io/billie/organisations/orders/data/OrderNotFoundException.kt b/src/main/kotlin/io/billie/organisations/orders/data/OrderNotFoundException.kt new file mode 100644 index 0000000..8844299 --- /dev/null +++ b/src/main/kotlin/io/billie/organisations/orders/data/OrderNotFoundException.kt @@ -0,0 +1,8 @@ +package io.billie.organisations.orders.data + +import io.billie.organisations.orders.domain.OrderId + +/** + * Error condition representing non-existence of order + */ +class OrderNotFoundException(orderId: OrderId) : RuntimeException("No order found with id " + orderId.id) \ No newline at end of file diff --git a/src/main/kotlin/io/billie/organisations/orders/data/OrderRepository.kt b/src/main/kotlin/io/billie/organisations/orders/data/OrderRepository.kt new file mode 100644 index 0000000..94832fb --- /dev/null +++ b/src/main/kotlin/io/billie/organisations/orders/data/OrderRepository.kt @@ -0,0 +1,107 @@ +package io.billie.organisations.orders.data + +import io.billie.organisations.orders.domain.Order +import io.billie.organisations.orders.domain.OrderAmount +import io.billie.organisations.orders.domain.OrderId +import io.billie.organisations.viewmodel.Entity +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.jdbc.core.ResultSetExtractor +import org.springframework.jdbc.core.RowMapper +import org.springframework.jdbc.support.GeneratedKeyHolder +import org.springframework.jdbc.support.KeyHolder +import org.springframework.stereotype.Repository +import java.sql.ResultSet +import java.util.* + +/** + * Offering service to query or create [Order] + */ +@Repository +class OrderRepository { + + @Autowired + lateinit var jdbcTemplate: JdbcTemplate + + fun findById(orderId: OrderId): Order? { + val orders = jdbcTemplate.query(orderQueryById(orderId), orderMapper()) + return if (orders.isEmpty()) null else orders[0] + } + + fun findByOrganisation(organisationId: Entity): List { + if(!validateOrganisation(organisationId)) { + throw OrganisationNotFoundException(organisationId) + } + return jdbcTemplate.query(orderQuery(organisationId), orderMapper()) + } + + fun create(order: Order): UUID { + if(!validateOrganisation(order.organisationId)) { + throw OrganisationNotFoundException(order.organisationId) + } + return createOrder(order) + } + + private fun validateOrganisation(organisationId: Entity): Boolean { + val reply: Int? = jdbcTemplate.query( + { connection -> + val ps = connection.prepareStatement( + "select count(1) from organisations_schema.organisations " + + "WHERE id = ? " + ) + ps.setObject(1, organisationId.id) + ps + }, + ResultSetExtractor { + it.next() + it.getInt(1) + } + ) + return (reply != null) && (reply > 0) + } + + private fun createOrder(order: Order): UUID { + val keyHolder: KeyHolder = GeneratedKeyHolder() + jdbcTemplate.update( + { connection -> + val ps = connection.prepareStatement( + "INSERT INTO organisations_schema.orders (" + + "organisation_id, " + + "amount" + + ") VALUES (?, ?)", + arrayOf("id") + ) + ps.setObject(1, order.organisationId.id) + ps.setBigDecimal(2, order.orderAmount.amount) + ps + }, keyHolder + ) + return keyHolder.getKeyAs(UUID::class.java)!! + } + + private fun orderQuery(organisationId: Entity) = "SELECT " + + "orders.id as id, " + + "orders.organisation_id as organisation_id, " + + "orders.amount as amount " + + "FROM " + + "organisations_schema.orders orders " + + "LEFT JOIN organisations_schema.shipments shipments ON orders.id = shipments.order_id " + + "WHERE orders.organisation_id = '" + organisationId.id.toString() + "'" + + private fun orderQueryById(orderId: OrderId) = "SELECT " + + "orders.id as id, " + + "orders.organisation_id as organisation_id, " + + "orders.amount as amount " + + "FROM " + + "organisations_schema.orders orders " + + "LEFT JOIN organisations_schema.shipments shipments ON orders.id = shipments.order_id " + + "where orders.id = '" + orderId.id.toString() + "'" + + private fun orderMapper() = RowMapper { it: ResultSet, _: Int -> + Order( + OrderId(it.getObject("id", UUID::class.java)), + Entity(it.getObject("organisation_id", UUID::class.java)), + OrderAmount(it.getBigDecimal("amount")) + ) + } +} diff --git a/src/main/kotlin/io/billie/organisations/orders/data/OrganisationNotFoundException.kt b/src/main/kotlin/io/billie/organisations/orders/data/OrganisationNotFoundException.kt new file mode 100644 index 0000000..31a4e84 --- /dev/null +++ b/src/main/kotlin/io/billie/organisations/orders/data/OrganisationNotFoundException.kt @@ -0,0 +1,8 @@ +package io.billie.organisations.orders.data + +import io.billie.organisations.viewmodel.Entity + +/** + * Error condition representing non-existence of organisation + */ +class OrganisationNotFoundException(organisationId: Entity) : RuntimeException("No organisation found with id " + organisationId.id) diff --git a/src/main/kotlin/io/billie/organisations/orders/domain/Order.kt b/src/main/kotlin/io/billie/organisations/orders/domain/Order.kt new file mode 100644 index 0000000..d3e5441 --- /dev/null +++ b/src/main/kotlin/io/billie/organisations/orders/domain/Order.kt @@ -0,0 +1,15 @@ +package io.billie.organisations.orders.domain + +import io.billie.organisations.viewmodel.Entity +import org.springframework.data.relational.core.mapping.Table + +/** + * Domain object represents order for organisation + */ +@Table("ORDERS") +data class Order ( + + val orderId: OrderId?, + val organisationId: Entity, + val orderAmount: OrderAmount +) \ No newline at end of file diff --git a/src/main/kotlin/io/billie/organisations/orders/domain/OrderAmount.kt b/src/main/kotlin/io/billie/organisations/orders/domain/OrderAmount.kt new file mode 100644 index 0000000..4cda619 --- /dev/null +++ b/src/main/kotlin/io/billie/organisations/orders/domain/OrderAmount.kt @@ -0,0 +1,12 @@ +package io.billie.organisations.orders.domain + +import java.math.BigDecimal + +/** + * Domain object represents order amount, should always be positive + */ +data class OrderAmount(val amount: BigDecimal) { + init { + require(amount.compareTo(BigDecimal.ZERO) > 0) { "Order amount must be positive" } + } +} diff --git a/src/main/kotlin/io/billie/organisations/orders/domain/OrderId.kt b/src/main/kotlin/io/billie/organisations/orders/domain/OrderId.kt new file mode 100644 index 0000000..bcf8148 --- /dev/null +++ b/src/main/kotlin/io/billie/organisations/orders/domain/OrderId.kt @@ -0,0 +1,8 @@ +package io.billie.organisations.orders.domain + +import java.util.* + +/** + * Domain object represents UUID based order id + */ +data class OrderId(val id: UUID) diff --git a/src/main/kotlin/io/billie/organisations/orders/resource/OrderResource.kt b/src/main/kotlin/io/billie/organisations/orders/resource/OrderResource.kt new file mode 100644 index 0000000..035797a --- /dev/null +++ b/src/main/kotlin/io/billie/organisations/orders/resource/OrderResource.kt @@ -0,0 +1,116 @@ +package io.billie.organisations.orders.resource + +import io.billie.organisations.orders.data.OrderNotFoundException +import io.billie.organisations.orders.data.OrganisationNotFoundException +import io.billie.organisations.orders.domain.Order +import io.billie.organisations.orders.domain.OrderAmount +import io.billie.organisations.orders.domain.OrderId +import io.billie.organisations.orders.service.OrderService +import io.billie.organisations.orders.view.OrderDto +import io.billie.organisations.orders.view.OrderRequest +import io.billie.organisations.viewmodel.Entity +import io.swagger.v3.oas.annotations.media.ArraySchema +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 org.springframework.http.HttpStatus +import org.springframework.web.bind.annotation.* +import org.springframework.web.server.ResponseStatusException +import java.util.* +import java.util.stream.Collectors +import javax.validation.Valid + +/** + * Rest end-points to find orders by organisation or create order for an organisation + */ +@RestController +@RequestMapping("v1") +class OrderResource(val orderService: OrderService) { + + @GetMapping("/orders/{orderId}") + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "Returns order matching the id", + content = [ + (Content( + mediaType = "application/json", + schema = Schema(implementation = OrderDto::class) + ))] + ), + ApiResponse(responseCode = "400", description = "Bad request", content = [Content()])] + ) + fun getById(@Valid @PathVariable orderId: UUID): OrderDto { + try { + val order = orderService.findById(OrderId(orderId)) + return mapToDto(order) + } catch (e: OrderNotFoundException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } + + @GetMapping("/organisations/{organisationId}/orders") + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "Returns list of orders of an organisation", + content = [ + (Content( + mediaType = "application/json", + array = (ArraySchema(schema = Schema(implementation = OrderDto::class))) + ))] + ), + ApiResponse(responseCode = "400", description = "Bad request", content = [Content()])] + ) + fun getByOrganisayion(@Valid @PathVariable organisationId: UUID): List { + try { + val orders = orderService.findByOrganisations(Entity(organisationId)) + return mapToDtos(orders) + } catch (e: OrganisationNotFoundException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } + + @PostMapping("/organisations/{organisationId}/orders") + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "Accepted the new order", + content = [ + (Content( + mediaType = "application/json", + array = (ArraySchema(schema = Schema(implementation = Entity::class))) + ))] + ), + ApiResponse(responseCode = "400", description = "Bad request", content = [Content()])] + ) + fun post(@Valid @PathVariable organisationId: UUID, + @Valid @RequestBody orderRequest: OrderRequest): OrderId { + try { + val id = orderService.createOrder(mapToDomain(organisationId, orderRequest)) + return OrderId(id) + } catch (e: OrganisationNotFoundException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } catch (e: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } + + private fun mapToDtos(orders: List): List { + return orders.stream() + .map(this::mapToDto) + .collect(Collectors.toList()) + } + + private fun mapToDto(order: Order): OrderDto { + return OrderDto(order.orderId!!.id, order.organisationId.id, order.orderAmount.amount) + } + + private fun mapToDomain(organisationId: UUID, orderRequest: OrderRequest): Order { + return Order(null, Entity(organisationId), OrderAmount(orderRequest.amount)) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/organisations/orders/service/OrderService.kt b/src/main/kotlin/io/billie/organisations/orders/service/OrderService.kt new file mode 100644 index 0000000..f01e776 --- /dev/null +++ b/src/main/kotlin/io/billie/organisations/orders/service/OrderService.kt @@ -0,0 +1,32 @@ +package io.billie.organisations.orders.service + +import io.billie.organisations.orders.data.OrderNotFoundException +import io.billie.organisations.orders.data.OrderRepository +import io.billie.organisations.orders.domain.Order +import io.billie.organisations.orders.domain.OrderId +import io.billie.organisations.viewmodel.Entity +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.* + +/** + * Offers services to fetch or create order per organisation + */ +@Service +class OrderService(val repository: OrderRepository) { + + @Transactional(readOnly = true) + fun findById(orderId: OrderId): Order { + return repository.findById(orderId)?: throw OrderNotFoundException(orderId) + } + + @Transactional(readOnly = true) + fun findByOrganisations(organisationId: Entity): List { + return repository.findByOrganisation(organisationId) + } + + @Transactional + fun createOrder(order: Order): UUID { + return repository.create(order) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/organisations/orders/view/OrderDto.kt b/src/main/kotlin/io/billie/organisations/orders/view/OrderDto.kt new file mode 100644 index 0000000..002bdcd --- /dev/null +++ b/src/main/kotlin/io/billie/organisations/orders/view/OrderDto.kt @@ -0,0 +1,15 @@ +package io.billie.organisations.orders.view + +import com.fasterxml.jackson.annotation.JsonProperty +import io.billie.organisations.orders.domain.Order +import java.math.BigDecimal +import java.util.UUID + +/** + * Data transfer object mapped to [Order] domain object and exposed to external service + */ +data class OrderDto ( + @JsonProperty("order_id") val id: UUID, + @JsonProperty("organisation_id") val organisationId: UUID, + @JsonProperty("amount") val amount: BigDecimal +) \ No newline at end of file diff --git a/src/main/kotlin/io/billie/organisations/orders/view/OrderRequest.kt b/src/main/kotlin/io/billie/organisations/orders/view/OrderRequest.kt new file mode 100644 index 0000000..2b42781 --- /dev/null +++ b/src/main/kotlin/io/billie/organisations/orders/view/OrderRequest.kt @@ -0,0 +1,10 @@ +package io.billie.organisations.orders.view + +import com.fasterxml.jackson.annotation.JsonProperty +import io.billie.organisations.orders.domain.Order +import java.math.BigDecimal + +/** + * Request object mapped to [Order] domain object + */ +data class OrderRequest(@JsonProperty("amount") val amount: BigDecimal) \ No newline at end of file diff --git a/src/test/kotlin/io/billie/functional/CanStoreAndReadOrderTest.kt b/src/test/kotlin/io/billie/functional/CanStoreAndReadOrderTest.kt new file mode 100644 index 0000000..f92391b --- /dev/null +++ b/src/test/kotlin/io/billie/functional/CanStoreAndReadOrderTest.kt @@ -0,0 +1,171 @@ +package io.billie.functional + +import com.fasterxml.jackson.databind.ObjectMapper +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import io.billie.functional.data.Fixtures +import io.billie.functional.data.OrderFixtures +import io.billie.organisations.orders.domain.OrderId +import io.billie.organisations.orders.view.OrderDto +import io.billie.organisations.viewmodel.Entity +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.result.MockMvcResultMatchers +import java.util.* + + +@AutoConfigureMockMvc +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) +class CanStoreAndReadOrderTest { + + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var mapper: ObjectMapper + + @Test + fun getOrdersSuccessfullyWorksDespiteNoOrderExist() { + // given an organisation exists + val organisationId = createOrganisation() + + // expect successful response when orders searched by organisation + mockMvc.perform( + MockMvcRequestBuilders.get("/v1/organisations/${organisationId.id}/orders") + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(MockMvcResultMatchers.status().isOk()) + } + + @Test + fun getOrdersByNonExistingOrganisationFails() { + // expect bad request error response when orders searched by organisation + mockMvc.perform( + MockMvcRequestBuilders.get("/v1/organisations/${UUID.randomUUID()}/orders") + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(MockMvcResultMatchers.status().isBadRequest()) + } + + @Test + fun orderCreationFailsForNonExistingOrganisation() { + // expect bad request error response when orders searched by organisation + mockMvc.perform( + MockMvcRequestBuilders.post("/v1/organisations/${UUID.randomUUID()}/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(OrderFixtures.orderRequestJson()) + ) + .andExpect(MockMvcResultMatchers.status().isBadRequest) + } + + @Test + fun orderCreationFailsForMissingAmount() { + // given an organisation exists + val organisationId = createOrganisation() + + // expect bad request error response when amount is missing + mockMvc.perform( + MockMvcRequestBuilders.post("/v1/organisations/${organisationId.id}/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(OrderFixtures.orderRequestJsonMissingAmount()) + ) + .andExpect(MockMvcResultMatchers.status().isBadRequest) + } + + @Test + fun orderCreationFailsForInvalidAmount() { + // given an organisation exists + val organisationId = createOrganisation() + + // expect bad request error response when amount is invalid + mockMvc.perform( + MockMvcRequestBuilders.post("/v1/organisations/${organisationId.id}/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(OrderFixtures.orderRequestJsonInvalidAmount()) + ) + .andExpect(MockMvcResultMatchers.status().isBadRequest) + } + + @Test + fun canCreateAndFetchOrderWithoutShipment() { + // given an organisation exists + val organisationId = createOrganisation() + + // when order is created + val orderId = createOrder(organisationId) + + // then order id is generated + Assertions.assertNotNull(orderId) + + // when order is found by order id + val order = getOrderById(orderId) + + // then order saved with expected values + Assertions.assertNotNull(order) + Assertions.assertEquals(order.organisationId, organisationId.id) + Assertions.assertEquals(20.0, order.amount.toDouble()) + } + + @Test + fun canCreateAndFetchMultipleOrdersWithoutShipment() { + // given an organisation exists + val organisationId = createOrganisation() + + // and multiple orders are created + createOrder(organisationId) + createOrder(organisationId) + createOrder(organisationId) + + // when order is found by organisation id + val ordersResponse = mockMvc.perform( + MockMvcRequestBuilders.get("/v1/organisations/${organisationId.id}/orders") + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andReturn() + + val orders: List = jacksonObjectMapper().readValue(ordersResponse.response.contentAsString) + + // then order saved with expected values + Assertions.assertEquals(3, orders.size) + } + + private fun createOrganisation(): Entity { + val orgResponse = mockMvc.perform( + MockMvcRequestBuilders.post("/organisations").contentType(MediaType.APPLICATION_JSON).content(Fixtures.orgRequestJson()) + ) + .andExpect(MockMvcResultMatchers.status().isOk) + .andReturn() + + return mapper.readValue(orgResponse.response.contentAsString, Entity::class.java) + } + + private fun createOrder(organisationId: Entity): OrderId { + val orderResponse = mockMvc.perform( + MockMvcRequestBuilders.post("/v1/organisations/${organisationId.id}/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(OrderFixtures.orderRequestJson()) + ) + .andExpect(MockMvcResultMatchers.status().isOk) + .andReturn() + + return mapper.readValue(orderResponse.response.contentAsString, OrderId::class.java) + } + + private fun getOrderById(orderId: OrderId): OrderDto { + val orderResponse = mockMvc.perform( + MockMvcRequestBuilders.get("/v1/orders/${orderId.id}") + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(MockMvcResultMatchers.status().isOk()) + .andReturn() + + return mapper.readValue(orderResponse.response.contentAsString, OrderDto::class.java) + } +} \ No newline at end of file diff --git a/src/test/kotlin/io/billie/functional/data/OrderFixtures.kt b/src/test/kotlin/io/billie/functional/data/OrderFixtures.kt new file mode 100644 index 0000000..80e81e0 --- /dev/null +++ b/src/test/kotlin/io/billie/functional/data/OrderFixtures.kt @@ -0,0 +1,21 @@ +package io.billie.functional.data + +object OrderFixtures { + + fun orderRequestJson(): String { + return "{\n" + + " \"amount\": 20.0\n" + + "}" + } + + fun orderRequestJsonMissingAmount(): String { + return "{\n" + + "}" + } + + fun orderRequestJsonInvalidAmount(): String { + return "{\n" + + " \"amount\": -1\n" + + "}" + } +} \ No newline at end of file From 4f912edcc8cc1252f40b6fe087d27f1231a27a11 Mon Sep 17 00:00:00 2001 From: Nikhil Jasani Date: Sun, 21 Jan 2024 19:17:27 +0000 Subject: [PATCH 3/4] adding shipment processing --- .../orders/data/OrderRepository.kt | 93 ++++++++--- ...ShipmentAmountExceedOrderTotalException.kt | 11 ++ .../data/ShipmentNotificationFailure.kt | 11 ++ .../organisations/orders/domain/Order.kt | 21 ++- .../organisations/orders/domain/Shipment.kt | 13 ++ .../orders/domain/ShipmentAmount.kt | 12 ++ .../organisations/orders/domain/ShipmentId.kt | 8 + .../orders/resource/OrderResource.kt | 79 ++++++--- .../orders/service/OrderService.kt | 14 +- .../orders/view/ShipmentRequest.kt | 10 ++ .../billie/functional/CanStoreShipmentTest.kt | 153 ++++++++++++++++++ .../functional/data/ShipmentFixtures.kt | 23 +++ 12 files changed, 400 insertions(+), 48 deletions(-) create mode 100644 src/main/kotlin/io/billie/organisations/orders/data/ShipmentAmountExceedOrderTotalException.kt create mode 100644 src/main/kotlin/io/billie/organisations/orders/data/ShipmentNotificationFailure.kt create mode 100644 src/main/kotlin/io/billie/organisations/orders/domain/Shipment.kt create mode 100644 src/main/kotlin/io/billie/organisations/orders/domain/ShipmentAmount.kt create mode 100644 src/main/kotlin/io/billie/organisations/orders/domain/ShipmentId.kt create mode 100644 src/main/kotlin/io/billie/organisations/orders/view/ShipmentRequest.kt create mode 100644 src/test/kotlin/io/billie/functional/CanStoreShipmentTest.kt create mode 100644 src/test/kotlin/io/billie/functional/data/ShipmentFixtures.kt diff --git a/src/main/kotlin/io/billie/organisations/orders/data/OrderRepository.kt b/src/main/kotlin/io/billie/organisations/orders/data/OrderRepository.kt index 94832fb..625d8ee 100644 --- a/src/main/kotlin/io/billie/organisations/orders/data/OrderRepository.kt +++ b/src/main/kotlin/io/billie/organisations/orders/data/OrderRepository.kt @@ -1,13 +1,10 @@ package io.billie.organisations.orders.data -import io.billie.organisations.orders.domain.Order -import io.billie.organisations.orders.domain.OrderAmount -import io.billie.organisations.orders.domain.OrderId +import io.billie.organisations.orders.domain.* import io.billie.organisations.viewmodel.Entity import org.springframework.beans.factory.annotation.Autowired import org.springframework.jdbc.core.JdbcTemplate import org.springframework.jdbc.core.ResultSetExtractor -import org.springframework.jdbc.core.RowMapper import org.springframework.jdbc.support.GeneratedKeyHolder import org.springframework.jdbc.support.KeyHolder import org.springframework.stereotype.Repository @@ -24,22 +21,35 @@ class OrderRepository { lateinit var jdbcTemplate: JdbcTemplate fun findById(orderId: OrderId): Order? { - val orders = jdbcTemplate.query(orderQueryById(orderId), orderMapper()) - return if (orders.isEmpty()) null else orders[0] + val orders = jdbcTemplate.query(orderQueryById(orderId), OrderExtractor()) + return if (orders!!.isEmpty()) null else orders[0] } fun findByOrganisation(organisationId: Entity): List { if(!validateOrganisation(organisationId)) { throw OrganisationNotFoundException(organisationId) } - return jdbcTemplate.query(orderQuery(organisationId), orderMapper()) + return jdbcTemplate.query(orderQuery(organisationId), OrderExtractor())?: listOf() } - fun create(order: Order): UUID { + fun create(order: Order): OrderId { if(!validateOrganisation(order.organisationId)) { throw OrganisationNotFoundException(order.organisationId) } - return createOrder(order) + val orderId = OrderId(createOrder(order)) + order.shipments.stream().forEach { shipment -> addShipment(orderId, shipment)} + return orderId + } + + fun update(order: Order): ShipmentId? { + val shipment = order.shipments.stream() + .filter { s -> s.shipmentId == null } + .findFirst() + return if (shipment.isPresent) ShipmentId(addShipment(order.orderId!!, shipment.get())) else null + } + + private fun addShipment(orderId: OrderId, shipment: Shipment): UUID { + return createShipment(orderId, shipment) } private fun validateOrganisation(organisationId: Entity): Boolean { @@ -79,29 +89,74 @@ class OrderRepository { return keyHolder.getKeyAs(UUID::class.java)!! } + private fun createShipment(orderId: OrderId, shipment: Shipment): UUID { + val keyHolder: KeyHolder = GeneratedKeyHolder() + jdbcTemplate.update( + { connection -> + val ps = connection.prepareStatement( + "INSERT INTO organisations_schema.shipments (" + + "order_id, " + + "amount" + + ") VALUES (?, ?)", + arrayOf("id") + ) + ps.setObject(1, orderId.id) + ps.setBigDecimal(2, shipment.shipmentAmount.amount) + ps + }, keyHolder + ) + return keyHolder.getKeyAs(UUID::class.java)!! + } + private fun orderQuery(organisationId: Entity) = "SELECT " + - "orders.id as id, " + + "orders.id as order_id, " + "orders.organisation_id as organisation_id, " + - "orders.amount as amount " + + "orders.amount as order_amount, " + + "shipments.id as shipment_id, " + + "shipments.amount as shipment_amount " + "FROM " + "organisations_schema.orders orders " + "LEFT JOIN organisations_schema.shipments shipments ON orders.id = shipments.order_id " + "WHERE orders.organisation_id = '" + organisationId.id.toString() + "'" private fun orderQueryById(orderId: OrderId) = "SELECT " + - "orders.id as id, " + + "orders.id as order_id, " + "orders.organisation_id as organisation_id, " + - "orders.amount as amount " + + "orders.amount as order_amount, " + + "shipments.id as shipment_id, " + + "shipments.amount as shipment_amount " + "FROM " + "organisations_schema.orders orders " + "LEFT JOIN organisations_schema.shipments shipments ON orders.id = shipments.order_id " + "where orders.id = '" + orderId.id.toString() + "'" - private fun orderMapper() = RowMapper { it: ResultSet, _: Int -> - Order( - OrderId(it.getObject("id", UUID::class.java)), - Entity(it.getObject("organisation_id", UUID::class.java)), - OrderAmount(it.getBigDecimal("amount")) - ) + class OrderExtractor : ResultSetExtractor> { + override fun extractData(rs: ResultSet): List? { + + val orderMap = HashMap() + + while (rs.next()) { + var orderId = OrderId(rs.getObject("order_id", UUID::class.java)) + var order = orderMap.get(orderId) + if (order == null) { + order = Order( + orderId, + Entity(rs.getObject("organisation_id", UUID::class.java)), + OrderAmount(rs.getBigDecimal("order_amount")), + mutableSetOf() + ) + orderMap.put(orderId, order) + } + var shipmentId = rs.getObject("shipment_id", UUID::class.java) + if (shipmentId != null) { + order.addShipment(Shipment( + ShipmentId(shipmentId), + orderId, + ShipmentAmount(rs.getBigDecimal("shipment_amount")) + )) + } + } + return orderMap.values.toList() + } } } diff --git a/src/main/kotlin/io/billie/organisations/orders/data/ShipmentAmountExceedOrderTotalException.kt b/src/main/kotlin/io/billie/organisations/orders/data/ShipmentAmountExceedOrderTotalException.kt new file mode 100644 index 0000000..77873f2 --- /dev/null +++ b/src/main/kotlin/io/billie/organisations/orders/data/ShipmentAmountExceedOrderTotalException.kt @@ -0,0 +1,11 @@ +package io.billie.organisations.orders.data + +import io.billie.organisations.orders.domain.OrderId +import io.billie.organisations.orders.domain.ShipmentAmount + + +/** + * Error condition representing total shipment amount exceeding order total amount + */ +class ShipmentAmountExceedOrderTotalException(shipmentAmount: ShipmentAmount, orderId: OrderId) + : RuntimeException("Shipments total ${shipmentAmount.amount} exceed order amount for order id ${orderId.id}") \ No newline at end of file diff --git a/src/main/kotlin/io/billie/organisations/orders/data/ShipmentNotificationFailure.kt b/src/main/kotlin/io/billie/organisations/orders/data/ShipmentNotificationFailure.kt new file mode 100644 index 0000000..f894a94 --- /dev/null +++ b/src/main/kotlin/io/billie/organisations/orders/data/ShipmentNotificationFailure.kt @@ -0,0 +1,11 @@ +package io.billie.organisations.orders.data + +import io.billie.organisations.orders.domain.OrderId +import io.billie.organisations.orders.domain.ShipmentAmount + + +/** + * Error condition representing shipment notification failure + */ +class ShipmentNotificationFailure(shipmentAmount: ShipmentAmount, orderId: OrderId) + : RuntimeException("Shipment notification for amount ${shipmentAmount.amount} failed for order id ${orderId.id}") \ No newline at end of file diff --git a/src/main/kotlin/io/billie/organisations/orders/domain/Order.kt b/src/main/kotlin/io/billie/organisations/orders/domain/Order.kt index d3e5441..81efad7 100644 --- a/src/main/kotlin/io/billie/organisations/orders/domain/Order.kt +++ b/src/main/kotlin/io/billie/organisations/orders/domain/Order.kt @@ -1,15 +1,30 @@ package io.billie.organisations.orders.domain +import io.billie.organisations.orders.data.ShipmentAmountExceedOrderTotalException import io.billie.organisations.viewmodel.Entity import org.springframework.data.relational.core.mapping.Table +import java.math.BigDecimal /** * Domain object represents order for organisation */ @Table("ORDERS") data class Order ( - val orderId: OrderId?, val organisationId: Entity, - val orderAmount: OrderAmount -) \ No newline at end of file + val orderAmount: OrderAmount, + val shipments: MutableSet ) { + + fun addShipment(shipment: Shipment) { + var existingShipmentTotal = BigDecimal.ZERO + for (existingShipment in shipments){ + existingShipmentTotal = existingShipmentTotal.add(existingShipment.shipmentAmount.amount) + } + + val shipmentTotal = existingShipmentTotal.add(shipment.shipmentAmount.amount) + if (shipmentTotal.compareTo(orderAmount.amount) > 0) + throw ShipmentAmountExceedOrderTotalException(ShipmentAmount(shipmentTotal), orderId!!) + + shipments.add(shipment) + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/organisations/orders/domain/Shipment.kt b/src/main/kotlin/io/billie/organisations/orders/domain/Shipment.kt new file mode 100644 index 0000000..515e4c8 --- /dev/null +++ b/src/main/kotlin/io/billie/organisations/orders/domain/Shipment.kt @@ -0,0 +1,13 @@ +package io.billie.organisations.orders.domain + +import org.springframework.data.relational.core.mapping.Table + +/** + * Domain object represents shipments for an [Order] + */ +@Table("SHIPMENTS") +data class Shipment( + val shipmentId: ShipmentId?, + val orderId: OrderId, + val shipmentAmount: ShipmentAmount +) diff --git a/src/main/kotlin/io/billie/organisations/orders/domain/ShipmentAmount.kt b/src/main/kotlin/io/billie/organisations/orders/domain/ShipmentAmount.kt new file mode 100644 index 0000000..ea00474 --- /dev/null +++ b/src/main/kotlin/io/billie/organisations/orders/domain/ShipmentAmount.kt @@ -0,0 +1,12 @@ +package io.billie.organisations.orders.domain + +import java.math.BigDecimal + +/** + * Domain object represents shipment amount, should always be positive + */ +data class ShipmentAmount(val amount: BigDecimal) { + init { + require(amount.compareTo(BigDecimal.ZERO) > 0) { "Shipment amount must be positive" } + } +} \ No newline at end of file diff --git a/src/main/kotlin/io/billie/organisations/orders/domain/ShipmentId.kt b/src/main/kotlin/io/billie/organisations/orders/domain/ShipmentId.kt new file mode 100644 index 0000000..dd620b4 --- /dev/null +++ b/src/main/kotlin/io/billie/organisations/orders/domain/ShipmentId.kt @@ -0,0 +1,8 @@ +package io.billie.organisations.orders.domain + +import java.util.* + +/** + * Domain object represents UUID based shipment id + */ +data class ShipmentId(val id: UUID) diff --git a/src/main/kotlin/io/billie/organisations/orders/resource/OrderResource.kt b/src/main/kotlin/io/billie/organisations/orders/resource/OrderResource.kt index 035797a..66ac7e3 100644 --- a/src/main/kotlin/io/billie/organisations/orders/resource/OrderResource.kt +++ b/src/main/kotlin/io/billie/organisations/orders/resource/OrderResource.kt @@ -2,12 +2,12 @@ package io.billie.organisations.orders.resource import io.billie.organisations.orders.data.OrderNotFoundException import io.billie.organisations.orders.data.OrganisationNotFoundException -import io.billie.organisations.orders.domain.Order -import io.billie.organisations.orders.domain.OrderAmount -import io.billie.organisations.orders.domain.OrderId +import io.billie.organisations.orders.data.ShipmentAmountExceedOrderTotalException +import io.billie.organisations.orders.domain.* import io.billie.organisations.orders.service.OrderService import io.billie.organisations.orders.view.OrderDto import io.billie.organisations.orders.view.OrderRequest +import io.billie.organisations.orders.view.ShipmentRequest import io.billie.organisations.viewmodel.Entity import io.swagger.v3.oas.annotations.media.ArraySchema import io.swagger.v3.oas.annotations.media.Content @@ -28,58 +28,84 @@ import javax.validation.Valid @RequestMapping("v1") class OrderResource(val orderService: OrderService) { - @GetMapping("/orders/{orderId}") + @GetMapping("/organisations/{organisationId}/orders") @ApiResponses( value = [ ApiResponse( responseCode = "200", - description = "Returns order matching the id", + description = "Returns list of orders of an organisation", content = [ (Content( mediaType = "application/json", - schema = Schema(implementation = OrderDto::class) + array = (ArraySchema(schema = Schema(implementation = OrderDto::class))) ))] ), ApiResponse(responseCode = "400", description = "Bad request", content = [Content()])] ) - fun getById(@Valid @PathVariable orderId: UUID): OrderDto { + fun getByOrganisayion(@Valid @PathVariable organisationId: UUID): List { try { - val order = orderService.findById(OrderId(orderId)) - return mapToDto(order) - } catch (e: OrderNotFoundException) { + val orders = orderService.findByOrganisations(Entity(organisationId)) + return mapToDtos(orders) + } catch (e: OrganisationNotFoundException) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) } } - @GetMapping("/organisations/{organisationId}/orders") + @PostMapping("/organisations/{organisationId}/orders") @ApiResponses( value = [ ApiResponse( responseCode = "200", - description = "Returns list of orders of an organisation", + description = "Accepted the new order", content = [ (Content( mediaType = "application/json", - array = (ArraySchema(schema = Schema(implementation = OrderDto::class))) + array = (ArraySchema(schema = Schema(implementation = Entity::class))) ))] ), ApiResponse(responseCode = "400", description = "Bad request", content = [Content()])] ) - fun getByOrganisayion(@Valid @PathVariable organisationId: UUID): List { + fun post(@Valid @PathVariable organisationId: UUID, + @Valid @RequestBody orderRequest: OrderRequest): OrderId { try { - val orders = orderService.findByOrganisations(Entity(organisationId)) - return mapToDtos(orders) + val orderId = orderService.createOrder(mapToDomain(organisationId, orderRequest)) + return orderId } catch (e: OrganisationNotFoundException) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } catch (e: IllegalArgumentException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) } } - @PostMapping("/organisations/{organisationId}/orders") + @GetMapping("/orders/{orderId}") @ApiResponses( value = [ ApiResponse( responseCode = "200", - description = "Accepted the new order", + description = "Returns order matching the id", + content = [ + (Content( + mediaType = "application/json", + schema = Schema(implementation = OrderDto::class) + ))] + ), + ApiResponse(responseCode = "400", description = "Bad request", content = [Content()])] + ) + fun getById(@Valid @PathVariable orderId: UUID): OrderDto { + try { + val order = orderService.findById(OrderId(orderId)) + return mapToDto(order) + } catch (e: OrderNotFoundException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } + } + + @PostMapping("/orders/{orderId}/shipments") + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "Registered shipment dispatch for an order", content = [ (Content( mediaType = "application/json", @@ -88,15 +114,16 @@ class OrderResource(val orderService: OrderService) { ), ApiResponse(responseCode = "400", description = "Bad request", content = [Content()])] ) - fun post(@Valid @PathVariable organisationId: UUID, - @Valid @RequestBody orderRequest: OrderRequest): OrderId { + fun post(@Valid @PathVariable orderId: UUID, + @Valid @RequestBody shipmentRequest: ShipmentRequest): ShipmentId { try { - val id = orderService.createOrder(mapToDomain(organisationId, orderRequest)) - return OrderId(id) - } catch (e: OrganisationNotFoundException) { + return orderService.notifyShipment(mapToDomain(orderId, shipmentRequest)) + } catch (e: OrderNotFoundException) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) } catch (e: IllegalArgumentException) { throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) + } catch (e: ShipmentAmountExceedOrderTotalException) { + throw ResponseStatusException(HttpStatus.BAD_REQUEST, e.message) } } @@ -111,6 +138,10 @@ class OrderResource(val orderService: OrderService) { } private fun mapToDomain(organisationId: UUID, orderRequest: OrderRequest): Order { - return Order(null, Entity(organisationId), OrderAmount(orderRequest.amount)) + return Order(null, Entity(organisationId), OrderAmount(orderRequest.amount), mutableSetOf()) + } + + private fun mapToDomain(orderId: UUID, shipmentRequest: ShipmentRequest): Shipment { + return Shipment(null, OrderId(orderId), ShipmentAmount(shipmentRequest.amount)) } } \ No newline at end of file diff --git a/src/main/kotlin/io/billie/organisations/orders/service/OrderService.kt b/src/main/kotlin/io/billie/organisations/orders/service/OrderService.kt index f01e776..ace058e 100644 --- a/src/main/kotlin/io/billie/organisations/orders/service/OrderService.kt +++ b/src/main/kotlin/io/billie/organisations/orders/service/OrderService.kt @@ -2,12 +2,14 @@ package io.billie.organisations.orders.service import io.billie.organisations.orders.data.OrderNotFoundException import io.billie.organisations.orders.data.OrderRepository +import io.billie.organisations.orders.data.ShipmentNotificationFailure import io.billie.organisations.orders.domain.Order import io.billie.organisations.orders.domain.OrderId +import io.billie.organisations.orders.domain.Shipment +import io.billie.organisations.orders.domain.ShipmentId import io.billie.organisations.viewmodel.Entity import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional -import java.util.* /** * Offers services to fetch or create order per organisation @@ -26,7 +28,15 @@ class OrderService(val repository: OrderRepository) { } @Transactional - fun createOrder(order: Order): UUID { + fun createOrder(order: Order): OrderId { return repository.create(order) } + + @Transactional + fun notifyShipment(shipment: Shipment): ShipmentId { + val order = findById(shipment.orderId) + order.addShipment(shipment) + return repository.update(order) + ?: throw ShipmentNotificationFailure(shipment.shipmentAmount, shipment.orderId) + } } \ No newline at end of file diff --git a/src/main/kotlin/io/billie/organisations/orders/view/ShipmentRequest.kt b/src/main/kotlin/io/billie/organisations/orders/view/ShipmentRequest.kt new file mode 100644 index 0000000..0ce0d97 --- /dev/null +++ b/src/main/kotlin/io/billie/organisations/orders/view/ShipmentRequest.kt @@ -0,0 +1,10 @@ +package io.billie.organisations.orders.view + +import com.fasterxml.jackson.annotation.JsonProperty +import io.billie.organisations.orders.domain.Shipment +import java.math.BigDecimal + +/** + * Request object mapped to [Shipment] domain object + */ +data class ShipmentRequest(@JsonProperty("amount") val amount: BigDecimal) \ No newline at end of file diff --git a/src/test/kotlin/io/billie/functional/CanStoreShipmentTest.kt b/src/test/kotlin/io/billie/functional/CanStoreShipmentTest.kt new file mode 100644 index 0000000..401add1 --- /dev/null +++ b/src/test/kotlin/io/billie/functional/CanStoreShipmentTest.kt @@ -0,0 +1,153 @@ +package io.billie.functional + +import com.fasterxml.jackson.databind.ObjectMapper +import io.billie.functional.data.Fixtures +import io.billie.functional.data.OrderFixtures +import io.billie.functional.data.ShipmentFixtures +import io.billie.organisations.orders.domain.OrderId +import io.billie.organisations.orders.domain.ShipmentAmount +import io.billie.organisations.orders.domain.ShipmentId +import io.billie.organisations.viewmodel.Entity +import org.hamcrest.MatcherAssert +import org.hamcrest.Matchers.notNullValue +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.http.MediaType +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders +import org.springframework.test.web.servlet.result.MockMvcResultMatchers +import java.math.BigDecimal +import java.util.* + +@AutoConfigureMockMvc +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.DEFINED_PORT) +class CanStoreShipmentTest { + + @Autowired + private lateinit var mockMvc: MockMvc + + @Autowired + private lateinit var mapper: ObjectMapper + + @Autowired + private lateinit var template: JdbcTemplate + + @Test + fun shipmentNotificationFailsForNonExistingOrder() { + // expect bad request error response when orders searched by organisation + mockMvc.perform( + MockMvcRequestBuilders.post("/v1/orders/${UUID.randomUUID()}/shipments") + .contentType(MediaType.APPLICATION_JSON) + .content(OrderFixtures.orderRequestJson()) + ) + .andExpect(MockMvcResultMatchers.status().isBadRequest) + } + + @Test + fun shipmentNotificationFailsForMissingAmount() { + // given an organisation exists + val organisationId = createOrganisation() + // and order exists + val orderId = createOrder(organisationId) + + // expect bad request error response when amount is missing + mockMvc.perform( + MockMvcRequestBuilders.post("/v1/orders/${orderId.id}/shipments") + .contentType(MediaType.APPLICATION_JSON) + .content(ShipmentFixtures.shipmentRequestJsonMissingAmount()) + ) + .andExpect(MockMvcResultMatchers.status().isBadRequest) + } + + @Test + fun shipmentNotificationFailsForInvalidAmount() { + // given an organisation exists + val organisationId = createOrganisation() + // and order exists + val orderId = createOrder(organisationId) + + // expect bad request error response when amount is missing + mockMvc.perform( + MockMvcRequestBuilders.post("/v1/orders/${orderId.id}/shipments") + .contentType(MediaType.APPLICATION_JSON) + .content(ShipmentFixtures.shipmentRequestJsonInvalidAmount()) + ) + .andExpect(MockMvcResultMatchers.status().isBadRequest) + } + + @Test + fun canNotifyShipment() { + // given an organisation exists + val organisationId = createOrganisation() + // and order exists + val orderId = createOrder(organisationId) + + // when shipment is notified for an order + val shipmentId = notifyShipment(orderId, ShipmentAmount(BigDecimal.TEN)) + + // then shipment saved + MatcherAssert.assertThat(shipmentId, notNullValue()) + } + + @Test + fun canNotNotifyShipmentIfAmountExceedsOrderAmount() { + // given an organisation exists + val organisationId = createOrganisation() + // and order exists + val orderId = createOrder(organisationId) + + // when 1st shipment is notified successfully - total amount 10 + notifyShipment(orderId, ShipmentAmount(BigDecimal.TEN)) + + // when 2nd shipment is notified successfully - total amount 20 + notifyShipment(orderId, ShipmentAmount(BigDecimal.TEN)) + + // expect bad request error response when amount exceeds order amount 20 + mockMvc.perform( + MockMvcRequestBuilders.post("/v1/orders/${orderId.id}/shipments") + .contentType(MediaType.APPLICATION_JSON) + .content(ShipmentFixtures.shipmentRequestJson(ShipmentAmount(BigDecimal.TEN))) + ) + .andExpect(MockMvcResultMatchers.status().isBadRequest) + } + + private fun createOrganisation(): Entity { + val orgResponse = mockMvc.perform( + MockMvcRequestBuilders.post("/organisations").contentType(MediaType.APPLICATION_JSON).content(Fixtures.orgRequestJson()) + ) + .andExpect(MockMvcResultMatchers.status().isOk) + .andReturn() + + return mapper.readValue(orgResponse.response.contentAsString, Entity::class.java) + } + + private fun createOrder(organisationId: Entity): OrderId { + val orderResponse = mockMvc.perform( + MockMvcRequestBuilders.post("/v1/organisations/${organisationId.id}/orders") + .contentType(MediaType.APPLICATION_JSON) + .content(OrderFixtures.orderRequestJson()) + ) + .andExpect(MockMvcResultMatchers.status().isOk) + .andReturn() + + return mapper.readValue(orderResponse.response.contentAsString, OrderId::class.java) + } + + private fun notifyShipment(orderId: OrderId, shipmentAmount: ShipmentAmount) : ShipmentId { + val shipmentResponse = mockMvc.perform( + MockMvcRequestBuilders.post("/v1/orders/${orderId.id}/shipments") + .contentType(MediaType.APPLICATION_JSON) + .content(ShipmentFixtures.shipmentRequestJson(shipmentAmount)) + ) + .andExpect(MockMvcResultMatchers.status().isOk) + .andReturn() + + return mapper.readValue(shipmentResponse.response.contentAsString, ShipmentId::class.java) + } + + private fun queryEntityFromDatabase(sql: String, shipmentId: ShipmentId): MutableMap = + template.queryForMap(sql, shipmentId.id) +} \ No newline at end of file diff --git a/src/test/kotlin/io/billie/functional/data/ShipmentFixtures.kt b/src/test/kotlin/io/billie/functional/data/ShipmentFixtures.kt new file mode 100644 index 0000000..7f17fb1 --- /dev/null +++ b/src/test/kotlin/io/billie/functional/data/ShipmentFixtures.kt @@ -0,0 +1,23 @@ +package io.billie.functional.data + +import io.billie.organisations.orders.domain.ShipmentAmount + +object ShipmentFixtures { + + fun shipmentRequestJson(shipmentAmount: ShipmentAmount): String { + return "{\n" + + " \"amount\": ${shipmentAmount.amount}\n" + + "}" + } + + fun shipmentRequestJsonMissingAmount(): String { + return "{\n" + + "}" + } + + fun shipmentRequestJsonInvalidAmount(): String { + return "{\n" + + " \"amount\": -1\n" + + "}" + } +} \ No newline at end of file From d8da5c1096992017f8d5585dc93b28fda4b066c9 Mon Sep 17 00:00:00 2001 From: Nikhil Jasani Date: Sun, 21 Jan 2024 19:22:39 +0000 Subject: [PATCH 4/4] documentation --- EXERCISE_DOC.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 EXERCISE_DOC.md diff --git a/EXERCISE_DOC.md b/EXERCISE_DOC.md new file mode 100644 index 0000000..2019230 --- /dev/null +++ b/EXERCISE_DOC.md @@ -0,0 +1,24 @@ +Thought Process on Programming Exercise +============= +### Assumptions + +I understood how Billie deals with e-commerce business and high level requirements from [here](README.md). However, I made few assumptions +while implementing the exercise instead of waiting for answers raised on weekend. +1. Requirement talks about _Merchant_ but the given code has _Organisation_ which somewhat similar but not completely same. **I assumed _Organisation_ as _Merchant_ domain for this exercise.** +2. _Order_ doesn't exist without _Organisation_ and _Shipment_ doesn't exist without _Order_. +3. The API requests are already validated from security point of view and authentication is implemented. +4. Designed data model is very basic and kept limited to the demo. No complex modeling, validation and business cases such as currency exchange etc. considered. + +### What is done +1. With availability of very basic domain information, I created two domains _Order_ and _Shipment_ including respective DB tables. +2. Domains perform basic validations such as shipment notification validates existence of order and total amount within range of order amount. +3. Added integration tests which tests possible use-cases including request validations. +4. Separate domain and view models(Dto). + +### Exercise vs Production scope +From resilience, scalability and performance point of view, I think the design has below basic amendments meet production level. +1. Multiple domains are deployed in single service, dividing them in separate microservices would be useful for scalability purpose. +2. Domains would have their own database which helps maintainability and limiting the scope to domain requirements. +4. When services communicate via REST end-points, it is possible to use contract testing to ensure neither server or client breaks the contact before moving to production. +5. Post end-points to be Idempotent to avoid multiple try from client doesn't result in new resource creation. +6. Optimize API timeouts, required retry strategy and circuit breaker to make services resilient.