diff --git a/README.md b/README.md index 5e58ae2a..771f02ef 100644 --- a/README.md +++ b/README.md @@ -1,139 +1,50 @@ -# Allo Bank Backend Developer Take-Home Test - -Thank you for applying to our team! This take-home test is designed to evaluate your practical skills in building **production-ready** Spring Boot applications within a finance domain, focusing on architectural patterns and complex data handling. - -## 📝 Objective - -Your task is to create a single Spring Boot REST API endpoint capable of aggregating data from multiple, distinct resources provided by the public, keyless **Frankfurter Exchange Rate API**. The primary focus is on handling Indonesian Rupiah (IDR) data. - -The focus of this test is not just functional correctness, but demonstrating clean code, advanced Spring concepts, thread-safe design, and architectural clarity. - -## I. Core Task: The Polymorphic API - -### 1. External API Integration (Frankfurter API) - -* **Base URL (Public):** `https://api.frankfurter.app/`. - -* You must integrate with three distinct data resources to enforce the architectural pattern: - - 1. `/latest?base=IDR` (The latest rates relative to IDR) - - 2. **Historical Data:** Query a specific, small time series (e.g., `/2024-01-01..2024-01-05?from=IDR&to=USD`). **Note:** *Use the date range provided in this example unless a different range is communicated separately.* - - 3. `/currencies` (The list of all supported currency symbols) - -### 2. Internal API Endpoint - -You must expose **one single endpoint** in your application: ```GET /api/finance/data/{resourceType}``` - -Where `{resourceType}` can be one of the three strings: `latest_idr_rates`, `historical_idr_usd`, or `supported_currencies`. - -### 3. Required Functionality & Business Logic - -* **Resource Handling:** Your service must correctly map the three incoming `resourceType` values to the correct data fetching strategies. - -* **Data Load:** All three resources should be fetched from the external API. - -* **Data Transformation (Latest IDR Rates only) - Unique Calculation:** For the **`latest_idr_rates`** resource, you must calculate and include a new field, `"USD_BuySpread_IDR"`. This is the Rupiah selling rate to USD after applying a banking spread/margin. - - **The Spread Factor Must Be Unique :** - - 1. **Input:** Your GitHub username (e.g., `johndoe47`). - 2. **Calculation:** Calculate the sum of the Unicode (ASCII) values of all characters in your lowercase GitHub username string. - 3. **Spread Factor Derivation:** `Spread Factor = (Sum of Unicode Values % 1000) / 100000.0` - *(This will yield a unique factor between 0.00000 and 0.00999, ensuring a personalized result.)* - - **Final Formula:** `USD_BuySpread_IDR = (1 / Rate_USD) * (1 + Spread Factor)` (where `Rate_USD` is the value from the API when `base=IDR`). - -* **Other Resources:** The `historical_idr_usd` and `supported_currencies` resources can return their data with minimal transformation, but the final output must be a unified JSON array of results. - -## II. Architectural Constraints - -Meeting the core task is only one part of the solution. The following constraints must be strictly adhered to and will be heavily weighted during evaluation: - -### Constraint A: The Strategy Pattern - -The logic for handling the three different resources (`latest_idr_rates`, `historical_idr_usd`, `supported_currencies`) must be implemented using the **Strategy Design Pattern**. - -1. Define a clear **Strategy Interface** (e.g., `IDRDataFetcher`). - -2. Implement **three concrete strategy classes** (one for each resource). - -3. The main `Controller` should dynamically select the correct strategy implementation using a map-based lookup injected by Spring, avoiding any manual `if/else` or `switch` logic in the controller layer. - -### Constraint B: Client Factory Bean - -The instance of your chosen external API client (`WebClient` or `RestTemplate`) **must be defined and created within a custom implementation of Spring's `FactoryBean` interface**. - -* This `FactoryBean` should be responsible for externalizing the API Base URL via `@Value` or `@ConfigurationProperties` and applying any initial configuration (e.g., timeouts, shared headers). - -* ***You may not define the client as a simple `@Bean` in a `@Configuration` class.*** - -### Constraint C: Startup Data Runner & Immutability - -The aggregated data for **ALL three resources** must be fetched **exactly once on application startup** and loaded into an in-memory store. - -1. Use a Spring Boot **`ApplicationRunner`** or **`CommandLineRunner`** component to initiate the data fetching process. - -2. The API endpoint (`GET /api/finance/data/{resourceType}`) must serve the data from this **in-memory store**, not by making a new call to the external API on every request. - -3. The in-memory storage mechanism (e.g., a service holding the data) must be designed to be **thread-safe** and ensure the data is **immutable** once the `ApplicationRunner` has finished loading it. - -## III. Production Readiness & Deliverables - -Your final solution must demonstrate production quality through code, testing, and communication. - -### 1. Robustness & Best Practices - -* Graceful **Error Handling** for network failures or 4xx/5xx responses from the external API. - -* Proper use of **Configuration Properties** (e.g., `application.yml`) for external service URLs. - -* Clear separation of concerns (Controller, Service, Model/DTO, etc.). - -### 2. Testing - -* **Unit Tests** for all three `IDRDataFetcher` strategy implementations, ensuring data calculation and transformation logic is covered (using mock clients for external calls). - -* **Integration Tests** to verify the `ApplicationRunner` successfully initializes and loads the data into the in-memory store before the application context is ready. - -### 3. Documentation - -A clear `README.md` is mandatory. It must include: - -* **Setup/Run Instructions:** Clear steps to clone, build, and run the application and tests. - -* **Endpoint Usage:** Example cURL commands to test the three different resource types. - -* **Personalization Note:** Clearly state your GitHub username and show the exact **Spread Factor** (e.g., `0.00765`) calculated by your function. - -* --- - -* ### 🛠️ Architectural Rationale - - This section should contain a brief, but detailed, explanation answering the following questions: - - 1. **Polymorphism Justification:** Explain *why* the Strategy Pattern was used over a simpler conditional block in the service layer for handling the multi-resource endpoint. Discuss the benefits in terms of **extensibility** and **maintainability**. - - 2. **Client Factory:** Explain the specific role and benefit of using a **`FactoryBean`** to construct the external API client. Why is this preferable to defining the client using a standard `@Bean` method in this scenario? - - 3. **Startup Runner Choice:** Justify the choice of using an `ApplicationRunner` (or `CommandLineRunner`) for the initial data ingestion over a simpler `@PostConstruct` method. - -## IV. Submission & Review Process - -1. **Fork** this repository. - -2. Implement your solution on a dedicated feature branch (e.g., `feat/idr-rate-aggregator`). - -3. When complete, submit your solution via a **Pull Request (PR)** back to the main repository. -4. Please complete the form to submit your technical test: [Click Here](https://forms.gle/nZKQ2EjTCPfAKHog7) - -**Your PR will be evaluated on the following:** - -* **Commit History:** Clean, atomic, and descriptive commit messages (e.g., "feat: Implement IDR latest rates strategy," "fix: Correctly calculate IDR spread in tests"). - -* **PR Description:** The description must clearly summarize the solution and **must contain the full answers** to the three "Architectural Rationale" questions from Section III. - -* **Code Review Readiness:** The code should be well-structured and ready for immediate review. - -Good luck! +# prerequisite +Java version 21.0.5 +Gradle version 8.14.3 + +# step by step +* Clone + git clone + +* Open terminal (Use Powershell) + +* Open application directory + cd + example: cd Z:\bs + +* Refresh Gradle Dependencies + gradle --refresh-dependencies + +* Build using gradle command + gradle build + +* Run application + cd + example: cd Z:\bs\build\libs + + java -jar + example: java -jar bs-0.0.1-SNAPSHOT.jar + +* Test API 'supported_currencies' + curl.exe "http://127.0.0.1:8080/api/finance/data/supported_currencies" + +* Test API 'historical_idr_usd' + curl.exe "http://127.0.0.1:8080/api/finance/data/historical_idr_usd?dateFrom=2026-03-25&dateTo=2026-04-01" + +* Test API 'latest_idr_rates' + curl.exe "http://127.0.0.1:8080/api/finance/data/latest_idr_rates" + +# Personalization Note +* Spread Factor : 0.0037 + +# Architectural Rationale +i. + With the Strategy Pattern, the code will be easier to read, debug, and modify. + in extensibility, When adding a new resource type doesn’t require changing existing logic. + in maintainability, Each strategy is independent, making it easier to read, test, and modify. + +ii. + If using a FactoryBean, it will define when it is created. + +iii. + CommandLineRunner is preferable because it executes after the entire Spring Boot application context completed. \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..ecddc320 --- /dev/null +++ b/build.gradle @@ -0,0 +1,36 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '3.2.5' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.self' +version = '0.0.1-SNAPSHOT' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.springframework.boot:spring-boot-starter-cache' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.mockito:mockito-core:3.12.4' +} + +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:2023.0.1" + } +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..eee8a60f --- /dev/null +++ b/settings.gradle @@ -0,0 +1,7 @@ +pluginManagement { + repositories { + maven { url = 'https://repo.spring.io/snapshot' } + gradlePluginPortal() + } +} +rootProject.name = 'bs' diff --git a/src/main/java/com/self/bs/BsApplication.java b/src/main/java/com/self/bs/BsApplication.java new file mode 100644 index 00000000..905d28dd --- /dev/null +++ b/src/main/java/com/self/bs/BsApplication.java @@ -0,0 +1,15 @@ +package com.self.bs; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +import com.self.bs.source.config.ExchangeRateProperties; + +@SpringBootApplication +@EnableConfigurationProperties(ExchangeRateProperties.class) +public class BsApplication { + public static void main(String[] args) { + SpringApplication.run(BsApplication.class, args); + } +} diff --git a/src/main/java/com/self/bs/source/component/CacheManagerFactoryBean.java b/src/main/java/com/self/bs/source/component/CacheManagerFactoryBean.java new file mode 100644 index 00000000..fbabaa69 --- /dev/null +++ b/src/main/java/com/self/bs/source/component/CacheManagerFactoryBean.java @@ -0,0 +1,26 @@ +package com.self.bs.source.component; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.stereotype.Component; + +import com.self.bs.source.config.ExchangeRateProperties; + +@Component +public class CacheManagerFactoryBean implements FactoryBean{ + + @Autowired + protected ExchangeRateProperties exchangeRateProperties; + + @Override + public ConcurrentMapCacheManager getObject() { + return new ConcurrentMapCacheManager(exchangeRateProperties.getCacheName()); + } + + @Override + public Class getObjectType() { + return ConcurrentMapCacheManager.class; + } + +} diff --git a/src/main/java/com/self/bs/source/component/ExternalApiClientFactory.java b/src/main/java/com/self/bs/source/component/ExternalApiClientFactory.java new file mode 100644 index 00000000..29088958 --- /dev/null +++ b/src/main/java/com/self/bs/source/component/ExternalApiClientFactory.java @@ -0,0 +1,26 @@ +package com.self.bs.source.component; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import com.self.bs.source.config.ExchangeRateProperties; + +@Component +public class ExternalApiClientFactory implements FactoryBean{ + @Autowired + protected ExchangeRateProperties exchangeRateProperties; + + @Override + public WebClient getObject() { + return WebClient.builder() + .baseUrl(exchangeRateProperties.getExternalUrl()) + .build(); + } + + @Override + public Class getObjectType() { + return WebClient.class; + } +} diff --git a/src/main/java/com/self/bs/source/config/ExchangeRateProperties.java b/src/main/java/com/self/bs/source/config/ExchangeRateProperties.java new file mode 100644 index 00000000..712406b7 --- /dev/null +++ b/src/main/java/com/self/bs/source/config/ExchangeRateProperties.java @@ -0,0 +1,64 @@ +package com.self.bs.source.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "bs.exchange-rate") +public class ExchangeRateProperties { + private String cacheName; + private String externalUrl; + private String baseCurrency; + private String targetCurrency; + private int defaultHistoricalRangeDate = 7; + private String dateFormat = "yyyy-MM-dd"; + private String rangeDateSeparator = ".."; + private String personalName; + + public String getCacheName() { + return cacheName; + } + public void setCacheName(String cacheName) { + this.cacheName = cacheName; + } + public String getExternalUrl() { + return externalUrl; + } + public void setExternalUrl(String externalUrl) { + this.externalUrl = externalUrl; + } + public String getBaseCurrency() { + return baseCurrency; + } + public void setBaseCurrency(String baseCurrency) { + this.baseCurrency = baseCurrency; + } + public String getTargetCurrency() { + return targetCurrency; + } + public void setTargetCurrency(String targetCurrency) { + this.targetCurrency = targetCurrency; + } + public int getDefaultHistoricalRangeDate() { + return defaultHistoricalRangeDate; + } + public void setDefaultHistoricalRangeDate(int defaultHistoricalRangeDate) { + this.defaultHistoricalRangeDate = defaultHistoricalRangeDate; + } + public String getDateFormat() { + return dateFormat; + } + public void setDateFormat(String dateFormat) { + this.dateFormat = dateFormat; + } + public String getRangeDateSeparator() { + return rangeDateSeparator; + } + public void setRangeDateSeparator(String rangeDateSeparator) { + this.rangeDateSeparator = rangeDateSeparator; + } + public String getPersonalName() { + return personalName; + } + public void setPersonalName(String personalName) { + this.personalName = personalName; + } +} diff --git a/src/main/java/com/self/bs/source/controller/ExchangeRateController.java b/src/main/java/com/self/bs/source/controller/ExchangeRateController.java new file mode 100644 index 00000000..669c72c9 --- /dev/null +++ b/src/main/java/com/self/bs/source/controller/ExchangeRateController.java @@ -0,0 +1,37 @@ +package com.self.bs.source.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.self.bs.source.config.ExchangeRateProperties; +import com.self.bs.source.dto.response.ResponseDto; +import com.self.bs.source.service.ExchangeRateService; + +@RestController +@RequestMapping("/api/finance/data/") +public class ExchangeRateController { + @Autowired + protected ExchangeRateService exchangeRateService; + + @Autowired + protected ExchangeRateProperties exchangeRateProperties; + + @GetMapping("supported_currencies") + public ResponseEntity> supportedCurrencies(){ + return ResponseEntity.ok(exchangeRateService.getCurrencyList()); + } + + @GetMapping("historical_idr_usd") + public ResponseEntity> historicalIdrUsd(@RequestParam String dateFrom, @RequestParam String dateTo){ + return ResponseEntity.ok(exchangeRateService.getExchangeRateHistorical(dateFrom, dateTo, exchangeRateProperties.getBaseCurrency(), exchangeRateProperties.getTargetCurrency())); + } + + @GetMapping("latest_idr_rates") + public ResponseEntity> latestIdrRates(){ + return ResponseEntity.ok(exchangeRateService.getLatestExchangeRate(exchangeRateProperties.getBaseCurrency(), exchangeRateProperties.getTargetCurrency())); + } +} diff --git a/src/main/java/com/self/bs/source/dto/request/ExchangeRateDataFetcherRequestDto.java b/src/main/java/com/self/bs/source/dto/request/ExchangeRateDataFetcherRequestDto.java new file mode 100644 index 00000000..f93e0a50 --- /dev/null +++ b/src/main/java/com/self/bs/source/dto/request/ExchangeRateDataFetcherRequestDto.java @@ -0,0 +1,49 @@ +package com.self.bs.source.dto.request; + +public class ExchangeRateDataFetcherRequestDto { + private String dateFrom; + private String dateTo; + private String baseCurrency; + private String targetCurrency; + + public ExchangeRateDataFetcherRequestDto (){ + } + + public ExchangeRateDataFetcherRequestDto (String dateFrom, String dateTo, String baseCurrency, String targetCurrency){ + this.dateFrom = dateFrom; + this.dateTo = dateTo; + this.baseCurrency = baseCurrency; + this.targetCurrency = targetCurrency; + } + + public String getDateFrom() { + return dateFrom; + } + public void setDateFrom(String dateFrom) { + this.dateFrom = dateFrom; + } + public String getDateTo() { + return dateTo; + } + public void setDateTo(String dateTo) { + this.dateTo = dateTo; + } + public String getBaseCurrency() { + return baseCurrency; + } + public void setBaseCurrency(String baseCurrency) { + this.baseCurrency = baseCurrency; + } + public String getTargetCurrency() { + return targetCurrency; + } + public void setTargetCurrency(String targetCurrency) { + this.targetCurrency = targetCurrency; + } + + @Override + public String toString() { + return "ExchangeRateDataFetcherRequestDto [dateFrom=" + dateFrom + ", dateTo=" + dateTo + ", baseCurrency=" + + baseCurrency + ", targetCurrency=" + targetCurrency + "]"; + } +} diff --git a/src/main/java/com/self/bs/source/dto/response/HistoryCurrencyRateResponseDto.java b/src/main/java/com/self/bs/source/dto/response/HistoryCurrencyRateResponseDto.java new file mode 100644 index 00000000..1489bc62 --- /dev/null +++ b/src/main/java/com/self/bs/source/dto/response/HistoryCurrencyRateResponseDto.java @@ -0,0 +1,58 @@ +package com.self.bs.source.dto.response; + +import java.util.Map; + +public class HistoryCurrencyRateResponseDto { + private String amount; + private String base; + private String start_date; + private String end_date; + private Map> rates; + + public HistoryCurrencyRateResponseDto() { + } + + public HistoryCurrencyRateResponseDto(String amount, String base, String start_date, String end_date, Map> rates) { + this.amount = amount; + this.base = base; + this.start_date = start_date; + this.end_date = end_date; + this.rates = rates; + } + + public String getAmount() { + return amount; + } + public void setAmount(String amount) { + this.amount = amount; + } + public String getBase() { + return base; + } + public void setBase(String base) { + this.base = base; + } + public String getStart_date() { + return start_date; + } + public void setStart_date(String start_date) { + this.start_date = start_date; + } + public String getEnd_date() { + return end_date; + } + public void setEnd_date(String end_date) { + this.end_date = end_date; + } + public Map> getRates() { + return rates; + } + public void setRates(Map> rates) { + this.rates = rates; + } + @Override + public String toString() { + return "HistoryCurrencyRateResponseDto [amount=" + amount + ", base=" + base + ", start_date=" + start_date + + ", end_date=" + end_date + ", rates=" + rates + "]"; + } +} diff --git a/src/main/java/com/self/bs/source/dto/response/LatestCurrencyRateResponseDto.java b/src/main/java/com/self/bs/source/dto/response/LatestCurrencyRateResponseDto.java new file mode 100644 index 00000000..6eb135bb --- /dev/null +++ b/src/main/java/com/self/bs/source/dto/response/LatestCurrencyRateResponseDto.java @@ -0,0 +1,58 @@ +package com.self.bs.source.dto.response; + +import java.util.Map; + +public class LatestCurrencyRateResponseDto { + private String amount; + private String base; + private String date; + private Map rates; + private Double USD_BuySpread_IDR; + + public LatestCurrencyRateResponseDto() { + } + + public LatestCurrencyRateResponseDto(String amount, String base, String date, Map rates, Double uSD_BuySpread_IDR) { + this.amount = amount; + this.base = base; + this.date = date; + this.rates = rates; + this.USD_BuySpread_IDR = uSD_BuySpread_IDR; + } + + public String getAmount() { + return amount; + } + public void setAmount(String amount) { + this.amount = amount; + } + public String getBase() { + return base; + } + public void setBase(String base) { + this.base = base; + } + public String getDate() { + return date; + } + public void setDate(String date) { + this.date = date; + } + public Map getRates() { + return rates; + } + public void setRates(Map rates) { + this.rates = rates; + } + public Double getUSD_BuySpread_IDR() { + return USD_BuySpread_IDR; + } + public void setUSD_BuySpread_IDR(Double uSD_BuySpread_IDR) { + USD_BuySpread_IDR = uSD_BuySpread_IDR; + } + @Override + public String toString() { + return "LatestCurrencyRateResponseDto [amount=" + amount + ", base=" + base + ", date=" + date + ", rates=" + + rates + ", USD_BuySpread_IDR=" + USD_BuySpread_IDR + "]"; + } +} diff --git a/src/main/java/com/self/bs/source/dto/response/ResponseDto.java b/src/main/java/com/self/bs/source/dto/response/ResponseDto.java new file mode 100644 index 00000000..43c8878e --- /dev/null +++ b/src/main/java/com/self/bs/source/dto/response/ResponseDto.java @@ -0,0 +1,48 @@ +package com.self.bs.source.dto.response; + +import java.time.LocalDateTime; + +public class ResponseDto { + private LocalDateTime timestamp; + private T data; + private String errorMessage; + + public ResponseDto(T data){ + this.timestamp = LocalDateTime.now(); + this.data = data; + } + + public ResponseDto(String errorMessage){ + this.timestamp = LocalDateTime.now(); + this.errorMessage = errorMessage; + } + + public T getData() { + return data; + } + + public void setData(T data) { + this.data = data; + } + + public LocalDateTime getTimestamp() { + return timestamp; + } + + public void setTimestamp(LocalDateTime timestamp) { + this.timestamp = timestamp; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + @Override + public String toString() { + return "ResponseDto [timestamp=" + timestamp + ", data=" + data + ", errorMessage=" + errorMessage + "]"; + } +} diff --git a/src/main/java/com/self/bs/source/enumeration/CacheKeywordEnum.java b/src/main/java/com/self/bs/source/enumeration/CacheKeywordEnum.java new file mode 100644 index 00000000..db1c5a3d --- /dev/null +++ b/src/main/java/com/self/bs/source/enumeration/CacheKeywordEnum.java @@ -0,0 +1,5 @@ +package com.self.bs.source.enumeration; + +public enum CacheKeywordEnum { + CURRENCY_LIST, HISTORICAL, LATEST_RATES +} diff --git a/src/main/java/com/self/bs/source/exception/ExchangeRateException.java b/src/main/java/com/self/bs/source/exception/ExchangeRateException.java new file mode 100644 index 00000000..a2291aa5 --- /dev/null +++ b/src/main/java/com/self/bs/source/exception/ExchangeRateException.java @@ -0,0 +1,10 @@ +package com.self.bs.source.exception; + +public class ExchangeRateException extends RuntimeException{ + + public static final String DATE_FROM_CANNOT_BE_AFTER_DATE_TO = "bs.exchange-rate.exception.001"; + + public ExchangeRateException (String message) { + super(message); + } +} diff --git a/src/main/java/com/self/bs/source/handler/CustomExceptionHandler.java b/src/main/java/com/self/bs/source/handler/CustomExceptionHandler.java new file mode 100644 index 00000000..6a8f6bfb --- /dev/null +++ b/src/main/java/com/self/bs/source/handler/CustomExceptionHandler.java @@ -0,0 +1,29 @@ +package com.self.bs.source.handler; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import com.self.bs.source.dto.response.ResponseDto; +import com.self.bs.source.exception.ExchangeRateException; + +@RestControllerAdvice +public class CustomExceptionHandler { + @ExceptionHandler(ExchangeRateException.class) + public ResponseEntity businessException(ExchangeRateException ex){ + return ResponseEntity.badRequest().body(new ResponseDto<>(ex.getMessage())); + } + + @ExceptionHandler(MissingServletRequestParameterException.class) + public ResponseEntity handleMissingParam(MissingServletRequestParameterException ex) { + String message = String.format("field '%s' is mandatory", ex.getParameterName()); + + return ResponseEntity.badRequest().body(new ResponseDto<>(message)); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity generalException(Exception ex){ + return ResponseEntity.internalServerError().body(new ResponseDto<>("Internal server error")); + } +} diff --git a/src/main/java/com/self/bs/source/service/CurrencyListDataFetcherService.java b/src/main/java/com/self/bs/source/service/CurrencyListDataFetcherService.java new file mode 100644 index 00000000..6070ac10 --- /dev/null +++ b/src/main/java/com/self/bs/source/service/CurrencyListDataFetcherService.java @@ -0,0 +1,28 @@ +package com.self.bs.source.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.stereotype.Service; + +import com.self.bs.source.config.ExchangeRateProperties; +import com.self.bs.source.dto.request.ExchangeRateDataFetcherRequestDto; +import com.self.bs.source.enumeration.CacheKeywordEnum; +import com.self.bs.source.webclient.ExchangeRateWebClient; + +@Service +public class CurrencyListDataFetcherService implements IExchangeRateDataFetcher{ + + @Autowired + protected ConcurrentMapCacheManager cacheManager; + + @Autowired + protected ExchangeRateProperties exchangeRateProperties; + + @Autowired + protected ExchangeRateWebClient exchangeRateWebClient; + + @Override + public void fetchData(ExchangeRateDataFetcherRequestDto requestDto) { + cacheManager.getCache(exchangeRateProperties.getCacheName()).put(CacheKeywordEnum.CURRENCY_LIST.name(), exchangeRateWebClient.getCurrencyList()); + } +} diff --git a/src/main/java/com/self/bs/source/service/ExchangeRateService.java b/src/main/java/com/self/bs/source/service/ExchangeRateService.java new file mode 100644 index 00000000..24069c36 --- /dev/null +++ b/src/main/java/com/self/bs/source/service/ExchangeRateService.java @@ -0,0 +1,74 @@ +package com.self.bs.source.service; + +import java.time.LocalDate; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.stereotype.Service; + +import com.self.bs.source.config.ExchangeRateProperties; +import com.self.bs.source.dto.request.ExchangeRateDataFetcherRequestDto; +import com.self.bs.source.dto.response.ResponseDto; +import com.self.bs.source.enumeration.CacheKeywordEnum; +import com.self.bs.source.exception.ExchangeRateException; + +@Service +public class ExchangeRateService { + @Autowired + protected ExchangeRateProperties exchangeRateProperties; + + @Autowired + protected ConcurrentMapCacheManager cacheManager; + + @Autowired + protected IExchangeRateDataFetcher currencyListDataFetcherService; + + @Autowired + protected IExchangeRateDataFetcher historyExchangeRateDataFetcherService; + + @Autowired + protected IExchangeRateDataFetcher latestCurrencyRateDataFetcherService; + + public ResponseDto getCurrencyList(){ + try { + return new ResponseDto(getDataFromMemory(CacheKeywordEnum.CURRENCY_LIST.name())); + } catch (NullPointerException e) { + currencyListDataFetcherService.fetchData(null); + + return new ResponseDto(getDataFromMemory(CacheKeywordEnum.CURRENCY_LIST.name())); + } + } + + public ResponseDto getExchangeRateHistorical(String dateFrom, String dateTo, String baseCurrency, String targetCurrency){ + String rangeDate = dateFrom.concat(exchangeRateProperties.getRangeDateSeparator()).concat(dateTo); + try { + return new ResponseDto(getDataFromMemory(CacheKeywordEnum.HISTORICAL.name().concat(rangeDate))); + } catch (NullPointerException e) { + if (LocalDate.parse(dateFrom).isAfter(LocalDate.parse(dateTo))) + throw new ExchangeRateException(ExchangeRateException.DATE_FROM_CANNOT_BE_AFTER_DATE_TO); + + ExchangeRateDataFetcherRequestDto requestDto = new ExchangeRateDataFetcherRequestDto(dateFrom, dateTo, baseCurrency, targetCurrency); + historyExchangeRateDataFetcherService.fetchData(requestDto); + + return new ResponseDto(getDataFromMemory(CacheKeywordEnum.HISTORICAL.name().concat(rangeDate))); + } + } + + public ResponseDto getLatestExchangeRate(String baseCurrency, String targetCurrency){ + try { + return new ResponseDto(getDataFromMemory(CacheKeywordEnum.LATEST_RATES.name())); + } catch (NullPointerException e) { + ExchangeRateDataFetcherRequestDto requestDto = new ExchangeRateDataFetcherRequestDto(); + requestDto.setBaseCurrency(baseCurrency); + requestDto.setTargetCurrency(targetCurrency); + + latestCurrencyRateDataFetcherService.fetchData(requestDto); + + return new ResponseDto(getDataFromMemory(CacheKeywordEnum.LATEST_RATES.name())); + } + } + + public Object getDataFromMemory(String keyword){ + return cacheManager.getCache(exchangeRateProperties.getCacheName()).get(keyword).get(); + } +} diff --git a/src/main/java/com/self/bs/source/service/HistoryExchangeRateDataFetcherService.java b/src/main/java/com/self/bs/source/service/HistoryExchangeRateDataFetcherService.java new file mode 100644 index 00000000..e964c863 --- /dev/null +++ b/src/main/java/com/self/bs/source/service/HistoryExchangeRateDataFetcherService.java @@ -0,0 +1,33 @@ +package com.self.bs.source.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.stereotype.Service; + +import com.self.bs.source.config.ExchangeRateProperties; +import com.self.bs.source.dto.request.ExchangeRateDataFetcherRequestDto; +import com.self.bs.source.dto.response.HistoryCurrencyRateResponseDto; +import com.self.bs.source.enumeration.CacheKeywordEnum; +import com.self.bs.source.webclient.ExchangeRateWebClient; + +@Service +public class HistoryExchangeRateDataFetcherService implements IExchangeRateDataFetcher{ + + @Autowired + protected ConcurrentMapCacheManager cacheManager; + + @Autowired + protected ExchangeRateProperties exchangeRateProperties; + + @Autowired + protected ExchangeRateWebClient exchangeRateWebClient; + + @Override + public void fetchData(ExchangeRateDataFetcherRequestDto requestDto) { + String rangeDate = requestDto.getDateFrom().concat(exchangeRateProperties.getRangeDateSeparator()).concat(requestDto.getDateTo()); + + HistoryCurrencyRateResponseDto data = exchangeRateWebClient.getHistoryCurrencyRate(requestDto, rangeDate); + + cacheManager.getCache(exchangeRateProperties.getCacheName()).put(CacheKeywordEnum.HISTORICAL.name().concat(rangeDate), data); + } +} diff --git a/src/main/java/com/self/bs/source/service/IExchangeRateDataFetcher.java b/src/main/java/com/self/bs/source/service/IExchangeRateDataFetcher.java new file mode 100644 index 00000000..7964ad4c --- /dev/null +++ b/src/main/java/com/self/bs/source/service/IExchangeRateDataFetcher.java @@ -0,0 +1,7 @@ +package com.self.bs.source.service; + +import com.self.bs.source.dto.request.ExchangeRateDataFetcherRequestDto; + +public interface IExchangeRateDataFetcher { + void fetchData(ExchangeRateDataFetcherRequestDto requestData); +} diff --git a/src/main/java/com/self/bs/source/service/LatestCurrencyRateDataFetcherService.java b/src/main/java/com/self/bs/source/service/LatestCurrencyRateDataFetcherService.java new file mode 100644 index 00000000..96058c03 --- /dev/null +++ b/src/main/java/com/self/bs/source/service/LatestCurrencyRateDataFetcherService.java @@ -0,0 +1,40 @@ +package com.self.bs.source.service; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.stereotype.Service; + +import com.self.bs.source.config.ExchangeRateProperties; +import com.self.bs.source.dto.request.ExchangeRateDataFetcherRequestDto; +import com.self.bs.source.dto.response.LatestCurrencyRateResponseDto; +import com.self.bs.source.enumeration.CacheKeywordEnum; +import com.self.bs.source.webclient.ExchangeRateWebClient; + +@Service +public class LatestCurrencyRateDataFetcherService implements IExchangeRateDataFetcher{ + + @Autowired + protected ConcurrentMapCacheManager cacheManager; + + @Autowired + protected ExchangeRateProperties exchangeRateProperties; + + @Autowired + protected ExchangeRateWebClient exchangeRateWebClient; + + @Override + public void fetchData(ExchangeRateDataFetcherRequestDto requestDto) { + LatestCurrencyRateResponseDto data = exchangeRateWebClient.getLatestCurrencyRate(requestDto); + + // calculate USD_BuySpread_IDR + int asci = exchangeRateProperties.getPersonalName().chars().sum() % 1000; + Double spreadFactor = asci / 100000.0; + + Double calculate = (1 / Double.valueOf(data.getRates().get(requestDto.getTargetCurrency()))) * (1 + spreadFactor); + + data.setUSD_BuySpread_IDR(calculate); + + // save data + cacheManager.getCache(exchangeRateProperties.getCacheName()).put(CacheKeywordEnum.LATEST_RATES.name(), data); + } +} diff --git a/src/main/java/com/self/bs/source/startup/StartUpApplicationRunner.java b/src/main/java/com/self/bs/source/startup/StartUpApplicationRunner.java new file mode 100644 index 00000000..7cfbd598 --- /dev/null +++ b/src/main/java/com/self/bs/source/startup/StartUpApplicationRunner.java @@ -0,0 +1,40 @@ +package com.self.bs.source.startup; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; + +import com.self.bs.source.config.ExchangeRateProperties; +import com.self.bs.source.dto.request.ExchangeRateDataFetcherRequestDto; +import com.self.bs.source.service.IExchangeRateDataFetcher; + +@Component +public class StartUpApplicationRunner implements CommandLineRunner{ + @Autowired + protected ExchangeRateProperties exchangeRateProperties; + + @Autowired + protected IExchangeRateDataFetcher currencyListDataFetcherService; + + @Autowired + protected IExchangeRateDataFetcher historyExchangeRateDataFetcherService; + + @Autowired + protected IExchangeRateDataFetcher latestCurrencyRateDataFetcherService; + + @Override + public void run(String... args) throws Exception { + // Fetch Exchange Rate Data + ExchangeRateDataFetcherRequestDto requestDto = new ExchangeRateDataFetcherRequestDto( + LocalDate.now().minusDays(exchangeRateProperties.getDefaultHistoricalRangeDate()).format(DateTimeFormatter.ofPattern(exchangeRateProperties.getDateFormat())), + LocalDate.now().format(DateTimeFormatter.ofPattern(exchangeRateProperties.getDateFormat())), + exchangeRateProperties.getBaseCurrency(), exchangeRateProperties.getTargetCurrency()); + + currencyListDataFetcherService.fetchData(null); + historyExchangeRateDataFetcherService.fetchData(requestDto); + latestCurrencyRateDataFetcherService.fetchData(requestDto); + } +} diff --git a/src/main/java/com/self/bs/source/webclient/ExchangeRateWebClient.java b/src/main/java/com/self/bs/source/webclient/ExchangeRateWebClient.java new file mode 100644 index 00000000..667a5705 --- /dev/null +++ b/src/main/java/com/self/bs/source/webclient/ExchangeRateWebClient.java @@ -0,0 +1,49 @@ +package com.self.bs.source.webclient; + +import java.util.Map; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import com.self.bs.source.dto.request.ExchangeRateDataFetcherRequestDto; +import com.self.bs.source.dto.response.HistoryCurrencyRateResponseDto; +import com.self.bs.source.dto.response.LatestCurrencyRateResponseDto; + +@Component +public class ExchangeRateWebClient { + @Autowired + protected WebClient webClient; + + public Map getCurrencyList(){ + return webClient.get() + .uri("/currencies") + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() {}) + .block(); + } + + public LatestCurrencyRateResponseDto getLatestCurrencyRate(ExchangeRateDataFetcherRequestDto requestDto){ + return webClient.get() + .uri(uriBuilder -> uriBuilder.path("/latest") + .queryParam("base", requestDto.getBaseCurrency()) + .build() + ) + .retrieve() + .bodyToMono(LatestCurrencyRateResponseDto.class) + .block(); + } + + public HistoryCurrencyRateResponseDto getHistoryCurrencyRate(ExchangeRateDataFetcherRequestDto requestDto, String rangeDate){ + return webClient.get() + .uri(uriBuilder -> uriBuilder.path("/{rangeDate}") + .queryParam("from", requestDto.getBaseCurrency()) + .queryParam("to", requestDto.getTargetCurrency()) + .build(rangeDate) + ) + .retrieve() + .bodyToMono(HistoryCurrencyRateResponseDto.class) + .block(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..e3309fcc --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,13 @@ +spring: + application: + name: bs + main: + allow-bean-definition-overriding: true + +bs: + exchange-rate: + cacheName: cn + externalUrl: https://api.frankfurter.app + baseCurrency: IDR + targetCurrency: USD + personalName: bobbisetiawan diff --git a/src/test/java/com/self/bs/BsApplicationTests.java b/src/test/java/com/self/bs/BsApplicationTests.java new file mode 100644 index 00000000..5c85f398 --- /dev/null +++ b/src/test/java/com/self/bs/BsApplicationTests.java @@ -0,0 +1,41 @@ +package com.self.bs; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; + +import com.self.bs.source.config.ExchangeRateProperties; +import com.self.bs.source.enumeration.CacheKeywordEnum; + +@SpringBootTest +class BsApplicationTests { + @Autowired + protected ConcurrentMapCacheManager cacheManager; + + @Autowired + protected ExchangeRateProperties exchangeRateProperties; + + @Test + void contextLoads() { + String dateFrom = LocalDate.now().minusDays(exchangeRateProperties.getDefaultHistoricalRangeDate()).format(DateTimeFormatter.ofPattern(exchangeRateProperties.getDateFormat())); + String dateTo = LocalDate.now().format(DateTimeFormatter.ofPattern(exchangeRateProperties.getDateFormat())); + + String rangeDate = dateFrom.concat(exchangeRateProperties.getRangeDateSeparator()).concat(dateTo); + + List cacheNameList = List.of(CacheKeywordEnum.CURRENCY_LIST.name(), + CacheKeywordEnum.HISTORICAL.name().concat(rangeDate), + CacheKeywordEnum.LATEST_RATES.name()); + + for (String key : cacheNameList){ + assertNotNull(cacheManager.getCache(exchangeRateProperties.getCacheName()).get(key)); + } + } + +} diff --git a/src/test/java/com/self/bs/source/service/CurrencyListDataFetcherServiceTest.java b/src/test/java/com/self/bs/source/service/CurrencyListDataFetcherServiceTest.java new file mode 100644 index 00000000..d4ebd052 --- /dev/null +++ b/src/test/java/com/self/bs/source/service/CurrencyListDataFetcherServiceTest.java @@ -0,0 +1,56 @@ +package com.self.bs.source.service; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.test.util.ReflectionTestUtils; + +import com.self.bs.source.config.ExchangeRateProperties; +import com.self.bs.source.webclient.ExchangeRateWebClient; + +@ExtendWith(MockitoExtension.class) +public class CurrencyListDataFetcherServiceTest { + @InjectMocks + protected CurrencyListDataFetcherService currencyListDataFetcherService; + + @Mock + protected ExchangeRateWebClient exchangeRateWebClient; + + @Mock + protected ConcurrentMapCacheManager cacheManager; + + @Mock + protected ExchangeRateProperties exchangeRateProperties; + + @BeforeEach + void setup() { + cacheManager = new ConcurrentMapCacheManager(); + + ReflectionTestUtils.setField(currencyListDataFetcherService, "cacheManager", cacheManager); + } + + @Test + public void getCurrencyList_shouldCallGetCurrencyList_whenInvoke(){ + Map mockData = new HashMap<>(); + mockData.put("IDR", "Indonesian Rupiah"); + mockData.put("USD", "United States Dollar"); + + when(exchangeRateWebClient.getCurrencyList()).thenReturn(mockData); + when(exchangeRateProperties.getCacheName()).thenReturn("cn"); + + currencyListDataFetcherService.fetchData(null); + + verify(exchangeRateWebClient, times(1)).getCurrencyList(); + } +} diff --git a/src/test/java/com/self/bs/source/service/HistoryExchangeRateDataFetcherServiceTest.java b/src/test/java/com/self/bs/source/service/HistoryExchangeRateDataFetcherServiceTest.java new file mode 100644 index 00000000..63acf528 --- /dev/null +++ b/src/test/java/com/self/bs/source/service/HistoryExchangeRateDataFetcherServiceTest.java @@ -0,0 +1,64 @@ +package com.self.bs.source.service; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.test.util.ReflectionTestUtils; + +import com.self.bs.source.config.ExchangeRateProperties; +import com.self.bs.source.dto.request.ExchangeRateDataFetcherRequestDto; +import com.self.bs.source.dto.response.HistoryCurrencyRateResponseDto; +import com.self.bs.source.webclient.ExchangeRateWebClient; + +@ExtendWith(MockitoExtension.class) +public class HistoryExchangeRateDataFetcherServiceTest { + @InjectMocks + protected HistoryExchangeRateDataFetcherService historyExchangeRateDataFetcherService; + + @Mock + protected ExchangeRateWebClient exchangeRateWebClient; + + @Mock + protected ConcurrentMapCacheManager cacheManager; + + @Mock + protected ExchangeRateProperties exchangeRateProperties; + + @BeforeEach + void setup() { + cacheManager = new ConcurrentMapCacheManager(); + + ReflectionTestUtils.setField(historyExchangeRateDataFetcherService, "cacheManager", cacheManager); + } + + @Test + public void getHistoryExchangeRate_shouldCallHistoryCurrencyRate_whenInvoke(){ + ExchangeRateDataFetcherRequestDto requestDto = new ExchangeRateDataFetcherRequestDto("2026-03-31", "2026-04-01", "IDR", "USD"); + String rangeDate = requestDto.getDateFrom().concat("..").concat(requestDto.getDateTo()); + + Map> rates = new HashMap<>(); + rates.put(requestDto.getDateFrom(), Map.of("USD", "0.005")); + rates.put(requestDto.getDateTo(), Map.of("USD", "0.006")); + + HistoryCurrencyRateResponseDto mockData = new HistoryCurrencyRateResponseDto("1.5", requestDto.getBaseCurrency(), requestDto.getDateFrom(), requestDto.getDateTo(), rates); + + when(exchangeRateWebClient.getHistoryCurrencyRate(requestDto, rangeDate)).thenReturn(mockData); + when(exchangeRateProperties.getCacheName()).thenReturn("cn"); + when(exchangeRateProperties.getRangeDateSeparator()).thenReturn(".."); + + historyExchangeRateDataFetcherService.fetchData(requestDto); + + verify(exchangeRateWebClient, times(1)).getHistoryCurrencyRate(requestDto, rangeDate); + } +} diff --git a/src/test/java/com/self/bs/source/service/LatestCurrencyRateDataFetcherServiceTest.java b/src/test/java/com/self/bs/source/service/LatestCurrencyRateDataFetcherServiceTest.java new file mode 100644 index 00000000..4a72c782 --- /dev/null +++ b/src/test/java/com/self/bs/source/service/LatestCurrencyRateDataFetcherServiceTest.java @@ -0,0 +1,61 @@ +package com.self.bs.source.service; + +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.Map; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.test.util.ReflectionTestUtils; + +import com.self.bs.source.config.ExchangeRateProperties; +import com.self.bs.source.dto.request.ExchangeRateDataFetcherRequestDto; +import com.self.bs.source.dto.response.LatestCurrencyRateResponseDto; +import com.self.bs.source.webclient.ExchangeRateWebClient; + +@ExtendWith(MockitoExtension.class) +public class LatestCurrencyRateDataFetcherServiceTest { + @InjectMocks + protected LatestCurrencyRateDataFetcherService latestCurrencyRateDataFetcherService; + + @Mock + protected ExchangeRateWebClient exchangeRateWebClient; + + @Mock + protected ConcurrentMapCacheManager cacheManager; + + @Mock + protected ExchangeRateProperties exchangeRateProperties; + + @BeforeEach + void setup() { + cacheManager = new ConcurrentMapCacheManager(); + + ReflectionTestUtils.setField(latestCurrencyRateDataFetcherService, "cacheManager", cacheManager); + } + + @Test + public void getLatestCurrencyRate_shouldCallGetLatestCurrencyRate_whenInvoke(){ + ExchangeRateDataFetcherRequestDto requestDto = new ExchangeRateDataFetcherRequestDto("2026-03-31", "2026-04-01", "IDR", "USD"); + + Map rates = new HashMap<>(); + rates.put("USD", "0.005"); + + LatestCurrencyRateResponseDto mockData = new LatestCurrencyRateResponseDto("1.5", requestDto.getBaseCurrency(), "2026-04-01", rates, 15.0); + when(exchangeRateWebClient.getLatestCurrencyRate(requestDto)).thenReturn(mockData); + when(exchangeRateProperties.getCacheName()).thenReturn("cn"); + when(exchangeRateProperties.getPersonalName()).thenReturn("bobbisetiawan"); + + latestCurrencyRateDataFetcherService.fetchData(requestDto); + + verify(exchangeRateWebClient, times(1)).getLatestCurrencyRate(requestDto); + } +}