diff --git a/README.md b/README.md index 347c6d5..85e82d6 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,29 @@ -# rate-boss![] -![Rate-boss scheme](doc/rate-boss.png) +# rate-boss -The essence of the service is quite simple. It collects exchange rate data from some source and stores it in a kafka topic +Rate-boss periodically pulls exchange rates from external sources, stores them in the database, and optionally publishes rate events to Kafka. It also exposes API endpoints to read the latest or historical rates and to convert amounts by timestamp. -Currency exchange rate is collected according to a specific schedule cron, which we specify in the configuration file. -We use quartz cluster mode to support the work of our service in multi node mode. +Supported sources today: Fixer, CBR, NBKZ. -![Rate-boss scheme](doc/rate-boss-quartz.png) +## Service flow (actual) -Quartz execute like this. First, we have `ExchangeGrabberMasterJob` its job is to take the list of currencies from the configuration -and create jobs `ExchangeGrabberJob` for each currency. `ExchangeGrabberJob` makes a request to a certain source of exchange rates -and send the received rates into kafka topic. +```mermaid +flowchart TD + subgraph Quartz Scheduler + M[ExchangeGrabberMasterJob
per source] -->|per currency| J[ExchangeGrabberJob] + end + + J -->|HTTP fetch| S[ExchangeRateSource
Fixer / CBR / NBKZ] + S -->|rates + timestamp| D[ExchangeDaoService] + D -->|save batch| DB[(PostgreSQL
rb.ex_rate)] + J -->|optional publish| E[ExchangeEventService] + E --> K[(Kafka topic)] + + API[ExchangeRateServiceSrv] -->|getExchangeRateData / getConvertedAmount| DB +``` + +## Quartz execution + +For each configured source: +- `ExchangeGrabberMasterJob` reads the list of base currencies from `rates.*Job.currencies`. +- It spawns `ExchangeGrabberJob` per currency. +- Each `ExchangeGrabberJob` calls the respective `ExchangeRateSource`, saves the rates, and (for some sources) emits Kafka events. diff --git a/src/main/kotlin/dev/vality/rateboss/client/nbkz/NbkzApiClient.kt b/src/main/kotlin/dev/vality/rateboss/client/nbkz/NbkzApiClient.kt new file mode 100644 index 0000000..1621521 --- /dev/null +++ b/src/main/kotlin/dev/vality/rateboss/client/nbkz/NbkzApiClient.kt @@ -0,0 +1,33 @@ +package dev.vality.rateboss.client.nbkz + +import dev.vality.rateboss.config.properties.RatesProperties +import org.springframework.http.HttpEntity +import org.springframework.http.HttpMethod +import org.springframework.stereotype.Component +import org.springframework.web.client.RestTemplate +import org.springframework.web.client.exchange +import org.springframework.web.util.UriComponentsBuilder +import java.time.LocalDate +import java.time.format.DateTimeFormatter + +@Component +class NbkzApiClient( + private val restTemplate: RestTemplate, + private val ratesProperties: RatesProperties, +) { + fun getExchangeRates(date: LocalDate): String { + val url = buildUrl(ratesProperties.source.nbkz.rootUrl, date, ratesProperties.source.nbkz.dateFormat) + return restTemplate.exchange(url, HttpMethod.GET, HttpEntity(null, null)).body!! + } + + private fun buildUrl( + endpoint: String, + date: LocalDate, + dateFormat: String, + ): String = + UriComponentsBuilder + .fromUriString(endpoint) + .queryParam("fdate", date.format(DateTimeFormatter.ofPattern(dateFormat))) + .build() + .toUriString() +} diff --git a/src/main/kotlin/dev/vality/rateboss/config/JobConfig.kt b/src/main/kotlin/dev/vality/rateboss/config/JobConfig.kt index 2924c67..e2eeaad 100644 --- a/src/main/kotlin/dev/vality/rateboss/config/JobConfig.kt +++ b/src/main/kotlin/dev/vality/rateboss/config/JobConfig.kt @@ -3,6 +3,7 @@ package dev.vality.rateboss.config import dev.vality.rateboss.config.properties.RatesProperties import dev.vality.rateboss.job.CbrExchangeGrabberMasterJob import dev.vality.rateboss.job.FixerExchangeGrabberMasterJob +import dev.vality.rateboss.job.NbkzExchangeGrabberMasterJob import org.quartz.* import org.quartz.impl.JobDetailImpl import org.springframework.beans.factory.annotation.Autowired @@ -33,6 +34,13 @@ class JobConfig { schedulerFactoryBean.scheduleJob(cbrExchangeRateGrabberMasterTrigger()) } } + val nbkzJobTriggerName = ratesProperties.nbkzJob.jobTriggerName + if (nbkzJobTriggerName.isNotEmpty()) { + schedulerFactoryBean.addJob(nbkzExchangeRateGrabberMasterJob(), true, true) + if (!schedulerFactoryBean.checkExists(TriggerKey(ratesProperties.nbkzJob.jobTriggerName))) { + schedulerFactoryBean.scheduleJob(nbkzExchangeRateGrabberMasterTrigger()) + } + } } fun fixerExchangeRateGrabberMasterJob(): JobDetailImpl { @@ -64,4 +72,19 @@ class JobConfig { .withIdentity(ratesProperties.cbrJob.jobTriggerName) .withSchedule(CronScheduleBuilder.cronSchedule(ratesProperties.cbrJob.jobCron)) .build() + + fun nbkzExchangeRateGrabberMasterJob(): JobDetailImpl { + val jobDetail = JobDetailImpl() + jobDetail.key = JobKey(ratesProperties.nbkzJob.jobKey) + jobDetail.jobClass = NbkzExchangeGrabberMasterJob::class.java + return jobDetail + } + + fun nbkzExchangeRateGrabberMasterTrigger(): CronTrigger = + TriggerBuilder + .newTrigger() + .forJob(nbkzExchangeRateGrabberMasterJob()) + .withIdentity(ratesProperties.nbkzJob.jobTriggerName) + .withSchedule(CronScheduleBuilder.cronSchedule(ratesProperties.nbkzJob.jobCron)) + .build() } diff --git a/src/main/kotlin/dev/vality/rateboss/config/properties/RatesProperties.kt b/src/main/kotlin/dev/vality/rateboss/config/properties/RatesProperties.kt index 9264c98..1030d3f 100644 --- a/src/main/kotlin/dev/vality/rateboss/config/properties/RatesProperties.kt +++ b/src/main/kotlin/dev/vality/rateboss/config/properties/RatesProperties.kt @@ -9,6 +9,7 @@ import java.time.ZoneId data class RatesProperties( val fixerJob: JobDescription, val cbrJob: JobDescription, + val nbkzJob: JobDescription, val source: RatesSourceProperties, ) @@ -27,6 +28,7 @@ data class CurrencyProperties( data class RatesSourceProperties( val fixer: FixerProperties, val cbr: CbrProperties, + val nbkz: NbkzProperties, ) data class FixerProperties( @@ -38,3 +40,9 @@ data class CbrProperties( val rootUrl: String, val timeZone: ZoneId, ) + +data class NbkzProperties( + val rootUrl: String, + val dateFormat: String, + val timeZone: ZoneId, +) diff --git a/src/main/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJob.kt b/src/main/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJob.kt new file mode 100644 index 0000000..50416bc --- /dev/null +++ b/src/main/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJob.kt @@ -0,0 +1,37 @@ +package dev.vality.rateboss.job + +import dev.vality.rateboss.extensions.getApplicationContext +import dev.vality.rateboss.source.ExchangeRateSource +import dev.vality.rateboss.source.ExchangeRateSourceException +import dev.vality.rateboss.source.impl.NbkzExchangeRateSource +import dev.vality.rateboss.source.model.ExchangeRates +import mu.KotlinLogging +import org.quartz.JobExecutionContext +import org.springframework.context.ApplicationContext +import org.springframework.retry.support.RetryTemplate + +private val log = KotlinLogging.logger {} + +class NbkzExchangeGrabberJob : AbstractExchangeGrabberJob() { + override fun executeInternal(context: JobExecutionContext) { + val applicationContext = context.getApplicationContext() + val currencySymbolCode = context.jobDetail.jobDataMap["currencySymbolCode"] as String + val currencyExponent = context.jobDetail.jobDataMap["currencyExponent"] as Int + val exchangeRateSource = applicationContext.getBean(NbkzExchangeRateSource::class.java) + val sourceId = exchangeRateSource.getSourceId() + log.info { "Process NbkzExchangeGrabberJob for $sourceId" } + val exchangeRates = getExchangeRates(applicationContext, exchangeRateSource, currencySymbolCode) + saveExchangeRates(applicationContext, currencySymbolCode, currencyExponent, exchangeRates, sourceId) + } + + private fun getExchangeRates( + applicationContext: ApplicationContext, + exchangeRateSource: ExchangeRateSource, + currencySymbolCode: String, + ): ExchangeRates { + val retryTemplate = applicationContext.getBean(RetryTemplate::class.java) + return retryTemplate.execute { + exchangeRateSource.getExchangeRate(currencySymbolCode) + } + } +} diff --git a/src/main/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberMasterJob.kt b/src/main/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberMasterJob.kt new file mode 100644 index 0000000..881a147 --- /dev/null +++ b/src/main/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberMasterJob.kt @@ -0,0 +1,18 @@ +package dev.vality.rateboss.job + +import dev.vality.rateboss.config.properties.RatesProperties +import dev.vality.rateboss.extensions.getApplicationContext +import org.quartz.JobExecutionContext +import org.quartz.Scheduler + +class NbkzExchangeGrabberMasterJob : AbstractExchangeGrabberMasterJob() { + override fun executeInternal(context: JobExecutionContext) { + val applicationContext = context.getApplicationContext() + val ratesProperties = applicationContext.getBean(RatesProperties::class.java) + val currencies = ratesProperties.nbkzJob.currencies + val schedulerFactoryBean = applicationContext.getBean(Scheduler::class.java) + launchJob(currencies, schedulerFactoryBean, NbkzExchangeGrabberJob::class.java, getJobName()) + } + + override fun getJobName(): String = "nbkzJob" +} diff --git a/src/main/kotlin/dev/vality/rateboss/job/constant/ExRateSources.kt b/src/main/kotlin/dev/vality/rateboss/job/constant/ExRateSources.kt index b0d71d1..b9c6915 100644 --- a/src/main/kotlin/dev/vality/rateboss/job/constant/ExRateSources.kt +++ b/src/main/kotlin/dev/vality/rateboss/job/constant/ExRateSources.kt @@ -3,4 +3,5 @@ package dev.vality.rateboss.job.constant object ExRateSources { const val FIXER = "fixer" const val CBR = "cbr" + const val NBKZ = "nbkz" } diff --git a/src/main/kotlin/dev/vality/rateboss/source/impl/NbkzExchangeRateSource.kt b/src/main/kotlin/dev/vality/rateboss/source/impl/NbkzExchangeRateSource.kt new file mode 100644 index 0000000..fc5c9f2 --- /dev/null +++ b/src/main/kotlin/dev/vality/rateboss/source/impl/NbkzExchangeRateSource.kt @@ -0,0 +1,79 @@ +package dev.vality.rateboss.source.impl + +import dev.vality.rateboss.client.nbkz.NbkzApiClient +import dev.vality.rateboss.config.properties.RatesProperties +import dev.vality.rateboss.job.constant.ExRateSources +import dev.vality.rateboss.source.ExchangeRateSource +import dev.vality.rateboss.source.ExchangeRateSourceException +import dev.vality.rateboss.source.model.ExchangeRates +import mu.KotlinLogging +import org.springframework.stereotype.Component +import org.w3c.dom.Element +import org.w3c.dom.Node +import java.io.ByteArrayInputStream +import java.math.BigDecimal +import java.nio.charset.StandardCharsets +import java.time.LocalDate +import java.time.ZoneOffset +import javax.xml.parsers.DocumentBuilderFactory + +private val log = KotlinLogging.logger {} + +@Component +class NbkzExchangeRateSource( + private val nbkzApiClient: NbkzApiClient, + private val ratesProperties: RatesProperties, +) : ExchangeRateSource { + override fun getExchangeRate(currencySymbolCode: String): ExchangeRates { + val timeZone = ratesProperties.source.nbkz.timeZone + val date = LocalDate.now(timeZone) + log.info { "Trying to get exchange rates from nbkz for currency=$currencySymbolCode, date=$date" } + val response = + try { + nbkzApiClient.getExchangeRates(date) + } catch (e: Exception) { + throw ExchangeRateSourceException("Remote client exception", e) + } + val rates = + try { + parseRates(response) + } catch (e: Exception) { + throw ExchangeRateSourceException("Failed to parse response from NbkzApi", e) + } + if (rates.isEmpty()) { + throw ExchangeRateSourceException("Unsuccessful response from NbkzApi") + } + val nextDayTimestamp = date.plusDays(1).atStartOfDay().toEpochSecond(ZoneOffset.UTC) + log.info { "Exchange rates from nbkz have been retrieved, date=$date, exchangeRates=$rates, targetTimestamp=$nextDayTimestamp" } + return ExchangeRates( + rates = rates, + timestamp = nextDayTimestamp, + ) + } + + override fun getSourceId(): String = ExRateSources.NBKZ + + private fun parseRates(xmlContent: String): Map { + val factory = DocumentBuilderFactory.newInstance() + val builder = factory.newDocumentBuilder() + val document = builder.parse(ByteArrayInputStream(xmlContent.toByteArray(StandardCharsets.UTF_8))) + val items = document.getElementsByTagName("item") + val rates = mutableMapOf() + for (i in 0 until items.length) { + val itemNode = items.item(i) + if (itemNode.nodeType != Node.ELEMENT_NODE) { + continue + } + val item = itemNode as Element + val titleNodes = item.getElementsByTagName("title") + val descriptionNodes = item.getElementsByTagName("description") + if (titleNodes.length == 0 || descriptionNodes.length == 0) { + continue + } + val currencyCode = titleNodes.item(0).textContent.trim { it <= ' ' } + val rateStr = descriptionNodes.item(0).textContent.trim { it <= ' ' } + rates[currencyCode] = BigDecimal(rateStr) + } + return rates + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 78f7ff9..1248cd3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -90,6 +90,13 @@ rates: currencies: - symbolCode: "RUB" exponent: 2 + nbkzJob: + jobCron: '0 0 0/1 * * ?' + jobKey: 'nbkz-exchange-rate-grabber-master-job' + jobTriggerName: 'nbkz-exchange-rate-grabber-master-job-trigger' + currencies: + - symbolCode: "KZT" + exponent: 2 source: fixer: rootUrl: https://api.apilayer.com/fixer/ @@ -97,3 +104,7 @@ rates: cbr: rootUrl: https://www.cbr.ru/scripts/XML_daily.asp timeZone: Europe/Moscow + nbkz: + rootUrl: https://nationalbank.kz/rss/get_rates.cfm + dateFormat: dd.MM.yyyy + timeZone: Asia/Almaty diff --git a/src/test/kotlin/dev/vality/rateboss/client/nbkz/NbkzApiClientTest.kt b/src/test/kotlin/dev/vality/rateboss/client/nbkz/NbkzApiClientTest.kt new file mode 100644 index 0000000..f75d3be --- /dev/null +++ b/src/test/kotlin/dev/vality/rateboss/client/nbkz/NbkzApiClientTest.kt @@ -0,0 +1,43 @@ +package dev.vality.rateboss.client.nbkz + +import dev.vality.rateboss.config.TestConfig +import dev.vality.rateboss.source.ExchangeRateSource +import dev.vality.rateboss.source.impl.NbkzExchangeRateSource +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Disabled +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Import +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.junit.jupiter.SpringExtension +import java.time.LocalDate + +@Disabled("integration test") +@ExtendWith(SpringExtension::class) +@ContextConfiguration(classes = [NbkzApiClient::class, NbkzExchangeRateSource::class]) +@Import(TestConfig::class) +class NbkzApiClientTest { + @Autowired + lateinit var nbkzApiClient: NbkzApiClient + + @Autowired + lateinit var nbkzExchangeRateSource: ExchangeRateSource + + @Test + fun getExchangeRates() { + val response = nbkzApiClient.getExchangeRates(LocalDate.now()) + + assertNotNull(response) + assertTrue(response.isNotBlank()) + } + + @Test + fun getExchangeRatesViaSource() { + val exchangeRates = nbkzExchangeRateSource.getExchangeRate("KZT") + + assertNotNull(exchangeRates) + assertTrue(exchangeRates.rates.isNotEmpty()) + } +} diff --git a/src/test/kotlin/dev/vality/rateboss/config/TestConfig.kt b/src/test/kotlin/dev/vality/rateboss/config/TestConfig.kt index 2d45379..691e729 100644 --- a/src/test/kotlin/dev/vality/rateboss/config/TestConfig.kt +++ b/src/test/kotlin/dev/vality/rateboss/config/TestConfig.kt @@ -26,9 +26,16 @@ class TestConfig { "cbr-name", listOf(CurrencyProperties("RUB", 2)), ), + JobDescription( + "nbkz-cron", + "nbkz-key", + "nbkz-name", + listOf(CurrencyProperties("KZT", 2)), + ), RatesSourceProperties( FixerProperties("url", "key"), CbrProperties("https://www.cbr.ru/scripts/XML_daily.asp", ZoneId.of("Europe/Moscow")), + NbkzProperties("https://nationalbank.kz/rss/get_rates.cfm", "dd.MM.yyyy", ZoneId.of("Asia/Almaty")), ), ) } diff --git a/src/test/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt b/src/test/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt new file mode 100644 index 0000000..1a60e41 --- /dev/null +++ b/src/test/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt @@ -0,0 +1,70 @@ +package dev.vality.rateboss.job + +import dev.vality.rateboss.ContainerConfiguration +import dev.vality.rateboss.config.properties.RatesProperties +import dev.vality.rateboss.service.ExchangeDaoService +import dev.vality.rateboss.source.impl.NbkzExchangeRateSource +import dev.vality.rateboss.source.model.ExchangeRates +import org.awaitility.Awaitility.await +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.atLeastOnce +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.quartz.Scheduler +import org.quartz.TriggerKey +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean +import java.math.BigDecimal +import java.time.Instant +import java.util.concurrent.TimeUnit + +@SpringBootTest( + properties = [ + "rates.nbkz-job.jobCron=0/5 * * * * ?", + "rates.nbkz-job.currencies.[0].symbolCode=KZT", + "rates.nbkz-job.currencies.[0].exponent=2", + ], +) +class NbkzExchangeGrabberJobTest : ContainerConfiguration() { + @MockitoSpyBean + lateinit var exchangeDaoService: ExchangeDaoService + + @MockitoBean + lateinit var nbkzExchangeRateSource: NbkzExchangeRateSource + + @Autowired + lateinit var scheduler: Scheduler + + @Autowired + @Qualifier("rates-dev.vality.rateboss.config.properties.RatesProperties") + lateinit var ratesProperties: RatesProperties + + @BeforeEach + fun setUp() { + scheduler.unscheduleJob(TriggerKey(ratesProperties.fixerJob.jobTriggerName)) + scheduler.unscheduleJob(TriggerKey(ratesProperties.cbrJob.jobTriggerName)) + } + + @Test + fun `test grabber job`() { + whenever(nbkzExchangeRateSource.getSourceId()).thenReturn("sourceId") + whenever(nbkzExchangeRateSource.getExchangeRate(any())).then { + ExchangeRates( + rates = + mapOf( + "USD" to BigDecimal.valueOf(470.12), + "EUR" to BigDecimal.valueOf(510.34), + ), + timestamp = Instant.now().epochSecond, + ) + } + await().atMost(5, TimeUnit.SECONDS).untilAsserted { + verify(exchangeDaoService, atLeastOnce()).saveExchangeRates(any()) + } + } +} diff --git a/src/test/kotlin/dev/vality/rateboss/service/ExRateServiceHandlerTest.kt b/src/test/kotlin/dev/vality/rateboss/service/ExRateServiceHandlerTest.kt index 12e46b6..ce45241 100644 --- a/src/test/kotlin/dev/vality/rateboss/service/ExRateServiceHandlerTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/service/ExRateServiceHandlerTest.kt @@ -8,6 +8,7 @@ import dev.vality.rateboss.ContainerConfiguration import dev.vality.rateboss.converter.Constants import dev.vality.rateboss.dao.domain.Tables import dev.vality.rateboss.dao.domain.tables.pojos.ExRate +import org.apache.commons.math3.fraction.BigFraction import org.jooq.DSLContext import org.junit.jupiter.api.Assertions.* import org.junit.jupiter.api.BeforeEach @@ -84,6 +85,40 @@ class ExRateServiceHandlerTest : ContainerConfiguration() { assertEquals(exRate.rationalP, result.exchange_rate.p) } + @Test + fun getExchangeRateDataFromNbkz() { + val sourceCurrency = "KZT" + val destinationCurrency = "USD" + val sourceId = "nbkz" + val exRate = + ExRate().apply { + sourceCurrencySymbolicCode = sourceCurrency + sourceCurrencyExponent = 2 + destinationCurrencySymbolicCode = destinationCurrency + destinationCurrencyExponent = 2 + rationalP = 4701200 + rationalQ = 10000 + rateTimestamp = LocalDateTime.now() + source = sourceId + } + dslContext + .insertInto(Tables.EX_RATE) + .set(dslContext.newRecord(Tables.EX_RATE, exRate)) + .execute() + val request = + GetCurrencyExchangeRateRequest() + .setCurrencyData( + CurrencyData() + .setSourceCurrency(sourceCurrency) + .setDestinationCurrency(destinationCurrency), + ) + + val result = exRateServiceHandler.getExchangeRateData(request) + + assertEquals(exRate.rationalQ, result.exchange_rate.q) + assertEquals(exRate.rationalP, result.exchange_rate.p) + } + @Test fun getEmptyExRateForConvertedAmount() { val conversionRequest = @@ -133,4 +168,43 @@ class ExRateServiceHandlerTest : ContainerConfiguration() { assertEquals(exRate.rationalP, result.p) assertEquals(exRate.rationalQ / conversionRequest.amount, result.q) } + + @Test + fun getSuccessConvertedAmountFromNbkz() { + val sourceCurrency = "KZT" + val destinationCurrency = "USD" + val sourceId = "nbkz" + val exRate = + ExRate().apply { + sourceCurrencySymbolicCode = sourceCurrency + sourceCurrencyExponent = 2 + destinationCurrencySymbolicCode = destinationCurrency + destinationCurrencyExponent = 2 + rationalP = 4701200 + rationalQ = 10000 + rateTimestamp = LocalDateTime.now().minusDays(1) + source = sourceId + } + dslContext + .insertInto(Tables.EX_RATE) + .set(dslContext.newRecord(Tables.EX_RATE, exRate)) + .execute() + val conversionRequest = + ConversionRequest() + .setAmount(100L) + .setDatetime( + exRate.rateTimestamp.plusMinutes(10).format(DateTimeFormatter.ofPattern(Constants.DATE_TIME_FORMAT)), + ).setDestination(destinationCurrency) + .setSource(sourceCurrency) + + val result = exRateServiceHandler.getConvertedAmount(sourceId, conversionRequest) + + val expected = + BigFraction(conversionRequest.amount) + .multiply(BigFraction(exRate.rationalP, exRate.rationalQ)) + + assertNotNull(result) + assertEquals(expected.numeratorAsLong, result.p) + assertEquals(expected.denominatorAsLong, result.q) + } } diff --git a/src/test/kotlin/dev/vality/rateboss/source/NbkzExchangeRateSourceTest.kt b/src/test/kotlin/dev/vality/rateboss/source/NbkzExchangeRateSourceTest.kt new file mode 100644 index 0000000..f62e16c --- /dev/null +++ b/src/test/kotlin/dev/vality/rateboss/source/NbkzExchangeRateSourceTest.kt @@ -0,0 +1,78 @@ +package dev.vality.rateboss.source + +import dev.vality.rateboss.client.nbkz.NbkzApiClient +import dev.vality.rateboss.config.TestConfig +import dev.vality.rateboss.source.impl.NbkzExchangeRateSource +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.ExtendWith +import org.mockito.kotlin.any +import org.mockito.kotlin.whenever +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.context.annotation.Import +import org.springframework.test.context.ContextConfiguration +import org.springframework.test.context.bean.override.mockito.MockitoBean +import org.springframework.test.context.junit.jupiter.SpringExtension +import org.springframework.web.client.ResourceAccessException + +@ExtendWith(SpringExtension::class) +@ContextConfiguration(classes = [NbkzApiClient::class, NbkzExchangeRateSource::class]) +@Import(TestConfig::class) +class NbkzExchangeRateSourceTest { + @Autowired + lateinit var exchangeRateSource: ExchangeRateSource + + @MockitoBean + lateinit var nbkzApiClient: NbkzApiClient + + @Test + fun getFailedExchangeRate() { + val currencySymbolCode = "KZT" + whenever(nbkzApiClient.getExchangeRates(any())).thenThrow(ResourceAccessException("Error")) + + val exception = + org.junit.jupiter.api.assertThrows { + exchangeRateSource.getExchangeRate(currencySymbolCode) + } + + assertEquals("Remote client exception", exception.message) + } + + @Test + fun getEmptyExchangeRate() { + val currencySymbolCode = "KZT" + whenever(nbkzApiClient.getExchangeRates(any())).thenReturn("") + + val exception = + org.junit.jupiter.api.assertThrows { + exchangeRateSource.getExchangeRate(currencySymbolCode) + } + + assertEquals("Unsuccessful response from NbkzApi", exception.message) + } + + @Test + fun getSuccessExchangeRate() { + val currencySymbolCode = "KZT" + whenever(nbkzApiClient.getExchangeRates(any())).thenReturn( + """ + + + + USD + 470.12 + + + + """.trimIndent(), + ) + + val exchangeRate = exchangeRateSource.getExchangeRate(currencySymbolCode) + + assertNotNull(exchangeRate) + assertTrue(exchangeRate.rates.isNotEmpty()) + assertTrue(exchangeRate.rates.containsKey("USD")) + } +}