diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..434dfcf2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +target/ +.idea/ +*.iml +.classpath +.project +.settings/ diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..721611d4 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "explorer.compactFolders": false, + "java.configuration.updateBuildConfiguration": "automatic" +} diff --git a/README.md b/README.md index 5e58ae2a..eede7983 100644 --- a/README.md +++ b/README.md @@ -1,139 +1,129 @@ # Allo Bank Backend Developer Take-Home Test -Thank you for applying to our team! This take-home test is designed to evaluate your practical skills in building **production-ready** Spring Boot applications within a finance domain, focusing on architectural patterns and complex data handling. +Spring Boot REST API for preloading and serving IDR-related exchange data from Frankfurter API using Strategy Pattern, custom `FactoryBean`, and startup data runner with immutable in-memory storage. -## 📝 Objective +## Tech Stack -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. +- Java 17 +- Spring Boot 3.3.x +- Maven +- JUnit 5 + Mockito -The focus of this test is not just functional correctness, but demonstrating clean code, advanced Spring concepts, thread-safe design, and architectural clarity. +## Setup and Run -## I. Core Task: The Polymorphic API +1. Clone repository: -### 1. External API Integration (Frankfurter API) +```bash +git clone +cd allobank-backend-test +``` -* **Base URL (Public):** `https://api.frankfurter.app/`. +2. Build application: -* You must integrate with three distinct data resources to enforce the architectural pattern: +```bash +mvn clean package +``` - 1. `/latest?base=IDR` (The latest rates relative to IDR) +3. Run application: - 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.* +```bash +mvn spring-boot:run +``` - 3. `/currencies` (The list of all supported currency symbols) +4. Run tests: -### 2. Internal API Endpoint +```bash +mvn test +``` -You must expose **one single endpoint** in your application: ```GET /api/finance/data/{resourceType}``` +Application default port: `8080` -Where `{resourceType}` can be one of the three strings: `latest_idr_rates`, `historical_idr_usd`, or `supported_currencies`. +## API Endpoint -### 3. Required Functionality & Business Logic +Single endpoint: -* **Resource Handling:** Your service must correctly map the three incoming `resourceType` values to the correct data fetching strategies. +`GET /api/finance/data/{resourceType}` -* **Data Load:** All three resources should be fetched from the external API. +Supported `resourceType` values: -* **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. +- `latest_idr_rates` +- `historical_idr_usd` +- `supported_currencies` - **The Spread Factor Must Be Unique :** +Example cURL: - 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.)* +```bash +curl http://localhost:8080/api/finance/data/latest_idr_rates +curl http://localhost:8080/api/finance/data/historical_idr_usd +curl http://localhost:8080/api/finance/data/supported_currencies +``` - **Final Formula:** `USD_BuySpread_IDR = (1 / Rate_USD) * (1 + Spread Factor)` (where `Rate_USD` is the value from the API when `base=IDR`). +If unsupported `resourceType` is used, API returns `400 Bad Request`. -* **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. +## Personalization Note -## II. Architectural Constraints +- GitHub username used: `joniheri` +- ASCII sum (lowercase username): `856` +- Spread factor formula: + - `Spread Factor = (ASCII_SUM % 1000) / 100000.0` + - `Spread Factor = (856 % 1000) / 100000.0 = 0.00856` -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: +For `latest_idr_rates`, custom field is calculated as: -### Constraint A: The Strategy Pattern +`USD_BuySpread_IDR = (1 / Rate_USD) * (1 + Spread_Factor)` -The logic for handling the three different resources (`latest_idr_rates`, `historical_idr_usd`, `supported_currencies`) must be implemented using the **Strategy Design Pattern**. +## Architecture Summary -1. Define a clear **Strategy Interface** (e.g., `IDRDataFetcher`). +### Strategy Pattern -2. Implement **three concrete strategy classes** (one for each resource). +- Strategy interface: `IDRDataFetcher` +- Concrete strategies: + - `LatestIdrRatesFetcher` + - `HistoricalIdrUsdFetcher` + - `SupportedCurrenciesFetcher` +- Strategy lookup uses Spring-injected map through `IDRDataFetcherRegistry`. +- Controller and service do not use manual `if/else` or `switch` branching for resource dispatch. -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. +### Client Factory Bean -### Constraint B: Client Factory Bean +- External API client (`RestTemplate`) is created via custom `FactoryBean`: + - `RestTemplateFactoryBean` +- Base URL and client settings are externalized in `application.yml` via `FrankfurterApiProperties`. -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**. +### Startup Runner and Immutability -* 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). +- Data preload happens once on startup using `ApplicationRunner`: + - `FinanceDataPreloadRunner` +- All three resources are fetched and saved into `FinanceDataStore`. +- `FinanceDataStore` uses `AtomicReference` and deep immutable copy to enforce thread safety and immutability after initialization. +- API serves only from in-memory store after startup preload. -* ***You may not define the client as a simple `@Bean` in a `@Configuration` class.*** +## Architectural Rationale -### Constraint C: Startup Data Runner & Immutability +1. Polymorphism Justification (Strategy Pattern) +The endpoint must serve three resource types with different upstream resources and transformation rules. Using `IDRDataFetcher` plus three concrete strategies keeps each behavior isolated and small. The controller/service layer only delegates by key through `IDRDataFetcherRegistry`, so adding a new resource type later only needs a new strategy class without touching existing branching logic. This improves extensibility and reduces regression risk when requirements grow. -The aggregated data for **ALL three resources** must be fetched **exactly once on application startup** and loaded into an in-memory store. +2. Client Factory Justification (`FactoryBean`) +`RestTemplateFactoryBean` centralizes external client construction concerns: timeout setup and base URL wiring from `frankfurter.api.base-url`. The `RestTemplate` is configured with `DefaultUriBuilderFactory(baseUrl)`, so client code only uses relative paths (`/latest`, `/{range}`, `/currencies`). This keeps endpoint composition consistent and removes repeated base URL concatenation in business/client code. -1. Use a Spring Boot **`ApplicationRunner`** or **`CommandLineRunner`** component to initiate the data fetching process. +3. Startup Runner Choice (`ApplicationRunner`) +`ApplicationRunner` is used so initial preload runs after Spring context and dependencies are fully ready, with clear startup lifecycle semantics and error handling options (`fail-fast`). Compared to `@PostConstruct`, runner-based initialization is easier to test, easier to control with properties, and cleaner for production startup orchestration. -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. +## Error Handling -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. +- Unsupported resource type -> `400` +- Data not initialized -> `503` +- Invalid numeric upstream values -> `400` +- Optional fallback payload on preload failure when `finance.preload.fail-fast=false` -## III. Production Readiness & Deliverables +## Test Coverage -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! +- Unit tests for each strategy: + - `LatestIdrRatesFetcherTest` + - `HistoricalIdrUsdFetcherTest` + - `SupportedCurrenciesFetcherTest` +- Service and store tests: + - `FinanceDataServiceTest` + - `FinanceDataStoreTest` +- Integration test for startup preloading: + - `FinanceDataPreloadRunnerIntegrationTest` diff --git a/pom.xml b/pom.xml new file mode 100644 index 00000000..80f88884 --- /dev/null +++ b/pom.xml @@ -0,0 +1,87 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.5 + + + + com.allobank + allo-backend-test + 0.0.1-SNAPSHOT + allo-backend-test + Allo Bank Backend Developer Take-Home Test + + + 17 + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + 2.6.0 + + + + org.springframework.boot + spring-boot-starter-test + test + + + + org.mockito + mockito-junit-jupiter + test + + + + org.springframework.boot + spring-boot-devtools + runtime + true + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + com.allobank.finance.AlloBankTestApplication + + + + org.jacoco + jacoco-maven-plugin + 0.8.12 + + + prepare-agent + + prepare-agent + + + + report + verify + + report + + + + + + + diff --git a/postman/allo-backend-test.postman_collection.json b/postman/allo-backend-test.postman_collection.json new file mode 100644 index 00000000..1b4c3c4d --- /dev/null +++ b/postman/allo-backend-test.postman_collection.json @@ -0,0 +1,106 @@ +{ + "info": { + "_postman_id": "b87cfb30-c8c9-4a8e-b8dc-6ab777b8f001", + "name": "Allo Backend Test API", + "description": "Collection for testing /api/finance/data/{resourceType} endpoints.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" + }, + "variable": [ + { + "key": "baseUrl", + "value": "http://localhost:8080" + } + ], + "item": [ + { + "name": "Home", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/", + "host": [ + "{{baseUrl}}" + ], + "path": [] + } + } + }, + { + "name": "Finance - latest_idr_rates", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/finance/data/latest_idr_rates", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "finance", + "data", + "latest_idr_rates" + ] + } + } + }, + { + "name": "Finance - historical_idr_usd", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/finance/data/historical_idr_usd", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "finance", + "data", + "historical_idr_usd" + ] + } + } + }, + { + "name": "Finance - supported_currencies", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/finance/data/supported_currencies", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "finance", + "data", + "supported_currencies" + ] + } + } + }, + { + "name": "Finance - invalid resourceType (expect 400)", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/api/finance/data/invalid_type", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "api", + "finance", + "data", + "invalid_type" + ] + } + } + } + ] +} diff --git a/src/main/java/com/allobank/finance/AlloBankTestApplication.java b/src/main/java/com/allobank/finance/AlloBankTestApplication.java new file mode 100644 index 00000000..c2e8de4b --- /dev/null +++ b/src/main/java/com/allobank/finance/AlloBankTestApplication.java @@ -0,0 +1,14 @@ +package com.allobank.finance; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; + +@SpringBootApplication +@ConfigurationPropertiesScan +public class AlloBankTestApplication { + + public static void main(String[] args) { + SpringApplication.run(AlloBankTestApplication.class, args); + } +} diff --git a/src/main/java/com/allobank/finance/client/.gitkeep b/src/main/java/com/allobank/finance/client/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/allobank/finance/client/FrankfurterClient.java b/src/main/java/com/allobank/finance/client/FrankfurterClient.java new file mode 100644 index 00000000..e04bbe80 --- /dev/null +++ b/src/main/java/com/allobank/finance/client/FrankfurterClient.java @@ -0,0 +1,54 @@ +package com.allobank.finance.client; + +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import com.allobank.finance.config.FrankfurterApiProperties; + +import java.util.Map; + +@Component +public class FrankfurterClient { + + private final RestTemplate restTemplate; + private final FrankfurterApiProperties properties; + + public FrankfurterClient(RestTemplate restTemplate, FrankfurterApiProperties properties) { + this.restTemplate = restTemplate; + this.properties = properties; + } + + public Map fetchLatestIdrRatesRaw() { + String url = "/latest?base=IDR"; + return getMap(url); + } + + public Map fetchHistoricalIdrUsdRaw() { + String url = "/" + properties.getHistoricalRange() + "?from=IDR&to=USD"; + return getMap(url); + } + + public Map fetchSupportedCurrenciesRaw() { + String url = "/currencies"; + ResponseEntity> response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() { + }); + return response.getBody() == null ? Map.of() : response.getBody(); + } + + private Map getMap(String url) { + ResponseEntity> response = restTemplate.exchange( + url, + HttpMethod.GET, + null, + new ParameterizedTypeReference<>() { + }); + return response.getBody() == null ? Map.of() : response.getBody(); + } +} diff --git a/src/main/java/com/allobank/finance/config/.gitkeep b/src/main/java/com/allobank/finance/config/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/allobank/finance/config/FrankfurterApiProperties.java b/src/main/java/com/allobank/finance/config/FrankfurterApiProperties.java new file mode 100644 index 00000000..a7a53f00 --- /dev/null +++ b/src/main/java/com/allobank/finance/config/FrankfurterApiProperties.java @@ -0,0 +1,53 @@ +package com.allobank.finance.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "frankfurter.api") +public class FrankfurterApiProperties { + + private String baseUrl; + private String historicalRange; + private String githubUsername; + private int connectTimeoutMillis; + private int readTimeoutMillis; + + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public String getHistoricalRange() { + return historicalRange; + } + + public void setHistoricalRange(String historicalRange) { + this.historicalRange = historicalRange; + } + + public String getGithubUsername() { + return githubUsername; + } + + public void setGithubUsername(String githubUsername) { + this.githubUsername = githubUsername; + } + + public int getConnectTimeoutMillis() { + return connectTimeoutMillis; + } + + public void setConnectTimeoutMillis(int connectTimeoutMillis) { + this.connectTimeoutMillis = connectTimeoutMillis; + } + + public int getReadTimeoutMillis() { + return readTimeoutMillis; + } + + public void setReadTimeoutMillis(int readTimeoutMillis) { + this.readTimeoutMillis = readTimeoutMillis; + } +} diff --git a/src/main/java/com/allobank/finance/config/OpenApiConfig.java b/src/main/java/com/allobank/finance/config/OpenApiConfig.java new file mode 100644 index 00000000..fe0b839d --- /dev/null +++ b/src/main/java/com/allobank/finance/config/OpenApiConfig.java @@ -0,0 +1,14 @@ +package com.allobank.finance.config; + +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.info.Contact; +import io.swagger.v3.oas.annotations.info.Info; +import io.swagger.v3.oas.annotations.servers.Server; +import org.springframework.context.annotation.Configuration; + +@Configuration +@OpenAPIDefinition(info = @Info(title = "Allo Backend Test API", version = "v1", description = "API documentation for finance data endpoints.", contact = @Contact(name = "Allo Backend Candidate")), servers = { + @Server(url = "http://localhost:8080", description = "Local server") +}) +public class OpenApiConfig { +} diff --git a/src/main/java/com/allobank/finance/config/RestTemplateFactoryBean.java b/src/main/java/com/allobank/finance/config/RestTemplateFactoryBean.java new file mode 100644 index 00000000..1079f4c5 --- /dev/null +++ b/src/main/java/com/allobank/finance/config/RestTemplateFactoryBean.java @@ -0,0 +1,37 @@ +package com.allobank.finance.config; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.DefaultUriBuilderFactory; + +@Component +public class RestTemplateFactoryBean implements FactoryBean { + + private final FrankfurterApiProperties properties; + + public RestTemplateFactoryBean(FrankfurterApiProperties properties) { + this.properties = properties; + } + + @Override + public RestTemplate getObject() { + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(properties.getConnectTimeoutMillis()); + requestFactory.setReadTimeout(properties.getReadTimeoutMillis()); + RestTemplate restTemplate = new RestTemplate(requestFactory); + restTemplate.setUriTemplateHandler(new DefaultUriBuilderFactory(properties.getBaseUrl())); + return restTemplate; + } + + @Override + public Class getObjectType() { + return RestTemplate.class; + } + + @Override + public boolean isSingleton() { + return true; + } +} diff --git a/src/main/java/com/allobank/finance/controller/.gitkeep b/src/main/java/com/allobank/finance/controller/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/allobank/finance/controller/FinanceDataController.java b/src/main/java/com/allobank/finance/controller/FinanceDataController.java new file mode 100644 index 00000000..ff3e5ae3 --- /dev/null +++ b/src/main/java/com/allobank/finance/controller/FinanceDataController.java @@ -0,0 +1,36 @@ +package com.allobank.finance.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +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; + +import com.allobank.finance.service.FinanceDataService; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/finance/data") +@Tag(name = "Finance Data", description = "Endpoints for preloaded finance data resources") +public class FinanceDataController { + + private final FinanceDataService financeDataService; + + public FinanceDataController(FinanceDataService financeDataService) { + this.financeDataService = financeDataService; + } + + @GetMapping("/{resourceType}") + @Operation(summary = "Get finance data by resource type", description = "Supported resourceType: latest_idr_rates, historical_idr_usd, supported_currencies. Response is a unified JSON array.") + @ApiResponse(responseCode = "200", description = "Resource found") + @ApiResponse(responseCode = "400", description = "Unsupported resource type") + public List> getFinanceData( + @Parameter(description = "Type of finance resource. Examples: latest_idr_rates, historical_idr_usd, supported_currencies, invalid_type") @PathVariable String resourceType) { + return financeDataService.findByResourceType(resourceType); + } +} diff --git a/src/main/java/com/allobank/finance/controller/HomeController.java b/src/main/java/com/allobank/finance/controller/HomeController.java new file mode 100644 index 00000000..fc5df1e8 --- /dev/null +++ b/src/main/java/com/allobank/finance/controller/HomeController.java @@ -0,0 +1,23 @@ +package com.allobank.finance.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@Tag(name = "Home", description = "Basic health/info endpoint") +public class HomeController { + + @GetMapping("/") + @Operation(summary = "Service info", description = "Returns basic app status and main endpoint info") + public Map home() { + return Map.of( + "app", "allo-backend-test", + "message", "Service is running", + "endpoints", Map.of( + "finance_data", "/api/finance/data/{resourceType}")); + } +} diff --git a/src/main/java/com/allobank/finance/dto/.gitkeep b/src/main/java/com/allobank/finance/dto/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/allobank/finance/exception/.gitkeep b/src/main/java/com/allobank/finance/exception/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/allobank/finance/exception/DataNotInitializedException.java b/src/main/java/com/allobank/finance/exception/DataNotInitializedException.java new file mode 100644 index 00000000..48f53389 --- /dev/null +++ b/src/main/java/com/allobank/finance/exception/DataNotInitializedException.java @@ -0,0 +1,8 @@ +package com.allobank.finance.exception; + +public class DataNotInitializedException extends RuntimeException { + + public DataNotInitializedException(String message) { + super(message); + } +} diff --git a/src/main/java/com/allobank/finance/exception/GlobalExceptionHandler.java b/src/main/java/com/allobank/finance/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..e21c9eff --- /dev/null +++ b/src/main/java/com/allobank/finance/exception/GlobalExceptionHandler.java @@ -0,0 +1,45 @@ +package com.allobank.finance.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import com.allobank.finance.service.FinanceDataService; + +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + private final FinanceDataService financeDataService; + + public GlobalExceptionHandler(FinanceDataService financeDataService) { + this.financeDataService = financeDataService; + } + + @ExceptionHandler(ResourceTypeNotSupportedException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Map handleResourceTypeNotSupported(ResourceTypeNotSupportedException exception) { + return Map.of( + "error", "Bad Request", + "message", exception.getMessage(), + "supportedResourceTypes", financeDataService.supportedResourceTypes()); + } + + @ExceptionHandler(DataNotInitializedException.class) + @ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE) + public Map handleDataNotInitialized(DataNotInitializedException exception) { + return Map.of( + "error", "Service Unavailable", + "message", exception.getMessage()); + } + + @ExceptionHandler(IllegalArgumentException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Map handleIllegalArgument(IllegalArgumentException exception) { + return Map.of( + "error", "Bad Request", + "message", exception.getMessage()); + } +} diff --git a/src/main/java/com/allobank/finance/exception/ResourceTypeNotSupportedException.java b/src/main/java/com/allobank/finance/exception/ResourceTypeNotSupportedException.java new file mode 100644 index 00000000..71e74c23 --- /dev/null +++ b/src/main/java/com/allobank/finance/exception/ResourceTypeNotSupportedException.java @@ -0,0 +1,8 @@ +package com.allobank.finance.exception; + +public class ResourceTypeNotSupportedException extends RuntimeException { + + public ResourceTypeNotSupportedException(String message) { + super(message); + } +} diff --git a/src/main/java/com/allobank/finance/repository/.gitkeep b/src/main/java/com/allobank/finance/repository/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/allobank/finance/runner/.gitkeep b/src/main/java/com/allobank/finance/runner/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/allobank/finance/runner/FinanceDataPreloadRunner.java b/src/main/java/com/allobank/finance/runner/FinanceDataPreloadRunner.java new file mode 100644 index 00000000..71a8a1a3 --- /dev/null +++ b/src/main/java/com/allobank/finance/runner/FinanceDataPreloadRunner.java @@ -0,0 +1,62 @@ +package com.allobank.finance.runner; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; + +import com.allobank.finance.store.FinanceDataStore; +import com.allobank.finance.strategy.IDRDataFetcher; +import com.allobank.finance.strategy.IDRDataFetcherRegistry; + +import java.time.OffsetDateTime; +import java.util.LinkedHashMap; +import java.util.Map; + +@Component +@ConditionalOnProperty(name = "finance.preload.enabled", havingValue = "true", matchIfMissing = true) +public class FinanceDataPreloadRunner implements ApplicationRunner { + + private static final Logger log = LoggerFactory.getLogger(FinanceDataPreloadRunner.class); + + private final IDRDataFetcherRegistry registry; + private final FinanceDataStore financeDataStore; + private final boolean failFast; + + public FinanceDataPreloadRunner( + IDRDataFetcherRegistry registry, + FinanceDataStore financeDataStore, + @Value("${finance.preload.fail-fast:false}") boolean failFast) { + this.registry = registry; + this.financeDataStore = financeDataStore; + this.failFast = failFast; + } + + @Override + public void run(ApplicationArguments args) { + Map loadedData = new LinkedHashMap<>(); + for (Map.Entry entry : registry.asMap().entrySet()) { + String resourceType = entry.getKey(); + try { + loadedData.put(resourceType, entry.getValue().fetch()); + } catch (Exception exception) { + if (failFast) { + throw exception; + } + String message = exception.getMessage() == null ? "Unknown upstream error" : exception.getMessage(); + log.warn("Preload failed for resourceType='{}'. App will continue with fallback payload. Cause: {}", + resourceType, message); + loadedData.put(resourceType, java.util.List.of(Map.of( + "resourceType", resourceType, + "status", "unavailable", + "message", "Upstream source unavailable during preload", + "details", message, + "at", OffsetDateTime.now().toString()))); + } + } + financeDataStore.initializeOnce(loadedData); + } +} diff --git a/src/main/java/com/allobank/finance/service/.gitkeep b/src/main/java/com/allobank/finance/service/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/allobank/finance/service/FinanceDataService.java b/src/main/java/com/allobank/finance/service/FinanceDataService.java new file mode 100644 index 00000000..a6bc8726 --- /dev/null +++ b/src/main/java/com/allobank/finance/service/FinanceDataService.java @@ -0,0 +1,39 @@ +package com.allobank.finance.service; + +import org.springframework.stereotype.Service; + +import com.allobank.finance.exception.ResourceTypeNotSupportedException; +import com.allobank.finance.store.FinanceDataStore; +import com.allobank.finance.strategy.IDRDataFetcherRegistry; + +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Service +public class FinanceDataService { + + private final FinanceDataStore financeDataStore; + private final IDRDataFetcherRegistry registry; + + public FinanceDataService(FinanceDataStore financeDataStore, IDRDataFetcherRegistry registry) { + this.financeDataStore = financeDataStore; + this.registry = registry; + } + + public List> findByResourceType(String resourceType) { + String normalizedResourceType = resourceType == null + ? "" + : resourceType.trim().toLowerCase(Locale.ROOT); + + if (!registry.asMap().containsKey(normalizedResourceType)) { + throw new ResourceTypeNotSupportedException("Unsupported resourceType: " + resourceType); + } + + return financeDataStore.getByResourceType(normalizedResourceType); + } + + public List supportedResourceTypes() { + return List.copyOf(registry.asMap().keySet()); + } +} diff --git a/src/main/java/com/allobank/finance/store/.gitkeep b/src/main/java/com/allobank/finance/store/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/allobank/finance/store/FinanceDataStore.java b/src/main/java/com/allobank/finance/store/FinanceDataStore.java new file mode 100644 index 00000000..89080138 --- /dev/null +++ b/src/main/java/com/allobank/finance/store/FinanceDataStore.java @@ -0,0 +1,72 @@ +package com.allobank.finance.store; + +import org.springframework.stereotype.Component; + +import com.allobank.finance.exception.DataNotInitializedException; +import com.allobank.finance.exception.ResourceTypeNotSupportedException; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +@Component +public class FinanceDataStore { + + private final AtomicReference> dataRef = new AtomicReference<>(); + + public void initializeOnce(Map initialData) { + Map immutableData = deepImmutableMap(initialData); + dataRef.compareAndSet(null, immutableData); + } + + @SuppressWarnings("unchecked") + public List> getByResourceType(String resourceType) { + Map currentData = dataRef.get(); + if (currentData == null) { + throw new DataNotInitializedException("Finance data is not initialized yet."); + } + + Object value = currentData.get(resourceType); + if (value == null) { + throw new ResourceTypeNotSupportedException("Unsupported resourceType: " + resourceType); + } + return (List>) value; + } + + public List supportedResourceTypes() { + Map currentData = dataRef.get(); + if (currentData == null) { + throw new DataNotInitializedException("Finance data is not initialized yet."); + } + return List.copyOf(currentData.keySet()); + } + + private static Map deepImmutableMap(Map source) { + Map copied = new LinkedHashMap<>(); + for (Map.Entry entry : source.entrySet()) { + copied.put(entry.getKey(), deepImmutableValue(entry.getValue())); + } + return Collections.unmodifiableMap(copied); + } + + private static Object deepImmutableValue(Object value) { + if (value instanceof Map mapValue) { + Map nested = new LinkedHashMap<>(); + for (Map.Entry entry : mapValue.entrySet()) { + nested.put(String.valueOf(entry.getKey()), deepImmutableValue(entry.getValue())); + } + return Collections.unmodifiableMap(nested); + } + + if (value instanceof List listValue) { + List copied = listValue.stream() + .map(FinanceDataStore::deepImmutableValue) + .toList(); + return Collections.unmodifiableList(copied); + } + + return value; + } +} diff --git a/src/main/java/com/allobank/finance/strategy/.gitkeep b/src/main/java/com/allobank/finance/strategy/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/allobank/finance/strategy/HistoricalIdrUsdFetcher.java b/src/main/java/com/allobank/finance/strategy/HistoricalIdrUsdFetcher.java new file mode 100644 index 00000000..cc351dea --- /dev/null +++ b/src/main/java/com/allobank/finance/strategy/HistoricalIdrUsdFetcher.java @@ -0,0 +1,64 @@ +package com.allobank.finance.strategy; + +import org.springframework.stereotype.Component; + +import com.allobank.finance.client.FrankfurterClient; + +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +public class HistoricalIdrUsdFetcher implements IDRDataFetcher { + + private final FrankfurterClient frankfurterClient; + + public HistoricalIdrUsdFetcher(FrankfurterClient frankfurterClient) { + this.frankfurterClient = frankfurterClient; + } + + @Override + public String resourceType() { + return "historical_idr_usd"; + } + + @Override + public List> fetch() { + Map rawResponse = frankfurterClient.fetchHistoricalIdrUsdRaw(); + + @SuppressWarnings("unchecked") + Map> ratesByDate = (Map>) rawResponse.getOrDefault( + "rates", + Map.of()); + + return ratesByDate.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(entry -> { + BigDecimal usdPerIdr = toBigDecimal(entry.getValue().get("USD")); + + Map result = new LinkedHashMap<>(); + result.put("resourceType", resourceType()); + result.put("date", entry.getKey()); + result.put("usd_per_idr", usdPerIdr); + return result; + }) + .toList(); + } + + private static BigDecimal toBigDecimal(Object value) { + if (value == null) { + throw new IllegalArgumentException("USD rate is missing from historical upstream response."); + } + if (value instanceof BigDecimal bigDecimal) { + return bigDecimal; + } + if (value instanceof Number number) { + return new BigDecimal(number.toString()); + } + if (value instanceof String stringValue) { + return new BigDecimal(stringValue); + } + throw new IllegalArgumentException("Numeric value expected but got: " + value); + } +} diff --git a/src/main/java/com/allobank/finance/strategy/IDRDataFetcher.java b/src/main/java/com/allobank/finance/strategy/IDRDataFetcher.java new file mode 100644 index 00000000..1e990c60 --- /dev/null +++ b/src/main/java/com/allobank/finance/strategy/IDRDataFetcher.java @@ -0,0 +1,11 @@ +package com.allobank.finance.strategy; + +import java.util.List; +import java.util.Map; + +public interface IDRDataFetcher { + + String resourceType(); + + List> fetch(); +} diff --git a/src/main/java/com/allobank/finance/strategy/IDRDataFetcherRegistry.java b/src/main/java/com/allobank/finance/strategy/IDRDataFetcherRegistry.java new file mode 100644 index 00000000..b1a7f302 --- /dev/null +++ b/src/main/java/com/allobank/finance/strategy/IDRDataFetcherRegistry.java @@ -0,0 +1,23 @@ +package com.allobank.finance.strategy; + +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Component +public class IDRDataFetcherRegistry { + + private final Map fetcherMap; + + public IDRDataFetcherRegistry(List fetchers) { + this.fetcherMap = fetchers.stream() + .collect(Collectors.toUnmodifiableMap(IDRDataFetcher::resourceType, Function.identity())); + } + + public Map asMap() { + return fetcherMap; + } +} diff --git a/src/main/java/com/allobank/finance/strategy/LatestIdrRatesFetcher.java b/src/main/java/com/allobank/finance/strategy/LatestIdrRatesFetcher.java new file mode 100644 index 00000000..5eb7ae06 --- /dev/null +++ b/src/main/java/com/allobank/finance/strategy/LatestIdrRatesFetcher.java @@ -0,0 +1,88 @@ +package com.allobank.finance.strategy; + +import org.springframework.stereotype.Component; + +import com.allobank.finance.client.FrankfurterClient; +import com.allobank.finance.config.FrankfurterApiProperties; + +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +@Component +public class LatestIdrRatesFetcher implements IDRDataFetcher { + + private static final MathContext MATH_CONTEXT = new MathContext(12, RoundingMode.HALF_UP); + private static final BigDecimal ONE = BigDecimal.ONE; + + private final FrankfurterClient frankfurterClient; + private final FrankfurterApiProperties properties; + + public LatestIdrRatesFetcher(FrankfurterClient frankfurterClient, FrankfurterApiProperties properties) { + this.frankfurterClient = frankfurterClient; + this.properties = properties; + } + + @Override + public String resourceType() { + return "latest_idr_rates"; + } + + @Override + public List> fetch() { + Map rawResponse = frankfurterClient.fetchLatestIdrRatesRaw(); + + @SuppressWarnings("unchecked") + Map rates = (Map) rawResponse.getOrDefault("rates", Map.of()); + + BigDecimal rateUsd = toBigDecimal(rates.get("USD")); + if (rateUsd.signum() <= 0) { + throw new IllegalArgumentException("Rate USD must be positive."); + } + + BigDecimal spreadFactor = calculateSpreadFactor(properties.getGithubUsername()); + BigDecimal usdBuySpreadIdr = ONE.divide(rateUsd, MATH_CONTEXT) + .multiply(ONE.add(spreadFactor), MATH_CONTEXT) + .setScale(8, RoundingMode.HALF_UP); + + Map enrichedRates = new LinkedHashMap<>(rates); + enrichedRates.put("USD_BuySpread_IDR", usdBuySpreadIdr); + + Map result = new LinkedHashMap<>(); + result.put("resourceType", resourceType()); + result.put("base", rawResponse.getOrDefault("base", "IDR")); + result.put("date", rawResponse.get("date")); + result.put("spread_factor", spreadFactor.setScale(5, RoundingMode.HALF_UP)); + result.put("rates", enrichedRates); + return List.of(result); + } + + private static BigDecimal calculateSpreadFactor(String githubUsername) { + int asciiSum = githubUsername == null + ? 0 + : githubUsername.toLowerCase(Locale.ROOT).chars().sum(); + int modValue = asciiSum % 1000; + return BigDecimal.valueOf(modValue) + .divide(BigDecimal.valueOf(100000L), MATH_CONTEXT); + } + + private static BigDecimal toBigDecimal(Object value) { + if (value == null) { + throw new IllegalArgumentException("USD rate is missing from upstream response."); + } + if (value instanceof BigDecimal bigDecimal) { + return bigDecimal; + } + if (value instanceof Number number) { + return new BigDecimal(number.toString(), MATH_CONTEXT); + } + if (value instanceof String stringValue) { + return new BigDecimal(stringValue, MATH_CONTEXT); + } + throw new IllegalArgumentException("Numeric value expected but got: " + value); + } +} diff --git a/src/main/java/com/allobank/finance/strategy/SupportedCurrenciesFetcher.java b/src/main/java/com/allobank/finance/strategy/SupportedCurrenciesFetcher.java new file mode 100644 index 00000000..643fc3df --- /dev/null +++ b/src/main/java/com/allobank/finance/strategy/SupportedCurrenciesFetcher.java @@ -0,0 +1,39 @@ +package com.allobank.finance.strategy; + +import org.springframework.stereotype.Component; + +import com.allobank.finance.client.FrankfurterClient; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +public class SupportedCurrenciesFetcher implements IDRDataFetcher { + + private final FrankfurterClient frankfurterClient; + + public SupportedCurrenciesFetcher(FrankfurterClient frankfurterClient) { + this.frankfurterClient = frankfurterClient; + } + + @Override + public String resourceType() { + return "supported_currencies"; + } + + @Override + public List> fetch() { + Map rawResponse = frankfurterClient.fetchSupportedCurrenciesRaw(); + return rawResponse.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(entry -> { + Map result = new LinkedHashMap<>(); + result.put("resourceType", resourceType()); + result.put("code", entry.getKey()); + result.put("name", entry.getValue()); + return result; + }) + .toList(); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..9dec207d --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,23 @@ +spring: + application: + name: allo-backend-test + +server: + port: 8080 + +springdoc: + swagger-ui: + path: /swagger-ui + +frankfurter: + api: + base-url: https://api.frankfurter.app + historical-range: 2024-01-01..2024-01-05 + github-username: joniheri + connect-timeout-millis: 5000 + read-timeout-millis: 5000 + +finance: + preload: + enabled: true + fail-fast: false diff --git a/src/test/java/com/allobank/test/AlloBankTestApplicationTests.java b/src/test/java/com/allobank/test/AlloBankTestApplicationTests.java new file mode 100644 index 00000000..6352f2c7 --- /dev/null +++ b/src/test/java/com/allobank/test/AlloBankTestApplicationTests.java @@ -0,0 +1,16 @@ +package com.allobank.test; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +import com.allobank.finance.AlloBankTestApplication; + +@SpringBootTest( + classes = AlloBankTestApplication.class, + properties = "finance.preload.enabled=false") +class AlloBankTestApplicationTests { + + @Test + void contextLoads() { + } +} diff --git a/src/test/java/com/allobank/test/integration/.gitkeep b/src/test/java/com/allobank/test/integration/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/test/java/com/allobank/test/runner/FinanceDataPreloadRunnerIntegrationTest.java b/src/test/java/com/allobank/test/runner/FinanceDataPreloadRunnerIntegrationTest.java new file mode 100644 index 00000000..5ecbecc2 --- /dev/null +++ b/src/test/java/com/allobank/test/runner/FinanceDataPreloadRunnerIntegrationTest.java @@ -0,0 +1,76 @@ +package com.allobank.test.runner; + +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.test.context.ActiveProfiles; + +import com.allobank.finance.AlloBankTestApplication; +import com.allobank.finance.client.FrankfurterClient; +import com.allobank.finance.store.FinanceDataStore; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +@SpringBootTest(properties = { + "finance.preload.enabled=true", + "finance.preload.fail-fast=true" +}, classes = { + AlloBankTestApplication.class, + FinanceDataPreloadRunnerIntegrationTest.StubFrankfurterClientConfiguration.class +}) +@ActiveProfiles("test") +class FinanceDataPreloadRunnerIntegrationTest { + + @Autowired + private FinanceDataStore financeDataStore; + + @Test + void runnerShouldPreloadAllResourcesIntoStoreOnStartup() { + List> latest = financeDataStore.getByResourceType("latest_idr_rates"); + List> historical = financeDataStore.getByResourceType("historical_idr_usd"); + List> currencies = financeDataStore.getByResourceType("supported_currencies"); + + assertEquals(1, latest.size()); + assertEquals(2, historical.size()); + assertEquals(2, currencies.size()); + } + + @Test + void runnerLoadedDataShouldBeImmutableAtReadTime() { + List> latest = financeDataStore.getByResourceType("latest_idr_rates"); + assertThrows(UnsupportedOperationException.class, () -> latest.add(Map.of())); + } + + @TestConfiguration + static class StubFrankfurterClientConfiguration { + + @Bean + @Primary + FrankfurterClient frankfurterClient() { + FrankfurterClient mockClient = Mockito.mock(FrankfurterClient.class); + Mockito.when(mockClient.fetchLatestIdrRatesRaw()).thenReturn(Map.of( + "base", "IDR", + "date", "2024-01-05", + "rates", Map.of( + "USD", new BigDecimal("0.000064"), + "EUR", new BigDecimal("0.000057")))); + Mockito.when(mockClient.fetchHistoricalIdrUsdRaw()).thenReturn(Map.of( + "rates", Map.of( + "2024-01-01", Map.of("USD", new BigDecimal("0.000063")), + "2024-01-02", Map.of("USD", new BigDecimal("0.000064"))))); + Mockito.when(mockClient.fetchSupportedCurrenciesRaw()).thenReturn(Map.of( + "USD", "United States Dollar", + "IDR", "Indonesian Rupiah")); + return mockClient; + } + } +} diff --git a/src/test/java/com/allobank/test/service/.gitkeep b/src/test/java/com/allobank/test/service/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/test/java/com/allobank/test/service/FinanceDataServiceTest.java b/src/test/java/com/allobank/test/service/FinanceDataServiceTest.java new file mode 100644 index 00000000..46f092f6 --- /dev/null +++ b/src/test/java/com/allobank/test/service/FinanceDataServiceTest.java @@ -0,0 +1,81 @@ +package com.allobank.test.service; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.allobank.finance.exception.ResourceTypeNotSupportedException; +import com.allobank.finance.service.FinanceDataService; +import com.allobank.finance.store.FinanceDataStore; +import com.allobank.finance.strategy.IDRDataFetcher; +import com.allobank.finance.strategy.IDRDataFetcherRegistry; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class FinanceDataServiceTest { + + @Mock + private FinanceDataStore financeDataStore; + + @Mock + private IDRDataFetcherRegistry registry; + + @InjectMocks + private FinanceDataService financeDataService; + + @Test + void findByResourceTypeShouldDelegateToStore() { + List> expected = List.of(Map.of("base", "IDR")); + when(registry.asMap()).thenReturn(Map.of("latest_idr_rates", mock(IDRDataFetcher.class))); + when(financeDataStore.getByResourceType("latest_idr_rates")).thenReturn(expected); + + List> actual = financeDataService.findByResourceType("latest_idr_rates"); + + assertEquals(expected, actual); + } + + @Test + void findByResourceTypeShouldNormalizeIncomingValue() { + List> expected = List.of(Map.of("base", "IDR")); + when(registry.asMap()).thenReturn(Map.of("latest_idr_rates", mock(IDRDataFetcher.class))); + when(financeDataStore.getByResourceType("latest_idr_rates")).thenReturn(expected); + + List> actual = financeDataService.findByResourceType(" LATEST_IDR_RATES "); + + assertEquals(expected, actual); + } + + @Test + void supportedResourceTypesShouldUseRegistryKeys() { + List expected = List.of("latest_idr_rates", "historical_idr_usd", "supported_currencies"); + when(registry.asMap()).thenReturn(Map.of( + "latest_idr_rates", mock(IDRDataFetcher.class), + "historical_idr_usd", mock(IDRDataFetcher.class), + "supported_currencies", mock(IDRDataFetcher.class))); + + List actual = financeDataService.supportedResourceTypes(); + + List sortedExpected = new ArrayList<>(expected); + List sortedActual = new ArrayList<>(actual); + sortedExpected.sort(String::compareTo); + sortedActual.sort(String::compareTo); + assertEquals(sortedExpected, sortedActual); + } + + @Test + void findByResourceTypeShouldThrowWhenResourceTypeUnsupported() { + when(registry.asMap()).thenReturn(Map.of()); + + assertThrows(ResourceTypeNotSupportedException.class, () -> financeDataService.findByResourceType("unknown")); + } +} diff --git a/src/test/java/com/allobank/test/store/FinanceDataStoreTest.java b/src/test/java/com/allobank/test/store/FinanceDataStoreTest.java new file mode 100644 index 00000000..85a6f10f --- /dev/null +++ b/src/test/java/com/allobank/test/store/FinanceDataStoreTest.java @@ -0,0 +1,51 @@ +package com.allobank.test.store; + +import org.junit.jupiter.api.Test; + +import com.allobank.finance.exception.DataNotInitializedException; +import com.allobank.finance.exception.ResourceTypeNotSupportedException; +import com.allobank.finance.store.FinanceDataStore; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class FinanceDataStoreTest { + + @Test + void initializeOnceShouldKeepFirstValue() { + FinanceDataStore store = new FinanceDataStore(); + + store.initializeOnce(Map.of("latest_idr_rates", List.of(Map.of("value", "first")))); + store.initializeOnce(Map.of("latest_idr_rates", List.of(Map.of("value", "second")))); + + assertEquals(List.of(Map.of("value", "first")), store.getByResourceType("latest_idr_rates")); + } + + @Test + void getByResourceTypeShouldThrowWhenStoreNotInitialized() { + FinanceDataStore store = new FinanceDataStore(); + + assertThrows(DataNotInitializedException.class, () -> store.getByResourceType("latest_idr_rates")); + } + + @Test + void getByResourceTypeShouldThrowForUnsupportedResourceType() { + FinanceDataStore store = new FinanceDataStore(); + store.initializeOnce(Map.of("latest_idr_rates", List.of(Map.of("ok", true)))); + + assertThrows(ResourceTypeNotSupportedException.class, () -> store.getByResourceType("not_found")); + } + + @Test + void storeValueShouldBeDeeplyImmutable() { + FinanceDataStore store = new FinanceDataStore(); + store.initializeOnce(Map.of("latest_idr_rates", List.of(Map.of("base", "IDR")))); + + List> storedValue = store.getByResourceType("latest_idr_rates"); + assertThrows(UnsupportedOperationException.class, () -> storedValue.add(Map.of())); + assertThrows(UnsupportedOperationException.class, () -> storedValue.get(0).put("x", "y")); + } +} diff --git a/src/test/java/com/allobank/test/strategy/.gitkeep b/src/test/java/com/allobank/test/strategy/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/test/java/com/allobank/test/strategy/HistoricalIdrUsdFetcherTest.java b/src/test/java/com/allobank/test/strategy/HistoricalIdrUsdFetcherTest.java new file mode 100644 index 00000000..c5f7c6bc --- /dev/null +++ b/src/test/java/com/allobank/test/strategy/HistoricalIdrUsdFetcherTest.java @@ -0,0 +1,51 @@ +package com.allobank.test.strategy; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.allobank.finance.client.FrankfurterClient; +import com.allobank.finance.strategy.HistoricalIdrUsdFetcher; + +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class HistoricalIdrUsdFetcherTest { + + @Mock + private FrankfurterClient frankfurterClient; + + @InjectMocks + private HistoricalIdrUsdFetcher fetcher; + + @Test + void resourceTypeShouldMatchContract() { + assertEquals("historical_idr_usd", fetcher.resourceType()); + } + + @Test + void fetchShouldTransformHistoricalRatesIntoUnifiedList() { + Map> rates = new LinkedHashMap<>(); + rates.put("2024-01-02", Map.of("USD", new BigDecimal("0.000064"))); + rates.put("2024-01-01", Map.of("USD", new BigDecimal("0.000063"))); + + when(frankfurterClient.fetchHistoricalIdrUsdRaw()).thenReturn(Map.of("rates", rates)); + + List> actual = fetcher.fetch(); + + assertEquals(2, actual.size()); + assertEquals("historical_idr_usd", actual.get(0).get("resourceType")); + assertEquals("2024-01-01", actual.get(0).get("date")); + assertEquals(new BigDecimal("0.000063"), actual.get(0).get("usd_per_idr")); + assertEquals("2024-01-02", actual.get(1).get("date")); + assertEquals(new BigDecimal("0.000064"), actual.get(1).get("usd_per_idr")); + } +} diff --git a/src/test/java/com/allobank/test/strategy/LatestIdrRatesFetcherTest.java b/src/test/java/com/allobank/test/strategy/LatestIdrRatesFetcherTest.java new file mode 100644 index 00000000..9ec9f743 --- /dev/null +++ b/src/test/java/com/allobank/test/strategy/LatestIdrRatesFetcherTest.java @@ -0,0 +1,95 @@ +package com.allobank.test.strategy; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.allobank.finance.client.FrankfurterClient; +import com.allobank.finance.config.FrankfurterApiProperties; +import com.allobank.finance.strategy.LatestIdrRatesFetcher; + +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class LatestIdrRatesFetcherTest { + + private static final MathContext MATH_CONTEXT = new MathContext(12, RoundingMode.HALF_UP); + + @Mock + private FrankfurterClient frankfurterClient; + + @Mock + private FrankfurterApiProperties properties; + + @InjectMocks + private LatestIdrRatesFetcher fetcher; + + @Test + void resourceTypeShouldMatchContract() { + assertEquals("latest_idr_rates", fetcher.resourceType()); + } + + @Test + void fetchShouldCalculateUsdBuySpreadIdr() { + Map rates = new LinkedHashMap<>(); + rates.put("USD", new BigDecimal("0.000064")); + rates.put("EUR", new BigDecimal("0.000057")); + + Map rawResponse = new LinkedHashMap<>(); + rawResponse.put("base", "IDR"); + rawResponse.put("date", "2024-01-05"); + rawResponse.put("rates", rates); + + when(frankfurterClient.fetchLatestIdrRatesRaw()).thenReturn(rawResponse); + when(properties.getGithubUsername()).thenReturn("joniheri"); + + List> actual = fetcher.fetch(); + Map result = actual.get(0); + + BigDecimal expectedSpreadFactor = calculateSpreadFactor("joniheri"); + BigDecimal expectedUsdBuySpread = BigDecimal.ONE + .divide(new BigDecimal("0.000064"), MATH_CONTEXT) + .multiply(BigDecimal.ONE.add(expectedSpreadFactor), MATH_CONTEXT) + .setScale(8, RoundingMode.HALF_UP); + + @SuppressWarnings("unchecked") + Map actualRates = (Map) result.get("rates"); + + assertEquals(1, actual.size()); + assertEquals("latest_idr_rates", result.get("resourceType")); + assertEquals("IDR", result.get("base")); + assertEquals("2024-01-05", result.get("date")); + assertEquals(expectedSpreadFactor.setScale(5, RoundingMode.HALF_UP), result.get("spread_factor")); + assertEquals(expectedUsdBuySpread, actualRates.get("USD_BuySpread_IDR")); + } + + @Test + void fetchShouldThrowWhenUsdMissing() { + Map rawResponse = new LinkedHashMap<>(); + rawResponse.put("base", "IDR"); + rawResponse.put("date", "2024-01-05"); + rawResponse.put("rates", Map.of("EUR", new BigDecimal("0.000057"))); + + when(frankfurterClient.fetchLatestIdrRatesRaw()).thenReturn(rawResponse); + + assertThrows(IllegalArgumentException.class, () -> fetcher.fetch()); + } + + private static BigDecimal calculateSpreadFactor(String githubUsername) { + int asciiSum = githubUsername.toLowerCase(Locale.ROOT).chars().sum(); + int modValue = asciiSum % 1000; + return BigDecimal.valueOf(modValue).divide(BigDecimal.valueOf(100000L), MATH_CONTEXT); + } +} diff --git a/src/test/java/com/allobank/test/strategy/SupportedCurrenciesFetcherTest.java b/src/test/java/com/allobank/test/strategy/SupportedCurrenciesFetcherTest.java new file mode 100644 index 00000000..dc737b6c --- /dev/null +++ b/src/test/java/com/allobank/test/strategy/SupportedCurrenciesFetcherTest.java @@ -0,0 +1,47 @@ +package com.allobank.test.strategy; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.allobank.finance.client.FrankfurterClient; +import com.allobank.finance.strategy.SupportedCurrenciesFetcher; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class SupportedCurrenciesFetcherTest { + + @Mock + private FrankfurterClient frankfurterClient; + + @InjectMocks + private SupportedCurrenciesFetcher fetcher; + + @Test + void resourceTypeShouldMatchContract() { + assertEquals("supported_currencies", fetcher.resourceType()); + } + + @Test + void fetchShouldReturnSortedUnifiedCurrencyRows() { + when(frankfurterClient.fetchSupportedCurrenciesRaw()).thenReturn(Map.of( + "USD", "United States Dollar", + "IDR", "Indonesian Rupiah")); + + List> actual = fetcher.fetch(); + + assertEquals(2, actual.size()); + assertEquals("IDR", actual.get(0).get("code")); + assertEquals("Indonesian Rupiah", actual.get(0).get("name")); + assertEquals("USD", actual.get(1).get("code")); + assertEquals("United States Dollar", actual.get(1).get("name")); + assertEquals("supported_currencies", actual.get(0).get("resourceType")); + } +}