diff --git a/README.md b/README.md index 85e82d6..23b4f76 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 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. -Supported sources today: Fixer, CBR, NBKZ. +Supported sources today: Fixer, CBR, BI, NBKZ. ## Service flow (actual) @@ -27,3 +27,8 @@ 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. + +## BI source notes + +- Source: Bank Indonesia +- Stored BI rate format: `target currency per 1000 IDR` (e.g. `USD per 1000 IDR`), i.e. `1000 / (jual_subkurslokal / nil_subkurslokal)`. diff --git a/src/main/kotlin/dev/vality/rateboss/client/bi/BiApiClient.kt b/src/main/kotlin/dev/vality/rateboss/client/bi/BiApiClient.kt new file mode 100644 index 0000000..57d064d --- /dev/null +++ b/src/main/kotlin/dev/vality/rateboss/client/bi/BiApiClient.kt @@ -0,0 +1,49 @@ +package dev.vality.rateboss.client.bi + +import dev.vality.rateboss.config.properties.RatesProperties +import org.springframework.http.HttpEntity +import org.springframework.http.HttpHeaders +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 BiApiClient( + private val restTemplate: RestTemplate, + private val ratesProperties: RatesProperties, +) { + fun getExchangeRates( + currencySymbolCode: String, + startDate: LocalDate, + endDate: LocalDate, + ): String { + val url = buildUrl(currencySymbolCode, startDate, endDate) + val requestHeaders = HttpHeaders() + requestHeaders["User-Agent"] = BROWSER_USER_AGENT + return restTemplate.exchange(url, HttpMethod.GET, HttpEntity(requestHeaders)).body!! + } + + private fun buildUrl( + currencySymbolCode: String, + startDate: LocalDate, + endDate: LocalDate, + ): String = + UriComponentsBuilder + .fromUriString(ratesProperties.source.bi.rootUrl) + .queryParam("mts", currencySymbolCode) + .queryParam("startdate", startDate.format(BI_DATE_FORMATTER)) + .queryParam("enddate", endDate.format(BI_DATE_FORMATTER)) + .build() + .toUriString() + + companion object { + private val BI_DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("dd-MM-yyyy") + private const val BROWSER_USER_AGENT = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36" + } +} diff --git a/src/main/kotlin/dev/vality/rateboss/config/JobConfig.kt b/src/main/kotlin/dev/vality/rateboss/config/JobConfig.kt index 7eb4386..996eed8 100644 --- a/src/main/kotlin/dev/vality/rateboss/config/JobConfig.kt +++ b/src/main/kotlin/dev/vality/rateboss/config/JobConfig.kt @@ -1,6 +1,7 @@ package dev.vality.rateboss.config import dev.vality.rateboss.config.properties.RatesProperties +import dev.vality.rateboss.job.BiExchangeGrabberMasterJob import dev.vality.rateboss.job.CbrExchangeGrabberMasterJob import dev.vality.rateboss.job.FixerExchangeGrabberMasterJob import dev.vality.rateboss.job.NbazExchangeGrabberMasterJob @@ -47,6 +48,16 @@ class JobConfig { schedulerFactoryBean.triggerJob(JobKey(ratesProperties.cbrJob.jobKey)) } } + val biJobTriggerName = ratesProperties.biJob.jobTriggerName + if (biJobTriggerName.isNotEmpty()) { + schedulerFactoryBean.addJob(biExchangeRateGrabberMasterJob(), true, true) + if (!schedulerFactoryBean.checkExists(TriggerKey(ratesProperties.biJob.jobTriggerName))) { + schedulerFactoryBean.scheduleJob(biExchangeRateGrabberMasterTrigger()) + } + if (runOnStartup) { + schedulerFactoryBean.triggerJob(JobKey(ratesProperties.biJob.jobKey)) + } + } val nbkzJobTriggerName = ratesProperties.nbkzJob.jobTriggerName if (nbkzJobTriggerName.isNotEmpty()) { schedulerFactoryBean.addJob(nbkzExchangeRateGrabberMasterJob(), true, true) @@ -119,6 +130,21 @@ class JobConfig { .withSchedule(CronScheduleBuilder.cronSchedule(ratesProperties.cbrJob.jobCron)) .build() + fun biExchangeRateGrabberMasterJob(): JobDetailImpl { + val jobDetail = JobDetailImpl() + jobDetail.key = JobKey(ratesProperties.biJob.jobKey) + jobDetail.jobClass = BiExchangeGrabberMasterJob::class.java + return jobDetail + } + + fun biExchangeRateGrabberMasterTrigger(): CronTrigger = + TriggerBuilder + .newTrigger() + .forJob(biExchangeRateGrabberMasterJob()) + .withIdentity(ratesProperties.biJob.jobTriggerName) + .withSchedule(CronScheduleBuilder.cronSchedule(ratesProperties.biJob.jobCron)) + .build() + fun nbkzExchangeRateGrabberMasterJob(): JobDetailImpl { val jobDetail = JobDetailImpl() jobDetail.key = JobKey(ratesProperties.nbkzJob.jobKey) 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 90a60d8..1f24ae9 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 biJob: JobDescription, val nbkzJob: JobDescription, val nbkrJob: JobDescription, val nbuzJob: JobDescription, @@ -31,6 +32,7 @@ data class CurrencyProperties( data class RatesSourceProperties( val fixer: FixerProperties, val cbr: CbrProperties, + val bi: BiProperties, val nbkz: NbkzProperties, val nbkr: NbkrProperties, val nbuz: NbuzProperties, @@ -47,6 +49,13 @@ data class CbrProperties( val timeZone: ZoneId, ) +data class BiProperties( + val rootUrl: String, + val timeZone: ZoneId, + val lookbackDays: Long, + val targetCurrencies: List, +) + data class NbkzProperties( val rootUrl: String, val dateFormat: String, diff --git a/src/main/kotlin/dev/vality/rateboss/job/BiExchangeGrabberJob.kt b/src/main/kotlin/dev/vality/rateboss/job/BiExchangeGrabberJob.kt new file mode 100644 index 0000000..f9a27e6 --- /dev/null +++ b/src/main/kotlin/dev/vality/rateboss/job/BiExchangeGrabberJob.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.BiExchangeRateSource +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 BiExchangeGrabberJob : 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(BiExchangeRateSource::class.java) + val sourceId = exchangeRateSource.getSourceId() + log.info { "Process BiExchangeGrabberJob 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/BiExchangeGrabberMasterJob.kt b/src/main/kotlin/dev/vality/rateboss/job/BiExchangeGrabberMasterJob.kt new file mode 100644 index 0000000..aae6683 --- /dev/null +++ b/src/main/kotlin/dev/vality/rateboss/job/BiExchangeGrabberMasterJob.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 BiExchangeGrabberMasterJob : AbstractExchangeGrabberMasterJob() { + override fun executeInternal(context: JobExecutionContext) { + val applicationContext = context.getApplicationContext() + val ratesProperties = applicationContext.getBean(RatesProperties::class.java) + val currencies = ratesProperties.biJob.currencies + val schedulerFactoryBean = applicationContext.getBean(Scheduler::class.java) + launchJob(currencies, schedulerFactoryBean, BiExchangeGrabberJob::class.java, getJobName()) + } + + override fun getJobName(): String = "biJob" +} 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 0874393..0c4e845 100644 --- a/src/main/kotlin/dev/vality/rateboss/job/constant/ExRateSources.kt +++ b/src/main/kotlin/dev/vality/rateboss/job/constant/ExRateSources.kt @@ -3,6 +3,7 @@ package dev.vality.rateboss.job.constant object ExRateSources { const val FIXER = "fixer" const val CBR = "cbr" + const val BI = "bi" const val NBKZ = "nbkz" const val NBKR = "nbkr" const val NBUZ = "nbuz" diff --git a/src/main/kotlin/dev/vality/rateboss/source/impl/BiExchangeRateSource.kt b/src/main/kotlin/dev/vality/rateboss/source/impl/BiExchangeRateSource.kt new file mode 100644 index 0000000..247cf5a --- /dev/null +++ b/src/main/kotlin/dev/vality/rateboss/source/impl/BiExchangeRateSource.kt @@ -0,0 +1,141 @@ +package dev.vality.rateboss.source.impl + +import dev.vality.rateboss.client.bi.BiApiClient +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.math.MathContext +import java.nio.charset.StandardCharsets +import java.time.LocalDate +import java.time.OffsetDateTime +import java.time.ZoneOffset +import javax.xml.parsers.DocumentBuilderFactory + +private val log = KotlinLogging.logger {} + +@Component +class BiExchangeRateSource( + private val biApiClient: BiApiClient, + private val ratesProperties: RatesProperties, +) : ExchangeRateSource { + override fun getExchangeRate(currencySymbolCode: String): ExchangeRates { + val timeZone = ratesProperties.source.bi.timeZone + val date = LocalDate.now(timeZone) + val lookbackDays = ratesProperties.source.bi.lookbackDays + val targetCurrencies = ratesProperties.source.bi.targetCurrencies + val startDate = date.minusDays(lookbackDays) + log.info { "Trying to get exchange rates from bi for currency=$currencySymbolCode, date=$date" } + + val rates = mutableMapOf() + for (targetCurrency in targetCurrencies) { + val response = + try { + biApiClient.getExchangeRates( + currencySymbolCode = targetCurrency, + startDate = startDate, + endDate = date, + ) + } catch (e: Exception) { + throw ExchangeRateSourceException("Remote client exception", e) + } + + val parsedRate = + try { + parseRate(response, targetCurrency) + } catch (e: Exception) { + throw ExchangeRateSourceException("Failed to parse response from BiApi", e) + } + if (parsedRate != null) { + rates[targetCurrency] = parsedRate + } + } + + if (rates.isEmpty()) { + throw ExchangeRateSourceException("Unsuccessful response from BiApi for period $startDate..$date") + } + + val nextDayTimestamp = date.plusDays(1).atStartOfDay().toEpochSecond(ZoneOffset.UTC) + log.info { "Exchange rates from bi have been retrieved, date=$date, exchangeRates=$rates, targetTimestamp=$nextDayTimestamp" } + + return ExchangeRates( + rates = rates, + timestamp = nextDayTimestamp, + ) + } + + override fun getSourceId(): String = ExRateSources.BI + + private fun parseRate( + xmlContent: String, + targetCurrency: String, + ): BigDecimal? { + val document = + DocumentBuilderFactory + .newInstance() + .newDocumentBuilder() + .parse(ByteArrayInputStream(xmlContent.toByteArray(StandardCharsets.UTF_8))) + + val tables = document.getElementsByTagName("Table") + var lastTimestamp: OffsetDateTime? = null + var idrPerTargetCurrency: BigDecimal? = null + + for (i in 0 until tables.length) { + val tableNode = tables.item(i) + if (tableNode.nodeType != Node.ELEMENT_NODE) { + continue + } + val table = tableNode as Element + val parsedDate = + extractTextByTag(table, "tgl_subkurslokal") + ?.let { OffsetDateTime.parse(it.trim()) } + ?: continue + val nominal = extractDecimalByTag(table, "nil_subkurslokal") + val sell = extractDecimalByTag(table, "jual_subkurslokal") + if (nominal == null || nominal <= BigDecimal.ZERO || sell == null || sell <= BigDecimal.ZERO) { + continue + } + val normalizedSell = sell.divide(nominal, MathContext.DECIMAL64) + if (lastTimestamp == null || parsedDate.isAfter(lastTimestamp)) { + lastTimestamp = parsedDate + idrPerTargetCurrency = normalizedSell + } + } + + val rate = idrPerTargetCurrency ?: return null + log.debug { "BI rate parsed for targetCurrency=$targetCurrency: idrPerTargetCurrency=$rate" } + // Use 1000 here instead of 1. + return BI_RATE_MULTIPLIER.divide(rate, MathContext.DECIMAL64) + } + + private fun extractDecimalByTag( + parent: Element, + tag: String, + ): BigDecimal? = + extractTextByTag(parent, tag) + ?.normalizeDecimal() + ?.toBigDecimalOrNull() + + private fun extractTextByTag( + parent: Element, + tag: String, + ): String? = parent.getElementsByTagName(tag).item(0)?.textContent + + private fun String?.normalizeDecimal(): String = + this + ?.replace(" ", "") + ?.replace(",", ".") + ?.trim() + .orEmpty() + + companion object { + private val BI_RATE_MULTIPLIER: BigDecimal = BigDecimal.valueOf(1000L) + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 51c5f07..7301a67 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -90,6 +90,13 @@ rates: currencies: - symbolCode: "RUB" exponent: 2 + biJob: + jobCron: '0 0 0/1 * * ?' + jobKey: 'bi-exchange-rate-grabber-master-job' + jobTriggerName: 'bi-exchange-rate-grabber-master-job-trigger' + currencies: + - symbolCode: "IDR" + exponent: 2 nbkzJob: jobCron: '0 0 0/1 * * ?' jobKey: 'nbkz-exchange-rate-grabber-master-job' @@ -125,6 +132,11 @@ rates: cbr: rootUrl: https://www.cbr.ru/scripts/XML_daily.asp timeZone: Europe/Moscow + bi: + rootUrl: https://www.bi.go.id/biwebservice/wskursbi.asmx/getSubKursLokal3 + timeZone: Asia/Jakarta + lookbackDays: 7 + targetCurrencies: [ "USD", "EUR" ] nbkz: rootUrl: https://nationalbank.kz/rss/get_rates.cfm dateFormat: dd.MM.yyyy diff --git a/src/test/kotlin/dev/vality/rateboss/config/TestConfig.kt b/src/test/kotlin/dev/vality/rateboss/config/TestConfig.kt index ff7490b..638da36 100644 --- a/src/test/kotlin/dev/vality/rateboss/config/TestConfig.kt +++ b/src/test/kotlin/dev/vality/rateboss/config/TestConfig.kt @@ -41,6 +41,12 @@ class TestConfig { "cbr-name", listOf(CurrencyProperties("RUB", 2)), ), + JobDescription( + "bi-cron", + "bi-key", + "bi-name", + listOf(CurrencyProperties("IDR", 2)), + ), JobDescription( "nbkz-cron", "nbkz-key", @@ -68,6 +74,12 @@ class TestConfig { RatesSourceProperties( FixerProperties("url", "key"), CbrProperties("https://www.cbr.ru/scripts/XML_daily.asp", ZoneId.of("Europe/Moscow")), + BiProperties( + "https://www.bi.go.id/biwebservice/wskursbi.asmx/getSubKursLokal3", + ZoneId.of("Asia/Jakarta"), + 7, + listOf("USD", "EUR"), + ), NbkzProperties("https://nationalbank.kz/rss/get_rates.cfm", "dd.MM.yyyy", ZoneId.of("Asia/Almaty")), NbkrProperties("https://www.nbkr.kg/XML/daily.xml", ZoneId.of("Asia/Bishkek")), NbuzProperties( diff --git a/src/test/kotlin/dev/vality/rateboss/job/CbrExchangeGrabberJobTest.kt b/src/test/kotlin/dev/vality/rateboss/job/CbrExchangeGrabberJobTest.kt index 11e9e97..8ee2560 100644 --- a/src/test/kotlin/dev/vality/rateboss/job/CbrExchangeGrabberJobTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/job/CbrExchangeGrabberJobTest.kt @@ -51,6 +51,7 @@ class CbrExchangeGrabberJobTest : ContainerConfiguration() { scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkzJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkrJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbuzJob.jobTriggerName)) + scheduler.unscheduleJob(TriggerKey(ratesProperties.biJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbazJob.jobTriggerName)) } diff --git a/src/test/kotlin/dev/vality/rateboss/job/FixerExchangeGrabberJobTest.kt b/src/test/kotlin/dev/vality/rateboss/job/FixerExchangeGrabberJobTest.kt index 826113d..c582439 100644 --- a/src/test/kotlin/dev/vality/rateboss/job/FixerExchangeGrabberJobTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/job/FixerExchangeGrabberJobTest.kt @@ -62,6 +62,7 @@ class FixerExchangeGrabberJobTest : ContainerConfiguration() { scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkzJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkrJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbuzJob.jobTriggerName)) + scheduler.unscheduleJob(TriggerKey(ratesProperties.biJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbazJob.jobTriggerName)) } diff --git a/src/test/kotlin/dev/vality/rateboss/job/NbazExchangeGrabberJobTest.kt b/src/test/kotlin/dev/vality/rateboss/job/NbazExchangeGrabberJobTest.kt index 79a61e3..4e49ed0 100644 --- a/src/test/kotlin/dev/vality/rateboss/job/NbazExchangeGrabberJobTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/job/NbazExchangeGrabberJobTest.kt @@ -52,6 +52,7 @@ class NbazExchangeGrabberJobTest : ContainerConfiguration() { scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkzJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkrJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbuzJob.jobTriggerName)) + scheduler.unscheduleJob(TriggerKey(ratesProperties.biJob.jobTriggerName)) } @Test diff --git a/src/test/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJobTest.kt b/src/test/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJobTest.kt index 4e22fd2..b42766b 100644 --- a/src/test/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJobTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJobTest.kt @@ -51,6 +51,7 @@ class NbkrExchangeGrabberJobTest : ContainerConfiguration() { scheduler.unscheduleJob(TriggerKey(ratesProperties.cbrJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkzJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbuzJob.jobTriggerName)) + scheduler.unscheduleJob(TriggerKey(ratesProperties.biJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbazJob.jobTriggerName)) } diff --git a/src/test/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt b/src/test/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt index 36dd2a9..c56a0b8 100644 --- a/src/test/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt @@ -51,6 +51,7 @@ class NbkzExchangeGrabberJobTest : ContainerConfiguration() { scheduler.unscheduleJob(TriggerKey(ratesProperties.cbrJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkrJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbuzJob.jobTriggerName)) + scheduler.unscheduleJob(TriggerKey(ratesProperties.biJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbazJob.jobTriggerName)) } diff --git a/src/test/kotlin/dev/vality/rateboss/job/NbuzExchangeGrabberJobTest.kt b/src/test/kotlin/dev/vality/rateboss/job/NbuzExchangeGrabberJobTest.kt index 4d48e92..090bf2b 100644 --- a/src/test/kotlin/dev/vality/rateboss/job/NbuzExchangeGrabberJobTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/job/NbuzExchangeGrabberJobTest.kt @@ -51,6 +51,7 @@ class NbuzExchangeGrabberJobTest : ContainerConfiguration() { scheduler.unscheduleJob(TriggerKey(ratesProperties.cbrJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkzJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkrJob.jobTriggerName)) + scheduler.unscheduleJob(TriggerKey(ratesProperties.biJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.nbazJob.jobTriggerName)) } diff --git a/src/test/kotlin/dev/vality/rateboss/source/BiExchangeRateSourceTest.kt b/src/test/kotlin/dev/vality/rateboss/source/BiExchangeRateSourceTest.kt new file mode 100644 index 0000000..45ee2ab --- /dev/null +++ b/src/test/kotlin/dev/vality/rateboss/source/BiExchangeRateSourceTest.kt @@ -0,0 +1,115 @@ +package dev.vality.rateboss.source + +import dev.vality.rateboss.client.bi.BiApiClient +import dev.vality.rateboss.config.TestConfig +import dev.vality.rateboss.source.impl.BiExchangeRateSource +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 +import java.math.BigDecimal + +@ExtendWith(SpringExtension::class) +@ContextConfiguration(classes = [BiApiClient::class, BiExchangeRateSource::class]) +@Import(TestConfig::class) +class BiExchangeRateSourceTest { + @Autowired + lateinit var exchangeRateSource: ExchangeRateSource + + @MockitoBean + lateinit var biApiClient: BiApiClient + + @Test + fun getFailedExchangeRate() { + val currencySymbolCode = "IDR" + whenever(biApiClient.getExchangeRates(any(), any(), 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 = "IDR" + whenever(biApiClient.getExchangeRates(any(), any(), any())).thenReturn("") + + val exception = + org.junit.jupiter.api.assertThrows { + exchangeRateSource.getExchangeRate(currencySymbolCode) + } + + assertTrue(exception.message!!.startsWith("Unsuccessful response from BiApi for period")) + } + + @Test + fun getSuccessExchangeRate() { + val currencySymbolCode = "IDR" + whenever(biApiClient.getExchangeRates(any(), any(), any())).thenReturn( + """ + + + + + 1.00 + 17654.28 + 17831.72 + 2026-05-26T00:00:00+07:00 +
+
+
+
+ """.trimIndent(), + ) + + val exchangeRate = exchangeRateSource.getExchangeRate(currencySymbolCode) + + assertNotNull(exchangeRate) + assertTrue(exchangeRate.rates.isNotEmpty()) + assertTrue(exchangeRate.rates.containsKey("USD")) + assertEquals(BigDecimal("0.05607983974624994"), exchangeRate.rates["USD"]) + } + + @Test + fun getSuccessExchangeRateWhenCurrentDayMissing() { + val currencySymbolCode = "IDR" + whenever(biApiClient.getExchangeRates(any(), any(), any())).thenReturn( + """ + + + + + 1.00 + + + 2026-05-27T00:00:00+07:00 +
+ + 1.00 + 17654.28 + 17831.72 + 2026-05-26T00:00:00+07:00 +
+
+
+
+ """.trimIndent(), + ) + + val exchangeRate = exchangeRateSource.getExchangeRate(currencySymbolCode) + + assertEquals(BigDecimal("0.05607983974624994"), exchangeRate.rates["USD"]) + } +}