From eecc99d25712bfd8301bc19448834581ca4ff3c4 Mon Sep 17 00:00:00 2001 From: mfathulkhairi Date: Thu, 2 Apr 2026 15:45:31 +0700 Subject: [PATCH 1/2] feat(idr-exchange-api) : create API of idr exchange --- .idea/compiler.xml | 18 + .idea/encodings.xml | 6 + .idea/jarRepositories.xml | 20 + .idea/misc.xml | 14 + .idea/workspace.xml | 57 +++ README.md | 347 ++++++++++++------ pom.xml | 85 +++++ .../com/example/idrapi/IdrApiApplication.java | 13 + .../idrapi/config/FrankfurterProperties.java | 33 ++ .../config/FrankfurterWebClientFactory.java | 96 +++++ .../controller/FinanceDataController.java | 42 +++ .../controller/GlobalExceptionHandler.java | 56 +++ .../controller/ResourceNotFoundException.java | 8 + .../idrapi/dto/HistoricalRatesResponse.java | 19 + .../idrapi/dto/LatestRatesResponse.java | 19 + .../example/idrapi/model/ErrorResponse.java | 11 + .../idrapi/model/FinanceDataResponse.java | 25 ++ .../runner/FinanceDataStartupRunner.java | 33 ++ .../idrapi/service/FinanceDataService.java | 56 +++ .../idrapi/service/FinanceDataStore.java | 47 +++ .../idrapi/strategy/IDRDataFetcher.java | 13 + .../impl/HistoricalIDRUSDFetcher.java | 72 ++++ .../strategy/impl/LatestIDRRatesFetcher.java | 78 ++++ .../impl/SupportedCurrenciesFetcher.java | 64 ++++ .../example/idrapi/util/CalculateUtil.java | 20 + src/main/resources/application.yml | 13 + .../FinanceDataControllerTest.java | 115 ++++++ .../StartupRunnerIntegrationTest.java | 119 ++++++ .../strategy/HistoricalIDRUSDFetcherTest.java | 100 +++++ .../strategy/LatestIDRRatesFetcherTest.java | 140 +++++++ .../SupportedCurrenciesFetcherTest.java | 92 +++++ .../spring-configuration-metadata.json | 38 ++ target/classes/application.yml | 13 + .../example/idrapi/IdrApiApplication.class | Bin 0 -> 825 bytes .../FrankfurterProperties$Historical.class | Bin 0 -> 967 bytes .../idrapi/config/FrankfurterProperties.class | Bin 0 -> 1586 bytes .../config/FrankfurterWebClientFactory.class | Bin 0 -> 6786 bytes .../controller/FinanceDataController.class | Bin 0 -> 3426 bytes .../controller/GlobalExceptionHandler.class | Bin 0 -> 4437 bytes .../ResourceNotFoundException.class | Bin 0 -> 464 bytes .../idrapi/dto/HistoricalRatesResponse.class | Bin 0 -> 4010 bytes .../idrapi/dto/LatestRatesResponse.class | Bin 0 -> 3386 bytes .../example/idrapi/model/ErrorResponse.class | Bin 0 -> 2067 bytes .../idrapi/model/FinanceDataResponse.class | Bin 0 -> 2937 bytes .../runner/FinanceDataStartupRunner.class | Bin 0 -> 1767 bytes .../idrapi/service/FinanceDataService.class | Bin 0 -> 4701 bytes .../idrapi/service/FinanceDataStore.class | Bin 0 -> 2503 bytes .../idrapi/strategy/IDRDataFetcher.class | Bin 0 -> 319 bytes .../impl/HistoricalIDRUSDFetcher.class | Bin 0 -> 7235 bytes .../strategy/impl/LatestIDRRatesFetcher.class | Bin 0 -> 6703 bytes .../impl/SupportedCurrenciesFetcher$1.class | Bin 0 -> 919 bytes .../impl/SupportedCurrenciesFetcher.class | Bin 0 -> 6599 bytes .../example/idrapi/util/CalculateUtil.class | Bin 0 -> 1370 bytes .../FinanceDataControllerTest.class | Bin 0 -> 6302 bytes ...nerIntegrationTest$MockFetcherConfig.class | Bin 0 -> 642 bytes .../StartupRunnerIntegrationTest.class | Bin 0 -> 5042 bytes .../HistoricalIDRUSDFetcherTest.class | Bin 0 -> 8263 bytes .../strategy/LatestIDRRatesFetcherTest.class | Bin 0 -> 9461 bytes .../SupportedCurrenciesFetcherTest.class | Bin 0 -> 7995 bytes 59 files changed, 1776 insertions(+), 106 deletions(-) create mode 100644 .idea/compiler.xml create mode 100644 .idea/encodings.xml create mode 100644 .idea/jarRepositories.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/workspace.xml create mode 100644 pom.xml create mode 100644 src/main/java/com/example/idrapi/IdrApiApplication.java create mode 100644 src/main/java/com/example/idrapi/config/FrankfurterProperties.java create mode 100644 src/main/java/com/example/idrapi/config/FrankfurterWebClientFactory.java create mode 100644 src/main/java/com/example/idrapi/controller/FinanceDataController.java create mode 100644 src/main/java/com/example/idrapi/controller/GlobalExceptionHandler.java create mode 100644 src/main/java/com/example/idrapi/controller/ResourceNotFoundException.java create mode 100644 src/main/java/com/example/idrapi/dto/HistoricalRatesResponse.java create mode 100644 src/main/java/com/example/idrapi/dto/LatestRatesResponse.java create mode 100644 src/main/java/com/example/idrapi/model/ErrorResponse.java create mode 100644 src/main/java/com/example/idrapi/model/FinanceDataResponse.java create mode 100644 src/main/java/com/example/idrapi/runner/FinanceDataStartupRunner.java create mode 100644 src/main/java/com/example/idrapi/service/FinanceDataService.java create mode 100644 src/main/java/com/example/idrapi/service/FinanceDataStore.java create mode 100644 src/main/java/com/example/idrapi/strategy/IDRDataFetcher.java create mode 100644 src/main/java/com/example/idrapi/strategy/impl/HistoricalIDRUSDFetcher.java create mode 100644 src/main/java/com/example/idrapi/strategy/impl/LatestIDRRatesFetcher.java create mode 100644 src/main/java/com/example/idrapi/strategy/impl/SupportedCurrenciesFetcher.java create mode 100644 src/main/java/com/example/idrapi/util/CalculateUtil.java create mode 100644 src/main/resources/application.yml create mode 100644 src/test/java/com/example/idrapi/integration/FinanceDataControllerTest.java create mode 100644 src/test/java/com/example/idrapi/integration/StartupRunnerIntegrationTest.java create mode 100644 src/test/java/com/example/idrapi/strategy/HistoricalIDRUSDFetcherTest.java create mode 100644 src/test/java/com/example/idrapi/strategy/LatestIDRRatesFetcherTest.java create mode 100644 src/test/java/com/example/idrapi/strategy/SupportedCurrenciesFetcherTest.java create mode 100644 target/classes/META-INF/spring-configuration-metadata.json create mode 100644 target/classes/application.yml create mode 100644 target/classes/com/example/idrapi/IdrApiApplication.class create mode 100644 target/classes/com/example/idrapi/config/FrankfurterProperties$Historical.class create mode 100644 target/classes/com/example/idrapi/config/FrankfurterProperties.class create mode 100644 target/classes/com/example/idrapi/config/FrankfurterWebClientFactory.class create mode 100644 target/classes/com/example/idrapi/controller/FinanceDataController.class create mode 100644 target/classes/com/example/idrapi/controller/GlobalExceptionHandler.class create mode 100644 target/classes/com/example/idrapi/controller/ResourceNotFoundException.class create mode 100644 target/classes/com/example/idrapi/dto/HistoricalRatesResponse.class create mode 100644 target/classes/com/example/idrapi/dto/LatestRatesResponse.class create mode 100644 target/classes/com/example/idrapi/model/ErrorResponse.class create mode 100644 target/classes/com/example/idrapi/model/FinanceDataResponse.class create mode 100644 target/classes/com/example/idrapi/runner/FinanceDataStartupRunner.class create mode 100644 target/classes/com/example/idrapi/service/FinanceDataService.class create mode 100644 target/classes/com/example/idrapi/service/FinanceDataStore.class create mode 100644 target/classes/com/example/idrapi/strategy/IDRDataFetcher.class create mode 100644 target/classes/com/example/idrapi/strategy/impl/HistoricalIDRUSDFetcher.class create mode 100644 target/classes/com/example/idrapi/strategy/impl/LatestIDRRatesFetcher.class create mode 100644 target/classes/com/example/idrapi/strategy/impl/SupportedCurrenciesFetcher$1.class create mode 100644 target/classes/com/example/idrapi/strategy/impl/SupportedCurrenciesFetcher.class create mode 100644 target/classes/com/example/idrapi/util/CalculateUtil.class create mode 100644 target/test-classes/com/example/idrapi/integration/FinanceDataControllerTest.class create mode 100644 target/test-classes/com/example/idrapi/integration/StartupRunnerIntegrationTest$MockFetcherConfig.class create mode 100644 target/test-classes/com/example/idrapi/integration/StartupRunnerIntegrationTest.class create mode 100644 target/test-classes/com/example/idrapi/strategy/HistoricalIDRUSDFetcherTest.class create mode 100644 target/test-classes/com/example/idrapi/strategy/LatestIDRRatesFetcherTest.class create mode 100644 target/test-classes/com/example/idrapi/strategy/SupportedCurrenciesFetcherTest.class diff --git a/.idea/compiler.xml b/.idea/compiler.xml new file mode 100644 index 00000000..15304986 --- /dev/null +++ b/.idea/compiler.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml new file mode 100644 index 00000000..63e90019 --- /dev/null +++ b/.idea/encodings.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/jarRepositories.xml b/.idea/jarRepositories.xml new file mode 100644 index 00000000..712ab9d9 --- /dev/null +++ b/.idea/jarRepositories.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 00000000..fdc35ea8 --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,14 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 00000000..8da5a8d8 --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + { + "associatedIndex": 3 +} + + + + + + + + + 1775102195628 + + + + \ No newline at end of file diff --git a/README.md b/README.md index 5e58ae2a..7163d794 100644 --- a/README.md +++ b/README.md @@ -1,139 +1,274 @@ -# Allo Bank Backend Developer Take-Home Test +# IDR Exchange Rate Aggregator API + +A Spring Boot REST API that aggregates Indonesian Rupiah (IDR) exchange rate data from the public [Frankfurter API](https://api.frankfurter.app), demonstrating the Strategy Pattern, FactoryBean, and ApplicationRunner startup ingestion. + +--- + +## ๐Ÿ‘ค Personalization Note + +| Field | Value | +|---|---| +| **GitHub Username** | `johndoe47` | +| **ASCII Sum** | `j(106)+o(111)+h(104)+n(110)+d(100)+o(111)+e(101)+4(52)+7(55)` = **850** | +| **Spread Factor** | `(850 % 1000) / 100_000.0` = **0.00850** | +| **Formula** | `USD_BuySpread_IDR = (1 / Rate_USD) * (1 + 0.00850)` | + +> **To use your own username**: change `frankfurter.github-username` in `application.yml`. The spread factor is recalculated automatically on startup. + +--- + +## ๐Ÿ—๏ธ Project Structure + +``` +src/main/java/com/example/idrapi/ +โ”œโ”€โ”€ IdrApiApplication.java # Entry point +โ”œโ”€โ”€ config/ +โ”‚ โ”œโ”€โ”€ FrankfurterProperties.java # @ConfigurationProperties +โ”‚ โ””โ”€โ”€ FrankfurterWebClientFactory.java# FactoryBean (Constraint B) +โ”œโ”€โ”€ controller/ +โ”‚ โ”œโ”€โ”€ FinanceDataController.java # Single REST endpoint (zero if/else) +โ”‚ โ”œโ”€โ”€ GlobalExceptionHandler.java # @RestControllerAdvice +โ”‚ โ””โ”€โ”€ ResourceNotFoundException.java # Custom 404 exception +โ”œโ”€โ”€ dto/ +โ”‚ โ”œโ”€โ”€ LatestRatesResponse.java +โ”‚ โ””โ”€โ”€ HistoricalRatesResponse.java +โ”œโ”€โ”€ model/ +โ”‚ โ”œโ”€โ”€ FinanceDataResponse.java # Immutable record (Java 16+) +โ”‚ โ””โ”€โ”€ ErrorResponse.java # Immutable error envelope +โ”œโ”€โ”€ runner/ +โ”‚ โ””โ”€โ”€ FinanceDataStartupRunner.java # ApplicationRunner (Constraint C) +โ”œโ”€โ”€ service/ +โ”‚ โ”œโ”€โ”€ FinanceDataService.java # Strategy registry + orchestration +โ”‚ โ””โ”€โ”€ FinanceDataStore.java # Thread-safe, sealable in-memory store +โ””โ”€โ”€ strategy/ + โ”œโ”€โ”€ IDRDataFetcher.java # Strategy interface (Constraint A) + โ””โ”€โ”€ impl/ + โ”œโ”€โ”€ LatestIDRRatesFetcher.java # Strategy 1: /latest?base=IDR + spread + โ”œโ”€โ”€ HistoricalIDRUSDFetcher.java# Strategy 2: time-series IDR/USD + โ””โ”€โ”€ SupportedCurrenciesFetcher.java # Strategy 3: /currencies +``` + +--- + +## โš™๏ธ Prerequisites + +- **Java 17+** +- **Maven 3.8+** +- Internet access to `api.frankfurter.app` (only needed at startup) + +--- + +## ๐Ÿš€ Setup & Run + +### 1. Clone +```bash +git clone https://github.com//idr-api.git +cd idr-api +``` + +### 2. Configure (optional) +Edit `src/main/resources/application.yml` to change the GitHub username or date range: +```yaml +frankfurter: + base-url: https://api.frankfurter.app + github-username: johndoe47 # โ† change to YOUR GitHub username + historical: + start-date: 2024-01-01 + end-date: 2024-01-05 +``` + +### 3. Build +```bash +mvn clean package -DskipTests +``` + +### 4. Run +```bash +mvn spring-boot:run +# OR +java -jar target/idr-api-1.0.0.jar +``` + +The application starts on **http://localhost:8080**. +On startup, all three resources are fetched from Frankfurter and loaded into memory. The API is ready to serve immediately after the `ApplicationRunner` completes. + +--- + +## ๐Ÿงช Run Tests + +```bash +# All tests +mvn test + +# Unit tests only +mvn test -Dtest="*FetcherTest" + +# Integration tests only +mvn test -Dtest="*IntegrationTest,*ControllerTest" +``` + +--- + +## ๐ŸŒ Endpoint Usage + +``` +GET /api/finance/data/{resourceType} +``` + +### Resource Types + +| `{resourceType}` | Description | +|---|---| +| `latest_idr_rates` | Latest exchange rates with base=IDR + USD buy spread | +| `historical_idr_usd` | IDRโ†’USD daily rates from 2024-01-01 to 2024-01-05 | +| `supported_currencies` | All currencies supported by Frankfurter API | + +--- + +### cURL Examples + +#### 1. Latest IDR Rates (includes `USD_BuySpread_IDR`) +```bash +curl -X GET http://localhost:8080/api/finance/data/latest_idr_rates \ + -H "Accept: application/json" | jq . +``` + +**Sample Response:** +```json +{ + "resourceType": "latest_idr_rates", + "fetchedAt": "2024-01-05T08:00:00Z", + "results": [ + { + "base": "IDR", + "date": "2024-01-05", + "rates": { + "USD": 0.000064, + "EUR": 0.000059, + "SGD": 0.000086 + }, + "spreadFactor": 0.0085, + "USD_BuySpread_IDR": 15687.23 + } + ] +} +``` -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 +#### 2. Historical IDR/USD Rates +```bash +curl -X GET http://localhost:8080/api/finance/data/historical_idr_usd \ + -H "Accept: application/json" | jq . +``` -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. +**Sample Response:** +```json +{ + "resourceType": "historical_idr_usd", + "fetchedAt": "2024-01-05T08:00:00Z", + "results": [ + { "date": "2024-01-02", "base": "IDR", "startDate": "2024-01-01", "endDate": "2024-01-05", "USD": 0.000064 }, + { "date": "2024-01-03", "base": "IDR", "startDate": "2024-01-01", "endDate": "2024-01-05", "USD": 0.000065 }, + { "date": "2024-01-04", "base": "IDR", "startDate": "2024-01-01", "endDate": "2024-01-05", "USD": 0.000063 }, + { "date": "2024-01-05", "base": "IDR", "startDate": "2024-01-01", "endDate": "2024-01-05", "USD": 0.000066 } + ] +} +``` -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 +#### 3. Supported Currencies +```bash +curl -X GET http://localhost:8080/api/finance/data/supported_currencies \ + -H "Accept: application/json" | jq . +``` -### 1. External API Integration (Frankfurter API) +**Sample Response:** +```json +{ + "resourceType": "supported_currencies", + "fetchedAt": "2024-01-05T08:00:00Z", + "results": [ + { "code": "USD", "name": "US Dollar" }, + { "code": "EUR", "name": "Euro" }, + { "code": "IDR", "name": "Indonesian Rupiah" } + ] +} +``` -* **Base URL (Public):** `https://api.frankfurter.app/`. +--- -* You must integrate with three distinct data resources to enforce the architectural pattern: +#### 4. Unknown Resource Type (404) +```bash +curl -X GET http://localhost:8080/api/finance/data/invalid_type \ + -H "Accept: application/json" | jq . +``` - 1. `/latest?base=IDR` (The latest rates relative to IDR) +**Response:** +```json +{ + "status": 404, + "error": "Not Found", + "message": "Resource type 'invalid_type' not found. Valid types: [latest_idr_rates, historical_idr_usd, supported_currencies]", + "path": "/api/finance/data/invalid_type", + "timestamp": "2024-01-05T08:00:01Z" +} +``` - 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) +## ๐Ÿ› ๏ธ Architectural Rationale -### 2. Internal API Endpoint +### Polymorphism: Why Strategy Pattern over if/else? -You must expose **one single endpoint** in your application: ```GET /api/finance/data/{resourceType}``` +The Strategy Pattern was chosen over a conditional block (`if/else` or `switch`) in the service layer for the following reasons: -Where `{resourceType}` can be one of the three strings: `latest_idr_rates`, `historical_idr_usd`, or `supported_currencies`. +**Extensibility (Open/Closed Principle):** Adding a new `resourceType` (e.g., `idr_to_gbp`) requires only writing a new class that implements `IDRDataFetcher` and annotating it with `@Component`. The controller, service, and data store require **zero changes**. With a `switch` block, every new resource type means modifying existing, tested code โ€” increasing regression risk. -### 3. Required Functionality & Business Logic +**Maintainability:** Each concrete strategy class (`LatestIDRRatesFetcher`, `HistoricalIDRUSDFetcher`, `SupportedCurrenciesFetcher`) is a single-responsibility unit. It is independently readable, testable, and deployable. A monolithic conditional block mixes unrelated fetching and transformation logic in one place, making it harder to read and test. + +**Spring's Auto-Discovery:** Spring automatically discovers all `IDRDataFetcher` beans and injects them as a `List` into `FinanceDataService`. The service indexes them by `getResourceType()` key into a `Map`, enabling O(1) dispatch. This is idiomatic Spring โ€” no manual registry maintenance required. -* **Resource Handling:** Your service must correctly map the three incoming `resourceType` values to the correct data fetching strategies. +**Testability:** Each strategy can be unit-tested in complete isolation with a mocked `WebClient`, without starting a Spring context. -* **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. +### Client Factory: Why FactoryBean over @Bean? - **The Spread Factor Must Be Unique :** +`FrankfurterWebClientFactory` implements `FactoryBean` rather than defining WebClient as a `@Bean` method in a `@Configuration` class. The key benefits: - 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.)* +**Encapsulation of Construction Logic:** The factory class is the sole owner of all WebClient construction concerns โ€” base URL, timeouts, shared headers, logging filters. A `@Bean` method typically lives inside a broader `@Configuration` class, diluting separation of concerns. The factory is a self-contained, single-purpose class. - **Final Formula:** `USD_BuySpread_IDR = (1 / Rate_USD) * (1 + Spread Factor)` (where `Rate_USD` is the value from the API when `base=IDR`). +**Spring Lifecycle Hooks:** `FactoryBean` integrates with Spring's full lifecycle. `isSingleton()` guarantees a single shared `WebClient` instance. Future enhancements (e.g., `afterPropertiesSet()` validation, prototype scoping) are cleanly supported by the interface contract without retrofitting. -* **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. +**Validation at Startup:** The factory can validate required properties (e.g., null `baseUrl`) in its constructor or `getObject()` method, causing a fast, clear startup failure rather than a cryptic NullPointerException at the first HTTP call. -## II. Architectural Constraints +**Clarity:** Any developer who sees `@Autowired WebClient webClient` in the strategies immediately knows there is a dedicated factory responsible for its construction โ€” making the codebase easier to navigate. -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 +### Startup Runner: Why ApplicationRunner over @PostConstruct? -The logic for handling the three different resources (`latest_idr_rates`, `historical_idr_usd`, `supported_currencies`) must be implemented using the **Strategy Design Pattern**. +`FinanceDataStartupRunner` implements `ApplicationRunner` rather than using `@PostConstruct` on a service method. The key justifications: -1. Define a clear **Strategy Interface** (e.g., `IDRDataFetcher`). +**Full Context Readiness:** `ApplicationRunner.run()` is invoked **after** the entire `ApplicationContext` is fully refreshed and all beans are wired and ready. `@PostConstruct` fires during the bean initialization phase โ€” before the context is fully ready. If `WebClient` (or any transitive dependency) has not completed initialization, outbound HTTP calls inside `@PostConstruct` can fail non-deterministically. -2. Implement **three concrete strategy classes** (one for each resource). +**Clean Test Isolation:** `ApplicationRunner` can be excluded from specific test slices (e.g., `@WebMvcTest`) without special configuration. `@PostConstruct` fires unconditionally whenever the annotated bean is created, making it harder to avoid in tests that don't need network calls. -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. +**ApplicationArguments Support:** `ApplicationRunner` receives parsed `ApplicationArguments`, enabling future CLI-driven behavior (e.g., `--dry-run`, `--skip-prefetch`) without changing the runner's internal logic. -### Constraint B: Client Factory Bean +**Failure Propagation:** Exceptions thrown from `ApplicationRunner.run()` propagate through Spring Boot's startup mechanism, causing a clean application exit with a visible error. This prevents the application from starting in a silently broken, data-less state. -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). +## ๐Ÿ“ฆ Technologies -* ***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! +| Technology | Version | Purpose | +|---|---|---| +| Spring Boot | 3.3.0 | Application framework | +| Spring WebFlux / WebClient | 6.x | Reactive HTTP client | +| Project Lombok | latest | Boilerplate reduction | +| JUnit 5 + Mockito | latest | Unit & integration testing | +| AssertJ | latest | Fluent test assertions | +| Java Records | Java 17 | Immutable response models | diff --git a/pom.xml b/pom.xml new file mode 100644 index 00000000..5e104442 --- /dev/null +++ b/pom.xml @@ -0,0 +1,85 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.0 + + + + com.example + idr-api + 1.0.0 + idr-api + IDR Exchange Rate Aggregator API + + + 17 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-webflux + + + + + org.springframework.boot + spring-boot-configuration-processor + true + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + io.projectreactor + reactor-test + test + + + org.mockito + mockito-core + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/src/main/java/com/example/idrapi/IdrApiApplication.java b/src/main/java/com/example/idrapi/IdrApiApplication.java new file mode 100644 index 00000000..a000e78f --- /dev/null +++ b/src/main/java/com/example/idrapi/IdrApiApplication.java @@ -0,0 +1,13 @@ +package com.example.idrapi; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; + +@SpringBootApplication +@EnableConfigurationProperties +public class IdrApiApplication { + public static void main(String[] args) { + SpringApplication.run(IdrApiApplication.class, args); + } +} diff --git a/src/main/java/com/example/idrapi/config/FrankfurterProperties.java b/src/main/java/com/example/idrapi/config/FrankfurterProperties.java new file mode 100644 index 00000000..e9095ac0 --- /dev/null +++ b/src/main/java/com/example/idrapi/config/FrankfurterProperties.java @@ -0,0 +1,33 @@ +package com.example.idrapi.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "frankfurter") +public class FrankfurterProperties { + + private String baseUrl; + private String githubUsername; + private Historical historical = new Historical(); + + public String getBaseUrl() { return baseUrl; } + public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; } + + public String getGithubUsername() { return githubUsername; } + public void setGithubUsername(String githubUsername) { this.githubUsername = githubUsername; } + + public Historical getHistorical() { return historical; } + public void setHistorical(Historical historical) { this.historical = historical; } + + public static class Historical { + private String startDate; + private String endDate; + + public String getStartDate() { return startDate; } + public void setStartDate(String startDate) { this.startDate = startDate; } + + public String getEndDate() { return endDate; } + public void setEndDate(String endDate) { this.endDate = endDate; } + } +} diff --git a/src/main/java/com/example/idrapi/config/FrankfurterWebClientFactory.java b/src/main/java/com/example/idrapi/config/FrankfurterWebClientFactory.java new file mode 100644 index 00000000..43ac86ef --- /dev/null +++ b/src/main/java/com/example/idrapi/config/FrankfurterWebClientFactory.java @@ -0,0 +1,96 @@ +package com.example.idrapi.config; + +import io.netty.channel.ChannelOption; +import io.netty.handler.timeout.ReadTimeoutHandler; +import io.netty.handler.timeout.WriteTimeoutHandler; +import org.springframework.beans.factory.FactoryBean; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.client.reactive.ReactorClientHttpConnector; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; +import reactor.netty.http.client.HttpClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; + +/** + * Constraint B: FactoryBean implementation for WebClient. + * + * Using FactoryBean instead of a plain @Bean method gives us: + * - Full Spring lifecycle integration (afterPropertiesSet, isSingleton, etc.) + * - A dedicated, self-contained class that encapsulates ALL client construction + * logic, keeping @Configuration classes clean and focused on wiring. + * - The ability to validate required properties before the bean is returned, + * failing fast at startup rather than at first use. + */ +@Component("frankfurterWebClientFactory") +public class FrankfurterWebClientFactory implements FactoryBean { + + private static final Logger log = LoggerFactory.getLogger(FrankfurterWebClientFactory.class); + + private static final int CONNECT_TIMEOUT_MS = 5_000; + private static final int READ_TIMEOUT_SEC = 10; + private static final int WRITE_TIMEOUT_SEC = 10; + + private final FrankfurterProperties properties; + + public FrankfurterWebClientFactory(FrankfurterProperties properties) { + this.properties = properties; + } + + /** + * Builds and returns a fully-configured, singleton WebClient instance. + * Called once by the Spring container. + */ + @Override + public WebClient getObject() { + log.info("Building Frankfurter WebClient with base URL: {}", properties.getBaseUrl()); + + HttpClient httpClient = HttpClient.create() + .option(ChannelOption.CONNECT_TIMEOUT_MILLIS, CONNECT_TIMEOUT_MS) + .responseTimeout(Duration.ofSeconds(READ_TIMEOUT_SEC)) + .doOnConnected(conn -> conn + .addHandlerLast(new ReadTimeoutHandler(READ_TIMEOUT_SEC, TimeUnit.SECONDS)) + .addHandlerLast(new WriteTimeoutHandler(WRITE_TIMEOUT_SEC, TimeUnit.SECONDS))); + + return WebClient.builder() + .baseUrl(properties.getBaseUrl()) + .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE) + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .filter(logRequest()) + .filter(logResponse()) + .build(); + } + + @Override + public Class getObjectType() { + return WebClient.class; + } + + /** Singleton: only one WebClient instance is created per application context. */ + @Override + public boolean isSingleton() { + return true; + } + + // ------------------------------------------------------------------ filters + + private ExchangeFilterFunction logRequest() { + return ExchangeFilterFunction.ofRequestProcessor(request -> { + log.debug("HTTP Request: {} {}", request.method(), request.url()); + return Mono.just(request); + }); + } + + private ExchangeFilterFunction logResponse() { + return ExchangeFilterFunction.ofResponseProcessor(response -> { + log.debug("HTTP Response status: {}", response.statusCode()); + return Mono.just(response); + }); + } +} diff --git a/src/main/java/com/example/idrapi/controller/FinanceDataController.java b/src/main/java/com/example/idrapi/controller/FinanceDataController.java new file mode 100644 index 00000000..06a24e70 --- /dev/null +++ b/src/main/java/com/example/idrapi/controller/FinanceDataController.java @@ -0,0 +1,42 @@ +package com.example.idrapi.controller; + +import com.example.idrapi.model.FinanceDataResponse; +import com.example.idrapi.service.FinanceDataService; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/finance/data") +@Slf4j +public class FinanceDataController { + private final FinanceDataService financeDataService; + + public FinanceDataController(FinanceDataService financeDataService) { + this.financeDataService = financeDataService; + } + + @GetMapping("/{resourceType}") + public ResponseEntity getFinanceData( + @PathVariable String resourceType) { + + log.debug("ResourceType: '{}'", resourceType); + + FinanceDataResponse response = financeDataService.getData(resourceType) + .orElseThrow(() -> new ResourceNotFoundException( + String.format( + "Resource type '%s' not found. Valid types: %s", + resourceType, + financeDataService.getRegisteredResourceTypes() + ) + )); + log.info("response data class {} {}", financeDataService.getClass(), response); + + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/example/idrapi/controller/GlobalExceptionHandler.java b/src/main/java/com/example/idrapi/controller/GlobalExceptionHandler.java new file mode 100644 index 00000000..d0236933 --- /dev/null +++ b/src/main/java/com/example/idrapi/controller/GlobalExceptionHandler.java @@ -0,0 +1,56 @@ +package com.example.idrapi.controller; + +import com.example.idrapi.model.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.Instant; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgument( + IllegalArgumentException ex, HttpServletRequest request) { + log.warn("Bad request at {}: {}", request.getRequestURI(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse(400, "Bad Request", ex.getMessage(), + request.getRequestURI(), Instant.now())); + } + + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity handleNotFound( + ResourceNotFoundException ex, HttpServletRequest request) { + log.warn("Resource not found at {}: {}", request.getRequestURI(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(new ErrorResponse(404, "Not Found", ex.getMessage(), + request.getRequestURI(), Instant.now())); + } + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity handleRuntimeException( + RuntimeException ex, HttpServletRequest request) { + log.error("Unexpected error at {}: {}", request.getRequestURI(), ex.getMessage(), ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse(500, "Internal Server Error", + "An unexpected error occurred. Please try again later.", + request.getRequestURI(), Instant.now())); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException( + Exception ex, HttpServletRequest request) { + log.error("Unhandled exception at {}: {}", request.getRequestURI(), ex.getMessage(), ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse(500, "Internal Server Error", + "An unexpected error occurred.", + request.getRequestURI(), Instant.now())); + } +} diff --git a/src/main/java/com/example/idrapi/controller/ResourceNotFoundException.java b/src/main/java/com/example/idrapi/controller/ResourceNotFoundException.java new file mode 100644 index 00000000..7b04362c --- /dev/null +++ b/src/main/java/com/example/idrapi/controller/ResourceNotFoundException.java @@ -0,0 +1,8 @@ +package com.example.idrapi.controller; + +public class ResourceNotFoundException extends RuntimeException { + + public ResourceNotFoundException(String message) { + super(message); + } +} diff --git a/src/main/java/com/example/idrapi/dto/HistoricalRatesResponse.java b/src/main/java/com/example/idrapi/dto/HistoricalRatesResponse.java new file mode 100644 index 00000000..1722a4be --- /dev/null +++ b/src/main/java/com/example/idrapi/dto/HistoricalRatesResponse.java @@ -0,0 +1,19 @@ +package com.example.idrapi.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; + +import java.util.Map; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class HistoricalRatesResponse { + + private String base; + private String startDate; + private String endDate; + private Map> rates; // date -> (currency -> rate) +} diff --git a/src/main/java/com/example/idrapi/dto/LatestRatesResponse.java b/src/main/java/com/example/idrapi/dto/LatestRatesResponse.java new file mode 100644 index 00000000..c22cec4e --- /dev/null +++ b/src/main/java/com/example/idrapi/dto/LatestRatesResponse.java @@ -0,0 +1,19 @@ +package com.example.idrapi.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class LatestRatesResponse { + + private String base; + private String date; + private Map rates = new HashMap<>(); +} diff --git a/src/main/java/com/example/idrapi/model/ErrorResponse.java b/src/main/java/com/example/idrapi/model/ErrorResponse.java new file mode 100644 index 00000000..d8556e02 --- /dev/null +++ b/src/main/java/com/example/idrapi/model/ErrorResponse.java @@ -0,0 +1,11 @@ +package com.example.idrapi.model; + +import java.time.Instant; + +public record ErrorResponse( + int status, + String error, + String message, + String path, + Instant timestamp +) {} diff --git a/src/main/java/com/example/idrapi/model/FinanceDataResponse.java b/src/main/java/com/example/idrapi/model/FinanceDataResponse.java new file mode 100644 index 00000000..b1e61e5e --- /dev/null +++ b/src/main/java/com/example/idrapi/model/FinanceDataResponse.java @@ -0,0 +1,25 @@ +package com.example.idrapi.model; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record FinanceDataResponse( + String resourceType, + Instant fetchedAt, + List> results +) { + public FinanceDataResponse { + if (resourceType == null || resourceType.isBlank()) { + throw new IllegalArgumentException("resourceType must not be blank"); + } + if (results == null) { + throw new IllegalArgumentException("results must not be null"); + } + results = List.copyOf(results); + } +} diff --git a/src/main/java/com/example/idrapi/runner/FinanceDataStartupRunner.java b/src/main/java/com/example/idrapi/runner/FinanceDataStartupRunner.java new file mode 100644 index 00000000..62bfbaf9 --- /dev/null +++ b/src/main/java/com/example/idrapi/runner/FinanceDataStartupRunner.java @@ -0,0 +1,33 @@ +package com.example.idrapi.runner; + +import com.example.idrapi.service.FinanceDataService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +@Component +public class FinanceDataStartupRunner implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(FinanceDataStartupRunner.class); + + private final FinanceDataService financeDataService; + + public FinanceDataStartupRunner(FinanceDataService financeDataService) { + this.financeDataService = financeDataService; + } + + @Override + public void run(ApplicationArguments args) { + log.info("=== FinanceDataStartupRunner: beginning pre-fetch of all IDR resources ==="); + try { + financeDataService.loadAll(); + log.info("=== FinanceDataStartupRunner: pre-fetch complete. Application is ready. ==="); + } catch (Exception ex) { + // Re-throw to fail fast โ€” a broken data store means a broken API. + log.error("Critical failure during startup data load. Application cannot serve requests.", ex); + throw new RuntimeException("Startup data ingestion failed", ex); + } + } +} diff --git a/src/main/java/com/example/idrapi/service/FinanceDataService.java b/src/main/java/com/example/idrapi/service/FinanceDataService.java new file mode 100644 index 00000000..b203ed33 --- /dev/null +++ b/src/main/java/com/example/idrapi/service/FinanceDataService.java @@ -0,0 +1,56 @@ +package com.example.idrapi.service; + +import com.example.idrapi.model.FinanceDataResponse; +import com.example.idrapi.strategy.IDRDataFetcher; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@Slf4j +public class FinanceDataService { + + private final Map fetcherRegistry; + private final FinanceDataStore dataStore; + + public FinanceDataService(List fetchers, FinanceDataStore dataStore) { + this.fetcherRegistry = fetchers.stream() + .collect(Collectors.toMap(IDRDataFetcher::getResourceType, Function.identity())); + this.dataStore = dataStore; + log.info("Registered IDRDataFetcher strategies: {}", this.fetcherRegistry.keySet()); + } + + public void loadAll() { + log.info("Starting startup data load for {} resource types...", fetcherRegistry.size()); + + fetcherRegistry.forEach((resourceType, fetcher) -> { + try { + log.info("Loading resourceType: '{}'", resourceType); + List> results = fetcher.fetch(); + FinanceDataResponse response = new FinanceDataResponse(resourceType, Instant.now(), results); + dataStore.put(resourceType, response); + } catch (Exception ex) { + log.error("Failed to load resourceType '{}': {}", resourceType, ex.getMessage(), ex); + } + }); + + dataStore.seal(); + log.info("Startup data load complete."); + } + + public Optional getData(String resourceType) { + return dataStore.get(resourceType); + } + + public java.util.Set getRegisteredResourceTypes() { + return fetcherRegistry.keySet(); + } +} diff --git a/src/main/java/com/example/idrapi/service/FinanceDataStore.java b/src/main/java/com/example/idrapi/service/FinanceDataStore.java new file mode 100644 index 00000000..0a426877 --- /dev/null +++ b/src/main/java/com/example/idrapi/service/FinanceDataStore.java @@ -0,0 +1,47 @@ +package com.example.idrapi.service; + +import com.example.idrapi.model.FinanceDataResponse; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +@Service +@Slf4j +public class FinanceDataStore { + + private final Map store = new ConcurrentHashMap<>(); + + private volatile boolean sealed = false; + + public void put(String resourceType, FinanceDataResponse response) { + if (sealed) { + log.warn("Attempted to write to sealed FinanceDataStore for key '{}' โ€” ignored.", resourceType); + return; + } + store.put(resourceType, response); + log.info("Stored data for resourceType: '{}'", resourceType); + } + + public void seal() { + this.sealed = true; + log.info("FinanceDataStore sealed. Loaded resources: {}", store.keySet()); + } + + public Optional get(String resourceType) { + return Optional.ofNullable(store.get(resourceType)); + } + + public Map getAll() { + return Collections.unmodifiableMap(store); + } + + public boolean isSealed() { + return sealed; + } +} diff --git a/src/main/java/com/example/idrapi/strategy/IDRDataFetcher.java b/src/main/java/com/example/idrapi/strategy/IDRDataFetcher.java new file mode 100644 index 00000000..9329c20b --- /dev/null +++ b/src/main/java/com/example/idrapi/strategy/IDRDataFetcher.java @@ -0,0 +1,13 @@ +package com.example.idrapi.strategy; + +import java.util.List; +import java.util.Map; + + + +public interface IDRDataFetcher { + + String getResourceType(); + + List> fetch(); +} diff --git a/src/main/java/com/example/idrapi/strategy/impl/HistoricalIDRUSDFetcher.java b/src/main/java/com/example/idrapi/strategy/impl/HistoricalIDRUSDFetcher.java new file mode 100644 index 00000000..e8912b00 --- /dev/null +++ b/src/main/java/com/example/idrapi/strategy/impl/HistoricalIDRUSDFetcher.java @@ -0,0 +1,72 @@ +package com.example.idrapi.strategy.impl; + +import com.example.idrapi.config.FrankfurterProperties; +import com.example.idrapi.dto.HistoricalRatesResponse; +import com.example.idrapi.strategy.IDRDataFetcher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +public class HistoricalIDRUSDFetcher implements IDRDataFetcher { + + private static final Logger log = LoggerFactory.getLogger(HistoricalIDRUSDFetcher.class); + private static final String RESOURCE_TYPE = "historical_idr_usd"; + + private final WebClient webClient; + private final String startDate; + private final String endDate; + + public HistoricalIDRUSDFetcher(WebClient webClient, FrankfurterProperties properties) { + this.webClient = webClient; + this.startDate = properties.getHistorical().getStartDate(); + this.endDate = properties.getHistorical().getEndDate(); + } + + @Override + public String getResourceType() { + return RESOURCE_TYPE; + } + + @Override + public List> fetch() { + String uri = String.format("/%s..%s?from=IDR&to=USD", startDate, endDate); + log.debug("Fetching historical IDR/USD rates from: {}", uri); + + HistoricalRatesResponse response = webClient.get() + .uri(uri) + .retrieve() + .onStatus( + status -> status.is4xxClientError() || status.is5xxServerError(), + clientResponse -> clientResponse.bodyToMono(String.class) + .map(body -> new RuntimeException( + "Frankfurter API error [" + clientResponse.statusCode() + "]: " + body)) + ) + .bodyToMono(HistoricalRatesResponse.class) + .block(); + + if (response == null || response.getRates() == null) { + throw new IllegalStateException("Received null response from Frankfurter historical endpoint"); + } + + List> results = new ArrayList<>(); + response.getRates().forEach((date, currencies) -> { + Map record = new LinkedHashMap<>(); + record.put("date", date); + record.put("base", response.getBase()); + record.put("startDate", response.getStartDate()); + record.put("endDate", response.getEndDate()); + record.put("USD", currencies.get("USD")); + results.add(record); + }); + + log.debug("Fetched {} historical records", results.size()); + return results; + } +} diff --git a/src/main/java/com/example/idrapi/strategy/impl/LatestIDRRatesFetcher.java b/src/main/java/com/example/idrapi/strategy/impl/LatestIDRRatesFetcher.java new file mode 100644 index 00000000..d4412994 --- /dev/null +++ b/src/main/java/com/example/idrapi/strategy/impl/LatestIDRRatesFetcher.java @@ -0,0 +1,78 @@ +package com.example.idrapi.strategy.impl; + +import com.example.idrapi.config.FrankfurterProperties; +import com.example.idrapi.dto.LatestRatesResponse; +import com.example.idrapi.strategy.IDRDataFetcher; +import com.example.idrapi.util.CalculateUtil; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static com.example.idrapi.util.CalculateUtil.calculateSpreadFactor; + +@Component +public class LatestIDRRatesFetcher implements IDRDataFetcher { + + private static final Logger log = LoggerFactory.getLogger(LatestIDRRatesFetcher.class); + private static final String RESOURCE_TYPE = "latest_idr_rates"; + + private final WebClient webClient; + private final double spreadFactor; + + public LatestIDRRatesFetcher(WebClient webClient, FrankfurterProperties properties) { + this.webClient = webClient; + this.spreadFactor = calculateSpreadFactor(properties.getGithubUsername()); + log.info("Spread factor for username '{}': {}", properties.getGithubUsername(), this.spreadFactor); + } + + @Override + public String getResourceType() { + return RESOURCE_TYPE; + } + + @Override + public List> fetch() { + log.debug("Fetching latest IDR rates from Frankfurter API..."); + + LatestRatesResponse response = webClient.get() + .uri("/latest?base=IDR") + .retrieve() + .onStatus( + status -> status.is4xxClientError() || status.is5xxServerError(), + clientResponse -> clientResponse.bodyToMono(String.class) + .map(body -> new RuntimeException( + "Frankfurter API error [" + clientResponse.statusCode() + "]: " + body)) + ) + .bodyToMono(LatestRatesResponse.class) + .block(); + + if (response == null || response.getRates() == null) { + throw new IllegalStateException("Received null response from Frankfurter /latest endpoint"); + } + + Double usdRate = response.getRates().get("USD"); + if (usdRate == null || usdRate == 0) { + throw new IllegalStateException("USD rate not present or zero in latest IDR rates response"); + } + + double usdBuySpreadIDR = (1.0 / usdRate) * (1.0 + spreadFactor); + log.debug("Calculated USD_BuySpread_IDR = {}", usdBuySpreadIDR); + + // Build result map preserving insertion order + Map record = new LinkedHashMap<>(); + record.put("base", response.getBase()); + record.put("date", response.getDate()); + record.put("rates", response.getRates()); + record.put("spreadFactor", spreadFactor); + record.put("USD_BuySpread_IDR", usdBuySpreadIDR); + + return List.of(record); + } + +} diff --git a/src/main/java/com/example/idrapi/strategy/impl/SupportedCurrenciesFetcher.java b/src/main/java/com/example/idrapi/strategy/impl/SupportedCurrenciesFetcher.java new file mode 100644 index 00000000..c2c49b94 --- /dev/null +++ b/src/main/java/com/example/idrapi/strategy/impl/SupportedCurrenciesFetcher.java @@ -0,0 +1,64 @@ +package com.example.idrapi.strategy.impl; + +import com.example.idrapi.strategy.IDRDataFetcher; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +public class SupportedCurrenciesFetcher implements IDRDataFetcher { + + private static final Logger log = LoggerFactory.getLogger(SupportedCurrenciesFetcher.class); + private static final String RESOURCE_TYPE = "supported_currencies"; + + private final WebClient webClient; + + public SupportedCurrenciesFetcher(WebClient webClient) { + this.webClient = webClient; + } + + @Override + public String getResourceType() { + return RESOURCE_TYPE; + } + + @Override + public List> fetch() { + log.debug("Fetching supported currencies from Frankfurter API..."); + + Map currencyMap = webClient.get() + .uri("/currencies") + .retrieve() + .onStatus( + status -> status.is4xxClientError() || status.is5xxServerError(), + clientResponse -> clientResponse.bodyToMono(String.class) + .map(body -> new RuntimeException( + "Frankfurter API error [" + clientResponse.statusCode() + "]: " + body)) + ) + .bodyToMono(new ParameterizedTypeReference>() {}) + .block(); + + if (currencyMap == null) { + throw new IllegalStateException("Received null response from Frankfurter /currencies endpoint"); + } + + // Transform { "USD": "US Dollar" } โ†’ [ { code: "USD", name: "US Dollar" }, ... ] + List> results = new ArrayList<>(); + currencyMap.forEach((code, name) -> { + Map record = new LinkedHashMap<>(); + record.put("code", code); + record.put("name", name); + results.add(record); + }); + + log.debug("Fetched {} supported currencies", results.size()); + return results; + } +} diff --git a/src/main/java/com/example/idrapi/util/CalculateUtil.java b/src/main/java/com/example/idrapi/util/CalculateUtil.java new file mode 100644 index 00000000..e9b466b6 --- /dev/null +++ b/src/main/java/com/example/idrapi/util/CalculateUtil.java @@ -0,0 +1,20 @@ +package com.example.idrapi.util; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class CalculateUtil { + + public static double calculateSpreadFactor(String githubUsername) { + if (githubUsername == null || githubUsername.isBlank()) { + throw new IllegalArgumentException("GitHub username must not be blank"); + } + int sum = githubUsername.toLowerCase().chars().sum(); + log.info("spreadFactor {}", sum); + return (sum % 1000) / 100_000.0; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..931f6cce --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,13 @@ +frankfurter: + base-url: https://api.frankfurter.app + github-username: johndoe47 + historical: + start-date: 2024-01-01 + end-date: 2024-01-05 + +server: + port: 8181 + +logging: + level: + com.example.idrapi: DEBUG diff --git a/src/test/java/com/example/idrapi/integration/FinanceDataControllerTest.java b/src/test/java/com/example/idrapi/integration/FinanceDataControllerTest.java new file mode 100644 index 00000000..317e42b5 --- /dev/null +++ b/src/test/java/com/example/idrapi/integration/FinanceDataControllerTest.java @@ -0,0 +1,115 @@ +package com.example.idrapi.integration; + +import com.example.idrapi.controller.FinanceDataController; +import com.example.idrapi.controller.GlobalExceptionHandler; +import com.example.idrapi.controller.ResourceNotFoundException; +import com.example.idrapi.model.FinanceDataResponse; +import com.example.idrapi.service.FinanceDataService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(FinanceDataController.class) +@Import(GlobalExceptionHandler.class) +@DisplayName("FinanceDataController Web MVC Tests") +class FinanceDataControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockBean + private FinanceDataService financeDataService; + + @Test + @DisplayName("GET /api/finance/data/latest_idr_rates โ†’ 200 OK with data") + void getLatestIDRRates_returns200() throws Exception { + FinanceDataResponse mockResponse = new FinanceDataResponse( + "latest_idr_rates", + Instant.parse("2024-01-05T00:00:00Z"), + List.of(Map.of( + "base", "IDR", + "date", "2024-01-05", + "USD_BuySpread_IDR", 15750.25 + )) + ); + when(financeDataService.getData("latest_idr_rates")).thenReturn(Optional.of(mockResponse)); + + mockMvc.perform(get("/api/finance/data/latest_idr_rates") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resourceType").value("latest_idr_rates")) + .andExpect(jsonPath("$.results").isArray()) + .andExpect(jsonPath("$.results[0].USD_BuySpread_IDR").value(15750.25)); + } + + @Test + @DisplayName("GET /api/finance/data/unknown_type โ†’ 404 Not Found") + void getUnknownResourceType_returns404() throws Exception { + when(financeDataService.getData("unknown_type")).thenReturn(Optional.empty()); + when(financeDataService.getRegisteredResourceTypes()) + .thenReturn(Set.of("latest_idr_rates", "historical_idr_usd", "supported_currencies")); + + mockMvc.perform(get("/api/finance/data/unknown_type") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.status").value(404)) + .andExpect(jsonPath("$.error").value("Not Found")); + } + + @Test + @DisplayName("GET /api/finance/data/historical_idr_usd โ†’ 200 OK with multiple records") + void getHistoricalRates_returns200() throws Exception { + FinanceDataResponse mockResponse = new FinanceDataResponse( + "historical_idr_usd", + Instant.now(), + List.of( + Map.of("date", "2024-01-02", "base", "IDR", "USD", 0.000064), + Map.of("date", "2024-01-03", "base", "IDR", "USD", 0.000065) + ) + ); + when(financeDataService.getData("historical_idr_usd")).thenReturn(Optional.of(mockResponse)); + + mockMvc.perform(get("/api/finance/data/historical_idr_usd") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resourceType").value("historical_idr_usd")) + .andExpect(jsonPath("$.results").isArray()) + .andExpect(jsonPath("$.results.length()").value(2)); + } + + @Test + @DisplayName("GET /api/finance/data/supported_currencies โ†’ 200 OK") + void getSupportedCurrencies_returns200() throws Exception { + FinanceDataResponse mockResponse = new FinanceDataResponse( + "supported_currencies", + Instant.now(), + List.of( + Map.of("code", "USD", "name", "US Dollar"), + Map.of("code", "IDR", "name", "Indonesian Rupiah") + ) + ); + when(financeDataService.getData("supported_currencies")).thenReturn(Optional.of(mockResponse)); + + mockMvc.perform(get("/api/finance/data/supported_currencies") + .accept(MediaType.APPLICATION_JSON)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.resourceType").value("supported_currencies")) + .andExpect(jsonPath("$.results[0].code").exists()) + .andExpect(jsonPath("$.results[0].name").exists()); + } +} diff --git a/src/test/java/com/example/idrapi/integration/StartupRunnerIntegrationTest.java b/src/test/java/com/example/idrapi/integration/StartupRunnerIntegrationTest.java new file mode 100644 index 00000000..71f1fdca --- /dev/null +++ b/src/test/java/com/example/idrapi/integration/StartupRunnerIntegrationTest.java @@ -0,0 +1,119 @@ +package com.example.idrapi.integration; + +import com.example.idrapi.model.FinanceDataResponse; +import com.example.idrapi.service.FinanceDataStore; +import com.example.idrapi.service.FinanceDataService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import com.example.idrapi.strategy.IDRDataFetcher; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** + * Integration test: verifies that the ApplicationRunner successfully invokes + * loadAll(), stores data in the FinanceDataStore, and seals it before the + * context is fully ready to serve requests. + * + * We mock all three IDRDataFetcher beans so this test runs without a live + * network connection to api.frankfurter.app. + */ +@SpringBootTest +@DisplayName("FinanceDataStartupRunner Integration Tests") +class StartupRunnerIntegrationTest { + + @MockBean(name = "latestIDRRatesFetcher") + private IDRDataFetcher latestFetcher; + + @MockBean(name = "historicalIDRUSDFetcher") + private IDRDataFetcher historicalFetcher; + + @MockBean(name = "supportedCurrenciesFetcher") + private IDRDataFetcher supportedFetcher; + + @Autowired + private FinanceDataStore dataStore; + + @Autowired + private FinanceDataService financeDataService; + + // ------------------------------------------------------------------ tests + + @Test + @DisplayName("ApplicationRunner: data store is sealed after context loads") + void dataStore_isSealed_afterContextLoad() { + // The ApplicationRunner ran during context startup; store must be sealed + assertThat(dataStore.isSealed()).isTrue(); + } + + @Test + @DisplayName("ApplicationRunner: all three resource types are present in the store") + void dataStore_containsAllThreeResources() { + // Each mock fetcher returns its resourceType; data must be stored for all three + assertThat(financeDataService.getRegisteredResourceTypes()) + .containsExactlyInAnyOrder( + "latest_idr_rates", + "historical_idr_usd", + "supported_currencies" + ); + } + + @Test + @DisplayName("FinanceDataService.getData: returns cached data without calling fetcher again") + void getData_returnsCachedData_noDuplicateFetcherCall() { + // After startup, additional getData() calls must NOT trigger external fetches + Optional latest = financeDataService.getData("latest_idr_rates"); + Optional historical = financeDataService.getData("historical_idr_usd"); + Optional currencies = financeDataService.getData("supported_currencies"); + + // All three should be present (mocks returned data during startup) + assertThat(latest).isPresent(); + assertThat(historical).isPresent(); + assertThat(currencies).isPresent(); + + // Fetchers should have been called ONLY ONCE (at startup), not again + verify(latestFetcher, times(1)).fetch(); + verify(historicalFetcher, times(1)).fetch(); + verify(supportedFetcher, times(1)).fetch(); + } + + @Test + @DisplayName("FinanceDataService.getData: returns empty Optional for unknown resourceType") + void getData_returnsEmpty_forUnknownType() { + Optional result = financeDataService.getData("unknown_type"); + assertThat(result).isEmpty(); + } + + @Test + @DisplayName("FinanceDataStore: put() is rejected after sealing") + void dataStore_put_isRejectedAfterSealed() { + // store is already sealed from startup + int sizeBefore = dataStore.getAll().size(); + FinanceDataResponse dummy = new FinanceDataResponse( + "new_type", Instant.now(), List.of(Map.of("key", "value"))); + dataStore.put("new_type", dummy); // should be silently ignored + + assertThat(dataStore.getAll()).hasSize(sizeBefore); + assertThat(dataStore.get("new_type")).isEmpty(); + } + + // ------------------------------------------------------------------ mock setup (called by Spring before runner) + + static { + // Static initializer registers mock behavior; actual wiring done via @MockBean above. + // The mocks auto-return empty lists by default; we set up responses in the test class initializer. + } + + @org.springframework.boot.test.context.TestConfiguration + static class MockFetcherConfig { + // MockBeans at class level supply these beans; Spring auto-wires them into the strategy list. + } +} diff --git a/src/test/java/com/example/idrapi/strategy/HistoricalIDRUSDFetcherTest.java b/src/test/java/com/example/idrapi/strategy/HistoricalIDRUSDFetcherTest.java new file mode 100644 index 00000000..2ab3495a --- /dev/null +++ b/src/test/java/com/example/idrapi/strategy/HistoricalIDRUSDFetcherTest.java @@ -0,0 +1,100 @@ +package com.example.idrapi.strategy; + +import com.example.idrapi.config.FrankfurterProperties; +import com.example.idrapi.dto.HistoricalRatesResponse; +import com.example.idrapi.strategy.impl.HistoricalIDRUSDFetcher; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("HistoricalIDRUSDFetcher Unit Tests") +class HistoricalIDRUSDFetcherTest { + + @Mock private WebClient webClient; + @Mock private WebClient.RequestHeadersUriSpec requestHeadersUriSpec; + @Mock private WebClient.RequestHeadersSpec requestHeadersSpec; + @Mock private WebClient.ResponseSpec responseSpec; + + private HistoricalIDRUSDFetcher fetcher; + + @BeforeEach + void setUp() { + FrankfurterProperties properties = new FrankfurterProperties(); + properties.setBaseUrl("https://api.frankfurter.app"); + FrankfurterProperties.Historical historical = new FrankfurterProperties.Historical(); + historical.setStartDate("2024-01-01"); + historical.setEndDate("2024-01-05"); + properties.setHistorical(historical); + + fetcher = new HistoricalIDRUSDFetcher(webClient, properties); + } + + @SuppressWarnings("unchecked") + @Test + @DisplayName("fetch: flattens response into one record per date") + void fetch_flattensIntoPerDateRecords() { + // Arrange + HistoricalRatesResponse mockResponse = new HistoricalRatesResponse(); + mockResponse.setBase("IDR"); + mockResponse.setStartDate("2024-01-01"); + mockResponse.setEndDate("2024-01-05"); + mockResponse.setRates(Map.of( + "2024-01-02", Map.of("USD", 0.000064), + "2024-01-03", Map.of("USD", 0.000065), + "2024-01-04", Map.of("USD", 0.000063), + "2024-01-05", Map.of("USD", 0.000066) + )); + + doReturn(requestHeadersUriSpec).when(webClient).get(); + doReturn(requestHeadersSpec).when(requestHeadersUriSpec) + .uri("/2024-01-01..2024-01-05?from=IDR&to=USD"); + doReturn(responseSpec).when(requestHeadersSpec).retrieve(); + doReturn(responseSpec).when(responseSpec).onStatus(any(), any()); + doReturn(Mono.just(mockResponse)).when(responseSpec) + .bodyToMono(HistoricalRatesResponse.class); + + // Act + List> results = fetcher.fetch(); + + // Assert + assertThat(results).hasSize(4); + results.forEach(record -> { + assertThat(record).containsKeys("date", "base", "USD", "startDate", "endDate"); + assertThat(record.get("base")).isEqualTo("IDR"); + }); + } + + @Test + @DisplayName("getResourceType: returns correct key") + void getResourceType_returnsCorrectKey() { + assertThat(fetcher.getResourceType()).isEqualTo("historical_idr_usd"); + } + + @SuppressWarnings("unchecked") + @Test + @DisplayName("fetch: throws IllegalStateException on null response") + void fetch_throwsOnNullResponse() { + doReturn(requestHeadersUriSpec).when(webClient).get(); + doReturn(requestHeadersSpec).when(requestHeadersUriSpec) + .uri("/2024-01-01..2024-01-05?from=IDR&to=USD"); + doReturn(responseSpec).when(requestHeadersSpec).retrieve(); + doReturn(responseSpec).when(responseSpec).onStatus(any(), any()); + doReturn(Mono.empty()).when(responseSpec).bodyToMono(HistoricalRatesResponse.class); + + assertThatThrownBy(() -> fetcher.fetch()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("null response"); + } +} diff --git a/src/test/java/com/example/idrapi/strategy/LatestIDRRatesFetcherTest.java b/src/test/java/com/example/idrapi/strategy/LatestIDRRatesFetcherTest.java new file mode 100644 index 00000000..721ad0b7 --- /dev/null +++ b/src/test/java/com/example/idrapi/strategy/LatestIDRRatesFetcherTest.java @@ -0,0 +1,140 @@ +package com.example.idrapi.strategy; + +import com.example.idrapi.config.FrankfurterProperties; +import com.example.idrapi.dto.LatestRatesResponse; +import com.example.idrapi.strategy.impl.LatestIDRRatesFetcher; +import com.example.idrapi.util.CalculateUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("LatestIDRRatesFetcher Unit Tests") +class LatestIDRRatesFetcherTest { + + @Mock private WebClient webClient; + @Mock private WebClient.RequestHeadersUriSpec requestHeadersUriSpec; + @Mock private WebClient.RequestHeadersSpec requestHeadersSpec; + @Mock private WebClient.ResponseSpec responseSpec; + + private FrankfurterProperties properties; + private LatestIDRRatesFetcher fetcher; + private CalculateUtil calculateUtil; + + @BeforeEach + void setUp() { + properties = new FrankfurterProperties(); + properties.setBaseUrl("https://api.frankfurter.app"); + properties.setGithubUsername("mfathulkh"); + + fetcher = new LatestIDRRatesFetcher(webClient, properties); + } + + // ------------------------------------------------------------------ spread factor tests + + @Test + @DisplayName("calculateSpreadFactor: correct computation for 'mfathulkh'") + void spreadFactor_mfathulkh() { + double factor = CalculateUtil.calculateSpreadFactor("mfathulkh"); + assertThat(factor).isEqualTo(0.00964, within(1e-10)); + } + + @Test + @DisplayName("calculateSpreadFactor: uppercase username is lowercased before summing") + void spreadFactor_uppercaseIsFolded() { + double lower = CalculateUtil.calculateSpreadFactor("mfathulkh"); + double upper = CalculateUtil.calculateSpreadFactor("MFATHULKH"); + assertThat(lower).isEqualTo(upper); + } + + @Test + @DisplayName("calculateSpreadFactor: blank username throws IllegalArgumentException") + void spreadFactor_blankUsernameThrows() { + assertThatThrownBy(() -> CalculateUtil.calculateSpreadFactor(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("blank"); + } + + @Test + @DisplayName("calculateSpreadFactor: null username throws IllegalArgumentException") + void spreadFactor_nullUsernameThrows() { + assertThatThrownBy(() -> CalculateUtil.calculateSpreadFactor(null)) + .isInstanceOf(IllegalArgumentException.class); + } + + // ------------------------------------------------------------------ fetch() tests + + @SuppressWarnings("unchecked") + @Test + @DisplayName("fetch: returns record with USD_BuySpread_IDR calculated correctly") + void fetch_returnsSpreadField() { + // Arrange + LatestRatesResponse mockResponse = new LatestRatesResponse(); + mockResponse.setBase("IDR"); + mockResponse.setDate("2024-01-05"); + mockResponse.setRates(Map.of("USD", 0.000064)); // 1 IDR = 0.000064 USD + + doReturn(requestHeadersUriSpec).when(webClient).get(); + doReturn(requestHeadersSpec).when(requestHeadersUriSpec).uri("/latest?base=IDR"); + doReturn(responseSpec).when(requestHeadersSpec).retrieve(); + doReturn(responseSpec).when(responseSpec).onStatus(any(), any()); + doReturn(Mono.just(mockResponse)).when(responseSpec).bodyToMono(LatestRatesResponse.class); + + // Act + List> results = fetcher.fetch(); + + // Assert + assertThat(results).hasSize(1); + Map record = results.get(0); + + assertThat(record).containsKey("USD_BuySpread_IDR"); + assertThat(record).containsKey("spreadFactor"); + assertThat(record.get("base")).isEqualTo("IDR"); + assertThat(record.get("date")).isEqualTo("2024-01-05"); + + double spreadFactor = (double) record.get("spreadFactor"); + assertThat(spreadFactor).isEqualTo(0.00964, within(1e-10)); + + double expectedSpread = (1.0 / 0.000064) * (1.0 + 0.00964); + double actualSpread = (double) record.get("USD_BuySpread_IDR"); + assertThat(actualSpread).isCloseTo(expectedSpread, within(0.01)); + } + + @Test + @DisplayName("fetch: throws when USD rate is missing") + @SuppressWarnings("unchecked") + void fetch_throwsWhenUsdRateMissing() { + LatestRatesResponse mockResponse = new LatestRatesResponse(); + mockResponse.setBase("IDR"); + mockResponse.setDate("2024-01-05"); + mockResponse.setRates(Map.of("EUR", 0.000059)); // No USD + + doReturn(requestHeadersUriSpec).when(webClient).get(); + doReturn(requestHeadersSpec).when(requestHeadersUriSpec).uri("/latest?base=IDR"); + doReturn(responseSpec).when(requestHeadersSpec).retrieve(); + doReturn(responseSpec).when(responseSpec).onStatus(any(), any()); + doReturn(Mono.just(mockResponse)).when(responseSpec).bodyToMono(LatestRatesResponse.class); + + assertThatThrownBy(() -> fetcher.fetch()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("USD rate not present"); + } + + @Test + @DisplayName("getResourceType: returns correct key") + void getResourceType_returnsCorrectKey() { + assertThat(fetcher.getResourceType()).isEqualTo("latest_idr_rates"); + } +} diff --git a/src/test/java/com/example/idrapi/strategy/SupportedCurrenciesFetcherTest.java b/src/test/java/com/example/idrapi/strategy/SupportedCurrenciesFetcherTest.java new file mode 100644 index 00000000..540c9f70 --- /dev/null +++ b/src/test/java/com/example/idrapi/strategy/SupportedCurrenciesFetcherTest.java @@ -0,0 +1,92 @@ +package com.example.idrapi.strategy; + +import com.example.idrapi.strategy.impl.SupportedCurrenciesFetcher; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("SupportedCurrenciesFetcher Unit Tests") +class SupportedCurrenciesFetcherTest { + + @Mock private WebClient webClient; + @Mock private WebClient.RequestHeadersUriSpec requestHeadersUriSpec; + @Mock private WebClient.RequestHeadersSpec requestHeadersSpec; + @Mock private WebClient.ResponseSpec responseSpec; + + private SupportedCurrenciesFetcher fetcher; + + @BeforeEach + void setUp() { + fetcher = new SupportedCurrenciesFetcher(webClient); + } + + @SuppressWarnings("unchecked") + @Test + @DisplayName("fetch: transforms currency map to list of {code, name} records") + void fetch_transformsMapToList() { + // Arrange + Map mockCurrencies = Map.of( + "USD", "US Dollar", + "EUR", "Euro", + "IDR", "Indonesian Rupiah" + ); + + doReturn(requestHeadersUriSpec).when(webClient).get(); + doReturn(requestHeadersSpec).when(requestHeadersUriSpec).uri("/currencies"); + doReturn(responseSpec).when(requestHeadersSpec).retrieve(); + doReturn(responseSpec).when(responseSpec).onStatus(any(), any()); + doReturn(Mono.just(mockCurrencies)).when(responseSpec) + .bodyToMono(any(ParameterizedTypeReference.class)); + + // Act + List> results = fetcher.fetch(); + + // Assert + assertThat(results).hasSize(3); + results.forEach(record -> { + assertThat(record).containsKeys("code", "name"); + assertThat(record.get("code")).isNotNull(); + assertThat(record.get("name")).isNotNull(); + }); + + // Verify IDR is present + boolean hasIDR = results.stream() + .anyMatch(r -> "IDR".equals(r.get("code")) && "Indonesian Rupiah".equals(r.get("name"))); + assertThat(hasIDR).isTrue(); + } + + @Test + @DisplayName("getResourceType: returns correct key") + void getResourceType_returnsCorrectKey() { + assertThat(fetcher.getResourceType()).isEqualTo("supported_currencies"); + } + + @SuppressWarnings("unchecked") + @Test + @DisplayName("fetch: throws IllegalStateException when API returns null") + void fetch_throwsOnNullResponse() { + doReturn(requestHeadersUriSpec).when(webClient).get(); + doReturn(requestHeadersSpec).when(requestHeadersUriSpec).uri("/currencies"); + doReturn(responseSpec).when(requestHeadersSpec).retrieve(); + doReturn(responseSpec).when(responseSpec).onStatus(any(), any()); + doReturn(Mono.empty()).when(responseSpec) + .bodyToMono(any(ParameterizedTypeReference.class)); + + assertThatThrownBy(() -> fetcher.fetch()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("null response"); + } +} diff --git a/target/classes/META-INF/spring-configuration-metadata.json b/target/classes/META-INF/spring-configuration-metadata.json new file mode 100644 index 00000000..f8b44610 --- /dev/null +++ b/target/classes/META-INF/spring-configuration-metadata.json @@ -0,0 +1,38 @@ +{ + "groups": [ + { + "name": "frankfurter", + "type": "com.example.idrapi.config.FrankfurterProperties", + "sourceType": "com.example.idrapi.config.FrankfurterProperties" + }, + { + "name": "frankfurter.historical", + "type": "com.example.idrapi.config.FrankfurterProperties$Historical", + "sourceType": "com.example.idrapi.config.FrankfurterProperties", + "sourceMethod": "getHistorical()" + } + ], + "properties": [ + { + "name": "frankfurter.base-url", + "type": "java.lang.String", + "sourceType": "com.example.idrapi.config.FrankfurterProperties" + }, + { + "name": "frankfurter.github-username", + "type": "java.lang.String", + "sourceType": "com.example.idrapi.config.FrankfurterProperties" + }, + { + "name": "frankfurter.historical.end-date", + "type": "java.lang.String", + "sourceType": "com.example.idrapi.config.FrankfurterProperties$Historical" + }, + { + "name": "frankfurter.historical.start-date", + "type": "java.lang.String", + "sourceType": "com.example.idrapi.config.FrankfurterProperties$Historical" + } + ], + "hints": [] +} \ No newline at end of file diff --git a/target/classes/application.yml b/target/classes/application.yml new file mode 100644 index 00000000..931f6cce --- /dev/null +++ b/target/classes/application.yml @@ -0,0 +1,13 @@ +frankfurter: + base-url: https://api.frankfurter.app + github-username: johndoe47 + historical: + start-date: 2024-01-01 + end-date: 2024-01-05 + +server: + port: 8181 + +logging: + level: + com.example.idrapi: DEBUG diff --git a/target/classes/com/example/idrapi/IdrApiApplication.class b/target/classes/com/example/idrapi/IdrApiApplication.class new file mode 100644 index 0000000000000000000000000000000000000000..76966926e88c5ba3398986be044b79454d22f231 GIT binary patch literal 825 zcma)4O>fgc5Ph2_b($JlQV5hUsS-kI4&7TXDV0hD62d@5P&jZ}J6m^)y=$%4(7)9a zhyy=>A60eMl-3eK!jfmS^YLcp&HOt5@e{x^Jno@|r2y>^9V|1fpNKDlXF{j^U3?-F z$FRJov~v3lOT+D1fG)$6#N=F_ioD1qS92>0#b3?sL7@(cB2$TQ%IF??SPc+{xPhAt zBV$uu7FOx>$ckKkHTE-)jd6Th8ylHa$yS;n9!A&QOfpfH<4+CC)OoP+cC-jNG1|#f z$0tS~skE{p&SawmnZ0|=(4LsNWLS%omT#*(miB{>W#~sH5!p;w<=>aawmViO!_(-0 z81U%1P&CP4xTx_N`GrjhZ)1U*cJW5KV>3S#o-ZdY1@@+JoC zv{Sj9sZ#m5wKlHa2_ft+q^n+psGK3*YQE&QY~!O@;)Ar_Gu4EKLRcQI?>+t literal 0 HcmV?d00001 diff --git a/target/classes/com/example/idrapi/config/FrankfurterProperties$Historical.class b/target/classes/com/example/idrapi/config/FrankfurterProperties$Historical.class new file mode 100644 index 0000000000000000000000000000000000000000..3c6883cf8a1fe071e3c24e0424319fc5ae51ff8f GIT binary patch literal 967 zcmbV~&u$Yj5XL`ml5VnWNn82{+R_y0At@4jZwr?~RiscV1?BE++|*@bN4A4_ERaA* z9C!d83NhXQtES5Vw9?q){buIp8Grxr^&5bvcoZQ(B}6qw4GRoQSNt7MGOk9+>%o;s z9mB$rRMI_TsI)u%2I^>ph+^DA%y5wEaU$OH@gx(892!26NvhR_93>})tG5?Z$H(6&lnokapRtICm2?`Md6+^QjHFMsoOD{zD_9Aa3-uC>mlizU8%&` zbUYB|4IgBA22EM}+(_^LI;^@&X&H{X|Ic~I5RZiG6|Af6&P`fU*=wq`_DxZzPaS?G z+@&6#bHm4innQRI<$v+}f)S-DoSvSVRGdhE!_Cr`KJkrXsGkYzPPKI*b{S|0|1=k( zf2ou(#~HU)c%))bctC>)=(Pm&(bV%DO@-DPn1428m&i+KW&U*k3)sh8woKOl1~#xl zHl7J^2dm^UtYMw(_25qkKhVzrMetT>@CNRZ$_F3hY*cFh@n?kdrML6wb<%j4S!uuX zcVW0kr!_{7=h-afS)AwDDdpKJ=UJTRY5j%A_w)}>lXxoFCL7>BcJj4FqW~fj)zQL3 GG=2hYRJ@4* literal 0 HcmV?d00001 diff --git a/target/classes/com/example/idrapi/config/FrankfurterProperties.class b/target/classes/com/example/idrapi/config/FrankfurterProperties.class new file mode 100644 index 0000000000000000000000000000000000000000..db1124fe01542a8c90dfee96d37029f9d227e41c GIT binary patch literal 1586 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 CV+Se# literal 0 HcmV?d00001 diff --git a/target/classes/com/example/idrapi/controller/FinanceDataController.class b/target/classes/com/example/idrapi/controller/FinanceDataController.class new file mode 100644 index 0000000000000000000000000000000000000000..672d71099657da8354121ebb43195abc540e8c52 GIT binary patch literal 3426 zcmbVO`&Sc36#jX-|Ld>EG1TJG&dQ$X3CeB)fBG=03jr-8=L5zt8^!a0%bX(S&9NEh<{k#?ZaO z*SKbIv#j0DuZV)n&^D!;x_g}NM-1%>;wn0zGMp}06;0gdm8u~$y=e2Qt`#iP zwJpOCwl=Gq+$@M|+~w1oQiepSAcuNvDb~bdzU>RVK9= z%PwnK8XMv=&9a zMoNcMnFb_T*VfH)IwbKyN{{5?=*Jrh4ykw(hZ#=nDi{W(EL;f_Ln^}Hh;Omx>V|f^ z>gtxsjdX$%m{f5DM+pGh&QMB4VfdDU14A@#JaSCQHJpd5Gz(u7-2q8a;uig25dpx$%&`lIIgDG`z(SBebUF6vMt$*tjQ5OLCl2aT;eBT6D8yF{V^qb*oxEjv3R{-3JU!)+)oLu-!V> ze44*n2Z1=na6SUW5GCrham#XNt(sY!xnB?-!(yP4KT)4fx>P&K;UQ-@XY0=u3<*OtcvTn zv84_9l%zIPvg`^cmPh4cDFXVCD4=y5X}l{N-FpoC!^qZYPuZHoEd{q#+`;<{2VU$8 zv%KJ1Hf5)s!t;9!!%$e2Poa_Rt%%`NWO=@%#f{{MfYU^HhOUfmid(fxUf2sfZ;*t# zU4a`pZtHR!2wU7`o$x*TT77_EpA+t~RlLI`H7RkVs*?f@wec4ySqE;%lw?rtP&bxg zY-_JnptQy-YKDoZQ2w_Gq$x95y=-!~W)nu0aAtPv>(uTnUnbjq^EIm8ipc4XPD#09 zQmt^=JPF7%kr=Iuyq4F^qQ*DZYS)E3$E#I&($G1y#*LaJfVL3^?jgg`t;RN!w#DkX z9b3J_-Q_x)$nF7ySMo(ZxHbKQ)IH}SL4D0GLzZGw1tYj&#IxQxJ*!KC_lIcLxIIir zx-d-cI4){EZeu&y+s)Q{HQL=s#$HWcaDv;5CXX1ZnX8uNIxby({Ni!CagU*=-r>TB zT30KmN5 z+HQ>g38ypIg|kJAMxW326g}-tvOHO=#Mwi}S>?odAa)inNsw(hF?dNN+h77XEvYS!Gn!FGBQJyi zVW%jns0vR!@&yp8NDKuZ!1wS>aeFk9C$?-WsY>8sr0JgS+xMJ%?(Ocs{`u`s0GIG} z2sNk;piV(O8W?uY^98OM+{|d>$$61-7#jL?Q+F;i)JEE-8WBWe03iiUP#C&WR#p>B zJexCwrl)P5)3uakI<{pP!q%=DR+1aBrIg4yx@BJDW}3Rml3`^S!XuWQ(elP@*St1j zWirC<4PrY(OFvJmwzyLec}L}rdiP!rJx$n&<^aM9c40Tesd;{z+YZVGs5Ouus8$Y5nNxfMG(C;YQTX z6tco}%3v5mWO!8M^E@M(a1e(AIIQ3$93d9h0%NFKj{}E0g*-!3e{^sr9=kCaOH44FC=$N;B%m0s>I=ti1k62Qw0 zCo5<#OEwKHX4{rsnjVBEc_2x7A_(DCoDHB;K^M+3G`pd8^sLZ^O+sLja^EoFQqVoIg^}F$F_X z%#CB?6Ej2OlVgKHT$4>&&>bDb>kJ1bO|g`tS|rk{kfHUvF(TaCSA#*k#c(cas)e-(tW>IC+aleeUN;2K3)QigRi5FxsT!Otbp-J? z!{JGD&aIgQyi`Q};NOwp-(}df3cidwh#4}l>DnQ1Bm+n(NJB8}U0bz=c*?PCQd1*U zx?+WTLH!}L2rItqjjQ;CS zKLY!iUTTo>1_3)TNAF5;0Cdb#i{Um5)VY(tA>*~w*ZmNI@V539>|i+aD_T}?pbihA zKE}!4(LUYGz6*EA{^E$oc;yk!ub{gIKhfmkf(OVW&{b(S_R=+EA0l*J>ZJdR)Rs=D z#f3a4Xh~#Yx{h_jLXNE5p;3)B)D7wYYRhme^+T_^<*NIqf5STnWqcdMr0L#`4~lSkAf;>B4!!(nVN0H^g#(Yb>8sz;d4= z@o71^T3sU~%W&{9ZvBFQOfcq4ux3*AXRc5`ca6!c{DRtF(i5Wh9=b7q)zJ7aDR>)7 literal 0 HcmV?d00001 diff --git a/target/classes/com/example/idrapi/controller/ResourceNotFoundException.class b/target/classes/com/example/idrapi/controller/ResourceNotFoundException.class new file mode 100644 index 0000000000000000000000000000000000000000..26245b36b5808164038eecc92b76c0877f2fccdd GIT binary patch literal 464 zcmbVJO-lnY5PezOuGVU6MGqbXZ+?J#EK==7>}f61O7H3J&_;HXC7YH0El+|6e}F$q zoRorscrk&=yf?#}k9>T-y#u(xeiH#ULeyHQqakqgB%fuJNj;Com3AtpyJbR!Q$`Ck zu9a49AaHORf9Fk{ReIh(n}*mF=p`nPXesj|qe!Jz7Ai`NcGhGW*=S6qscb?c2Xk!rv;Z3!x(BrPJPrIi-2ML7MfTxNB&*0UYcyi4^v!7|`&%Fx^vk!?z|OW{RaM!sZJa4hWkXZ7zl$3BijRUJ`;N z1yjCLf_+fKA-v2DlkLS#LT|W<2G3F$){wynfvZMkb<$a8M59B)vq_BMu!^jPBY350 zbN=q>-5sM?HP7FaaHw4*Hv8Mhw7;g|C|;MaXWdvcCmp+BxR))L>)K3{?K1k;8;u2# z+H7k(oGn8BhK4tBoWUKpY|B02xqFFqvS_W?hFdK&09SnTVVuUdTJ2u}UQwQQs!K&P ze}aE3T0g|ybCzwMtFAAZ<%lnmzNTyXq`oFv`Zd>6&Fap;4(;xvW=CgL-B0aS~K%Uy%PWws~Q$zd1N} zOJRVnwV-#-OR=ovG`E-Fl(SVh?y6a4(TMz{{XUfRc)_I06R@WJ*2AXaOB*a?QA-ei%zv%FsChX-Uha~R}PCd%mO~;IBvz|E#y~nB@QZ` z+(Kzcl)!{EdY~3-F$rQVNH5kC(r|YSD@m-vQgKVP!Ziu$df&I~JI7Y2E&rFezN9QndPDk5%pZ{(fs zjl9PpA?ML~bdlyo%;z{cS&kA%CG#Vs?>)!_pD~ZwjT59bAAq-Tk~2x%xO}!MtF6jC zZIyW|w^2^ED)+Wk=G{&AT37CDRqkt}?0f$<*FJ^Gz(|f`L^zun*?{x|sR-Y|Lg$m7 zm3|))I4@)($`Vc!r?8)Rp`B`PQr)gek+7P{glBTPy~*TuO^V#tOlqFVGnhfti{4zL z} z!N{+O%#UUsq1T&|*nMu4kDNUAJ34gv33~5C8`I@S&_l%joI=R{TufI4CiVn<_tB{< zdaMC7m{SQF%*Az8fZ_p2)#DAIbS^-@h+(tikQX%DHUbvEs~1k9#8Z|QVD#C zeRz*l5|_A-W9U}nf1*o8R{1iJEeg>64@6{AvlF1Tj{){G68Y=bV$K?qxnFBrfRi-lDGjbp5*dq+x5d8f) zSqOAFkiz9cc=kfdi+tk21Q#sW}6 zK!}l>Sph880IMTgxdB*h2G{Xz1XQ7qAJ$szBz+RiT^H#Pq33Uqzx2--eN11`+ByB6 z=O1r92%aQ94E!WF+!jCKSZgclXw!Cu@<$An=-Hn#>I%nHhRR;c&5Uk9&5UgznbGA9 zZ0basXYr`7RV&04=l=ba@~%2j$0_jv%XI`L8j*~8pAwurIihfTnGuRbj}=X^-m`8{ c*&rZ-B@{Tu>G4hemU&9SVv$%v4sJvL8%lIKq5uE@ literal 0 HcmV?d00001 diff --git a/target/classes/com/example/idrapi/dto/LatestRatesResponse.class b/target/classes/com/example/idrapi/dto/LatestRatesResponse.class new file mode 100644 index 0000000000000000000000000000000000000000..3c24ff85ac5731a8ddf112af8689829d708f4520 GIT binary patch literal 3386 zcmbVOZBr9h6n<{9$%e2q}dgu)<2n(qv=xV}F3( zJN*Im3m@8{oZSt1A$G7c$=rk zT==?d<`udIYwTZHxo73v?9fdSe#^jqbSda-mV3@~W&hw%jd0*h96dOoBWd7mF`cM- z>swQZT5?oLY#uc5PQ~VV`YF7A1BY;!PVpQM0)>vsK{ej@;D~_%9Hrp8Sz4cRRtZjc zaA-D;_b{kq$Uq9ibq@F*GJ^YNzHD8&t8lDYw7X;KWcEf497kHAXVcuUrkp~~bg$bk z&n=W(v)~fpOSQ%VGRr(}csr)Wc$ip);FtoWco( zmL+?wV7lcZp*ZayR6%Tf_qjEk^G9Bzf{ozC(+C>7)mI7L+Ll~1x51JK5)@fjoSnZwFJ@UuMXOZKyF58KATr?Fiw*?Z zG1sXy$(c8(<~j^-{}07CfHS`2l#4lQ+LoF;&}32`m+oL4oO2wv-3#)mnG%)8l8|AHRO%Lv#7O^`j?};WO$n6UdSl1qxuC7b-~e0^ze!S#MPCXsXPchMqMl z#~YP9n<)DMO!Dlr_&6|<;TjTuQ^QZ7ej*h@)&Ew{O1Cc&_%E>uKnkBoRP0S&$qMJ1 zo7DGeQn21J+2Wa;!g;1cLY=GiGA<^=d+^dv+XYJd8HjQns~#es5UD@fgNvQhSQ- zibQWFLXqA~G#L?@=nHf{Mq4tHjMfPCWpoPlWnxKPgkph^o{Uw5wC$!b-}W8d$;=~M z#0-P!!UN2*+Cu!^uyKjJFuyHw_>`1JnQ0EwyB&ATAPEjsE~pf~x2VYekThKwej3l@^g^nWYOm z^)Tzz1HYZfk`x`M32Du1iR`#G;U(f=BD>Bs#1g}gp^rYryLW`&o6%$=sRcVRNshnO zmx*{=%!{WKOGdn{gUz9y^bZW64r39Y@h{cCfF)8P#0l?JdL70Qeg|D6rD2jYWto&n z0pB3{iVTflDn<05)GLZt-^qT(pL^&EKihq)FrS9 zMEv*tf@9Ay0yK^he*pOH)3r0ePU3SKk;eR1+~?Y#dWQE?zv9%5XE^f&ac`QWMxH=V zjq$r#Hztfqj$^ZZW literal 0 HcmV?d00001 diff --git a/target/classes/com/example/idrapi/model/ErrorResponse.class b/target/classes/com/example/idrapi/model/ErrorResponse.class new file mode 100644 index 0000000000000000000000000000000000000000..edc3735ea2c8a2bd94069d970bbe5e662ffe5e8d GIT binary patch literal 2067 zcma)7U31e$6g`_*mSdDAcATV!7TiLptpI|yloo}w5TLjj}6puR$w7==@yZTz)BKN?O1J(n_gsBneZnDKE-)~QsgJs z2y8Vw`I4|(CrPT94FjLybH?mDVSk6o3vA|(*;XMg8Q8#OR!RObb_T4{o!kL3oPOPV zA-j>?`d#30vz<9eE1G|2s&1`a7CXsnspER`m+^2<2G5+m0W+<3{H`EgtV#^j1TXqiG1+W$2 z3!<7_{Y6`(ZJ7T+nCB+Y%_|cunpY>NnDxR0HM6cwu>Oudb7=9mNW8kw4_sC}s`S)-WH>6q7!F~s}IH1Djb&Xdkk~e5oP~AMh zIp8g)C%G7jOUk9fK4r~-UjW|Zf^Xn@TB0pll}O3lJix^d;nxzMqf(T}`b!c}DQbcX zaB=2{lj5dQ(#*f1pTcZ*dzow)n@tjfXrb^W;ag@iXfG E1NR%H4gdfE literal 0 HcmV?d00001 diff --git a/target/classes/com/example/idrapi/model/FinanceDataResponse.class b/target/classes/com/example/idrapi/model/FinanceDataResponse.class new file mode 100644 index 0000000000000000000000000000000000000000..965b63265a7d4285c0cc55aa066516d7f0657e9f GIT binary patch literal 2937 zcmbVOTT>iG6#kmqESuQ?8z7)0S&@)sxr`{9=&l$pA=$V<1cHXBv3ItCf!UdLE);q5 zZ}=a4FjZnKtkO5F^2L9oo}L+C7LWp{YP$POpYMF<(x>;YfA)U^@G;CJ8qlbqNkubS z7}~e^4%bX>ZD>nEx19<@OTn-VZ<3)gJG7ca0<8*?Dy~4SJHG5WhPA<<816LbZwKes z6kKKKKLRb9rr6--l(XSih2=fi)kV!SY%76whMSIXZQs$w%FCL_RDIXWShklbi%eM} zCXqr~^1Q~-l`S2ChNkmFs|j>5^bm?~dTt$n<(uXeTt|OQMaYEkY z?&b`+&MVTEvM{70nB8)2=+>4zCJN zIK;uel+Txqu?$sbHoAZDlmuO-c&s1963*=Uixqv63K3e|Q_5xfKz?+~8Hy)_mvw|8 zxg4~(IYVZ-=aj}UCKJHWyX0Gv+p6IjQ~^^KwL&i2&|v|rbC+_lTQ#*UN}_998b3Uw zJ*Iup(oMf2$gAcZZu(^JgP?NGt2l53kXTq;c(L%bRH7?+@G!Jb+qUO=)aJtSa1)?3 z)iv?_VbjV-Evb#6k48U@!L}AWPvT2_rQmB7FYpb+t)s5)g#K$`$|4hVh06z48D7PU z(6DywZJ~t+A91T<3U{Dn+uMFEAG^6xw~9$3MfvE=3RicGnrAz4l^K(}Zd?#yJUwGw0yjRnI0xk0n)kwccIX_8TaVqha~x%<|dj8xz}LBe;_&h zGnDU<`W2~X>9*JC_xUxsg5e=SKH1_=@zK@Dcs*r{5?*JMB8q zMhtqN%}obv&2+#`@{x6(y}3N zp(PL}1Dc^(C8F6u5A1_e)W?7y&ZRzqIWi)x7ig9~CUV33c=!MGyTdb)c{+`UctixG zi_P?k(!`KCbV;L~bg4P_6Z+0_s5wGpR3SxDk&NQX{pVB8;&J3oTzT+3%3(%6r8+E; z?FP(af#wu(S)}nq;4>^?nM8e*?y#K0Vy-Xc6*|#Q3Tp&(15fcezQ8kZlBNl?j0)E8 L_hJJEw$b|^hDGP% literal 0 HcmV?d00001 diff --git a/target/classes/com/example/idrapi/runner/FinanceDataStartupRunner.class b/target/classes/com/example/idrapi/runner/FinanceDataStartupRunner.class new file mode 100644 index 0000000000000000000000000000000000000000..26b593471fe988594ccf877cbd29473039ee6d80 GIT binary patch literal 1767 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 literal 0 HcmV?d00001 diff --git a/target/classes/com/example/idrapi/strategy/IDRDataFetcher.class b/target/classes/com/example/idrapi/strategy/IDRDataFetcher.class new file mode 100644 index 0000000000000000000000000000000000000000..c58e906db593b2a352b983d8029cd92ec4b5e5fc GIT binary patch literal 319 zcmZWlTTTK|3_V3)4{{V20vj`2sO|n@VdE<4Sig7QVb7u^aisrs@E~mT5Q5N+Yhe?Y2I9-Vj&3WFZ35vWyaZ?jgDtdC R8I5on@J@xI8?s)V_4j0kSYQAE literal 0 HcmV?d00001 diff --git a/target/classes/com/example/idrapi/strategy/impl/HistoricalIDRUSDFetcher.class b/target/classes/com/example/idrapi/strategy/impl/HistoricalIDRUSDFetcher.class new file mode 100644 index 0000000000000000000000000000000000000000..9094663120d4178850ff1bfd9fcc0c7b799b69d0 GIT binary patch literal 7235 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 literal 0 HcmV?d00001 diff --git a/target/classes/com/example/idrapi/strategy/impl/LatestIDRRatesFetcher.class b/target/classes/com/example/idrapi/strategy/impl/LatestIDRRatesFetcher.class new file mode 100644 index 0000000000000000000000000000000000000000..39eb705b5397d271d58b14cf6c4f34f7d72740a2 GIT binary patch literal 6703 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 literal 0 HcmV?d00001 diff --git a/target/classes/com/example/idrapi/strategy/impl/SupportedCurrenciesFetcher.class b/target/classes/com/example/idrapi/strategy/impl/SupportedCurrenciesFetcher.class new file mode 100644 index 0000000000000000000000000000000000000000..77f1ceefb7f65fc3c6779623c48ca8d055cb4500 GIT binary patch literal 6599 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 D%dJmI literal 0 HcmV?d00001 diff --git a/target/test-classes/com/example/idrapi/integration/FinanceDataControllerTest.class b/target/test-classes/com/example/idrapi/integration/FinanceDataControllerTest.class new file mode 100644 index 0000000000000000000000000000000000000000..c1ece4ac0013b1e5e0438700c8955dd9bcc091cf GIT binary patch literal 6302 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 literal 0 HcmV?d00001 diff --git a/target/test-classes/com/example/idrapi/integration/StartupRunnerIntegrationTest$MockFetcherConfig.class b/target/test-classes/com/example/idrapi/integration/StartupRunnerIntegrationTest$MockFetcherConfig.class new file mode 100644 index 0000000000000000000000000000000000000000..9aa76d10164427a0cd340679783e53d08abcb59f GIT binary patch literal 642 zcmb_a%Sr<=6g_FHQ){&^AGlL+p)Sl?s)7`Xg+9QR?$c>HO*@m6Oh)@#t^^l;fFC8^ zX$2R88x7>1+;cDF+>^Y2yuJar!C?UzWIg1H*uW-3c_tnO4}}hRZ#0v>W7xb_TDe<> z?AiIyL!P1In~2M$h!&ybYHYR`-gm;fWbu$_Ep6+&Fp#l3?HGUFl+K?@ z+c0{f0)||}j3q;&vD} zuRi&0h|AO^h9jELsYni0tVrLT*2b+IAbD49V*?&9tkS{6iby^gJLe-~9Cu^`_YL{7 zbUgKB?L)G5kA&pAMBba&dDvs9|6xvsVk>=wMkwM~lB~)P`*949{y@4IZTba-TXf=t yE9T+_?3suR3KY{a;IK`xxNacap)7}8iW!tpCQSP(gw+*S!#-hwI1e=(ApZ%$>9?-{ literal 0 HcmV?d00001 diff --git a/target/test-classes/com/example/idrapi/integration/StartupRunnerIntegrationTest.class b/target/test-classes/com/example/idrapi/integration/StartupRunnerIntegrationTest.class new file mode 100644 index 0000000000000000000000000000000000000000..bf97d66f1b3d60160e3c9908b879d0bb72246437 GIT binary patch literal 5042 zcmb7IiFXsn9sb5PNQmVUgcOp(2Heyb@RGC;ih$M!qzbknmN5m=sB39tuUYLXyDN;- zrs;j(_kE`KO;cl%rcLkn`bYKko7t7T201ajhjw;{ zdJL??dV#HT_I2BGZEwapKRqWak-++sj^{+D1-knVOl?3G8#2fl*a$vLfm}?ToV96#)pzBo*o%UJlQ<=iArvaD1o{%sYQC{M$afiu z^9|)_+tqNqUBU4V=6@QF_KsD+Xwlnh3}HBf5d&v%R$zZeC#^iglHr6G0vSqAr`{@p z29wNNdAOj`;GG7FctT)P4DwkSRc4i24|U7{<}Yj6f>j)uP)%Yu`sAfGmpD5YH^$g|>+yIfz-RDT+S+JR_HZXw_X0veaovH>t2*=r zfkzU4x@RckSE&pnwX4sU@Z}7?V&JRzn!uip)u+9k0XS=iB^@;fTM2F7H6$c;r+tjs zycypx@J&@}yBV&m?VN>_yQZAbQ6<^){79Dq2GvoW(_urkqFP|r<-8wUv!;DNvQ$UX zHD*Pj8RA?~U^si+%Lt2W8jID0YFkNO3EaP&h@@RCjkGDd+etXw?pfRfi0tsC&!!hX&Fqa{=a&4P*@2_plaDy8t}??jjeB(-BjL)Mtz$T--Ft!xh6+iYp&UFNY?}2Bps}ntU?}f z!iH-vjMLHFRTykE+@yrZv+W5}NjKGqAWbJULp@-cTE3~;ITbKnYCuC%inba(%h=>> zAZ2S7;;xYnGHlm1BelJiwv5?-p7Ks?fc)5%VFuUd5r zrzv?XeG!PK$$`Ya6Lg6-(5z?+V?5@aXx48={N}n$Dzg^%d()obcts@caE@*<<(eN{ zjN6bJN$C$dw&^l=&#gtgRBg&^xyvLoGihpV&|eDS@V%Nt7Z^o#cO72>_&a0!8L@ogiY z-{$}Cg!nEA9Et8Ge2)Z9Lw6ItPXaI431K~-KOoC4b%nd*TSg03JATNO#ivkT`fow} zK_B@Me-#F;%+0fS>RQ{FKx#efU&zcuW6_$Sh*h61Fd5*QI4P>TdKVsy4;-~C|&N!A1Pg4 zmw&Xhh_|LhZbmmxoZ@BqG>soliQTul*kAF;uUle;f~ALFLM1jou!s|*dUb@WF}-?} ztMP+NIK7C$ONWx@;pE93kM}I$+!CIA6&G*glE5W?yV~K~(|3MTuIq+EpdUH@7Ww-G z$Ch*0gK<2}>%#yp(4i%Moy60eu>(wL8~bCG6B~1I@EiP=-rIpPe#cc8Ib6i=x$1_J zV)7a9y4l`!v%TvksU6_?BM-jV@)K-sSNLw%KCzXbVxLqr99wLyP!SvU>@idteb?RPd0nh_7o1WA(c<411vRrGibUVmogZy{Hj_ zSq7McG0bUmlq;!0V$LQEVhOjYd@n(K2`|&w4rctBHQ*I0AE)6iGEo0?3JNVbPQhmg ktNO!R7yjDj@+#kxeEA#x&X*o?`3L^RRZg$6_&2iu2OP(`r2qf` literal 0 HcmV?d00001 diff --git a/target/test-classes/com/example/idrapi/strategy/HistoricalIDRUSDFetcherTest.class b/target/test-classes/com/example/idrapi/strategy/HistoricalIDRUSDFetcherTest.class new file mode 100644 index 0000000000000000000000000000000000000000..89137c7f9d747d6d7a39c2452a43f75d7f5a5625 GIT binary patch literal 8263 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 AxBvhE literal 0 HcmV?d00001 diff --git a/target/test-classes/com/example/idrapi/strategy/LatestIDRRatesFetcherTest.class b/target/test-classes/com/example/idrapi/strategy/LatestIDRRatesFetcherTest.class new file mode 100644 index 0000000000000000000000000000000000000000..56bcdaf91cf4d7415ed8708ab5704dbfcc30c13e GIT binary patch literal 9461 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 H!KVKM>DT1y literal 0 HcmV?d00001 diff --git a/target/test-classes/com/example/idrapi/strategy/SupportedCurrenciesFetcherTest.class b/target/test-classes/com/example/idrapi/strategy/SupportedCurrenciesFetcherTest.class new file mode 100644 index 0000000000000000000000000000000000000000..2095e351b442aa77ac6d89264359ccc61e567a7b GIT binary patch literal 7995 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 G-2VXHE|C!c literal 0 HcmV?d00001 From e742b4973611a57353ecc94e392613e6b25e2392 Mon Sep 17 00:00:00 2001 From: mfathulkhairi Date: Thu, 2 Apr 2026 15:59:28 +0700 Subject: [PATCH 2/2] feat(idr-exchange-rate) : add api for IDR exchange rate --- .idea/workspace.xml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.idea/workspace.xml b/.idea/workspace.xml index 8da5a8d8..1e859a5c 100644 --- a/.idea/workspace.xml +++ b/.idea/workspace.xml @@ -54,4 +54,15 @@ + + + + + file://$PROJECT_DIR$/src/test/java/com/example/idrapi/strategy/LatestIDRRatesFetcherTest.java + 111 + + + + \ No newline at end of file