From b31a1a61ece23a652125a40843b3d79a219472f6 Mon Sep 17 00:00:00 2001 From: haidir Date: Thu, 2 Apr 2026 10:25:34 +0700 Subject: [PATCH 1/6] chore: initialize Spring Boot project structure --- pom.xml | 29 +++++++++++++++++++ .../com/allo/finance/FinanceApplication.java | 12 ++++++++ src/main/resources/application.yml | 1 + target/classes/application.yml | 1 + 4 files changed, 43 insertions(+) create mode 100644 pom.xml create mode 100644 src/main/java/com/allo/finance/FinanceApplication.java create mode 100644 src/main/resources/application.yml create mode 100644 target/classes/application.yml diff --git a/pom.xml b/pom.xml new file mode 100644 index 00000000..d8b00577 --- /dev/null +++ b/pom.xml @@ -0,0 +1,29 @@ + + + 4.0.0 + com.allo + finance + 0.0.1-SNAPSHOT + + org.springframework.boot + spring-boot-starter-parent + 3.2.0 + + + + + org.springframework.boot + spring-boot-starter-webflux + + + org.projectlombok + lombok + true + + + org.springframework.boot + spring-boot-starter-test + test + + + diff --git a/src/main/java/com/allo/finance/FinanceApplication.java b/src/main/java/com/allo/finance/FinanceApplication.java new file mode 100644 index 00000000..4e030a35 --- /dev/null +++ b/src/main/java/com/allo/finance/FinanceApplication.java @@ -0,0 +1,12 @@ + +package com.allo.finance; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class FinanceApplication { + public static void main(String[] args) { + SpringApplication.run(FinanceApplication.class, args); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1 @@ + diff --git a/target/classes/application.yml b/target/classes/application.yml new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/target/classes/application.yml @@ -0,0 +1 @@ + From 99dc46036e2e166da789d839581b85b04e5dad3a Mon Sep 17 00:00:00 2001 From: haidir Date: Thu, 2 Apr 2026 10:30:45 +0700 Subject: [PATCH 2/6] feat: implement external API integration with strategy pattern and WebClient configuration --- .../allo/finance/config/JacksonConfig.java | 18 ++++++ .../allo/finance/config/WebClientFactory.java | 24 ++++++++ .../finance/strategy/CurrencyFetcher.java | 22 +++++++ .../finance/strategy/HistoricalFetcher.java | 56 +++++++++++++++++ .../allo/finance/strategy/IDRDataFetcher.java | 7 +++ .../allo/finance/strategy/LatestFetcher.java | 60 +++++++++++++++++++ src/main/resources/application.yml | 6 ++ target/classes/application.yml | 6 ++ 8 files changed, 199 insertions(+) create mode 100644 src/main/java/com/allo/finance/config/JacksonConfig.java create mode 100644 src/main/java/com/allo/finance/config/WebClientFactory.java create mode 100644 src/main/java/com/allo/finance/strategy/CurrencyFetcher.java create mode 100644 src/main/java/com/allo/finance/strategy/HistoricalFetcher.java create mode 100644 src/main/java/com/allo/finance/strategy/IDRDataFetcher.java create mode 100644 src/main/java/com/allo/finance/strategy/LatestFetcher.java diff --git a/src/main/java/com/allo/finance/config/JacksonConfig.java b/src/main/java/com/allo/finance/config/JacksonConfig.java new file mode 100644 index 00000000..d548ca75 --- /dev/null +++ b/src/main/java/com/allo/finance/config/JacksonConfig.java @@ -0,0 +1,18 @@ +package com.allo.finance.config; + +import com.fasterxml.jackson.databind.SerializationFeature; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer; + +@Configuration +public class JacksonConfig { + + @SuppressWarnings("deprecation") + @Bean + public Jackson2ObjectMapperBuilderCustomizer jsonCustomizer() { + return builder -> builder.featuresToEnable( + SerializationFeature.WRITE_BIGDECIMAL_AS_PLAIN + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/allo/finance/config/WebClientFactory.java b/src/main/java/com/allo/finance/config/WebClientFactory.java new file mode 100644 index 00000000..38d9153b --- /dev/null +++ b/src/main/java/com/allo/finance/config/WebClientFactory.java @@ -0,0 +1,24 @@ + +package com.allo.finance.config; + +import org.springframework.beans.factory.FactoryBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +@Component +public class WebClientFactory implements FactoryBean { + + @Value("${external.api.base-url}") + private String baseUrl; + + @Override + public WebClient getObject() { + return WebClient.builder().baseUrl(baseUrl).build(); + } + + @Override + public Class getObjectType() { + return WebClient.class; + } +} diff --git a/src/main/java/com/allo/finance/strategy/CurrencyFetcher.java b/src/main/java/com/allo/finance/strategy/CurrencyFetcher.java new file mode 100644 index 00000000..f51bf66d --- /dev/null +++ b/src/main/java/com/allo/finance/strategy/CurrencyFetcher.java @@ -0,0 +1,22 @@ + +package com.allo.finance.strategy; + +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +@Service +public class CurrencyFetcher implements IDRDataFetcher { + + private final WebClient client; + + public CurrencyFetcher(WebClient client) { + this.client = client; + } + + public String getType() { return "supported_currencies"; } + + public Object fetch() { + return client.get().uri("/currencies") + .retrieve().bodyToMono(Object.class).block(); + } +} diff --git a/src/main/java/com/allo/finance/strategy/HistoricalFetcher.java b/src/main/java/com/allo/finance/strategy/HistoricalFetcher.java new file mode 100644 index 00000000..5f6c6e9f --- /dev/null +++ b/src/main/java/com/allo/finance/strategy/HistoricalFetcher.java @@ -0,0 +1,56 @@ +package com.allo.finance.strategy; + +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.Map; + +@Service +public class HistoricalFetcher implements IDRDataFetcher { + + private final WebClient client; + + public HistoricalFetcher(WebClient client) { + this.client = client; + } + + @Override + public String getType() { + return "historical_idr_usd"; + } + + @Override + public Object fetch() { + + Map res = client.get() + .uri("/2024-01-01..2024-01-05?from=IDR&to=USD") + .retrieve() + .bodyToMono(Map.class) + .block(); + + Map> rates = + (Map>) res.get("rates"); + + // ✅ FIX: convert nested map + Map formattedRates = new LinkedHashMap<>(); + + rates.forEach((date, currencyMap) -> { + + Map inner = new LinkedHashMap<>(); + + currencyMap.forEach((currency, value) -> { + BigDecimal bd = new BigDecimal(value.toString()); + inner.put(currency, bd); + }); + + formattedRates.put(date, inner); + }); + + // replace + res.put("rates", formattedRates); + + return res; + } +} \ No newline at end of file diff --git a/src/main/java/com/allo/finance/strategy/IDRDataFetcher.java b/src/main/java/com/allo/finance/strategy/IDRDataFetcher.java new file mode 100644 index 00000000..9bda0c27 --- /dev/null +++ b/src/main/java/com/allo/finance/strategy/IDRDataFetcher.java @@ -0,0 +1,7 @@ + +package com.allo.finance.strategy; + +public interface IDRDataFetcher { + String getType(); + Object fetch(); +} diff --git a/src/main/java/com/allo/finance/strategy/LatestFetcher.java b/src/main/java/com/allo/finance/strategy/LatestFetcher.java new file mode 100644 index 00000000..974a155b --- /dev/null +++ b/src/main/java/com/allo/finance/strategy/LatestFetcher.java @@ -0,0 +1,60 @@ +package com.allo.finance.strategy; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import java.math.BigDecimal; +import java.util.LinkedHashMap; +import java.util.Map; + +@Service +public class LatestFetcher implements IDRDataFetcher { + + private final WebClient client; + + @Value("${app.github-username}") + private String username; + + public LatestFetcher(WebClient client) { + this.client = client; + } + + @Override + public String getType() { + return "latest_idr_rates"; + } + + @Override + public Object fetch() { + + Map res = client.get() + .uri("/latest?base=IDR") + .retrieve() + .bodyToMono(Map.class) + .block(); + + Map rates = (Map) res.get("rates"); + + Map formattedRates = new LinkedHashMap<>(); + rates.forEach((k, v) -> { + BigDecimal bd = new BigDecimal(v.toString()); + formattedRates.put(k, bd); + }); + + res.put("rates", formattedRates); + + double usd = rates.get("USD"); + + int sum = username.chars().sum(); + double spread = (sum % 1000) / 100000.0; + + double calc = (1 / usd) * (1 + spread); + + BigDecimal spreadResult = new BigDecimal(String.valueOf(calc)); + + res.put("USD_BuySpread_IDR", spreadResult); + + return res; + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8b137891..65700198 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1 +1,7 @@ +external: + api: + base-url: https://api.frankfurter.app + +app: + github-username: haidir diff --git a/target/classes/application.yml b/target/classes/application.yml index 8b137891..65700198 100644 --- a/target/classes/application.yml +++ b/target/classes/application.yml @@ -1 +1,7 @@ +external: + api: + base-url: https://api.frankfurter.app + +app: + github-username: haidir From d46c4f9d99c9f799ef461218216cf7432f525058 Mon Sep 17 00:00:00 2001 From: haidir Date: Thu, 2 Apr 2026 14:52:59 +0700 Subject: [PATCH 3/6] feat: add startup data loader, immutable in-memory store, and finance API controller --- .../finance/controller/FinanceController.java | 21 ++++++ .../com/allo/finance/runner/DataLoader.java | 30 +++++++++ .../com/allo/finance/store/DataStore.java | 19 ++++++ .../java/com/allo/finance/SpreadTest.java | 16 +++++ .../com/allo/test/unit/SpreadUtilTest.java | 19 ++++++ .../com/allo/test/unit/util/SpreadUtil.java | 15 +++++ .../com/allo/finance/FinanceApplication.class | Bin 0 -> 742 bytes .../allo/finance/config/JacksonConfig.class | Bin 0 -> 1710 bytes .../finance/config/WebClientFactory.class | Bin 0 -> 1577 bytes .../controller/FinanceController.class | Bin 0 -> 1365 bytes .../com/allo/finance/runner/DataLoader.class | Bin 0 -> 2470 bytes .../com/allo/finance/store/DataStore.class | Bin 0 -> 1161 bytes .../finance/strategy/CurrencyFetcher.class | Bin 0 -> 1809 bytes .../finance/strategy/HistoricalFetcher.class | Bin 0 -> 3721 bytes .../finance/strategy/IDRDataFetcher.class | Bin 0 -> 215 bytes .../allo/finance/strategy/LatestFetcher.class | Bin 0 -> 3891 bytes target/finance-0.0.1-SNAPSHOT.jar | Bin 0 -> 12456 bytes target/maven-archiver/pom.properties | 3 + .../compile/default-compile/createdFiles.lst | 10 +++ .../compile/default-compile/inputFiles.lst | 10 +++ .../default-testCompile/createdFiles.lst | 3 + .../default-testCompile/inputFiles.lst | 3 + .../2026-04-02T14-44-18_570.dumpstream | 5 ++ .../TEST-com.allo.finance.SpreadTest.xml | 60 ++++++++++++++++++ ...TEST-com.allo.test.unit.SpreadUtilTest.xml | 60 ++++++++++++++++++ .../com.allo.finance.SpreadTest.txt | 4 ++ .../com.allo.test.unit.SpreadUtilTest.txt | 4 ++ .../com/allo/finance/SpreadTest.class | Bin 0 -> 809 bytes .../com/allo/test/unit/SpreadUtilTest.class | Bin 0 -> 908 bytes .../com/allo/test/util/SpreadUtil.class | Bin 0 -> 787 bytes 30 files changed, 282 insertions(+) create mode 100644 src/main/java/com/allo/finance/controller/FinanceController.java create mode 100644 src/main/java/com/allo/finance/runner/DataLoader.java create mode 100644 src/main/java/com/allo/finance/store/DataStore.java create mode 100644 src/test/java/com/allo/finance/SpreadTest.java create mode 100644 src/test/java/com/allo/test/unit/SpreadUtilTest.java create mode 100644 src/test/java/com/allo/test/unit/util/SpreadUtil.java create mode 100644 target/classes/com/allo/finance/FinanceApplication.class create mode 100644 target/classes/com/allo/finance/config/JacksonConfig.class create mode 100644 target/classes/com/allo/finance/config/WebClientFactory.class create mode 100644 target/classes/com/allo/finance/controller/FinanceController.class create mode 100644 target/classes/com/allo/finance/runner/DataLoader.class create mode 100644 target/classes/com/allo/finance/store/DataStore.class create mode 100644 target/classes/com/allo/finance/strategy/CurrencyFetcher.class create mode 100644 target/classes/com/allo/finance/strategy/HistoricalFetcher.class create mode 100644 target/classes/com/allo/finance/strategy/IDRDataFetcher.class create mode 100644 target/classes/com/allo/finance/strategy/LatestFetcher.class create mode 100644 target/finance-0.0.1-SNAPSHOT.jar create mode 100644 target/maven-archiver/pom.properties create mode 100644 target/maven-status/maven-compiler-plugin/compile/default-compile/createdFiles.lst create mode 100644 target/maven-status/maven-compiler-plugin/compile/default-compile/inputFiles.lst create mode 100644 target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/createdFiles.lst create mode 100644 target/maven-status/maven-compiler-plugin/testCompile/default-testCompile/inputFiles.lst create mode 100644 target/surefire-reports/2026-04-02T14-44-18_570.dumpstream create mode 100644 target/surefire-reports/TEST-com.allo.finance.SpreadTest.xml create mode 100644 target/surefire-reports/TEST-com.allo.test.unit.SpreadUtilTest.xml create mode 100644 target/surefire-reports/com.allo.finance.SpreadTest.txt create mode 100644 target/surefire-reports/com.allo.test.unit.SpreadUtilTest.txt create mode 100644 target/test-classes/com/allo/finance/SpreadTest.class create mode 100644 target/test-classes/com/allo/test/unit/SpreadUtilTest.class create mode 100644 target/test-classes/com/allo/test/util/SpreadUtil.class diff --git a/src/main/java/com/allo/finance/controller/FinanceController.java b/src/main/java/com/allo/finance/controller/FinanceController.java new file mode 100644 index 00000000..ba50b7ec --- /dev/null +++ b/src/main/java/com/allo/finance/controller/FinanceController.java @@ -0,0 +1,21 @@ +package com.allo.finance.controller; + +import com.allo.finance.store.DataStore; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/finance") +public class FinanceController { + + private final DataStore store; + + public FinanceController(DataStore store){ + this.store = store; + } + + @GetMapping("/data/{type}") + public ResponseEntity get(@PathVariable String type){ + return ResponseEntity.ok(store.get(type)); + } +} \ No newline at end of file diff --git a/src/main/java/com/allo/finance/runner/DataLoader.java b/src/main/java/com/allo/finance/runner/DataLoader.java new file mode 100644 index 00000000..f04d190e --- /dev/null +++ b/src/main/java/com/allo/finance/runner/DataLoader.java @@ -0,0 +1,30 @@ + +package com.allo.finance.runner; + +import com.allo.finance.store.DataStore; +import com.allo.finance.strategy.IDRDataFetcher; +import org.springframework.boot.ApplicationArguments; +import org.springframework.boot.ApplicationRunner; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Component +public class DataLoader implements ApplicationRunner { + + private final List fetchers; + private final DataStore store; + + public DataLoader(List fetchers, DataStore store) { + this.fetchers = fetchers; + this.store = store; + } + + public void run(ApplicationArguments args){ + Map temp = new HashMap<>(); + fetchers.forEach(f -> temp.put(f.getType(), f.fetch())); + store.setAll(temp); + } +} diff --git a/src/main/java/com/allo/finance/store/DataStore.java b/src/main/java/com/allo/finance/store/DataStore.java new file mode 100644 index 00000000..2a8f6bb7 --- /dev/null +++ b/src/main/java/com/allo/finance/store/DataStore.java @@ -0,0 +1,19 @@ + +package com.allo.finance.store; + +import org.springframework.stereotype.Service; +import java.util.*; + +@Service +public class DataStore { + + private Map data = Map.of(); + + public synchronized void setAll(Map d){ + data = Collections.unmodifiableMap(new HashMap<>(d)); + } + + public Object get(String key){ + return data.get(key); + } +} diff --git a/src/test/java/com/allo/finance/SpreadTest.java b/src/test/java/com/allo/finance/SpreadTest.java new file mode 100644 index 00000000..24ce6ada --- /dev/null +++ b/src/test/java/com/allo/finance/SpreadTest.java @@ -0,0 +1,16 @@ + +package com.allo.finance; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +public class SpreadTest { + + @Test + void testSpread(){ + String user = "haidir"; + int sum = user.chars().sum(); + double spread = (sum % 1000) / 100000.0; + assertTrue(spread >= 0); + } +} diff --git a/src/test/java/com/allo/test/unit/SpreadUtilTest.java b/src/test/java/com/allo/test/unit/SpreadUtilTest.java new file mode 100644 index 00000000..8cee5ef1 --- /dev/null +++ b/src/test/java/com/allo/test/unit/SpreadUtilTest.java @@ -0,0 +1,19 @@ + +package com.allo.test.unit; + +import com.allo.test.util.SpreadUtil; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.*; + +class SpreadUtilTest { + + @Test + void testSpread(){ + SpreadUtil util = new SpreadUtil(); + ReflectionTestUtils.setField(util,"username","haidir"); + double result = util.calculateSpread(); + assertTrue(result >= 0); + } +} diff --git a/src/test/java/com/allo/test/unit/util/SpreadUtil.java b/src/test/java/com/allo/test/unit/util/SpreadUtil.java new file mode 100644 index 00000000..c95eb8a7 --- /dev/null +++ b/src/test/java/com/allo/test/unit/util/SpreadUtil.java @@ -0,0 +1,15 @@ + +package com.allo.test.util; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class SpreadUtil { + @Value("${github.username}") + private String username; + + public double calculateSpread() { + int sum = username.chars().sum(); + return (sum % 1000) / 100000.0; + } +} diff --git a/target/classes/com/allo/finance/FinanceApplication.class b/target/classes/com/allo/finance/FinanceApplication.class new file mode 100644 index 0000000000000000000000000000000000000000..26c17ac0802ca396b0c49ee4e7e7e16d3903da14 GIT binary patch literal 742 zcma)4OHbQC5dJnKID}AWctbf<2r1;yz4a1`R3dRQ4-pg&J*~~gEZDozdfoPKaRT+w zAJ89#I%|-yL=IT;jOO*t>^Jl8>CayP->}m{4Ko4iZ8R{;uyiYa3Z4p`@U!Sv#*Sh3 zKxyR;8D_fsqX12Y-PmMYq^aR!rG<_qKYrHVCzDjg!YQL$Xkk7;yNv~WVCWf}@O)yG zPR3Sb@|Ur9JTk`dVd<*-fdaRPd ziYS$p7UcHfK0|$At|h}_sI)vSvPjxXpUcn*O)S!pu*%yn&bqr%Im2G~4gx%QCKNqn ztvhY*5ygc~2yS^wm^N`D-Ho|E7oILBEhV;wrm(R*R(`6!K#(y<1e=;N&$>O9L=|=AX?7j>y zk@R7piH{`P&k5*YnXEaiV3k08@z)ZJ@;X895p!3s_5M0RmlR;Wg3-hp*2%v?e}niX QRl{Z}x3EpxxBUeC2dad?H2?qr literal 0 HcmV?d00001 diff --git a/target/classes/com/allo/finance/config/JacksonConfig.class b/target/classes/com/allo/finance/config/JacksonConfig.class new file mode 100644 index 0000000000000000000000000000000000000000..f1d73ef8ef0dcc791746f33079f5e2a330edcc4a GIT binary patch literal 1710 zcmb_c>2A|N5dO9eF%6~-silyGxN>teE$036M&nzo5)v#OOe)hFne};jQvuE!N7rm+t3NKXbg*647)b!@7vd8^BR%04}yW7<4g&Ji~=R zH@yarjfh?}!|zyiZg@?qx>psEwt%goM@+jJBJywB4dV=ZEF9BUSb%uyIu3$6%!B;?Inosa+MV|v7h$1jFT?o_Y|GS|w4NL1H97P1iCu5pT^2v!d(qP!eY@>>R^_udMM&?U+cA`Rq9f!8apt$ zPku05c-a#F_A_<)d)s1HARV`5YS*eXqLjxNv2s3#5nOO_6k`l-MJXCiA&+AbJGe~O z=NFQCGupRIya#(jAAQ(ItNjfc$opw^ zQw1ErL6XQP?0C1vlO$Qa><2i&Yx?T89_+HtcB~IuZL&E6b8T^%A(~mLq%ewvg`|xXY=$SEU*|#7SGr#FMMLg-{(HVFtK65u3Du6| zwU#Eu)7){D)FFQ(tEINaU{zbn-I6}TdSn93aN)Wh zuPH6GBe~=0nyUA6Sf?aCf8V4c0{Pl^8PerG0~?{aSRsXJ%vhK;uxA<8PM;~m+syGe z|AhmS?7WS0IL{Do6U?x3DtZe^qV=MUO9nDgmtm(dEL>s84dbFJg${VF!!7S}yhfW0 z^RHSuR1H~CfvUQ4RckL4rac0N<)JNb7)YO@q^q8rLTpX7fqEn z70tDL_aj5BZbkUYth<}B@SUP`vgMPu4<#|yqh)iy zNx)OYc)yQIt_T9MN^GdQ7GcXLi`nBA#q0_hO&>bwa829EXM2upwyCmkmm>7xdnXK^ z2BLOdPr&LxiHbwbPxhhpVqQGW3-qa_XueA0T7X5wNTH97tH5 z-9yUa7Fijj93h&B(e&t1*VzHrXxH_28$LI&O3CEA5dE8c!-b$6o d4&Xi9Zv!v4Su-3wp!GuuUa~XEZICzFha}vaH_BDecT~c*ln3kP{&E860mS?T7LD5g|wWv2u zSyqvvVm$YKZG=(OG=?YtY|62$@QU(lTy$*tD`|E_5ZG~{IXx0yi=uORjRul`u*rO+ zu|3b!sn&3ez}iA(3WEx)oOCwz3VL>bpHFwvr6b28O4}HQmcc1ebAr-y-jB zL0m2Zb#}=yORqqJ-Vd5X`wq}9LHiV@>3@r)ZTj2uOyN6NVd*QJ&#}#IT1PQN>j;vx zOJfXoNSZpe#$DVaiTq+zhWoTnF%m4*1ZIfB!f&?W-NF|*OW!c^1CyUfpMWJ8CR<># z2iWZZyA=8yowtE!$?!q!_z?5-AEl!O`WD}TMaUsfI){;>5F0JU>!a8-OKi&|uXN1$ EU#=B+%m4rY literal 0 HcmV?d00001 diff --git a/target/classes/com/allo/finance/runner/DataLoader.class b/target/classes/com/allo/finance/runner/DataLoader.class new file mode 100644 index 0000000000000000000000000000000000000000..5cccb9700874d02a7b809ae980776416c0cc484a GIT binary patch literal 2470 zcmb7G4O1IM7=AW<9E5{}2(ntgD8a}XlN_J)cU=-Y{He~E^~LO!;F8* z8EdC?#vkDLn;f6L+?WG0l`_Na?z`{4`@GNl>~8-0=jEROK88vog#iPDCWbI9kbNSb zN~i8 zIf2PutZf+_?8`zV##Q%aOa1KmhgQ||LTkO0Ad0Qs8F3be0;e z`wbo^Hw=mqP10{LaqBgglHX9ZD6LGQy81M*V8<oHNbrU7ndB^kuuNl^`>Wy)??$H}I8-3Lf#Yjm~dGnd3h7 z4wY4kxZw&}mo{(PBY`n0tT*~QdAQ?6-3_*-TWhLdzT|m_9n#o~9p5qu%dv&}VoJK7 zeX_gdtJfy)nK<$WAFZ+zs$v$81*Vf3#W&Gng=~GB=KJ)WiEZp;@q@ti{MqZQrD5*q z*KngLgFpoaSlopkC&!XJPMrZ;;L59A`A~Fu##Mq$LEwFUbNXRs{{y~)Dct6mZ!7x) zv<=}7+YdQ9)UQ6=XW3Tw1#0NuIU?0v%iXo`;7gc#YUoLI+QF3@;>iSf++fyu|u@y2f9HyFz= z2>>r}J_QcPmw(5VlK?L;^NWObiI42%1i-of0M20po7`&z=W!2P34mS8XS8U5LxPUT zrgf?5u0d@x*A&~S!qN-O3-DVI#IrJhM;b^9nyF!WgCI0JGhP2^BMu4wb9}-1L6kU3 jVHfw=>J<;zKIFe<>PznPHNL_3_>q=0EkB`(8fO0m(%+wW literal 0 HcmV?d00001 diff --git a/target/classes/com/allo/finance/store/DataStore.class b/target/classes/com/allo/finance/store/DataStore.class new file mode 100644 index 0000000000000000000000000000000000000000..db17156d7ae5291d0f149e3addba180868a32d2b GIT binary patch literal 1161 zcma)5Yi|-k6g>kJ1eQWuORZ06werxeuO>!nP1DBsfvOQhqTdD>VA^GeEJIEIl_r{2 zP5c4=DC3=FQy!Y=mz}w1?>%?UnL9s!eft66DYg;_Asj=*!W^OunL~ceZHK!Z`&Hvm zG&MtXTe?#3FoX-mdJ-29k0D_p35y}+rw_Gs>;v9s2&p#1LZMil!YgsiBOOD=!U7f< za!u8R`krwS4(lbOcTqOQ6`3pYiQsV@ckTnWc7#0gN zHN|=YtGFD)6$@8!jbV9Qzo#6BL`vljsM*l%sg`U@-f)C3C-(=}47ai4hJ_sRWKTzs zTN^>igoTyBj!B3iUXvY{>!DG*HBGxc3jO?=_N3dXOdz9ssq9q9r9IW6@^n?Y;^nZ{ z5Z+sJb~2%=n%t>#&({)w5#5y}bFKO>`-wR^5PH{f7+8xTJI!{|7Xf4DK`K^e`+b#XRHf!|JAzikvK6L{d=5ri6K!_L$4{GOXI@)`9N)&REE_zLs!e5 zsLO#QpLShWX&&5up{l%&J?I;|+eXg^aCtybw2taOIh7o z#9ab&*dzke`WdaJb+Ysssq$A`58)$)A-}89336cEF?rrG2^;su{I*|o-~T?qLt0Jo GBj7hWnh9V4 literal 0 HcmV?d00001 diff --git a/target/classes/com/allo/finance/strategy/CurrencyFetcher.class b/target/classes/com/allo/finance/strategy/CurrencyFetcher.class new file mode 100644 index 0000000000000000000000000000000000000000..3003e5ef50d551b101cb8df7eb83cd9c673aa6fc GIT binary patch literal 1809 zcmb_dYjfLF5IxsUtk_Z85Yo~I?;5ZZnp@rvhdyv8(7}OD63hoaFtWUHZmZ~;E6JVw zE12OiVFrExKZ@Z<^5C?S@}cQ?q`kY=?w;M%9{v5#AAbS(4BZAQsCt<5F^`(U(*0l( z=qQLs`kTRh2~&mIhKWsjL!sJg_v=_d-9y92B7B8*Xb*G{MON>bIEX{hN$P@BMu)na zIVW*=xG8D4FOG4-$Vkk98$IhrIvG0?kM>+}AP=p3pdZRWI|;(nOhoTxF%LG@rH}qj z2HnEDvw*V-D@isUTbIi4hpo|TD{qDchbE3o$rsC z%6WI5VaY|ki+CBYczD&vYj|B@>zS38M^{*I!ct@+3SXUeT*=tRiA+&9@rH*teY}O1 z!WXAPJ&UW*7}(+AuHCY+Rk(h9B;6=T61Kls0_${Wo#=5kh)lx$(YZ&bfmN(|c-zM{ ztSemkpJx^32aydQa9h}y+2p5V_HbRH_2l)vduRJjkOu#4@6FaJR-x1GE6jE6kW)I> zGqHS~9Sp?n27`!}<(>_Ls2@0!>r>-gx^EJNwce8&US=>o7zdpSXIFgrGLp=_EQ(#m78*Yo}|fE>#W%A33{Gvh^m TC2rN~B&8+XrfgDvUaS8Lmq7ur literal 0 HcmV?d00001 diff --git a/target/classes/com/allo/finance/strategy/HistoricalFetcher.class b/target/classes/com/allo/finance/strategy/HistoricalFetcher.class new file mode 100644 index 0000000000000000000000000000000000000000..4642cef7c727d0e62a99e1b0721516f9c0280256 GIT binary patch literal 3721 zcmb_f`&ZOP6#ixx2rjAMR;bn%TSR#Y!Dk;UT7e?Ai&{X`zHBx-3z6Mqn`E){{jl%n zpVFSw>Y<+Y^yi-b5B*Pi`b`oc$ZD;4fXrkv_ulW```tUkpa1;&JAe`VkU(E?pyz;Y{wSuVRq$#zV~ zl7=66W+3O6jY-=NT+g=5@`Mbm1?f?fRko!=mB;d~H*fe=&vxcZo>`GsU2oC2Dhq}u zO)IdMq*1CloVbn=$ru-8VJwu+HDjwlc41ZhWxMEIuKC3lv|*cutd57VUEtaKH$hkD zC1K9?_H6=a_q2SoCjDSinnmgP=RJF-DlNkA5a=-u4j()`IDCM=Lqn_cBPU9pTNyb$ zKD{q+N6ycTw_pc$YIsz~E<7f1?*4tMkA|MsJX>I?H@}wnnLy?LQ zl)xiv5zE`oqAX6D{sLo{!BM2M>_*S($l(~E#r(<2p{@IRUNYde58Ifv06KyWnHz&|A<`xOPm5G&SYL$ZYX3fxgzj`hf>Bt~~}z3j0>*5e4@6U6FIP zZr>q)ounI(1jdZ?mTXJXSlg)U20rgI)rfe0YZGc2Ea9q#w{$Gy zZT6NBXy~l%EV+x)$cL$-gjwQM#`TsNThevy?XP6b#zK*SNz*BorQe-*-No9SbDFdk z391rEQed6dyMByJ-d#4?ugxaO~E(Fvs}4pWy}a104le zZTLu_J1JVXyvSV2eWK*9=~#f>hR<#@xlZnO;_0UqmBZ6gm3@2NTN#UD{d#nP(5L0v zVXWaBfxR2@T`zSSz7y!Y`&OQFQJ#7CY33zd;3fWL)xDZ$VI${_nBn>?-zGSwIA;29 zK=cp%0_`WhrEs3ltw{6PiUwYPw_qFQ_|_5a#sysD8|6Z+0+%>%Bp*2lyo@P6mHI)B zf@3=S(2pDEDoA!6Rv`;Bmh1 zS;5{Eu4CX2?8y&g53Jx&3Y<^f#L-JP@bsDegTLY#aQ8?G{64sXlWANSP*BpLi$fve zU3Bs>Y{%og3wPoP>_!hm*N-k7!d~7SdT&W0HJnBD>69wzbw z32dFAb#A6NMaVG3ohXIa_)h}nSFx$^M=KE`7D0@t6eH@miHS@5uVeBC&YW3?BuSF} zq}CwXF_b_N*-t^RZ*o-B@HqORR0;uiyo2}fVLba7 ipW<_V>!SZVNXsvpw$R5me93Vi$FDekjc@TicK!ts$=+#_!01qW5xDeg`$GmyZ z*Zl!tg)u`!NM(ECLhF{7wGl=tesNYfRdsxw?K0s+uADq7%g`rG8gUa`3sdp!p;6Ki zSf$*)J1fF0oo~LpaJDwpk`R|c@Q**|9w$Y6wNmA^_KNB6&qdG;2!x(b1|pvjV}Ovt LF!T#y6m}$U7i2d3 literal 0 HcmV?d00001 diff --git a/target/classes/com/allo/finance/strategy/LatestFetcher.class b/target/classes/com/allo/finance/strategy/LatestFetcher.class new file mode 100644 index 0000000000000000000000000000000000000000..d32ec8f11d2fd84f1806aecc76cd11a1e7e7824a GIT binary patch literal 3891 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.net.URLClassPath.disableClassPathURLCheck=true +'other' has different root + diff --git a/target/surefire-reports/TEST-com.allo.finance.SpreadTest.xml b/target/surefire-reports/TEST-com.allo.finance.SpreadTest.xml new file mode 100644 index 00000000..36dcf594 --- /dev/null +++ b/target/surefire-reports/TEST-com.allo.finance.SpreadTest.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/target/surefire-reports/TEST-com.allo.test.unit.SpreadUtilTest.xml b/target/surefire-reports/TEST-com.allo.test.unit.SpreadUtilTest.xml new file mode 100644 index 00000000..8f0084f7 --- /dev/null +++ b/target/surefire-reports/TEST-com.allo.test.unit.SpreadUtilTest.xml @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/target/surefire-reports/com.allo.finance.SpreadTest.txt b/target/surefire-reports/com.allo.finance.SpreadTest.txt new file mode 100644 index 00000000..2faadcec --- /dev/null +++ b/target/surefire-reports/com.allo.finance.SpreadTest.txt @@ -0,0 +1,4 @@ +------------------------------------------------------------------------------- +Test set: com.allo.finance.SpreadTest +------------------------------------------------------------------------------- +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.166 s -- in com.allo.finance.SpreadTest diff --git a/target/surefire-reports/com.allo.test.unit.SpreadUtilTest.txt b/target/surefire-reports/com.allo.test.unit.SpreadUtilTest.txt new file mode 100644 index 00000000..aaaef409 --- /dev/null +++ b/target/surefire-reports/com.allo.test.unit.SpreadUtilTest.txt @@ -0,0 +1,4 @@ +------------------------------------------------------------------------------- +Test set: com.allo.test.unit.SpreadUtilTest +------------------------------------------------------------------------------- +Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.425 s -- in com.allo.test.unit.SpreadUtilTest diff --git a/target/test-classes/com/allo/finance/SpreadTest.class b/target/test-classes/com/allo/finance/SpreadTest.class new file mode 100644 index 0000000000000000000000000000000000000000..d6fc0f4da84639c2c5faaab4cf4eea6bb6cd7051 GIT binary patch literal 809 zcmaJ<%Wl&^6g?9=aY>w}4F%dXEulP`q)>lADQX2m3JM@KQ5Nhbj!7msc4UwF1=g%s z@BysZ@LKQzd?VjX#M=5zwhI{qD%u!eODRU4PFAyE5wDMiG|ER)hHocx+{22jN&wk*_aY@;sF z2&1txO{kf6uCy09a;}`yIQAl~!hm{;GIRP-;t80|7p;MXU4d;koH^3>L+7OmWZ-&E zZ%%=uz87;3=EHEr@=8|)-m_#j^rF5T`ixY&p)37?j8y6`gQlJ+GVOH#gLyOTw+QB+zQ+4OFZs<1`3DEtm7wyMNzF zI#?>A7bcPGJyz*7m5Z4VQysX09lnwD%Yj#l0_}Br4o|@|-~J5oF-tdS(==eQ4X_sl z*uzbFz%6ETS@T<%gaY=fR`=i&E`3Es;JChS{G7kpz?JYxYxUiGSmpm;SDrs zuV!r}dg-$(^XgY|2bGMjO;AEE=?v1{W{#^ujW27T)SIkqqQ&kuD{bV?v1g$uj1z$a PMpE&2c?#U4&EfDj5@oM0 literal 0 HcmV?d00001 diff --git a/target/test-classes/com/allo/test/unit/SpreadUtilTest.class b/target/test-classes/com/allo/test/unit/SpreadUtilTest.class new file mode 100644 index 0000000000000000000000000000000000000000..03589b3e689658601867871690919f465ea383f4 GIT binary patch literal 908 zcmah{(QXn!6g|VP?7F4UQmIy3p|&a%?Z!ug8XKD?CTNp_B>FZiQ#y3nC9|{o0e*xJ zet^%u*hUjSz<<(s2QX4%;%;(h=FYw6oO|c~`1SQWfXDDXWMDb4bGU>o!_t7i<$lPc zzJJ&qh(I%BAIV7SCk$4#)^Xr6Yy|Pp=V2K8S|r*}wG92%ND1D1MOzOJEEfw5Zkh-c z@u6_BNH`~4_N4Mqz>52KGoChonNrQJbg?d_uU1 z&<9e4J%)$X=Dgy@jHjgy@T_N2RioB1GD|tE;wnQS;9-!4T#HE=<68GUtl_$Y8#%0F zgP~%S4pQm`*^$&j`Fte(y@ZN10!f(1$A`8`1%q9EWBOHQ*qrN^f%w}mQy4`*i+hw* zXv#>uOov^e+PoW*r`U{1U56`a&QqtYPbBeeHUEp;AXVm-{uE}L*J3EoXI^|m%E~H{ zq#-F|`wWXM&4beyd^F`>J4z!hhoU1BNviguDAs)1g!SgX8)fP=YFcrs0&yTsaVr&42g^$NmJ{I>$AGZH=*4i5|$qHA72(3Co0C9CKAD thVZ6bE)B^Y+$H`J#a2-x-W|GMK;|dP4jk(#L!BH(?mb!=?vu@6_cv3i*`NRb literal 0 HcmV?d00001 diff --git a/target/test-classes/com/allo/test/util/SpreadUtil.class b/target/test-classes/com/allo/test/util/SpreadUtil.class new file mode 100644 index 0000000000000000000000000000000000000000..2b0785f997c6cbc9e9f457870162cabe7ec8c15f GIT binary patch literal 787 zcmaJ<+invv5IxRq+gwOnN-vbQTv|x-P`~h&f6Q{d&v3#txal$F1iTi%v*bz^s zzU5;}v%EI;goje-lpl-88GFXX-vT}qaVERe`l)wl?wvOy<;GdK_eYDj45b5eM6qUH zY56QmLTO(K;`J(-NW?>7l|P^VEV;3wMz;I^Rhu}QR2yZnaB_AD`J7J}SdXDPFqw_y zQ{`!z7nii>#bW4OI%VplmByV-B|k99)M%+)7q{pU6ms1P{uz*d8+59Xy+La;#TJE0 z0aR$;rVuB~$d)^w!9I{vz)jkH7zhXtISM7drA6)Zr@b5^4eW@|srOx9_42{05y&w-EpU literal 0 HcmV?d00001 From 2d6d46cd513905c922a4111e5bb29645b1fd0a37 Mon Sep 17 00:00:00 2001 From: haidir Date: Fri, 3 Apr 2026 10:21:08 +0700 Subject: [PATCH 4/6] feat: improve strategy implementation, add unit tests, and configure .gitignore --- .gitignore | 72 +++++++++++++++++++ pom.xml | 4 ++ ...stFetcher.java => LatestRatesFetcher.java} | 18 ++--- .../com/allo/finance/util/SpreadUtil.java | 15 ++++ src/main/resources/application.yml | 4 +- .../java/com/allo/finance/SpreadTest.java | 16 ----- .../integration/FinanceControllerIT.java | 24 +++++++ .../com/allo/test/unit/SpreadUtilTest.java | 27 +++++-- .../allo/test/unit/runner/DataLoaderTest.java | 40 +++++++++++ .../unit/strategy/CurrencyFetcherTest.java | 50 +++++++++++++ .../unit/strategy/HistoricalFetcherTest.java | 50 +++++++++++++ .../unit/strategy/LatestRatesFetcherTest.java | 54 ++++++++++++++ .../com/allo/test/unit/util/SpreadUtil.java | 2 +- 13 files changed, 344 insertions(+), 32 deletions(-) create mode 100644 .gitignore rename src/main/java/com/allo/finance/strategy/{LatestFetcher.java => LatestRatesFetcher.java} (71%) create mode 100644 src/main/java/com/allo/finance/util/SpreadUtil.java delete mode 100644 src/test/java/com/allo/finance/SpreadTest.java create mode 100644 src/test/java/com/allo/finance/integration/FinanceControllerIT.java create mode 100644 src/test/java/com/allo/test/unit/runner/DataLoaderTest.java create mode 100644 src/test/java/com/allo/test/unit/strategy/CurrencyFetcherTest.java create mode 100644 src/test/java/com/allo/test/unit/strategy/HistoricalFetcherTest.java create mode 100644 src/test/java/com/allo/test/unit/strategy/LatestRatesFetcherTest.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..bfce9f2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,72 @@ +# ======================== +# BUILD FILES +# ======================== +/target/ +*.class +*.jar +*.war +*.ear + +# ======================== +# LOG FILES +# ======================== +*.log + +# ======================== +# IDE - IntelliJ +# ======================== +.idea/ +*.iws +*.iml +*.ipr +out/ + +# ======================== +# IDE - Eclipse +# ======================== +.project +.classpath +.settings/ + +# ======================== +# IDE - VS Code +# ======================== +.vscode/ + +# ======================== +# OS FILES +# ======================== +.DS_Store +Thumbs.db + +# ======================== +# ENV FILES +# ======================== +.env +.env.* + +# ======================== +# MAVEN +# ======================== +.mvn/wrapper/maven-wrapper.jar +.mvn/wrapper/maven-wrapper.properties + +# ======================== +# SPRING BOOT +# ======================== +/logs/ +*.pid + +# ======================== +# TEST OUTPUT +# ======================== +/surefire-reports/ +/failsafe-reports/ + +# ======================== +# TEMP FILES +# ======================== +*.tmp +*.swp +*.lst +*.txt \ No newline at end of file diff --git a/pom.xml b/pom.xml index d8b00577..7406ca12 100644 --- a/pom.xml +++ b/pom.xml @@ -11,6 +11,10 @@ + + org.springframework.boot + spring-boot-starter-web + org.springframework.boot spring-boot-starter-webflux diff --git a/src/main/java/com/allo/finance/strategy/LatestFetcher.java b/src/main/java/com/allo/finance/strategy/LatestRatesFetcher.java similarity index 71% rename from src/main/java/com/allo/finance/strategy/LatestFetcher.java rename to src/main/java/com/allo/finance/strategy/LatestRatesFetcher.java index 974a155b..65c42c37 100644 --- a/src/main/java/com/allo/finance/strategy/LatestFetcher.java +++ b/src/main/java/com/allo/finance/strategy/LatestRatesFetcher.java @@ -1,23 +1,25 @@ package com.allo.finance.strategy; +import com.allo.finance.util.SpreadUtil; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; + import java.math.BigDecimal; +import java.math.RoundingMode; import java.util.LinkedHashMap; import java.util.Map; @Service -public class LatestFetcher implements IDRDataFetcher { +public class LatestRatesFetcher implements IDRDataFetcher { private final WebClient client; + private final SpreadUtil spreadUtil; - @Value("${app.github-username}") - private String username; - - public LatestFetcher(WebClient client) { + public LatestRatesFetcher(WebClient client, SpreadUtil spreadUtil) { this.client = client; + this.spreadUtil = spreadUtil; } @Override @@ -46,12 +48,12 @@ public Object fetch() { double usd = rates.get("USD"); - int sum = username.chars().sum(); - double spread = (sum % 1000) / 100000.0; + double spread = spreadUtil.calculateSpread(); double calc = (1 / usd) * (1 + spread); - BigDecimal spreadResult = new BigDecimal(String.valueOf(calc)); + BigDecimal spreadResult = new BigDecimal(calc) + .setScale(2, RoundingMode.HALF_UP); res.put("USD_BuySpread_IDR", spreadResult); diff --git a/src/main/java/com/allo/finance/util/SpreadUtil.java b/src/main/java/com/allo/finance/util/SpreadUtil.java new file mode 100644 index 00000000..89c34db3 --- /dev/null +++ b/src/main/java/com/allo/finance/util/SpreadUtil.java @@ -0,0 +1,15 @@ + +package com.allo.finance.util; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class SpreadUtil { + @Value("${github.username}") + private String username; + + public double calculateSpread() { + int sum = username.chars().sum(); + return (sum % 1000) / 100000.0; + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 65700198..a7ef459e 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,5 +3,5 @@ external: api: base-url: https://api.frankfurter.app -app: - github-username: haidir +github: + username: haidir diff --git a/src/test/java/com/allo/finance/SpreadTest.java b/src/test/java/com/allo/finance/SpreadTest.java deleted file mode 100644 index 24ce6ada..00000000 --- a/src/test/java/com/allo/finance/SpreadTest.java +++ /dev/null @@ -1,16 +0,0 @@ - -package com.allo.finance; - -import org.junit.jupiter.api.Test; -import static org.junit.jupiter.api.Assertions.*; - -public class SpreadTest { - - @Test - void testSpread(){ - String user = "haidir"; - int sum = user.chars().sum(); - double spread = (sum % 1000) / 100000.0; - assertTrue(spread >= 0); - } -} diff --git a/src/test/java/com/allo/finance/integration/FinanceControllerIT.java b/src/test/java/com/allo/finance/integration/FinanceControllerIT.java new file mode 100644 index 00000000..5cbb3d83 --- /dev/null +++ b/src/test/java/com/allo/finance/integration/FinanceControllerIT.java @@ -0,0 +1,24 @@ +package com.allo.finance.integration; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +class FinanceControllerIT { + + @Autowired + private MockMvc mockMvc; + + @Test + void shouldReturnLatestRates() throws Exception { + mockMvc.perform(get("/api/finance/data/latest_idr_rates")) + .andExpect(status().isOk()); + } +} \ No newline at end of file diff --git a/src/test/java/com/allo/test/unit/SpreadUtilTest.java b/src/test/java/com/allo/test/unit/SpreadUtilTest.java index 8cee5ef1..83ffb49e 100644 --- a/src/test/java/com/allo/test/unit/SpreadUtilTest.java +++ b/src/test/java/com/allo/test/unit/SpreadUtilTest.java @@ -1,7 +1,6 @@ - package com.allo.test.unit; -import com.allo.test.util.SpreadUtil; +import com.allo.finance.util.SpreadUtil; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; @@ -10,10 +9,28 @@ class SpreadUtilTest { @Test - void testSpread(){ + void shouldCalculateSpreadCorrectly() { SpreadUtil util = new SpreadUtil(); - ReflectionTestUtils.setField(util,"username","haidir"); + + ReflectionTestUtils.setField(util, "username", "haidir"); + double result = util.calculateSpread(); + + int sum = "haidir".chars().sum(); + double expected = (sum % 1000) / 100000.0; + + assertEquals(expected, result); + } + + @Test + void shouldReturnSpreadWithinRange() { + SpreadUtil util = new SpreadUtil(); + + ReflectionTestUtils.setField(util, "username", "haidir"); + + double result = util.calculateSpread(); + assertTrue(result >= 0); + assertTrue(result < 0.01); } -} +} \ No newline at end of file diff --git a/src/test/java/com/allo/test/unit/runner/DataLoaderTest.java b/src/test/java/com/allo/test/unit/runner/DataLoaderTest.java new file mode 100644 index 00000000..51468a36 --- /dev/null +++ b/src/test/java/com/allo/test/unit/runner/DataLoaderTest.java @@ -0,0 +1,40 @@ +package com.allo.test.unit.runner; + +import com.allo.finance.runner.DataLoader; +import com.allo.finance.strategy.IDRDataFetcher; +import com.allo.finance.store.DataStore; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.*; + +import java.util.List; +import java.util.Map; + +import static org.mockito.Mockito.*; + +class DataLoaderTest { + + @Mock DataStore store; + @Mock IDRDataFetcher fetcher; + + @InjectMocks + DataLoader loader; + + @BeforeEach + void setup() { + MockitoAnnotations.openMocks(this); + } + + @Test + void shouldLoadDataIntoStore() throws Exception { + + when(fetcher.getType()).thenReturn("test"); + when(fetcher.fetch()).thenReturn(Map.of("key", "value")); + + loader = new DataLoader(List.of(fetcher), store); + + loader.run(null); + + verify(store).setAll(anyMap()); + } +} \ No newline at end of file diff --git a/src/test/java/com/allo/test/unit/strategy/CurrencyFetcherTest.java b/src/test/java/com/allo/test/unit/strategy/CurrencyFetcherTest.java new file mode 100644 index 00000000..9efcf36a --- /dev/null +++ b/src/test/java/com/allo/test/unit/strategy/CurrencyFetcherTest.java @@ -0,0 +1,50 @@ +package com.allo.test.unit.strategy; + +import com.allo.finance.strategy.CurrencyFetcher; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.*; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class CurrencyFetcherTest { + + @Mock WebClient webClient; + @Mock WebClient.RequestHeadersUriSpec uriSpec; + @Mock WebClient.RequestHeadersSpec headersSpec; + @Mock WebClient.ResponseSpec responseSpec; + + @InjectMocks + CurrencyFetcher fetcher; + + @BeforeEach + void setup() { + MockitoAnnotations.openMocks(this); + } + + @Test + void shouldFetchCurrencies() { + + Map mock = Map.of("USD", "Dollar"); + + when(webClient.get()).thenReturn(uriSpec); + when(uriSpec.uri(anyString())).thenReturn(headersSpec); + when(headersSpec.retrieve()).thenReturn(responseSpec); + + // 🔥 FIX FINAL + when(responseSpec.bodyToMono(Mockito.any(Class.class))) + .thenReturn(Mono.just(mock)); + + Object result = fetcher.fetch(); + + assertEquals(mock, result); + + verify(webClient).get(); + verify(uriSpec).uri(anyString()); + } +} \ No newline at end of file diff --git a/src/test/java/com/allo/test/unit/strategy/HistoricalFetcherTest.java b/src/test/java/com/allo/test/unit/strategy/HistoricalFetcherTest.java new file mode 100644 index 00000000..aa7df105 --- /dev/null +++ b/src/test/java/com/allo/test/unit/strategy/HistoricalFetcherTest.java @@ -0,0 +1,50 @@ +package com.allo.test.unit.strategy; + +import com.allo.finance.strategy.HistoricalFetcher; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.*; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class HistoricalFetcherTest { + + @Mock WebClient webClient; + @Mock WebClient.RequestHeadersUriSpec uriSpec; + @Mock WebClient.RequestHeadersSpec headersSpec; + @Mock WebClient.ResponseSpec responseSpec; + + @InjectMocks + HistoricalFetcher fetcher; + + @BeforeEach + void setup() { + MockitoAnnotations.openMocks(this); + } + + @Test + void shouldFetchHistoricalData() { + + Map rates = new HashMap<>(); + rates.put("2024-01-01", Map.of("USD", 0.000065)); + + Map mock = new HashMap<>(); + mock.put("rates", rates); + + when(webClient.get()).thenReturn(uriSpec); + when(uriSpec.uri(anyString())).thenReturn(headersSpec); + when(headersSpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.bodyToMono(Map.class)) + .thenReturn(Mono.just(mock)); + + Object result = fetcher.fetch(); + + assertEquals(mock, result); + } +} \ No newline at end of file diff --git a/src/test/java/com/allo/test/unit/strategy/LatestRatesFetcherTest.java b/src/test/java/com/allo/test/unit/strategy/LatestRatesFetcherTest.java new file mode 100644 index 00000000..81b22fa5 --- /dev/null +++ b/src/test/java/com/allo/test/unit/strategy/LatestRatesFetcherTest.java @@ -0,0 +1,54 @@ +package com.allo.test.unit.strategy; + +import com.allo.finance.strategy.LatestRatesFetcher; +import com.allo.finance.util.SpreadUtil; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.*; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class LatestRatesFetcherTest { + + @Mock WebClient webClient; + @Mock WebClient.RequestHeadersUriSpec uriSpec; + @Mock WebClient.RequestHeadersSpec headersSpec; + @Mock WebClient.ResponseSpec responseSpec; + @Mock SpreadUtil spreadUtil; + + @InjectMocks + LatestRatesFetcher fetcher; + + @BeforeEach + void setup() { + MockitoAnnotations.openMocks(this); + } + + @Test + void shouldCalculateUsdBuySpreadCorrectly() { + + Map mock = new HashMap<>(); + mock.put("rates", Map.of("USD", 0.000065)); + + when(webClient.get()).thenReturn(uriSpec); + when(uriSpec.uri(anyString())).thenReturn(headersSpec); + when(headersSpec.retrieve()).thenReturn(responseSpec); + when(responseSpec.bodyToMono(Map.class)) + .thenReturn(Mono.just(mock)); + when(spreadUtil.calculateSpread()).thenReturn(0.001); + + Map result = (Map) fetcher.fetch(); + + double expected = (1 / 0.000065) * (1 + 0.001); + + assertEquals(expected, result.get("USD_BuySpread_IDR")); + + verify(webClient).get(); + } +} \ No newline at end of file diff --git a/src/test/java/com/allo/test/unit/util/SpreadUtil.java b/src/test/java/com/allo/test/unit/util/SpreadUtil.java index c95eb8a7..9c9e6825 100644 --- a/src/test/java/com/allo/test/unit/util/SpreadUtil.java +++ b/src/test/java/com/allo/test/unit/util/SpreadUtil.java @@ -1,5 +1,5 @@ -package com.allo.test.util; +package com.allo.test.unit.util; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; From 1c15b5c2d882715ff0e797882ba9ddbec3c1840e Mon Sep 17 00:00:00 2001 From: haidir Date: Fri, 3 Apr 2026 11:16:46 +0700 Subject: [PATCH 5/6] feat: add error handling, timeout support, and standardized API response mapping --- .../finance/controller/FinanceController.java | 21 +++++--- .../com/allo/finance/dto/ApiResponse.java | 20 ++++++++ .../exception/GlobalExceptionHandler.java | 22 ++++++++ .../finance/strategy/CurrencyFetcher.java | 18 ++++++- .../finance/strategy/HistoricalFetcher.java | 47 +++++++++++------- .../finance/strategy/LatestRatesFetcher.java | 46 ++++++++++------- .../unit/strategy/CurrencyFetcherTest.java | 2 +- .../unit/strategy/LatestRatesFetcherTest.java | 8 ++- .../com/allo/test/unit/util/SpreadUtil.java | 15 ------ target/finance-0.0.1-SNAPSHOT.jar | Bin 12456 -> 0 bytes 10 files changed, 134 insertions(+), 65 deletions(-) create mode 100644 src/main/java/com/allo/finance/dto/ApiResponse.java create mode 100644 src/main/java/com/allo/finance/exception/GlobalExceptionHandler.java delete mode 100644 src/test/java/com/allo/test/unit/util/SpreadUtil.java delete mode 100644 target/finance-0.0.1-SNAPSHOT.jar diff --git a/src/main/java/com/allo/finance/controller/FinanceController.java b/src/main/java/com/allo/finance/controller/FinanceController.java index ba50b7ec..0cff0f08 100644 --- a/src/main/java/com/allo/finance/controller/FinanceController.java +++ b/src/main/java/com/allo/finance/controller/FinanceController.java @@ -1,21 +1,26 @@ package com.allo.finance.controller; +import com.allo.finance.dto.ApiResponse; import com.allo.finance.store.DataStore; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import java.util.List; + @RestController -@RequestMapping("/api/finance") +@RequestMapping("/api/finance/data") public class FinanceController { - private final DataStore store; + private final DataStore dataStore; - public FinanceController(DataStore store){ - this.store = store; + public FinanceController(DataStore dataStore) { + this.dataStore = dataStore; } - @GetMapping("/data/{type}") - public ResponseEntity get(@PathVariable String type){ - return ResponseEntity.ok(store.get(type)); + @GetMapping("/{type}") + public List get(@PathVariable String type) { + + Object data = dataStore.get(type); + + return List.of(new ApiResponse(type, data)); } } \ No newline at end of file diff --git a/src/main/java/com/allo/finance/dto/ApiResponse.java b/src/main/java/com/allo/finance/dto/ApiResponse.java new file mode 100644 index 00000000..2d380fb2 --- /dev/null +++ b/src/main/java/com/allo/finance/dto/ApiResponse.java @@ -0,0 +1,20 @@ +package com.allo.finance.dto; + +public class ApiResponse { + + private String resourceType; + private Object data; + + public ApiResponse(String resourceType, Object data) { + this.resourceType = resourceType; + this.data = data; + } + + public String getResourceType() { + return resourceType; + } + + public Object getData() { + return data; + } +} \ No newline at end of file diff --git a/src/main/java/com/allo/finance/exception/GlobalExceptionHandler.java b/src/main/java/com/allo/finance/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..b1c5461a --- /dev/null +++ b/src/main/java/com/allo/finance/exception/GlobalExceptionHandler.java @@ -0,0 +1,22 @@ +package com.allo.finance.exception; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.Map; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception ex) { + + return ResponseEntity.internalServerError().body( + Map.of( + "error", "Internal Server Error", + "message", ex.getMessage() + ) + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/allo/finance/strategy/CurrencyFetcher.java b/src/main/java/com/allo/finance/strategy/CurrencyFetcher.java index f51bf66d..6da2b467 100644 --- a/src/main/java/com/allo/finance/strategy/CurrencyFetcher.java +++ b/src/main/java/com/allo/finance/strategy/CurrencyFetcher.java @@ -1,6 +1,9 @@ package com.allo.finance.strategy; +import java.time.Duration; +import java.util.Map; + import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; @@ -16,7 +19,18 @@ public CurrencyFetcher(WebClient client) { public String getType() { return "supported_currencies"; } public Object fetch() { - return client.get().uri("/currencies") - .retrieve().bodyToMono(Object.class).block(); + try { + return client.get().uri("/currencies") + .retrieve() + .bodyToMono(Object.class) + .timeout(Duration.ofSeconds(10)) + .block(); + } catch (Exception e) { + + return Map.of( + "error", "Failed to fetch latest rates", + "message", e.getMessage() + ); + } } } diff --git a/src/main/java/com/allo/finance/strategy/HistoricalFetcher.java b/src/main/java/com/allo/finance/strategy/HistoricalFetcher.java index 5f6c6e9f..85c28cc3 100644 --- a/src/main/java/com/allo/finance/strategy/HistoricalFetcher.java +++ b/src/main/java/com/allo/finance/strategy/HistoricalFetcher.java @@ -4,6 +4,7 @@ import org.springframework.web.reactive.function.client.WebClient; import java.math.BigDecimal; +import java.time.Duration; import java.util.LinkedHashMap; import java.util.Map; @@ -23,34 +24,42 @@ public String getType() { @Override public Object fetch() { + try { - Map res = client.get() - .uri("/2024-01-01..2024-01-05?from=IDR&to=USD") - .retrieve() - .bodyToMono(Map.class) - .block(); + Map res = client.get() + .uri("/2024-01-01..2024-01-05?from=IDR&to=USD") + .retrieve() + .bodyToMono(Map.class) + .timeout(Duration.ofSeconds(10)) + .block(); - Map> rates = - (Map>) res.get("rates"); + Map> rates = + (Map>) res.get("rates"); - // ✅ FIX: convert nested map - Map formattedRates = new LinkedHashMap<>(); + Map formattedRates = new LinkedHashMap<>(); - rates.forEach((date, currencyMap) -> { + rates.forEach((date, currencyMap) -> { - Map inner = new LinkedHashMap<>(); + Map inner = new LinkedHashMap<>(); - currencyMap.forEach((currency, value) -> { - BigDecimal bd = new BigDecimal(value.toString()); - inner.put(currency, bd); + currencyMap.forEach((currency, value) -> { + BigDecimal bd = new BigDecimal(value.toString()); + inner.put(currency, bd); + }); + + formattedRates.put(date, inner); }); - formattedRates.put(date, inner); - }); + res.put("rates", formattedRates); - // replace - res.put("rates", formattedRates); + return res; + + } catch (Exception e) { - return res; + return Map.of( + "error", "Failed to fetch latest rates", + "message", e.getMessage() + ); + } } } \ No newline at end of file diff --git a/src/main/java/com/allo/finance/strategy/LatestRatesFetcher.java b/src/main/java/com/allo/finance/strategy/LatestRatesFetcher.java index 65c42c37..6b39363a 100644 --- a/src/main/java/com/allo/finance/strategy/LatestRatesFetcher.java +++ b/src/main/java/com/allo/finance/strategy/LatestRatesFetcher.java @@ -8,6 +8,7 @@ import java.math.BigDecimal; import java.math.RoundingMode; +import java.time.Duration; import java.util.LinkedHashMap; import java.util.Map; @@ -30,33 +31,40 @@ public String getType() { @Override public Object fetch() { - Map res = client.get() - .uri("/latest?base=IDR") - .retrieve() - .bodyToMono(Map.class) - .block(); + try { - Map rates = (Map) res.get("rates"); + Map res = client.get() + .uri("/latest?base=IDR") + .retrieve() + .bodyToMono(Map.class) + .timeout(Duration.ofSeconds(10)) + .block(); - Map formattedRates = new LinkedHashMap<>(); - rates.forEach((k, v) -> { - BigDecimal bd = new BigDecimal(v.toString()); - formattedRates.put(k, bd); - }); + if (res == null || res.get("rates") == null) { + throw new RuntimeException("Invalid response from API"); + } - res.put("rates", formattedRates); + Map rates = (Map) res.get("rates"); - double usd = rates.get("USD"); + double usd = rates.get("USD"); - double spread = spreadUtil.calculateSpread(); + double spread = spreadUtil.calculateSpread(); - double calc = (1 / usd) * (1 + spread); + double calc = (1 / usd) * (1 + spread); - BigDecimal spreadResult = new BigDecimal(calc) - .setScale(2, RoundingMode.HALF_UP); + BigDecimal result = BigDecimal.valueOf(calc) + .setScale(2, RoundingMode.HALF_UP); - res.put("USD_BuySpread_IDR", spreadResult); + res.put("USD_BuySpread_IDR", result); - return res; + return res; + + } catch (Exception e) { + + return Map.of( + "error", "Failed to fetch latest rates", + "message", e.getMessage() + ); + } } } \ No newline at end of file diff --git a/src/test/java/com/allo/test/unit/strategy/CurrencyFetcherTest.java b/src/test/java/com/allo/test/unit/strategy/CurrencyFetcherTest.java index 9efcf36a..74721102 100644 --- a/src/test/java/com/allo/test/unit/strategy/CurrencyFetcherTest.java +++ b/src/test/java/com/allo/test/unit/strategy/CurrencyFetcherTest.java @@ -36,7 +36,6 @@ void shouldFetchCurrencies() { when(uriSpec.uri(anyString())).thenReturn(headersSpec); when(headersSpec.retrieve()).thenReturn(responseSpec); - // 🔥 FIX FINAL when(responseSpec.bodyToMono(Mockito.any(Class.class))) .thenReturn(Mono.just(mock)); @@ -46,5 +45,6 @@ void shouldFetchCurrencies() { verify(webClient).get(); verify(uriSpec).uri(anyString()); + verify(responseSpec).bodyToMono(any(Class.class)); } } \ No newline at end of file diff --git a/src/test/java/com/allo/test/unit/strategy/LatestRatesFetcherTest.java b/src/test/java/com/allo/test/unit/strategy/LatestRatesFetcherTest.java index 81b22fa5..679205f6 100644 --- a/src/test/java/com/allo/test/unit/strategy/LatestRatesFetcherTest.java +++ b/src/test/java/com/allo/test/unit/strategy/LatestRatesFetcherTest.java @@ -8,6 +8,7 @@ import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; +import java.math.BigDecimal; import java.util.HashMap; import java.util.Map; @@ -47,8 +48,13 @@ void shouldCalculateUsdBuySpreadCorrectly() { double expected = (1 / 0.000065) * (1 + 0.001); - assertEquals(expected, result.get("USD_BuySpread_IDR")); + assertEquals(expected, + ((BigDecimal) result.get("USD_BuySpread_IDR")).doubleValue(), + 0.01 + ); verify(webClient).get(); + verify(uriSpec).uri(anyString()); + verify(responseSpec).bodyToMono(any(Class.class)); } } \ No newline at end of file diff --git a/src/test/java/com/allo/test/unit/util/SpreadUtil.java b/src/test/java/com/allo/test/unit/util/SpreadUtil.java deleted file mode 100644 index 9c9e6825..00000000 --- a/src/test/java/com/allo/test/unit/util/SpreadUtil.java +++ /dev/null @@ -1,15 +0,0 @@ - -package com.allo.test.unit.util; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.stereotype.Component; - -@Component -public class SpreadUtil { - @Value("${github.username}") - private String username; - - public double calculateSpread() { - int sum = username.chars().sum(); - return (sum % 1000) / 100000.0; - } -} diff --git a/target/finance-0.0.1-SNAPSHOT.jar b/target/finance-0.0.1-SNAPSHOT.jar deleted file mode 100644 index 9c52f119cd2bfce24dc6bbceb0f6127e135b4213..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 12456 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 Date: Fri, 3 Apr 2026 11:20:01 +0700 Subject: [PATCH 6/6] docs: update README with API usage, architecture explanation, and setup instructions --- README.md | 171 +++++++++++++++++++++++++++++------------------------- 1 file changed, 91 insertions(+), 80 deletions(-) diff --git a/README.md b/README.md index 5e58ae2a..e5daf34b 100644 --- a/README.md +++ b/README.md @@ -1,139 +1,150 @@ -# Allo Bank Backend Developer Take-Home Test +## Ringkasan -Thank you for applying to our team! This take-home test is designed to evaluate your practical skills in building **production-ready** Spring Boot applications within a finance domain, focusing on architectural patterns and complex data handling. +PR ini berisi implementasi REST API untuk mengagregasi data nilai tukar IDR dari Frankfurter API sesuai dengan requirement. -## 📝 Objective +Data yang disediakan mencakup: -Your task is to create a single Spring Boot REST API endpoint capable of aggregating data from multiple, distinct resources provided by the public, keyless **Frankfurter Exchange Rate API**. The primary focus is on handling Indonesian Rupiah (IDR) data. +* Kurs terbaru terhadap IDR +* Data historis IDR ke USD +* Daftar mata uang yang tersedia -The focus of this test is not just functional correctness, but demonstrating clean code, advanced Spring concepts, thread-safe design, and architectural clarity. +Seluruh data diambil satu kali saat aplikasi start dan disimpan di memory, sehingga endpoint tidak melakukan request ulang ke API eksternal. -## I. Core Task: The Polymorphic API +--- -### 1. External API Integration (Frankfurter API) +## Fitur yang Diimplementasikan -* **Base URL (Public):** `https://api.frankfurter.app/`. +* Endpoint: -* You must integrate with three distinct data resources to enforce the architectural pattern: + * `GET /api/finance/data/{resourceType}` - 1. `/latest?base=IDR` (The latest rates relative to IDR) +* Resource yang didukung: - 2. **Historical Data:** Query a specific, small time series (e.g., `/2024-01-01..2024-01-05?from=IDR&to=USD`). **Note:** *Use the date range provided in this example unless a different range is communicated separately.* + * `latest_idr_rates` + * `historical_idr_usd` + * `supported_currencies` - 3. `/currencies` (The list of all supported currency symbols) +* Penambahan field: -### 2. Internal API Endpoint + * `USD_BuySpread_IDR` (khusus latest rate) -You must expose **one single endpoint** in your application: ```GET /api/finance/data/{resourceType}``` +--- -Where `{resourceType}` can be one of the three strings: `latest_idr_rates`, `historical_idr_usd`, or `supported_currencies`. +## Pendekatan Arsitektur -### 3. Required Functionality & Business Logic +### Strategy Pattern -* **Resource Handling:** Your service must correctly map the three incoming `resourceType` values to the correct data fetching strategies. +Digunakan untuk memisahkan logic pengambilan data berdasarkan resource. -* **Data Load:** All three resources should be fetched from the external API. +Setiap resource memiliki implementasi sendiri dari interface `IDRDataFetcher`, sehingga tidak perlu menggunakan if/else di controller. -* **Data Transformation (Latest IDR Rates only) - Unique Calculation:** For the **`latest_idr_rates`** resource, you must calculate and include a new field, `"USD_BuySpread_IDR"`. This is the Rupiah selling rate to USD after applying a banking spread/margin. +Pendekatan ini memudahkan jika ingin menambahkan resource baru ke depannya. - **The Spread Factor Must Be Unique :** +--- - 1. **Input:** Your GitHub username (e.g., `johndoe47`). - 2. **Calculation:** Calculate the sum of the Unicode (ASCII) values of all characters in your lowercase GitHub username string. - 3. **Spread Factor Derivation:** `Spread Factor = (Sum of Unicode Values % 1000) / 100000.0` - *(This will yield a unique factor between 0.00000 and 0.00999, ensuring a personalized result.)* +### FactoryBean untuk WebClient - **Final Formula:** `USD_BuySpread_IDR = (1 / Rate_USD) * (1 + Spread Factor)` (where `Rate_USD` is the value from the API when `base=IDR`). +WebClient dibuat menggunakan FactoryBean agar konfigurasi seperti base URL dan timeout terpusat. -* **Other Resources:** The `historical_idr_usd` and `supported_currencies` resources can return their data with minimal transformation, but the final output must be a unified JSON array of results. +Dengan cara ini, pembuatan client tidak tersebar di berbagai class dan lebih mudah dikelola. -## II. Architectural Constraints +--- -Meeting the core task is only one part of the solution. The following constraints must be strictly adhered to and will be heavily weighted during evaluation: +### ApplicationRunner -### Constraint A: The Strategy Pattern +Digunakan untuk load data dari external API saat aplikasi start. -The logic for handling the three different resources (`latest_idr_rates`, `historical_idr_usd`, `supported_currencies`) must be implemented using the **Strategy Design Pattern**. +Alasan penggunaan: -1. Define a clear **Strategy Interface** (e.g., `IDRDataFetcher`). +* memastikan data hanya diambil sekali +* menghindari pemanggilan API berulang +* memastikan semua dependency sudah siap sebelum proses fetch -2. Implement **three concrete strategy classes** (one for each resource). +--- -3. The main `Controller` should dynamically select the correct strategy implementation using a map-based lookup injected by Spring, avoiding any manual `if/else` or `switch` logic in the controller layer. +## Response Design -### Constraint B: Client Factory Bean +Response dibuat konsisten menggunakan wrapper: -The instance of your chosen external API client (`WebClient` or `RestTemplate`) **must be defined and created within a custom implementation of Spring's `FactoryBean` interface**. +```json +[ + { + "resourceType": "latest_idr_rates", + "data": { ... } + } +] +``` -* This `FactoryBean` should be responsible for externalizing the API Base URL via `@Value` or `@ConfigurationProperties` and applying any initial configuration (e.g., timeouts, shared headers). +Pendekatan ini memudahkan parsing di sisi client dan menjaga konsistensi antar endpoint. -* ***You may not define the client as a simple `@Bean` in a `@Configuration` class.*** +--- -### Constraint C: Startup Data Runner & Immutability +## Error Handling & Timeout -The aggregated data for **ALL three resources** must be fetched **exactly once on application startup** and loaded into an in-memory store. +* Menambahkan timeout pada pemanggilan WebClient +* Menangani error dari external API secara graceful +* Menambahkan GlobalExceptionHandler untuk menangani error umum -1. Use a Spring Boot **`ApplicationRunner`** or **`CommandLineRunner`** component to initiate the data fetching process. +Contoh response saat error: -2. The API endpoint (`GET /api/finance/data/{resourceType}`) must serve the data from this **in-memory store**, not by making a new call to the external API on every request. +```json +{ + "error": "Failed to fetch data", + "message": "TimeoutException" +} +``` -3. The in-memory storage mechanism (e.g., a service holding the data) must be designed to be **thread-safe** and ensure the data is **immutable** once the `ApplicationRunner` has finished loading it. +--- -## III. Production Readiness & Deliverables +## Testing -Your final solution must demonstrate production quality through code, testing, and communication. +Unit test mencakup: -### 1. Robustness & Best Practices +* Strategy (latest, historical, currencies) +* SpreadUtil +* DataLoader -* Graceful **Error Handling** for network failures or 4xx/5xx responses from the external API. +WebClient dimock agar test tidak bergantung ke API eksternal. -* Proper use of **Configuration Properties** (e.g., `application.yml`) for external service URLs. +--- -* Clear separation of concerns (Controller, Service, Model/DTO, etc.). +## Cara Menjalankan -### 2. Testing +```bash +mvn clean install +mvn spring-boot:run +``` -* **Unit Tests** for all three `IDRDataFetcher` strategy implementations, ensuring data calculation and transformation logic is covered (using mock clients for external calls). +--- -* **Integration Tests** to verify the `ApplicationRunner` successfully initializes and loads the data into the in-memory store before the application context is ready. +## Contoh Penggunaan -### 3. Documentation +```bash +curl http://localhost:8080/api/finance/data/latest_idr_rates +curl http://localhost:8080/api/finance/data/historical_idr_usd +curl http://localhost:8080/api/finance/data/supported_currencies +``` -A clear `README.md` is mandatory. It must include: +--- -* **Setup/Run Instructions:** Clear steps to clone, build, and run the application and tests. +## Personalisasi Spread -* **Endpoint Usage:** Example cURL commands to test the three different resource types. +GitHub Username: **haidir** -* **Personalization Note:** Clearly state your GitHub username and show the exact **Spread Factor** (e.g., `0.00765`) calculated by your function. +Spread dihitung menggunakan: +(sum ASCII username % 1000) / 100000.0 -* --- +--- -* ### 🛠️ Architectural Rationale +## Catatan - This section should contain a brief, but detailed, explanation answering the following questions: +* Data disimpan dalam bentuk immutable setelah load +* Menggunakan BigDecimal untuk menjaga presisi perhitungan +* Endpoint hanya membaca dari memory (tidak hit API lagi) - 1. **Polymorphism Justification:** Explain *why* the Strategy Pattern was used over a simpler conditional block in the service layer for handling the multi-resource endpoint. Discuss the benefits in terms of **extensibility** and **maintainability**. +--- - 2. **Client Factory:** Explain the specific role and benefit of using a **`FactoryBean`** to construct the external API client. Why is this preferable to defining the client using a standard `@Bean` method in this scenario? +## Penutup - 3. **Startup Runner Choice:** Justify the choice of using an `ApplicationRunner` (or `CommandLineRunner`) for the initial data ingestion over a simpler `@PostConstruct` method. +Implementasi ini dibuat dengan fokus pada clean architecture, maintainability, dan testability. -## IV. Submission & Review Process - -1. **Fork** this repository. - -2. Implement your solution on a dedicated feature branch (e.g., `feat/idr-rate-aggregator`). - -3. When complete, submit your solution via a **Pull Request (PR)** back to the main repository. -4. Please complete the form to submit your technical test: [Click Here](https://forms.gle/nZKQ2EjTCPfAKHog7) - -**Your PR will be evaluated on the following:** - -* **Commit History:** Clean, atomic, and descriptive commit messages (e.g., "feat: Implement IDR latest rates strategy," "fix: Correctly calculate IDR spread in tests"). - -* **PR Description:** The description must clearly summarize the solution and **must contain the full answers** to the three "Architectural Rationale" questions from Section III. - -* **Code Review Readiness:** The code should be well-structured and ready for immediate review. - -Good luck!