diff --git a/README.md b/README.md index d4b06b3..0454f90 100644 --- a/README.md +++ b/README.md @@ -47,10 +47,11 @@ Running the tests: ```shell cd docker compose up database -d -gradle flywayMigrate -gradle clean build -docs at -> http://localhost:8080/swagger-ui/index.html +./gradlew flywayMigrate +./gradlew clean build ``` +docs at -> http://localhost:8080/swagger-ui/index.html + Work has been started but not done yet to containerise the kotlin service. The service runs in the host right now. Feel free to fix that if it makes your life easier diff --git a/src/main/kotlin/io/billie/orders/data/OrderAlreadyExists.kt b/src/main/kotlin/io/billie/orders/data/OrderAlreadyExists.kt new file mode 100644 index 0000000..f7a1518 --- /dev/null +++ b/src/main/kotlin/io/billie/orders/data/OrderAlreadyExists.kt @@ -0,0 +1,3 @@ +package io.billie.orders.data + +class OrderAlreadyExists(val externalId: String) : RuntimeException() diff --git a/src/main/kotlin/io/billie/orders/data/OrderRepository.kt b/src/main/kotlin/io/billie/orders/data/OrderRepository.kt new file mode 100644 index 0000000..caa7789 --- /dev/null +++ b/src/main/kotlin/io/billie/orders/data/OrderRepository.kt @@ -0,0 +1,68 @@ +package io.billie.orders.data + +import io.billie.orders.model.OrderRequest +import io.billie.orders.model.OrderState +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.jdbc.core.JdbcTemplate +import org.springframework.jdbc.core.ResultSetExtractor +import org.springframework.jdbc.support.GeneratedKeyHolder +import org.springframework.jdbc.support.KeyHolder +import org.springframework.stereotype.Repository +import org.springframework.transaction.annotation.Transactional +import java.sql.Timestamp +import java.util.* + + +@Repository +class OrderRepository { + + @Autowired + lateinit var jdbcTemplate: JdbcTemplate + + @Transactional + fun create(order: OrderRequest): UUID { + if (orderExists(order)) { + throw OrderAlreadyExists(order.externalId) + } + return createOrder(order) + } + + private fun createOrder(order: OrderRequest): UUID { + val keyHolder: KeyHolder = GeneratedKeyHolder() + jdbcTemplate.update( + { connection -> + val ps = connection.prepareStatement( + "INSERT INTO organisations_schema.orders (" + + "external_id, " + + "created_time, " + + "organisation_id, " + + "state " + + ") VALUES (?, ?, ?, ?)", + arrayOf("id") + ) + ps.setString(1, order.externalId) + ps.setTimestamp(2, Timestamp.valueOf(order.createdTime)) + ps.setObject(3, order.organisationId) + ps.setString(4, OrderState.NEW.name) + ps + }, keyHolder + ) + return keyHolder.getKeyAs(UUID::class.java)!! + } + + private fun orderExists(order: OrderRequest): Boolean { + val reply: Int? = jdbcTemplate.query( + "select count(o.id) from organisations_schema.orders o " + + "WHERE o.external_id = ? " + + "AND o.organisation_id = ? ", + ResultSetExtractor { + it.next() + it.getInt(1) + }, + order.externalId, + order.organisationId + ) + return (reply != null) && (reply > 0) + } + +} diff --git a/src/main/kotlin/io/billie/orders/model/OrderRequest.kt b/src/main/kotlin/io/billie/orders/model/OrderRequest.kt new file mode 100644 index 0000000..bb9aac8 --- /dev/null +++ b/src/main/kotlin/io/billie/orders/model/OrderRequest.kt @@ -0,0 +1,18 @@ +package io.billie.orders.model + +import com.fasterxml.jackson.annotation.JsonFormat +import com.fasterxml.jackson.annotation.JsonProperty +import org.springframework.data.relational.core.mapping.Table +import java.time.LocalDateTime +import java.util.* +import javax.validation.constraints.NotBlank +import javax.validation.constraints.NotNull + + +data class OrderRequest( + @field:NotNull @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") @JsonProperty("created_time") val createdTime: LocalDateTime, + @field:NotNull @JsonProperty("organisation_id") val organisationId: UUID, + @field:NotBlank @JsonProperty("external_id") val externalId: String, + @JsonProperty("state") val state: String, //TODO proper enum validator + // TODO data fields needed for invoicing +) diff --git a/src/main/kotlin/io/billie/orders/model/OrderState.kt b/src/main/kotlin/io/billie/orders/model/OrderState.kt new file mode 100644 index 0000000..be6a436 --- /dev/null +++ b/src/main/kotlin/io/billie/orders/model/OrderState.kt @@ -0,0 +1,10 @@ +package io.billie.orders.model + +enum class OrderState { + NEW, //announced + SHIPPED, //shipped by merchant + MERCHANT_PAID, + INVOICED, //the buyer is invoiced + COMPLETED, //the buyer paid the invoice, final state + CANCELED //the order was cancelled (for example, was never shipped) +} diff --git a/src/main/kotlin/io/billie/orders/resource/OrderResource.kt b/src/main/kotlin/io/billie/orders/resource/OrderResource.kt new file mode 100644 index 0000000..6fb540f --- /dev/null +++ b/src/main/kotlin/io/billie/orders/resource/OrderResource.kt @@ -0,0 +1,68 @@ +package io.billie.orders.resource + +import io.billie.orders.data.OrderAlreadyExists +import io.billie.orders.model.OrderRequest +import io.billie.orders.service.OrderService +import io.billie.shared.model.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.BAD_REQUEST +import org.springframework.http.HttpStatus.CONFLICT +import org.springframework.web.bind.annotation.* +import org.springframework.web.server.ResponseStatusException +import java.util.* +import javax.validation.Valid + + +@RestController +@RequestMapping("orders") +class OrderResource(val service: OrderService) { + + @PostMapping + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "Accepted the new order", + content = [ + (Content( + mediaType = "application/json", + array = (ArraySchema(schema = Schema(implementation = Entity::class))) + ))] + ), + ApiResponse(responseCode = "409", description = "Conflict", content = [Content()])] + ) + fun post(@Valid @RequestBody order: OrderRequest): Entity { + try { + val id = service.createOrder(order) + return Entity(id) + } catch (e: OrderAlreadyExists) { + throw ResponseStatusException(CONFLICT, e.message) + } + } + + @PatchMapping + @ApiResponses( + value = [ + ApiResponse( + responseCode = "200", + description = "The order was updated", + content = [ + (Content( + mediaType = "application/json", + array = (ArraySchema(schema = Schema(implementation = Entity::class))) + ))] + ), + ApiResponse(responseCode = "400", description = "Bad request", content = [Content()])] + ) + fun patch(@Valid @RequestBody order: OrderRequest) { // TODO add return type + try { + // TODO use for notifying the order was shipped by updating the state + } catch (e: OrderAlreadyExists) { + throw ResponseStatusException(BAD_REQUEST, e.message) + } + } +} diff --git a/src/main/kotlin/io/billie/orders/service/OrderService.kt b/src/main/kotlin/io/billie/orders/service/OrderService.kt new file mode 100644 index 0000000..af31db6 --- /dev/null +++ b/src/main/kotlin/io/billie/orders/service/OrderService.kt @@ -0,0 +1,22 @@ +package io.billie.orders.service + +import io.billie.orders.data.OrderRepository +import io.billie.orders.model.OrderRequest +import io.billie.organisations.data.OrganisationRepository +import io.billie.organisations.data.UnableToFindCountry +import org.springframework.stereotype.Service +import org.springframework.transaction.annotation.Transactional +import java.util.* + +@Service +class OrderService(val orderRepository: OrderRepository) { + @Transactional + fun createOrder(order: OrderRequest): UUID { + //TODO validateOrder: + // createdTime is in the past; organisation exists + + return orderRepository.create(order); + } + + +} diff --git a/src/main/kotlin/io/billie/organisations/resource/OrganisationResource.kt b/src/main/kotlin/io/billie/organisations/resource/OrganisationResource.kt index b108a1f..0418664 100644 --- a/src/main/kotlin/io/billie/organisations/resource/OrganisationResource.kt +++ b/src/main/kotlin/io/billie/organisations/resource/OrganisationResource.kt @@ -3,12 +3,12 @@ package io.billie.organisations.resource import io.billie.organisations.data.UnableToFindCountry import io.billie.organisations.service.OrganisationService import io.billie.organisations.viewmodel.* +import io.billie.shared.model.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.http.HttpStatus.BAD_REQUEST import org.springframework.web.bind.annotation.* import org.springframework.web.server.ResponseStatusException diff --git a/src/main/kotlin/io/billie/organisations/viewmodel/Entity.kt b/src/main/kotlin/io/billie/shared/model/Entity.kt similarity index 55% rename from src/main/kotlin/io/billie/organisations/viewmodel/Entity.kt rename to src/main/kotlin/io/billie/shared/model/Entity.kt index d35b67b..846877f 100644 --- a/src/main/kotlin/io/billie/organisations/viewmodel/Entity.kt +++ b/src/main/kotlin/io/billie/shared/model/Entity.kt @@ -1,4 +1,4 @@ -package io.billie.organisations.viewmodel +package io.billie.shared.model import java.util.* 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..c20cd79 --- /dev/null +++ b/src/main/resources/db/migration/V9__add_orders_table.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS organisations_schema.orders +( + id UUID DEFAULT uuid_generate_v4() PRIMARY KEY, + external_id VARCHAR NOT NULL, + created_time TIMESTAMP NOT NULL, + organisation_id UUID NOT NULL, + state VARCHAR(30) NOT NULL +); diff --git a/src/test/kotlin/io/billie/functional/CanCreateOrdersTest.kt b/src/test/kotlin/io/billie/functional/CanCreateOrdersTest.kt new file mode 100644 index 0000000..47b6f70 --- /dev/null +++ b/src/test/kotlin/io/billie/functional/CanCreateOrdersTest.kt @@ -0,0 +1,85 @@ +package io.billie.functional + +import io.billie.functional.data.Fixtures.orderRequestCreateOrder +import io.billie.functional.data.Fixtures.orderRequestCreateOrderWithoutCreatedTime +import io.billie.functional.data.Fixtures.orderRequestCreateOrderWithoutOrganisationId +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.boot.test.context.SpringBootTest.WebEnvironment.DEFINED_PORT +import org.springframework.boot.test.web.server.LocalServerPort +import org.springframework.http.MediaType.APPLICATION_JSON +import org.springframework.test.web.servlet.MockMvc +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post +import org.springframework.test.web.servlet.result.MockMvcResultMatchers.status +import java.util.* + + +@AutoConfigureMockMvc +@SpringBootTest(webEnvironment = DEFINED_PORT) +class CanCreateOrdersTest { + + @LocalServerPort + private val port = 8080 + + @Autowired + private lateinit var mockMvc: MockMvc + + private val ORGANISATION_ID = "a5bd8429-7d48-4219-8cb0-5941c8f8821e" + private val VALID_TIME = "2023-06-19T09:34:57" + + @Test + fun createOrder() { + + val content = orderRequestCreateOrder(UUID.randomUUID().toString(), ORGANISATION_ID, VALID_TIME) + mockMvc.perform( + post("/orders").contentType(APPLICATION_JSON).content(content) + ) + .andExpect(status().isOk) + } + + @Test + fun cannotCreateOrderWhenExternalIdIsBlank() { + val content = orderRequestCreateOrder("", ORGANISATION_ID, VALID_TIME) + mockMvc.perform( + post("/orders").contentType(APPLICATION_JSON).content(content) + ) + .andExpect(status().isBadRequest) + } + + @Test + fun cannotCreateOrderWithSameExternalIdAndOrganisation() { + val content = orderRequestCreateOrder(UUID.randomUUID().toString(), ORGANISATION_ID, VALID_TIME) + mockMvc.perform( + post("/orders").contentType(APPLICATION_JSON).content(content) + ) + .andExpect(status().isOk) + + mockMvc.perform( + post("/orders").contentType(APPLICATION_JSON).content(content) + ) + .andExpect(status().isConflict) + } + + @Test + fun cannotCreateOrderWithoutCreatedTime() { + val content = orderRequestCreateOrderWithoutCreatedTime(UUID.randomUUID().toString(), ORGANISATION_ID) + mockMvc.perform( + post("/orders").contentType(APPLICATION_JSON).content(content) + ) + .andExpect(status().isBadRequest) + } + + + @Test + fun cannotCreateOrderWithoutOrganisationId() { + val content = orderRequestCreateOrderWithoutOrganisationId(UUID.randomUUID().toString(), VALID_TIME) + mockMvc.perform( + post("/orders").contentType(APPLICATION_JSON).content(content) + ) + .andExpect(status().isBadRequest) + } + + +} diff --git a/src/test/kotlin/io/billie/functional/CanStoreAndReadOrganisationTest.kt b/src/test/kotlin/io/billie/functional/CanStoreAndReadOrganisationTest.kt index 2d57630..c34a32e 100644 --- a/src/test/kotlin/io/billie/functional/CanStoreAndReadOrganisationTest.kt +++ b/src/test/kotlin/io/billie/functional/CanStoreAndReadOrganisationTest.kt @@ -11,7 +11,7 @@ import io.billie.functional.data.Fixtures.orgRequestJsonNameBlank import io.billie.functional.data.Fixtures.orgRequestJsonNoContactDetails import io.billie.functional.data.Fixtures.orgRequestJsonNoCountryCode import io.billie.functional.data.Fixtures.orgRequestJsonNoLegalEntityType -import io.billie.organisations.viewmodel.Entity +import io.billie.shared.model.Entity import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.core.IsEqual.equalTo import org.junit.jupiter.api.Test diff --git a/src/test/kotlin/io/billie/functional/data/Fixtures.kt b/src/test/kotlin/io/billie/functional/data/Fixtures.kt index 9954801..03df9bd 100644 --- a/src/test/kotlin/io/billie/functional/data/Fixtures.kt +++ b/src/test/kotlin/io/billie/functional/data/Fixtures.kt @@ -126,6 +126,31 @@ object Fixtures { "}" } + fun orderRequestCreateOrder(externalId: String, organisationId: String, createdTime: String): String { + return "{\n" + + " \"created_time\": \"" + createdTime + "\",\n" + + " \"organisation_id\": \"" + organisationId +"\",\n" + + " \"external_id\": \"" + externalId +"\"\n" + + "}" + + } + + fun orderRequestCreateOrderWithoutOrganisationId(externalId: String, createdTime: String): String { + return "{\n" + + " \"created_time\": \"" + createdTime + "\",\n" + + " \"external_id\": \"" + externalId +"\"\n" + + "}" + + } + + fun orderRequestCreateOrderWithoutCreatedTime(externalId: String, organisationId: String): String { + return "{\n" + + " \"organisation_id\": \"" + organisationId +"\",\n" + + " \"external_id\": \"" + externalId +"\"\n" + + "}" + + } + fun bbcFixture(id: UUID): Map { val data = HashMap() data["id"] = id