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
-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.
-
+## 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"))
+ }
+}