From f2e5a875ed80ea1df6cc7db7d22f0a52000600a1 Mon Sep 17 00:00:00 2001 From: jonheri Date: Mon, 13 Apr 2026 15:15:41 +0700 Subject: [PATCH 1/6] feat: implement IDR finance data aggregator with strategy preload and immutable store - add Strategy-based fetchers for latest rates, historical USD/IDR, and supported currencies - preload external finance data once at startup via ApplicationRunner - store data in thread-safe immutable in-memory store - expose /api/finance/data/{resourceType} endpoint with global exception handling - add RestTemplate FactoryBean config, OpenAPI setup, unit tests, and Postman collection --- .gitignore | 6 + .vscode/settings.json | 4 + pom.xml | 84 +++++++++++++ .../allo-backend-test.postman_collection.json | 106 ++++++++++++++++ .../test/AlloBankTestApplication.java | 14 +++ .../java/com/allobank/test/client/.gitkeep | 0 .../test/client/FrankfurterClient.java | 118 ++++++++++++++++++ .../java/com/allobank/test/config/.gitkeep | 0 .../test/config/FrankfurterApiProperties.java | 53 ++++++++ .../allobank/test/config/OpenApiConfig.java | 22 ++++ .../test/config/RestTemplateFactoryBean.java | 34 +++++ .../com/allobank/test/controller/.gitkeep | 0 .../controller/FinanceDataController.java | 42 +++++++ .../test/controller/HomeController.java | 23 ++++ src/main/java/com/allobank/test/dto/.gitkeep | 0 .../java/com/allobank/test/exception/.gitkeep | 0 .../DataNotInitializedException.java | 8 ++ .../exception/GlobalExceptionHandler.java | 44 +++++++ .../ResourceTypeNotSupportedException.java | 8 ++ .../com/allobank/test/repository/.gitkeep | 0 .../java/com/allobank/test/runner/.gitkeep | 0 .../test/runner/FinanceDataPreloadRunner.java | 61 +++++++++ .../java/com/allobank/test/service/.gitkeep | 0 .../test/service/FinanceDataService.java | 24 ++++ .../java/com/allobank/test/store/.gitkeep | 0 .../allobank/test/store/FinanceDataStore.java | 41 ++++++ .../java/com/allobank/test/strategy/.gitkeep | 0 .../strategy/HistoricalIdrUsdFetcher.java | 24 ++++ .../test/strategy/IDRDataFetcher.java | 8 ++ .../test/strategy/IDRDataFetcherRegistry.java | 23 ++++ .../test/strategy/LatestIdrRatesFetcher.java | 24 ++++ .../strategy/SupportedCurrenciesFetcher.java | 24 ++++ src/main/resources/application.yml | 23 ++++ .../test/AlloBankTestApplicationTests.java | 12 ++ .../com/allobank/test/integration/.gitkeep | 0 .../java/com/allobank/test/service/.gitkeep | 0 .../test/service/FinanceDataServiceTest.java | 44 +++++++ .../test/store/FinanceDataStoreTest.java | 38 ++++++ .../java/com/allobank/test/strategy/.gitkeep | 0 39 files changed, 912 insertions(+) create mode 100644 .gitignore create mode 100644 .vscode/settings.json create mode 100644 pom.xml create mode 100644 postman/allo-backend-test.postman_collection.json create mode 100644 src/main/java/com/allobank/test/AlloBankTestApplication.java create mode 100644 src/main/java/com/allobank/test/client/.gitkeep create mode 100644 src/main/java/com/allobank/test/client/FrankfurterClient.java create mode 100644 src/main/java/com/allobank/test/config/.gitkeep create mode 100644 src/main/java/com/allobank/test/config/FrankfurterApiProperties.java create mode 100644 src/main/java/com/allobank/test/config/OpenApiConfig.java create mode 100644 src/main/java/com/allobank/test/config/RestTemplateFactoryBean.java create mode 100644 src/main/java/com/allobank/test/controller/.gitkeep create mode 100644 src/main/java/com/allobank/test/controller/FinanceDataController.java create mode 100644 src/main/java/com/allobank/test/controller/HomeController.java create mode 100644 src/main/java/com/allobank/test/dto/.gitkeep create mode 100644 src/main/java/com/allobank/test/exception/.gitkeep create mode 100644 src/main/java/com/allobank/test/exception/DataNotInitializedException.java create mode 100644 src/main/java/com/allobank/test/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/allobank/test/exception/ResourceTypeNotSupportedException.java create mode 100644 src/main/java/com/allobank/test/repository/.gitkeep create mode 100644 src/main/java/com/allobank/test/runner/.gitkeep create mode 100644 src/main/java/com/allobank/test/runner/FinanceDataPreloadRunner.java create mode 100644 src/main/java/com/allobank/test/service/.gitkeep create mode 100644 src/main/java/com/allobank/test/service/FinanceDataService.java create mode 100644 src/main/java/com/allobank/test/store/.gitkeep create mode 100644 src/main/java/com/allobank/test/store/FinanceDataStore.java create mode 100644 src/main/java/com/allobank/test/strategy/.gitkeep create mode 100644 src/main/java/com/allobank/test/strategy/HistoricalIdrUsdFetcher.java create mode 100644 src/main/java/com/allobank/test/strategy/IDRDataFetcher.java create mode 100644 src/main/java/com/allobank/test/strategy/IDRDataFetcherRegistry.java create mode 100644 src/main/java/com/allobank/test/strategy/LatestIdrRatesFetcher.java create mode 100644 src/main/java/com/allobank/test/strategy/SupportedCurrenciesFetcher.java create mode 100644 src/main/resources/application.yml create mode 100644 src/test/java/com/allobank/test/AlloBankTestApplicationTests.java create mode 100644 src/test/java/com/allobank/test/integration/.gitkeep create mode 100644 src/test/java/com/allobank/test/service/.gitkeep create mode 100644 src/test/java/com/allobank/test/service/FinanceDataServiceTest.java create mode 100644 src/test/java/com/allobank/test/store/FinanceDataStoreTest.java create mode 100644 src/test/java/com/allobank/test/strategy/.gitkeep 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/pom.xml b/pom.xml new file mode 100644 index 00000000..8a5e99ad --- /dev/null +++ b/pom.xml @@ -0,0 +1,84 @@ + + + 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 + + + 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/test/AlloBankTestApplication.java b/src/main/java/com/allobank/test/AlloBankTestApplication.java new file mode 100644 index 00000000..9530cbbc --- /dev/null +++ b/src/main/java/com/allobank/test/AlloBankTestApplication.java @@ -0,0 +1,14 @@ +package com.allobank.test; + +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/test/client/.gitkeep b/src/main/java/com/allobank/test/client/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/allobank/test/client/FrankfurterClient.java b/src/main/java/com/allobank/test/client/FrankfurterClient.java new file mode 100644 index 00000000..b512e3c0 --- /dev/null +++ b/src/main/java/com/allobank/test/client/FrankfurterClient.java @@ -0,0 +1,118 @@ +package com.allobank.test.client; + +import com.allobank.test.config.FrankfurterApiProperties; +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 java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Component +public class FrankfurterClient { + + private static final MathContext MATH_CONTEXT = new MathContext(12, RoundingMode.HALF_UP); + private static final BigDecimal ONE = BigDecimal.ONE; + + private final RestTemplate restTemplate; + private final FrankfurterApiProperties properties; + + public FrankfurterClient(RestTemplate restTemplate, FrankfurterApiProperties properties) { + this.restTemplate = restTemplate; + this.properties = properties; + } + + public Map fetchLatestIdrRates() { + String url = properties.getBaseUrl() + "/latest?from=EUR&to=IDR,USD"; + Map response = getMap(url); + + @SuppressWarnings("unchecked") + Map rates = (Map) response.getOrDefault("rates", Map.of()); + + BigDecimal rateUsd = toBigDecimal(rates.get("USD")); + BigDecimal usdBuySpreadIdr = ONE.divide(rateUsd, MATH_CONTEXT) + .multiply(ONE.add(calculateSpreadFactor(properties.getGithubUsername())), MATH_CONTEXT); + + Map enrichedRates = new LinkedHashMap<>(rates); + enrichedRates.put("USD_BuySpread_IDR", usdBuySpreadIdr.setScale(8, RoundingMode.HALF_UP)); + + Map result = new LinkedHashMap<>(); + result.put("base", response.get("base")); + result.put("EUR", 1); + result.put("date", response.get("date")); + result.put("rates", enrichedRates); + return result; + } + + public Map fetchHistoricalIdrUsd() { + String url = properties.getBaseUrl() + "/" + properties.getHistoricalRange() + "?from=USD&to=IDR"; + Map response = getMap(url); + + @SuppressWarnings("unchecked") + Map> ratesByDate = (Map>) response.getOrDefault("rates", + Map.of()); + + List> history = ratesByDate.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(entry -> { + BigDecimal idrPerUsd = toBigDecimal(entry.getValue().get("IDR")); + return Map.of( + "date", entry.getKey(), + "idr_per_usd", idrPerUsd); + }) + .toList(); + + Map result = new LinkedHashMap<>(); + result.put("base", "USD"); + result.put("USD", 1); + result.put("quote", "IDR"); + result.put("rates", history); + return result; + } + + public Map fetchSupportedCurrencies() { + String url = properties.getBaseUrl() + "/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(); + } + + private static BigDecimal calculateSpreadFactor(String githubUsername) { + int asciiSum = githubUsername == null + ? 0 + : githubUsername.chars().sum(); + int modValue = asciiSum % 1000; + return BigDecimal.valueOf(modValue) + .divide(BigDecimal.valueOf(100000L), MATH_CONTEXT); + } + + private static BigDecimal toBigDecimal(Object value) { + if (value instanceof Number number) { + return BigDecimal.valueOf(number.doubleValue()); + } + 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/test/config/.gitkeep b/src/main/java/com/allobank/test/config/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/allobank/test/config/FrankfurterApiProperties.java b/src/main/java/com/allobank/test/config/FrankfurterApiProperties.java new file mode 100644 index 00000000..7e0f931d --- /dev/null +++ b/src/main/java/com/allobank/test/config/FrankfurterApiProperties.java @@ -0,0 +1,53 @@ +package com.allobank.test.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/test/config/OpenApiConfig.java b/src/main/java/com/allobank/test/config/OpenApiConfig.java new file mode 100644 index 00000000..cd303b99 --- /dev/null +++ b/src/main/java/com/allobank/test/config/OpenApiConfig.java @@ -0,0 +1,22 @@ +package com.allobank.test.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/test/config/RestTemplateFactoryBean.java b/src/main/java/com/allobank/test/config/RestTemplateFactoryBean.java new file mode 100644 index 00000000..75c1f41e --- /dev/null +++ b/src/main/java/com/allobank/test/config/RestTemplateFactoryBean.java @@ -0,0 +1,34 @@ +package com.allobank.test.config; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +@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()); + return new RestTemplate(requestFactory); + } + + @Override + public Class getObjectType() { + return RestTemplate.class; + } + + @Override + public boolean isSingleton() { + return true; + } +} diff --git a/src/main/java/com/allobank/test/controller/.gitkeep b/src/main/java/com/allobank/test/controller/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/allobank/test/controller/FinanceDataController.java b/src/main/java/com/allobank/test/controller/FinanceDataController.java new file mode 100644 index 00000000..6c7c511c --- /dev/null +++ b/src/main/java/com/allobank/test/controller/FinanceDataController.java @@ -0,0 +1,42 @@ +package com.allobank.test.controller; + +import com.allobank.test.service.FinanceDataService; +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 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. Use other values to test invalid response (400)." + ) + @ApiResponse(responseCode = "200", description = "Resource found") + @ApiResponse(responseCode = "400", description = "Unsupported resource type") + public Map getFinanceData( + @Parameter(description = "Type of finance resource. Examples: latest_idr_rates, historical_idr_usd, supported_currencies, invalid_type") + @PathVariable String resourceType + ) { + return Map.of( + "resourceType", resourceType, + "data", financeDataService.findByResourceType(resourceType) + ); + } +} diff --git a/src/main/java/com/allobank/test/controller/HomeController.java b/src/main/java/com/allobank/test/controller/HomeController.java new file mode 100644 index 00000000..94276fca --- /dev/null +++ b/src/main/java/com/allobank/test/controller/HomeController.java @@ -0,0 +1,23 @@ +package com.allobank.test.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/test/dto/.gitkeep b/src/main/java/com/allobank/test/dto/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/allobank/test/exception/.gitkeep b/src/main/java/com/allobank/test/exception/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/allobank/test/exception/DataNotInitializedException.java b/src/main/java/com/allobank/test/exception/DataNotInitializedException.java new file mode 100644 index 00000000..5f7abe69 --- /dev/null +++ b/src/main/java/com/allobank/test/exception/DataNotInitializedException.java @@ -0,0 +1,8 @@ +package com.allobank.test.exception; + +public class DataNotInitializedException extends RuntimeException { + + public DataNotInitializedException(String message) { + super(message); + } +} diff --git a/src/main/java/com/allobank/test/exception/GlobalExceptionHandler.java b/src/main/java/com/allobank/test/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..d0512c40 --- /dev/null +++ b/src/main/java/com/allobank/test/exception/GlobalExceptionHandler.java @@ -0,0 +1,44 @@ +package com.allobank.test.exception; + +import com.allobank.test.service.FinanceDataService; +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 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/test/exception/ResourceTypeNotSupportedException.java b/src/main/java/com/allobank/test/exception/ResourceTypeNotSupportedException.java new file mode 100644 index 00000000..1fcca1c2 --- /dev/null +++ b/src/main/java/com/allobank/test/exception/ResourceTypeNotSupportedException.java @@ -0,0 +1,8 @@ +package com.allobank.test.exception; + +public class ResourceTypeNotSupportedException extends RuntimeException { + + public ResourceTypeNotSupportedException(String message) { + super(message); + } +} diff --git a/src/main/java/com/allobank/test/repository/.gitkeep b/src/main/java/com/allobank/test/repository/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/allobank/test/runner/.gitkeep b/src/main/java/com/allobank/test/runner/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/allobank/test/runner/FinanceDataPreloadRunner.java b/src/main/java/com/allobank/test/runner/FinanceDataPreloadRunner.java new file mode 100644 index 00000000..59892ea2 --- /dev/null +++ b/src/main/java/com/allobank/test/runner/FinanceDataPreloadRunner.java @@ -0,0 +1,61 @@ +package com.allobank.test.runner; + +import com.allobank.test.store.FinanceDataStore; +import com.allobank.test.strategy.IDRDataFetcher; +import com.allobank.test.strategy.IDRDataFetcherRegistry; +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 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, Map.of( + "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/test/service/.gitkeep b/src/main/java/com/allobank/test/service/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/allobank/test/service/FinanceDataService.java b/src/main/java/com/allobank/test/service/FinanceDataService.java new file mode 100644 index 00000000..dd593680 --- /dev/null +++ b/src/main/java/com/allobank/test/service/FinanceDataService.java @@ -0,0 +1,24 @@ +package com.allobank.test.service; + +import com.allobank.test.store.FinanceDataStore; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class FinanceDataService { + + private final FinanceDataStore financeDataStore; + + public FinanceDataService(FinanceDataStore financeDataStore) { + this.financeDataStore = financeDataStore; + } + + public Object findByResourceType(String resourceType) { + return financeDataStore.getByResourceType(resourceType); + } + + public List supportedResourceTypes() { + return financeDataStore.supportedResourceTypes(); + } +} diff --git a/src/main/java/com/allobank/test/store/.gitkeep b/src/main/java/com/allobank/test/store/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/allobank/test/store/FinanceDataStore.java b/src/main/java/com/allobank/test/store/FinanceDataStore.java new file mode 100644 index 00000000..59467890 --- /dev/null +++ b/src/main/java/com/allobank/test/store/FinanceDataStore.java @@ -0,0 +1,41 @@ +package com.allobank.test.store; + +import com.allobank.test.exception.DataNotInitializedException; +import com.allobank.test.exception.ResourceTypeNotSupportedException; +import org.springframework.stereotype.Component; + +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 = Map.copyOf(initialData); + dataRef.compareAndSet(null, immutableData); + } + + public Object 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 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()); + } +} diff --git a/src/main/java/com/allobank/test/strategy/.gitkeep b/src/main/java/com/allobank/test/strategy/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/com/allobank/test/strategy/HistoricalIdrUsdFetcher.java b/src/main/java/com/allobank/test/strategy/HistoricalIdrUsdFetcher.java new file mode 100644 index 00000000..e383e627 --- /dev/null +++ b/src/main/java/com/allobank/test/strategy/HistoricalIdrUsdFetcher.java @@ -0,0 +1,24 @@ +package com.allobank.test.strategy; + +import com.allobank.test.client.FrankfurterClient; +import org.springframework.stereotype.Component; + +@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 Object fetch() { + return frankfurterClient.fetchHistoricalIdrUsd(); + } +} diff --git a/src/main/java/com/allobank/test/strategy/IDRDataFetcher.java b/src/main/java/com/allobank/test/strategy/IDRDataFetcher.java new file mode 100644 index 00000000..c41a43d7 --- /dev/null +++ b/src/main/java/com/allobank/test/strategy/IDRDataFetcher.java @@ -0,0 +1,8 @@ +package com.allobank.test.strategy; + +public interface IDRDataFetcher { + + String resourceType(); + + Object fetch(); +} diff --git a/src/main/java/com/allobank/test/strategy/IDRDataFetcherRegistry.java b/src/main/java/com/allobank/test/strategy/IDRDataFetcherRegistry.java new file mode 100644 index 00000000..d724729a --- /dev/null +++ b/src/main/java/com/allobank/test/strategy/IDRDataFetcherRegistry.java @@ -0,0 +1,23 @@ +package com.allobank.test.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/test/strategy/LatestIdrRatesFetcher.java b/src/main/java/com/allobank/test/strategy/LatestIdrRatesFetcher.java new file mode 100644 index 00000000..6a45492a --- /dev/null +++ b/src/main/java/com/allobank/test/strategy/LatestIdrRatesFetcher.java @@ -0,0 +1,24 @@ +package com.allobank.test.strategy; + +import com.allobank.test.client.FrankfurterClient; +import org.springframework.stereotype.Component; + +@Component +public class LatestIdrRatesFetcher implements IDRDataFetcher { + + private final FrankfurterClient frankfurterClient; + + public LatestIdrRatesFetcher(FrankfurterClient frankfurterClient) { + this.frankfurterClient = frankfurterClient; + } + + @Override + public String resourceType() { + return "latest_idr_rates"; + } + + @Override + public Object fetch() { + return frankfurterClient.fetchLatestIdrRates(); + } +} diff --git a/src/main/java/com/allobank/test/strategy/SupportedCurrenciesFetcher.java b/src/main/java/com/allobank/test/strategy/SupportedCurrenciesFetcher.java new file mode 100644 index 00000000..64aef328 --- /dev/null +++ b/src/main/java/com/allobank/test/strategy/SupportedCurrenciesFetcher.java @@ -0,0 +1,24 @@ +package com.allobank.test.strategy; + +import com.allobank.test.client.FrankfurterClient; +import org.springframework.stereotype.Component; + +@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 Object fetch() { + return frankfurterClient.fetchSupportedCurrencies(); + } +} 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..6bb0c277 --- /dev/null +++ b/src/test/java/com/allobank/test/AlloBankTestApplicationTests.java @@ -0,0 +1,12 @@ +package com.allobank.test; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest(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/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..a37402b1 --- /dev/null +++ b/src/test/java/com/allobank/test/service/FinanceDataServiceTest.java @@ -0,0 +1,44 @@ +package com.allobank.test.service; + +import com.allobank.test.store.FinanceDataStore; +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 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 FinanceDataServiceTest { + + @Mock + private FinanceDataStore financeDataStore; + + @InjectMocks + private FinanceDataService financeDataService; + + @Test + void findByResourceTypeShouldDelegateToStore() { + Map expected = Map.of("base", "EUR"); + when(financeDataStore.getByResourceType("latest_idr_rates")).thenReturn(expected); + + Object actual = financeDataService.findByResourceType("latest_idr_rates"); + + assertEquals(expected, actual); + } + + @Test + void supportedResourceTypesShouldDelegateToStore() { + List expected = List.of("latest_idr_rates", "historical_idr_usd", "supported_currencies"); + when(financeDataStore.supportedResourceTypes()).thenReturn(expected); + + List actual = financeDataService.supportedResourceTypes(); + + assertEquals(expected, actual); + } +} 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..de5d0a78 --- /dev/null +++ b/src/test/java/com/allobank/test/store/FinanceDataStoreTest.java @@ -0,0 +1,38 @@ +package com.allobank.test.store; + +import com.allobank.test.exception.DataNotInitializedException; +import com.allobank.test.exception.ResourceTypeNotSupportedException; +import org.junit.jupiter.api.Test; + +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", "first")); + store.initializeOnce(Map.of("latest_idr_rates", "second")); + + assertEquals("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", Map.of("ok", true))); + + assertThrows(ResourceTypeNotSupportedException.class, () -> store.getByResourceType("not_found")); + } +} 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 From a21bd55ee6ec2b9ef9c719c8fa68976bb62ddb01 Mon Sep 17 00:00:00 2001 From: jonheri Date: Mon, 13 Apr 2026 16:23:18 +0700 Subject: [PATCH 2/6] refactor: simplify IDR data fetch flow and harden preload/store handling - refine strategy fetchers and service/controller data path - improve preload runner and immutable in-memory store behavior - update README and add/adjust unit + integration tests for runner/strategies --- README.md | 181 ++++++++---------- .../test/client/FrankfurterClient.java | 83 +------- .../controller/FinanceDataController.java | 10 +- .../test/runner/FinanceDataPreloadRunner.java | 5 +- .../test/service/FinanceDataService.java | 22 ++- .../allobank/test/store/FinanceDataStore.java | 37 +++- .../strategy/HistoricalIdrUsdFetcher.java | 42 +++- .../test/strategy/IDRDataFetcher.java | 5 +- .../test/strategy/LatestIdrRatesFetcher.java | 69 ++++++- .../strategy/SupportedCurrenciesFetcher.java | 18 +- ...nanceDataPreloadRunnerIntegrationTest.java | 71 +++++++ .../test/service/FinanceDataServiceTest.java | 45 ++++- .../test/store/FinanceDataStoreTest.java | 19 +- .../strategy/HistoricalIdrUsdFetcherTest.java | 49 +++++ .../strategy/LatestIdrRatesFetcherTest.java | 93 +++++++++ .../SupportedCurrenciesFetcherTest.java | 45 +++++ 16 files changed, 584 insertions(+), 210 deletions(-) create mode 100644 src/test/java/com/allobank/test/runner/FinanceDataPreloadRunnerIntegrationTest.java create mode 100644 src/test/java/com/allobank/test/strategy/HistoricalIdrUsdFetcherTest.java create mode 100644 src/test/java/com/allobank/test/strategy/LatestIdrRatesFetcherTest.java create mode 100644 src/test/java/com/allobank/test/strategy/SupportedCurrenciesFetcherTest.java diff --git a/README.md b/README.md index 5e58ae2a..01a2635b 100644 --- a/README.md +++ b/README.md @@ -1,139 +1,112 @@ # 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. Run application: -* You must integrate with three distinct data resources to enforce the architectural pattern: +```bash +mvn spring-boot:run +``` - 1. `/latest?base=IDR` (The latest rates relative to IDR) +3. Run tests: - 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 test +``` - 3. `/currencies` (The list of all supported currency symbols) +Application default port: `8080` -### 2. Internal API Endpoint +## API Endpoint -You must expose **one single endpoint** in your application: ```GET /api/finance/data/{resourceType}``` +Single endpoint: -Where `{resourceType}` can be one of the three strings: `latest_idr_rates`, `historical_idr_usd`, or `supported_currencies`. +`GET /api/finance/data/{resourceType}` -### 3. Required Functionality & Business Logic +Supported `resourceType` values: -* **Resource Handling:** Your service must correctly map the three incoming `resourceType` values to the correct data fetching strategies. +- `latest_idr_rates` +- `historical_idr_usd` +- `supported_currencies` -* **Data Load:** All three resources should be fetched from the external API. +Example cURL: -* **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. +```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 +``` - **The Spread Factor Must Be Unique :** +If unsupported `resourceType` is used, API returns `400 Bad Request`. - 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.)* +## Personalization Note - **Final Formula:** `USD_BuySpread_IDR = (1 / Rate_USD) * (1 + Spread Factor)` (where `Rate_USD` is the value from the API when `base=IDR`). +- 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` -* **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. +For `latest_idr_rates`, custom field is calculated as: -## II. Architectural Constraints +`USD_BuySpread_IDR = (1 / Rate_USD) * (1 + Spread_Factor)` -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: +## Architecture Summary -### Constraint A: The Strategy Pattern +### Strategy Pattern -The logic for handling the three different resources (`latest_idr_rates`, `historical_idr_usd`, `supported_currencies`) must be implemented using the **Strategy Design Pattern**. +- 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. -1. Define a clear **Strategy Interface** (e.g., `IDRDataFetcher`). +### Client Factory Bean -2. Implement **three concrete strategy classes** (one for each resource). +- External API client (`RestTemplate`) is created via custom `FactoryBean`: + - `RestTemplateFactoryBean` +- Base URL and client settings are externalized in `application.yml` via `FrankfurterApiProperties`. -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. +### Startup Runner and Immutability -### Constraint B: Client Factory Bean +- 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. -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**. +## Error Handling -* 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). +- 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` -* ***You may not define the client as a simple `@Bean` in a `@Configuration` class.*** +## Test Coverage -### Constraint C: Startup Data Runner & Immutability - -The aggregated data for **ALL three resources** must be fetched **exactly once on application startup** and loaded into an in-memory store. - -1. Use a Spring Boot **`ApplicationRunner`** or **`CommandLineRunner`** component to initiate the data fetching process. - -2. The API endpoint (`GET /api/finance/data/{resourceType}`) must serve the data from this **in-memory store**, not by making a new call to the external API on every request. - -3. The in-memory storage mechanism (e.g., a service holding the data) must be designed to be **thread-safe** and ensure the data is **immutable** once the `ApplicationRunner` has finished loading it. - -## III. Production Readiness & Deliverables - -Your final solution must demonstrate production quality through code, testing, and communication. - -### 1. Robustness & Best Practices - -* Graceful **Error Handling** for network failures or 4xx/5xx responses from the external API. - -* Proper use of **Configuration Properties** (e.g., `application.yml`) for external service URLs. - -* Clear separation of concerns (Controller, Service, Model/DTO, etc.). - -### 2. Testing - -* **Unit Tests** for all three `IDRDataFetcher` strategy implementations, ensuring data calculation and transformation logic is covered (using mock clients for external calls). - -* **Integration Tests** to verify the `ApplicationRunner` successfully initializes and loads the data into the in-memory store before the application context is ready. - -### 3. Documentation - -A clear `README.md` is mandatory. It must include: - -* **Setup/Run Instructions:** Clear steps to clone, build, and run the application and tests. - -* **Endpoint Usage:** Example cURL commands to test the three different resource types. - -* **Personalization Note:** Clearly state your GitHub username and show the exact **Spread Factor** (e.g., `0.00765`) calculated by your function. - -* --- - -* ### 🛠️ Architectural Rationale - - This section should contain a brief, but detailed, explanation answering the following questions: - - 1. **Polymorphism Justification:** Explain *why* the Strategy Pattern was used over a simpler conditional block in the service layer for handling the multi-resource endpoint. Discuss the benefits in terms of **extensibility** and **maintainability**. - - 2. **Client Factory:** Explain the specific role and benefit of using a **`FactoryBean`** to construct the external API client. Why is this preferable to defining the client using a standard `@Bean` method in this scenario? - - 3. **Startup Runner Choice:** Justify the choice of using an `ApplicationRunner` (or `CommandLineRunner`) for the initial data ingestion over a simpler `@PostConstruct` method. - -## IV. Submission & Review Process - -1. **Fork** this repository. - -2. Implement your solution on a dedicated feature branch (e.g., `feat/idr-rate-aggregator`). - -3. When complete, submit your solution via a **Pull Request (PR)** back to the main repository. -4. Please complete the form to submit your technical test: [Click Here](https://forms.gle/nZKQ2EjTCPfAKHog7) - -**Your PR will be evaluated on the following:** - -* **Commit History:** Clean, atomic, and descriptive commit messages (e.g., "feat: Implement IDR latest rates strategy," "fix: Correctly calculate IDR spread in tests"). - -* **PR Description:** The description must clearly summarize the solution and **must contain the full answers** to the three "Architectural Rationale" questions from Section III. - -* **Code Review Readiness:** The code should be well-structured and ready for immediate review. - -Good luck! +- Unit tests for each strategy: + - `LatestIdrRatesFetcherTest` + - `HistoricalIdrUsdFetcherTest` + - `SupportedCurrenciesFetcherTest` +- Service and store tests: + - `FinanceDataServiceTest` + - `FinanceDataStoreTest` +- Integration test for startup preloading: + - `FinanceDataPreloadRunnerIntegrationTest` diff --git a/src/main/java/com/allobank/test/client/FrankfurterClient.java b/src/main/java/com/allobank/test/client/FrankfurterClient.java index b512e3c0..4bc2164e 100644 --- a/src/main/java/com/allobank/test/client/FrankfurterClient.java +++ b/src/main/java/com/allobank/test/client/FrankfurterClient.java @@ -7,19 +7,11 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; -import java.math.BigDecimal; -import java.math.MathContext; -import java.math.RoundingMode; -import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; @Component public class FrankfurterClient { - private static final MathContext MATH_CONTEXT = new MathContext(12, RoundingMode.HALF_UP); - private static final BigDecimal ONE = BigDecimal.ONE; - private final RestTemplate restTemplate; private final FrankfurterApiProperties properties; @@ -28,62 +20,24 @@ public FrankfurterClient(RestTemplate restTemplate, FrankfurterApiProperties pro this.properties = properties; } - public Map fetchLatestIdrRates() { - String url = properties.getBaseUrl() + "/latest?from=EUR&to=IDR,USD"; - Map response = getMap(url); - - @SuppressWarnings("unchecked") - Map rates = (Map) response.getOrDefault("rates", Map.of()); - - BigDecimal rateUsd = toBigDecimal(rates.get("USD")); - BigDecimal usdBuySpreadIdr = ONE.divide(rateUsd, MATH_CONTEXT) - .multiply(ONE.add(calculateSpreadFactor(properties.getGithubUsername())), MATH_CONTEXT); - - Map enrichedRates = new LinkedHashMap<>(rates); - enrichedRates.put("USD_BuySpread_IDR", usdBuySpreadIdr.setScale(8, RoundingMode.HALF_UP)); - - Map result = new LinkedHashMap<>(); - result.put("base", response.get("base")); - result.put("EUR", 1); - result.put("date", response.get("date")); - result.put("rates", enrichedRates); - return result; + public Map fetchLatestIdrRatesRaw() { + String url = properties.getBaseUrl() + "/latest?base=IDR"; + return getMap(url); } - public Map fetchHistoricalIdrUsd() { - String url = properties.getBaseUrl() + "/" + properties.getHistoricalRange() + "?from=USD&to=IDR"; - Map response = getMap(url); - - @SuppressWarnings("unchecked") - Map> ratesByDate = (Map>) response.getOrDefault("rates", - Map.of()); - - List> history = ratesByDate.entrySet().stream() - .sorted(Map.Entry.comparingByKey()) - .map(entry -> { - BigDecimal idrPerUsd = toBigDecimal(entry.getValue().get("IDR")); - return Map.of( - "date", entry.getKey(), - "idr_per_usd", idrPerUsd); - }) - .toList(); - - Map result = new LinkedHashMap<>(); - result.put("base", "USD"); - result.put("USD", 1); - result.put("quote", "IDR"); - result.put("rates", history); - return result; + public Map fetchHistoricalIdrUsdRaw() { + String url = properties.getBaseUrl() + "/" + properties.getHistoricalRange() + "?from=IDR&to=USD"; + return getMap(url); } - public Map fetchSupportedCurrencies() { + public Map fetchSupportedCurrenciesRaw() { String url = properties.getBaseUrl() + "/currencies"; ResponseEntity> response = restTemplate.exchange( url, HttpMethod.GET, null, new ParameterizedTypeReference<>() { - }); + }); return response.getBody() == null ? Map.of() : response.getBody(); } @@ -93,26 +47,7 @@ private Map getMap(String url) { HttpMethod.GET, null, new ParameterizedTypeReference<>() { - }); + }); return response.getBody() == null ? Map.of() : response.getBody(); } - - private static BigDecimal calculateSpreadFactor(String githubUsername) { - int asciiSum = githubUsername == null - ? 0 - : githubUsername.chars().sum(); - int modValue = asciiSum % 1000; - return BigDecimal.valueOf(modValue) - .divide(BigDecimal.valueOf(100000L), MATH_CONTEXT); - } - - private static BigDecimal toBigDecimal(Object value) { - if (value instanceof Number number) { - return BigDecimal.valueOf(number.doubleValue()); - } - 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/test/controller/FinanceDataController.java b/src/main/java/com/allobank/test/controller/FinanceDataController.java index 6c7c511c..c11437bf 100644 --- a/src/main/java/com/allobank/test/controller/FinanceDataController.java +++ b/src/main/java/com/allobank/test/controller/FinanceDataController.java @@ -10,6 +10,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import java.util.List; import java.util.Map; @RestController @@ -26,17 +27,14 @@ public FinanceDataController(FinanceDataService financeDataService) { @GetMapping("/{resourceType}") @Operation( summary = "Get finance data by resource type", - description = "Supported resourceType: latest_idr_rates, historical_idr_usd, supported_currencies. Use other values to test invalid response (400)." + 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 Map getFinanceData( + public List> getFinanceData( @Parameter(description = "Type of finance resource. Examples: latest_idr_rates, historical_idr_usd, supported_currencies, invalid_type") @PathVariable String resourceType ) { - return Map.of( - "resourceType", resourceType, - "data", financeDataService.findByResourceType(resourceType) - ); + return financeDataService.findByResourceType(resourceType); } } diff --git a/src/main/java/com/allobank/test/runner/FinanceDataPreloadRunner.java b/src/main/java/com/allobank/test/runner/FinanceDataPreloadRunner.java index 59892ea2..093644c8 100644 --- a/src/main/java/com/allobank/test/runner/FinanceDataPreloadRunner.java +++ b/src/main/java/com/allobank/test/runner/FinanceDataPreloadRunner.java @@ -48,12 +48,13 @@ public void run(ApplicationArguments args) { } 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, Map.of( + 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/test/service/FinanceDataService.java b/src/main/java/com/allobank/test/service/FinanceDataService.java index dd593680..cc08da9e 100644 --- a/src/main/java/com/allobank/test/service/FinanceDataService.java +++ b/src/main/java/com/allobank/test/service/FinanceDataService.java @@ -1,24 +1,38 @@ package com.allobank.test.service; +import com.allobank.test.exception.ResourceTypeNotSupportedException; import com.allobank.test.store.FinanceDataStore; +import com.allobank.test.strategy.IDRDataFetcherRegistry; import org.springframework.stereotype.Service; 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) { + public FinanceDataService(FinanceDataStore financeDataStore, IDRDataFetcherRegistry registry) { this.financeDataStore = financeDataStore; + this.registry = registry; } - public Object findByResourceType(String resourceType) { - return financeDataStore.getByResourceType(resourceType); + 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 financeDataStore.supportedResourceTypes(); + return List.copyOf(registry.asMap().keySet()); } } diff --git a/src/main/java/com/allobank/test/store/FinanceDataStore.java b/src/main/java/com/allobank/test/store/FinanceDataStore.java index 59467890..d2dd7eb8 100644 --- a/src/main/java/com/allobank/test/store/FinanceDataStore.java +++ b/src/main/java/com/allobank/test/store/FinanceDataStore.java @@ -4,6 +4,8 @@ import com.allobank.test.exception.ResourceTypeNotSupportedException; import org.springframework.stereotype.Component; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; @@ -14,11 +16,12 @@ public class FinanceDataStore { private final AtomicReference> dataRef = new AtomicReference<>(); public void initializeOnce(Map initialData) { - Map immutableData = Map.copyOf(initialData); + Map immutableData = deepImmutableMap(initialData); dataRef.compareAndSet(null, immutableData); } - public Object getByResourceType(String resourceType) { + @SuppressWarnings("unchecked") + public List> getByResourceType(String resourceType) { Map currentData = dataRef.get(); if (currentData == null) { throw new DataNotInitializedException("Finance data is not initialized yet."); @@ -28,7 +31,7 @@ public Object getByResourceType(String resourceType) { if (value == null) { throw new ResourceTypeNotSupportedException("Unsupported resourceType: " + resourceType); } - return value; + return (List>) value; } public List supportedResourceTypes() { @@ -38,4 +41,32 @@ public List supportedResourceTypes() { } 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); + } + + @SuppressWarnings("unchecked") + 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/test/strategy/HistoricalIdrUsdFetcher.java b/src/main/java/com/allobank/test/strategy/HistoricalIdrUsdFetcher.java index e383e627..7cd75a20 100644 --- a/src/main/java/com/allobank/test/strategy/HistoricalIdrUsdFetcher.java +++ b/src/main/java/com/allobank/test/strategy/HistoricalIdrUsdFetcher.java @@ -3,6 +3,11 @@ import com.allobank.test.client.FrankfurterClient; import org.springframework.stereotype.Component; +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + @Component public class HistoricalIdrUsdFetcher implements IDRDataFetcher { @@ -18,7 +23,40 @@ public String resourceType() { } @Override - public Object fetch() { - return frankfurterClient.fetchHistoricalIdrUsd(); + 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/test/strategy/IDRDataFetcher.java b/src/main/java/com/allobank/test/strategy/IDRDataFetcher.java index c41a43d7..a075b73e 100644 --- a/src/main/java/com/allobank/test/strategy/IDRDataFetcher.java +++ b/src/main/java/com/allobank/test/strategy/IDRDataFetcher.java @@ -1,8 +1,11 @@ package com.allobank.test.strategy; +import java.util.List; +import java.util.Map; + public interface IDRDataFetcher { String resourceType(); - Object fetch(); + List> fetch(); } diff --git a/src/main/java/com/allobank/test/strategy/LatestIdrRatesFetcher.java b/src/main/java/com/allobank/test/strategy/LatestIdrRatesFetcher.java index 6a45492a..f9729fa6 100644 --- a/src/main/java/com/allobank/test/strategy/LatestIdrRatesFetcher.java +++ b/src/main/java/com/allobank/test/strategy/LatestIdrRatesFetcher.java @@ -1,15 +1,29 @@ package com.allobank.test.strategy; import com.allobank.test.client.FrankfurterClient; +import com.allobank.test.config.FrankfurterApiProperties; import org.springframework.stereotype.Component; +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) { + public LatestIdrRatesFetcher(FrankfurterClient frankfurterClient, FrankfurterApiProperties properties) { this.frankfurterClient = frankfurterClient; + this.properties = properties; } @Override @@ -18,7 +32,56 @@ public String resourceType() { } @Override - public Object fetch() { - return frankfurterClient.fetchLatestIdrRates(); + 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/test/strategy/SupportedCurrenciesFetcher.java b/src/main/java/com/allobank/test/strategy/SupportedCurrenciesFetcher.java index 64aef328..9f926e1e 100644 --- a/src/main/java/com/allobank/test/strategy/SupportedCurrenciesFetcher.java +++ b/src/main/java/com/allobank/test/strategy/SupportedCurrenciesFetcher.java @@ -3,6 +3,10 @@ import com.allobank.test.client.FrankfurterClient; import org.springframework.stereotype.Component; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + @Component public class SupportedCurrenciesFetcher implements IDRDataFetcher { @@ -18,7 +22,17 @@ public String resourceType() { } @Override - public Object fetch() { - return frankfurterClient.fetchSupportedCurrencies(); + 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/test/java/com/allobank/test/runner/FinanceDataPreloadRunnerIntegrationTest.java b/src/test/java/com/allobank/test/runner/FinanceDataPreloadRunnerIntegrationTest.java new file mode 100644 index 00000000..e0ef6cb8 --- /dev/null +++ b/src/test/java/com/allobank/test/runner/FinanceDataPreloadRunnerIntegrationTest.java @@ -0,0 +1,71 @@ +package com.allobank.test.runner; + +import com.allobank.test.client.FrankfurterClient; +import com.allobank.test.store.FinanceDataStore; +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 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" +}) +@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/FinanceDataServiceTest.java b/src/test/java/com/allobank/test/service/FinanceDataServiceTest.java index a37402b1..11c4821a 100644 --- a/src/test/java/com/allobank/test/service/FinanceDataServiceTest.java +++ b/src/test/java/com/allobank/test/service/FinanceDataServiceTest.java @@ -1,16 +1,22 @@ package com.allobank.test.service; +import com.allobank.test.exception.ResourceTypeNotSupportedException; import com.allobank.test.store.FinanceDataStore; +import com.allobank.test.strategy.IDRDataFetcher; +import com.allobank.test.strategy.IDRDataFetcherRegistry; 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 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) @@ -19,26 +25,55 @@ class FinanceDataServiceTest { @Mock private FinanceDataStore financeDataStore; + @Mock + private IDRDataFetcherRegistry registry; + @InjectMocks private FinanceDataService financeDataService; @Test void findByResourceTypeShouldDelegateToStore() { - Map expected = Map.of("base", "EUR"); + 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); - Object actual = financeDataService.findByResourceType("latest_idr_rates"); + List> actual = financeDataService.findByResourceType(" LATEST_IDR_RATES "); assertEquals(expected, actual); } @Test - void supportedResourceTypesShouldDelegateToStore() { + void supportedResourceTypesShouldUseRegistryKeys() { List expected = List.of("latest_idr_rates", "historical_idr_usd", "supported_currencies"); - when(financeDataStore.supportedResourceTypes()).thenReturn(expected); + 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(); - assertEquals(expected, actual); + 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 index de5d0a78..d70d61a8 100644 --- a/src/test/java/com/allobank/test/store/FinanceDataStoreTest.java +++ b/src/test/java/com/allobank/test/store/FinanceDataStoreTest.java @@ -4,6 +4,7 @@ import com.allobank.test.exception.ResourceTypeNotSupportedException; import org.junit.jupiter.api.Test; +import java.util.List; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -15,10 +16,10 @@ class FinanceDataStoreTest { void initializeOnceShouldKeepFirstValue() { FinanceDataStore store = new FinanceDataStore(); - store.initializeOnce(Map.of("latest_idr_rates", "first")); - store.initializeOnce(Map.of("latest_idr_rates", "second")); + 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("first", store.getByResourceType("latest_idr_rates")); + assertEquals(List.of(Map.of("value", "first")), store.getByResourceType("latest_idr_rates")); } @Test @@ -31,8 +32,18 @@ void getByResourceTypeShouldThrowWhenStoreNotInitialized() { @Test void getByResourceTypeShouldThrowForUnsupportedResourceType() { FinanceDataStore store = new FinanceDataStore(); - store.initializeOnce(Map.of("latest_idr_rates", Map.of("ok", true))); + 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/HistoricalIdrUsdFetcherTest.java b/src/test/java/com/allobank/test/strategy/HistoricalIdrUsdFetcherTest.java new file mode 100644 index 00000000..65e8a765 --- /dev/null +++ b/src/test/java/com/allobank/test/strategy/HistoricalIdrUsdFetcherTest.java @@ -0,0 +1,49 @@ +package com.allobank.test.strategy; + +import com.allobank.test.client.FrankfurterClient; +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 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..ffbcae67 --- /dev/null +++ b/src/test/java/com/allobank/test/strategy/LatestIdrRatesFetcherTest.java @@ -0,0 +1,93 @@ +package com.allobank.test.strategy; + +import com.allobank.test.client.FrankfurterClient; +import com.allobank.test.config.FrankfurterApiProperties; +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 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..2e414a1d --- /dev/null +++ b/src/test/java/com/allobank/test/strategy/SupportedCurrenciesFetcherTest.java @@ -0,0 +1,45 @@ +package com.allobank.test.strategy; + +import com.allobank.test.client.FrankfurterClient; +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 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")); + } +} From 020a333424796dcb937562af81529396950016f0 Mon Sep 17 00:00:00 2001 From: jonheri Date: Mon, 13 Apr 2026 16:47:37 +0700 Subject: [PATCH 3/6] chore: fix unchecked on method --- src/main/java/com/allobank/test/store/FinanceDataStore.java | 1 - 1 file changed, 1 deletion(-) diff --git a/src/main/java/com/allobank/test/store/FinanceDataStore.java b/src/main/java/com/allobank/test/store/FinanceDataStore.java index d2dd7eb8..fd40516d 100644 --- a/src/main/java/com/allobank/test/store/FinanceDataStore.java +++ b/src/main/java/com/allobank/test/store/FinanceDataStore.java @@ -50,7 +50,6 @@ private static Map deepImmutableMap(Map source) return Collections.unmodifiableMap(copied); } - @SuppressWarnings("unchecked") private static Object deepImmutableValue(Object value) { if (value instanceof Map mapValue) { Map nested = new LinkedHashMap<>(); From 71beedd473e37dcb481ff51b8fa82fbeaeca5cde Mon Sep 17 00:00:00 2001 From: jonheri Date: Tue, 14 Apr 2026 10:08:59 +0700 Subject: [PATCH 4/6] refactor: rename base package from 'test' to 'finance' for better domain clarity --- .../AlloBankTestApplication.java | 2 +- .../allobank/{test => finance}/client/.gitkeep | 0 .../client/FrankfurterClient.java | 9 +++++---- .../allobank/{test => finance}/config/.gitkeep | 0 .../config/FrankfurterApiProperties.java | 2 +- .../config/OpenApiConfig.java | 14 +++----------- .../config/RestTemplateFactoryBean.java | 2 +- .../{test => finance}/controller/.gitkeep | 0 .../controller/FinanceDataController.java | 14 +++++--------- .../controller/HomeController.java | 2 +- .../allobank/{test => finance}/dto/.gitkeep | 0 .../{test => finance}/exception/.gitkeep | 0 .../exception/DataNotInitializedException.java | 2 +- .../exception/GlobalExceptionHandler.java | 5 +++-- .../ResourceTypeNotSupportedException.java | 2 +- .../{test => finance}/repository/.gitkeep | 0 .../allobank/{test => finance}/runner/.gitkeep | 0 .../runner/FinanceDataPreloadRunner.java | 18 +++++++++--------- .../{test => finance}/service/.gitkeep | 0 .../service/FinanceDataService.java | 9 +++++---- .../allobank/{test => finance}/store/.gitkeep | 0 .../store/FinanceDataStore.java | 7 ++++--- .../{test => finance}/strategy/.gitkeep | 0 .../strategy/HistoricalIdrUsdFetcher.java | 8 +++++--- .../strategy/IDRDataFetcher.java | 2 +- .../strategy/IDRDataFetcherRegistry.java | 2 +- .../strategy/LatestIdrRatesFetcher.java | 7 ++++--- .../strategy/SupportedCurrenciesFetcher.java | 5 +++-- ...inanceDataPreloadRunnerIntegrationTest.java | 5 +++-- .../test/service/FinanceDataServiceTest.java | 10 ++++++---- .../test/store/FinanceDataStoreTest.java | 6 ++++-- .../strategy/HistoricalIdrUsdFetcherTest.java | 4 +++- .../strategy/LatestIdrRatesFetcherTest.java | 6 ++++-- .../SupportedCurrenciesFetcherTest.java | 4 +++- 34 files changed, 77 insertions(+), 70 deletions(-) rename src/main/java/com/allobank/{test => finance}/AlloBankTestApplication.java (93%) rename src/main/java/com/allobank/{test => finance}/client/.gitkeep (100%) rename src/main/java/com/allobank/{test => finance}/client/FrankfurterClient.java (92%) rename src/main/java/com/allobank/{test => finance}/config/.gitkeep (100%) rename src/main/java/com/allobank/{test => finance}/config/FrankfurterApiProperties.java (97%) rename src/main/java/com/allobank/{test => finance}/config/OpenApiConfig.java (53%) rename src/main/java/com/allobank/{test => finance}/config/RestTemplateFactoryBean.java (96%) rename src/main/java/com/allobank/{test => finance}/controller/.gitkeep (100%) rename src/main/java/com/allobank/{test => finance}/controller/FinanceDataController.java (77%) rename src/main/java/com/allobank/{test => finance}/controller/HomeController.java (94%) rename src/main/java/com/allobank/{test => finance}/dto/.gitkeep (100%) rename src/main/java/com/allobank/{test => finance}/exception/.gitkeep (100%) rename src/main/java/com/allobank/{test => finance}/exception/DataNotInitializedException.java (79%) rename src/main/java/com/allobank/{test => finance}/exception/GlobalExceptionHandler.java (94%) rename src/main/java/com/allobank/{test => finance}/exception/ResourceTypeNotSupportedException.java (81%) rename src/main/java/com/allobank/{test => finance}/repository/.gitkeep (100%) rename src/main/java/com/allobank/{test => finance}/runner/.gitkeep (100%) rename src/main/java/com/allobank/{test => finance}/runner/FinanceDataPreloadRunner.java (85%) rename src/main/java/com/allobank/{test => finance}/service/.gitkeep (100%) rename src/main/java/com/allobank/{test => finance}/service/FinanceDataService.java (82%) rename src/main/java/com/allobank/{test => finance}/store/.gitkeep (100%) rename src/main/java/com/allobank/{test => finance}/store/FinanceDataStore.java (93%) rename src/main/java/com/allobank/{test => finance}/strategy/.gitkeep (100%) rename src/main/java/com/allobank/{test => finance}/strategy/HistoricalIdrUsdFetcher.java (92%) rename src/main/java/com/allobank/{test => finance}/strategy/IDRDataFetcher.java (79%) rename src/main/java/com/allobank/{test => finance}/strategy/IDRDataFetcherRegistry.java (93%) rename src/main/java/com/allobank/{test => finance}/strategy/LatestIdrRatesFetcher.java (95%) rename src/main/java/com/allobank/{test => finance}/strategy/SupportedCurrenciesFetcher.java (92%) diff --git a/src/main/java/com/allobank/test/AlloBankTestApplication.java b/src/main/java/com/allobank/finance/AlloBankTestApplication.java similarity index 93% rename from src/main/java/com/allobank/test/AlloBankTestApplication.java rename to src/main/java/com/allobank/finance/AlloBankTestApplication.java index 9530cbbc..c2e8de4b 100644 --- a/src/main/java/com/allobank/test/AlloBankTestApplication.java +++ b/src/main/java/com/allobank/finance/AlloBankTestApplication.java @@ -1,4 +1,4 @@ -package com.allobank.test; +package com.allobank.finance; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; diff --git a/src/main/java/com/allobank/test/client/.gitkeep b/src/main/java/com/allobank/finance/client/.gitkeep similarity index 100% rename from src/main/java/com/allobank/test/client/.gitkeep rename to src/main/java/com/allobank/finance/client/.gitkeep diff --git a/src/main/java/com/allobank/test/client/FrankfurterClient.java b/src/main/java/com/allobank/finance/client/FrankfurterClient.java similarity index 92% rename from src/main/java/com/allobank/test/client/FrankfurterClient.java rename to src/main/java/com/allobank/finance/client/FrankfurterClient.java index 4bc2164e..3efa046c 100644 --- a/src/main/java/com/allobank/test/client/FrankfurterClient.java +++ b/src/main/java/com/allobank/finance/client/FrankfurterClient.java @@ -1,12 +1,13 @@ -package com.allobank.test.client; +package com.allobank.finance.client; -import com.allobank.test.config.FrankfurterApiProperties; 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 @@ -37,7 +38,7 @@ public Map fetchSupportedCurrenciesRaw() { HttpMethod.GET, null, new ParameterizedTypeReference<>() { - }); + }); return response.getBody() == null ? Map.of() : response.getBody(); } @@ -47,7 +48,7 @@ private Map getMap(String url) { HttpMethod.GET, null, new ParameterizedTypeReference<>() { - }); + }); return response.getBody() == null ? Map.of() : response.getBody(); } } diff --git a/src/main/java/com/allobank/test/config/.gitkeep b/src/main/java/com/allobank/finance/config/.gitkeep similarity index 100% rename from src/main/java/com/allobank/test/config/.gitkeep rename to src/main/java/com/allobank/finance/config/.gitkeep diff --git a/src/main/java/com/allobank/test/config/FrankfurterApiProperties.java b/src/main/java/com/allobank/finance/config/FrankfurterApiProperties.java similarity index 97% rename from src/main/java/com/allobank/test/config/FrankfurterApiProperties.java rename to src/main/java/com/allobank/finance/config/FrankfurterApiProperties.java index 7e0f931d..a7a53f00 100644 --- a/src/main/java/com/allobank/test/config/FrankfurterApiProperties.java +++ b/src/main/java/com/allobank/finance/config/FrankfurterApiProperties.java @@ -1,4 +1,4 @@ -package com.allobank.test.config; +package com.allobank.finance.config; import org.springframework.boot.context.properties.ConfigurationProperties; diff --git a/src/main/java/com/allobank/test/config/OpenApiConfig.java b/src/main/java/com/allobank/finance/config/OpenApiConfig.java similarity index 53% rename from src/main/java/com/allobank/test/config/OpenApiConfig.java rename to src/main/java/com/allobank/finance/config/OpenApiConfig.java index cd303b99..fe0b839d 100644 --- a/src/main/java/com/allobank/test/config/OpenApiConfig.java +++ b/src/main/java/com/allobank/finance/config/OpenApiConfig.java @@ -1,4 +1,4 @@ -package com.allobank.test.config; +package com.allobank.finance.config; import io.swagger.v3.oas.annotations.OpenAPIDefinition; import io.swagger.v3.oas.annotations.info.Contact; @@ -7,16 +7,8 @@ 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 = { +@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/test/config/RestTemplateFactoryBean.java b/src/main/java/com/allobank/finance/config/RestTemplateFactoryBean.java similarity index 96% rename from src/main/java/com/allobank/test/config/RestTemplateFactoryBean.java rename to src/main/java/com/allobank/finance/config/RestTemplateFactoryBean.java index 75c1f41e..44d2bcef 100644 --- a/src/main/java/com/allobank/test/config/RestTemplateFactoryBean.java +++ b/src/main/java/com/allobank/finance/config/RestTemplateFactoryBean.java @@ -1,4 +1,4 @@ -package com.allobank.test.config; +package com.allobank.finance.config; import org.springframework.beans.factory.FactoryBean; import org.springframework.http.client.SimpleClientHttpRequestFactory; diff --git a/src/main/java/com/allobank/test/controller/.gitkeep b/src/main/java/com/allobank/finance/controller/.gitkeep similarity index 100% rename from src/main/java/com/allobank/test/controller/.gitkeep rename to src/main/java/com/allobank/finance/controller/.gitkeep diff --git a/src/main/java/com/allobank/test/controller/FinanceDataController.java b/src/main/java/com/allobank/finance/controller/FinanceDataController.java similarity index 77% rename from src/main/java/com/allobank/test/controller/FinanceDataController.java rename to src/main/java/com/allobank/finance/controller/FinanceDataController.java index c11437bf..ff3e5ae3 100644 --- a/src/main/java/com/allobank/test/controller/FinanceDataController.java +++ b/src/main/java/com/allobank/finance/controller/FinanceDataController.java @@ -1,6 +1,5 @@ -package com.allobank.test.controller; +package com.allobank.finance.controller; -import com.allobank.test.service.FinanceDataService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -10,6 +9,8 @@ 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; @@ -25,16 +26,11 @@ public FinanceDataController(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." - ) + @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 - ) { + @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/test/controller/HomeController.java b/src/main/java/com/allobank/finance/controller/HomeController.java similarity index 94% rename from src/main/java/com/allobank/test/controller/HomeController.java rename to src/main/java/com/allobank/finance/controller/HomeController.java index 94276fca..fc5df1e8 100644 --- a/src/main/java/com/allobank/test/controller/HomeController.java +++ b/src/main/java/com/allobank/finance/controller/HomeController.java @@ -1,4 +1,4 @@ -package com.allobank.test.controller; +package com.allobank.finance.controller; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; diff --git a/src/main/java/com/allobank/test/dto/.gitkeep b/src/main/java/com/allobank/finance/dto/.gitkeep similarity index 100% rename from src/main/java/com/allobank/test/dto/.gitkeep rename to src/main/java/com/allobank/finance/dto/.gitkeep diff --git a/src/main/java/com/allobank/test/exception/.gitkeep b/src/main/java/com/allobank/finance/exception/.gitkeep similarity index 100% rename from src/main/java/com/allobank/test/exception/.gitkeep rename to src/main/java/com/allobank/finance/exception/.gitkeep diff --git a/src/main/java/com/allobank/test/exception/DataNotInitializedException.java b/src/main/java/com/allobank/finance/exception/DataNotInitializedException.java similarity index 79% rename from src/main/java/com/allobank/test/exception/DataNotInitializedException.java rename to src/main/java/com/allobank/finance/exception/DataNotInitializedException.java index 5f7abe69..48f53389 100644 --- a/src/main/java/com/allobank/test/exception/DataNotInitializedException.java +++ b/src/main/java/com/allobank/finance/exception/DataNotInitializedException.java @@ -1,4 +1,4 @@ -package com.allobank.test.exception; +package com.allobank.finance.exception; public class DataNotInitializedException extends RuntimeException { diff --git a/src/main/java/com/allobank/test/exception/GlobalExceptionHandler.java b/src/main/java/com/allobank/finance/exception/GlobalExceptionHandler.java similarity index 94% rename from src/main/java/com/allobank/test/exception/GlobalExceptionHandler.java rename to src/main/java/com/allobank/finance/exception/GlobalExceptionHandler.java index d0512c40..e21c9eff 100644 --- a/src/main/java/com/allobank/test/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/allobank/finance/exception/GlobalExceptionHandler.java @@ -1,11 +1,12 @@ -package com.allobank.test.exception; +package com.allobank.finance.exception; -import com.allobank.test.service.FinanceDataService; 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 diff --git a/src/main/java/com/allobank/test/exception/ResourceTypeNotSupportedException.java b/src/main/java/com/allobank/finance/exception/ResourceTypeNotSupportedException.java similarity index 81% rename from src/main/java/com/allobank/test/exception/ResourceTypeNotSupportedException.java rename to src/main/java/com/allobank/finance/exception/ResourceTypeNotSupportedException.java index 1fcca1c2..71e74c23 100644 --- a/src/main/java/com/allobank/test/exception/ResourceTypeNotSupportedException.java +++ b/src/main/java/com/allobank/finance/exception/ResourceTypeNotSupportedException.java @@ -1,4 +1,4 @@ -package com.allobank.test.exception; +package com.allobank.finance.exception; public class ResourceTypeNotSupportedException extends RuntimeException { diff --git a/src/main/java/com/allobank/test/repository/.gitkeep b/src/main/java/com/allobank/finance/repository/.gitkeep similarity index 100% rename from src/main/java/com/allobank/test/repository/.gitkeep rename to src/main/java/com/allobank/finance/repository/.gitkeep diff --git a/src/main/java/com/allobank/test/runner/.gitkeep b/src/main/java/com/allobank/finance/runner/.gitkeep similarity index 100% rename from src/main/java/com/allobank/test/runner/.gitkeep rename to src/main/java/com/allobank/finance/runner/.gitkeep diff --git a/src/main/java/com/allobank/test/runner/FinanceDataPreloadRunner.java b/src/main/java/com/allobank/finance/runner/FinanceDataPreloadRunner.java similarity index 85% rename from src/main/java/com/allobank/test/runner/FinanceDataPreloadRunner.java rename to src/main/java/com/allobank/finance/runner/FinanceDataPreloadRunner.java index 093644c8..71a8a1a3 100644 --- a/src/main/java/com/allobank/test/runner/FinanceDataPreloadRunner.java +++ b/src/main/java/com/allobank/finance/runner/FinanceDataPreloadRunner.java @@ -1,8 +1,5 @@ -package com.allobank.test.runner; +package com.allobank.finance.runner; -import com.allobank.test.store.FinanceDataStore; -import com.allobank.test.strategy.IDRDataFetcher; -import com.allobank.test.strategy.IDRDataFetcherRegistry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -11,6 +8,10 @@ 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; @@ -28,8 +29,7 @@ public class FinanceDataPreloadRunner implements ApplicationRunner { public FinanceDataPreloadRunner( IDRDataFetcherRegistry registry, FinanceDataStore financeDataStore, - @Value("${finance.preload.fail-fast:false}") boolean failFast - ) { + @Value("${finance.preload.fail-fast:false}") boolean failFast) { this.registry = registry; this.financeDataStore = financeDataStore; this.failFast = failFast; @@ -47,14 +47,14 @@ public void run(ApplicationArguments args) { 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); + 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() - ))); + "at", OffsetDateTime.now().toString()))); } } financeDataStore.initializeOnce(loadedData); diff --git a/src/main/java/com/allobank/test/service/.gitkeep b/src/main/java/com/allobank/finance/service/.gitkeep similarity index 100% rename from src/main/java/com/allobank/test/service/.gitkeep rename to src/main/java/com/allobank/finance/service/.gitkeep diff --git a/src/main/java/com/allobank/test/service/FinanceDataService.java b/src/main/java/com/allobank/finance/service/FinanceDataService.java similarity index 82% rename from src/main/java/com/allobank/test/service/FinanceDataService.java rename to src/main/java/com/allobank/finance/service/FinanceDataService.java index cc08da9e..a6bc8726 100644 --- a/src/main/java/com/allobank/test/service/FinanceDataService.java +++ b/src/main/java/com/allobank/finance/service/FinanceDataService.java @@ -1,10 +1,11 @@ -package com.allobank.test.service; +package com.allobank.finance.service; -import com.allobank.test.exception.ResourceTypeNotSupportedException; -import com.allobank.test.store.FinanceDataStore; -import com.allobank.test.strategy.IDRDataFetcherRegistry; 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; diff --git a/src/main/java/com/allobank/test/store/.gitkeep b/src/main/java/com/allobank/finance/store/.gitkeep similarity index 100% rename from src/main/java/com/allobank/test/store/.gitkeep rename to src/main/java/com/allobank/finance/store/.gitkeep diff --git a/src/main/java/com/allobank/test/store/FinanceDataStore.java b/src/main/java/com/allobank/finance/store/FinanceDataStore.java similarity index 93% rename from src/main/java/com/allobank/test/store/FinanceDataStore.java rename to src/main/java/com/allobank/finance/store/FinanceDataStore.java index fd40516d..89080138 100644 --- a/src/main/java/com/allobank/test/store/FinanceDataStore.java +++ b/src/main/java/com/allobank/finance/store/FinanceDataStore.java @@ -1,9 +1,10 @@ -package com.allobank.test.store; +package com.allobank.finance.store; -import com.allobank.test.exception.DataNotInitializedException; -import com.allobank.test.exception.ResourceTypeNotSupportedException; 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; diff --git a/src/main/java/com/allobank/test/strategy/.gitkeep b/src/main/java/com/allobank/finance/strategy/.gitkeep similarity index 100% rename from src/main/java/com/allobank/test/strategy/.gitkeep rename to src/main/java/com/allobank/finance/strategy/.gitkeep diff --git a/src/main/java/com/allobank/test/strategy/HistoricalIdrUsdFetcher.java b/src/main/java/com/allobank/finance/strategy/HistoricalIdrUsdFetcher.java similarity index 92% rename from src/main/java/com/allobank/test/strategy/HistoricalIdrUsdFetcher.java rename to src/main/java/com/allobank/finance/strategy/HistoricalIdrUsdFetcher.java index 7cd75a20..cc351dea 100644 --- a/src/main/java/com/allobank/test/strategy/HistoricalIdrUsdFetcher.java +++ b/src/main/java/com/allobank/finance/strategy/HistoricalIdrUsdFetcher.java @@ -1,8 +1,9 @@ -package com.allobank.test.strategy; +package com.allobank.finance.strategy; -import com.allobank.test.client.FrankfurterClient; import org.springframework.stereotype.Component; +import com.allobank.finance.client.FrankfurterClient; + import java.math.BigDecimal; import java.util.LinkedHashMap; import java.util.List; @@ -27,7 +28,8 @@ public List> fetch() { Map rawResponse = frankfurterClient.fetchHistoricalIdrUsdRaw(); @SuppressWarnings("unchecked") - Map> ratesByDate = (Map>) rawResponse.getOrDefault("rates", + Map> ratesByDate = (Map>) rawResponse.getOrDefault( + "rates", Map.of()); return ratesByDate.entrySet().stream() diff --git a/src/main/java/com/allobank/test/strategy/IDRDataFetcher.java b/src/main/java/com/allobank/finance/strategy/IDRDataFetcher.java similarity index 79% rename from src/main/java/com/allobank/test/strategy/IDRDataFetcher.java rename to src/main/java/com/allobank/finance/strategy/IDRDataFetcher.java index a075b73e..1e990c60 100644 --- a/src/main/java/com/allobank/test/strategy/IDRDataFetcher.java +++ b/src/main/java/com/allobank/finance/strategy/IDRDataFetcher.java @@ -1,4 +1,4 @@ -package com.allobank.test.strategy; +package com.allobank.finance.strategy; import java.util.List; import java.util.Map; diff --git a/src/main/java/com/allobank/test/strategy/IDRDataFetcherRegistry.java b/src/main/java/com/allobank/finance/strategy/IDRDataFetcherRegistry.java similarity index 93% rename from src/main/java/com/allobank/test/strategy/IDRDataFetcherRegistry.java rename to src/main/java/com/allobank/finance/strategy/IDRDataFetcherRegistry.java index d724729a..b1a7f302 100644 --- a/src/main/java/com/allobank/test/strategy/IDRDataFetcherRegistry.java +++ b/src/main/java/com/allobank/finance/strategy/IDRDataFetcherRegistry.java @@ -1,4 +1,4 @@ -package com.allobank.test.strategy; +package com.allobank.finance.strategy; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/allobank/test/strategy/LatestIdrRatesFetcher.java b/src/main/java/com/allobank/finance/strategy/LatestIdrRatesFetcher.java similarity index 95% rename from src/main/java/com/allobank/test/strategy/LatestIdrRatesFetcher.java rename to src/main/java/com/allobank/finance/strategy/LatestIdrRatesFetcher.java index f9729fa6..5eb7ae06 100644 --- a/src/main/java/com/allobank/test/strategy/LatestIdrRatesFetcher.java +++ b/src/main/java/com/allobank/finance/strategy/LatestIdrRatesFetcher.java @@ -1,9 +1,10 @@ -package com.allobank.test.strategy; +package com.allobank.finance.strategy; -import com.allobank.test.client.FrankfurterClient; -import com.allobank.test.config.FrankfurterApiProperties; 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; diff --git a/src/main/java/com/allobank/test/strategy/SupportedCurrenciesFetcher.java b/src/main/java/com/allobank/finance/strategy/SupportedCurrenciesFetcher.java similarity index 92% rename from src/main/java/com/allobank/test/strategy/SupportedCurrenciesFetcher.java rename to src/main/java/com/allobank/finance/strategy/SupportedCurrenciesFetcher.java index 9f926e1e..643fc3df 100644 --- a/src/main/java/com/allobank/test/strategy/SupportedCurrenciesFetcher.java +++ b/src/main/java/com/allobank/finance/strategy/SupportedCurrenciesFetcher.java @@ -1,8 +1,9 @@ -package com.allobank.test.strategy; +package com.allobank.finance.strategy; -import com.allobank.test.client.FrankfurterClient; import org.springframework.stereotype.Component; +import com.allobank.finance.client.FrankfurterClient; + import java.util.LinkedHashMap; import java.util.List; import java.util.Map; diff --git a/src/test/java/com/allobank/test/runner/FinanceDataPreloadRunnerIntegrationTest.java b/src/test/java/com/allobank/test/runner/FinanceDataPreloadRunnerIntegrationTest.java index e0ef6cb8..74a8686a 100644 --- a/src/test/java/com/allobank/test/runner/FinanceDataPreloadRunnerIntegrationTest.java +++ b/src/test/java/com/allobank/test/runner/FinanceDataPreloadRunnerIntegrationTest.java @@ -1,7 +1,5 @@ package com.allobank.test.runner; -import com.allobank.test.client.FrankfurterClient; -import com.allobank.test.store.FinanceDataStore; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; @@ -11,6 +9,9 @@ import org.springframework.context.annotation.Primary; import org.springframework.test.context.ActiveProfiles; +import com.allobank.finance.client.FrankfurterClient; +import com.allobank.finance.store.FinanceDataStore; + import java.math.BigDecimal; import java.util.List; import java.util.Map; diff --git a/src/test/java/com/allobank/test/service/FinanceDataServiceTest.java b/src/test/java/com/allobank/test/service/FinanceDataServiceTest.java index 11c4821a..46f092f6 100644 --- a/src/test/java/com/allobank/test/service/FinanceDataServiceTest.java +++ b/src/test/java/com/allobank/test/service/FinanceDataServiceTest.java @@ -1,15 +1,17 @@ package com.allobank.test.service; -import com.allobank.test.exception.ResourceTypeNotSupportedException; -import com.allobank.test.store.FinanceDataStore; -import com.allobank.test.strategy.IDRDataFetcher; -import com.allobank.test.strategy.IDRDataFetcherRegistry; 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; diff --git a/src/test/java/com/allobank/test/store/FinanceDataStoreTest.java b/src/test/java/com/allobank/test/store/FinanceDataStoreTest.java index d70d61a8..85a6f10f 100644 --- a/src/test/java/com/allobank/test/store/FinanceDataStoreTest.java +++ b/src/test/java/com/allobank/test/store/FinanceDataStoreTest.java @@ -1,9 +1,11 @@ package com.allobank.test.store; -import com.allobank.test.exception.DataNotInitializedException; -import com.allobank.test.exception.ResourceTypeNotSupportedException; 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; diff --git a/src/test/java/com/allobank/test/strategy/HistoricalIdrUsdFetcherTest.java b/src/test/java/com/allobank/test/strategy/HistoricalIdrUsdFetcherTest.java index 65e8a765..c5f7c6bc 100644 --- a/src/test/java/com/allobank/test/strategy/HistoricalIdrUsdFetcherTest.java +++ b/src/test/java/com/allobank/test/strategy/HistoricalIdrUsdFetcherTest.java @@ -1,12 +1,14 @@ package com.allobank.test.strategy; -import com.allobank.test.client.FrankfurterClient; 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; diff --git a/src/test/java/com/allobank/test/strategy/LatestIdrRatesFetcherTest.java b/src/test/java/com/allobank/test/strategy/LatestIdrRatesFetcherTest.java index ffbcae67..9ec9f743 100644 --- a/src/test/java/com/allobank/test/strategy/LatestIdrRatesFetcherTest.java +++ b/src/test/java/com/allobank/test/strategy/LatestIdrRatesFetcherTest.java @@ -1,13 +1,15 @@ package com.allobank.test.strategy; -import com.allobank.test.client.FrankfurterClient; -import com.allobank.test.config.FrankfurterApiProperties; 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; diff --git a/src/test/java/com/allobank/test/strategy/SupportedCurrenciesFetcherTest.java b/src/test/java/com/allobank/test/strategy/SupportedCurrenciesFetcherTest.java index 2e414a1d..dc737b6c 100644 --- a/src/test/java/com/allobank/test/strategy/SupportedCurrenciesFetcherTest.java +++ b/src/test/java/com/allobank/test/strategy/SupportedCurrenciesFetcherTest.java @@ -1,12 +1,14 @@ package com.allobank.test.strategy; -import com.allobank.test.client.FrankfurterClient; 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; From 8954fdac8a57cf9e38d586d94fadb766ab50ccaf Mon Sep 17 00:00:00 2001 From: jonheri Date: Tue, 14 Apr 2026 10:32:25 +0700 Subject: [PATCH 5/6] feat: add architectural rationale docs and centralize frankfurter base url in factory bean --- README.md | 11 +++++++++++ pom.xml | 3 +++ .../allobank/finance/client/FrankfurterClient.java | 6 +++--- .../finance/config/RestTemplateFactoryBean.java | 5 ++++- .../allobank/test/AlloBankTestApplicationTests.java | 6 +++++- .../FinanceDataPreloadRunnerIntegrationTest.java | 4 ++++ 6 files changed, 30 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 01a2635b..9ac91017 100644 --- a/README.md +++ b/README.md @@ -92,6 +92,17 @@ For `latest_idr_rates`, custom field is calculated as: - `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. +## Architectural Rationale + +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. + +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. + +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. + ## Error Handling - Unsupported resource type -> `400` diff --git a/pom.xml b/pom.xml index 8a5e99ad..80f88884 100644 --- a/pom.xml +++ b/pom.xml @@ -58,6 +58,9 @@ org.springframework.boot spring-boot-maven-plugin + + com.allobank.finance.AlloBankTestApplication + org.jacoco diff --git a/src/main/java/com/allobank/finance/client/FrankfurterClient.java b/src/main/java/com/allobank/finance/client/FrankfurterClient.java index 3efa046c..e04bbe80 100644 --- a/src/main/java/com/allobank/finance/client/FrankfurterClient.java +++ b/src/main/java/com/allobank/finance/client/FrankfurterClient.java @@ -22,17 +22,17 @@ public FrankfurterClient(RestTemplate restTemplate, FrankfurterApiProperties pro } public Map fetchLatestIdrRatesRaw() { - String url = properties.getBaseUrl() + "/latest?base=IDR"; + String url = "/latest?base=IDR"; return getMap(url); } public Map fetchHistoricalIdrUsdRaw() { - String url = properties.getBaseUrl() + "/" + properties.getHistoricalRange() + "?from=IDR&to=USD"; + String url = "/" + properties.getHistoricalRange() + "?from=IDR&to=USD"; return getMap(url); } public Map fetchSupportedCurrenciesRaw() { - String url = properties.getBaseUrl() + "/currencies"; + String url = "/currencies"; ResponseEntity> response = restTemplate.exchange( url, HttpMethod.GET, diff --git a/src/main/java/com/allobank/finance/config/RestTemplateFactoryBean.java b/src/main/java/com/allobank/finance/config/RestTemplateFactoryBean.java index 44d2bcef..1079f4c5 100644 --- a/src/main/java/com/allobank/finance/config/RestTemplateFactoryBean.java +++ b/src/main/java/com/allobank/finance/config/RestTemplateFactoryBean.java @@ -4,6 +4,7 @@ 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 { @@ -19,7 +20,9 @@ public RestTemplate getObject() { SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); requestFactory.setConnectTimeout(properties.getConnectTimeoutMillis()); requestFactory.setReadTimeout(properties.getReadTimeoutMillis()); - return new RestTemplate(requestFactory); + RestTemplate restTemplate = new RestTemplate(requestFactory); + restTemplate.setUriTemplateHandler(new DefaultUriBuilderFactory(properties.getBaseUrl())); + return restTemplate; } @Override diff --git a/src/test/java/com/allobank/test/AlloBankTestApplicationTests.java b/src/test/java/com/allobank/test/AlloBankTestApplicationTests.java index 6bb0c277..6352f2c7 100644 --- a/src/test/java/com/allobank/test/AlloBankTestApplicationTests.java +++ b/src/test/java/com/allobank/test/AlloBankTestApplicationTests.java @@ -3,7 +3,11 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; -@SpringBootTest(properties = "finance.preload.enabled=false") +import com.allobank.finance.AlloBankTestApplication; + +@SpringBootTest( + classes = AlloBankTestApplication.class, + properties = "finance.preload.enabled=false") class AlloBankTestApplicationTests { @Test diff --git a/src/test/java/com/allobank/test/runner/FinanceDataPreloadRunnerIntegrationTest.java b/src/test/java/com/allobank/test/runner/FinanceDataPreloadRunnerIntegrationTest.java index 74a8686a..5ecbecc2 100644 --- a/src/test/java/com/allobank/test/runner/FinanceDataPreloadRunnerIntegrationTest.java +++ b/src/test/java/com/allobank/test/runner/FinanceDataPreloadRunnerIntegrationTest.java @@ -9,6 +9,7 @@ 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; @@ -22,6 +23,9 @@ @SpringBootTest(properties = { "finance.preload.enabled=true", "finance.preload.fail-fast=true" +}, classes = { + AlloBankTestApplication.class, + FinanceDataPreloadRunnerIntegrationTest.StubFrankfurterClientConfiguration.class }) @ActiveProfiles("test") class FinanceDataPreloadRunnerIntegrationTest { From 4459f57eb480269b39245a20da53010a6994ca42 Mon Sep 17 00:00:00 2001 From: jonheri Date: Tue, 14 Apr 2026 10:52:25 +0700 Subject: [PATCH 6/6] docs: update README setup to include build command --- README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 9ac91017..eede7983 100644 --- a/README.md +++ b/README.md @@ -18,13 +18,19 @@ git clone cd allobank-backend-test ``` -2. Run application: +2. Build application: + +```bash +mvn clean package +``` + +3. Run application: ```bash mvn spring-boot:run ``` -3. Run tests: +4. Run tests: ```bash mvn test