diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..bfce9f2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,72 @@ +# ======================== +# BUILD FILES +# ======================== +/target/ +*.class +*.jar +*.war +*.ear + +# ======================== +# LOG FILES +# ======================== +*.log + +# ======================== +# IDE - IntelliJ +# ======================== +.idea/ +*.iws +*.iml +*.ipr +out/ + +# ======================== +# IDE - Eclipse +# ======================== +.project +.classpath +.settings/ + +# ======================== +# IDE - VS Code +# ======================== +.vscode/ + +# ======================== +# OS FILES +# ======================== +.DS_Store +Thumbs.db + +# ======================== +# ENV FILES +# ======================== +.env +.env.* + +# ======================== +# MAVEN +# ======================== +.mvn/wrapper/maven-wrapper.jar +.mvn/wrapper/maven-wrapper.properties + +# ======================== +# SPRING BOOT +# ======================== +/logs/ +*.pid + +# ======================== +# TEST OUTPUT +# ======================== +/surefire-reports/ +/failsafe-reports/ + +# ======================== +# TEMP FILES +# ======================== +*.tmp +*.swp +*.lst +*.txt \ No newline at end of file diff --git a/README.md b/README.md index 5e58ae2a..e5daf34b 100644 --- a/README.md +++ b/README.md @@ -1,139 +1,150 @@ -# Allo Bank Backend Developer Take-Home Test +## Ringkasan -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. +PR ini berisi implementasi REST API untuk mengagregasi data nilai tukar IDR dari Frankfurter API sesuai dengan requirement. -## 📝 Objective +Data yang disediakan mencakup: -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. +* Kurs terbaru terhadap IDR +* Data historis IDR ke USD +* Daftar mata uang yang tersedia -The focus of this test is not just functional correctness, but demonstrating clean code, advanced Spring concepts, thread-safe design, and architectural clarity. +Seluruh data diambil satu kali saat aplikasi start dan disimpan di memory, sehingga endpoint tidak melakukan request ulang ke API eksternal. -## I. Core Task: The Polymorphic API +--- -### 1. External API Integration (Frankfurter API) +## Fitur yang Diimplementasikan -* **Base URL (Public):** `https://api.frankfurter.app/`. +* Endpoint: -* You must integrate with three distinct data resources to enforce the architectural pattern: + * `GET /api/finance/data/{resourceType}` - 1. `/latest?base=IDR` (The latest rates relative to IDR) +* Resource yang didukung: - 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.* + * `latest_idr_rates` + * `historical_idr_usd` + * `supported_currencies` - 3. `/currencies` (The list of all supported currency symbols) +* Penambahan field: -### 2. Internal API Endpoint + * `USD_BuySpread_IDR` (khusus latest rate) -You must expose **one single endpoint** in your application: ```GET /api/finance/data/{resourceType}``` +--- -Where `{resourceType}` can be one of the three strings: `latest_idr_rates`, `historical_idr_usd`, or `supported_currencies`. +## Pendekatan Arsitektur -### 3. Required Functionality & Business Logic +### Strategy Pattern -* **Resource Handling:** Your service must correctly map the three incoming `resourceType` values to the correct data fetching strategies. +Digunakan untuk memisahkan logic pengambilan data berdasarkan resource. -* **Data Load:** All three resources should be fetched from the external API. +Setiap resource memiliki implementasi sendiri dari interface `IDRDataFetcher`, sehingga tidak perlu menggunakan if/else di controller. -* **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. +Pendekatan ini memudahkan jika ingin menambahkan resource baru ke depannya. - **The Spread Factor Must Be Unique :** +--- - 1. **Input:** Your GitHub username (e.g., `johndoe47`). - 2. **Calculation:** Calculate the sum of the Unicode (ASCII) values of all characters in your lowercase GitHub username string. - 3. **Spread Factor Derivation:** `Spread Factor = (Sum of Unicode Values % 1000) / 100000.0` - *(This will yield a unique factor between 0.00000 and 0.00999, ensuring a personalized result.)* +### FactoryBean untuk WebClient - **Final Formula:** `USD_BuySpread_IDR = (1 / Rate_USD) * (1 + Spread Factor)` (where `Rate_USD` is the value from the API when `base=IDR`). +WebClient dibuat menggunakan FactoryBean agar konfigurasi seperti base URL dan timeout terpusat. -* **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. +Dengan cara ini, pembuatan client tidak tersebar di berbagai class dan lebih mudah dikelola. -## II. Architectural Constraints +--- -Meeting the core task is only one part of the solution. The following constraints must be strictly adhered to and will be heavily weighted during evaluation: +### ApplicationRunner -### Constraint A: The Strategy Pattern +Digunakan untuk load data dari external API saat aplikasi start. -The logic for handling the three different resources (`latest_idr_rates`, `historical_idr_usd`, `supported_currencies`) must be implemented using the **Strategy Design Pattern**. +Alasan penggunaan: -1. Define a clear **Strategy Interface** (e.g., `IDRDataFetcher`). +* memastikan data hanya diambil sekali +* menghindari pemanggilan API berulang +* memastikan semua dependency sudah siap sebelum proses fetch -2. Implement **three concrete strategy classes** (one for each resource). +--- -3. The main `Controller` should dynamically select the correct strategy implementation using a map-based lookup injected by Spring, avoiding any manual `if/else` or `switch` logic in the controller layer. +## Response Design -### Constraint B: Client Factory Bean +Response dibuat konsisten menggunakan wrapper: -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**. +```json +[ + { + "resourceType": "latest_idr_rates", + "data": { ... } + } +] +``` -* 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). +Pendekatan ini memudahkan parsing di sisi client dan menjaga konsistensi antar endpoint. -* ***You may not define the client as a simple `@Bean` in a `@Configuration` class.*** +--- -### Constraint C: Startup Data Runner & Immutability +## Error Handling & Timeout -The aggregated data for **ALL three resources** must be fetched **exactly once on application startup** and loaded into an in-memory store. +* Menambahkan timeout pada pemanggilan WebClient +* Menangani error dari external API secara graceful +* Menambahkan GlobalExceptionHandler untuk menangani error umum -1. Use a Spring Boot **`ApplicationRunner`** or **`CommandLineRunner`** component to initiate the data fetching process. +Contoh response saat error: -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. +```json +{ + "error": "Failed to fetch data", + "message": "TimeoutException" +} +``` -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 +## Testing -Your final solution must demonstrate production quality through code, testing, and communication. +Unit test mencakup: -### 1. Robustness & Best Practices +* Strategy (latest, historical, currencies) +* SpreadUtil +* DataLoader -* Graceful **Error Handling** for network failures or 4xx/5xx responses from the external API. +WebClient dimock agar test tidak bergantung ke API eksternal. -* Proper use of **Configuration Properties** (e.g., `application.yml`) for external service URLs. +--- -* Clear separation of concerns (Controller, Service, Model/DTO, etc.). +## Cara Menjalankan -### 2. Testing +```bash +mvn clean install +mvn spring-boot:run +``` -* **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. +## Contoh Penggunaan -### 3. Documentation +```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 +``` -A clear `README.md` is mandatory. It must include: +--- -* **Setup/Run Instructions:** Clear steps to clone, build, and run the application and tests. +## Personalisasi Spread -* **Endpoint Usage:** Example cURL commands to test the three different resource types. +GitHub Username: **haidir** -* **Personalization Note:** Clearly state your GitHub username and show the exact **Spread Factor** (e.g., `0.00765`) calculated by your function. +Spread dihitung menggunakan: +(sum ASCII username % 1000) / 100000.0 -* --- +--- -* ### 🛠️ Architectural Rationale +## Catatan - This section should contain a brief, but detailed, explanation answering the following questions: +* Data disimpan dalam bentuk immutable setelah load +* Menggunakan BigDecimal untuk menjaga presisi perhitungan +* Endpoint hanya membaca dari memory (tidak hit API lagi) - 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? +## Penutup - 3. **Startup Runner Choice:** Justify the choice of using an `ApplicationRunner` (or `CommandLineRunner`) for the initial data ingestion over a simpler `@PostConstruct` method. +Implementasi ini dibuat dengan fokus pada clean architecture, maintainability, dan testability. -## 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! diff --git a/pom.xml b/pom.xml new file mode 100644 index 00000000..7406ca12 --- /dev/null +++ b/pom.xml @@ -0,0 +1,33 @@ + + + 4.0.0 + com.allo + finance + 0.0.1-SNAPSHOT + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-webflux + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/src/main/java/com/allo/finance/FinanceApplication.java b/src/main/java/com/allo/finance/FinanceApplication.java new file mode 100644 index 00000000..4e030a35 --- /dev/null +++ b/src/main/java/com/allo/finance/FinanceApplication.java @@ -0,0 +1,12 @@ + +package com.allo.finance; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class FinanceApplication { + public static void main(String[] args) { + SpringApplication.run(FinanceApplication.class, args); + } +} diff --git a/src/main/java/com/allo/finance/config/JacksonConfig.java b/src/main/java/com/allo/finance/config/JacksonConfig.java new file mode 100644 index 00000000..d548ca75 --- /dev/null +++ b/src/main/java/com/allo/finance/config/JacksonConfig.java @@ -0,0 +1,18 @@ +package com.allo.finance.config; + +import com.fasterxml.jackson.databind.SerializationFeature; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; + +@Configuration +public class JacksonConfig { + + @SuppressWarnings("deprecation") + @Bean + public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { + return builder -> builder.featuresToEnable( + SerializationFeature.WRITE_BIGDECIMAL_AS_PLAIN + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/allo/finance/config/WebClientFactory.java b/src/main/java/com/allo/finance/config/WebClientFactory.java new file mode 100644 index 00000000..38d9153b --- /dev/null +++ b/src/main/java/com/allo/finance/config/WebClientFactory.java @@ -0,0 +1,24 @@ + +package com.allo.finance.config; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +@Component +public class WebClientFactory implements FactoryBean { + + @Value("${external.api.base-url}") + private String baseUrl; + + @Override + public WebClient getObject() { + return WebClient.builder().baseUrl(baseUrl).build(); + } + + @Override + public Class getObjectType() { + return WebClient.class; + } +} diff --git a/src/main/java/com/allo/finance/controller/FinanceController.java b/src/main/java/com/allo/finance/controller/FinanceController.java new file mode 100644 index 00000000..0cff0f08 --- /dev/null +++ b/src/main/java/com/allo/finance/controller/FinanceController.java @@ -0,0 +1,26 @@ +package com.allo.finance.controller; + +import com.allo.finance.dto.ApiResponse; +import com.allo.finance.store.DataStore; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/finance/data") +public class FinanceController { + + private final DataStore dataStore; + + public FinanceController(DataStore dataStore) { + this.dataStore = dataStore; + } + + @GetMapping("/{type}") + public List get(@PathVariable String type) { + + Object data = dataStore.get(type); + + return List.of(new ApiResponse(type, data)); + } +} \ No newline at end of file diff --git a/src/main/java/com/allo/finance/dto/ApiResponse.java b/src/main/java/com/allo/finance/dto/ApiResponse.java new file mode 100644 index 00000000..2d380fb2 --- /dev/null +++ b/src/main/java/com/allo/finance/dto/ApiResponse.java @@ -0,0 +1,20 @@ +package com.allo.finance.dto; + +public class ApiResponse { + + private String resourceType; + private Object data; + + public ApiResponse(String resourceType, Object data) { + this.resourceType = resourceType; + this.data = data; + } + + public String getResourceType() { + return resourceType; + } + + public Object getData() { + return data; + } +} \ No newline at end of file diff --git a/src/main/java/com/allo/finance/exception/GlobalExceptionHandler.java b/src/main/java/com/allo/finance/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..b1c5461a --- /dev/null +++ b/src/main/java/com/allo/finance/exception/GlobalExceptionHandler.java @@ -0,0 +1,22 @@ +package com.allo.finance.exception; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception ex) { + + return ResponseEntity.internalServerError().body( + Map.of( + "error", "Internal Server Error", + "message", ex.getMessage() + ) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/allo/finance/runner/DataLoader.java b/src/main/java/com/allo/finance/runner/DataLoader.java new file mode 100644 index 00000000..f04d190e --- /dev/null +++ b/src/main/java/com/allo/finance/runner/DataLoader.java @@ -0,0 +1,30 @@ + +package com.allo.finance.runner; + +import com.allo.finance.store.DataStore; +import com.allo.finance.strategy.IDRDataFetcher; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Component +public class DataLoader implements ApplicationRunner { + + private final List fetchers; + private final DataStore store; + + public DataLoader(List fetchers, DataStore store) { + this.fetchers = fetchers; + this.store = store; + } + + public void run(ApplicationArguments args){ + Map temp = new HashMap<>(); + fetchers.forEach(f -> temp.put(f.getType(), f.fetch())); + store.setAll(temp); + } +} diff --git a/src/main/java/com/allo/finance/store/DataStore.java b/src/main/java/com/allo/finance/store/DataStore.java new file mode 100644 index 00000000..2a8f6bb7 --- /dev/null +++ b/src/main/java/com/allo/finance/store/DataStore.java @@ -0,0 +1,19 @@ + +package com.allo.finance.store; + +import org.springframework.stereotype.Service; +import java.util.*; + +@Service +public class DataStore { + + private Map data = Map.of(); + + public synchronized void setAll(Map d){ + data = Collections.unmodifiableMap(new HashMap<>(d)); + } + + public Object get(String key){ + return data.get(key); + } +} diff --git a/src/main/java/com/allo/finance/strategy/CurrencyFetcher.java b/src/main/java/com/allo/finance/strategy/CurrencyFetcher.java new file mode 100644 index 00000000..6da2b467 --- /dev/null +++ b/src/main/java/com/allo/finance/strategy/CurrencyFetcher.java @@ -0,0 +1,36 @@ + +package com.allo.finance.strategy; + +import java.time.Duration; +import java.util.Map; + +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +@Service +public class CurrencyFetcher implements IDRDataFetcher { + + private final WebClient client; + + public CurrencyFetcher(WebClient client) { + this.client = client; + } + + public String getType() { return "supported_currencies"; } + + public Object fetch() { + try { + return client.get().uri("/currencies") + .retrieve() + .bodyToMono(Object.class) + .timeout(Duration.ofSeconds(10)) + .block(); + } catch (Exception e) { + + return Map.of( + "error", "Failed to fetch latest rates", + "message", e.getMessage() + ); + } + } +} diff --git a/src/main/java/com/allo/finance/strategy/HistoricalFetcher.java b/src/main/java/com/allo/finance/strategy/HistoricalFetcher.java new file mode 100644 index 00000000..85c28cc3 --- /dev/null +++ b/src/main/java/com/allo/finance/strategy/HistoricalFetcher.java @@ -0,0 +1,65 @@ +package com.allo.finance.strategy; + +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import java.math.BigDecimal; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; + +@Service +public class HistoricalFetcher implements IDRDataFetcher { + + private final WebClient client; + + public HistoricalFetcher(WebClient client) { + this.client = client; + } + + @Override + public String getType() { + return "historical_idr_usd"; + } + + @Override + public Object fetch() { + try { + + Map res = client.get() + .uri("/2024-01-01..2024-01-05?from=IDR&to=USD") + .retrieve() + .bodyToMono(Map.class) + .timeout(Duration.ofSeconds(10)) + .block(); + + Map> rates = + (Map>) res.get("rates"); + + Map formattedRates = new LinkedHashMap<>(); + + rates.forEach((date, currencyMap) -> { + + Map inner = new LinkedHashMap<>(); + + currencyMap.forEach((currency, value) -> { + BigDecimal bd = new BigDecimal(value.toString()); + inner.put(currency, bd); + }); + + formattedRates.put(date, inner); + }); + + res.put("rates", formattedRates); + + return res; + + } catch (Exception e) { + + return Map.of( + "error", "Failed to fetch latest rates", + "message", e.getMessage() + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/allo/finance/strategy/IDRDataFetcher.java b/src/main/java/com/allo/finance/strategy/IDRDataFetcher.java new file mode 100644 index 00000000..9bda0c27 --- /dev/null +++ b/src/main/java/com/allo/finance/strategy/IDRDataFetcher.java @@ -0,0 +1,7 @@ + +package com.allo.finance.strategy; + +public interface IDRDataFetcher { + String getType(); + Object fetch(); +} diff --git a/src/main/java/com/allo/finance/strategy/LatestRatesFetcher.java b/src/main/java/com/allo/finance/strategy/LatestRatesFetcher.java new file mode 100644 index 00000000..6b39363a --- /dev/null +++ b/src/main/java/com/allo/finance/strategy/LatestRatesFetcher.java @@ -0,0 +1,70 @@ +package com.allo.finance.strategy; + +import com.allo.finance.util.SpreadUtil; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.Map; + +@Service +public class LatestRatesFetcher implements IDRDataFetcher { + + private final WebClient client; + private final SpreadUtil spreadUtil; + + public LatestRatesFetcher(WebClient client, SpreadUtil spreadUtil) { + this.client = client; + this.spreadUtil = spreadUtil; + } + + @Override + public String getType() { + return "latest_idr_rates"; + } + + @Override + public Object fetch() { + + try { + + Map res = client.get() + .uri("/latest?base=IDR") + .retrieve() + .bodyToMono(Map.class) + .timeout(Duration.ofSeconds(10)) + .block(); + + if (res == null || res.get("rates") == null) { + throw new RuntimeException("Invalid response from API"); + } + + Map rates = (Map) res.get("rates"); + + double usd = rates.get("USD"); + + double spread = spreadUtil.calculateSpread(); + + double calc = (1 / usd) * (1 + spread); + + BigDecimal result = BigDecimal.valueOf(calc) + .setScale(2, RoundingMode.HALF_UP); + + res.put("USD_BuySpread_IDR", result); + + return res; + + } catch (Exception e) { + + return Map.of( + "error", "Failed to fetch latest rates", + "message", e.getMessage() + ); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/allo/finance/util/SpreadUtil.java b/src/main/java/com/allo/finance/util/SpreadUtil.java new file mode 100644 index 00000000..89c34db3 --- /dev/null +++ b/src/main/java/com/allo/finance/util/SpreadUtil.java @@ -0,0 +1,15 @@ + +package com.allo.finance.util; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class SpreadUtil { + @Value("${github.username}") + private String username; + + public double calculateSpread() { + int sum = username.chars().sum(); + return (sum % 1000) / 100000.0; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..a7ef459e --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,7 @@ + +external: + api: + base-url: https://api.frankfurter.app + +github: + username: haidir diff --git a/src/test/java/com/allo/finance/integration/FinanceControllerIT.java b/src/test/java/com/allo/finance/integration/FinanceControllerIT.java new file mode 100644 index 00000000..5cbb3d83 --- /dev/null +++ b/src/test/java/com/allo/finance/integration/FinanceControllerIT.java @@ -0,0 +1,24 @@ +package com.allo.finance.integration; + +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.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +class FinanceControllerIT { + + @Autowired + private MockMvc mockMvc; + + @Test + void shouldReturnLatestRates() throws Exception { + mockMvc.perform(get("/api/finance/data/latest_idr_rates")) + .andExpect(status().isOk()); + } +} \ No newline at end of file diff --git a/src/test/java/com/allo/test/unit/SpreadUtilTest.java b/src/test/java/com/allo/test/unit/SpreadUtilTest.java new file mode 100644 index 00000000..83ffb49e --- /dev/null +++ b/src/test/java/com/allo/test/unit/SpreadUtilTest.java @@ -0,0 +1,36 @@ +package com.allo.test.unit; + +import com.allo.finance.util.SpreadUtil; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.*; + +class SpreadUtilTest { + + @Test + void shouldCalculateSpreadCorrectly() { + SpreadUtil util = new SpreadUtil(); + + ReflectionTestUtils.setField(util, "username", "haidir"); + + double result = util.calculateSpread(); + + int sum = "haidir".chars().sum(); + double expected = (sum % 1000) / 100000.0; + + assertEquals(expected, result); + } + + @Test + void shouldReturnSpreadWithinRange() { + SpreadUtil util = new SpreadUtil(); + + ReflectionTestUtils.setField(util, "username", "haidir"); + + double result = util.calculateSpread(); + + assertTrue(result >= 0); + assertTrue(result < 0.01); + } +} \ No newline at end of file diff --git a/src/test/java/com/allo/test/unit/runner/DataLoaderTest.java b/src/test/java/com/allo/test/unit/runner/DataLoaderTest.java new file mode 100644 index 00000000..51468a36 --- /dev/null +++ b/src/test/java/com/allo/test/unit/runner/DataLoaderTest.java @@ -0,0 +1,40 @@ +package com.allo.test.unit.runner; + +import com.allo.finance.runner.DataLoader; +import com.allo.finance.strategy.IDRDataFetcher; +import com.allo.finance.store.DataStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.*; + +import java.util.List; +import java.util.Map; + +import static org.mockito.Mockito.*; + +class DataLoaderTest { + + @Mock DataStore store; + @Mock IDRDataFetcher fetcher; + + @InjectMocks + DataLoader loader; + + @BeforeEach + void setup() { + MockitoAnnotations.openMocks(this); + } + + @Test + void shouldLoadDataIntoStore() throws Exception { + + when(fetcher.getType()).thenReturn("test"); + when(fetcher.fetch()).thenReturn(Map.of("key", "value")); + + loader = new DataLoader(List.of(fetcher), store); + + loader.run(null); + + verify(store).setAll(anyMap()); + } +} \ No newline at end of file diff --git a/src/test/java/com/allo/test/unit/strategy/CurrencyFetcherTest.java b/src/test/java/com/allo/test/unit/strategy/CurrencyFetcherTest.java new file mode 100644 index 00000000..74721102 --- /dev/null +++ b/src/test/java/com/allo/test/unit/strategy/CurrencyFetcherTest.java @@ -0,0 +1,50 @@ +package com.allo.test.unit.strategy; + +import com.allo.finance.strategy.CurrencyFetcher; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.*; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class CurrencyFetcherTest { + + @Mock WebClient webClient; + @Mock WebClient.RequestHeadersUriSpec uriSpec; + @Mock WebClient.RequestHeadersSpec headersSpec; + @Mock WebClient.ResponseSpec responseSpec; + + @InjectMocks + CurrencyFetcher fetcher; + + @BeforeEach + void setup() { + MockitoAnnotations.openMocks(this); + } + + @Test + void shouldFetchCurrencies() { + + Map mock = Map.of("USD", "Dollar"); + + when(webClient.get()).thenReturn(uriSpec); + when(uriSpec.uri(anyString())).thenReturn(headersSpec); + when(headersSpec.retrieve()).thenReturn(responseSpec); + + when(responseSpec.bodyToMono(Mockito.any(Class.class))) + .thenReturn(Mono.just(mock)); + + Object result = fetcher.fetch(); + + assertEquals(mock, result); + + verify(webClient).get(); + verify(uriSpec).uri(anyString()); + verify(responseSpec).bodyToMono(any(Class.class)); + } +} \ No newline at end of file diff --git a/src/test/java/com/allo/test/unit/strategy/HistoricalFetcherTest.java b/src/test/java/com/allo/test/unit/strategy/HistoricalFetcherTest.java new file mode 100644 index 00000000..aa7df105 --- /dev/null +++ b/src/test/java/com/allo/test/unit/strategy/HistoricalFetcherTest.java @@ -0,0 +1,50 @@ +package com.allo.test.unit.strategy; + +import com.allo.finance.strategy.HistoricalFetcher; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.*; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class HistoricalFetcherTest { + + @Mock WebClient webClient; + @Mock WebClient.RequestHeadersUriSpec uriSpec; + @Mock WebClient.RequestHeadersSpec headersSpec; + @Mock WebClient.ResponseSpec responseSpec; + + @InjectMocks + HistoricalFetcher fetcher; + + @BeforeEach + void setup() { + MockitoAnnotations.openMocks(this); + } + + @Test + void shouldFetchHistoricalData() { + + Map rates = new HashMap<>(); + rates.put("2024-01-01", Map.of("USD", 0.000065)); + + Map mock = new HashMap<>(); + mock.put("rates", rates); + + when(webClient.get()).thenReturn(uriSpec); + when(uriSpec.uri(anyString())).thenReturn(headersSpec); + when(headersSpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.bodyToMono(Map.class)) + .thenReturn(Mono.just(mock)); + + Object result = fetcher.fetch(); + + assertEquals(mock, result); + } +} \ No newline at end of file diff --git a/src/test/java/com/allo/test/unit/strategy/LatestRatesFetcherTest.java b/src/test/java/com/allo/test/unit/strategy/LatestRatesFetcherTest.java new file mode 100644 index 00000000..679205f6 --- /dev/null +++ b/src/test/java/com/allo/test/unit/strategy/LatestRatesFetcherTest.java @@ -0,0 +1,60 @@ +package com.allo.test.unit.strategy; + +import com.allo.finance.strategy.LatestRatesFetcher; +import com.allo.finance.util.SpreadUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.*; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.math.BigDecimal; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class LatestRatesFetcherTest { + + @Mock WebClient webClient; + @Mock WebClient.RequestHeadersUriSpec uriSpec; + @Mock WebClient.RequestHeadersSpec headersSpec; + @Mock WebClient.ResponseSpec responseSpec; + @Mock SpreadUtil spreadUtil; + + @InjectMocks + LatestRatesFetcher fetcher; + + @BeforeEach + void setup() { + MockitoAnnotations.openMocks(this); + } + + @Test + void shouldCalculateUsdBuySpreadCorrectly() { + + Map mock = new HashMap<>(); + mock.put("rates", Map.of("USD", 0.000065)); + + when(webClient.get()).thenReturn(uriSpec); + when(uriSpec.uri(anyString())).thenReturn(headersSpec); + when(headersSpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.bodyToMono(Map.class)) + .thenReturn(Mono.just(mock)); + when(spreadUtil.calculateSpread()).thenReturn(0.001); + + Map result = (Map) fetcher.fetch(); + + double expected = (1 / 0.000065) * (1 + 0.001); + + assertEquals(expected, + ((BigDecimal) result.get("USD_BuySpread_IDR")).doubleValue(), + 0.01 + ); + + verify(webClient).get(); + verify(uriSpec).uri(anyString()); + verify(responseSpec).bodyToMono(any(Class.class)); + } +} \ No newline at end of file diff --git a/target/classes/application.yml b/target/classes/application.yml new file mode 100644 index 00000000..65700198 --- /dev/null +++ b/target/classes/application.yml @@ -0,0 +1,7 @@ + +external: + api: + base-url: https://api.frankfurter.app + +app: + github-username: haidir diff --git a/target/classes/com/allo/finance/FinanceApplication.class b/target/classes/com/allo/finance/FinanceApplication.class new file mode 100644 index 00000000..26c17ac0 Binary files /dev/null and b/target/classes/com/allo/finance/FinanceApplication.class differ diff --git a/target/classes/com/allo/finance/config/JacksonConfig.class b/target/classes/com/allo/finance/config/JacksonConfig.class new file mode 100644 index 00000000..f1d73ef8 Binary files /dev/null and b/target/classes/com/allo/finance/config/JacksonConfig.class differ diff --git a/target/classes/com/allo/finance/config/WebClientFactory.class b/target/classes/com/allo/finance/config/WebClientFactory.class new file mode 100644 index 00000000..1a80a26b Binary files /dev/null and b/target/classes/com/allo/finance/config/WebClientFactory.class differ diff --git a/target/classes/com/allo/finance/controller/FinanceController.class b/target/classes/com/allo/finance/controller/FinanceController.class new file mode 100644 index 00000000..58cef7b9 Binary files /dev/null and b/target/classes/com/allo/finance/controller/FinanceController.class differ diff --git a/target/classes/com/allo/finance/runner/DataLoader.class b/target/classes/com/allo/finance/runner/DataLoader.class new file mode 100644 index 00000000..5cccb970 Binary files /dev/null and b/target/classes/com/allo/finance/runner/DataLoader.class differ diff --git a/target/classes/com/allo/finance/store/DataStore.class b/target/classes/com/allo/finance/store/DataStore.class new file mode 100644 index 00000000..db17156d Binary files /dev/null and b/target/classes/com/allo/finance/store/DataStore.class differ diff --git a/target/classes/com/allo/finance/strategy/CurrencyFetcher.class b/target/classes/com/allo/finance/strategy/CurrencyFetcher.class new file mode 100644 index 00000000..3003e5ef Binary files /dev/null and b/target/classes/com/allo/finance/strategy/CurrencyFetcher.class differ diff --git a/target/classes/com/allo/finance/strategy/HistoricalFetcher.class b/target/classes/com/allo/finance/strategy/HistoricalFetcher.class new file mode 100644 index 00000000..4642cef7 Binary files /dev/null and b/target/classes/com/allo/finance/strategy/HistoricalFetcher.class differ diff --git a/target/classes/com/allo/finance/strategy/IDRDataFetcher.class b/target/classes/com/allo/finance/strategy/IDRDataFetcher.class new file mode 100644 index 00000000..ec146a9c Binary files /dev/null and b/target/classes/com/allo/finance/strategy/IDRDataFetcher.class differ diff --git a/target/classes/com/allo/finance/strategy/LatestFetcher.class b/target/classes/com/allo/finance/strategy/LatestFetcher.class new file mode 100644 index 00000000..d32ec8f1 Binary files /dev/null and b/target/classes/com/allo/finance/strategy/LatestFetcher.class differ diff --git a/target/maven-archiver/pom.properties b/target/maven-archiver/pom.properties new file mode 100644 index 00000000..9dfb58b4 --- /dev/null +++ b/target/maven-archiver/pom.properties @@ -0,0 +1,3 @@ +artifactId=finance +groupId=com.allo +version=0.0.1-SNAPSHOT diff --git a/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst b/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst new file mode 100644 index 00000000..26bf34e8 --- /dev/null +++ b/target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst @@ -0,0 +1,10 @@ +com\allo\finance\config\WebClientFactory.class +com\allo\finance\strategy\LatestFetcher.class +com\allo\finance\config\JacksonConfig.class +com\allo\finance\store\DataStore.class +com\allo\finance\strategy\HistoricalFetcher.class +com\allo\finance\FinanceApplication.class +com\allo\finance\strategy\CurrencyFetcher.class +com\allo\finance\runner\DataLoader.class +com\allo\finance\strategy\IDRDataFetcher.class +com\allo\finance\controller\FinanceController.class diff --git a/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst b/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst new file mode 100644 index 00000000..ddb11d36 --- /dev/null +++ b/target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst @@ -0,0 +1,10 @@ +D:\project\allo-backend-test\src\main\java\com\allo\finance\strategy\HistoricalFetcher.java +D:\project\allo-backend-test\src\main\java\com\allo\finance\controller\FinanceController.java +D:\project\allo-backend-test\src\main\java\com\allo\finance\FinanceApplication.java +D:\project\allo-backend-test\src\main\java\com\allo\finance\store\DataStore.java +D:\project\allo-backend-test\src\main\java\com\allo\finance\strategy\LatestFetcher.java +D:\project\allo-backend-test\src\main\java\com\allo\finance\strategy\IDRDataFetcher.java +D:\project\allo-backend-test\src\main\java\com\allo\finance\config\WebClientFactory.java +D:\project\allo-backend-test\src\main\java\com\allo\finance\strategy\CurrencyFetcher.java +D:\project\allo-backend-test\src\main\java\com\allo\finance\runner\DataLoader.java +D:\project\allo-backend-test\src\main\java\com\allo\finance\config\JacksonConfig.java diff --git a/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst b/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst new file mode 100644 index 00000000..4d62db6f --- /dev/null +++ b/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst @@ -0,0 +1,3 @@ +com\allo\finance\SpreadTest.class +com\allo\test\unit\SpreadUtilTest.class +com\allo\test\util\SpreadUtil.class diff --git a/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst b/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst new file mode 100644 index 00000000..c05ecd4e --- /dev/null +++ b/target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst @@ -0,0 +1,3 @@ +D:\project\allo-backend-test\src\test\java\com\allo\test\unit\SpreadUtilTest.java +D:\project\allo-backend-test\src\test\java\com\allo\finance\SpreadTest.java +D:\project\allo-backend-test\src\test\java\com\allo\test\unit\util\SpreadUtil.java diff --git a/target/surefire-reports/2026-04-02T14-44-18_570.dumpstream b/target/surefire-reports/2026-04-02T14-44-18_570.dumpstream new file mode 100644 index 00000000..fd2a8cf0 --- /dev/null +++ b/target/surefire-reports/2026-04-02T14-44-18_570.dumpstream @@ -0,0 +1,5 @@ +# Created at 2026-04-02T14:44:19.859 +Boot Manifest-JAR contains absolute paths in classpath 'D:\project\allo-backend-test\target\test-classes' +Hint: -Djdk.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/target/surefire-reports/TEST-com.allo.finance.SpreadTest.xml b/target/surefire-reports/TEST-com.allo.finance.SpreadTest.xml new file mode 100644 index 00000000..36dcf594 --- /dev/null +++ b/target/surefire-reports/TEST-com.allo.finance.SpreadTest.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/target/surefire-reports/TEST-com.allo.test.unit.SpreadUtilTest.xml b/target/surefire-reports/TEST-com.allo.test.unit.SpreadUtilTest.xml new file mode 100644 index 00000000..8f0084f7 --- /dev/null +++ b/target/surefire-reports/TEST-com.allo.test.unit.SpreadUtilTest.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/target/surefire-reports/com.allo.finance.SpreadTest.txt b/target/surefire-reports/com.allo.finance.SpreadTest.txt new file mode 100644 index 00000000..2faadcec --- /dev/null +++ b/target/surefire-reports/com.allo.finance.SpreadTest.txt @@ -0,0 +1,4 @@ +------------------------------------------------------------------------------- +Test set: com.allo.finance.SpreadTest +------------------------------------------------------------------------------- +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.166 s -- in com.allo.finance.SpreadTest diff --git a/target/surefire-reports/com.allo.test.unit.SpreadUtilTest.txt b/target/surefire-reports/com.allo.test.unit.SpreadUtilTest.txt new file mode 100644 index 00000000..aaaef409 --- /dev/null +++ b/target/surefire-reports/com.allo.test.unit.SpreadUtilTest.txt @@ -0,0 +1,4 @@ +------------------------------------------------------------------------------- +Test set: com.allo.test.unit.SpreadUtilTest +------------------------------------------------------------------------------- +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.425 s -- in com.allo.test.unit.SpreadUtilTest diff --git a/target/test-classes/com/allo/finance/SpreadTest.class b/target/test-classes/com/allo/finance/SpreadTest.class new file mode 100644 index 00000000..d6fc0f4d Binary files /dev/null and b/target/test-classes/com/allo/finance/SpreadTest.class differ diff --git a/target/test-classes/com/allo/test/unit/SpreadUtilTest.class b/target/test-classes/com/allo/test/unit/SpreadUtilTest.class new file mode 100644 index 00000000..03589b3e Binary files /dev/null and b/target/test-classes/com/allo/test/unit/SpreadUtilTest.class differ diff --git a/target/test-classes/com/allo/test/util/SpreadUtil.class b/target/test-classes/com/allo/test/util/SpreadUtil.class new file mode 100644 index 00000000..2b0785f9 Binary files /dev/null and b/target/test-classes/com/allo/test/util/SpreadUtil.class differ