diff --git a/.editorconfig b/.editorconfig index 4a4de86..171f3ec 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,7 +8,7 @@ end_of_line = lf insert_final_newline = true [*.kt] -max_line_length = 100 +max_line_length = 120 [*.{kt,kts}] import_order = camelcase, spaces, semicolons diff --git a/build.gradle.kts b/build.gradle.kts index a83a60a..1b47003 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -36,12 +36,17 @@ val integrationTest: SourceSet = configurations[integrationTest.implementationConfigurationName].extendsFrom(configurations.testImplementation.get()) configurations[integrationTest.runtimeOnlyConfigurationName].extendsFrom(configurations.testRuntimeOnly.get()) +dependencyManagement { + imports { + mavenBom("org.springframework.cloud:spring-cloud-dependencies:2022.0.4") // Dodanie BOM dla Spring Cloud + } +} + dependencies { implementation("io.github.microutils:kotlin-logging-jvm:3.0.5") implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.15.2") implementation("org.jetbrains.exposed:exposed-core:0.55.0") implementation("org.jetbrains.exposed:exposed-java-time:0.55.0") -// implementation("org.jetbrains.exposed:exposed-kotlin-datetime:0.55.0") implementation("org.jetbrains.exposed:exposed-jdbc:0.55.0") implementation("javax.servlet:javax.servlet-api:4.0.1") @@ -50,6 +55,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-jdbc") + implementation("io.github.openfeign:feign-jackson:12.4") implementation("org.springframework.cloud:spring-cloud-starter-openfeign:3.1.2") // Spring Boot Test @@ -113,3 +119,8 @@ ktlint { exclude("**/integrationTest/**") } } + +tasks.wrapper { + gradleVersion = "8.10.2" + distributionType = Wrapper.DistributionType.ALL +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 3fa8f86..79eb9d0 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.4-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/BaseIntegrationTest.kt b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/BaseIntegrationTest.kt index 8571d43..6fa5222 100644 --- a/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/BaseIntegrationTest.kt +++ b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/BaseIntegrationTest.kt @@ -3,12 +3,11 @@ package camilyed.github.io.currencyexchangeapi.testing import camilyed.github.io.CurrencyExchangeApiApplication import camilyed.github.io.currencyexchangeapi.testing.abilties.MakeRequestAbility import camilyed.github.io.currencyexchangeapi.testing.postgres.PostgresInitializer +import camilyed.github.io.currencyexchangeapi.testing.utils.DatabaseCleaner import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.github.tomakehurst.wiremock.WireMockServer -import com.github.tomakehurst.wiremock.core.WireMockConfiguration -import com.github.tomakehurst.wiremock.core.WireMockConfiguration.options -import org.junit.jupiter.api.AfterAll +import com.github.tomakehurst.wiremock.client.WireMock import org.junit.jupiter.api.BeforeAll import org.junit.jupiter.api.BeforeEach import org.springframework.beans.factory.annotation.Autowired @@ -16,6 +15,8 @@ import org.springframework.boot.test.context.SpringBootTest import org.springframework.boot.test.web.client.TestRestTemplate import org.springframework.test.context.ActiveProfiles import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.DynamicPropertyRegistry +import org.springframework.test.context.DynamicPropertySource @ContextConfiguration( initializers = [PostgresInitializer::class], @@ -24,16 +25,17 @@ import org.springframework.test.context.ContextConfiguration @ActiveProfiles("test") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) class BaseIntegrationTest : MakeRequestAbility { + @Autowired override lateinit var restTemplate: TestRestTemplate private val objectMapper: ObjectMapper by lazy { jacksonObjectMapper() } - val wireMockServer = WireMockServer(options().port(8080)) - @BeforeEach fun beforeEach() { - wireMockServer.resetRequests() + wireMock.resetAll() + WireMock.reset() + DatabaseCleaner.cleanAllTables() } override fun toJson(obj: T): String { @@ -41,21 +43,34 @@ class BaseIntegrationTest : MakeRequestAbility { } companion object { - lateinit var wireMockServer: WireMockServer + protected val wireMock = WireMockServer(0) @JvmStatic - @BeforeAll - fun startWireMockServer() { - wireMockServer = WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort()) - wireMockServer.start() - System.setProperty("wiremock.server.port", wireMockServer.port().toString()) + @DynamicPropertySource + fun properties(registry: DynamicPropertyRegistry) { + registry.add("nbp.url") { "http://localhost:${wireMock.port()}/api/exchangerates/rates/A/USD" } } @JvmStatic - @AfterAll - fun stopWireMockServer() { - wireMockServer.stop() - System.clearProperty("wiremock.server.port") + @BeforeAll + fun startWireMock() { + if (!wireMock.isRunning) { + wireMock.start() + WireMock.configureFor(wireMock.port()) + println("WIREMOCK STARTED at port: ${wireMock.port()}") + } + } + + init { + Runtime.getRuntime().addShutdownHook( + Thread { + if (wireMock.isRunning) { + println("Shutting down WireMock server...") + wireMock.stop() + println("WireMock stopped") + } + }, + ) } } } diff --git a/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/abilties/CreateAccountAbility.kt b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/abilties/CreateAccountAbility.kt index b326911..4b73eba 100644 --- a/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/abilties/CreateAccountAbility.kt +++ b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/abilties/CreateAccountAbility.kt @@ -1,8 +1,12 @@ package camilyed.github.io.currencyexchangeapi.testing.abilties +import camilyed.github.io.currencyexchangeapi.api.AccountEndpoint +import camilyed.github.io.currencyexchangeapi.testing.assertion.isOkResponse import camilyed.github.io.currencyexchangeapi.testing.builders.CreateAccountJsonBuilder +import camilyed.github.io.currencyexchangeapi.testing.utils.parseBodyToType import org.springframework.http.HttpHeaders import org.springframework.http.ResponseEntity +import strikt.api.expectThat interface CreateAccountAbility : MakeRequestAbility { fun createAccount(builder: CreateAccountJsonBuilder): ResponseEntity { @@ -16,4 +20,10 @@ interface CreateAccountAbility : MakeRequestAbility { responseType = String::class.java, ) } + + fun thereIsAnAccount(builder: CreateAccountJsonBuilder): String { + val response = createAccount(builder) + expectThat(response).isOkResponse() + return parseBodyToType(response).id + } } diff --git a/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/abilties/ExchangePlnToUsdAbility.kt b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/abilties/ExchangePlnToUsdAbility.kt new file mode 100644 index 0000000..dd42a86 --- /dev/null +++ b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/abilties/ExchangePlnToUsdAbility.kt @@ -0,0 +1,19 @@ +package camilyed.github.io.currencyexchangeapi.testing.abilties + +import camilyed.github.io.currencyexchangeapi.testing.builders.ExchangePlnToUsdJsonBuilder +import org.springframework.http.HttpHeaders +import org.springframework.http.ResponseEntity + +interface ExchangePlnToUsdAbility : MakeRequestAbility { + fun exchangePlnToUsd(builder: ExchangePlnToUsdJsonBuilder): ResponseEntity { + val exchangeJson = builder.build() + val httpHeaders = HttpHeaders() + httpHeaders["X-Request-Id"] = builder.xRequestId + return put( + url = "/api/accounts//exchange-pln-to-usd", + body = exchangeJson, + headers = httpHeaders, + responseType = String::class.java, + ) + } +} diff --git a/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/abilties/GetCurrentExchangeRateAbility.kt b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/abilties/GetCurrentExchangeRateAbility.kt index 63cd922..6034bbe 100644 --- a/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/abilties/GetCurrentExchangeRateAbility.kt +++ b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/abilties/GetCurrentExchangeRateAbility.kt @@ -1,14 +1,12 @@ package camilyed.github.io.currencyexchangeapi.testing.abilties -import com.github.tomakehurst.wiremock.WireMockServer import com.github.tomakehurst.wiremock.client.WireMock interface GetCurrentExchangeRateAbility { - val wireMockServer: WireMockServer fun currentExchangeRateIs(rate: String) { - wireMockServer.stubFor( - WireMock.get("/api/exchangerates/rates/A/USD") + WireMock.stubFor( + WireMock.get(WireMock.urlEqualTo("/api/exchangerates/rates/A/USD")) .willReturn( WireMock.aResponse() .withHeader("Content-Type", "application/json") @@ -25,7 +23,7 @@ interface GetCurrentExchangeRateAbility { }] } """.trimIndent(), - ), + ).withStatus(200), ), ) } diff --git a/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/abilties/MakeRequestAbility.kt b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/abilties/MakeRequestAbility.kt index 4756400..1f1385e 100644 --- a/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/abilties/MakeRequestAbility.kt +++ b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/abilties/MakeRequestAbility.kt @@ -3,6 +3,7 @@ package camilyed.github.io.currencyexchangeapi.testing.abilties import org.springframework.boot.test.web.client.TestRestTemplate import org.springframework.http.HttpEntity import org.springframework.http.HttpHeaders +import org.springframework.http.HttpMethod import org.springframework.http.MediaType import org.springframework.http.ResponseEntity @@ -28,5 +29,17 @@ interface MakeRequestAbility { return restTemplate.postForEntity(url, requestEntity, responseType) } + fun put( + url: String, + body: Map, + headers: HttpHeaders, + responseType: Class, + ): ResponseEntity { + val jsonString = toJson(body) + headers.contentType = MediaType.APPLICATION_JSON + val requestEntity = HttpEntity(jsonString, headers) + return restTemplate.exchange(url, HttpMethod.PUT, requestEntity, responseType) + } + fun toJson(obj: T): String } diff --git a/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/assertion/Assertions.kt b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/assertion/Assertions.kt index 7fce537..a715ee6 100644 --- a/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/assertion/Assertions.kt +++ b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/assertion/Assertions.kt @@ -1,7 +1,6 @@ package camilyed.github.io.currencyexchangeapi.testing.assertion -import com.fasterxml.jackson.core.type.TypeReference -import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import camilyed.github.io.currencyexchangeapi.testing.utils.parseBodyToType import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import strikt.api.Assertion @@ -18,7 +17,7 @@ fun Assertion.Builder>.isOkResponse(): Assertion.Builder>.hasUUID(): Assertion.Builder> = assert("should contain a valid UUID in 'id' field") { - val body = parseBodyToMap(it) + val body = parseBodyToType>(it) val actualId = body["id"] as? String ?: fail("Response does not contain 'id' or 'id' is not a String") try { @@ -38,12 +37,21 @@ fun Assertion.Builder>.isBadRequest(): Assertion.Builder Assertion.Builder>.isUnprocessableEntity(): Assertion.Builder> = + assert("should have an UNPROCESSABLE ENTITY response status") { + if (it.statusCode == HttpStatus.UNPROCESSABLE_ENTITY) { + pass() + } else { + fail("Expected UNPROCESSABLE ENTITY, but got ${it.statusCode}") + } + } + fun Assertion.Builder>.hasProblemDetail( expectedField: String, expectedDetail: String, ): Assertion.Builder> = assert("should contain problem detail, field '$expectedField' with value '$expectedDetail'") { - val body = parseBodyToMap(it) + val body = parseBodyToType>(it) val actualDetail = body["detail"] as? String ?: fail("No 'detail' field in response body") as String if (actualDetail.contains("$expectedField: $expectedDetail")) { @@ -57,9 +65,3 @@ fun Assertion.Builder>.hasProblemDetail( ) } } - -val objectMapper = jacksonObjectMapper() - -private fun parseBodyToMap(response: ResponseEntity): Map { - return objectMapper.readValue(response.body!!, object : TypeReference>() {}) -} diff --git a/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/assertion/ExchangeToUsdAssertion.kt b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/assertion/ExchangeToUsdAssertion.kt new file mode 100644 index 0000000..08dc31e --- /dev/null +++ b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/assertion/ExchangeToUsdAssertion.kt @@ -0,0 +1,37 @@ +package camilyed.github.io.currencyexchangeapi.testing.assertion + +import camilyed.github.io.currencyexchangeapi.testing.utils.parseBodyToType +import org.springframework.http.ResponseEntity +import strikt.api.Assertion.Builder + +fun Builder>.hasPlnAmount(expectedPlnAmount: String): Builder> = + assert("should contain correct PLN amount") { + val body = parseBodyToType>(it) + + val actualPlnAmount = + body["balancePln"] as? String ?: fail( + "Response does not contain 'balancePln' or 'plnAmount' is not a String", + ) + + if (actualPlnAmount == expectedPlnAmount) { + pass() + } else { + fail("Expected PLN amount: $expectedPlnAmount, but got $actualPlnAmount") + } + } + +fun Builder>.hasUsdAmount(expectedUsdAmount: String): Builder> = + assert("should contain correct USD amount") { + val body = parseBodyToType>(it) + + val actualUsdAmount = + body["balanceUsd"] as? String ?: fail( + "Response does not contain 'balanceUsd' or 'balanceUsd' is not a String", + ) + + if (actualUsdAmount == expectedUsdAmount) { + pass() + } else { + fail("Expected USD amount: $expectedUsdAmount, but got $actualUsdAmount") + } + } diff --git a/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/builders/ExchangePlnToUsdJsonBuilder.kt b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/builders/ExchangePlnToUsdJsonBuilder.kt new file mode 100644 index 0000000..a8a904a --- /dev/null +++ b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/builders/ExchangePlnToUsdJsonBuilder.kt @@ -0,0 +1,46 @@ +package camilyed.github.io.currencyexchangeapi.testing.builders + +import java.util.UUID + +class ExchangePlnToUsdJsonBuilder { + var accountId: String? = UUID.randomUUID().toString() + var amount: String? = "100.00" + var xRequestId: String? = UUID.randomUUID().toString() + + fun withAccountId(accountId: String) = apply { + this.accountId = accountId + } + + fun withAmount(amount: String) = apply { + this.amount = amount + } + + fun withXRequestId(xRequestId: String) = apply { + this.xRequestId = xRequestId + } + + fun withoutXRequestId() = apply { + this.xRequestId = null + } + + fun withoutAccountId() = apply { + this.accountId = null + } + + fun withoutAmount() = apply { + this.amount = null + } + + fun build(): Map { + return mapOf( + "accountId" to accountId, + "amount" to amount, + ) + } + + companion object { + fun anExchangePlnToUsd(): ExchangePlnToUsdJsonBuilder { + return ExchangePlnToUsdJsonBuilder() + } + } +} diff --git a/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/config/WireMockConfig.kt b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/config/WireMockConfig.kt new file mode 100644 index 0000000..47e9fd8 --- /dev/null +++ b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/config/WireMockConfig.kt @@ -0,0 +1,19 @@ +package camilyed.github.io.currencyexchangeapi.testing.config + +import com.github.tomakehurst.wiremock.WireMockServer +import com.github.tomakehurst.wiremock.core.WireMockConfiguration +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Primary + +@Configuration +class WireMockConfig { + + @Bean + @Primary + fun wireMockServer(): WireMockServer { + val wireMockServer = WireMockServer(WireMockConfiguration.wireMockConfig().dynamicPort()) + wireMockServer.start() + return wireMockServer + } +} diff --git a/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/utils/DatabaseCleaner.kt b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/utils/DatabaseCleaner.kt new file mode 100644 index 0000000..17da229 --- /dev/null +++ b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/utils/DatabaseCleaner.kt @@ -0,0 +1,16 @@ +package camilyed.github.io.currencyexchangeapi.testing.utils + +import org.jetbrains.exposed.sql.transactions.TransactionManager +import org.jetbrains.exposed.sql.transactions.transaction + +object DatabaseCleaner { + + fun cleanAllTables() { + transaction { + val tables = TransactionManager.current().db.dialect.allTablesNames() + tables.forEach { tableName -> + exec("TRUNCATE $tableName") + } + } + } +} diff --git a/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/utils/ResponseParser.kt b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/utils/ResponseParser.kt new file mode 100644 index 0000000..cb98a8b --- /dev/null +++ b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/testing/utils/ResponseParser.kt @@ -0,0 +1,10 @@ +package camilyed.github.io.currencyexchangeapi.testing.utils + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import org.springframework.http.ResponseEntity + +val objectMapper = jacksonObjectMapper() + +inline fun parseBodyToType(response: ResponseEntity): T { + return objectMapper.readValue(response.body!!, T::class.java) +} diff --git a/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/web/AccountCreationIntegrationTest.kt b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/web/AccountCreationIntegrationTest.kt index a6afb27..7dff27e 100644 --- a/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/web/AccountCreationIntegrationTest.kt +++ b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/web/AccountCreationIntegrationTest.kt @@ -2,7 +2,6 @@ package camilyed.github.io.currencyexchangeapi.web import camilyed.github.io.currencyexchangeapi.testing.BaseIntegrationTest import camilyed.github.io.currencyexchangeapi.testing.abilties.CreateAccountAbility -import camilyed.github.io.currencyexchangeapi.testing.abilties.GetCurrentExchangeRateAbility import camilyed.github.io.currencyexchangeapi.testing.assertion.hasProblemDetail import camilyed.github.io.currencyexchangeapi.testing.assertion.hasUUID import camilyed.github.io.currencyexchangeapi.testing.assertion.isBadRequest @@ -14,8 +13,7 @@ import strikt.assertions.isEqualTo class AccountCreationIntegrationTest : BaseIntegrationTest(), - CreateAccountAbility, - GetCurrentExchangeRateAbility { + CreateAccountAbility { @Test fun `should create a new account`() { diff --git a/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/web/ExchangePlnToUsdIntegrationTest.kt b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/web/ExchangePlnToUsdIntegrationTest.kt new file mode 100644 index 0000000..59cff40 --- /dev/null +++ b/src/integrationTest/kotlin/camilyed/github/io/currencyexchangeapi/web/ExchangePlnToUsdIntegrationTest.kt @@ -0,0 +1,177 @@ +package camilyed.github.io.currencyexchangeapi.web + +import camilyed.github.io.currencyexchangeapi.testing.BaseIntegrationTest +import camilyed.github.io.currencyexchangeapi.testing.abilties.CreateAccountAbility +import camilyed.github.io.currencyexchangeapi.testing.abilties.ExchangePlnToUsdAbility +import camilyed.github.io.currencyexchangeapi.testing.abilties.GetCurrentExchangeRateAbility +import camilyed.github.io.currencyexchangeapi.testing.assertion.hasPlnAmount +import camilyed.github.io.currencyexchangeapi.testing.assertion.hasProblemDetail +import camilyed.github.io.currencyexchangeapi.testing.assertion.hasUsdAmount +import camilyed.github.io.currencyexchangeapi.testing.assertion.isBadRequest +import camilyed.github.io.currencyexchangeapi.testing.assertion.isOkResponse +import camilyed.github.io.currencyexchangeapi.testing.assertion.isUnprocessableEntity +import camilyed.github.io.currencyexchangeapi.testing.builders.CreateAccountJsonBuilder.Companion.aCreateAccount +import camilyed.github.io.currencyexchangeapi.testing.builders.ExchangePlnToUsdJsonBuilder.Companion.anExchangePlnToUsd +import org.junit.jupiter.api.Test +import strikt.api.expectThat +import java.util.UUID + +class ExchangePlnToUsdIntegrationTest : + BaseIntegrationTest(), + CreateAccountAbility, + ExchangePlnToUsdAbility, + GetCurrentExchangeRateAbility { + + @Test + fun `should exchange PLN to USD successfully`() { + // given + val accountId = thereIsAnAccount(aCreateAccount().withInitialBalance("1000.00")) + + // and + currentExchangeRateIs("4.0") + + // when + val response = exchangePlnToUsd( + anExchangePlnToUsd() + .withAccountId(accountId) + .withAmount("400"), + ) + + // then + expectThat(response) + .isOkResponse() + .hasPlnAmount("600.00") + .hasUsdAmount("100.00") + } + + @Test + fun `should return error when accountId is missing`() { + // when + val response = exchangePlnToUsd(anExchangePlnToUsd().withoutAccountId()) + + // then + expectThat(response) + .isBadRequest() + .hasProblemDetail("accountId", "Account ID cannot be blank") + } + + @Test + fun `should return error when amount is missing`() { + // when + val response = exchangePlnToUsd(anExchangePlnToUsd().withoutAmount()) + + // then + expectThat(response) + .isBadRequest() + .hasProblemDetail("amount", "Amount cannot be blank") + } + + @Test + fun `should return error when amount is not a valid number`() { + // when + val response = exchangePlnToUsd(anExchangePlnToUsd().withAmount("invalid")) + + // then + expectThat(response) + .isBadRequest() + .hasProblemDetail("amount", "Amount must be a valid decimal number") + } + + @Test + fun `should return error when X-Request-Id header is missing`() { + // when + val response = exchangePlnToUsd(anExchangePlnToUsd().withoutXRequestId()) + + // then + expectThat(response) + .isBadRequest() + .hasProblemDetail("X-Request-Id", "X-Request-Id is required and must be a valid UUID") + } + + @Test + fun `should return error when X-Request-Id header is not valid UUID`() { + // when + val response = exchangePlnToUsd(anExchangePlnToUsd().withXRequestId("not uuid")) + + // then + expectThat(response) + .isBadRequest() + .hasProblemDetail("X-Request-Id", "X-Request-Id is required and must be a valid UUID") + } + + @Test + fun `should return error when amount is zero`() { + // given + val accountId = thereIsAnAccount(aCreateAccount()) + + // and + currentExchangeRateIs("4.0") + + // when + val response = exchangePlnToUsd( + anExchangePlnToUsd() + .withAccountId(accountId) + .withAmount("0"), + ) + + // then + expectThat(response) + .isUnprocessableEntity() + .hasProblemDetail("amount", "Amount must be greater than 0") + } + + @Test + fun `should return error when not enough PLN funds for exchange`() { + // given + val accountId = thereIsAnAccount(aCreateAccount().withInitialBalance("100.00")) + + // and + currentExchangeRateIs("4.0") + + // when + val response = exchangePlnToUsd( + anExchangePlnToUsd() + .withAccountId(accountId) + .withAmount("200"), + ) + + // then + expectThat(response) + .isUnprocessableEntity() + .hasProblemDetail("balance", "Insufficient PLN balance") + } + + @Test + fun `should return the same result when the same X-Request-Id is used multiple times`() { + // given + val accountId = thereIsAnAccount(aCreateAccount().withInitialBalance("1000.00")) + + // and + currentExchangeRateIs("4.0") + + val requestId = UUID.randomUUID().toString() + val firstResponse = exchangePlnToUsd( + anExchangePlnToUsd() + .withAccountId(accountId) + .withAmount("400") + .withXRequestId(requestId), + ) + + expectThat(firstResponse) + .isOkResponse() + .hasPlnAmount("600.00") + .hasUsdAmount("100.00") + + val secondResponse = exchangePlnToUsd( + anExchangePlnToUsd() + .withAccountId(accountId) + .withAmount("400") + .withXRequestId(requestId), + ) + + expectThat(secondResponse) + .isOkResponse() + .hasPlnAmount("600.00") + .hasUsdAmount("100.00") + } +} diff --git a/src/integrationTest/resources/application-test.yml b/src/integrationTest/resources/application-test.yml index f52da5a..f0b4cbd 100644 --- a/src/integrationTest/resources/application-test.yml +++ b/src/integrationTest/resources/application-test.yml @@ -1,5 +1,3 @@ -nbp: - url: http://localhost:${wiremock.server.port}/api/exchangerates/rates/A/USD spring: datasource: diff --git a/src/main/kotlin/camilyed/github/io/CurrencyExchangeApiApplication.kt b/src/main/kotlin/camilyed/github/io/CurrencyExchangeApiApplication.kt index de1dbe3..743770e 100644 --- a/src/main/kotlin/camilyed/github/io/CurrencyExchangeApiApplication.kt +++ b/src/main/kotlin/camilyed/github/io/CurrencyExchangeApiApplication.kt @@ -3,13 +3,12 @@ package camilyed.github.io import org.springframework.boot.autoconfigure.ImportAutoConfiguration import org.springframework.boot.autoconfigure.SpringBootApplication import org.springframework.boot.runApplication -import org.springframework.cloud.commons.httpclient.HttpClientConfiguration import org.springframework.cloud.openfeign.EnableFeignClients import org.springframework.cloud.openfeign.FeignAutoConfiguration @EnableFeignClients @SpringBootApplication -@ImportAutoConfiguration(FeignAutoConfiguration::class, HttpClientConfiguration::class) +@ImportAutoConfiguration(FeignAutoConfiguration::class) class CurrencyExchangeApiApplication fun main(args: Array) { diff --git a/src/main/kotlin/camilyed/github/io/currencyexchangeapi/adapters/nbp/NbpFeignClient.kt b/src/main/kotlin/camilyed/github/io/currencyexchangeapi/adapters/nbp/NbpFeignClient.kt index f915b8d..aa6735c 100644 --- a/src/main/kotlin/camilyed/github/io/currencyexchangeapi/adapters/nbp/NbpFeignClient.kt +++ b/src/main/kotlin/camilyed/github/io/currencyexchangeapi/adapters/nbp/NbpFeignClient.kt @@ -1,20 +1,20 @@ package camilyed.github.io.currencyexchangeapi.adapters.nbp -import org.springframework.cloud.openfeign.FeignClient -import org.springframework.web.bind.annotation.GetMapping +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import feign.RequestLine import java.math.BigDecimal -@FeignClient(name = "nbpClient", url = "\${nbp.url}") interface NbpFeignClient { - - @GetMapping + @RequestLine("GET") fun getUsdToPlnRate(): NbpExchangeRateResponse } +@JsonIgnoreProperties(ignoreUnknown = true) data class NbpExchangeRateResponse( val rates: List, ) +@JsonIgnoreProperties(ignoreUnknown = true) data class Rate( val mid: BigDecimal, ) diff --git a/src/main/kotlin/camilyed/github/io/currencyexchangeapi/adapters/nbp/NbpFeignClientConfig.kt b/src/main/kotlin/camilyed/github/io/currencyexchangeapi/adapters/nbp/NbpFeignClientConfig.kt new file mode 100644 index 0000000..c41fc95 --- /dev/null +++ b/src/main/kotlin/camilyed/github/io/currencyexchangeapi/adapters/nbp/NbpFeignClientConfig.kt @@ -0,0 +1,22 @@ +package camilyed.github.io.currencyexchangeapi.adapters.nbp + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import feign.Feign +import feign.jackson.JacksonDecoder +import org.springframework.beans.factory.annotation.Value +import org.springframework.beans.factory.config.BeanDefinition +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.annotation.Scope + +@Configuration +class NbpFeignClientConfig { + + @Bean + @Scope(BeanDefinition.SCOPE_PROTOTYPE) + fun nbpFeignClient(@Value("\${nbp.url}") url: String): NbpFeignClient { + return Feign.builder() + .decoder(JacksonDecoder(jacksonObjectMapper())) + .target(NbpFeignClient::class.java, url) + } +} diff --git a/src/main/kotlin/camilyed/github/io/currencyexchangeapi/adapters/postgres/PostgresAccountOperationRepository.kt b/src/main/kotlin/camilyed/github/io/currencyexchangeapi/adapters/postgres/PostgresAccountOperationRepository.kt index 71e518f..9a7d3b3 100644 --- a/src/main/kotlin/camilyed/github/io/currencyexchangeapi/adapters/postgres/PostgresAccountOperationRepository.kt +++ b/src/main/kotlin/camilyed/github/io/currencyexchangeapi/adapters/postgres/PostgresAccountOperationRepository.kt @@ -38,13 +38,30 @@ class PostgresAccountOperationRepository( it[amountPln] = event.initialBalancePln it[amountUsd] = null it[exchangeRate] = null - it[description] = description(event) + it[description] = accountCreatedDescription(event) + } + } + + is AccountEvent.PlnToUsdExchangeEvent -> { + AccountOperationsTable.insert { + it[id] = UUID.randomUUID() + it[accountId] = event.accountId + it[operationType] = "PLN_TO_USD" + it[operationId] = event.operationId + it[createdAt] = timestamp + it[amountPln] = event.amountPln + it[amountUsd] = event.amountUsd + it[exchangeRate] = event.exchangeRate + it[description] = exchangePlnToUsdDescription(event) } } } } } - private fun description(event: AccountEvent.AccountCreatedEvent) = + private fun accountCreatedDescription(event: AccountEvent.AccountCreatedEvent) = "Created account for ${event.owner} with initial balance of ${event.initialBalancePln} PLN" + + private fun exchangePlnToUsdDescription(event: AccountEvent.PlnToUsdExchangeEvent) = + "Exchanged ${event.amountPln} PLN to ${event.amountUsd} USD at rate ${event.exchangeRate}" } diff --git a/src/main/kotlin/camilyed/github/io/currencyexchangeapi/api/AccountEndpoint.kt b/src/main/kotlin/camilyed/github/io/currencyexchangeapi/api/AccountEndpoint.kt index 124ca0d..6b3ec6f 100644 --- a/src/main/kotlin/camilyed/github/io/currencyexchangeapi/api/AccountEndpoint.kt +++ b/src/main/kotlin/camilyed/github/io/currencyexchangeapi/api/AccountEndpoint.kt @@ -2,6 +2,7 @@ package camilyed.github.io.currencyexchangeapi.api import camilyed.github.io.currencyexchangeapi.application.AccountService import camilyed.github.io.currencyexchangeapi.application.CreateAccountCommand +import camilyed.github.io.currencyexchangeapi.application.ExchangePlnToUsdCommand import camilyed.github.io.currencyexchangeapi.domain.AccountSnapshot import camilyed.github.io.currencyexchangeapi.infrastructure.InvalidHeaderException import jakarta.validation.Valid @@ -10,6 +11,7 @@ import jakarta.validation.constraints.Pattern import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestHeader import org.springframework.web.bind.annotation.RequestMapping @@ -33,6 +35,31 @@ class AccountEndpoint(private val accountService: AccountService) { return ResponseEntity.status(HttpStatus.CREATED).body(account.toAccountCreatedJson()) } + @PutMapping("/exchange-pln-to-usd") + fun exchangePlnToUsd( + @Valid @RequestBody request: ExchangePlnToUsdJson, + @RequestHeader("X-Request-Id") requestId: String?, + ): ResponseEntity { + if (requestId == null) { + throw InvalidHeaderException("X-Request-Id is required and must be a valid UUID") + } + val command = request.toCommand(requestId.toUUID()) + val account = accountService.exchangePlnToUsd(command) + return ResponseEntity.ok(account.toAccountSnapshotJson()) + } + + data class ExchangePlnToUsdJson( + @field:NotBlank(message = "Account ID cannot be blank") + val accountId: String?, + + @field:NotBlank(message = "Amount cannot be blank") + @field:Pattern( + regexp = "\\d+(\\.\\d{1,2})?", + message = "Amount must be a valid decimal number", + ) + val amount: String?, + ) + data class CreateAccountJson( @field:NotBlank(message = "Owner cannot be blank") val owner: String?, @@ -56,6 +83,12 @@ class AccountEndpoint(private val accountService: AccountService) { commandId, ) + private fun ExchangePlnToUsdJson.toCommand(operationId: UUID) = ExchangePlnToUsdCommand( + accountId = UUID.fromString(this.accountId), + amount = BigDecimal(this.amount), + commandId = operationId, + ) + private fun String.toUUID(): UUID { try { return UUID.fromString(this) @@ -65,4 +98,18 @@ class AccountEndpoint(private val accountService: AccountService) { } private fun AccountSnapshot.toAccountCreatedJson() = AccountCreatedJson(this.id.toString()) + + private fun AccountSnapshot.toAccountSnapshotJson() = AccountSnapshotJson( + id = this.id.toString(), + owner = this.owner, + balancePln = this.balancePln.toString(), + balanceUsd = this.balanceUsd.toString(), + ) + + data class AccountSnapshotJson( + val id: String, + val owner: String, + val balancePln: String, + val balanceUsd: String, + ) } diff --git a/src/main/kotlin/camilyed/github/io/currencyexchangeapi/application/AccountService.kt b/src/main/kotlin/camilyed/github/io/currencyexchangeapi/application/AccountService.kt index 84debc4..b2c1050 100644 --- a/src/main/kotlin/camilyed/github/io/currencyexchangeapi/application/AccountService.kt +++ b/src/main/kotlin/camilyed/github/io/currencyexchangeapi/application/AccountService.kt @@ -8,7 +8,7 @@ import camilyed.github.io.currencyexchangeapi.domain.AccountRepository import camilyed.github.io.currencyexchangeapi.domain.AccountSnapshot import camilyed.github.io.currencyexchangeapi.domain.CreateAccountData import camilyed.github.io.currencyexchangeapi.domain.CurrentExchangeRateProvider -import org.jetbrains.exposed.sql.transactions.transaction +import camilyed.github.io.currencyexchangeapi.infrastructure.executeInTransaction import java.util.UUID class AccountService( @@ -20,11 +20,11 @@ class AccountService( fun create(command: CreateAccountCommand): AccountSnapshot { val accountId = accountOperationRepository.findAccountIdBy(command.commandId) if (accountId != null) { - return transaction { findAccount(accountId).toSnapshot() } + return executeInTransaction { findAccount(accountId).toSnapshot() } } val id = repository.nextAccountId() val account = Account.createNewAccount(command.toCreateAccountData(id)) - inTransaction { + executeInTransaction { repository.save(account) val events = account.getEvents() accountOperationRepository.save(events) @@ -33,9 +33,21 @@ class AccountService( } fun exchangePlnToUsd(command: ExchangePlnToUsdCommand): AccountSnapshot { - val account = findAccount(command.accountId) + val accountId = accountOperationRepository.findAccountIdBy(command.commandId) + if (accountId != null) { + return executeInTransaction { findAccount(accountId).toSnapshot() } + } + val account = executeInTransaction { findAccount(command.accountId) } val currentExchange = currentExchangeRateProvider.currentExchange() - account.exchangePlnToUsd(Money.pln(command.amount), currentExchange) + account.exchangePlnToUsd( + amountPln = Money.pln(command.amount), + exchangeRate = currentExchange, + operationId = command.commandId, + ) + executeInTransaction { + repository.save(account) + accountOperationRepository.save(account.getEvents()) + } return account.toSnapshot() } diff --git a/src/main/kotlin/camilyed/github/io/currencyexchangeapi/application/ExchangePlnToUsdCommand.kt b/src/main/kotlin/camilyed/github/io/currencyexchangeapi/application/ExchangePlnToUsdCommand.kt index e5362a3..198c7c5 100644 --- a/src/main/kotlin/camilyed/github/io/currencyexchangeapi/application/ExchangePlnToUsdCommand.kt +++ b/src/main/kotlin/camilyed/github/io/currencyexchangeapi/application/ExchangePlnToUsdCommand.kt @@ -6,4 +6,5 @@ import java.util.UUID data class ExchangePlnToUsdCommand( val accountId: UUID, val amount: BigDecimal, + val commandId: UUID, ) diff --git a/src/main/kotlin/camilyed/github/io/currencyexchangeapi/application/InTransaction.kt b/src/main/kotlin/camilyed/github/io/currencyexchangeapi/application/InTransaction.kt deleted file mode 100644 index bdfcd14..0000000 --- a/src/main/kotlin/camilyed/github/io/currencyexchangeapi/application/InTransaction.kt +++ /dev/null @@ -1,5 +0,0 @@ -package camilyed.github.io.currencyexchangeapi.application - -var inTransaction: (() -> Any) -> Any = { block -> - block() -} diff --git a/src/main/kotlin/camilyed/github/io/currencyexchangeapi/domain/Account.kt b/src/main/kotlin/camilyed/github/io/currencyexchangeapi/domain/Account.kt index c7545ef..e198163 100644 --- a/src/main/kotlin/camilyed/github/io/currencyexchangeapi/domain/Account.kt +++ b/src/main/kotlin/camilyed/github/io/currencyexchangeapi/domain/Account.kt @@ -17,7 +17,11 @@ class Account private constructor( require(balanceUsd.currency == "USD") { "USD balance must be in USD" } } - fun exchangePlnToUsd(amountPln: Money, exchangeRate: ExchangeRate) { + fun exchangePlnToUsd( + amountPln: Money, + exchangeRate: ExchangeRate, + operationId: UUID, + ) { require(!amountPln.isZero()) { throw InvalidAmountException("Amount must be greater than 0") } @@ -30,6 +34,16 @@ class Account private constructor( val amountUsd = Money(exchangeRate.convertFromPln(amountPln.amount), "USD") balancePln -= amountPln balanceUsd += amountUsd + + addEvent( + AccountEvent.PlnToUsdExchangeEvent( + accountId = id, + operationId = operationId, + amountPln = amountPln.amount, + amountUsd = amountUsd.amount, + exchangeRate = exchangeRate.rate, + ), + ) } fun exchangeUsdToPln(amountUsd: Money, exchangeRate: ExchangeRate) { diff --git a/src/main/kotlin/camilyed/github/io/currencyexchangeapi/domain/AccountEvent.kt b/src/main/kotlin/camilyed/github/io/currencyexchangeapi/domain/AccountEvent.kt index ec53653..8305aeb 100644 --- a/src/main/kotlin/camilyed/github/io/currencyexchangeapi/domain/AccountEvent.kt +++ b/src/main/kotlin/camilyed/github/io/currencyexchangeapi/domain/AccountEvent.kt @@ -13,4 +13,12 @@ sealed class AccountEvent( val owner: String, val initialBalancePln: BigDecimal, ) : AccountEvent(accountId, operationId) + + data class PlnToUsdExchangeEvent( + override val accountId: UUID, + override val operationId: UUID, + val amountPln: BigDecimal, + val amountUsd: BigDecimal, + val exchangeRate: BigDecimal, + ) : AccountEvent(accountId, operationId) } diff --git a/src/main/kotlin/camilyed/github/io/currencyexchangeapi/infrastructure/GlobalExceptionHandler.kt b/src/main/kotlin/camilyed/github/io/currencyexchangeapi/infrastructure/GlobalExceptionHandler.kt index 0b3f11e..13abb7c 100644 --- a/src/main/kotlin/camilyed/github/io/currencyexchangeapi/infrastructure/GlobalExceptionHandler.kt +++ b/src/main/kotlin/camilyed/github/io/currencyexchangeapi/infrastructure/GlobalExceptionHandler.kt @@ -1,11 +1,14 @@ package camilyed.github.io.currencyexchangeapi.infrastructure +import camilyed.github.io.currencyexchangeapi.domain.InsufficientFundsException +import camilyed.github.io.currencyexchangeapi.domain.InvalidAmountException import jakarta.servlet.http.HttpServletRequest import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.validation.FieldError import org.springframework.web.bind.MethodArgumentNotValidException import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.ResponseStatus import org.springframework.web.bind.annotation.RestControllerAdvice @RestControllerAdvice @@ -41,6 +44,30 @@ class GlobalExceptionHandler { ) return ResponseEntity(problemDetails, HttpStatus.BAD_REQUEST) } + + @ExceptionHandler(InvalidAmountException::class) + @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) + fun handleInvalidAmountException(ex: InvalidAmountException): ResponseEntity { + val problemDetails = ProblemDetails( + title = "Invalid Amount", + status = HttpStatus.UNPROCESSABLE_ENTITY.value(), + detail = "amount: " + ex.message, + instance = null, + ) + return ResponseEntity(problemDetails, HttpStatus.UNPROCESSABLE_ENTITY) + } + + @ExceptionHandler(InsufficientFundsException::class) + @ResponseStatus(HttpStatus.UNPROCESSABLE_ENTITY) + fun handleInsufficientFunds(ex: InsufficientFundsException): ResponseEntity { + val problemDetails = ProblemDetails( + title = "Insufficient funds", + status = HttpStatus.UNPROCESSABLE_ENTITY.value(), + detail = "balance: " + ex.message, + instance = null, + ) + return ResponseEntity(problemDetails, HttpStatus.UNPROCESSABLE_ENTITY) + } } data class ProblemDetails( diff --git a/src/main/kotlin/camilyed/github/io/currencyexchangeapi/config/TransactionManagerConfig.kt b/src/main/kotlin/camilyed/github/io/currencyexchangeapi/infrastructure/TransactionManagerConfig.kt similarity index 50% rename from src/main/kotlin/camilyed/github/io/currencyexchangeapi/config/TransactionManagerConfig.kt rename to src/main/kotlin/camilyed/github/io/currencyexchangeapi/infrastructure/TransactionManagerConfig.kt index 4dd8d54..37acd9a 100644 --- a/src/main/kotlin/camilyed/github/io/currencyexchangeapi/config/TransactionManagerConfig.kt +++ b/src/main/kotlin/camilyed/github/io/currencyexchangeapi/infrastructure/TransactionManagerConfig.kt @@ -1,17 +1,26 @@ -package camilyed.github.io.currencyexchangeapi.config +@file:Suppress("UNCHECKED_CAST") + +package camilyed.github.io.currencyexchangeapi.infrastructure -import camilyed.github.io.currencyexchangeapi.application.inTransaction import jakarta.annotation.PostConstruct import org.jetbrains.exposed.sql.transactions.transaction import org.springframework.context.annotation.Configuration +fun executeInTransaction(block: () -> T): T { + return inTransaction(block as () -> Any) as T +} + +private var inTransaction: (() -> Any) -> Any = { block -> + block() +} + @Configuration class TransactionManagerConfig { @PostConstruct fun setupProductionTransaction() { inTransaction = { block -> - transaction { block() } // Using Exposed or any transaction manager + transaction { block() } } } } diff --git a/src/test/kotlin/camilyed/github/io/currencyexchangeapi/testing/builders/ExchangePlnToUsdCommandBuilder.kt b/src/test/kotlin/camilyed/github/io/currencyexchangeapi/testing/builders/ExchangePlnToUsdCommandBuilder.kt index 3438368..45b06fa 100644 --- a/src/test/kotlin/camilyed/github/io/currencyexchangeapi/testing/builders/ExchangePlnToUsdCommandBuilder.kt +++ b/src/test/kotlin/camilyed/github/io/currencyexchangeapi/testing/builders/ExchangePlnToUsdCommandBuilder.kt @@ -16,6 +16,7 @@ class ExchangePlnToUsdCommandBuilder private constructor() { return ExchangePlnToUsdCommand( accountId = accountId, amount = amount, + commandId = UUID.randomUUID(), ) }