From 1c2784bfb5ecc7ec1cb8dae66d5e4abeab53fc8e Mon Sep 17 00:00:00 2001 From: dmitriibaikov Date: Mon, 4 May 2026 18:24:57 +0300 Subject: [PATCH 1/3] TECH-1: Add KGS --- pom.xml | 8 +- .../rateboss/client/nbkr/NbkrApiClient.kt | 31 +++++ .../dev/vality/rateboss/config/AppConfig.kt | 11 ++ .../dev/vality/rateboss/config/JobConfig.kt | 26 ++++ .../config/properties/RatesProperties.kt | 7 ++ .../rateboss/job/NbkrExchangeGrabberJob.kt | 37 ++++++ .../job/NbkrExchangeGrabberMasterJob.kt | 18 +++ .../rateboss/job/constant/ExRateSources.kt | 1 + .../rateboss/model/NbkrDailyRatesXml.kt | 26 ++++ .../source/impl/NbkrExchangeRateSource.kt | 89 ++++++++++++++ src/main/resources/application.yml | 10 ++ .../rateboss/client/nbkr/NbkrApiClientTest.kt | 42 +++++++ .../dev/vality/rateboss/config/TestConfig.kt | 11 ++ .../job/NbkrExchangeGrabberJobTest.kt | 71 +++++++++++ .../job/NbkzExchangeGrabberJobTest.kt | 1 + .../source/NbkrExchangeRateSourceTest.kt | 114 ++++++++++++++++++ 16 files changed, 501 insertions(+), 2 deletions(-) create mode 100644 src/main/kotlin/dev/vality/rateboss/client/nbkr/NbkrApiClient.kt create mode 100644 src/main/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJob.kt create mode 100644 src/main/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberMasterJob.kt create mode 100644 src/main/kotlin/dev/vality/rateboss/model/NbkrDailyRatesXml.kt create mode 100644 src/main/kotlin/dev/vality/rateboss/source/impl/NbkrExchangeRateSource.kt create mode 100644 src/test/kotlin/dev/vality/rateboss/client/nbkr/NbkrApiClientTest.kt create mode 100644 src/test/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJobTest.kt create mode 100644 src/test/kotlin/dev/vality/rateboss/source/NbkrExchangeRateSourceTest.kt diff --git a/pom.xml b/pom.xml index d134209..9a57e3d 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ dev.vality service-parent-pom - 3.1.4 + 3.1.7 rate-boss 0.0.1-SNAPSHOT @@ -16,7 +16,7 @@ 8023 21 2.1.21 - 1.17.3 + 1.21.4 ${server.port} ${management.port} 5432 postgres @@ -57,6 +57,10 @@ com.fasterxml.jackson.module jackson-module-kotlin + + com.fasterxml.jackson.dataformat + jackson-dataformat-xml + org.jetbrains.kotlin kotlin-reflect diff --git a/src/main/kotlin/dev/vality/rateboss/client/nbkr/NbkrApiClient.kt b/src/main/kotlin/dev/vality/rateboss/client/nbkr/NbkrApiClient.kt new file mode 100644 index 0000000..87a50cb --- /dev/null +++ b/src/main/kotlin/dev/vality/rateboss/client/nbkr/NbkrApiClient.kt @@ -0,0 +1,31 @@ +package dev.vality.rateboss.client.nbkr + +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 java.nio.charset.Charset + +@Component +class NbkrApiClient( + private val restTemplate: RestTemplate, + private val ratesProperties: RatesProperties, +) { + fun getExchangeRates(): String { + val url = ratesProperties.source.nbkr.rootUrl + val bytes = + restTemplate + .exchange( + url, + HttpMethod.GET, + HttpEntity.EMPTY, + ByteArray::class.java, + ).body ?: byteArrayOf() + return String(bytes, NBKR_XML_CHARSET) + } + + companion object { + private val NBKR_XML_CHARSET: Charset = Charset.forName("Windows-1251") + } +} diff --git a/src/main/kotlin/dev/vality/rateboss/config/AppConfig.kt b/src/main/kotlin/dev/vality/rateboss/config/AppConfig.kt index bed1d29..50c19fa 100644 --- a/src/main/kotlin/dev/vality/rateboss/config/AppConfig.kt +++ b/src/main/kotlin/dev/vality/rateboss/config/AppConfig.kt @@ -1,5 +1,8 @@ package dev.vality.rateboss.config +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.dataformat.xml.XmlMapper +import com.fasterxml.jackson.module.kotlin.kotlinModule import org.springframework.beans.factory.annotation.Value import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Configuration @@ -13,6 +16,14 @@ class AppConfig { @Bean fun restTemplate() = RestTemplate() + @Bean + fun nbkrXmlMapper(): XmlMapper = + XmlMapper + .builder() + .addModule(kotlinModule()) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .build() + @Bean fun retryTemplate( @Value("\${retryTemplate.backOffPeriod}") backOffPeriod: Long, diff --git a/src/main/kotlin/dev/vality/rateboss/config/JobConfig.kt b/src/main/kotlin/dev/vality/rateboss/config/JobConfig.kt index 5dc65c6..8db168d 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.NbkrExchangeGrabberMasterJob import dev.vality.rateboss.job.NbkzExchangeGrabberMasterJob import org.quartz.* import org.quartz.impl.JobDetailImpl @@ -54,6 +55,16 @@ class JobConfig { schedulerFactoryBean.triggerJob(JobKey(ratesProperties.nbkzJob.jobKey)) } } + val nbkrJobTriggerName = ratesProperties.nbkrJob.jobTriggerName + if (nbkrJobTriggerName.isNotEmpty()) { + schedulerFactoryBean.addJob(nbkrExchangeRateGrabberMasterJob(), true, true) + if (!schedulerFactoryBean.checkExists(TriggerKey(ratesProperties.nbkrJob.jobTriggerName))) { + schedulerFactoryBean.scheduleJob(nbkrExchangeRateGrabberMasterTrigger()) + } + if (runOnStartup) { + schedulerFactoryBean.triggerJob(JobKey(ratesProperties.nbkrJob.jobKey)) + } + } } fun fixerExchangeRateGrabberMasterJob(): JobDetailImpl { @@ -100,4 +111,19 @@ class JobConfig { .withIdentity(ratesProperties.nbkzJob.jobTriggerName) .withSchedule(CronScheduleBuilder.cronSchedule(ratesProperties.nbkzJob.jobCron)) .build() + + fun nbkrExchangeRateGrabberMasterJob(): JobDetailImpl { + val jobDetail = JobDetailImpl() + jobDetail.key = JobKey(ratesProperties.nbkrJob.jobKey) + jobDetail.jobClass = NbkrExchangeGrabberMasterJob::class.java + return jobDetail + } + + fun nbkrExchangeRateGrabberMasterTrigger(): CronTrigger = + TriggerBuilder + .newTrigger() + .forJob(nbkrExchangeRateGrabberMasterJob()) + .withIdentity(ratesProperties.nbkrJob.jobTriggerName) + .withSchedule(CronScheduleBuilder.cronSchedule(ratesProperties.nbkrJob.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 1030d3f..95e9bab 100644 --- a/src/main/kotlin/dev/vality/rateboss/config/properties/RatesProperties.kt +++ b/src/main/kotlin/dev/vality/rateboss/config/properties/RatesProperties.kt @@ -10,6 +10,7 @@ data class RatesProperties( val fixerJob: JobDescription, val cbrJob: JobDescription, val nbkzJob: JobDescription, + val nbkrJob: JobDescription, val source: RatesSourceProperties, ) @@ -29,6 +30,7 @@ data class RatesSourceProperties( val fixer: FixerProperties, val cbr: CbrProperties, val nbkz: NbkzProperties, + val nbkr: NbkrProperties, ) data class FixerProperties( @@ -46,3 +48,8 @@ data class NbkzProperties( val dateFormat: String, val timeZone: ZoneId, ) + +data class NbkrProperties( + val rootUrl: String, + val timeZone: ZoneId, +) diff --git a/src/main/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJob.kt b/src/main/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJob.kt new file mode 100644 index 0000000..5e0b4be --- /dev/null +++ b/src/main/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJob.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.NbkrExchangeRateSource +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 NbkrExchangeGrabberJob : 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(NbkrExchangeRateSource::class.java) + val sourceId = exchangeRateSource.getSourceId() + log.info { "Process NbkrExchangeGrabberJob 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/NbkrExchangeGrabberMasterJob.kt b/src/main/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberMasterJob.kt new file mode 100644 index 0000000..7125485 --- /dev/null +++ b/src/main/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberMasterJob.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 NbkrExchangeGrabberMasterJob : AbstractExchangeGrabberMasterJob() { + override fun executeInternal(context: JobExecutionContext) { + val applicationContext = context.getApplicationContext() + val ratesProperties = applicationContext.getBean(RatesProperties::class.java) + val currencies = ratesProperties.nbkrJob.currencies + val schedulerFactoryBean = applicationContext.getBean(Scheduler::class.java) + launchJob(currencies, schedulerFactoryBean, NbkrExchangeGrabberJob::class.java, getJobName()) + } + + override fun getJobName(): String = "nbkrJob" +} 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 b9c6915..19968ae 100644 --- a/src/main/kotlin/dev/vality/rateboss/job/constant/ExRateSources.kt +++ b/src/main/kotlin/dev/vality/rateboss/job/constant/ExRateSources.kt @@ -4,4 +4,5 @@ object ExRateSources { const val FIXER = "fixer" const val CBR = "cbr" const val NBKZ = "nbkz" + const val NBKR = "nbkr" } diff --git a/src/main/kotlin/dev/vality/rateboss/model/NbkrDailyRatesXml.kt b/src/main/kotlin/dev/vality/rateboss/model/NbkrDailyRatesXml.kt new file mode 100644 index 0000000..e6a81ca --- /dev/null +++ b/src/main/kotlin/dev/vality/rateboss/model/NbkrDailyRatesXml.kt @@ -0,0 +1,26 @@ +package dev.vality.rateboss.model + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlElementWrapper +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement + +@JacksonXmlRootElement(localName = "CurrencyRates") +@JsonIgnoreProperties(ignoreUnknown = true) +data class NbkrDailyRatesXml( + @field:JacksonXmlProperty(isAttribute = true, localName = "Date") + val date: String? = null, + @field:JacksonXmlElementWrapper(useWrapping = false) + @field:JacksonXmlProperty(localName = "Currency") + val currencies: List? = null, +) + +@JsonIgnoreProperties(ignoreUnknown = true) +data class NbkrCurrencyXml( + @field:JacksonXmlProperty(isAttribute = true, localName = "ISOCode") + val isoCode: String? = null, + @field:JacksonXmlProperty(localName = "Nominal") + val nominal: String? = null, + @field:JacksonXmlProperty(localName = "Value") + val value: String? = null, +) diff --git a/src/main/kotlin/dev/vality/rateboss/source/impl/NbkrExchangeRateSource.kt b/src/main/kotlin/dev/vality/rateboss/source/impl/NbkrExchangeRateSource.kt new file mode 100644 index 0000000..14700f8 --- /dev/null +++ b/src/main/kotlin/dev/vality/rateboss/source/impl/NbkrExchangeRateSource.kt @@ -0,0 +1,89 @@ +package dev.vality.rateboss.source.impl + +import com.fasterxml.jackson.dataformat.xml.XmlMapper +import dev.vality.rateboss.client.nbkr.NbkrApiClient +import dev.vality.rateboss.config.properties.RatesProperties +import dev.vality.rateboss.job.constant.ExRateSources +import dev.vality.rateboss.model.NbkrDailyRatesXml +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 java.math.BigDecimal +import java.time.LocalDate +import java.time.ZoneOffset +import java.time.format.DateTimeFormatter + +private val log = KotlinLogging.logger {} + +@Component +class NbkrExchangeRateSource( + private val nbkrApiClient: NbkrApiClient, + private val ratesProperties: RatesProperties, + private val xmlMapper: XmlMapper, +) : ExchangeRateSource { + override fun getExchangeRate(currencySymbolCode: String): ExchangeRates { + val timeZone = ratesProperties.source.nbkr.timeZone + val date = LocalDate.now(timeZone) + log.info { "Trying to get exchange rates from nbkr for currency=$currencySymbolCode, date=$date" } + val parsed = fetchDailyRates() + val rates = buildRatesMap(parsed) + if (rates.isEmpty()) { + throw ExchangeRateSourceException("Unsuccessful response from NbkrApi") + } + val responseDate = + try { + parseDate(parsed) + } catch (e: Exception) { + throw ExchangeRateSourceException("Failed to parse date from NbkrApi", e) + } + val nextDayTimestamp = responseDate.plusDays(1).atStartOfDay().toEpochSecond(ZoneOffset.UTC) + log.info { + "Exchange rates from nbkr have been retrieved, date=$responseDate, " + + "exchangeRates=$rates, targetTimestamp=$nextDayTimestamp" + } + return ExchangeRates( + rates = rates, + timestamp = nextDayTimestamp, + ) + } + + override fun getSourceId(): String = ExRateSources.NBKR + + private fun fetchDailyRates(): NbkrDailyRatesXml = + try { + xmlMapper.readValue(nbkrApiClient.getExchangeRates(), NbkrDailyRatesXml::class.java) + } catch (e: Exception) { + throw ExchangeRateSourceException("Failed to get daily rates", e) + } + + private fun buildRatesMap(root: NbkrDailyRatesXml): Map { + val rates = mutableMapOf() + for (item in root.currencies.orEmpty()) { + val isoCode = item.isoCode?.trim { it <= ' ' }.orEmpty() + val nominal = item.nominal?.trim { it <= ' ' }?.toBigDecimalOrNull() + val value = + item.value + ?.trim { it <= ' ' } + ?.replace(",", ".") + ?.toBigDecimalOrNull() + if (isoCode.isBlank() || nominal == null || value == null || nominal.compareTo(BigDecimal.ZERO) == 0) { + log.debug { "Skip malformed NBKR currency record: $item" } + continue + } + rates[isoCode] = value.divide(nominal) + } + return rates + } + + private fun parseDate(root: NbkrDailyRatesXml): LocalDate { + val dateAttr = root.date?.trim { it <= ' ' }.orEmpty() + require(dateAttr.isNotBlank()) { "Missing Date on CurrencyRates" } + return LocalDate.parse(dateAttr, DATE_FORMATTER) + } + + companion object { + private val DATE_FORMATTER: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy") + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1248cd3..536a63a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -97,6 +97,13 @@ rates: currencies: - symbolCode: "KZT" exponent: 2 + nbkrJob: + jobCron: '0 0 0/1 * * ?' + jobKey: 'nbkr-exchange-rate-grabber-master-job' + jobTriggerName: 'nbkr-exchange-rate-grabber-master-job-trigger' + currencies: + - symbolCode: "KGS" + exponent: 2 source: fixer: rootUrl: https://api.apilayer.com/fixer/ @@ -108,3 +115,6 @@ rates: rootUrl: https://nationalbank.kz/rss/get_rates.cfm dateFormat: dd.MM.yyyy timeZone: Asia/Almaty + nbkr: + rootUrl: https://www.nbkr.kg/XML/daily.xml + timeZone: Asia/Bishkek diff --git a/src/test/kotlin/dev/vality/rateboss/client/nbkr/NbkrApiClientTest.kt b/src/test/kotlin/dev/vality/rateboss/client/nbkr/NbkrApiClientTest.kt new file mode 100644 index 0000000..2ed1a69 --- /dev/null +++ b/src/test/kotlin/dev/vality/rateboss/client/nbkr/NbkrApiClientTest.kt @@ -0,0 +1,42 @@ +package dev.vality.rateboss.client.nbkr + +import dev.vality.rateboss.config.TestConfig +import dev.vality.rateboss.source.ExchangeRateSource +import dev.vality.rateboss.source.impl.NbkrExchangeRateSource +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 + +@Disabled("integration test") +@ExtendWith(SpringExtension::class) +@ContextConfiguration(classes = [NbkrApiClient::class, NbkrExchangeRateSource::class]) +@Import(TestConfig::class) +class NbkrApiClientTest { + @Autowired + lateinit var nbkrApiClient: NbkrApiClient + + @Autowired + lateinit var nbkrExchangeRateSource: ExchangeRateSource + + @Test + fun getExchangeRates() { + val response = nbkrApiClient.getExchangeRates() + + assertNotNull(response) + assertTrue(response.isNotBlank()) + } + + @Test + fun getExchangeRatesViaSource() { + val exchangeRates = nbkrExchangeRateSource.getExchangeRate("KGS") + + 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 691e729..4307963 100644 --- a/src/test/kotlin/dev/vality/rateboss/config/TestConfig.kt +++ b/src/test/kotlin/dev/vality/rateboss/config/TestConfig.kt @@ -1,5 +1,6 @@ package dev.vality.rateboss.config +import com.fasterxml.jackson.dataformat.xml.XmlMapper import dev.vality.rateboss.config.properties.* import org.springframework.boot.test.context.TestConfiguration import org.springframework.context.annotation.Bean @@ -11,6 +12,9 @@ class TestConfig { @Bean fun testRestTemplate() = RestTemplate() + @Bean + fun nbkrXmlMapper(): XmlMapper = nbkrXmlMapper() + @Bean fun testRatesProperties(): RatesProperties = RatesProperties( @@ -32,10 +36,17 @@ class TestConfig { "nbkz-name", listOf(CurrencyProperties("KZT", 2)), ), + JobDescription( + "nbkr-cron", + "nbkr-key", + "nbkr-name", + listOf(CurrencyProperties("KGS", 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")), + NbkrProperties("https://www.nbkr.kg/XML/daily.xml", ZoneId.of("Asia/Bishkek")), ), ) } diff --git a/src/test/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJobTest.kt b/src/test/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJobTest.kt new file mode 100644 index 0000000..0be2122 --- /dev/null +++ b/src/test/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJobTest.kt @@ -0,0 +1,71 @@ +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.NbkrExchangeRateSource +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.nbkr-job.jobCron=0/5 * * * * ?", + "rates.nbkr-job.currencies.[0].symbolCode=KGS", + "rates.nbkr-job.currencies.[0].exponent=2", + ], +) +class NbkrExchangeGrabberJobTest : ContainerConfiguration() { + @MockitoSpyBean + lateinit var exchangeDaoService: ExchangeDaoService + + @MockitoBean + lateinit var nbkrExchangeRateSource: NbkrExchangeRateSource + + @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)) + scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkzJob.jobTriggerName)) + } + + @Test + fun `test grabber job`() { + whenever(nbkrExchangeRateSource.getSourceId()).thenReturn("sourceId") + whenever(nbkrExchangeRateSource.getExchangeRate(any())).then { + ExchangeRates( + rates = + mapOf( + "USD" to BigDecimal.valueOf(87.42), + "EUR" to BigDecimal.valueOf(102.10), + ), + timestamp = Instant.now().epochSecond, + ) + } + await().atMost(5, TimeUnit.SECONDS).untilAsserted { + verify(exchangeDaoService, atLeastOnce()).saveExchangeRates(any()) + } + } +} diff --git a/src/test/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt b/src/test/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt index 1a60e41..c7453dc 100644 --- a/src/test/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt @@ -48,6 +48,7 @@ class NbkzExchangeGrabberJobTest : ContainerConfiguration() { fun setUp() { scheduler.unscheduleJob(TriggerKey(ratesProperties.fixerJob.jobTriggerName)) scheduler.unscheduleJob(TriggerKey(ratesProperties.cbrJob.jobTriggerName)) + scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkrJob.jobTriggerName)) } @Test diff --git a/src/test/kotlin/dev/vality/rateboss/source/NbkrExchangeRateSourceTest.kt b/src/test/kotlin/dev/vality/rateboss/source/NbkrExchangeRateSourceTest.kt new file mode 100644 index 0000000..fd280ff --- /dev/null +++ b/src/test/kotlin/dev/vality/rateboss/source/NbkrExchangeRateSourceTest.kt @@ -0,0 +1,114 @@ +package dev.vality.rateboss.source + +import dev.vality.rateboss.client.nbkr.NbkrApiClient +import dev.vality.rateboss.config.TestConfig +import dev.vality.rateboss.source.impl.NbkrExchangeRateSource +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.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 = [NbkrApiClient::class, NbkrExchangeRateSource::class]) +@Import(TestConfig::class) +class NbkrExchangeRateSourceTest { + @Autowired + lateinit var exchangeRateSource: ExchangeRateSource + + @MockitoBean + lateinit var nbkrApiClient: NbkrApiClient + + @Test + fun getFailedExchangeRate() { + val currencySymbolCode = "KGS" + whenever(nbkrApiClient.getExchangeRates()).thenThrow(ResourceAccessException("Error")) + + val exception = + org.junit.jupiter.api.assertThrows { + exchangeRateSource.getExchangeRate(currencySymbolCode) + } + + assertEquals("Remote client exception", exception.message) + } + + @Test + fun getEmptyExchangeRate() { + val currencySymbolCode = "KGS" + whenever(nbkrApiClient.getExchangeRates()).thenReturn( + """ + + + + """.trimIndent(), + ) + + val exception = + org.junit.jupiter.api.assertThrows { + exchangeRateSource.getExchangeRate(currencySymbolCode) + } + + assertEquals("Unsuccessful response from NbkrApi", exception.message) + } + + @Test + fun getSuccessExchangeRate() { + val currencySymbolCode = "KGS" + whenever(nbkrApiClient.getExchangeRates()).thenReturn( + """ + + + + 1 + 87,4205 + + + 10 + 1021,028 + + + """.trimIndent(), + ) + + val exchangeRate = exchangeRateSource.getExchangeRate(currencySymbolCode) + + assertNotNull(exchangeRate) + assertTrue(exchangeRate.rates.isNotEmpty()) + assertTrue(exchangeRate.rates.containsKey("USD")) + assertEquals(BigDecimal("87.4205"), exchangeRate.rates["USD"]) + assertEquals(BigDecimal("102.1028"), exchangeRate.rates["EUR"]) + } + + @Test + fun getSuccessExchangeRateWithMalformedCurrencyRecord() { + val currencySymbolCode = "KGS" + whenever(nbkrApiClient.getExchangeRates()).thenReturn( + """ + + + + 1 + 87,4205 + + + 0 + 100,0000 + + + """.trimIndent(), + ) + + val exchangeRate = exchangeRateSource.getExchangeRate(currencySymbolCode) + + assertEquals(BigDecimal("87.4205"), exchangeRate.rates["USD"]) + assertTrue(!exchangeRate.rates.containsKey("BROKEN")) + } +} From 733678293b5494499e2b2c9563e653956e300974 Mon Sep 17 00:00:00 2001 From: dmitriibaikov Date: Tue, 5 May 2026 13:46:23 +0300 Subject: [PATCH 2/3] TECH-1: Add KGS [tests fix] --- src/test/kotlin/dev/vality/rateboss/config/TestConfig.kt | 9 ++++++++- .../dev/vality/rateboss/job/CbrExchangeGrabberJobTest.kt | 6 +++++- .../vality/rateboss/job/NbkrExchangeGrabberJobTest.kt | 4 +++- .../vality/rateboss/job/NbkzExchangeGrabberJobTest.kt | 4 +++- .../vality/rateboss/source/NbkrExchangeRateSourceTest.kt | 2 +- 5 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/test/kotlin/dev/vality/rateboss/config/TestConfig.kt b/src/test/kotlin/dev/vality/rateboss/config/TestConfig.kt index 4307963..6e38747 100644 --- a/src/test/kotlin/dev/vality/rateboss/config/TestConfig.kt +++ b/src/test/kotlin/dev/vality/rateboss/config/TestConfig.kt @@ -1,6 +1,8 @@ package dev.vality.rateboss.config +import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.dataformat.xml.XmlMapper +import com.fasterxml.jackson.module.kotlin.kotlinModule import dev.vality.rateboss.config.properties.* import org.springframework.boot.test.context.TestConfiguration import org.springframework.context.annotation.Bean @@ -13,7 +15,12 @@ class TestConfig { fun testRestTemplate() = RestTemplate() @Bean - fun nbkrXmlMapper(): XmlMapper = nbkrXmlMapper() + fun nbkrXmlMapper(): XmlMapper = + XmlMapper + .builder() + .addModule(kotlinModule()) + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false) + .build() @Bean fun testRatesProperties(): RatesProperties = diff --git a/src/test/kotlin/dev/vality/rateboss/job/CbrExchangeGrabberJobTest.kt b/src/test/kotlin/dev/vality/rateboss/job/CbrExchangeGrabberJobTest.kt index df34df5..a23b52a 100644 --- a/src/test/kotlin/dev/vality/rateboss/job/CbrExchangeGrabberJobTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/job/CbrExchangeGrabberJobTest.kt @@ -12,6 +12,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.quartz.JobKey import org.quartz.Scheduler import org.quartz.TriggerKey import org.springframework.beans.factory.annotation.Autowired @@ -47,6 +48,8 @@ class CbrExchangeGrabberJobTest : ContainerConfiguration() { @BeforeEach fun setUp() { scheduler.unscheduleJob(TriggerKey(ratesProperties.fixerJob.jobTriggerName)) + scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkzJob.jobTriggerName)) + scheduler.unscheduleJob(TriggerKey(ratesProperties.nbkrJob.jobTriggerName)) } @Test @@ -62,7 +65,8 @@ class CbrExchangeGrabberJobTest : ContainerConfiguration() { timestamp = Instant.now().epochSecond, ) } - await().atMost(5, TimeUnit.SECONDS).untilAsserted { + scheduler.triggerJob(JobKey(ratesProperties.cbrJob.jobKey)) + await().atMost(30, TimeUnit.SECONDS).untilAsserted { verify(exchangeDaoService, atLeastOnce()).saveExchangeRates(any()) } } diff --git a/src/test/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJobTest.kt b/src/test/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJobTest.kt index 0be2122..c15fc17 100644 --- a/src/test/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJobTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/job/NbkrExchangeGrabberJobTest.kt @@ -12,6 +12,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.quartz.JobKey import org.quartz.Scheduler import org.quartz.TriggerKey import org.springframework.beans.factory.annotation.Autowired @@ -64,7 +65,8 @@ class NbkrExchangeGrabberJobTest : ContainerConfiguration() { timestamp = Instant.now().epochSecond, ) } - await().atMost(5, TimeUnit.SECONDS).untilAsserted { + scheduler.triggerJob(JobKey(ratesProperties.nbkrJob.jobKey)) + await().atMost(30, TimeUnit.SECONDS).untilAsserted { verify(exchangeDaoService, atLeastOnce()).saveExchangeRates(any()) } } diff --git a/src/test/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt b/src/test/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt index c7453dc..c70696b 100644 --- a/src/test/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/job/NbkzExchangeGrabberJobTest.kt @@ -12,6 +12,7 @@ import org.mockito.kotlin.any import org.mockito.kotlin.atLeastOnce import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import org.quartz.JobKey import org.quartz.Scheduler import org.quartz.TriggerKey import org.springframework.beans.factory.annotation.Autowired @@ -64,7 +65,8 @@ class NbkzExchangeGrabberJobTest : ContainerConfiguration() { timestamp = Instant.now().epochSecond, ) } - await().atMost(5, TimeUnit.SECONDS).untilAsserted { + scheduler.triggerJob(JobKey(ratesProperties.nbkzJob.jobKey)) + await().atMost(30, TimeUnit.SECONDS).untilAsserted { verify(exchangeDaoService, atLeastOnce()).saveExchangeRates(any()) } } diff --git a/src/test/kotlin/dev/vality/rateboss/source/NbkrExchangeRateSourceTest.kt b/src/test/kotlin/dev/vality/rateboss/source/NbkrExchangeRateSourceTest.kt index fd280ff..f44e32c 100644 --- a/src/test/kotlin/dev/vality/rateboss/source/NbkrExchangeRateSourceTest.kt +++ b/src/test/kotlin/dev/vality/rateboss/source/NbkrExchangeRateSourceTest.kt @@ -37,7 +37,7 @@ class NbkrExchangeRateSourceTest { exchangeRateSource.getExchangeRate(currencySymbolCode) } - assertEquals("Remote client exception", exception.message) + assertEquals("Failed to get daily rates", exception.message) } @Test From 0b66ee05445a94d678493647073bfc543f0dfabb Mon Sep 17 00:00:00 2001 From: dmitriibaikov Date: Tue, 5 May 2026 13:53:27 +0300 Subject: [PATCH 3/3] TECH-1: Add KGS [review fix] --- .../dev/vality/rateboss/source/impl/NbkrExchangeRateSource.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/kotlin/dev/vality/rateboss/source/impl/NbkrExchangeRateSource.kt b/src/main/kotlin/dev/vality/rateboss/source/impl/NbkrExchangeRateSource.kt index 14700f8..a0ab43c 100644 --- a/src/main/kotlin/dev/vality/rateboss/source/impl/NbkrExchangeRateSource.kt +++ b/src/main/kotlin/dev/vality/rateboss/source/impl/NbkrExchangeRateSource.kt @@ -38,7 +38,7 @@ class NbkrExchangeRateSource( } catch (e: Exception) { throw ExchangeRateSourceException("Failed to parse date from NbkrApi", e) } - val nextDayTimestamp = responseDate.plusDays(1).atStartOfDay().toEpochSecond(ZoneOffset.UTC) + val nextDayTimestamp = responseDate.atStartOfDay().toEpochSecond(ZoneOffset.UTC) log.info { "Exchange rates from nbkr have been retrieved, date=$responseDate, " + "exchangeRates=$rates, targetTimestamp=$nextDayTimestamp"