From 19280ff9d9a9595fa9860919219a9baa589c56e8 Mon Sep 17 00:00:00 2001 From: Puneethkumar CK Date: Sat, 11 Apr 2026 14:37:10 +0200 Subject: [PATCH 1/6] chore(infra): @Slf4j, drop block comments, prefer var (STA-249) Three small style-rule cleanups across the platform: - Replace LoggerFactory.getLogger with @Slf4j in ExternalApiLoggingInterceptor. - Drop 5 leftover block comments from switch-default and no-op lambda bodies in webhook validators, OFAC parser, and the nonce manager. - Use var for ~20 obvious local declarations across ledger, compliance, custody, fiat-on-ramp, merchant-iam, merchant-onboarding, payment-orchestrator, and a few test files (PermissionTest, JournalCommandHandlerTest, DevCustodyAdapterTest). No behavior change. Verified with compileJava + compileTestJava on every touched module. --- .../controller/ApiKeyController.java | 2 +- .../service/ApiKeyApplicationService.java | 2 +- .../domain/service/ChainSelectionEngine.java | 2 +- .../NonceManagerPersistenceAdapter.java | 4 +-- .../provider/dev/DevCustodyAdapterTest.java | 3 +- .../chainalysis/ChainalysisAmlAdapter.java | 10 +++--- .../provider/ofacsdn/SdnXmlParser.java | 4 +-- .../ModulrWebhookSignatureValidator.java | 2 +- .../provider/stripe/StripePspAdapter.java | 5 ++- .../stripe/StripeSignatureValidator.java | 2 +- .../domain/model/LedgerTransaction.java | 11 +++--- .../domain/model/ReconciliationRecord.java | 12 +++---- .../domain/service/BalanceCalculator.java | 17 +++++---- .../domain/service/JournalCommandHandler.java | 35 +++++++++---------- .../service/JournalCommandHandlerTest.java | 4 +-- .../iam/domain/team/model/MerchantUser.java | 16 ++++----- .../team/model/core/PermissionTest.java | 24 ++++++------- .../controller/KybWebhookController.java | 4 +-- .../domain/service/PaymentCommandHandler.java | 3 +- .../http/ExternalApiLoggingInterceptor.java | 6 ++-- 20 files changed, 80 insertions(+), 88 deletions(-) diff --git a/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/controller/ApiKeyController.java b/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/controller/ApiKeyController.java index e378e24d..f788f9c9 100644 --- a/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/controller/ApiKeyController.java +++ b/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/controller/ApiKeyController.java @@ -42,7 +42,7 @@ public ApiKeyResponse createApiKey(@Valid @RequestBody CreateApiKeyRequest reque request.merchantId(), request.name(), request.environment()); var environment = ApiKeyEnvironment.valueOf(request.environment().toUpperCase()); - Instant expiresAt = request.expiresInSeconds() != null + var expiresAt = request.expiresInSeconds() != null ? Instant.now().plusSeconds(request.expiresInSeconds()) : null; diff --git a/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/service/ApiKeyApplicationService.java b/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/service/ApiKeyApplicationService.java index 0b77c82d..ce9d6b39 100644 --- a/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/service/ApiKeyApplicationService.java +++ b/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/service/ApiKeyApplicationService.java @@ -21,7 +21,7 @@ public class ApiKeyApplicationService { public ApiKeyResponse createApiKey(CreateApiKeyRequest request) { var environment = ApiKeyEnvironment.valueOf(request.environment().toUpperCase()); - Instant expiresAt = request.expiresInSeconds() != null + var expiresAt = request.expiresInSeconds() != null ? Instant.now().plusSeconds(request.expiresInSeconds()) : null; diff --git a/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/domain/service/ChainSelectionEngine.java b/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/domain/service/ChainSelectionEngine.java index 91f4a9f8..725f03cb 100644 --- a/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/domain/service/ChainSelectionEngine.java +++ b/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/domain/service/ChainSelectionEngine.java @@ -88,7 +88,7 @@ public ChainSelectionResult selectChain(ChainSelectionRequest request) { } // Determine selected chain - ChainCandidate selectedCandidate = resolveSelectedCandidate(scoredCandidates, request.preferredChain()); + var selectedCandidate = resolveSelectedCandidate(scoredCandidates, request.preferredChain()); // Build final candidates list with selected flag var finalCandidates = scoredCandidates.stream() diff --git a/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/infrastructure/persistence/NonceManagerPersistenceAdapter.java b/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/infrastructure/persistence/NonceManagerPersistenceAdapter.java index fd55362d..4ab048e3 100644 --- a/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/infrastructure/persistence/NonceManagerPersistenceAdapter.java +++ b/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/infrastructure/persistence/NonceManagerPersistenceAdapter.java @@ -25,7 +25,7 @@ public long assignNextNonce(UUID walletId, ChainId chainId) { // Acquire blocking transaction-scoped advisory lock (waits if held, auto-released at tx commit) jdbcTemplate.query( "SELECT pg_advisory_xact_lock(hashtext(?))", - rs -> { /* void function -- result set ignored */ }, + rs -> { }, walletId.toString()); log.debug("Advisory lock acquired for wallet={}", walletId); @@ -41,7 +41,7 @@ ON CONFLICT (wallet_id, chain_id) DO NOTHING chainId.value()); // Atomically read current value and increment, returning the pre-increment value - Long nonce = jdbcTemplate.queryForObject( + var nonce = jdbcTemplate.queryForObject( """ UPDATE wallet_nonces SET current_nonce = current_nonce + 1, diff --git a/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/infrastructure/provider/dev/DevCustodyAdapterTest.java b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/infrastructure/provider/dev/DevCustodyAdapterTest.java index c9df4dcc..ff503838 100644 --- a/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/infrastructure/provider/dev/DevCustodyAdapterTest.java +++ b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/infrastructure/provider/dev/DevCustodyAdapterTest.java @@ -15,7 +15,6 @@ import java.math.BigInteger; import java.net.http.HttpClient; import java.net.http.HttpClient.Version; -import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; @@ -83,7 +82,7 @@ void setUp() { .requestFactory(requestFactory) .build(); - Map restClients = new ConcurrentHashMap<>(); + var restClients = new ConcurrentHashMap(); restClients.put("base", restClient); restClients.put("ethereum", restClient); diff --git a/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/infrastructure/provider/chainalysis/ChainalysisAmlAdapter.java b/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/infrastructure/provider/chainalysis/ChainalysisAmlAdapter.java index 3ee3a2a7..ef527945 100644 --- a/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/infrastructure/provider/chainalysis/ChainalysisAmlAdapter.java +++ b/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/infrastructure/provider/chainalysis/ChainalysisAmlAdapter.java @@ -68,10 +68,10 @@ public AmlResult analyze(UUID senderId, UUID recipientId) { var senderResponse = registerAndAnalyzeTransfer(senderId.toString()); var recipientResponse = registerAndAnalyzeTransfer(recipientId.toString()); - List flagReasons = collectFlagReasons(senderResponse, recipientResponse, senderId, recipientId); - boolean flagged = !flagReasons.isEmpty(); - String chainAnalysis = buildChainAnalysis(senderResponse, recipientResponse); - String providerRef = buildProviderRef(senderId, recipientId); + var flagReasons = collectFlagReasons(senderResponse, recipientResponse, senderId, recipientId); + var flagged = !flagReasons.isEmpty(); + var chainAnalysis = buildChainAnalysis(senderResponse, recipientResponse); + var providerRef = buildProviderRef(senderId, recipientId); var result = AmlResult.builder() .amlResultId(UUID.randomUUID()) @@ -123,7 +123,7 @@ private ChainalysisTransferResponse getTransferAnalysis(String userId) { private List collectFlagReasons(ChainalysisTransferResponse senderResp, ChainalysisTransferResponse recipientResp, UUID senderId, UUID recipientId) { - List reasons = new ArrayList<>(); + var reasons = new ArrayList(); addRatingReasons(reasons, senderResp, "sender", senderId); addRatingReasons(reasons, recipientResp, "recipient", recipientId); addAlertReasons(reasons, senderResp, "sender", senderId); diff --git a/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/infrastructure/provider/ofacsdn/SdnXmlParser.java b/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/infrastructure/provider/ofacsdn/SdnXmlParser.java index 839e2d05..0a8d836f 100644 --- a/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/infrastructure/provider/ofacsdn/SdnXmlParser.java +++ b/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/infrastructure/provider/ofacsdn/SdnXmlParser.java @@ -71,7 +71,7 @@ private static SdnEntry parseSdnEntry(XMLStreamReader reader) throws XMLStreamEx aliases.add(alias); } } - default -> { /* skip unknown elements */ } + default -> { } } } else if (event == XMLStreamConstants.END_ELEMENT && "sdnEntry".equals(reader.getLocalName())) { break; @@ -98,7 +98,7 @@ private static SdnAlias parseAlias(XMLStreamReader reader) throws XMLStreamExcep case "category" -> category = readText(reader); case "firstName" -> firstName = readText(reader); case "lastName" -> lastName = readText(reader); - default -> { /* skip unknown elements */ } + default -> { } } } else if (event == XMLStreamConstants.END_ELEMENT && "aka".equals(reader.getLocalName())) { break; diff --git a/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/provider/modulr/ModulrWebhookSignatureValidator.java b/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/provider/modulr/ModulrWebhookSignatureValidator.java index b0a1af71..2fc372c8 100644 --- a/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/provider/modulr/ModulrWebhookSignatureValidator.java +++ b/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/provider/modulr/ModulrWebhookSignatureValidator.java @@ -83,7 +83,7 @@ ParsedSignature parseSignature(String header) { switch (kv[0].trim()) { case "t" -> timestamp = kv[1].trim(); case "v1" -> v1Signature = kv[1].trim(); - default -> { /* ignore unknown keys */ } + default -> { } } } diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/infrastructure/provider/stripe/StripePspAdapter.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/infrastructure/provider/stripe/StripePspAdapter.java index 05345bcc..51dea2a9 100644 --- a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/infrastructure/provider/stripe/StripePspAdapter.java +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/infrastructure/provider/stripe/StripePspAdapter.java @@ -18,7 +18,6 @@ import org.springframework.lang.Nullable; import org.springframework.stereotype.Component; import org.springframework.util.LinkedMultiValueMap; -import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestClient; import java.net.http.HttpClient; @@ -60,7 +59,7 @@ public PspPaymentResult initiatePayment(PspPaymentRequest request) { log.info("[STRIPE] Initiating payment collectionId={} amount={} currency={}", request.collectionId(), request.amount().amount(), request.amount().currency()); - MultiValueMap formData = new LinkedMultiValueMap<>(); + var formData = new LinkedMultiValueMap(); formData.add("amount", toMinorUnits(request)); formData.add("currency", request.amount().currency().toLowerCase()); formData.add("payment_method_types[]", "us_bank_account"); @@ -91,7 +90,7 @@ public PspRefundResult initiateRefund(PspRefundRequest request) { log.info("[STRIPE] Initiating refund collectionId={} pspRef={} amount={}", request.collectionId(), request.pspReference(), request.refundAmount().amount()); - MultiValueMap formData = new LinkedMultiValueMap<>(); + var formData = new LinkedMultiValueMap(); formData.add("payment_intent", request.pspReference()); formData.add("amount", toMinorUnitsFromRefund(request)); formData.add("reason", "requested_by_customer"); diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/infrastructure/provider/stripe/StripeSignatureValidator.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/infrastructure/provider/stripe/StripeSignatureValidator.java index 1c7ff3d6..52c802fe 100644 --- a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/infrastructure/provider/stripe/StripeSignatureValidator.java +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/infrastructure/provider/stripe/StripeSignatureValidator.java @@ -80,7 +80,7 @@ ParsedSignature parseSignature(String header) { switch (kv[0].trim()) { case "t" -> timestamp = kv[1].trim(); case "v1" -> v1Signature = kv[1].trim(); - default -> { /* ignore unknown keys */ } + default -> { } } } diff --git a/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/domain/model/LedgerTransaction.java b/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/domain/model/LedgerTransaction.java index 464960ce..12fe0c19 100644 --- a/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/domain/model/LedgerTransaction.java +++ b/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/domain/model/LedgerTransaction.java @@ -3,7 +3,6 @@ import java.math.BigDecimal; import java.time.Instant; import java.util.List; -import java.util.Map; import java.util.Objects; import java.util.UUID; import java.util.stream.Collectors; @@ -35,13 +34,13 @@ public record LedgerTransaction( } private static void validateBalance(List entries) { - Map> byCurrency = entries.stream() + var byCurrency = entries.stream() .collect(Collectors.groupingBy(JournalEntry::currency)); - for (Map.Entry> group : byCurrency.entrySet()) { - BigDecimal totalDebits = BigDecimal.ZERO; - BigDecimal totalCredits = BigDecimal.ZERO; - for (JournalEntry entry : group.getValue()) { + for (var group : byCurrency.entrySet()) { + var totalDebits = BigDecimal.ZERO; + var totalCredits = BigDecimal.ZERO; + for (var entry : group.getValue()) { if (entry.entryType() == EntryType.DEBIT) { totalDebits = totalDebits.add(entry.amount()); } else { diff --git a/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/domain/model/ReconciliationRecord.java b/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/domain/model/ReconciliationRecord.java index 5cf7fe38..2fa7872b 100644 --- a/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/domain/model/ReconciliationRecord.java +++ b/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/domain/model/ReconciliationRecord.java @@ -57,10 +57,10 @@ public static ReconciliationRecord create(UUID paymentId, BigDecimal tolerance) public ReconciliationRecord addLeg(ReconciliationLeg leg) { Objects.requireNonNull(leg, "leg must not be null"); - List updatedLegs = new ArrayList<>(this.legs); + var updatedLegs = new ArrayList<>(this.legs); updatedLegs.add(leg); - Set presentTypes = updatedLegs.stream() + var presentTypes = updatedLegs.stream() .map(ReconciliationLeg::legType) .collect(Collectors.toSet()); @@ -89,12 +89,12 @@ public ReconciliationRecord addLeg(ReconciliationLeg leg) { public ReconciliationRecord finalize(BigDecimal discrepancy) { Objects.requireNonNull(discrepancy, "discrepancy must not be null"); - Set presentTypes = this.legs.stream() + var presentTypes = this.legs.stream() .map(ReconciliationLeg::legType) .collect(Collectors.toSet()); - boolean allLegsPresent = presentTypes.containsAll(REQUIRED_LEGS); - boolean withinTolerance = discrepancy.abs().compareTo(this.tolerance) <= 0; + var allLegsPresent = presentTypes.containsAll(REQUIRED_LEGS); + var withinTolerance = discrepancy.abs().compareTo(this.tolerance) <= 0; ReconciliationStatus newStatus; Instant reconciledTime; @@ -139,7 +139,7 @@ public boolean hasLeg(ReconciliationLegType legType) { } public boolean hasAllRequiredLegs() { - Set presentTypes = this.legs.stream() + var presentTypes = this.legs.stream() .map(ReconciliationLeg::legType) .collect(Collectors.toSet()); return presentTypes.containsAll(REQUIRED_LEGS); diff --git a/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/domain/service/BalanceCalculator.java b/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/domain/service/BalanceCalculator.java index 8c1632df..a49b70ad 100644 --- a/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/domain/service/BalanceCalculator.java +++ b/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/domain/service/BalanceCalculator.java @@ -1,7 +1,6 @@ package com.stablecoin.payments.ledger.domain.service; import com.stablecoin.payments.ledger.domain.exception.AccountNotFoundException; -import com.stablecoin.payments.ledger.domain.model.Account; import com.stablecoin.payments.ledger.domain.model.AccountBalance; import com.stablecoin.payments.ledger.domain.model.EntryType; import com.stablecoin.payments.ledger.domain.port.AccountBalanceRepository; @@ -23,22 +22,22 @@ public class BalanceCalculator { private final AccountBalanceRepository balanceRepository; public Map computeBalances(List entries) { - List sorted = entries.stream() + var sorted = entries.stream() .sorted(Comparator.comparing(JournalEntryRequest::accountCode) .thenComparing(JournalEntryRequest::currency)) .toList(); - Map result = new LinkedHashMap<>(); - for (JournalEntryRequest req : sorted) { - String key = balanceKey(req.accountCode(), req.currency()); - BalanceUpdate previous = result.get(key); + var result = new LinkedHashMap(); + for (var req : sorted) { + var key = balanceKey(req.accountCode(), req.currency()); + var previous = result.get(key); result.put(key, computeSingleBalance(req, previous)); } return result; } private BalanceUpdate computeSingleBalance(JournalEntryRequest entryRequest, BalanceUpdate previous) { - Account account = accountRepository.findByAccountCode(entryRequest.accountCode()) + var account = accountRepository.findByAccountCode(entryRequest.accountCode()) .orElseThrow(() -> new AccountNotFoundException(entryRequest.accountCode())); BigDecimal currentBalance; @@ -47,14 +46,14 @@ private BalanceUpdate computeSingleBalance(JournalEntryRequest entryRequest, Bal currentBalance = previous.balanceAfter(); newVersion = previous.accountVersion(); } else { - AccountBalance current = balanceRepository + var current = balanceRepository .findForUpdate(entryRequest.accountCode(), entryRequest.currency()) .orElse(AccountBalance.zero(entryRequest.accountCode(), entryRequest.currency())); currentBalance = current.balance(); newVersion = current.version() + 1; } - BigDecimal newBalance = computeNewBalance( + var newBalance = computeNewBalance( currentBalance, entryRequest.entryType(), account.normalBalance(), entryRequest.amount()); return new BalanceUpdate(newBalance, newVersion); diff --git a/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/domain/service/JournalCommandHandler.java b/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/domain/service/JournalCommandHandler.java index e2da9bfe..34d82592 100644 --- a/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/domain/service/JournalCommandHandler.java +++ b/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/domain/service/JournalCommandHandler.java @@ -19,7 +19,6 @@ import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.UUID; @Service @@ -43,21 +42,21 @@ public LedgerTransaction postTransaction(TransactionRequest request) { return findExistingTransaction(request); } - Instant now = clock.instant(); - UUID transactionId = UUID.randomUUID(); - int baseSequence = entryRepository.countByPaymentId(request.paymentId()); + var now = clock.instant(); + var transactionId = UUID.randomUUID(); + var baseSequence = entryRepository.countByPaymentId(request.paymentId()); - Map balanceUpdates = balanceCalculator.computeBalances(request.entries()); + var balanceUpdates = balanceCalculator.computeBalances(request.entries()); - List entries = buildEntries(request, transactionId, baseSequence, balanceUpdates, now); + var entries = buildEntries(request, transactionId, baseSequence, balanceUpdates, now); - LedgerTransaction transaction = new LedgerTransaction( + var transaction = new LedgerTransaction( transactionId, request.paymentId(), request.correlationId(), request.sourceEvent(), request.sourceEventId(), request.description(), entries, now ); - LedgerTransaction saved = transactionRepository.save(transaction); + var saved = transactionRepository.save(transaction); persistBalanceUpdates(entries, balanceUpdates, now); @@ -80,11 +79,11 @@ private List buildEntries( Map balanceUpdates, Instant now ) { - List entries = new ArrayList<>(); - for (int i = 0; i < request.entries().size(); i++) { - JournalEntryRequest req = request.entries().get(i); - String key = BalanceCalculator.balanceKey(req.accountCode(), req.currency()); - BalanceUpdate update = balanceUpdates.get(key); + var entries = new ArrayList(); + for (var i = 0; i < request.entries().size(); i++) { + var req = request.entries().get(i); + var key = BalanceCalculator.balanceKey(req.accountCode(), req.currency()); + var update = balanceUpdates.get(key); entries.add(new JournalEntry( UUID.randomUUID(), @@ -111,11 +110,11 @@ private void persistBalanceUpdates( Map updates, Instant now ) { - Set persisted = new HashSet<>(); - for (JournalEntry entry : entries) { - String key = BalanceCalculator.balanceKey(entry.accountCode(), entry.currency()); + var persisted = new HashSet(); + for (var entry : entries) { + var key = BalanceCalculator.balanceKey(entry.accountCode(), entry.currency()); if (persisted.add(key)) { - BalanceUpdate update = updates.get(key); + var update = updates.get(key); balanceRepository.save(new AccountBalance( entry.accountCode(), entry.currency(), @@ -134,7 +133,7 @@ private void saveAuditEvent( int entryCount, Instant now ) { - String payload = "{\"transactionId\":\"" + transactionId + var payload = "{\"transactionId\":\"" + transactionId + "\",\"sourceEvent\":\"" + request.sourceEvent() + "\",\"entryCount\":" + entryCount + "}"; diff --git a/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/domain/service/JournalCommandHandlerTest.java b/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/domain/service/JournalCommandHandlerTest.java index 52078cef..8285a9fc 100644 --- a/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/domain/service/JournalCommandHandlerTest.java +++ b/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/domain/service/JournalCommandHandlerTest.java @@ -382,8 +382,8 @@ private void stubForRequest(TransactionRequest request) { given(transactionRepository.existsBySourceEventId(request.sourceEventId())).willReturn(false); given(entryRepository.countByPaymentId(request.paymentId())).willReturn(0); - Map balanceMap = new java.util.LinkedHashMap<>(); - for (JournalEntryRequest entry : request.entries()) { + var balanceMap = new java.util.LinkedHashMap(); + for (var entry : request.entries()) { balanceMap.put( BalanceCalculator.balanceKey(entry.accountCode(), entry.currency()), new BalanceUpdate(entry.amount(), 1L) diff --git a/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/domain/team/model/MerchantUser.java b/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/domain/team/model/MerchantUser.java index 8f596295..cd3463b3 100644 --- a/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/domain/team/model/MerchantUser.java +++ b/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/domain/team/model/MerchantUser.java @@ -52,8 +52,8 @@ public record MerchantUser( )); public MerchantUser acceptInvitation(String newFullName, String newPasswordHash) { - UserStatus newStatus = STATE_MACHINE.transition(status, ACCEPT_INVITATION); - Instant now = Instant.now(); + var newStatus = STATE_MACHINE.transition(status, ACCEPT_INVITATION); + var now = Instant.now(); return toBuilder() .status(newStatus) .fullName(newFullName) @@ -64,8 +64,8 @@ public MerchantUser acceptInvitation(String newFullName, String newPasswordHash) } public MerchantUser suspend() { - UserStatus newStatus = STATE_MACHINE.transition(status, SUSPEND); - Instant now = Instant.now(); + var newStatus = STATE_MACHINE.transition(status, SUSPEND); + var now = Instant.now(); return toBuilder() .status(newStatus) .suspendedAt(now) @@ -74,7 +74,7 @@ public MerchantUser suspend() { } public MerchantUser reactivate() { - UserStatus newStatus = STATE_MACHINE.transition(status, REACTIVATE); + var newStatus = STATE_MACHINE.transition(status, REACTIVATE); return toBuilder() .status(newStatus) .suspendedAt(null) @@ -83,8 +83,8 @@ public MerchantUser reactivate() { } public MerchantUser deactivate() { - UserStatus newStatus = STATE_MACHINE.transition(status, DEACTIVATE); - Instant now = Instant.now(); + var newStatus = STATE_MACHINE.transition(status, DEACTIVATE); + var now = Instant.now(); return toBuilder() .status(newStatus) .deactivatedAt(now) @@ -100,7 +100,7 @@ public MerchantUser changeRole(UUID newRoleId) { } public MerchantUser recordLogin() { - Instant now = Instant.now(); + var now = Instant.now(); return toBuilder() .lastLoginAt(now) .updatedAt(now) diff --git a/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/model/core/PermissionTest.java b/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/model/core/PermissionTest.java index 1285b393..ac9c83be 100644 --- a/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/model/core/PermissionTest.java +++ b/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/model/core/PermissionTest.java @@ -13,7 +13,7 @@ class Parse { @Test void parses_valid_permission_string() { - Permission permission = Permission.parse("payments:write"); + var permission = Permission.parse("payments:write"); assertThat(permission.namespace()).isEqualTo("payments"); assertThat(permission.action()).isEqualTo("write"); @@ -21,7 +21,7 @@ void parses_valid_permission_string() { @Test void parses_wildcard_permission() { - Permission permission = Permission.parse("*:*"); + var permission = Permission.parse("*:*"); assertThat(permission.namespace()).isEqualTo("*"); assertThat(permission.action()).isEqualTo("*"); @@ -29,7 +29,7 @@ void parses_wildcard_permission() { @Test void parses_namespace_wildcard() { - Permission permission = Permission.parse("payments:*"); + var permission = Permission.parse("payments:*"); assertThat(permission.namespace()).isEqualTo("payments"); assertThat(permission.action()).isEqualTo("*"); @@ -54,15 +54,15 @@ class Implies { @Test void full_wildcard_implies_any_permission() { - Permission wildcard = Permission.of("*", "*"); - Permission specific = Permission.of("payments", "write"); + var wildcard = Permission.of("*", "*"); + var specific = Permission.of("payments", "write"); assertThat(wildcard.implies(specific)).isTrue(); } @Test void namespace_wildcard_implies_same_namespace() { - Permission namespaceWildcard = Permission.of("payments", "*"); + var namespaceWildcard = Permission.of("payments", "*"); assertThat(namespaceWildcard.implies(Permission.of("payments", "read"))).isTrue(); assertThat(namespaceWildcard.implies(Permission.of("payments", "write"))).isTrue(); @@ -71,36 +71,36 @@ void namespace_wildcard_implies_same_namespace() { @Test void namespace_wildcard_does_not_imply_different_namespace() { - Permission namespaceWildcard = Permission.of("payments", "*"); + var namespaceWildcard = Permission.of("payments", "*"); assertThat(namespaceWildcard.implies(Permission.of("team", "read"))).isFalse(); } @Test void exact_match_implies_same_permission() { - Permission exact = Permission.of("payments", "read"); + var exact = Permission.of("payments", "read"); assertThat(exact.implies(Permission.of("payments", "read"))).isTrue(); } @Test void exact_match_does_not_imply_different_action() { - Permission exact = Permission.of("payments", "read"); + var exact = Permission.of("payments", "read"); assertThat(exact.implies(Permission.of("payments", "write"))).isFalse(); } @Test void exact_match_does_not_imply_different_namespace() { - Permission exact = Permission.of("payments", "read"); + var exact = Permission.of("payments", "read"); assertThat(exact.implies(Permission.of("transactions", "read"))).isFalse(); } @Test void specific_permission_does_not_imply_wildcard() { - Permission specific = Permission.of("payments", "read"); - Permission wildcard = Permission.of("*", "*"); + var specific = Permission.of("payments", "read"); + var wildcard = Permission.of("*", "*"); assertThat(specific.implies(wildcard)).isFalse(); } diff --git a/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/controller/KybWebhookController.java b/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/controller/KybWebhookController.java index 1b5f9b51..2f5a9d88 100644 --- a/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/controller/KybWebhookController.java +++ b/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/controller/KybWebhookController.java @@ -38,10 +38,10 @@ public ResponseEntity handleOnfidoWebhook(@RequestBody String rawBody, return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } - Map payload = webhookValidator.parsePayload(rawBody); + var payload = webhookValidator.parsePayload(rawBody); log.info("[WEBHOOK] Onfido webhook received action={}", payload.get("action")); - KybVerification result = kybProvider.handleWebhook(payload); + var result = kybProvider.handleWebhook(payload); if (result == null) { log.debug("[WEBHOOK] Non-check webhook ignored"); return ResponseEntity.ok().build(); diff --git a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/service/PaymentCommandHandler.java b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/service/PaymentCommandHandler.java index 843c8699..b66edcd8 100644 --- a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/service/PaymentCommandHandler.java +++ b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/service/PaymentCommandHandler.java @@ -21,7 +21,6 @@ import java.math.BigDecimal; import java.time.Duration; import java.time.Instant; -import java.util.Optional; import java.util.UUID; @Slf4j @@ -43,7 +42,7 @@ public InitiateResult initiatePayment(String idempotencyKey, UUID correlationId, String sourceCountry, String targetCountry) { log.info("Initiating payment idempotencyKey={}, correlationId={}", idempotencyKey, correlationId); - Optional existing = paymentRepository.findByIdempotencyKey(idempotencyKey); + var existing = paymentRepository.findByIdempotencyKey(idempotencyKey); if (existing.isPresent()) { log.info("Idempotent replay for idempotencyKey={}, paymentId={}", idempotencyKey, existing.get().paymentId()); diff --git a/platform-infra/src/main/java/com/stablecoin/payments/platform/infrastructure/http/ExternalApiLoggingInterceptor.java b/platform-infra/src/main/java/com/stablecoin/payments/platform/infrastructure/http/ExternalApiLoggingInterceptor.java index abc1dabb..9a469450 100644 --- a/platform-infra/src/main/java/com/stablecoin/payments/platform/infrastructure/http/ExternalApiLoggingInterceptor.java +++ b/platform-infra/src/main/java/com/stablecoin/payments/platform/infrastructure/http/ExternalApiLoggingInterceptor.java @@ -1,7 +1,6 @@ package com.stablecoin.payments.platform.infrastructure.http; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; +import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpRequest; import org.springframework.http.HttpStatusCode; @@ -17,10 +16,9 @@ import java.nio.charset.StandardCharsets; import java.util.Set; +@Slf4j public class ExternalApiLoggingInterceptor implements ClientHttpRequestInterceptor { - private static final Logger log = LoggerFactory.getLogger(ExternalApiLoggingInterceptor.class); - private static final Set SENSITIVE_HEADERS = Set.of( "authorization", "x-api-key", "api-key", "x-api-secret" ); From 1f48e7817f6767a1669eab056e41a7574de9d0da Mon Sep 17 00:00:00 2001 From: Puneethkumar CK Date: Sat, 11 Apr 2026 15:06:23 +0200 Subject: [PATCH 2/6] chore(infra): drop narration comments across services (STA-249) Remove ~135 step-by-step narration and section-divider comments across 29 production files. Code now stands on its own; the remaining 47 comments document non-obvious WHY (Temporal saga authority, JPA orphanRemoval ordering, advisory-lock semantics, PII masking rationale, dev-mode keypair notes, etc.). Heaviest cleanups: PayoutCommandHandler (-13), TransferCommandHandler (-12), RefundCommandHandler (-12), TracingConfig (-13 architecture explainer), RiskScoringService (-9 Factor 1-8 labels), CollectionCommandHandler (-7), PaymentEventConsumer (-7), MerchantTeam (-6 Invariant labels), LockService, MerchantOnboardingWorkflowImpl, PaymentWorkflowImpl, FxRateLockApplicationService. Verified: ./gradlew compileJava + compileTestJava BUILD SUCCESSFUL. --- .../security/UserJwtAuthenticationFilter.java | 5 ----- .../domain/service/ChainSelectionEngine.java | 3 --- .../domain/service/TransferCommandHandler.java | 12 ------------ .../persistence/NonceManagerPersistenceAdapter.java | 3 --- .../compliance/domain/model/ComplianceCheck.java | 4 ---- .../domain/service/RiskScoringService.java | 9 --------- .../domain/service/PayoutCommandHandler.java | 13 ------------- .../persistence/entity/PayoutOrderEntity.java | 2 -- .../domain/service/CollectionCommandHandler.java | 7 ------- .../onramp/domain/service/RefundCommandHandler.java | 12 ------------ .../service/FxQuoteApplicationService.java | 1 - .../service/FxRateLockApplicationService.java | 8 -------- .../payments/fx/domain/model/FxQuote.java | 1 - .../payments/fx/domain/service/LockService.java | 5 ----- .../messaging/PaymentEventConsumer.java | 7 ------- .../ledger/domain/service/AccountingRules.java | 1 - .../messaging/LedgerEventConsumer.java | 1 - .../iam/application/controller/ErrorCodes.java | 2 -- .../merchant/iam/domain/team/MerchantTeam.java | 6 ------ .../persistence/adapter/RoleRepositoryAdapter.java | 8 ++------ .../application/config/SecurityConfig.java | 2 -- .../application/controller/ErrorCodes.java | 2 -- .../controller/KybWebhookController.java | 1 - .../infrastructure/kyb/OnfidoKybAdapter.java | 2 -- .../workflow/MerchantOnboardingWorkflowImpl.java | 6 ------ .../domain/workflow/PaymentWorkflowImpl.java | 9 ++------- .../activity/ComplianceCheckActivityImpl.java | 1 - .../messaging/AbstractOutboxEventPublisher.java | 1 - .../infrastructure/tracing/TracingConfig.java | 13 ------------- 29 files changed, 4 insertions(+), 143 deletions(-) diff --git a/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/security/UserJwtAuthenticationFilter.java b/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/security/UserJwtAuthenticationFilter.java index c990b37f..4d41b9ff 100644 --- a/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/security/UserJwtAuthenticationFilter.java +++ b/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/security/UserJwtAuthenticationFilter.java @@ -53,27 +53,23 @@ protected void doFilterInternal(HttpServletRequest request, var jwt = SignedJWT.parse(token); var claims = jwt.getJWTClaimsSet(); - // Only handle tokens issued by S13 if (!merchantIamProperties.issuer().equals(claims.getIssuer())) { chain.doFilter(request, response); return; } - // Validate audience if (claims.getAudience() == null || !claims.getAudience().contains(merchantIamProperties.audience())) { sendUnauthorized(response, "JWT audience mismatch"); return; } - // Validate expiration if (claims.getExpirationTime() == null || claims.getExpirationTime().before(new Date())) { sendUnauthorized(response, "JWT has expired"); return; } - // Verify signature against S13 JWKS var jwksJson = userJwksProvider.fetchJwks(); var jwkSet = JWKSet.parse(jwksJson); var kid = jwt.getHeader().getKeyID(); @@ -90,7 +86,6 @@ protected void doFilterInternal(HttpServletRequest request, return; } - // Extract claims var userId = UUID.fromString(claims.getStringClaim("user_id")); var merchantId = UUID.fromString(claims.getStringClaim("merchant_id")); var roleId = UUID.fromString(claims.getStringClaim("role_id")); diff --git a/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/domain/service/ChainSelectionEngine.java b/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/domain/service/ChainSelectionEngine.java index 725f03cb..c6c31621 100644 --- a/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/domain/service/ChainSelectionEngine.java +++ b/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/domain/service/ChainSelectionEngine.java @@ -74,7 +74,6 @@ public ChainSelectionResult selectChain(ChainSelectionRequest request) { var candidateConfigs = new ArrayList<>(MVP_CHAINS.values()); - // Filter and score candidates var scoredCandidates = candidateConfigs.stream() .filter(config -> hasWalletWithSufficientBalance(config.chainId(), request.stablecoin(), request.amount())) .filter(config -> chainHealthProvider.getHealthScore(config.chainId()) > 0) @@ -87,10 +86,8 @@ public ChainSelectionResult selectChain(ChainSelectionRequest request) { .formatted(request.transferId())); } - // Determine selected chain var selectedCandidate = resolveSelectedCandidate(scoredCandidates, request.preferredChain()); - // Build final candidates list with selected flag var finalCandidates = scoredCandidates.stream() .map(candidate -> candidate.toBuilder() .selected(candidate.chainId().equals(selectedCandidate.chainId())) diff --git a/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/domain/service/TransferCommandHandler.java b/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/domain/service/TransferCommandHandler.java index 7c63137a..2bd1dd24 100644 --- a/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/domain/service/TransferCommandHandler.java +++ b/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/domain/service/TransferCommandHandler.java @@ -55,20 +55,17 @@ public TransferResult initiateTransfer(UUID paymentId, UUID correlationId, StablecoinTicker stablecoin, BigDecimal amount, String toWalletAddress, String preferredChain) { - // 1. Idempotency check var existing = chainTransferRepository.findByPaymentIdAndType(paymentId, transferType); if (existing.isPresent()) { log.info("Idempotent replay for paymentId={} transferType={}", paymentId, transferType); return new TransferResult(existing.get(), false); } - // 2. Select optimal chain var tempTransferId = UUID.randomUUID(); var selectionResult = chainSelectionEngine.selectChain( new ChainSelectionRequest(tempTransferId, stablecoin, amount, preferredChain)); var selectedChain = selectionResult.selectedChain(); - // 3. Find source wallet var walletPurpose = transferType == TransferType.RETURN ? WalletPurpose.OFF_RAMP : WalletPurpose.ON_RAMP; var wallet = walletRepository.findByChainIdAndPurpose(selectedChain, walletPurpose) .stream() @@ -77,7 +74,6 @@ public TransferResult initiateTransfer(UUID paymentId, UUID correlationId, .orElseThrow(() -> new WalletNotFoundException( "No active %s wallet found for chain %s".formatted(walletPurpose, selectedChain.value()))); - // 4. Reserve balance (with pessimistic lock) var balance = walletBalanceRepository.findByWalletIdAndStablecoinForUpdate( wallet.walletId(), stablecoin) .orElseThrow(() -> new InsufficientBalanceException( @@ -93,17 +89,14 @@ public TransferResult initiateTransfer(UUID paymentId, UUID correlationId, var reservedBalance = balance.reserve(amount); walletBalanceRepository.save(reservedBalance); - // 5. Create transfer aggregate in PENDING state var transfer = ChainTransfer.initiate( paymentId, correlationId, transferType, parentTransferId, stablecoin, amount, wallet.walletId(), toWalletAddress, wallet.address()); - // 6. Transition through states: PENDING → CHAIN_SELECTED → SIGNING transfer = transfer.selectChain(selectedChain); var nonceAssignment = nonceManager.assignNonce(wallet.walletId(), selectedChain, false); transfer = transfer.startSigning(nonceAssignment.nonce()); - // 7. Sign via custody engine var signRequest = new SignRequest( transfer.transferId(), selectedChain, wallet.address(), toWalletAddress, amount, stablecoin, @@ -114,7 +107,6 @@ public TransferResult initiateTransfer(UUID paymentId, UUID correlationId, var signResult = custodyEngine.signAndSubmit(signRequest); txHash = signResult.txHash(); } catch (Exception e) { - // Fail the transfer and release balance transfer = transfer.fail("Custody signing failed: " + e.getMessage(), CustodySigningException.ERROR_CODE); chainTransferRepository.save(transfer); releaseBalance(reservedBalance, amount); @@ -123,11 +115,9 @@ public TransferResult initiateTransfer(UUID paymentId, UUID correlationId, throw new CustodySigningException("Custody signing failed for transfer " + transfer.transferId(), e); } - // 8. SIGNING → SUBMITTED transfer = transfer.submit(txHash); transfer = chainTransferRepository.save(transfer); - // 9. Record participants transferParticipantRepository.save(TransferParticipant.create( transfer.transferId(), ParticipantType.INPUT, wallet.address(), wallet.walletId(), amount, stablecoin.ticker())); @@ -135,7 +125,6 @@ public TransferResult initiateTransfer(UUID paymentId, UUID correlationId, transfer.transferId(), ParticipantType.OUTPUT, toWalletAddress, null, amount, stablecoin.ticker())); - // 10. Record lifecycle events lifecycleEventRepository.save( TransferLifecycleEvent.record(transfer.transferId(), "BALANCE_RESERVED")); lifecycleEventRepository.save( @@ -145,7 +134,6 @@ public TransferResult initiateTransfer(UUID paymentId, UUID correlationId, lifecycleEventRepository.save( TransferLifecycleEvent.record(transfer.transferId(), "SUBMITTED")); - // 11. Publish outbox event transferEventPublisher.publish(new TransferSubmittedEvent( transfer.transferId(), transfer.paymentId(), transfer.correlationId(), selectedChain.value(), stablecoin.ticker(), amount, diff --git a/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/infrastructure/persistence/NonceManagerPersistenceAdapter.java b/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/infrastructure/persistence/NonceManagerPersistenceAdapter.java index 4ab048e3..2efc699a 100644 --- a/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/infrastructure/persistence/NonceManagerPersistenceAdapter.java +++ b/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/infrastructure/persistence/NonceManagerPersistenceAdapter.java @@ -22,7 +22,6 @@ public class NonceManagerPersistenceAdapter implements NonceRepository { @Override @Transactional(propagation = Propagation.REQUIRES_NEW) public long assignNextNonce(UUID walletId, ChainId chainId) { - // Acquire blocking transaction-scoped advisory lock (waits if held, auto-released at tx commit) jdbcTemplate.query( "SELECT pg_advisory_xact_lock(hashtext(?))", rs -> { }, @@ -30,7 +29,6 @@ public long assignNextNonce(UUID walletId, ChainId chainId) { log.debug("Advisory lock acquired for wallet={}", walletId); - // Upsert: ensure the row exists jdbcTemplate.update( """ INSERT INTO wallet_nonces (wallet_id, chain_id, current_nonce, updated_at) @@ -40,7 +38,6 @@ ON CONFLICT (wallet_id, chain_id) DO NOTHING walletId, chainId.value()); - // Atomically read current value and increment, returning the pre-increment value var nonce = jdbcTemplate.queryForObject( """ UPDATE wallet_nonces diff --git a/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/domain/model/ComplianceCheck.java b/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/domain/model/ComplianceCheck.java index f78f8687..cf70281c 100644 --- a/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/domain/model/ComplianceCheck.java +++ b/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/domain/model/ComplianceCheck.java @@ -65,21 +65,17 @@ public record ComplianceCheck( private static final StateMachine STATE_MACHINE = new StateMachine<>(List.of( - // Happy path new StateTransition<>(PENDING, START_KYC, KYC_IN_PROGRESS), new StateTransition<>(KYC_IN_PROGRESS, KYC_PASSED, SANCTIONS_SCREENING), new StateTransition<>(SANCTIONS_SCREENING, SANCTIONS_CLEAR, AML_SCREENING), new StateTransition<>(AML_SCREENING, AML_CLEAR, RISK_SCORING), new StateTransition<>(RISK_SCORING, RISK_SCORED, TRAVEL_RULE_PACKAGING), new StateTransition<>(TRAVEL_RULE_PACKAGING, TRAVEL_RULE_COMPLETE, PASSED), - // Risk critical path new StateTransition<>(RISK_SCORING, RISK_CRITICAL, MANUAL_REVIEW), - // Failure paths new StateTransition<>(KYC_IN_PROGRESS, KYC_FAILED, FAILED), new StateTransition<>(SANCTIONS_SCREENING, SANCTIONS_HIT_DETECTED, SANCTIONS_HIT), new StateTransition<>(AML_SCREENING, AML_FLAGGED, MANUAL_REVIEW), new StateTransition<>(TRAVEL_RULE_PACKAGING, TRAVEL_RULE_FAILED, FAILED), - // Sanctions hit escalation new StateTransition<>(SANCTIONS_HIT, ESCALATE_MANUAL_REVIEW, MANUAL_REVIEW) )); diff --git a/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/domain/service/RiskScoringService.java b/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/domain/service/RiskScoringService.java index 7d7c0492..092dd58c 100644 --- a/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/domain/service/RiskScoringService.java +++ b/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/domain/service/RiskScoringService.java @@ -34,34 +34,29 @@ public RiskScore calculateScore(RiskScoringContext context) { var factors = new ArrayList(); int score = 0; - // Factor 1: KYC tier if (check.kycResult() != null && check.kycResult().senderKycTier() == KycTier.KYC_TIER_1) { score += weights.kycTier1Penalty(); factors.add("kyc_tier_1_sender"); } - // Factor 2: High-value transaction if (check.sourceAmount() != null && check.sourceAmount().compareTo(HIGH_VALUE_THRESHOLD) >= 0) { score += weights.highValuePenalty(); factors.add("high_value_transaction"); } - // Factor 3: AML flags if (check.amlResult() != null && check.amlResult().flagged()) { score += weights.amlFlagPenalty(); factors.add("aml_flagged"); } - // Factor 4: Cross-border if (check.sourceCountry() != null && check.targetCountry() != null && !check.sourceCountry().equals(check.targetCountry())) { score += weights.crossBorderPenalty(); factors.add("cross_border"); } - // Factor 5: Corridor risk (configurable per country pair) if (check.sourceCountry() != null && check.targetCountry() != null) { int corridorRisk = weights.corridorRisk(check.sourceCountry(), check.targetCountry()); if (corridorRisk > 0) { @@ -70,22 +65,18 @@ public RiskScore calculateScore(RiskScoringContext context) { } } - // Factor 6: New customer (no existing risk profile) if (context.customerProfile() == null) { score += weights.newCustomerPenalty(); factors.add("new_customer"); } - // Factor 7: Amount relative to tier limit score += amountToLimitScore(check.sourceAmount(), context.customerProfile(), factors); - // Factor 8: Transaction velocity if (context.recentTransactionCount() >= VELOCITY_THRESHOLD) { score += weights.highVelocityPenalty(); factors.add("high_velocity"); } - // Cap at 100 score = Math.min(score, MAX_SCORE); var band = RiskScore.bandForScore(score); diff --git a/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/domain/service/PayoutCommandHandler.java b/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/domain/service/PayoutCommandHandler.java index dc480b8a..fad233a5 100644 --- a/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/domain/service/PayoutCommandHandler.java +++ b/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/domain/service/PayoutCommandHandler.java @@ -48,7 +48,6 @@ public PayoutResult initiatePayout(UUID paymentId, UUID correlationId, UUID tran String recipientAccountHash, BankAccount bankAccount, MobileMoneyAccount mobileMoneyAccount, PaymentRail paymentRail, PartnerIdentifier offRampPartner) { - // 1. Idempotency: check if payout order already exists for this paymentId var existing = payoutOrderRepository.findByPaymentId(paymentId); if (existing.isPresent()) { log.info("Payout order already exists for paymentId={} payoutId={} status={}", @@ -56,13 +55,11 @@ public PayoutResult initiatePayout(UUID paymentId, UUID correlationId, UUID tran return new PayoutResult(existing.get(), false); } - // 2. Create new payout order in PENDING state var order = PayoutOrder.create(paymentId, correlationId, transferId, payoutType, stablecoin, redeemedAmount, targetCurrency, appliedFxRate, recipientId, recipientAccountHash, bankAccount, mobileMoneyAccount, paymentRail, offRampPartner); - // 3. HOLD_STABLECOIN path: skip redemption and payout if (payoutType == PayoutType.HOLD_STABLECOIN) { order = order.holdStablecoin().completeHold(); order = payoutOrderRepository.save(order); @@ -71,25 +68,20 @@ public PayoutResult initiatePayout(UUID paymentId, UUID correlationId, UUID tran return new PayoutResult(order, true); } - // 4. FIAT path: redeem stablecoin order = order.startRedemption(); var redemptionResult = redemptionGateway.redeem(new RedemptionRequest( order.payoutId(), stablecoin.ticker(), redeemedAmount, order.appliedFxRate())); - // 5. Complete redemption with fiat amount order = order.completeRedemption(redemptionResult.fiatReceived()); - // 5a. Save order before child records (FK constraint) order = payoutOrderRepository.save(order); - // 6. Record StablecoinRedemption var redemption = StablecoinRedemption.create( order.payoutId(), stablecoin, redeemedAmount, redemptionResult.fiatReceived(), redemptionResult.fiatCurrency(), offRampPartner.partnerName(), redemptionResult.partnerReference()); stablecoinRedemptionRepository.save(redemption); - // 7. Publish StablecoinRedeemedEvent via outbox eventPublisher.publish(new StablecoinRedeemedEvent( redemption.redemptionId(), order.payoutId(), @@ -101,7 +93,6 @@ public PayoutResult initiatePayout(UUID paymentId, UUID correlationId, UUID tran redemptionResult.fiatCurrency(), redemptionResult.redeemedAt())); - // 8. Initiate fiat payout with partner var payoutResult = payoutPartnerGateway.initiatePayout( new com.stablecoin.payments.offramp.domain.port.PayoutRequest( order.payoutId(), @@ -112,10 +103,8 @@ public PayoutResult initiatePayout(UUID paymentId, UUID correlationId, UUID tran paymentRail, offRampPartner)); - // 9. Transition to PAYOUT_INITIATED order = order.initiatePayout(payoutResult.partnerReference()); - // 10. Record OffRampTransaction for audit trail var offRampTxn = OffRampTransaction.create( order.payoutId(), offRampPartner.partnerName(), @@ -126,10 +115,8 @@ public PayoutResult initiatePayout(UUID paymentId, UUID correlationId, UUID tran null); offRampTransactionRepository.save(offRampTxn); - // 11. Save payout order (final state) order = payoutOrderRepository.save(order); - // 12. Publish FiatPayoutInitiatedEvent via outbox eventPublisher.publish(new FiatPayoutInitiatedEvent( order.payoutId(), paymentId, diff --git a/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/persistence/entity/PayoutOrderEntity.java b/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/persistence/entity/PayoutOrderEntity.java index 4666a608..1180f382 100644 --- a/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/persistence/entity/PayoutOrderEntity.java +++ b/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/persistence/entity/PayoutOrderEntity.java @@ -93,7 +93,6 @@ public class PayoutOrderEntity { @Column(name = "error_code", length = 100) private String errorCode; - // -- BankAccount VO flattened -- @Column(name = "bank_account_number", length = 50) private String bankAccountNumber; @@ -106,7 +105,6 @@ public class PayoutOrderEntity { @Column(name = "bank_country", length = 2) private String bankCountry; - // -- MobileMoneyAccount VO flattened -- @Column(name = "mobile_money_provider", length = 30) private String mobileMoneyProvider; diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/service/CollectionCommandHandler.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/service/CollectionCommandHandler.java index 705491b7..7c441142 100644 --- a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/service/CollectionCommandHandler.java +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/service/CollectionCommandHandler.java @@ -37,7 +37,6 @@ public class CollectionCommandHandler { public CollectionResult initiateCollection(UUID paymentId, UUID correlationId, Money amount, PaymentRail paymentRail, PspIdentifier psp, BankAccount senderAccount) { - // 1. Idempotency: check if collection order already exists for this paymentId var existing = collectionOrderRepository.findByPaymentId(paymentId); if (existing.isPresent()) { log.info("Collection order already exists for paymentId={} collectionId={} status={}", @@ -45,22 +44,17 @@ public CollectionResult initiateCollection(UUID paymentId, UUID correlationId, return new CollectionResult(existing.get(), false); } - // 2. Create new collection order in PENDING state var order = CollectionOrder.initiate(paymentId, correlationId, amount, paymentRail, psp, senderAccount); - // 3. Call PSP to initiate payment (collectionId as idempotency key for safe retries) var pspResult = pspGateway.initiatePayment(new PspPaymentRequest( order.collectionId(), amount, paymentRail, senderAccount, psp.pspName(), order.collectionId().toString())); - // 4. Transition: PENDING -> PAYMENT_INITIATED -> AWAITING_CONFIRMATION order = order.initiatePayment(); order = order.awaitConfirmation(pspResult.pspReference()); - // 5. Save collection order first (PspTransaction FK depends on it) order = collectionOrderRepository.save(order); - // 6. Record PspTransaction var pspTransaction = PspTransaction.create( order.collectionId(), psp.pspName(), @@ -72,7 +66,6 @@ public CollectionResult initiateCollection(UUID paymentId, UUID correlationId, null); pspTransactionRepository.save(pspTransaction); - // 7. Publish CollectionInitiatedEvent via outbox eventPublisher.publish(new CollectionInitiatedEvent( order.collectionId(), paymentId, diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/service/RefundCommandHandler.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/service/RefundCommandHandler.java index 2b93dca6..0bbb98f1 100644 --- a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/service/RefundCommandHandler.java +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/domain/service/RefundCommandHandler.java @@ -34,11 +34,9 @@ public class RefundCommandHandler { private final CollectionEventPublisher eventPublisher; public Refund initiateRefund(UUID collectionId, Money refundAmount, String reason) { - // 1. Find collection order var order = collectionOrderRepository.findById(collectionId) .orElseThrow(() -> new CollectionOrderNotFoundException(collectionId)); - // 2. Idempotency: check if refund already exists for this collection var existingRefunds = refundRepository.findByCollectionId(collectionId); var existingActive = existingRefunds.stream() .filter(r -> r.status() == RefundStatus.COMPLETED @@ -51,41 +49,31 @@ public Refund initiateRefund(UUID collectionId, Money refundAmount, String reaso return existingActive.get(); } - // 3. Validate collection is in COLLECTED state if (order.status() != CollectionStatus.COLLECTED) { throw new RefundNotAllowedException(collectionId, order.status()); } - // 4. Validate refund amount <= collected amount if (refundAmount.amount().compareTo(order.collectedAmount().amount()) > 0) { throw new RefundAmountExceededException(collectionId, refundAmount, order.collectedAmount()); } - // 5. Create Refund in PENDING and transition to PROCESSING var refund = Refund.initiate(collectionId, order.paymentId(), refundAmount, reason) .startProcessing(); - // 6. Transition collection through refund states up to REFUND_PROCESSING var updatedOrder = order.initiateRefund() .startRefundProcessing(); - // 7. Call PSP for refund (between startRefundProcessing and completeRefund - // so failure leaves order in REFUND_PROCESSING for retry/compensation) var pspResult = pspGateway.initiateRefund(new PspRefundRequest( collectionId, order.pspReference(), refundAmount, order.psp().pspName(), reason)); - // 8. Complete refund with PSP reference: PROCESSING -> COMPLETED refund = refund.complete(pspResult.pspRefundRef()); - // 9. Transition collection: REFUND_PROCESSING -> REFUNDED and persist once updatedOrder = updatedOrder.completeRefund(); collectionOrderRepository.save(updatedOrder); - // 10. Save refund refund = refundRepository.save(refund); - // 11. Publish RefundCompletedEvent via outbox eventPublisher.publish(new RefundCompletedEvent( refund.refundId(), collectionId, diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/service/FxQuoteApplicationService.java b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/service/FxQuoteApplicationService.java index fed27bfe..bdb30fb2 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/service/FxQuoteApplicationService.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/service/FxQuoteApplicationService.java @@ -32,7 +32,6 @@ public FxQuoteResponse getQuote(FxQuoteRequest request) { log.info("Getting FX quote for {}:{} amount={}", request.fromCurrency(), request.toCurrency(), request.amount()); - // Try cache first, then provider var corridorRate = rateCache.get(request.fromCurrency(), request.toCurrency()) .or(() -> { log.info("Cache miss for {}:{}, fetching from provider", diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/service/FxRateLockApplicationService.java b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/service/FxRateLockApplicationService.java index f53c8b92..1857f930 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/service/FxRateLockApplicationService.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/service/FxRateLockApplicationService.java @@ -45,7 +45,6 @@ public record LockRateResult(FxRateLockResponse response, boolean created) {} public LockRateResult lockRate(UUID quoteId, FxRateLockRequest request) { log.info("Locking rate for quote={} payment={}", quoteId, request.paymentId()); - // Idempotency: check if a lock already exists for this paymentId var existingLock = lockRepository.findByPaymentId(request.paymentId()); if (existingLock.isPresent()) { log.info("Idempotent lock return for payment={} lockId={}", @@ -53,35 +52,29 @@ public LockRateResult lockRate(UUID quoteId, FxRateLockRequest request) { return new LockRateResult(responseMapper.toResponse(existingLock.get()), false); } - // Load and validate quote var quote = quoteRepository.findById(quoteId) .orElseThrow(() -> QuoteNotFoundException.withId(quoteId)); validateQuote(quote); - // Load liquidity pool for corridor var pool = poolRepository.findByCorridor(quote.fromCurrency(), quote.toCurrency()) .orElseThrow(() -> PoolNotFoundException.forCorridor( quote.fromCurrency(), quote.toCurrency())); - // Check sufficient liquidity if (!pool.hasSufficientLiquidity(quote.targetAmount())) { throw InsufficientLiquidityException.forCorridor( quote.fromCurrency(), quote.toCurrency(), quote.targetAmount(), pool.availableBalance()); } - // Delegate to domain service var lockResult = lockService.lockRate( quote, request.paymentId(), request.correlationId(), request.sourceCountry(), request.targetCountry(), pool); - // Persist all changes quoteRepository.save(lockResult.lockedQuote()); var savedLock = lockRepository.save(lockResult.lock()); poolRepository.save(lockResult.updatedPool()); - // Publish domain event via outbox publishFxRateLockedEvent(savedLock, request.correlationId()); log.info("Rate locked: lockId={} rate={} expires={}", @@ -105,7 +98,6 @@ public void releaseLock(UUID lockId) { var expiredLock = lock.expire(); lockRepository.save(expiredLock); - // Release reserved liquidity back to the pool poolRepository.findByCorridor(lock.fromCurrency(), lock.toCurrency()) .ifPresentOrElse( pool -> { diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/domain/model/FxQuote.java b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/domain/model/FxQuote.java index bbf94d6d..9005a75f 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/domain/model/FxQuote.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/domain/model/FxQuote.java @@ -48,7 +48,6 @@ public static FxQuote create(String fromCurrency, String toCurrency, BigDecimal var feeBps = corridorRate.feeBps(); var spreadBps = corridorRate.spreadBps(); - // Apply spread to rate var spreadFactor = BigDecimal.ONE.subtract(BigDecimal.valueOf(spreadBps).movePointLeft(4)); var effectiveRate = rate.multiply(spreadFactor); diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/domain/service/LockService.java b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/domain/service/LockService.java index ffc36d81..46591e27 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/domain/service/LockService.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/domain/service/LockService.java @@ -19,26 +19,21 @@ public LockResult lockRate(FxQuote quote, UUID paymentId, UUID correlationId, LiquidityPool pool) { log.info("Locking rate for quote={} payment={}", quote.quoteId(), paymentId); - // Validate quote is lockable if (!quote.isActive()) { throw new IllegalStateException("Quote %s is not active (status=%s)" .formatted(quote.quoteId(), quote.status())); } - // Check liquidity if (!pool.hasSufficientLiquidity(quote.targetAmount())) { throw new IllegalStateException( "Insufficient liquidity in pool %s for amount %s (available=%s)" .formatted(pool.poolId(), quote.targetAmount(), pool.availableBalance())); } - // Lock the quote var lockedQuote = quote.lock(); - // Create the rate lock var lock = FxRateLock.fromQuote(quote, paymentId, correlationId, sourceCountry, targetCountry); - // Reserve liquidity var updatedPool = pool.reserve(quote.targetAmount()); log.info("Locked rate: lock={} rate={} expires={}", lock.lockId(), lock.lockedRate(), lock.expiresAt()); diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/messaging/PaymentEventConsumer.java b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/messaging/PaymentEventConsumer.java index df3deafc..00ab060f 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/messaging/PaymentEventConsumer.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/messaging/PaymentEventConsumer.java @@ -43,18 +43,15 @@ public void onPaymentCompleted(String message) { var lock = lockOpt.get(); - // Idempotency: already consumed if (lock.status() == FxRateLockStatus.CONSUMED) { log.info("[PAYMENT-EVENT] Lock already consumed lockId={} paymentId={}", lock.lockId(), event.paymentId()); return; } - // Consume the lock var consumedLock = lockService.consumeLock(lock, event.paymentId()); lockRepository.save(consumedLock); - // Consume pool reservation var pool = poolRepository.findByCorridor(lock.fromCurrency(), lock.toCurrency()) .orElseThrow(() -> new IllegalStateException( "Pool not found for corridor %s:%s".formatted(lock.fromCurrency(), lock.toCurrency()))); @@ -62,7 +59,6 @@ public void onPaymentCompleted(String message) { var updatedPool = liquidityService.consumeReservation(pool, lock.targetAmount()); poolRepository.save(updatedPool); - // Publish threshold breach event if needed if (updatedPool.isBelowThreshold()) { log.warn("[PAYMENT-EVENT] Liquidity threshold breached poolId={} available={} threshold={}", updatedPool.poolId(), updatedPool.availableBalance(), updatedPool.minimumThreshold()); @@ -93,18 +89,15 @@ public void onPaymentFailed(String message) { var lock = lockOpt.get(); - // Idempotency: already expired if (lock.status() == FxRateLockStatus.EXPIRED) { log.info("[PAYMENT-EVENT] Lock already expired lockId={} paymentId={}", lock.lockId(), event.paymentId()); return; } - // Expire the lock var expiredLock = lockService.expireLock(lock); lockRepository.save(expiredLock); - // Release pool reservation back to available var pool = poolRepository.findByCorridor(lock.fromCurrency(), lock.toCurrency()) .orElseThrow(() -> new IllegalStateException( "Pool not found for corridor %s:%s".formatted(lock.fromCurrency(), lock.toCurrency()))); diff --git a/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/domain/service/AccountingRules.java b/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/domain/service/AccountingRules.java index e8e0e456..ecf2d128 100644 --- a/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/domain/service/AccountingRules.java +++ b/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/domain/service/AccountingRules.java @@ -9,7 +9,6 @@ public final class AccountingRules { - // Chart of accounts codes public static final String FIAT_RECEIVABLE = "1000"; public static final String FIAT_CASH = "1001"; public static final String STABLECOIN_INVENTORY = "1010"; diff --git a/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/infrastructure/messaging/LedgerEventConsumer.java b/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/infrastructure/messaging/LedgerEventConsumer.java index 4c9ea7cd..eb4680f0 100644 --- a/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/infrastructure/messaging/LedgerEventConsumer.java +++ b/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/infrastructure/messaging/LedgerEventConsumer.java @@ -200,7 +200,6 @@ public void onPaymentCompleted(String message) { feeAmount, fxLeg.currency())); }); - // Finalize reconciliation — checks all 5 legs present and amounts within tolerance reconciliationCommandHandler.finalizeReconciliation(event.paymentId()); } diff --git a/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/application/controller/ErrorCodes.java b/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/application/controller/ErrorCodes.java index 3dd18d03..8d1e4fa3 100644 --- a/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/application/controller/ErrorCodes.java +++ b/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/application/controller/ErrorCodes.java @@ -7,7 +7,6 @@ @NoArgsConstructor(access = PRIVATE) public final class ErrorCodes { - // 4xx — client errors public static final String BAD_REQUEST_CODE = iamCode(1); public static final String USER_NOT_FOUND_CODE = iamCode(2); public static final String ROLE_NOT_FOUND_CODE = iamCode(3); @@ -21,7 +20,6 @@ public final class ErrorCodes { public static final String INVALID_USER_STATE_CODE = iamCode(11); public static final String MFA_REQUIRED_CODE = iamCode(12); - // 5xx — server errors public static final String INTERNAL_ERROR_CODE = iamCode(50); static String iamCode(int code) { diff --git a/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/domain/team/MerchantTeam.java b/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/domain/team/MerchantTeam.java index 577e6696..f8ec5f97 100644 --- a/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/domain/team/MerchantTeam.java +++ b/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/domain/team/MerchantTeam.java @@ -177,7 +177,6 @@ public MerchantUser createFirstAdmin(String email, String emailHash, String full public InviteResult inviteUser(String email, String emailHash, String fullName, UUID roleId, UUID invitedBy, String tokenHash) { - // Invariant: email unique within merchant (among non-deactivated users) var emailExists = users.stream() .anyMatch(u -> u.emailHash().equals(emailHash) && u.status() != UserStatus.DEACTIVATED); @@ -185,7 +184,6 @@ public InviteResult inviteUser(String email, String emailHash, String fullName, throw UserAlreadyExistsException.forMerchant(merchantId, email); } - // Verify role exists var role = findRoleById(roleId); var now = Instant.now(); @@ -254,7 +252,6 @@ public MerchantUser acceptInvitation(UUID invitationId, String fullName, String } invitations.set(invIdx, accepted); - // Find the corresponding INVITED user by emailHash var userIdx = findUserIndexByEmailHash(invitation.emailHash(), UserStatus.INVITED); var user = users.get(userIdx); var activated = user.acceptInvitation(fullName, passwordHash); @@ -284,7 +281,6 @@ public MerchantUser changeUserRole(UUID userId, UUID newRoleId, UUID changedBy) var previousRoleId = user.roleId(); var previousRole = findRoleById(previousRoleId); - // Invariant: cannot demote the last admin var adminRole = findRoleByName(BuiltInRole.ADMIN.name()); if (user.isAdmin(adminRole.roleId()) && !newRoleId.equals(adminRole.roleId())) { var adminCount = countActiveAdmins(adminRole.roleId()); @@ -317,7 +313,6 @@ public MerchantUser suspendUser(UUID userId, String reason, UUID suspendedBy) { var userIdx = findUserIndex(userId); var user = users.get(userIdx); - // Invariant: cannot suspend the last admin var adminRole = findRoleByName(BuiltInRole.ADMIN.name()); if (user.isAdmin(adminRole.roleId())) { var adminCount = countActiveAdmins(adminRole.roleId()); @@ -370,7 +365,6 @@ public MerchantUser deactivateUser(UUID userId, String reason, UUID deactivatedB var userIdx = findUserIndex(userId); var user = users.get(userIdx); - // Invariant: cannot deactivate the last admin var adminRole = findRoleByName(BuiltInRole.ADMIN.name()); if (user.isAdmin(adminRole.roleId()) && user.isActive()) { var adminCount = countActiveAdmins(adminRole.roleId()); diff --git a/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/infrastructure/persistence/adapter/RoleRepositoryAdapter.java b/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/infrastructure/persistence/adapter/RoleRepositoryAdapter.java index 3208c4b4..7d460181 100644 --- a/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/infrastructure/persistence/adapter/RoleRepositoryAdapter.java +++ b/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/infrastructure/persistence/adapter/RoleRepositoryAdapter.java @@ -59,7 +59,6 @@ public Role save(Role role) { entity.setCreatedBy(role.createdBy()); entity.setUpdatedAt(role.updatedAt()); - // Determine whether permissions have changed var incomingPerms = role.permissions().stream() .map(p -> p.namespace() + ":" + p.action()) .collect(Collectors.toSet()); @@ -68,17 +67,14 @@ public Role save(Role role) { .collect(Collectors.toSet()); if (!incomingPerms.equals(existingPerms)) { - // Clear the managed collection so orphanRemoval issues DELETEs, - // flush to push DELETEs to DB before new INSERTs (avoids unique constraint violation), - // then add the replacement permission entities. + // Clear + flush before re-adding: orphanRemoval DELETEs must hit the DB before + // new INSERTs to avoid a unique constraint violation on (role_id, permission). entity.getPermissions().clear(); entityManager.flush(); entity.getPermissions().addAll(mapper.buildPermissionEntities(entity, role.permissions())); } - // Save non-permission fields; the updated permissions collection is cascade-flushed. jpa.save(entity); - // Evict and reload to get a clean domain object with the persisted permissions. entityManager.flush(); entityManager.refresh(entity); return mapper.toDomain(entity); diff --git a/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/config/SecurityConfig.java b/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/config/SecurityConfig.java index f99bdad2..49208f74 100644 --- a/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/config/SecurityConfig.java +++ b/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/config/SecurityConfig.java @@ -22,9 +22,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti http.csrf(csrf -> csrf.disable()).authorizeHttpRequests(auth -> auth // Webhook endpoints use HMAC validation, not JWT .requestMatchers("/api/internal/webhooks/**").permitAll() - // Actuator health/readiness .requestMatchers("/actuator/**").permitAll() - // OpenAPI / Swagger UI .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html").permitAll() .anyRequest().authenticated()); return http.build(); diff --git a/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/controller/ErrorCodes.java b/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/controller/ErrorCodes.java index 6e570a6f..aeff754e 100644 --- a/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/controller/ErrorCodes.java +++ b/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/controller/ErrorCodes.java @@ -7,13 +7,11 @@ @NoArgsConstructor(access = PRIVATE) public final class ErrorCodes { - // 4xx — client errors public static final String BAD_REQUEST_CODE = moCode(1); public static final String MERCHANT_NOT_FOUND_CODE = moCode(2); public static final String MERCHANT_ALREADY_EXISTS_CODE = moCode(3); public static final String INVALID_STATE_CODE = moCode(4); - // 5xx — server errors public static final String INTERNAL_ERROR_CODE = moCode(50); static String moCode(int code) { diff --git a/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/controller/KybWebhookController.java b/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/controller/KybWebhookController.java index 2f5a9d88..c07e2059 100644 --- a/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/controller/KybWebhookController.java +++ b/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/controller/KybWebhookController.java @@ -53,7 +53,6 @@ public ResponseEntity handleOnfidoWebhook(@RequestBody String rawBody, return ResponseEntity.ok().build(); } - // Signal the Temporal workflow with the KYB result var signal = new KybResultSignal(result.kybId(), result.provider(), result.providerRef(), result.status().name(), result.riskSignals(), result.reviewNotes(), result.completedAt()); diff --git a/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/infrastructure/kyb/OnfidoKybAdapter.java b/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/infrastructure/kyb/OnfidoKybAdapter.java index 59936226..bbc3db77 100644 --- a/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/infrastructure/kyb/OnfidoKybAdapter.java +++ b/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/infrastructure/kyb/OnfidoKybAdapter.java @@ -121,7 +121,6 @@ public KybVerification handleWebhook(Map payload) { return null; } - // Fetch full check details @SuppressWarnings("unchecked") var checkResponse = restClient.get().uri("/checks/{checkId}", checkId).retrieve().body(Map.class); @@ -159,7 +158,6 @@ private Map buildRiskSignals(String result, Map signals.put("onfido_result", result); signals.put("provider", "onfido"); - // Map result to risk_score for RiskTierCalculator int riskScore = switch (result != null ? result : "") { case "clear" -> 0; case "consider" -> 35; diff --git a/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/infrastructure/temporal/workflow/MerchantOnboardingWorkflowImpl.java b/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/infrastructure/temporal/workflow/MerchantOnboardingWorkflowImpl.java index 9490a804..bd565169 100644 --- a/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/infrastructure/temporal/workflow/MerchantOnboardingWorkflowImpl.java +++ b/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/infrastructure/temporal/workflow/MerchantOnboardingWorkflowImpl.java @@ -33,7 +33,6 @@ public class MerchantOnboardingWorkflowImpl implements MerchantOnboardingWorkflo RetryOptions.newBuilder().setMaximumAttempts(5).setInitialInterval(Duration.ofSeconds(1)).build()) .build()); - // Signal state private KybResultSignal kybResult; private ReviewDecisionSignal reviewResult; private String currentStatus = "STARTED"; @@ -87,11 +86,9 @@ public OnboardingResult runOnboarding(UUID merchantId) { onboardingActivities.processKybResult(merchantId, kybResult); onboardingActivities.notifyOpsTeam(merchantId); - // Wait for ops review decision boolean reviewReceived = Workflow.await(REVIEW_TIMEOUT, () -> reviewResult != null); if (!reviewReceived) { - // Escalate and wait again onboardingActivities.escalateReview(merchantId); reviewReceived = Workflow.await(REVIEW_TIMEOUT, () -> reviewResult != null); } @@ -109,8 +106,6 @@ public OnboardingResult runOnboarding(UUID merchantId) { publishKybFailedEvent(merchantId, reviewResult.reason()); return OnboardingResult.rejected(merchantId, reviewResult.reason()); } - - // APPROVED — fall through to risk tier calculation } var riskSignals = kybResult.riskSignals() != null ? kybResult.riskSignals() : Map.of(); @@ -132,7 +127,6 @@ public void kybResultReceived(KybResultSignal signal) { @Override public void documentUploaded(DocumentUploadedSignal signal) { - // Future: track document uploads for document-wait phase } @Override diff --git a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/workflow/PaymentWorkflowImpl.java b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/workflow/PaymentWorkflowImpl.java index f7e74ba9..d2b5abb7 100644 --- a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/workflow/PaymentWorkflowImpl.java +++ b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/workflow/PaymentWorkflowImpl.java @@ -135,13 +135,11 @@ public class PaymentWorkflowImpl implements PaymentWorkflow { .build()) .build()); - // Workflow state — deterministic, no external I/O private String currentState = "INITIATED"; private boolean cancelRequested; private CancelRequest cancelReason; private final Deque compensationStack = new ArrayDeque<>(); - // Async signal state private FiatCollectedSignal fiatCollectedSignal; private ChainConfirmedSignal chainConfirmedSignal; @@ -274,7 +272,6 @@ public PaymentResult executePayment(PaymentRequest request) { log.info("Fiat collection initiated for paymentId={}, collectionId={}, waiting for signal", request.paymentId(), fiatResult.collectionId()); - // Wait for fiat collected signal (Stripe webhook → S3 → Kafka → S1 signal) boolean fiatReceived = Workflow.await(FIAT_COLLECTION_TIMEOUT, () -> fiatCollectedSignal != null); @@ -305,8 +302,8 @@ public PaymentResult executePayment(PaymentRequest request) { request.correlationId(), "USDC", fxResult.targetAmount(), - "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18", // Default recipient wallet - "base" // Preferred chain + "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18", + "base" )); } catch (Exception e) { currentState = "FAILED"; @@ -332,7 +329,6 @@ public PaymentResult executePayment(PaymentRequest request) { log.info("Chain transfer submitted for paymentId={}, transferId={}, txHash={}, waiting for confirmation", request.paymentId(), chainResult.transferId(), chainResult.txHash()); - // Wait for chain confirmation signal (S4 monitor → Kafka → S1 signal) boolean chainConfirmed = Workflow.await(CHAIN_CONFIRMATION_TIMEOUT, () -> chainConfirmedSignal != null); @@ -394,7 +390,6 @@ public PaymentResult executePayment(PaymentRequest request) { currentState = "COMPLETED"; log.info("Payment workflow completed for paymentId={}", request.paymentId()); - // Sync terminal state to DB syncStateToDb(PaymentStateUpdate.completed( request.paymentId(), fxResult.quoteId(), fxResult.lockedRate(), fxResult.targetAmount(), fxResult.targetCurrency(), diff --git a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/infrastructure/activity/ComplianceCheckActivityImpl.java b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/infrastructure/activity/ComplianceCheckActivityImpl.java index 3bd8ca6f..ccc5648c 100644 --- a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/infrastructure/activity/ComplianceCheckActivityImpl.java +++ b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/infrastructure/activity/ComplianceCheckActivityImpl.java @@ -48,7 +48,6 @@ public ComplianceResult checkCompliance(ComplianceRequest request) { log.info("Compliance check initiated for paymentId={}, checkId={}, status={}", request.paymentId(), response.checkId(), response.status()); - // Poll until terminal state while (!isTerminal(response.status())) { Activity.getExecutionContext().heartbeat(response.status()); sleep(POLL_INTERVAL_MS); diff --git a/platform-infra/src/main/java/com/stablecoin/payments/platform/infrastructure/messaging/AbstractOutboxEventPublisher.java b/platform-infra/src/main/java/com/stablecoin/payments/platform/infrastructure/messaging/AbstractOutboxEventPublisher.java index c2e86d56..6133ca59 100644 --- a/platform-infra/src/main/java/com/stablecoin/payments/platform/infrastructure/messaging/AbstractOutboxEventPublisher.java +++ b/platform-infra/src/main/java/com/stablecoin/payments/platform/infrastructure/messaging/AbstractOutboxEventPublisher.java @@ -31,7 +31,6 @@ private String resolveKey(Object event) { return String.valueOf(value); } } catch (NoSuchMethodException ignored) { - // try next field name } catch (Exception e) { throw new IllegalArgumentException( "Error invoking accessor '" + fieldName + "' on " + event.getClass().getName(), e); diff --git a/platform-infra/src/main/java/com/stablecoin/payments/platform/infrastructure/tracing/TracingConfig.java b/platform-infra/src/main/java/com/stablecoin/payments/platform/infrastructure/tracing/TracingConfig.java index d2696204..f43c7ff7 100644 --- a/platform-infra/src/main/java/com/stablecoin/payments/platform/infrastructure/tracing/TracingConfig.java +++ b/platform-infra/src/main/java/com/stablecoin/payments/platform/infrastructure/tracing/TracingConfig.java @@ -6,17 +6,4 @@ @Configuration @ConditionalOnProperty(name = "app.tracing.enabled", havingValue = "true", matchIfMissing = true) public class TracingConfig { - - // Spring Boot auto-configures all tracing beans (OtlpHttpSpanExporter, TracerProvider, - // ContextPropagators) when micrometer-tracing-bridge-otel and opentelemetry-exporter-otlp - // are on the classpath. No manual bean definitions needed. - // - // Key application properties driving behavior: - // management.tracing.sampling.probability — sampling rate (1.0 = 100% in dev) - // management.otlp.tracing.endpoint — OTLP collector HTTP endpoint - // - // This @Configuration class exists to: - // 1. Provide a single toggle (app.tracing.enabled) to disable all tracing - // 2. Document the tracing architecture for developers - // 3. Serve as an extension point for custom SpanProcessor/SpanExporter beans } From d411ccd450081223c7b4e0e389f6646f5b65f2c6 Mon Sep 17 00:00:00 2001 From: Puneethkumar CK Date: Sat, 11 Apr 2026 16:47:30 +0200 Subject: [PATCH 3/6] test(infra): replace Mockito generic matchers with literal values (STA-249) Eliminate any()/eq()/anyString()/anyLong()/anyInt()/anyBoolean()/ anyList() from 30 unit test files across api-gateway-iam (14), payment-orchestrator (6), fx-liquidity-engine (3), merchant-onboarding (3), merchant-iam (2), compliance-travel-rule (1), ledger-accounting (1). ~180 matcher usages removed. Stubs now take the literal values the SUT actually passes; save verifications use eqIgnoringTimestamps / eqIgnoring from platform-test TestUtils. Where runtime-generated values genuinely cannot be pre-constructed (workflowId from generated paymentId, Duration computed at revoke time) we use typed argThat(lambda) -- the same escape hatch eqIgnoring itself uses. Verified: 1,467 unit tests pass across all 7 affected modules. --- .../controller/ApiKeyControllerTest.java | 16 +- .../controller/AuthControllerTest.java | 12 +- .../controller/MerchantControllerTest.java | 18 +- .../controller/OAuthClientControllerTest.java | 16 +- .../security/AuditLogFilterTest.java | 70 ++++--- .../security/RateLimitFilterTest.java | 13 +- .../service/ApiKeyCommandHandlerTest.java | 14 +- .../iam/domain/service/ApiKeyServiceTest.java | 40 +++- .../service/AuthCommandHandlerTest.java | 37 ++-- .../iam/domain/service/AuthServiceTest.java | 25 ++- .../service/MerchantCommandHandlerTest.java | 57 ++++-- .../domain/service/MerchantServiceTest.java | 53 ++++- .../OAuthClientCommandHandlerTest.java | 10 +- .../service/OAuthClientServiceTest.java | 42 +++- .../ComplianceCheckCommandHandlerTest.java | 189 +++++++++--------- .../FxQuoteApplicationServiceTest.java | 8 +- .../FxRateLockApplicationServiceTest.java | 44 ++-- .../messaging/PaymentEventConsumerTest.java | 70 +++++-- .../LedgerOutboxEventPublisherTest.java | 46 ++--- .../iam/domain/team/AuthServiceTest.java | 29 ++- .../domain/team/MerchantTeamServiceTest.java | 42 ++-- .../controller/KybWebhookControllerTest.java | 25 ++- .../merchant/MerchantCommandHandlerTest.java | 21 +- .../MerchantOnboardingWorkflowTest.java | 19 +- .../controller/PaymentControllerMvcTest.java | 29 ++- .../controller/PaymentControllerTest.java | 29 ++- .../service/PaymentCommandHandlerTest.java | 68 +++---- .../domain/workflow/PaymentWorkflowTest.java | 142 ++++++++----- .../ComplianceCheckActivityImplTest.java | 23 ++- .../activity/FxLockActivityImplTest.java | 9 +- 30 files changed, 740 insertions(+), 476 deletions(-) diff --git a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/controller/ApiKeyControllerTest.java b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/controller/ApiKeyControllerTest.java index dceef416..2886dc3f 100644 --- a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/controller/ApiKeyControllerTest.java +++ b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/controller/ApiKeyControllerTest.java @@ -3,6 +3,7 @@ import com.stablecoin.payments.gateway.iam.api.response.ApiKeyResponse; import com.stablecoin.payments.gateway.iam.application.controller.mapper.GatewayRequestResponseMapper; import com.stablecoin.payments.gateway.iam.domain.exception.MerchantNotFoundException; +import com.stablecoin.payments.gateway.iam.domain.model.ApiKeyEnvironment; import com.stablecoin.payments.gateway.iam.domain.service.ApiKeyCommandHandler; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -17,7 +18,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; @@ -38,15 +38,18 @@ class ApiKeyControllerTest { @DisplayName("createApiKey should return key with raw key") void shouldCreateApiKey() { var keyId = UUID.randomUUID(); + var merchantId = UUID.randomUUID(); + var commandResult = new ApiKeyCommandHandler.CreateApiKeyResult(null, "pk_live_raw123"); var response = new ApiKeyResponse( keyId, "pk_live_raw123", "pk_live_abc", "My Key", "LIVE", List.of("payments:read"), List.of(), Instant.now().plusSeconds(86400), Instant.now()); - given(apiKeyCommandHandler.create(any(), any(), any(), any(), any(), any())) - .willReturn(new ApiKeyCommandHandler.CreateApiKeyResult(null, "pk_live_raw123")); - given(mapper.toApiKeyResponse(any())).willReturn(response); + given(apiKeyCommandHandler.create(merchantId, "My Key", ApiKeyEnvironment.LIVE, + List.of("payments:read"), List.of(), null)) + .willReturn(commandResult); + given(mapper.toApiKeyResponse(commandResult)).willReturn(response); var request = new com.stablecoin.payments.gateway.iam.api.request.CreateApiKeyRequest( - UUID.randomUUID(), "My Key", "LIVE", List.of("payments:read"), null, null); + merchantId, "My Key", "LIVE", List.of("payments:read"), null, null); var result = controller.createApiKey(request); @@ -59,7 +62,8 @@ void shouldCreateApiKey() { @DisplayName("createApiKey should throw when merchant not found") void shouldThrowWhenMerchantNotFound() { var merchantId = UUID.randomUUID(); - given(apiKeyCommandHandler.create(any(), any(), any(), any(), any(), any())) + given(apiKeyCommandHandler.create(merchantId, "My Key", ApiKeyEnvironment.LIVE, + List.of(), List.of(), null)) .willThrow(MerchantNotFoundException.byId(merchantId)); var request = new com.stablecoin.payments.gateway.iam.api.request.CreateApiKeyRequest( diff --git a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/controller/AuthControllerTest.java b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/controller/AuthControllerTest.java index b282490e..07ce7c86 100644 --- a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/controller/AuthControllerTest.java +++ b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/controller/AuthControllerTest.java @@ -16,10 +16,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; @@ -42,7 +38,8 @@ void shouldIssueToken() { var clientId = UUID.randomUUID(); var tokenResult = new AuthCommandHandler.TokenResult("jwt-token", UUID.randomUUID(), 3600L, List.of("payments:read")); var tokenResponse = new TokenResponse("jwt-token", "Bearer", 3600, "payments:read"); - given(authCommandHandler.issueToken(eq(clientId), anyString(), anyList())).willReturn(tokenResult); + given(authCommandHandler.issueToken(clientId, "secret", List.of("payments:read"))) + .willReturn(tokenResult); given(mapper.toTokenResponse(tokenResult)).willReturn(tokenResponse); var request = new com.stablecoin.payments.gateway.iam.api.request.TokenRequest( @@ -58,11 +55,12 @@ void shouldIssueToken() { @Test @DisplayName("issueToken should propagate invalid credentials") void shouldPropagateInvalidCredentials() { - given(authCommandHandler.issueToken(any(), anyString(), anyList())) + var clientId = UUID.randomUUID(); + given(authCommandHandler.issueToken(clientId, "wrong", List.of())) .willThrow(InvalidClientCredentialsException.clientNotFound()); var request = new com.stablecoin.payments.gateway.iam.api.request.TokenRequest( - "client_credentials", UUID.randomUUID(), "wrong", null); + "client_credentials", clientId, "wrong", null); assertThatThrownBy(() -> controller.issueToken(request)) .isInstanceOf(InvalidClientCredentialsException.class); diff --git a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/controller/MerchantControllerTest.java b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/controller/MerchantControllerTest.java index fff40dfa..8240d179 100644 --- a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/controller/MerchantControllerTest.java +++ b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/controller/MerchantControllerTest.java @@ -18,7 +18,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; @ExtendWith(MockitoExtension.class) @@ -38,15 +37,18 @@ class MerchantControllerTest { @DisplayName("createMerchant should return created merchant") void shouldCreateMerchant() { var merchantId = UUID.randomUUID(); + var externalId = UUID.randomUUID(); + var registered = Merchant.builder().build(); var response = new MerchantResponse( merchantId, UUID.randomUUID(), "Test Co", "US", List.of("payments:read"), "ACTIVE", "VERIFIED", "STARTER", Instant.now()); - given(merchantCommandHandler.register(any(), any(), any(), any(), any())) - .willReturn(Merchant.builder().build()); - given(mapper.toMerchantResponse(any(Merchant.class))).willReturn(response); + given(merchantCommandHandler.register(externalId, "Test Co", "US", + List.of("payments:read"), List.of())) + .willReturn(registered); + given(mapper.toMerchantResponse(registered)).willReturn(response); var request = new com.stablecoin.payments.gateway.iam.api.request.CreateMerchantRequest( - UUID.randomUUID(), "Test Co", "US", List.of("payments:read"), null); + externalId, "Test Co", "US", List.of("payments:read"), null); var result = controller.createMerchant(request); @@ -57,12 +59,12 @@ void shouldCreateMerchant() { @DisplayName("getMerchant should return merchant by id") void shouldGetMerchant() { var merchantId = UUID.randomUUID(); + var merchant = Merchant.builder().build(); var response = new MerchantResponse( merchantId, UUID.randomUUID(), "Test Co", "US", List.of("payments:read"), "ACTIVE", "VERIFIED", "STARTER", Instant.now()); - given(merchantCommandHandler.findById(merchantId)) - .willReturn(Merchant.builder().build()); - given(mapper.toMerchantResponse(any(Merchant.class))).willReturn(response); + given(merchantCommandHandler.findById(merchantId)).willReturn(merchant); + given(mapper.toMerchantResponse(merchant)).willReturn(response); var result = controller.getMerchant(merchantId); diff --git a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/controller/OAuthClientControllerTest.java b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/controller/OAuthClientControllerTest.java index 9bbf4e99..02b184a0 100644 --- a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/controller/OAuthClientControllerTest.java +++ b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/controller/OAuthClientControllerTest.java @@ -19,8 +19,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; @ExtendWith(MockitoExtension.class) @@ -41,12 +39,14 @@ class OAuthClientControllerTest { void shouldCreateOAuthClient() { var merchantId = UUID.randomUUID(); var clientId = UUID.randomUUID(); + var commandResult = new OAuthClientCommandHandler.CreateOAuthClientResult(null, "raw-secret-hex"); var response = new OAuthClientResponse( clientId, "raw-secret-hex", merchantId, "My Client", List.of("payments:read"), List.of("client_credentials"), Instant.now()); - given(oauthClientCommandHandler.create(eq(merchantId), any(), any(), any())) - .willReturn(new OAuthClientCommandHandler.CreateOAuthClientResult(null, "raw-secret-hex")); - given(mapper.toOAuthClientResponse(any())).willReturn(response); + given(oauthClientCommandHandler.create(merchantId, "My Client", + List.of("payments:read"), List.of("client_credentials"))) + .willReturn(commandResult); + given(mapper.toOAuthClientResponse(commandResult)).willReturn(response); var request = new CreateOAuthClientRequest( "My Client", List.of("payments:read"), List.of("client_credentials")); @@ -63,7 +63,8 @@ void shouldCreateOAuthClient() { @DisplayName("createOAuthClient should throw when merchant not found") void shouldThrowWhenMerchantNotFound() { var merchantId = UUID.randomUUID(); - given(oauthClientCommandHandler.create(eq(merchantId), any(), any(), any())) + given(oauthClientCommandHandler.create(merchantId, "Client", + List.of(), List.of("client_credentials"))) .willThrow(MerchantNotFoundException.byId(merchantId)); var request = new CreateOAuthClientRequest("Client", null, null); @@ -76,7 +77,8 @@ void shouldThrowWhenMerchantNotFound() { @DisplayName("createOAuthClient should throw when merchant not active") void shouldThrowWhenMerchantNotActive() { var merchantId = UUID.randomUUID(); - given(oauthClientCommandHandler.create(eq(merchantId), any(), any(), any())) + given(oauthClientCommandHandler.create(merchantId, "Client", + List.of(), List.of("client_credentials"))) .willThrow(MerchantNotActiveException.of(merchantId)); var request = new CreateOAuthClientRequest("Client", null, null); diff --git a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/security/AuditLogFilterTest.java b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/security/AuditLogFilterTest.java index 64d3aa3d..0b43e52d 100644 --- a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/security/AuditLogFilterTest.java +++ b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/security/AuditLogFilterTest.java @@ -9,7 +9,6 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.web.MockHttpServletRequest; @@ -18,14 +17,13 @@ import java.io.IOException; import java.util.List; +import java.util.Map; import java.util.UUID; -import static org.assertj.core.api.Assertions.assertThat; +import static com.stablecoin.payments.gateway.iam.fixtures.TestUtils.eqIgnoring; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.then; import static org.mockito.BDDMockito.willThrow; -import static org.mockito.Mockito.never; @ExtendWith(MockitoExtension.class) class AuditLogFilterTest { @@ -65,7 +63,7 @@ void shouldAlwaysCallFilterChain() throws ServletException, IOException { void shouldSkipAuditWhenNotAuthenticated() throws ServletException, IOException { filter.doFilterInternal(request, response, filterChain); - then(auditLogRepository).should(never()).save(any()); + then(auditLogRepository).shouldHaveNoInteractions(); } @Nested @@ -87,18 +85,17 @@ void shouldPersistAuditLogEntry() throws ServletException, IOException { filter.doFilterInternal(request, response, filterChain); - var captor = ArgumentCaptor.forClass(AuditLogEntry.class); - then(auditLogRepository).should().save(captor.capture()); - - var entry = captor.getValue(); - assertThat(entry.getMerchantId()).isEqualTo(merchantId); - assertThat(entry.getAction()).isEqualTo("POST"); - assertThat(entry.getResource()).isEqualTo("/v1/payments"); - assertThat(entry.getSourceIp()).isEqualTo("10.0.0.1"); - assertThat(entry.getDetail()).containsEntry("status_code", 201); - assertThat(entry.getDetail()).containsEntry("auth_method", "API_KEY"); - assertThat(entry.getDetail()).containsEntry("client_id", clientId.toString()); - assertThat(entry.getOccurredAt()).isNotNull(); + var expectedEntry = AuditLogEntry.builder() + .merchantId(merchantId) + .action("POST") + .resource("/v1/payments") + .sourceIp("10.0.0.1") + .detail(Map.of( + "status_code", 201, + "auth_method", "API_KEY", + "client_id", clientId.toString())) + .build(); + then(auditLogRepository).should().save(eqIgnoring(expectedEntry, "logId", "occurredAt")); } @Test @@ -109,20 +106,37 @@ void shouldRecordJwtAuthMethod() throws ServletException, IOException { filter.doFilterInternal(request, response, filterChain); - var captor = ArgumentCaptor.forClass(AuditLogEntry.class); - then(auditLogRepository).should().save(captor.capture()); - assertThat(captor.getValue().getDetail()).containsEntry("auth_method", "JWT"); + var expectedEntry = AuditLogEntry.builder() + .merchantId(merchantId) + .action("POST") + .resource("/v1/payments") + .sourceIp("10.0.0.1") + .detail(Map.of( + "status_code", 200, + "auth_method", "JWT", + "client_id", clientId.toString())) + .build(); + then(auditLogRepository).should().save(eqIgnoring(expectedEntry, "logId", "occurredAt")); } @Test void shouldNotFailWhenRepositoryThrows() throws ServletException, IOException { + var throwingEntry = AuditLogEntry.builder() + .merchantId(merchantId) + .action("POST") + .resource("/v1/payments") + .sourceIp("10.0.0.1") + .detail(Map.of( + "status_code", 200, + "auth_method", "API_KEY", + "client_id", clientId.toString())) + .build(); willThrow(new RuntimeException("DB down")) - .given(auditLogRepository).save(any()); + .given(auditLogRepository).save(eqIgnoring(throwingEntry, "logId", "occurredAt")); filter.doFilterInternal(request, response, filterChain); then(filterChain).should().doFilter(request, response); - // No exception propagated — filter swallows it } @Test @@ -133,7 +147,17 @@ void shouldAuditEvenWhenFilterChainThrows() throws ServletException, IOException assertThatThrownBy(() -> filter.doFilterInternal(request, response, filterChain)) .isInstanceOf(ServletException.class); - then(auditLogRepository).should().save(any()); + var expectedEntry = AuditLogEntry.builder() + .merchantId(merchantId) + .action("POST") + .resource("/v1/payments") + .sourceIp("10.0.0.1") + .detail(Map.of( + "status_code", 200, + "auth_method", "API_KEY", + "client_id", clientId.toString())) + .build(); + then(auditLogRepository).should().save(eqIgnoring(expectedEntry, "logId", "occurredAt")); } } } diff --git a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/security/RateLimitFilterTest.java b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/security/RateLimitFilterTest.java index 11b642e2..253e92da 100644 --- a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/security/RateLimitFilterTest.java +++ b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/security/RateLimitFilterTest.java @@ -2,6 +2,7 @@ import com.stablecoin.payments.gateway.iam.domain.model.Merchant; import com.stablecoin.payments.gateway.iam.domain.model.MerchantStatus; +import com.stablecoin.payments.gateway.iam.domain.model.RateLimitEvent; import com.stablecoin.payments.gateway.iam.domain.model.RateLimitPolicy; import com.stablecoin.payments.gateway.iam.domain.model.RateLimitTier; import com.stablecoin.payments.gateway.iam.domain.port.MerchantRepository; @@ -27,8 +28,8 @@ import java.util.Optional; import java.util.UUID; +import static com.stablecoin.payments.gateway.iam.fixtures.TestUtils.eqIgnoring; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.never; @@ -216,7 +217,15 @@ void shouldPersistRateLimitEvent() throws ServletException, IOException { filter.doFilterInternal(request, response, filterChain); - then(rateLimitEventRepository).should().save(any()); + var expectedEvent = RateLimitEvent.builder() + .merchantId(merchantId) + .endpoint(endpoint) + .tier(RateLimitTier.STARTER) + .requestCount(61) + .limitValue(60) + .breached(true) + .build(); + then(rateLimitEventRepository).should().save(eqIgnoring(expectedEvent, "eventId", "occurredAt")); } } } diff --git a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/ApiKeyCommandHandlerTest.java b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/ApiKeyCommandHandlerTest.java index ed97765b..2f6f6dfd 100644 --- a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/ApiKeyCommandHandlerTest.java +++ b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/ApiKeyCommandHandlerTest.java @@ -35,7 +35,6 @@ import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; @@ -85,7 +84,6 @@ void shouldCreateApiKey() { given(apiKeyGenerator.generate(ApiKeyEnvironment.LIVE)) .willReturn(new ApiKeyGenerator.GeneratedApiKey("pk_live_abc123", "pk_live_")); given(apiKeyHasher.hash("pk_live_abc123")).willReturn("sha256hash"); - given(apiKeyRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); var expected = ApiKey.builder() .merchantId(MERCHANT_ID) @@ -98,6 +96,8 @@ void shouldCreateApiKey() { .active(true) .version(0L) .build(); + given(apiKeyRepository.save(eqIgnoring(expected, "keyId"))) + .willAnswer(inv -> inv.getArgument(0)); apiKeyCommandHandler.create(MERCHANT_ID, "My Key", ApiKeyEnvironment.LIVE, List.of("payments:read"), List.of("10.0.0.1"), null); @@ -111,7 +111,6 @@ void shouldUseAllMerchantScopesWhenNoneRequested() { given(apiKeyGenerator.generate(ApiKeyEnvironment.LIVE)) .willReturn(new ApiKeyGenerator.GeneratedApiKey("pk_live_abc123", "pk_live_")); given(apiKeyHasher.hash("pk_live_abc123")).willReturn("sha256hash"); - given(apiKeyRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); var expected = ApiKey.builder() .merchantId(MERCHANT_ID) @@ -124,6 +123,8 @@ void shouldUseAllMerchantScopesWhenNoneRequested() { .active(true) .version(0L) .build(); + given(apiKeyRepository.save(eqIgnoring(expected, "keyId"))) + .willAnswer(inv -> inv.getArgument(0)); apiKeyCommandHandler.create(MERCHANT_ID, "My Key", ApiKeyEnvironment.LIVE, null, null, null); @@ -180,16 +181,13 @@ void shouldRevokeAndPublishEvent() { .active(true).createdAt(Instant.now()) .updatedAt(Instant.now()).version(0L).build(); given(apiKeyRepository.findById(keyId)).willReturn(Optional.of(apiKey)); - given(apiKeyRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + + apiKeyCommandHandler.revoke(keyId); var expectedKey = apiKey.toBuilder() .active(false) .build(); - var expectedEvent = new ApiKeyRevokedEvent(keyId, MERCHANT_ID, "pk_live_", null); - - apiKeyCommandHandler.revoke(keyId); - then(apiKeyRepository).should().save(eqIgnoringTimestamps(expectedKey)); then(eventPublisher).should().publish(eqIgnoringTimestamps(expectedEvent)); } diff --git a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/ApiKeyServiceTest.java b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/ApiKeyServiceTest.java index 383c154f..0c5f67e7 100644 --- a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/ApiKeyServiceTest.java +++ b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/ApiKeyServiceTest.java @@ -32,9 +32,10 @@ import java.util.Optional; import java.util.UUID; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; @@ -84,7 +85,20 @@ void shouldCreateApiKey() { given(apiKeyGenerator.generate(ApiKeyEnvironment.LIVE)) .willReturn(new ApiKeyGenerator.GeneratedApiKey("pk_live_abc123", "pk_live_")); given(apiKeyHasher.hash("pk_live_abc123")).willReturn("sha256hash"); - given(apiKeyRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + + var expected = ApiKey.builder() + .merchantId(MERCHANT_ID) + .keyHash("sha256hash") + .keyPrefix("pk_live_") + .name("My Key") + .environment(ApiKeyEnvironment.LIVE) + .scopes(List.of("payments:read")) + .allowedIps(List.of("10.0.0.1")) + .active(true) + .version(0L) + .build(); + given(apiKeyRepository.save(eqIgnoring(expected, "keyId"))) + .willAnswer(inv -> inv.getArgument(0)); var result = apiKeyService.create(MERCHANT_ID, "My Key", ApiKeyEnvironment.LIVE, List.of("payments:read"), List.of("10.0.0.1"), null); @@ -100,7 +114,20 @@ void shouldUseAllMerchantScopesWhenNoneRequested() { given(apiKeyGenerator.generate(ApiKeyEnvironment.LIVE)) .willReturn(new ApiKeyGenerator.GeneratedApiKey("pk_live_abc123", "pk_live_")); given(apiKeyHasher.hash("pk_live_abc123")).willReturn("sha256hash"); - given(apiKeyRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + + var expected = ApiKey.builder() + .merchantId(MERCHANT_ID) + .keyHash("sha256hash") + .keyPrefix("pk_live_") + .name("My Key") + .environment(ApiKeyEnvironment.LIVE) + .scopes(List.of("payments:read", "payments:write")) + .allowedIps(List.of()) + .active(true) + .version(0L) + .build(); + given(apiKeyRepository.save(eqIgnoring(expected, "keyId"))) + .willAnswer(inv -> inv.getArgument(0)); var result = apiKeyService.create(MERCHANT_ID, "My Key", ApiKeyEnvironment.LIVE, null, null, null); @@ -157,12 +184,13 @@ void shouldRevokeAndPublishEvent() { .active(true).createdAt(Instant.now()) .updatedAt(Instant.now()).version(0L).build(); given(apiKeyRepository.findById(keyId)).willReturn(Optional.of(apiKey)); - given(apiKeyRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); apiKeyService.revoke(keyId); - then(apiKeyRepository).should().save(any()); - then(eventPublisher).should().publish(any(ApiKeyRevokedEvent.class)); + var expectedKey = apiKey.toBuilder().active(false).build(); + var expectedEvent = new ApiKeyRevokedEvent(keyId, MERCHANT_ID, "pk_live_", null); + then(apiKeyRepository).should().save(eqIgnoringTimestamps(expectedKey)); + then(eventPublisher).should().publish(eqIgnoringTimestamps(expectedEvent)); } @Test diff --git a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/AuthCommandHandlerTest.java b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/AuthCommandHandlerTest.java index 8f0bf76a..0fc1f301 100644 --- a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/AuthCommandHandlerTest.java +++ b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/AuthCommandHandlerTest.java @@ -23,16 +23,16 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.UUID; import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; @@ -100,8 +100,10 @@ void shouldIssueTokenWithClientScopes() { given(oauthClientRepository.findActiveById(CLIENT_ID)).willReturn(Optional.of(activeClient())); given(clientSecretHasher.matches("secret", "$2a$12$hash")).willReturn(true); given(merchantRepository.findById(MERCHANT_ID)).willReturn(Optional.of(activeMerchant())); - given(tokenIssuer.issueToken(eq(MERCHANT_ID), eq(CLIENT_ID), anyList())).willReturn("jwt-token"); - given(accessTokenRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(tokenIssuer.issueToken(MERCHANT_ID, CLIENT_ID, List.of("payments:read", "payments:write"))) + .willReturn("jwt-token"); + + authCommandHandler.issueToken(CLIENT_ID, "secret", null); var expected = AccessToken.builder() .merchantId(MERCHANT_ID) @@ -109,9 +111,6 @@ void shouldIssueTokenWithClientScopes() { .scopes(List.of("payments:read", "payments:write")) .revoked(false) .build(); - - authCommandHandler.issueToken(CLIENT_ID, "secret", null); - then(accessTokenRepository).should().save(eqIgnoring(expected, "jti")); } @@ -120,8 +119,10 @@ void shouldIssueTokenWithRequestedScopes() { given(oauthClientRepository.findActiveById(CLIENT_ID)).willReturn(Optional.of(activeClient())); given(clientSecretHasher.matches("secret", "$2a$12$hash")).willReturn(true); given(merchantRepository.findById(MERCHANT_ID)).willReturn(Optional.of(activeMerchant())); - given(tokenIssuer.issueToken(eq(MERCHANT_ID), eq(CLIENT_ID), anyList())).willReturn("jwt-token"); - given(accessTokenRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(tokenIssuer.issueToken(MERCHANT_ID, CLIENT_ID, List.of("payments:read"))) + .willReturn("jwt-token"); + + authCommandHandler.issueToken(CLIENT_ID, "secret", List.of("payments:read")); var expected = AccessToken.builder() .merchantId(MERCHANT_ID) @@ -129,9 +130,6 @@ void shouldIssueTokenWithRequestedScopes() { .scopes(List.of("payments:read")) .revoked(false) .build(); - - authCommandHandler.issueToken(CLIENT_ID, "secret", List.of("payments:read")); - then(accessTokenRepository).should().save(eqIgnoring(expected, "jti")); } @@ -192,16 +190,19 @@ void shouldRevokeActiveToken() { .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(3600)) .revoked(false).build(); given(accessTokenRepository.findByJti(jti)).willReturn(Optional.of(token)); - given(accessTokenRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + + authCommandHandler.revokeToken(jti); var expected = token.toBuilder() .revoked(true) .build(); - - authCommandHandler.revokeToken(jti); - then(accessTokenRepository).should().save(eqIgnoring(expected, "revokedAt")); - then(tokenRevocationCache).should().markRevoked(eq(jti), any()); + then(tokenRevocationCache).should().markRevoked(argThat(id -> jti.equals(id)), + argThat(duration -> { + assertThat((Duration) duration) + .isBetween(Duration.ofSeconds(3500), Duration.ofSeconds(3600)); + return true; + })); } } diff --git a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/AuthServiceTest.java b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/AuthServiceTest.java index 6327a500..2bc4ccc7 100644 --- a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/AuthServiceTest.java +++ b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/AuthServiceTest.java @@ -23,16 +23,16 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.time.Duration; import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.UUID; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyList; -import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; @@ -100,8 +100,8 @@ void shouldIssueTokenWithClientScopes() { given(oauthClientRepository.findActiveById(CLIENT_ID)).willReturn(Optional.of(activeClient())); given(clientSecretHasher.matches("secret", "$2a$12$hash")).willReturn(true); given(merchantRepository.findById(MERCHANT_ID)).willReturn(Optional.of(activeMerchant())); - given(tokenIssuer.issueToken(eq(MERCHANT_ID), eq(CLIENT_ID), anyList())).willReturn("jwt-token"); - given(accessTokenRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(tokenIssuer.issueToken(MERCHANT_ID, CLIENT_ID, List.of("payments:read", "payments:write"))) + .willReturn("jwt-token"); var result = authService.issueToken(CLIENT_ID, "secret", null); @@ -115,8 +115,8 @@ void shouldIssueTokenWithRequestedScopes() { given(oauthClientRepository.findActiveById(CLIENT_ID)).willReturn(Optional.of(activeClient())); given(clientSecretHasher.matches("secret", "$2a$12$hash")).willReturn(true); given(merchantRepository.findById(MERCHANT_ID)).willReturn(Optional.of(activeMerchant())); - given(tokenIssuer.issueToken(eq(MERCHANT_ID), eq(CLIENT_ID), anyList())).willReturn("jwt-token"); - given(accessTokenRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(tokenIssuer.issueToken(MERCHANT_ID, CLIENT_ID, List.of("payments:read"))) + .willReturn("jwt-token"); var result = authService.issueToken(CLIENT_ID, "secret", List.of("payments:read")); @@ -180,12 +180,17 @@ void shouldRevokeActiveToken() { .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(3600)) .revoked(false).build(); given(accessTokenRepository.findByJti(jti)).willReturn(Optional.of(token)); - given(accessTokenRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); authService.revokeToken(jti); - then(accessTokenRepository).should().save(any()); - then(tokenRevocationCache).should().markRevoked(eq(jti), any()); + var expectedRevoked = token.toBuilder().revoked(true).build(); + then(accessTokenRepository).should().save(eqIgnoring(expectedRevoked, "revokedAt")); + then(tokenRevocationCache).should().markRevoked(argThat(id -> jti.equals(id)), + argThat(duration -> { + assertThat((Duration) duration) + .isBetween(Duration.ofSeconds(3500), Duration.ofSeconds(3600)); + return true; + })); } } diff --git a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/MerchantCommandHandlerTest.java b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/MerchantCommandHandlerTest.java index 7df0445e..fee95e79 100644 --- a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/MerchantCommandHandlerTest.java +++ b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/MerchantCommandHandlerTest.java @@ -29,7 +29,6 @@ import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; @@ -92,7 +91,6 @@ class Register { @Test void shouldRegisterNewMerchant() { var externalId = UUID.randomUUID(); - given(merchantRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); var expected = Merchant.builder() .externalId(externalId) @@ -105,6 +103,8 @@ void shouldRegisterNewMerchant() { .rateLimitTier(RateLimitTier.STARTER) .version(0L) .build(); + given(merchantRepository.save(eqIgnoring(expected, "merchantId"))) + .willAnswer(inv -> inv.getArgument(0)); merchantCommandHandler.register(externalId, "Test Corp", "US", List.of("payments:read"), List.of(new Corridor("US", "DE"))); @@ -114,9 +114,9 @@ void shouldRegisterNewMerchant() { @Test void shouldHandleNullScopesAndCorridors() { - given(merchantRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); - + var externalId = UUID.randomUUID(); var expected = Merchant.builder() + .externalId(externalId) .name("Test Corp") .country("US") .scopes(List.of()) @@ -126,10 +126,12 @@ void shouldHandleNullScopesAndCorridors() { .rateLimitTier(RateLimitTier.STARTER) .version(0L) .build(); + given(merchantRepository.save(eqIgnoring(expected, "merchantId"))) + .willAnswer(inv -> inv.getArgument(0)); - merchantCommandHandler.register(UUID.randomUUID(), "Test Corp", "US", null, null); + merchantCommandHandler.register(externalId, "Test Corp", "US", null, null); - then(merchantRepository).should().save(eqIgnoring(expected, "merchantId", "externalId")); + then(merchantRepository).should().save(eqIgnoring(expected, "merchantId")); } } @@ -142,12 +144,13 @@ void shouldActivatePendingMerchant() { var externalId = UUID.randomUUID(); var merchant = pendingMerchant(externalId); given(merchantRepository.findByExternalId(externalId)).willReturn(Optional.of(merchant)); - given(merchantRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); var expected = merchant.toBuilder() .status(MerchantStatus.ACTIVE) .kybStatus(KybStatus.VERIFIED) .build(); + given(merchantRepository.save(eqIgnoringTimestamps(expected))) + .willAnswer(inv -> inv.getArgument(0)); merchantCommandHandler.activate(externalId); @@ -173,11 +176,12 @@ void shouldSuspendAndDeactivateAll() { var externalId = UUID.randomUUID(); var merchant = activeMerchant(externalId); given(merchantRepository.findByExternalId(externalId)).willReturn(Optional.of(merchant)); - given(merchantRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); var expected = merchant.toBuilder() .status(MerchantStatus.SUSPENDED) .build(); + given(merchantRepository.save(eqIgnoringTimestamps(expected))) + .willAnswer(inv -> inv.getArgument(0)); merchantCommandHandler.suspend(externalId); @@ -206,11 +210,12 @@ void shouldCloseAndDeactivateAll() { var externalId = UUID.randomUUID(); var merchant = activeMerchant(externalId); given(merchantRepository.findByExternalId(externalId)).willReturn(Optional.of(merchant)); - given(merchantRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); var expected = merchant.toBuilder() .status(MerchantStatus.CLOSED) .build(); + given(merchantRepository.save(eqIgnoringTimestamps(expected))) + .willAnswer(inv -> inv.getArgument(0)); merchantCommandHandler.close(externalId); @@ -270,8 +275,15 @@ void shouldActivateAndProvisionDefaultClient() { .version(0L) .build(); given(merchantRepository.findByExternalId(externalId)).willReturn(Optional.of(merchant)); - given(merchantRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + var activatedMerchant = merchant.toBuilder() + .status(MerchantStatus.ACTIVE) + .kybStatus(KybStatus.VERIFIED) + .build(); + given(merchantRepository.save(eqIgnoringTimestamps(activatedMerchant))) + .willAnswer(inv -> inv.getArgument(0)); + + var createdAt = Instant.parse("2026-01-01T00:00:00Z"); var oauthClient = OAuthClient.builder() .clientId(clientId) .merchantId(merchantId) @@ -280,11 +292,13 @@ void shouldActivateAndProvisionDefaultClient() { .scopes(List.of("payments:read")) .grantTypes(List.of("client_credentials")) .active(true) - .createdAt(Instant.now()) - .updatedAt(Instant.now()) + .createdAt(createdAt) + .updatedAt(createdAt) .version(0L) .build(); - given(oauthClientCommandHandler.create(any(), any(), any(), any())) + given(oauthClientCommandHandler.create( + merchantId, "Acme Corp Default Client", + List.of("payments:read"), List.of("client_credentials"))) .willReturn(new OAuthClientCommandHandler.CreateOAuthClientResult(oauthClient, "raw-secret")); merchantCommandHandler.activateAndProvisionOAuthClient( @@ -293,7 +307,10 @@ void shouldActivateAndProvisionDefaultClient() { then(oauthClientCommandHandler).should().create( merchantId, "Acme Corp Default Client", List.of("payments:read"), List.of("client_credentials")); - then(eventPublisher).should().publish(any(OAuthClientProvisionedEvent.class)); + var expectedEvent = new OAuthClientProvisionedEvent( + clientId, merchantId, "raw-secret", "Acme Corp Default Client", + List.of("payments:read"), List.of("client_credentials"), createdAt); + then(eventPublisher).should().publish(expectedEvent); } @Test @@ -316,7 +333,13 @@ void shouldUseMerchantScopesWhenNoneProvided() { .version(0L) .build(); given(merchantRepository.findByExternalId(externalId)).willReturn(Optional.of(merchant)); - given(merchantRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + + var activatedMerchant = merchant.toBuilder() + .status(MerchantStatus.ACTIVE) + .kybStatus(KybStatus.VERIFIED) + .build(); + given(merchantRepository.save(eqIgnoringTimestamps(activatedMerchant))) + .willAnswer(inv -> inv.getArgument(0)); var oauthClient = OAuthClient.builder() .clientId(clientId) @@ -330,7 +353,9 @@ void shouldUseMerchantScopesWhenNoneProvided() { .updatedAt(Instant.now()) .version(0L) .build(); - given(oauthClientCommandHandler.create(any(), any(), any(), any())) + given(oauthClientCommandHandler.create( + merchantId, "Test Corp Default Client", + List.of("payments:read", "payments:write"), List.of("client_credentials"))) .willReturn(new OAuthClientCommandHandler.CreateOAuthClientResult(oauthClient, "raw-secret")); merchantCommandHandler.activateAndProvisionOAuthClient( diff --git a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/MerchantServiceTest.java b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/MerchantServiceTest.java index 5955257b..d0edb221 100644 --- a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/MerchantServiceTest.java +++ b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/MerchantServiceTest.java @@ -23,9 +23,10 @@ import java.util.Optional; import java.util.UUID; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; @@ -86,7 +87,19 @@ class Register { @Test void shouldRegisterNewMerchant() { var externalId = UUID.randomUUID(); - given(merchantRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + var expected = Merchant.builder() + .externalId(externalId) + .name("Test Corp") + .country("US") + .scopes(List.of("payments:read")) + .corridors(List.of(new Corridor("US", "DE"))) + .status(MerchantStatus.PENDING) + .kybStatus(KybStatus.PENDING) + .rateLimitTier(RateLimitTier.STARTER) + .version(0L) + .build(); + given(merchantRepository.save(eqIgnoring(expected, "merchantId"))) + .willAnswer(inv -> inv.getArgument(0)); var result = merchantService.register(externalId, "Test Corp", "US", List.of("payments:read"), List.of(new Corridor("US", "DE"))); @@ -94,14 +107,27 @@ void shouldRegisterNewMerchant() { assertThat(result.getStatus()).isEqualTo(MerchantStatus.PENDING); assertThat(result.getKybStatus()).isEqualTo(KybStatus.PENDING); assertThat(result.getExternalId()).isEqualTo(externalId); - then(merchantRepository).should().save(any()); + then(merchantRepository).should().save(eqIgnoring(expected, "merchantId")); } @Test void shouldHandleNullScopesAndCorridors() { - given(merchantRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); - - var result = merchantService.register(UUID.randomUUID(), "Test Corp", "US", null, null); + var externalId = UUID.randomUUID(); + var expected = Merchant.builder() + .externalId(externalId) + .name("Test Corp") + .country("US") + .scopes(List.of()) + .corridors(List.of()) + .status(MerchantStatus.PENDING) + .kybStatus(KybStatus.PENDING) + .rateLimitTier(RateLimitTier.STARTER) + .version(0L) + .build(); + given(merchantRepository.save(eqIgnoring(expected, "merchantId"))) + .willAnswer(inv -> inv.getArgument(0)); + + var result = merchantService.register(externalId, "Test Corp", "US", null, null); assertThat(result.getScopes()).isEmpty(); assertThat(result.getCorridors()).isEmpty(); @@ -117,7 +143,12 @@ void shouldActivatePendingMerchant() { var externalId = UUID.randomUUID(); var merchant = pendingMerchant(externalId); given(merchantRepository.findByExternalId(externalId)).willReturn(Optional.of(merchant)); - given(merchantRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + var expected = merchant.toBuilder() + .status(MerchantStatus.ACTIVE) + .kybStatus(KybStatus.VERIFIED) + .build(); + given(merchantRepository.save(eqIgnoringTimestamps(expected))) + .willAnswer(inv -> inv.getArgument(0)); var result = merchantService.activate(externalId); @@ -144,7 +175,9 @@ void shouldSuspendAndDeactivateAll() { var externalId = UUID.randomUUID(); var merchant = activeMerchant(externalId); given(merchantRepository.findByExternalId(externalId)).willReturn(Optional.of(merchant)); - given(merchantRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + var expected = merchant.toBuilder().status(MerchantStatus.SUSPENDED).build(); + given(merchantRepository.save(eqIgnoringTimestamps(expected))) + .willAnswer(inv -> inv.getArgument(0)); var result = merchantService.suspend(externalId); @@ -173,7 +206,9 @@ void shouldCloseAndDeactivateAll() { var externalId = UUID.randomUUID(); var merchant = activeMerchant(externalId); given(merchantRepository.findByExternalId(externalId)).willReturn(Optional.of(merchant)); - given(merchantRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + var expected = merchant.toBuilder().status(MerchantStatus.CLOSED).build(); + given(merchantRepository.save(eqIgnoringTimestamps(expected))) + .willAnswer(inv -> inv.getArgument(0)); var result = merchantService.close(externalId); diff --git a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/OAuthClientCommandHandlerTest.java b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/OAuthClientCommandHandlerTest.java index 52ed210f..0dc8d3a5 100644 --- a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/OAuthClientCommandHandlerTest.java +++ b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/OAuthClientCommandHandlerTest.java @@ -27,7 +27,6 @@ import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; @@ -93,7 +92,6 @@ void shouldCreateOAuthClient() { given(merchantRepository.findById(MERCHANT_ID)).willReturn(Optional.of(activeMerchant())); given(clientSecretGenerator.generate()).willReturn("raw-secret-hex"); given(clientSecretHasher.hash("raw-secret-hex")).willReturn("$2a$12$hashed"); - given(oauthClientRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); var expected = OAuthClient.builder() .merchantId(MERCHANT_ID) @@ -104,6 +102,8 @@ void shouldCreateOAuthClient() { .active(true) .version(0L) .build(); + given(oauthClientRepository.save(eqIgnoring(expected, "clientId"))) + .willAnswer(inv -> inv.getArgument(0)); commandHandler.create(MERCHANT_ID, "My Client", List.of("payments:read"), List.of("client_credentials")); @@ -117,7 +117,6 @@ void shouldUseMerchantScopesAsDefault() { given(merchantRepository.findById(MERCHANT_ID)).willReturn(Optional.of(activeMerchant())); given(clientSecretGenerator.generate()).willReturn("secret"); given(clientSecretHasher.hash("secret")).willReturn("hash"); - given(oauthClientRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); var expected = OAuthClient.builder() .merchantId(MERCHANT_ID) @@ -128,6 +127,8 @@ void shouldUseMerchantScopesAsDefault() { .active(true) .version(0L) .build(); + given(oauthClientRepository.save(eqIgnoring(expected, "clientId"))) + .willAnswer(inv -> inv.getArgument(0)); commandHandler.create(MERCHANT_ID, "Client", List.of(), List.of()); @@ -140,7 +141,6 @@ void shouldDefaultToClientCredentials() { given(merchantRepository.findById(MERCHANT_ID)).willReturn(Optional.of(activeMerchant())); given(clientSecretGenerator.generate()).willReturn("secret"); given(clientSecretHasher.hash("secret")).willReturn("hash"); - given(oauthClientRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); var expected = OAuthClient.builder() .merchantId(MERCHANT_ID) @@ -151,6 +151,8 @@ void shouldDefaultToClientCredentials() { .active(true) .version(0L) .build(); + given(oauthClientRepository.save(eqIgnoring(expected, "clientId"))) + .willAnswer(inv -> inv.getArgument(0)); commandHandler.create(MERCHANT_ID, "Client", null, null); diff --git a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/OAuthClientServiceTest.java b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/OAuthClientServiceTest.java index 5b01841e..9a0e5ee1 100644 --- a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/OAuthClientServiceTest.java +++ b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/OAuthClientServiceTest.java @@ -6,6 +6,7 @@ import com.stablecoin.payments.gateway.iam.domain.model.KybStatus; import com.stablecoin.payments.gateway.iam.domain.model.Merchant; import com.stablecoin.payments.gateway.iam.domain.model.MerchantStatus; +import com.stablecoin.payments.gateway.iam.domain.model.OAuthClient; import com.stablecoin.payments.gateway.iam.domain.model.RateLimitTier; import com.stablecoin.payments.gateway.iam.domain.port.ClientSecretGenerator; import com.stablecoin.payments.gateway.iam.domain.port.ClientSecretHasher; @@ -24,9 +25,9 @@ import java.util.Optional; import java.util.UUID; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; @ExtendWith(MockitoExtension.class) @@ -91,7 +92,18 @@ void shouldCreateOAuthClient() { given(merchantRepository.findById(MERCHANT_ID)).willReturn(Optional.of(activeMerchant())); given(clientSecretGenerator.generate()).willReturn("raw-secret-hex"); given(clientSecretHasher.hash("raw-secret-hex")).willReturn("$2a$12$hashed"); - given(oauthClientRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + + var expected = OAuthClient.builder() + .merchantId(MERCHANT_ID) + .clientSecretHash("$2a$12$hashed") + .name("My Client") + .scopes(List.of("payments:read")) + .grantTypes(List.of("client_credentials")) + .active(true) + .version(0L) + .build(); + given(oauthClientRepository.save(eqIgnoring(expected, "clientId"))) + .willAnswer(inv -> inv.getArgument(0)); var result = service.create(MERCHANT_ID, "My Client", List.of("payments:read"), List.of("client_credentials")); @@ -111,7 +123,18 @@ void shouldUseMerchantScopesAsDefault() { given(merchantRepository.findById(MERCHANT_ID)).willReturn(Optional.of(activeMerchant())); given(clientSecretGenerator.generate()).willReturn("secret"); given(clientSecretHasher.hash("secret")).willReturn("hash"); - given(oauthClientRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + + var expected = OAuthClient.builder() + .merchantId(MERCHANT_ID) + .clientSecretHash("hash") + .name("Client") + .scopes(List.of("payments:read", "payments:write")) + .grantTypes(List.of("client_credentials")) + .active(true) + .version(0L) + .build(); + given(oauthClientRepository.save(eqIgnoring(expected, "clientId"))) + .willAnswer(inv -> inv.getArgument(0)); var result = service.create(MERCHANT_ID, "Client", List.of(), List.of()); @@ -125,7 +148,18 @@ void shouldDefaultToClientCredentials() { given(merchantRepository.findById(MERCHANT_ID)).willReturn(Optional.of(activeMerchant())); given(clientSecretGenerator.generate()).willReturn("secret"); given(clientSecretHasher.hash("secret")).willReturn("hash"); - given(oauthClientRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + + var expected = OAuthClient.builder() + .merchantId(MERCHANT_ID) + .clientSecretHash("hash") + .name("Client") + .scopes(List.of("payments:read", "payments:write")) + .grantTypes(List.of("client_credentials")) + .active(true) + .version(0L) + .build(); + given(oauthClientRepository.save(eqIgnoring(expected, "clientId"))) + .willAnswer(inv -> inv.getArgument(0)); var result = service.create(MERCHANT_ID, "Client", null, null); diff --git a/compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/domain/service/ComplianceCheckCommandHandlerTest.java b/compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/domain/service/ComplianceCheckCommandHandlerTest.java index 14d9d4a2..3a2624a8 100644 --- a/compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/domain/service/ComplianceCheckCommandHandlerTest.java +++ b/compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/domain/service/ComplianceCheckCommandHandlerTest.java @@ -11,6 +11,7 @@ import com.stablecoin.payments.compliance.domain.model.Money; import com.stablecoin.payments.compliance.domain.model.OverallResult; import com.stablecoin.payments.compliance.domain.model.RiskScoringWeights; +import com.stablecoin.payments.compliance.domain.model.TravelRulePackage; import com.stablecoin.payments.compliance.domain.port.AmlProvider; import com.stablecoin.payments.compliance.domain.port.ComplianceCheckRepository; import com.stablecoin.payments.compliance.domain.port.CustomerRiskProfileRepository; @@ -42,10 +43,9 @@ import static com.stablecoin.payments.compliance.fixtures.CustomerRiskProfileFixtures.aRiskProfile; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; @ExtendWith(MockitoExtension.class) @@ -105,8 +105,8 @@ void shouldCompleteFullPipeline() { given(amlProvider.analyze(senderId, recipientId)).willReturn(anAmlClearResult(null)); given(profileRepository.findByCustomerId(senderId)) .willReturn(Optional.of(aRiskProfile())); - given(travelRuleProvider.transmit(any())).willReturn("tr-ref-123"); - given(checkRepository.save(any(ComplianceCheck.class))) + given(travelRuleProvider.transmit(argThat((TravelRulePackage p) -> p != null))).willReturn("tr-ref-123"); + given(checkRepository.save(argThat((ComplianceCheck c) -> c != null))) .willAnswer(invocation -> invocation.getArgument(0)); var result = handler.initiateCheck( @@ -128,13 +128,13 @@ void shouldSaveCompletedCheck() { var senderId = UUID.randomUUID(); var recipientId = UUID.randomUUID(); given(checkRepository.findByPaymentId(paymentId)).willReturn(Optional.empty()); - given(kycProvider.verify(any(), any())).willReturn(aKycResult(null)); - given(sanctionsProvider.screen(any(), any())).willReturn(aSanctionsClearResult(null)); - given(amlProvider.analyze(any(), any())).willReturn(anAmlClearResult(null)); - given(profileRepository.findByCustomerId(any())) + given(kycProvider.verify(senderId, recipientId)).willReturn(aKycResult(null)); + given(sanctionsProvider.screen(senderId, recipientId)).willReturn(aSanctionsClearResult(null)); + given(amlProvider.analyze(senderId, recipientId)).willReturn(anAmlClearResult(null)); + given(profileRepository.findByCustomerId(senderId)) .willReturn(Optional.of(aRiskProfile())); - given(travelRuleProvider.transmit(any())).willReturn("tr-ref-123"); - given(checkRepository.save(any(ComplianceCheck.class))) + given(travelRuleProvider.transmit(argThat((TravelRulePackage p) -> p != null))).willReturn("tr-ref-123"); + given(checkRepository.save(argThat((ComplianceCheck c) -> c != null))) .willAnswer(invocation -> invocation.getArgument(0)); handler.initiateCheck( @@ -160,13 +160,13 @@ void shouldPublishPassedEvent() { var senderId = UUID.randomUUID(); var recipientId = UUID.randomUUID(); given(checkRepository.findByPaymentId(paymentId)).willReturn(Optional.empty()); - given(kycProvider.verify(any(), any())).willReturn(aKycResult(null)); - given(sanctionsProvider.screen(any(), any())).willReturn(aSanctionsClearResult(null)); - given(amlProvider.analyze(any(), any())).willReturn(anAmlClearResult(null)); - given(profileRepository.findByCustomerId(any())) + given(kycProvider.verify(senderId, recipientId)).willReturn(aKycResult(null)); + given(sanctionsProvider.screen(senderId, recipientId)).willReturn(aSanctionsClearResult(null)); + given(amlProvider.analyze(senderId, recipientId)).willReturn(anAmlClearResult(null)); + given(profileRepository.findByCustomerId(senderId)) .willReturn(Optional.of(aRiskProfile())); - given(travelRuleProvider.transmit(any())).willReturn("tr-ref-123"); - given(checkRepository.save(any(ComplianceCheck.class))) + given(travelRuleProvider.transmit(argThat((TravelRulePackage p) -> p != null))).willReturn("tr-ref-123"); + given(checkRepository.save(argThat((ComplianceCheck c) -> c != null))) .willAnswer(invocation -> invocation.getArgument(0)); handler.initiateCheck( @@ -192,12 +192,12 @@ void shouldSkipTravelRuleBelowThreshold() { var senderId = UUID.randomUUID(); var recipientId = UUID.randomUUID(); given(checkRepository.findByPaymentId(paymentId)).willReturn(Optional.empty()); - given(kycProvider.verify(any(), any())).willReturn(aKycResult(null)); - given(sanctionsProvider.screen(any(), any())).willReturn(aSanctionsClearResult(null)); - given(amlProvider.analyze(any(), any())).willReturn(anAmlClearResult(null)); - given(profileRepository.findByCustomerId(any())) + given(kycProvider.verify(senderId, recipientId)).willReturn(aKycResult(null)); + given(sanctionsProvider.screen(senderId, recipientId)).willReturn(aSanctionsClearResult(null)); + given(amlProvider.analyze(senderId, recipientId)).willReturn(anAmlClearResult(null)); + given(profileRepository.findByCustomerId(senderId)) .willReturn(Optional.of(aRiskProfile())); - given(checkRepository.save(any(ComplianceCheck.class))) + given(checkRepository.save(argThat((ComplianceCheck c) -> c != null))) .willAnswer(invocation -> invocation.getArgument(0)); var result = handler.initiateCheck( @@ -210,7 +210,7 @@ void shouldSkipTravelRuleBelowThreshold() { .usingRecursiveComparison() .comparingOnlyFields("paymentId", "status", "overallResult") .isEqualTo(expected); - then(travelRuleProvider).should(never()).transmit(any()); + then(travelRuleProvider).shouldHaveNoInteractions(); } @Test @@ -226,8 +226,8 @@ void shouldInvokeAllProvidersInOrder() { given(amlProvider.analyze(senderId, recipientId)).willReturn(anAmlClearResult(null)); given(profileRepository.findByCustomerId(senderId)) .willReturn(Optional.of(aRiskProfile())); - given(travelRuleProvider.transmit(any())).willReturn("tr-ref-123"); - given(checkRepository.save(any(ComplianceCheck.class))) + given(travelRuleProvider.transmit(argThat((TravelRulePackage p) -> p != null))).willReturn("tr-ref-123"); + given(checkRepository.save(argThat((ComplianceCheck c) -> c != null))) .willAnswer(invocation -> invocation.getArgument(0)); handler.initiateCheck( @@ -236,9 +236,9 @@ void shouldInvokeAllProvidersInOrder() { then(kycProvider).should().verify(senderId, recipientId); then(sanctionsProvider).should().screen(senderId, recipientId); then(amlProvider).should().analyze(senderId, recipientId); - then(travelRuleProvider).should().transmit(any()); - then(checkRepository).should().save(any(ComplianceCheck.class)); - then(eventPublisher).should().publish(any()); + then(travelRuleProvider).should().transmit(argThat((TravelRulePackage p) -> p != null)); + then(checkRepository).should().save(argThat((ComplianceCheck c) -> c != null)); + then(eventPublisher).should().publish(argThat(o -> o instanceof ComplianceCheckPassed)); } } @@ -253,8 +253,8 @@ void shouldStopOnKycRejection() { var senderId = UUID.randomUUID(); var recipientId = UUID.randomUUID(); given(checkRepository.findByPaymentId(paymentId)).willReturn(Optional.empty()); - given(kycProvider.verify(any(), any())).willReturn(aKycRejectedResult(null)); - given(checkRepository.save(any(ComplianceCheck.class))) + given(kycProvider.verify(senderId, recipientId)).willReturn(aKycRejectedResult(null)); + given(checkRepository.save(argThat((ComplianceCheck c) -> c != null))) .willAnswer(invocation -> invocation.getArgument(0)); var result = handler.initiateCheck( @@ -267,8 +267,8 @@ void shouldStopOnKycRejection() { .usingRecursiveComparison() .comparingOnlyFields("paymentId", "status", "overallResult") .isEqualTo(expected); - then(sanctionsProvider).should(never()).screen(any(), any()); - then(amlProvider).should(never()).analyze(any(), any()); + then(sanctionsProvider).shouldHaveNoInteractions(); + then(amlProvider).shouldHaveNoInteractions(); } @Test @@ -278,8 +278,8 @@ void shouldSaveFailedCheck() { var senderId = UUID.randomUUID(); var recipientId = UUID.randomUUID(); given(checkRepository.findByPaymentId(paymentId)).willReturn(Optional.empty()); - given(kycProvider.verify(any(), any())).willReturn(aKycRejectedResult(null)); - given(checkRepository.save(any(ComplianceCheck.class))) + given(kycProvider.verify(senderId, recipientId)).willReturn(aKycRejectedResult(null)); + given(checkRepository.save(argThat((ComplianceCheck c) -> c != null))) .willAnswer(invocation -> invocation.getArgument(0)); handler.initiateCheck( @@ -306,8 +306,8 @@ void shouldPublishFailedEventOnKycRejection() { var senderId = UUID.randomUUID(); var recipientId = UUID.randomUUID(); given(checkRepository.findByPaymentId(paymentId)).willReturn(Optional.empty()); - given(kycProvider.verify(any(), any())).willReturn(aKycRejectedResult(null)); - given(checkRepository.save(any(ComplianceCheck.class))) + given(kycProvider.verify(senderId, recipientId)).willReturn(aKycRejectedResult(null)); + given(checkRepository.save(argThat((ComplianceCheck c) -> c != null))) .willAnswer(invocation -> invocation.getArgument(0)); handler.initiateCheck( @@ -337,9 +337,9 @@ void shouldStopOnSanctionsHit() { var senderId = UUID.randomUUID(); var recipientId = UUID.randomUUID(); given(checkRepository.findByPaymentId(paymentId)).willReturn(Optional.empty()); - given(kycProvider.verify(any(), any())).willReturn(aKycResult(null)); - given(sanctionsProvider.screen(any(), any())).willReturn(aSanctionsHitResult(null)); - given(checkRepository.save(any(ComplianceCheck.class))) + given(kycProvider.verify(senderId, recipientId)).willReturn(aKycResult(null)); + given(sanctionsProvider.screen(senderId, recipientId)).willReturn(aSanctionsHitResult(null)); + given(checkRepository.save(argThat((ComplianceCheck c) -> c != null))) .willAnswer(invocation -> invocation.getArgument(0)); var result = handler.initiateCheck( @@ -353,7 +353,7 @@ void shouldStopOnSanctionsHit() { .usingRecursiveComparison() .comparingOnlyFields("paymentId", "status", "overallResult") .isEqualTo(expected); - then(amlProvider).should(never()).analyze(any(), any()); + then(amlProvider).shouldHaveNoInteractions(); } @Test @@ -363,9 +363,9 @@ void shouldSaveSanctionsHitCheck() { var senderId = UUID.randomUUID(); var recipientId = UUID.randomUUID(); given(checkRepository.findByPaymentId(paymentId)).willReturn(Optional.empty()); - given(kycProvider.verify(any(), any())).willReturn(aKycResult(null)); - given(sanctionsProvider.screen(any(), any())).willReturn(aSanctionsHitResult(null)); - given(checkRepository.save(any(ComplianceCheck.class))) + given(kycProvider.verify(senderId, recipientId)).willReturn(aKycResult(null)); + given(sanctionsProvider.screen(senderId, recipientId)).willReturn(aSanctionsHitResult(null)); + given(checkRepository.save(argThat((ComplianceCheck c) -> c != null))) .willAnswer(invocation -> invocation.getArgument(0)); handler.initiateCheck( @@ -393,9 +393,9 @@ void shouldPublishSanctionsHitEvents() { var senderId = UUID.randomUUID(); var recipientId = UUID.randomUUID(); given(checkRepository.findByPaymentId(paymentId)).willReturn(Optional.empty()); - given(kycProvider.verify(any(), any())).willReturn(aKycResult(null)); - given(sanctionsProvider.screen(any(), any())).willReturn(aSanctionsHitResult(null)); - given(checkRepository.save(any(ComplianceCheck.class))) + given(kycProvider.verify(senderId, recipientId)).willReturn(aKycResult(null)); + given(sanctionsProvider.screen(senderId, recipientId)).willReturn(aSanctionsHitResult(null)); + given(checkRepository.save(argThat((ComplianceCheck c) -> c != null))) .willAnswer(invocation -> invocation.getArgument(0)); handler.initiateCheck( @@ -435,10 +435,10 @@ void shouldStopOnAmlFlag() { var senderId = UUID.randomUUID(); var recipientId = UUID.randomUUID(); given(checkRepository.findByPaymentId(paymentId)).willReturn(Optional.empty()); - given(kycProvider.verify(any(), any())).willReturn(aKycResult(null)); - given(sanctionsProvider.screen(any(), any())).willReturn(aSanctionsClearResult(null)); - given(amlProvider.analyze(any(), any())).willReturn(anAmlFlaggedResult(null)); - given(checkRepository.save(any(ComplianceCheck.class))) + given(kycProvider.verify(senderId, recipientId)).willReturn(aKycResult(null)); + given(sanctionsProvider.screen(senderId, recipientId)).willReturn(aSanctionsClearResult(null)); + given(amlProvider.analyze(senderId, recipientId)).willReturn(anAmlFlaggedResult(null)); + given(checkRepository.save(argThat((ComplianceCheck c) -> c != null))) .willAnswer(invocation -> invocation.getArgument(0)); var result = handler.initiateCheck( @@ -452,7 +452,7 @@ void shouldStopOnAmlFlag() { .usingRecursiveComparison() .comparingOnlyFields("paymentId", "status", "overallResult") .isEqualTo(expected); - then(travelRuleProvider).should(never()).transmit(any()); + then(travelRuleProvider).shouldHaveNoInteractions(); } @Test @@ -462,10 +462,10 @@ void shouldSaveManualReviewCheck() { var senderId = UUID.randomUUID(); var recipientId = UUID.randomUUID(); given(checkRepository.findByPaymentId(paymentId)).willReturn(Optional.empty()); - given(kycProvider.verify(any(), any())).willReturn(aKycResult(null)); - given(sanctionsProvider.screen(any(), any())).willReturn(aSanctionsClearResult(null)); - given(amlProvider.analyze(any(), any())).willReturn(anAmlFlaggedResult(null)); - given(checkRepository.save(any(ComplianceCheck.class))) + given(kycProvider.verify(senderId, recipientId)).willReturn(aKycResult(null)); + given(sanctionsProvider.screen(senderId, recipientId)).willReturn(aSanctionsClearResult(null)); + given(amlProvider.analyze(senderId, recipientId)).willReturn(anAmlFlaggedResult(null)); + given(checkRepository.save(argThat((ComplianceCheck c) -> c != null))) .willAnswer(invocation -> invocation.getArgument(0)); handler.initiateCheck( @@ -493,10 +493,10 @@ void shouldPublishFailedEventOnAmlFlag() { var senderId = UUID.randomUUID(); var recipientId = UUID.randomUUID(); given(checkRepository.findByPaymentId(paymentId)).willReturn(Optional.empty()); - given(kycProvider.verify(any(), any())).willReturn(aKycResult(null)); - given(sanctionsProvider.screen(any(), any())).willReturn(aSanctionsClearResult(null)); - given(amlProvider.analyze(any(), any())).willReturn(anAmlFlaggedResult(null)); - given(checkRepository.save(any(ComplianceCheck.class))) + given(kycProvider.verify(senderId, recipientId)).willReturn(aKycResult(null)); + given(sanctionsProvider.screen(senderId, recipientId)).willReturn(aSanctionsClearResult(null)); + given(amlProvider.analyze(senderId, recipientId)).willReturn(anAmlFlaggedResult(null)); + given(checkRepository.save(argThat((ComplianceCheck c) -> c != null))) .willAnswer(invocation -> invocation.getArgument(0)); handler.initiateCheck( @@ -545,11 +545,11 @@ void shouldRouteToManualReviewOnCriticalRisk() { var senderId = UUID.randomUUID(); var recipientId = UUID.randomUUID(); given(checkRepository.findByPaymentId(paymentId)).willReturn(Optional.empty()); - given(kycProvider.verify(any(), any())).willReturn(aKycTier1Result(null)); - given(sanctionsProvider.screen(any(), any())).willReturn(aSanctionsClearResult(null)); - given(amlProvider.analyze(any(), any())).willReturn(anAmlClearResult(null)); - given(profileRepository.findByCustomerId(any())).willReturn(Optional.empty()); - given(checkRepository.save(any(ComplianceCheck.class))) + given(kycProvider.verify(senderId, recipientId)).willReturn(aKycTier1Result(null)); + given(sanctionsProvider.screen(senderId, recipientId)).willReturn(aSanctionsClearResult(null)); + given(amlProvider.analyze(senderId, recipientId)).willReturn(anAmlClearResult(null)); + given(profileRepository.findByCustomerId(senderId)).willReturn(Optional.empty()); + given(checkRepository.save(argThat((ComplianceCheck c) -> c != null))) .willAnswer(invocation -> invocation.getArgument(0)); var result = criticalHandler.initiateCheck( @@ -563,7 +563,7 @@ void shouldRouteToManualReviewOnCriticalRisk() { .usingRecursiveComparison() .comparingOnlyFields("paymentId", "status", "overallResult") .isEqualTo(expected); - then(travelRuleProvider).should(never()).transmit(any()); + then(travelRuleProvider).shouldHaveNoInteractions(); } @Test @@ -573,11 +573,11 @@ void shouldSaveManualReviewCheckOnCriticalRisk() { var senderId = UUID.randomUUID(); var recipientId = UUID.randomUUID(); given(checkRepository.findByPaymentId(paymentId)).willReturn(Optional.empty()); - given(kycProvider.verify(any(), any())).willReturn(aKycTier1Result(null)); - given(sanctionsProvider.screen(any(), any())).willReturn(aSanctionsClearResult(null)); - given(amlProvider.analyze(any(), any())).willReturn(anAmlClearResult(null)); - given(profileRepository.findByCustomerId(any())).willReturn(Optional.empty()); - given(checkRepository.save(any(ComplianceCheck.class))) + given(kycProvider.verify(senderId, recipientId)).willReturn(aKycTier1Result(null)); + given(sanctionsProvider.screen(senderId, recipientId)).willReturn(aSanctionsClearResult(null)); + given(amlProvider.analyze(senderId, recipientId)).willReturn(anAmlClearResult(null)); + given(profileRepository.findByCustomerId(senderId)).willReturn(Optional.empty()); + given(checkRepository.save(argThat((ComplianceCheck c) -> c != null))) .willAnswer(invocation -> invocation.getArgument(0)); criticalHandler.initiateCheck( @@ -605,11 +605,11 @@ void shouldPublishFailedEventOnCriticalRisk() { var senderId = UUID.randomUUID(); var recipientId = UUID.randomUUID(); given(checkRepository.findByPaymentId(paymentId)).willReturn(Optional.empty()); - given(kycProvider.verify(any(), any())).willReturn(aKycTier1Result(null)); - given(sanctionsProvider.screen(any(), any())).willReturn(aSanctionsClearResult(null)); - given(amlProvider.analyze(any(), any())).willReturn(anAmlClearResult(null)); - given(profileRepository.findByCustomerId(any())).willReturn(Optional.empty()); - given(checkRepository.save(any(ComplianceCheck.class))) + given(kycProvider.verify(senderId, recipientId)).willReturn(aKycTier1Result(null)); + given(sanctionsProvider.screen(senderId, recipientId)).willReturn(aSanctionsClearResult(null)); + given(amlProvider.analyze(senderId, recipientId)).willReturn(anAmlClearResult(null)); + given(profileRepository.findByCustomerId(senderId)).willReturn(Optional.empty()); + given(checkRepository.save(argThat((ComplianceCheck c) -> c != null))) .willAnswer(invocation -> invocation.getArgument(0)); criticalHandler.initiateCheck( @@ -642,14 +642,14 @@ void shouldHandleTravelRuleFailure() { var senderId = UUID.randomUUID(); var recipientId = UUID.randomUUID(); given(checkRepository.findByPaymentId(paymentId)).willReturn(Optional.empty()); - given(kycProvider.verify(any(), any())).willReturn(aKycResult(null)); - given(sanctionsProvider.screen(any(), any())).willReturn(aSanctionsClearResult(null)); - given(amlProvider.analyze(any(), any())).willReturn(anAmlClearResult(null)); - given(profileRepository.findByCustomerId(any())) + given(kycProvider.verify(senderId, recipientId)).willReturn(aKycResult(null)); + given(sanctionsProvider.screen(senderId, recipientId)).willReturn(aSanctionsClearResult(null)); + given(amlProvider.analyze(senderId, recipientId)).willReturn(anAmlClearResult(null)); + given(profileRepository.findByCustomerId(senderId)) .willReturn(Optional.of(aRiskProfile())); - given(travelRuleProvider.transmit(any())) + given(travelRuleProvider.transmit(argThat((TravelRulePackage p) -> p != null))) .willThrow(new RuntimeException("Network error")); - given(checkRepository.save(any(ComplianceCheck.class))) + given(checkRepository.save(argThat((ComplianceCheck c) -> c != null))) .willAnswer(invocation -> invocation.getArgument(0)); var result = handler.initiateCheck( @@ -673,14 +673,14 @@ void shouldSaveFailedCheckOnTravelRuleError() { var senderId = UUID.randomUUID(); var recipientId = UUID.randomUUID(); given(checkRepository.findByPaymentId(paymentId)).willReturn(Optional.empty()); - given(kycProvider.verify(any(), any())).willReturn(aKycResult(null)); - given(sanctionsProvider.screen(any(), any())).willReturn(aSanctionsClearResult(null)); - given(amlProvider.analyze(any(), any())).willReturn(anAmlClearResult(null)); - given(profileRepository.findByCustomerId(any())) + given(kycProvider.verify(senderId, recipientId)).willReturn(aKycResult(null)); + given(sanctionsProvider.screen(senderId, recipientId)).willReturn(aSanctionsClearResult(null)); + given(amlProvider.analyze(senderId, recipientId)).willReturn(anAmlClearResult(null)); + given(profileRepository.findByCustomerId(senderId)) .willReturn(Optional.of(aRiskProfile())); - given(travelRuleProvider.transmit(any())) + given(travelRuleProvider.transmit(argThat((TravelRulePackage p) -> p != null))) .willThrow(new RuntimeException("Connection refused")); - given(checkRepository.save(any(ComplianceCheck.class))) + given(checkRepository.save(argThat((ComplianceCheck c) -> c != null))) .willAnswer(invocation -> invocation.getArgument(0)); handler.initiateCheck( @@ -707,14 +707,14 @@ void shouldPublishFailedEventOnTravelRuleFailure() { var senderId = UUID.randomUUID(); var recipientId = UUID.randomUUID(); given(checkRepository.findByPaymentId(paymentId)).willReturn(Optional.empty()); - given(kycProvider.verify(any(), any())).willReturn(aKycResult(null)); - given(sanctionsProvider.screen(any(), any())).willReturn(aSanctionsClearResult(null)); - given(amlProvider.analyze(any(), any())).willReturn(anAmlClearResult(null)); - given(profileRepository.findByCustomerId(any())) + given(kycProvider.verify(senderId, recipientId)).willReturn(aKycResult(null)); + given(sanctionsProvider.screen(senderId, recipientId)).willReturn(aSanctionsClearResult(null)); + given(amlProvider.analyze(senderId, recipientId)).willReturn(anAmlClearResult(null)); + given(profileRepository.findByCustomerId(senderId)) .willReturn(Optional.of(aRiskProfile())); - given(travelRuleProvider.transmit(any())) + given(travelRuleProvider.transmit(argThat((TravelRulePackage p) -> p != null))) .willThrow(new RuntimeException("Timeout")); - given(checkRepository.save(any(ComplianceCheck.class))) + given(checkRepository.save(argThat((ComplianceCheck c) -> c != null))) .willAnswer(invocation -> invocation.getArgument(0)); handler.initiateCheck( @@ -747,8 +747,9 @@ void shouldThrowOnDuplicate() { .isInstanceOf(DuplicatePaymentException.class) .hasMessageContaining(paymentId.toString()); - then(kycProvider).should(never()).verify(any(), any()); - then(checkRepository).should(never()).save(any()); + then(kycProvider).shouldHaveNoInteractions(); + then(checkRepository).should().findByPaymentId(paymentId); + then(checkRepository).shouldHaveNoMoreInteractions(); } } diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/FxQuoteApplicationServiceTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/FxQuoteApplicationServiceTest.java index 9dd3deb7..e529b697 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/FxQuoteApplicationServiceTest.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/FxQuoteApplicationServiceTest.java @@ -23,14 +23,10 @@ import static com.stablecoin.payments.fx.fixtures.CorridorRateFixtures.aUsdEurRate; import static com.stablecoin.payments.fx.fixtures.FxQuoteFixtures.anActiveQuote; -import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; -import static org.mockito.Mockito.never; @ExtendWith(MockitoExtension.class) @DisplayName("FxQuoteApplicationService") @@ -84,7 +80,7 @@ void shouldCreateQuoteFromCachedRate() { .usingRecursiveComparison() .isEqualTo(expectedResponse); - then(rateProvider).should(never()).getRate(any(), any()); + then(rateProvider).shouldHaveNoInteractions(); } @Test @@ -114,7 +110,7 @@ void shouldCreateQuoteFromProviderWhenCacheMisses() { .usingRecursiveComparison() .isEqualTo(expectedResponse); - then(rateCache).should().put(eq("USD"), eq("EUR"), eqIgnoringTimestamps(corridorRate)); + then(rateCache).should().put("USD", "EUR", corridorRate); } @Test diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/FxRateLockApplicationServiceTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/FxRateLockApplicationServiceTest.java index a9d1bce4..0025791f 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/FxRateLockApplicationServiceTest.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/FxRateLockApplicationServiceTest.java @@ -35,12 +35,11 @@ import static com.stablecoin.payments.fx.fixtures.FxRateLockFixtures.anActiveLock; import static com.stablecoin.payments.fx.fixtures.LiquidityPoolFixtures.aPoolWithLowBalance; import static com.stablecoin.payments.fx.fixtures.LiquidityPoolFixtures.aUsdEurPool; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; -import static org.mockito.Mockito.never; @ExtendWith(MockitoExtension.class) @DisplayName("FxRateLockApplicationService") @@ -110,7 +109,12 @@ void shouldLockRateSuccessfully() { .usingRecursiveComparison() .isEqualTo(expected); - then(eventPublisher).should().publish(any(FxRateLocked.class)); + var expectedEvent = new FxRateLocked( + lock.lockId(), lock.quoteId(), lock.paymentId(), CORRELATION_ID, + lock.fromCurrency(), lock.toCurrency(), + lock.sourceAmount(), lock.targetAmount(), lock.lockedRate(), + lock.feeBps(), lock.lockedAt(), lock.expiresAt()); + then(eventPublisher).should().publish(eqIgnoring(expectedEvent)); } @Test @@ -140,10 +144,11 @@ void shouldReturnExistingLockForSamePaymentId() { .usingRecursiveComparison() .isEqualTo(expected); - then(quoteRepository).should(never()).findById(any()); - then(lockService).should(never()).lockRate(any(), any(), any(), any(), any(), any()); - then(lockRepository).should(never()).save(any()); - then(eventPublisher).should(never()).publish(any()); + then(quoteRepository).shouldHaveNoInteractions(); + then(lockService).shouldHaveNoInteractions(); + then(lockRepository).should().findByPaymentId(PAYMENT_ID); + then(lockRepository).shouldHaveNoMoreInteractions(); + then(eventPublisher).shouldHaveNoInteractions(); } @Test @@ -160,8 +165,9 @@ void shouldThrowWhenQuoteNotFound() { .isInstanceOf(QuoteNotFoundException.class) .hasMessageContaining(quoteId.toString()); - then(lockRepository).should(never()).save(any()); - then(eventPublisher).should(never()).publish(any()); + then(lockRepository).should().findByPaymentId(PAYMENT_ID); + then(lockRepository).shouldHaveNoMoreInteractions(); + then(eventPublisher).shouldHaveNoInteractions(); } @Test @@ -179,8 +185,9 @@ void shouldThrowWhenQuoteExpired() { .isInstanceOf(QuoteExpiredException.class) .hasMessageContaining(quoteId.toString()); - then(lockRepository).should(never()).save(any()); - then(eventPublisher).should(never()).publish(any()); + then(lockRepository).should().findByPaymentId(PAYMENT_ID); + then(lockRepository).shouldHaveNoMoreInteractions(); + then(eventPublisher).shouldHaveNoInteractions(); } @Test @@ -198,8 +205,9 @@ void shouldThrowWhenQuoteAlreadyLocked() { .isInstanceOf(QuoteAlreadyLockedException.class) .hasMessageContaining(quoteId.toString()); - then(lockRepository).should(never()).save(any()); - then(eventPublisher).should(never()).publish(any()); + then(lockRepository).should().findByPaymentId(PAYMENT_ID); + then(lockRepository).shouldHaveNoMoreInteractions(); + then(eventPublisher).shouldHaveNoInteractions(); } @Test @@ -218,8 +226,9 @@ void shouldThrowWhenPoolNotFound() { .isInstanceOf(PoolNotFoundException.class) .hasMessageContaining("USD:EUR"); - then(lockRepository).should(never()).save(any()); - then(eventPublisher).should(never()).publish(any()); + then(lockRepository).should().findByPaymentId(PAYMENT_ID); + then(lockRepository).shouldHaveNoMoreInteractions(); + then(eventPublisher).shouldHaveNoInteractions(); } @Test @@ -239,8 +248,9 @@ void shouldThrowWhenInsufficientLiquidity() { .isInstanceOf(InsufficientLiquidityException.class) .hasMessageContaining("USD:EUR"); - then(lockRepository).should(never()).save(any()); - then(eventPublisher).should(never()).publish(any()); + then(lockRepository).should().findByPaymentId(PAYMENT_ID); + then(lockRepository).shouldHaveNoMoreInteractions(); + then(eventPublisher).shouldHaveNoInteractions(); } } } diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/infrastructure/messaging/PaymentEventConsumerTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/infrastructure/messaging/PaymentEventConsumerTest.java index 1af61ea9..42bd5170 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/infrastructure/messaging/PaymentEventConsumerTest.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/infrastructure/messaging/PaymentEventConsumerTest.java @@ -18,11 +18,11 @@ import java.util.Optional; import java.util.UUID; -import static org.mockito.ArgumentMatchers.any; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; class PaymentEventConsumerTest { @@ -83,14 +83,24 @@ void consumesLockAndReservation() { given(lockRepository.findByPaymentId(paymentId)).willReturn(Optional.of(lock)); given(poolRepository.findByCorridor("USD", "EUR")).willReturn(Optional.of(pool)); - given(lockRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); - given(poolRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); consumer.onPaymentCompleted(paymentEvent(paymentId)); - then(lockRepository).should().save(any(FxRateLock.class)); - then(poolRepository).should().save(any(LiquidityPool.class)); - then(eventPublisher).should(never()).publish(any()); + var expectedConsumedLock = new FxRateLock( + lock.lockId(), lock.quoteId(), lock.paymentId(), lock.correlationId(), + lock.fromCurrency(), lock.toCurrency(), + lock.sourceAmount(), lock.targetAmount(), lock.lockedRate(), + lock.feeBps(), lock.feeAmount(), + lock.sourceCountry(), lock.targetCountry(), + FxRateLockStatus.CONSUMED, + lock.lockedAt(), lock.expiresAt(), Instant.now()); + then(lockRepository).should().save(eqIgnoring(expectedConsumedLock, "consumedAt")); + var expectedPool = new LiquidityPool( + pool.poolId(), pool.fromCurrency(), pool.toCurrency(), + pool.availableBalance(), pool.reservedBalance().subtract(lock.targetAmount()), + pool.minimumThreshold(), pool.maximumCapacity(), pool.updatedAt()); + then(poolRepository).should().save(eqIgnoringTimestamps(expectedPool)); + then(eventPublisher).shouldHaveNoInteractions(); } @Test @@ -98,17 +108,18 @@ void consumesLockAndReservation() { void publishesThresholdEvent() { var paymentId = UUID.randomUUID(); var lock = anActiveLock(paymentId); - // Pool available=1000 after consuming 920 reservation stays at 1000, threshold=50000 — breach var pool = aPool(new BigDecimal("1000.00"), new BigDecimal("920.00"), new BigDecimal("50000.00")); given(lockRepository.findByPaymentId(paymentId)).willReturn(Optional.of(lock)); given(poolRepository.findByCorridor("USD", "EUR")).willReturn(Optional.of(pool)); - given(lockRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); - given(poolRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); consumer.onPaymentCompleted(paymentEvent(paymentId)); - then(eventPublisher).should().publish(any(LiquidityThresholdBreached.class)); + var expectedEvent = new LiquidityThresholdBreached( + pool.poolId(), "USD", "EUR", + new BigDecimal("1000.00"), new BigDecimal("50000.00"), + Instant.now()); + then(eventPublisher).should().publish(eqIgnoringTimestamps(expectedEvent)); } @Test @@ -129,8 +140,9 @@ void idempotentConsumed() { consumer.onPaymentCompleted(paymentEvent(paymentId)); - then(lockRepository).should(never()).save(any()); - then(poolRepository).should(never()).save(any()); + then(lockRepository).should().findByPaymentId(paymentId); + then(lockRepository).shouldHaveNoMoreInteractions(); + then(poolRepository).shouldHaveNoInteractions(); } @Test @@ -141,8 +153,9 @@ void noLockFound() { consumer.onPaymentCompleted(paymentEvent(paymentId)); - then(lockRepository).should(never()).save(any()); - then(poolRepository).should(never()).findByCorridor(any(), any()); + then(lockRepository).should().findByPaymentId(paymentId); + then(lockRepository).shouldHaveNoMoreInteractions(); + then(poolRepository).shouldHaveNoInteractions(); } } @@ -159,13 +172,24 @@ void expiresLockAndReleasesReservation() { given(lockRepository.findByPaymentId(paymentId)).willReturn(Optional.of(lock)); given(poolRepository.findByCorridor("USD", "EUR")).willReturn(Optional.of(pool)); - given(lockRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); - given(poolRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); consumer.onPaymentFailed(paymentEvent(paymentId)); - then(lockRepository).should().save(any(FxRateLock.class)); - then(poolRepository).should().save(any(LiquidityPool.class)); + var expectedExpiredLock = new FxRateLock( + lock.lockId(), lock.quoteId(), lock.paymentId(), lock.correlationId(), + lock.fromCurrency(), lock.toCurrency(), + lock.sourceAmount(), lock.targetAmount(), lock.lockedRate(), + lock.feeBps(), lock.feeAmount(), + lock.sourceCountry(), lock.targetCountry(), + FxRateLockStatus.EXPIRED, + lock.lockedAt(), lock.expiresAt(), null); + then(lockRepository).should().save(expectedExpiredLock); + var expectedPool = new LiquidityPool( + pool.poolId(), pool.fromCurrency(), pool.toCurrency(), + pool.availableBalance().add(lock.targetAmount()), + pool.reservedBalance().subtract(lock.targetAmount()), + pool.minimumThreshold(), pool.maximumCapacity(), pool.updatedAt()); + then(poolRepository).should().save(eqIgnoringTimestamps(expectedPool)); } @Test @@ -186,8 +210,9 @@ void idempotentExpired() { consumer.onPaymentFailed(paymentEvent(paymentId)); - then(lockRepository).should(never()).save(any()); - then(poolRepository).should(never()).save(any()); + then(lockRepository).should().findByPaymentId(paymentId); + then(lockRepository).shouldHaveNoMoreInteractions(); + then(poolRepository).shouldHaveNoInteractions(); } @Test @@ -198,7 +223,8 @@ void noLockFoundFailed() { consumer.onPaymentFailed(paymentEvent(paymentId)); - then(lockRepository).should(never()).save(any()); + then(lockRepository).should().findByPaymentId(paymentId); + then(lockRepository).shouldHaveNoMoreInteractions(); } } } diff --git a/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/infrastructure/messaging/LedgerOutboxEventPublisherTest.java b/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/infrastructure/messaging/LedgerOutboxEventPublisherTest.java index 3ce2fb61..7e6f99cc 100644 --- a/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/infrastructure/messaging/LedgerOutboxEventPublisherTest.java +++ b/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/infrastructure/messaging/LedgerOutboxEventPublisherTest.java @@ -10,13 +10,13 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; import java.math.BigDecimal; import java.time.Instant; import java.util.UUID; -import static org.assertj.core.api.Assertions.assertThat; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; @@ -47,19 +47,13 @@ void schedulesOutboxEvent() { publisher.publishReconciliationCompleted(domainEvent); - var captor = ArgumentCaptor.forClass(ReconciliationCompletedEvent.class); - then(outbox).should().schedule(captor.capture(), - org.mockito.ArgumentMatchers.eq(PAYMENT_ID.toString())); - - var apiEvent = captor.getValue(); - assertThat(apiEvent) - .usingRecursiveComparison() - .ignoringFields("eventId") - .isEqualTo(new ReconciliationCompletedEvent( - ReconciliationCompletedEvent.SCHEMA_VERSION, - apiEvent.eventId(), - ReconciliationCompletedEvent.EVENT_TYPE, - REC_ID, PAYMENT_ID, "RECONCILED", NOW)); + var expectedEvent = new ReconciliationCompletedEvent( + ReconciliationCompletedEvent.SCHEMA_VERSION, + null, + ReconciliationCompletedEvent.EVENT_TYPE, + REC_ID, PAYMENT_ID, "RECONCILED", NOW); + then(outbox).should().schedule(eqIgnoring(expectedEvent, "eventId"), + argThat((String s) -> PAYMENT_ID.toString().equals(s))); } } @@ -76,20 +70,14 @@ REC_ID, PAYMENT_ID, new BigDecimal("1.50"), "USDC", publisher.publishReconciliationDiscrepancy(domainEvent); - var captor = ArgumentCaptor.forClass(ReconciliationDiscrepancyEvent.class); - then(outbox).should().schedule(captor.capture(), - org.mockito.ArgumentMatchers.eq(PAYMENT_ID.toString())); - - var apiEvent = captor.getValue(); - assertThat(apiEvent) - .usingRecursiveComparison() - .ignoringFields("eventId") - .isEqualTo(new ReconciliationDiscrepancyEvent( - ReconciliationDiscrepancyEvent.SCHEMA_VERSION, - apiEvent.eventId(), - ReconciliationDiscrepancyEvent.EVENT_TYPE, - REC_ID, PAYMENT_ID, new BigDecimal("1.50"), "USDC", - "Stablecoin discrepancy", NOW)); + var expectedEvent = new ReconciliationDiscrepancyEvent( + ReconciliationDiscrepancyEvent.SCHEMA_VERSION, + null, + ReconciliationDiscrepancyEvent.EVENT_TYPE, + REC_ID, PAYMENT_ID, new BigDecimal("1.50"), "USDC", + "Stablecoin discrepancy", NOW); + then(outbox).should().schedule(eqIgnoring(expectedEvent, "eventId"), + argThat((String s) -> PAYMENT_ID.toString().equals(s))); } } } diff --git a/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/AuthServiceTest.java b/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/AuthServiceTest.java index bd170baa..2f25abc0 100644 --- a/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/AuthServiceTest.java +++ b/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/AuthServiceTest.java @@ -22,8 +22,7 @@ import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; @@ -83,21 +82,21 @@ class Login { @Test void shouldPersistSessionOnSuccessfulLogin() { var user = buildActiveUser(); + var role = buildRole(); given(emailHasher.hash("admin@test.com")).willReturn("hash"); given(loginAttemptTracker.isLockedOut("hash")).willReturn(false); given(userRepository.findByMerchantIdAndEmailHash(MERCHANT_ID, "hash")) .willReturn(Optional.of(user)); given(passwordHasher.verify("password", null)).willReturn(true); - given(roleRepository.findById(ROLE_ID)).willReturn(Optional.of(buildRole())); + given(roleRepository.findById(ROLE_ID)).willReturn(Optional.of(role)); given(jwtTokenIssuer.refreshTokenTtlSeconds()).willReturn(86400); - given(jwtTokenIssuer.issueAccessToken(any(), any(), anyBoolean())).willReturn("access"); - given(jwtTokenIssuer.issueRefreshToken(any(), any())).willReturn("refresh"); - given(userRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); - given(sessionRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(jwtTokenIssuer.issueAccessToken(user, role, false)).willReturn("access"); authService.login(MERCHANT_ID, "admin@test.com", "password"); - then(sessionRepository).should().save(any(UserSession.class)); + then(sessionRepository).should().save(argThat((UserSession s) -> + USER_ID.equals(s.userId()) && MERCHANT_ID.equals(s.merchantId()))); + then(jwtTokenIssuer).should().issueAccessToken(user, role, false); } } @@ -118,18 +117,20 @@ private UserSession buildValidSession() { @Test void shouldRefreshTokenForActiveUserWithValidSession() { + var user = buildActiveUser(); + var role = buildRole(); var parsed = new JwtTokenIssuer.ParsedRefreshToken( UUID.randomUUID(), USER_ID, SESSION_ID, Instant.now().plusSeconds(3600).getEpochSecond()); given(jwtTokenIssuer.parseRefreshToken("refresh-jwt")).willReturn(parsed); given(sessionRepository.findById(SESSION_ID)).willReturn(Optional.of(buildValidSession())); - given(userRepository.findById(USER_ID)).willReturn(Optional.of(buildActiveUser())); - given(roleRepository.findById(ROLE_ID)).willReturn(Optional.of(buildRole())); - given(jwtTokenIssuer.issueAccessToken(any(), any(), anyBoolean())).willReturn("new-access-token"); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + given(roleRepository.findById(ROLE_ID)).willReturn(Optional.of(role)); + given(jwtTokenIssuer.issueAccessToken(user, role, false)).willReturn("new-access-token"); authService.refreshToken("refresh-jwt"); - then(jwtTokenIssuer).should().issueAccessToken(any(), any(), anyBoolean()); + then(jwtTokenIssuer).should().issueAccessToken(user, role, false); } @Test @@ -254,12 +255,10 @@ void shouldActivateMfaWhenTotpValid() { var user = buildActiveUser(); given(mfaProvider.verify("secret", "123456")).willReturn(true); given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); - given(userRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); - - var expectedUser = user.enableMfa("secret"); authService.activateMfa(USER_ID, "secret", "123456"); + var expectedUser = user.enableMfa("secret"); then(userRepository).should().save(eqIgnoringTimestamps(expectedUser)); } diff --git a/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/MerchantTeamServiceTest.java b/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/MerchantTeamServiceTest.java index 8004f756..f1ef3e46 100644 --- a/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/MerchantTeamServiceTest.java +++ b/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/MerchantTeamServiceTest.java @@ -23,6 +23,7 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.UUID; @@ -30,9 +31,7 @@ import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; -import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; @@ -109,8 +108,6 @@ void invites_user_saves_and_sends_email() { given(tokenGenerator.hash("plain-token")).willReturn("token-hash"); given(emailHasher.hash("new@test.com")).willReturn("new-hash"); given(roleRepository.findById(VIEWER_ROLE_ID)).willReturn(Optional.of(viewerRole)); - given(userRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); - given(invitationRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); var expectedUser = MerchantUser.builder() .merchantId(MERCHANT_ID) @@ -134,8 +131,12 @@ void invites_user_saves_and_sends_email() { then(userRepository).should().save(eqIgnoring(expectedUser, "userId")); then(invitationRepository).should().save(eqIgnoring(expectedInvitation, "invitationId")); then(emailSenderProvider).should().sendInvitationEmail( - eq("new@test.com"), eq("New User"), eq("ACME Corp"), eq("plain-token"), any()); - then(eventPublisher).should().publish(any()); + argThat((String s) -> "new@test.com".equals(s)), + argThat((String s) -> "New User".equals(s)), + argThat((String s) -> "ACME Corp".equals(s)), + argThat((String s) -> "plain-token".equals(s)), + argThat((Instant i) -> i != null)); + then(eventPublisher).should().publish(argThat(Objects::nonNull)); } @Test @@ -143,7 +144,7 @@ void throws_when_role_not_found() { givenEmptyTeam(); given(tokenGenerator.generateToken()).willReturn("token"); given(tokenGenerator.hash("token")).willReturn("hash"); - given(emailHasher.hash(anyString())).willReturn("email-hash"); + given(emailHasher.hash("x@test.com")).willReturn("email-hash"); var unknownRoleId = UUID.randomUUID(); @@ -184,8 +185,8 @@ void activates_user_on_valid_token() { given(roleRepository.findByMerchantId(MERCHANT_ID)).willReturn(List.of(adminRole, viewerRole)); given(userRepository.findByMerchantId(MERCHANT_ID)).willReturn(List.of(adminUser, invitedUser)); given(invitationRepository.findByMerchantId(MERCHANT_ID)).willReturn(List.of(invitation)); - given(userRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); - given(invitationRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(userRepository.save(argThat((MerchantUser u) -> u != null))).willAnswer(inv -> inv.getArgument(0)); + given(invitationRepository.save(argThat((Invitation i) -> i != null))).willAnswer(inv -> inv.getArgument(0)); var expectedUser = invitedUser.toBuilder() .status(UserStatus.ACTIVE) @@ -219,7 +220,7 @@ void suspends_user_and_revokes_sessions() { given(roleRepository.findByMerchantId(MERCHANT_ID)).willReturn(List.of(adminRole, viewerRole)); given(userRepository.findByMerchantId(MERCHANT_ID)).willReturn(List.of(adminUser, viewer)); given(invitationRepository.findByMerchantId(MERCHANT_ID)).willReturn(List.of()); - given(userRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(userRepository.save(argThat((MerchantUser u) -> u != null))).willAnswer(inv -> inv.getArgument(0)); var expectedUser = viewer.toBuilder() .status(UserStatus.SUSPENDED) @@ -253,7 +254,7 @@ void reactivates_suspended_user() { given(roleRepository.findByMerchantId(MERCHANT_ID)).willReturn(List.of(adminRole, viewerRole)); given(userRepository.findByMerchantId(MERCHANT_ID)).willReturn(List.of(adminUser, suspended)); given(invitationRepository.findByMerchantId(MERCHANT_ID)).willReturn(List.of()); - given(userRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(userRepository.save(argThat((MerchantUser u) -> u != null))).willAnswer(inv -> inv.getArgument(0)); var expectedUser = suspended.toBuilder() .status(UserStatus.ACTIVE) @@ -286,7 +287,7 @@ void deactivates_user_revokes_sessions_and_evicts_cache() { given(roleRepository.findByMerchantId(MERCHANT_ID)).willReturn(List.of(adminRole, viewerRole)); given(userRepository.findByMerchantId(MERCHANT_ID)).willReturn(List.of(adminUser, viewer)); given(invitationRepository.findByMerchantId(MERCHANT_ID)).willReturn(List.of()); - given(userRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(userRepository.save(argThat((MerchantUser u) -> u != null))).willAnswer(inv -> inv.getArgument(0)); var expectedUser = viewer.toBuilder() .status(UserStatus.DEACTIVATED) @@ -308,7 +309,7 @@ class CreateRole { @Test void creates_custom_role_and_saves() { givenEmptyTeam(); - given(roleRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(roleRepository.save(argThat((Role r) -> r != null))).willAnswer(inv -> inv.getArgument(0)); var expectedRole = Role.builder() .merchantId(MERCHANT_ID) @@ -346,7 +347,7 @@ void updates_custom_role_and_evicts_cache() { .willReturn(List.of(adminRole, viewerRole, customRole)); given(userRepository.findByMerchantId(MERCHANT_ID)).willReturn(List.of(adminUser)); given(invitationRepository.findByMerchantId(MERCHANT_ID)).willReturn(List.of()); - given(roleRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(roleRepository.save(argThat((Role r) -> r != null))).willAnswer(inv -> inv.getArgument(0)); var expectedRole = customRole.toBuilder() .permissions(List.of( @@ -384,7 +385,7 @@ void changes_role_and_evicts_cache() { given(invitationRepository.findByMerchantId(MERCHANT_ID)).willReturn(List.of()); given(roleRepository.findById(VIEWER_ROLE_ID)).willReturn(Optional.of(viewerRole)); given(roleRepository.findById(ADMIN_ROLE_ID)).willReturn(Optional.of(adminRole)); - given(userRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(userRepository.save(argThat((MerchantUser u) -> u != null))).willAnswer(inv -> inv.getArgument(0)); var expectedUser = viewer.toBuilder() .roleId(ADMIN_ROLE_ID) @@ -440,7 +441,7 @@ class SeedRolesAndFirstAdmin { @Test void should_save_invitation_record_for_first_admin() { given(emailHasher.hash("admin@test.com")).willReturn("admin-hash"); - given(userRepository.save(any())).willAnswer(inv -> inv.getArgument(0)); + given(userRepository.save(argThat((MerchantUser u) -> u != null))).willAnswer(inv -> inv.getArgument(0)); given(tokenGenerator.generateToken()).willReturn("invite-token"); given(tokenGenerator.hash("invite-token")).willReturn("hashed-token"); @@ -455,8 +456,11 @@ void should_save_invitation_record_for_first_admin() { then(invitationRepository).should().save(eqIgnoring(expectedInvitation, "invitationId", "roleId", "invitedBy")); then(emailSenderProvider).should().sendInvitationEmail( - eq("admin@test.com"), eq("Admin User"), eq("ACME Corp"), - eq("invite-token"), any(Instant.class)); + argThat((String s) -> "admin@test.com".equals(s)), + argThat((String s) -> "Admin User".equals(s)), + argThat((String s) -> "ACME Corp".equals(s)), + argThat((String s) -> "invite-token".equals(s)), + argThat((Instant i) -> i != null)); } } } diff --git a/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/application/controller/KybWebhookControllerTest.java b/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/application/controller/KybWebhookControllerTest.java index 26e37cfd..b923943a 100644 --- a/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/application/controller/KybWebhookControllerTest.java +++ b/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/application/controller/KybWebhookControllerTest.java @@ -4,6 +4,7 @@ import com.stablecoin.payments.merchant.onboarding.domain.merchant.model.core.KybStatus; import com.stablecoin.payments.merchant.onboarding.domain.merchant.model.core.KybVerification; import com.stablecoin.payments.merchant.onboarding.infrastructure.kyb.OnfidoWebhookValidator; +import com.stablecoin.payments.merchant.onboarding.infrastructure.temporal.signal.KybResultSignal; import com.stablecoin.payments.merchant.onboarding.infrastructure.temporal.workflow.MerchantOnboardingWorkflow; import io.temporal.client.WorkflowClient; import org.junit.jupiter.api.DisplayName; @@ -20,12 +21,10 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; @ExtendWith(MockitoExtension.class) @DisplayName("KybWebhookController") @@ -50,7 +49,7 @@ void shouldReturn401WhenSignatureInvalid() { var response = controller.handleOnfidoWebhook(body, "bad-sig"); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); - then(kybProvider).should(never()).handleWebhook(any()); + then(kybProvider).shouldHaveNoInteractions(); } @Test @@ -67,13 +66,16 @@ void shouldSignalWorkflowOnValidWebhook() { given(webhookValidator.isValid(body, "valid-sig")).willReturn(true); given(webhookValidator.parsePayload(body)).willReturn(payload); given(kybProvider.handleWebhook(payload)).willReturn(kybResult); - given(workflowClient.newWorkflowStub(eq(MerchantOnboardingWorkflow.class), eq("onboarding-" + merchantId))) + given(workflowClient.newWorkflowStub(MerchantOnboardingWorkflow.class, "onboarding-" + merchantId)) .willReturn(workflowStub); var response = controller.handleOnfidoWebhook(body, "valid-sig"); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - then(workflowStub).should().kybResultReceived(any()); + then(workflowStub).should().kybResultReceived(argThat((KybResultSignal s) -> + kybResult.kybId().equals(s.kybId()) + && "onfido".equals(s.provider()) + && KybStatus.PASSED.name().equals(s.status()))); } @Test @@ -89,7 +91,7 @@ void shouldSkipNonCheckEvent() { var response = controller.handleOnfidoWebhook(body, "sig"); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - then(workflowClient).should(never()).newWorkflowStub(any(Class.class), any(String.class)); + then(workflowClient).shouldHaveNoInteractions(); } @Test @@ -108,7 +110,7 @@ void shouldReturn200WhenMerchantIdCannotBeResolved() { var response = controller.handleOnfidoWebhook(body, "sig"); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - then(workflowClient).should(never()).newWorkflowStub(any(Class.class), any(String.class)); + then(workflowClient).shouldHaveNoInteractions(); } @Test @@ -126,12 +128,15 @@ void shouldResolveMerchantIdFromPayloadTags() { given(webhookValidator.isValid(body, "sig")).willReturn(true); given(webhookValidator.parsePayload(body)).willReturn(payload); given(kybProvider.handleWebhook(payload)).willReturn(kybResult); - given(workflowClient.newWorkflowStub(eq(MerchantOnboardingWorkflow.class), eq("onboarding-" + merchantId))) + given(workflowClient.newWorkflowStub(MerchantOnboardingWorkflow.class, "onboarding-" + merchantId)) .willReturn(workflowStub); var response = controller.handleOnfidoWebhook(body, "sig"); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - then(workflowStub).should().kybResultReceived(any()); + then(workflowStub).should().kybResultReceived(argThat((KybResultSignal s) -> + kybResult.kybId().equals(s.kybId()) + && "onfido".equals(s.provider()) + && KybStatus.PASSED.name().equals(s.status()))); } } diff --git a/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/domain/merchant/MerchantCommandHandlerTest.java b/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/domain/merchant/MerchantCommandHandlerTest.java index 30137512..8d39739c 100644 --- a/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/domain/merchant/MerchantCommandHandlerTest.java +++ b/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/domain/merchant/MerchantCommandHandlerTest.java @@ -28,11 +28,10 @@ import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.BDDMockito.willThrow; -import static org.mockito.Mockito.never; @ExtendWith(MockitoExtension.class) @DisplayName("MerchantCommandHandler") @@ -68,7 +67,7 @@ void shouldApplyMerchant() { null, null, List.of("GB->US")); given(merchantRepository.existsByRegistrationNumberAndCountry("REG-123", "GB")) .willReturn(false); - given(merchantRepository.save(any(Merchant.class))) + given(merchantRepository.save(argThat((Merchant m) -> m != null))) .willAnswer(inv -> inv.getArgument(0)); var expectedMerchant = Merchant.createNew( @@ -100,7 +99,7 @@ void shouldStartKyb() { var merchant = MerchantFixtures.appliedMerchant(); var merchantId = merchant.getMerchantId(); given(merchantRepository.findById(merchantId)).willReturn(Optional.of(merchant)); - given(merchantRepository.save(any(Merchant.class))) + given(merchantRepository.save(argThat((Merchant m) -> m != null))) .willAnswer(inv -> inv.getArgument(0)); var expectedMerchant = merchant.toBuilder().build(); @@ -123,7 +122,7 @@ void shouldActivateMerchant() { var approver = MerchantFixtures.anApprover(); var scopes = List.of("payments:read", "payments:write"); given(merchantRepository.findById(merchantId)).willReturn(Optional.of(merchant)); - given(merchantRepository.save(any(Merchant.class))) + given(merchantRepository.save(argThat((Merchant m) -> m != null))) .willAnswer(inv -> inv.getArgument(0)); var expectedMerchant = merchant.toBuilder().build(); @@ -162,12 +161,12 @@ void shouldRejectActivationWhenPolicyFails() { var merchantId = merchant.getMerchantId(); given(merchantRepository.findById(merchantId)).willReturn(Optional.of(merchant)); willThrow(new IllegalStateException("KYB not passed")) - .given(activationPolicy).validate(any()); + .given(activationPolicy).validate(merchant); // when / then assertThatThrownBy(() -> handler.activate(merchantId, MerchantFixtures.anApprover(), List.of("payments:read"))) .isInstanceOf(IllegalStateException.class); - then(eventPublisher).should(never()).publish(any()); + then(eventPublisher).shouldHaveNoInteractions(); } @Test @@ -177,7 +176,7 @@ void shouldSuspendMerchant() { var merchant = MerchantFixtures.activeMerchant(); var merchantId = merchant.getMerchantId(); given(merchantRepository.findById(merchantId)).willReturn(Optional.of(merchant)); - given(merchantRepository.save(any(Merchant.class))) + given(merchantRepository.save(argThat((Merchant m) -> m != null))) .willAnswer(inv -> inv.getArgument(0)); var expectedMerchant = merchant.toBuilder().build(); @@ -205,7 +204,7 @@ void shouldReactivateMerchant() { var merchant = MerchantFixtures.suspendedMerchant(); var merchantId = merchant.getMerchantId(); given(merchantRepository.findById(merchantId)).willReturn(Optional.of(merchant)); - given(merchantRepository.save(any(Merchant.class))) + given(merchantRepository.save(argThat((Merchant m) -> m != null))) .willAnswer(inv -> inv.getArgument(0)); var expectedMerchant = merchant.toBuilder().build(); @@ -225,7 +224,7 @@ void shouldCloseMerchant() { var merchant = MerchantFixtures.activeMerchant(); var merchantId = merchant.getMerchantId(); given(merchantRepository.findById(merchantId)).willReturn(Optional.of(merchant)); - given(merchantRepository.save(any(Merchant.class))) + given(merchantRepository.save(argThat((Merchant m) -> m != null))) .willAnswer(inv -> inv.getArgument(0)); var expectedMerchant = merchant.toBuilder().build(); @@ -255,7 +254,7 @@ void shouldApproveCorridor() { var approvedBy = MerchantFixtures.anApprover(); var expiresAt = Instant.now().plusSeconds(86400); given(merchantRepository.findById(merchantId)).willReturn(Optional.of(merchant)); - given(approvedCorridorRepository.save(any(ApprovedCorridor.class))) + given(approvedCorridorRepository.save(argThat((ApprovedCorridor c) -> c != null))) .willAnswer(inv -> inv.getArgument(0)); var expectedCorridor = ApprovedCorridor.builder() diff --git a/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/infrastructure/temporal/workflow/MerchantOnboardingWorkflowTest.java b/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/infrastructure/temporal/workflow/MerchantOnboardingWorkflowTest.java index 028a5f71..62427724 100644 --- a/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/infrastructure/temporal/workflow/MerchantOnboardingWorkflowTest.java +++ b/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/infrastructure/temporal/workflow/MerchantOnboardingWorkflowTest.java @@ -18,8 +18,7 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; @@ -51,8 +50,8 @@ void shouldRejectWhenCompanyNotFound(WorkflowClient workflowClient, Worker worke assertThat(result.status()).isEqualTo("REJECTED"); assertThat(result.failureReason()).contains("not found in official registry"); - then(onboardingActivities).should().rejectMerchant(any(), anyString()); - then(onboardingActivities).should(never()).startKyb(any()); + then(onboardingActivities).should().rejectMerchant(merchantId, "Company not found in official registry"); + then(onboardingActivities).should(never()).startKyb(merchantId); } @Test @@ -67,7 +66,7 @@ void shouldRejectWhenCompanyDissolved(WorkflowClient workflowClient, Worker work assertThat(result.status()).isEqualTo("REJECTED"); assertThat(result.failureReason()).contains("not active"); assertThat(result.failureReason()).contains("dissolved"); - then(onboardingActivities).should(never()).startKyb(any()); + then(onboardingActivities).should(never()).startKyb(merchantId); } @Test @@ -76,7 +75,7 @@ void shouldProceedWhenCompanyActive(WorkflowClient workflowClient, Worker worker var merchantId = UUID.randomUUID(); given(onboardingActivities.verifyCompanyRegistry(merchantId)).willReturn("active"); given(onboardingActivities.startKyb(merchantId)).willReturn("ref-123"); - given(onboardingActivities.calculateRiskTier(any())).willReturn("LOW"); + given(onboardingActivities.calculateRiskTier(argThat((Map m) -> m != null))).willReturn("LOW"); var workflow = startWorkflow(workflowClient, worker, merchantId); @@ -101,7 +100,7 @@ void shouldCompleteOnboardingWhenKybPasses(WorkflowClient workflowClient, Worker var merchantId = UUID.randomUUID(); given(onboardingActivities.verifyCompanyRegistry(merchantId)).willReturn("active"); given(onboardingActivities.startKyb(merchantId)).willReturn("provider-ref-123"); - given(onboardingActivities.calculateRiskTier(any())).willReturn("LOW"); + given(onboardingActivities.calculateRiskTier(argThat((Map m) -> m != null))).willReturn("LOW"); var workflow = startWorkflow(workflowClient, worker, merchantId); @@ -132,7 +131,9 @@ void shouldRejectWhenKybFails(WorkflowClient workflowClient, Worker worker) { assertThat(result.status()).isEqualTo("REJECTED"); assertThat(result.failureReason()).isEqualTo("KYB verification failed"); - then(onboardingActivities).should().processKybResult(any(), any()); + then(onboardingActivities).should().processKybResult( + argThat((UUID id) -> merchantId.equals(id)), + argThat((KybResultSignal s) -> "FAILED".equals(s.status()))); } } @@ -146,7 +147,7 @@ void shouldHandleManualReviewApproval(WorkflowClient workflowClient, Worker work var merchantId = UUID.randomUUID(); given(onboardingActivities.verifyCompanyRegistry(merchantId)).willReturn("active"); given(onboardingActivities.startKyb(merchantId)).willReturn("provider-ref-789"); - given(onboardingActivities.calculateRiskTier(any())).willReturn("MEDIUM"); + given(onboardingActivities.calculateRiskTier(argThat((Map m) -> m != null))).willReturn("MEDIUM"); var workflow = startWorkflow(workflowClient, worker, merchantId); diff --git a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/application/controller/PaymentControllerMvcTest.java b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/application/controller/PaymentControllerMvcTest.java index c61a1ce3..1e8bfb3a 100644 --- a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/application/controller/PaymentControllerMvcTest.java +++ b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/application/controller/PaymentControllerMvcTest.java @@ -29,8 +29,7 @@ import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.anIdempotentReplayResult; import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.anInitiateResult; import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.anInitiatedPayment; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.BDDMockito.given; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; @@ -59,10 +58,15 @@ void shouldReturn201() throws Exception { // given var result = anInitiateResult(); given(commandHandler.initiatePayment( - eq(IDEMPOTENCY_KEY), any(UUID.class), - eq(SENDER_ID), eq(RECIPIENT_ID), eq(SOURCE_AMOUNT_VALUE), - eq(SOURCE_CURRENCY), eq(TARGET_CURRENCY), - eq(SOURCE_COUNTRY), eq(TARGET_COUNTRY))) + argThat((String k) -> IDEMPOTENCY_KEY.equals(k)), + argThat((UUID id) -> id != null), + argThat((UUID id) -> SENDER_ID.equals(id)), + argThat((UUID id) -> RECIPIENT_ID.equals(id)), + argThat(amt -> SOURCE_AMOUNT_VALUE.compareTo(amt) == 0), + argThat((String c) -> SOURCE_CURRENCY.equals(c)), + argThat((String c) -> TARGET_CURRENCY.equals(c)), + argThat((String c) -> SOURCE_COUNTRY.equals(c)), + argThat((String c) -> TARGET_COUNTRY.equals(c)))) .willReturn(result); // when/then @@ -93,10 +97,15 @@ void shouldReturn200ForReplay() throws Exception { // given var result = anIdempotentReplayResult(); given(commandHandler.initiatePayment( - eq(IDEMPOTENCY_KEY), any(UUID.class), - eq(SENDER_ID), eq(RECIPIENT_ID), eq(SOURCE_AMOUNT_VALUE), - eq(SOURCE_CURRENCY), eq(TARGET_CURRENCY), - eq(SOURCE_COUNTRY), eq(TARGET_COUNTRY))) + argThat((String k) -> IDEMPOTENCY_KEY.equals(k)), + argThat((UUID id) -> id != null), + argThat((UUID id) -> SENDER_ID.equals(id)), + argThat((UUID id) -> RECIPIENT_ID.equals(id)), + argThat(amt -> SOURCE_AMOUNT_VALUE.compareTo(amt) == 0), + argThat((String c) -> SOURCE_CURRENCY.equals(c)), + argThat((String c) -> TARGET_CURRENCY.equals(c)), + argThat((String c) -> SOURCE_COUNTRY.equals(c)), + argThat((String c) -> TARGET_COUNTRY.equals(c)))) .willReturn(result); // when/then diff --git a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/application/controller/PaymentControllerTest.java b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/application/controller/PaymentControllerTest.java index 57d8ee53..47e2dfd7 100644 --- a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/application/controller/PaymentControllerTest.java +++ b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/application/controller/PaymentControllerTest.java @@ -28,8 +28,7 @@ import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.anInitiatedPayment; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.BDDMockito.given; @ExtendWith(MockitoExtension.class) @@ -58,10 +57,15 @@ void shouldReturn201ForNewPayment() { var initiateResult = anInitiateResult(); given(commandHandler.initiatePayment( - eq(IDEMPOTENCY_KEY), any(UUID.class), - eq(SENDER_ID), eq(RECIPIENT_ID), eq(SOURCE_AMOUNT_VALUE), - eq(SOURCE_CURRENCY), eq(TARGET_CURRENCY), - eq(SOURCE_COUNTRY), eq(TARGET_COUNTRY))) + argThat((String k) -> IDEMPOTENCY_KEY.equals(k)), + argThat((UUID id) -> id != null), + argThat((UUID id) -> SENDER_ID.equals(id)), + argThat((UUID id) -> RECIPIENT_ID.equals(id)), + argThat(amt -> SOURCE_AMOUNT_VALUE.compareTo(amt) == 0), + argThat((String c) -> SOURCE_CURRENCY.equals(c)), + argThat((String c) -> TARGET_CURRENCY.equals(c)), + argThat((String c) -> SOURCE_COUNTRY.equals(c)), + argThat((String c) -> TARGET_COUNTRY.equals(c)))) .willReturn(initiateResult); // when @@ -88,10 +92,15 @@ void shouldReturn200ForReplay() { var replayResult = anIdempotentReplayResult(); given(commandHandler.initiatePayment( - eq(IDEMPOTENCY_KEY), any(UUID.class), - eq(SENDER_ID), eq(RECIPIENT_ID), eq(SOURCE_AMOUNT_VALUE), - eq(SOURCE_CURRENCY), eq(TARGET_CURRENCY), - eq(SOURCE_COUNTRY), eq(TARGET_COUNTRY))) + argThat((String k) -> IDEMPOTENCY_KEY.equals(k)), + argThat((UUID id) -> id != null), + argThat((UUID id) -> SENDER_ID.equals(id)), + argThat((UUID id) -> RECIPIENT_ID.equals(id)), + argThat(amt -> SOURCE_AMOUNT_VALUE.compareTo(amt) == 0), + argThat((String c) -> SOURCE_CURRENCY.equals(c)), + argThat((String c) -> TARGET_CURRENCY.equals(c)), + argThat((String c) -> SOURCE_COUNTRY.equals(c)), + argThat((String c) -> TARGET_COUNTRY.equals(c)))) .willReturn(replayResult); // when diff --git a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/service/PaymentCommandHandlerTest.java b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/service/PaymentCommandHandlerTest.java index 3c1c1d23..021439b1 100644 --- a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/service/PaymentCommandHandlerTest.java +++ b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/service/PaymentCommandHandlerTest.java @@ -17,7 +17,6 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -39,10 +38,10 @@ import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.aFailedPayment; import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.anInitiatedPayment; import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; @@ -64,6 +63,10 @@ class PaymentCommandHandlerTest { @InjectMocks private PaymentCommandHandler handler; + private static Class argThatClass(Class expected) { + return argThat(c -> c == expected); + } + @Nested @DisplayName("initiatePayment") class InitiatePayment { @@ -81,11 +84,13 @@ void shouldCreateAndStartWorkflow() { given(paymentRepository.findByIdempotencyKey(IDEMPOTENCY_KEY)) .willReturn(Optional.empty()); - given(paymentRepository.save(any(Payment.class))) + given(paymentRepository.save(eqIgnoring(expectedPayment, "paymentId", "createdAt", "updatedAt", "expiresAt"))) .willAnswer(inv -> inv.getArgument(0)); var workflowStub = mock(PaymentWorkflow.class); - given(workflowClient.newWorkflowStub(eq(PaymentWorkflow.class), any(WorkflowOptions.class))) + given(workflowClient.newWorkflowStub( + argThatClass(PaymentWorkflow.class), + argThat((WorkflowOptions opts) -> opts != null && opts.getWorkflowId().startsWith("payment-")))) .willReturn(workflowStub); // when @@ -98,26 +103,21 @@ void shouldCreateAndStartWorkflow() { // then assertThat(result.replay()).isFalse(); - assertThat(result.payment()) - .usingRecursiveComparison() - .ignoringFields("paymentId", "createdAt", "updatedAt", "expiresAt") - .isEqualTo(expectedPayment); - then(paymentRepository).should().save(eqIgnoring(expectedPayment, "paymentId")); + then(paymentRepository).should().save(eqIgnoring(expectedPayment, "paymentId", "createdAt", "updatedAt", "expiresAt")); - var eventCaptor = ArgumentCaptor.forClass(Object.class); - then(eventPublisher).should().publish(eventCaptor.capture()); - var publishedEvent = (PaymentInitiated) eventCaptor.getValue(); var expectedEvent = new PaymentInitiated( result.payment().paymentId(), IDEMPOTENCY_KEY, CORRELATION_ID, SENDER_ID, RECIPIENT_ID, new Money(SOURCE_AMOUNT_VALUE, SOURCE_CURRENCY), TARGET_CURRENCY, new Corridor(SOURCE_COUNTRY, TARGET_COUNTRY), null); - assertThat(publishedEvent) - .usingRecursiveComparison() - .ignoringFields("initiatedAt") - .isEqualTo(expectedEvent); - - then(workflowClient).should().newWorkflowStub(eq(PaymentWorkflow.class), any(WorkflowOptions.class)); + then(eventPublisher).should().publish(eqIgnoringTimestamps(expectedEvent)); + + then(workflowClient).should().newWorkflowStub( + argThat((Class c) -> c == PaymentWorkflow.class), + argThat((WorkflowOptions opts) -> + opts != null + && opts.getWorkflowId().equals("payment-" + result.payment().paymentId()) + && PaymentCommandHandler.TASK_QUEUE.equals(opts.getTaskQueue()))); } @Test @@ -142,23 +142,27 @@ void shouldReturnExistingOnReplay() { .usingRecursiveComparison() .isEqualTo(existingPayment); - then(paymentRepository).should(never()).save(any()); - then(eventPublisher).should(never()).publish(any()); - then(workflowClient).should(never()).newWorkflowStub(eq(PaymentWorkflow.class), any(WorkflowOptions.class)); + then(paymentRepository).should(never()).save(existingPayment); + then(eventPublisher).shouldHaveNoInteractions(); + then(workflowClient).shouldHaveNoInteractions(); } @Test @DisplayName("should handle concurrent duplicate by returning existing payment") void shouldHandleConcurrentDuplicate() { // given + var expectedPayment = Payment.initiate( + IDEMPOTENCY_KEY, CORRELATION_ID, SENDER_ID, RECIPIENT_ID, + new Money(SOURCE_AMOUNT_VALUE, SOURCE_CURRENCY), + SOURCE_CURRENCY, TARGET_CURRENCY, + new Corridor(SOURCE_COUNTRY, TARGET_COUNTRY) + ); var existingPayment = anInitiatedPayment(); - given(paymentRepository.findByIdempotencyKey(IDEMPOTENCY_KEY)) - .willReturn(Optional.empty()); - given(paymentRepository.save(any(Payment.class))) - .willThrow(new DataIntegrityViolationException("duplicate key")); given(paymentRepository.findByIdempotencyKey(IDEMPOTENCY_KEY)) .willReturn(Optional.empty()) .willReturn(Optional.of(existingPayment)); + given(paymentRepository.save(eqIgnoring(expectedPayment, "paymentId", "createdAt", "updatedAt", "expiresAt"))) + .willThrow(new DataIntegrityViolationException("duplicate key")); // when var result = handler.initiatePayment( @@ -174,8 +178,8 @@ void shouldHandleConcurrentDuplicate() { .usingRecursiveComparison() .isEqualTo(existingPayment); - then(eventPublisher).should(never()).publish(any()); - then(workflowClient).should(never()).newWorkflowStub(eq(PaymentWorkflow.class), any(WorkflowOptions.class)); + then(eventPublisher).shouldHaveNoInteractions(); + then(workflowClient).shouldHaveNoInteractions(); } } @@ -241,14 +245,8 @@ void shouldSendCancelSignal() { .usingRecursiveComparison() .isEqualTo(payment); - var cancelCaptor = ArgumentCaptor.forClass(CancelRequest.class); - then(workflowStub).should().cancelPayment(cancelCaptor.capture()); - - var capturedRequest = cancelCaptor.getValue(); var expectedCancel = new CancelRequest(payment.paymentId(), "Customer requested", "API"); - assertThat(capturedRequest) - .usingRecursiveComparison() - .isEqualTo(expectedCancel); + then(workflowStub).should().cancelPayment(expectedCancel); } @Test diff --git a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/workflow/PaymentWorkflowTest.java b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/workflow/PaymentWorkflowTest.java index cb9fe654..bf7ef806 100644 --- a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/workflow/PaymentWorkflowTest.java +++ b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/workflow/PaymentWorkflowTest.java @@ -2,16 +2,21 @@ import com.stablecoin.payments.orchestrator.domain.workflow.activity.ChainReturnRequest; import com.stablecoin.payments.orchestrator.domain.workflow.activity.ChainTransferActivity; +import com.stablecoin.payments.orchestrator.domain.workflow.activity.ChainTransferRequest; import com.stablecoin.payments.orchestrator.domain.workflow.activity.ChainTransferResult; import com.stablecoin.payments.orchestrator.domain.workflow.activity.ComplianceCheckActivity; +import com.stablecoin.payments.orchestrator.domain.workflow.activity.ComplianceRequest; import com.stablecoin.payments.orchestrator.domain.workflow.activity.ComplianceResult; import com.stablecoin.payments.orchestrator.domain.workflow.activity.EventPublishingActivity; import com.stablecoin.payments.orchestrator.domain.workflow.activity.FiatCollectionActivity; +import com.stablecoin.payments.orchestrator.domain.workflow.activity.FiatCollectionRequest; import com.stablecoin.payments.orchestrator.domain.workflow.activity.FiatCollectionResult; import com.stablecoin.payments.orchestrator.domain.workflow.activity.FiatRefundRequest; import com.stablecoin.payments.orchestrator.domain.workflow.activity.FxLockActivity; +import com.stablecoin.payments.orchestrator.domain.workflow.activity.FxLockRequest; import com.stablecoin.payments.orchestrator.domain.workflow.activity.FxReleaseRequest; import com.stablecoin.payments.orchestrator.domain.workflow.activity.OffRampActivity; +import com.stablecoin.payments.orchestrator.domain.workflow.activity.OffRampRequest; import com.stablecoin.payments.orchestrator.domain.workflow.activity.OffRampResult; import com.stablecoin.payments.orchestrator.domain.workflow.activity.PaymentEventRequest; import com.stablecoin.payments.orchestrator.domain.workflow.activity.UpdatePaymentStateActivity; @@ -48,13 +53,11 @@ import static com.stablecoin.payments.orchestrator.fixtures.WorkflowFixtures.anInitiatedCollectionResult; import static com.stablecoin.payments.orchestrator.fixtures.WorkflowFixtures.anInitiatedPayoutResult; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.BDDMockito.willThrow; import static org.mockito.Mockito.atLeast; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; @DisplayName("PaymentWorkflow") class PaymentWorkflowTest { @@ -67,6 +70,45 @@ class PaymentWorkflowTest { private final UpdatePaymentStateActivity updateStateActivity = mock(UpdatePaymentStateActivity.class); private final EventPublishingActivity eventPublishingActivity = mock(EventPublishingActivity.class); + private static ComplianceRequest expectedComplianceRequest() { + var req = aPaymentRequest(); + return new ComplianceRequest( + req.paymentId(), req.senderId(), req.recipientId(), + req.sourceAmount(), req.sourceCurrency(), req.targetCurrency(), + req.sourceCountry(), req.targetCountry()); + } + + private static FxLockRequest expectedFxLockRequest() { + var req = aPaymentRequest(); + return new FxLockRequest( + req.idempotencyKey(), req.paymentId(), + req.sourceCurrency(), req.targetCurrency(), + req.sourceAmount(), req.sourceCountry(), req.targetCountry()); + } + + private static FiatCollectionRequest expectedFiatCollectionRequest() { + var req = aPaymentRequest(); + return new FiatCollectionRequest( + req.paymentId(), req.correlationId(), + req.sourceAmount(), req.sourceCurrency(), req.sourceCountry()); + } + + private static ChainTransferRequest expectedChainTransferRequest() { + var req = aPaymentRequest(); + return new ChainTransferRequest( + req.paymentId(), req.correlationId(), + "USDC", new BigDecimal("920.00"), + "0x742d35Cc6634C0532925a3b844Bc9e7595f2bD18", "base"); + } + + private static OffRampRequest expectedOffRampRequest() { + var req = aPaymentRequest(); + return new OffRampRequest( + req.paymentId(), req.correlationId(), TRANSFER_ID, + "USDC", new BigDecimal("920.00"), "EUR", + BigDecimal.ONE, req.recipientId()); + } + @RegisterExtension public TestWorkflowExtension testWorkflow = TestWorkflowExtension.newBuilder() .setWorkflowTypes(PaymentWorkflowImpl.class) @@ -83,13 +125,13 @@ class FullSandwichHappyPath { @Test @DisplayName("should complete full payment sandwich: compliance → FX → fiat → chain → off-ramp") void shouldCompleteFullSandwich(WorkflowClient workflowClient, Worker worker) { - given(complianceActivity.checkCompliance(any())) + given(complianceActivity.checkCompliance(expectedComplianceRequest())) .willReturn(new ComplianceResult(CHECK_ID, PASSED, null)); - given(fxLockActivity.lockFxRate(any())) + given(fxLockActivity.lockFxRate(expectedFxLockRequest())) .willReturn(aLockedFxResult()); // Fiat collection: return INITIATED, send signal synchronously from activity - given(fiatCollectionActivity.initiateCollection(any())) + given(fiatCollectionActivity.initiateCollection(expectedFiatCollectionRequest())) .willAnswer(invocation -> { var stub = workflowClient.newWorkflowStub( PaymentWorkflow.class, "payment-" + PAYMENT_ID); @@ -98,7 +140,7 @@ void shouldCompleteFullSandwich(WorkflowClient workflowClient, Worker worker) { }); // Chain transfer: return SUBMITTED, send confirmation signal synchronously - given(chainTransferActivity.submitTransfer(any())) + given(chainTransferActivity.submitTransfer(expectedChainTransferRequest())) .willAnswer(invocation -> { var stub = workflowClient.newWorkflowStub( PaymentWorkflow.class, "payment-" + PAYMENT_ID); @@ -106,7 +148,7 @@ void shouldCompleteFullSandwich(WorkflowClient workflowClient, Worker worker) { return aSubmittedTransferResult(); }); - given(offRampActivity.initiatePayout(any())) + given(offRampActivity.initiatePayout(expectedOffRampRequest())) .willReturn(anInitiatedPayoutResult()); startWorkflow(workflowClient, worker); @@ -120,11 +162,11 @@ void shouldCompleteFullSandwich(WorkflowClient workflowClient, Worker worker) { .usingRecursiveComparison() .isEqualTo(expected); - then(complianceActivity).should().checkCompliance(any()); - then(fxLockActivity).should().lockFxRate(any()); - then(fiatCollectionActivity).should().initiateCollection(any()); - then(chainTransferActivity).should().submitTransfer(any()); - then(offRampActivity).should().initiatePayout(any()); + then(complianceActivity).should().checkCompliance(expectedComplianceRequest()); + then(fxLockActivity).should().lockFxRate(expectedFxLockRequest()); + then(fiatCollectionActivity).should().initiateCollection(expectedFiatCollectionRequest()); + then(chainTransferActivity).should().submitTransfer(expectedChainTransferRequest()); + then(offRampActivity).should().initiatePayout(expectedOffRampRequest()); } } @@ -136,7 +178,7 @@ class ComplianceFailure { @DisplayName("should return FAILED when compliance check fails") void shouldFailWhenComplianceCheckFails(WorkflowClient workflowClient, Worker worker) { - given(complianceActivity.checkCompliance(any())) + given(complianceActivity.checkCompliance(expectedComplianceRequest())) .willReturn(new ComplianceResult(CHECK_ID, ComplianceResult.ComplianceStatus.FAILED, "PEP match")); @@ -148,8 +190,8 @@ void shouldFailWhenComplianceCheckFails(WorkflowClient workflowClient, .usingRecursiveComparison() .isEqualTo(expected); - then(fxLockActivity).should(never()).lockFxRate(any()); - then(fiatCollectionActivity).should(never()).initiateCollection(any()); + then(fxLockActivity).shouldHaveNoInteractions(); + then(fiatCollectionActivity).shouldHaveNoInteractions(); var expectedEvent = PaymentEventRequest.failed( PAYMENT_ID, aPaymentRequest().correlationId(), @@ -162,7 +204,7 @@ PAYMENT_ID, aPaymentRequest().correlationId(), @DisplayName("should return FAILED when compliance detects sanctions hit") void shouldFailOnSanctionsHit(WorkflowClient workflowClient, Worker worker) { - given(complianceActivity.checkCompliance(any())) + given(complianceActivity.checkCompliance(expectedComplianceRequest())) .willReturn(new ComplianceResult(CHECK_ID, SANCTIONS_HIT, "OFAC sanctions list match")); @@ -175,8 +217,8 @@ void shouldFailOnSanctionsHit(WorkflowClient workflowClient, .usingRecursiveComparison() .isEqualTo(expected); - then(fxLockActivity).should(never()).lockFxRate(any()); - then(fiatCollectionActivity).should(never()).initiateCollection(any()); + then(fxLockActivity).shouldHaveNoInteractions(); + then(fiatCollectionActivity).shouldHaveNoInteractions(); var expectedEvent = PaymentEventRequest.failed( PAYMENT_ID, aPaymentRequest().correlationId(), @@ -194,9 +236,9 @@ class FxLockFailure { @DisplayName("should return FAILED when FX rate lock fails — no compensation needed") void shouldFailWhenFxLockFails(WorkflowClient workflowClient, Worker worker) { - given(complianceActivity.checkCompliance(any())) + given(complianceActivity.checkCompliance(expectedComplianceRequest())) .willReturn(new ComplianceResult(CHECK_ID, PASSED, null)); - given(fxLockActivity.lockFxRate(any())) + given(fxLockActivity.lockFxRate(expectedFxLockRequest())) .willReturn(new com.stablecoin.payments.orchestrator.domain.workflow.activity.FxLockResult( null, null, null, null, null, INSUFFICIENT_LIQUIDITY, "No liquidity for USD/EUR")); @@ -210,7 +252,7 @@ void shouldFailWhenFxLockFails(WorkflowClient workflowClient, .usingRecursiveComparison() .isEqualTo(expected); - then(fiatCollectionActivity).should(never()).initiateCollection(any()); + then(fiatCollectionActivity).shouldHaveNoInteractions(); var expectedEvent = PaymentEventRequest.failed( PAYMENT_ID, aPaymentRequest().correlationId(), @@ -228,11 +270,11 @@ class FiatCollectionFailure { @DisplayName("should fail and release FX lock when fiat collection is rejected") void shouldFailAndReleaseFxLockWhenCollectionRejected(WorkflowClient workflowClient, Worker worker) { - given(complianceActivity.checkCompliance(any())) + given(complianceActivity.checkCompliance(expectedComplianceRequest())) .willReturn(new ComplianceResult(CHECK_ID, PASSED, null)); - given(fxLockActivity.lockFxRate(any())) + given(fxLockActivity.lockFxRate(expectedFxLockRequest())) .willReturn(aLockedFxResult()); - given(fiatCollectionActivity.initiateCollection(any())) + given(fiatCollectionActivity.initiateCollection(expectedFiatCollectionRequest())) .willReturn(new FiatCollectionResult(null, FiatCollectionStatus.FAILED, "PSP declined")); @@ -249,7 +291,7 @@ void shouldFailAndReleaseFxLockWhenCollectionRejected(WorkflowClient workflowCli then(fxLockActivity).should().releaseLock( new FxReleaseRequest(LOCK_ID, PAYMENT_ID, "Fiat collection failed: PSP declined")); - then(chainTransferActivity).should(never()).submitTransfer(any()); + then(chainTransferActivity).shouldHaveNoInteractions(); } } @@ -261,12 +303,12 @@ class ChainTransferFailure { @DisplayName("should fail and compensate fiat + FX when chain transfer is rejected") void shouldCompensateFiatAndFxWhenChainTransferFails(WorkflowClient workflowClient, Worker worker) { - given(complianceActivity.checkCompliance(any())) + given(complianceActivity.checkCompliance(expectedComplianceRequest())) .willReturn(new ComplianceResult(CHECK_ID, PASSED, null)); - given(fxLockActivity.lockFxRate(any())) + given(fxLockActivity.lockFxRate(expectedFxLockRequest())) .willReturn(aLockedFxResult()); - given(fiatCollectionActivity.initiateCollection(any())) + given(fiatCollectionActivity.initiateCollection(expectedFiatCollectionRequest())) .willAnswer(invocation -> { var stub = workflowClient.newWorkflowStub( PaymentWorkflow.class, "payment-" + PAYMENT_ID); @@ -275,7 +317,7 @@ void shouldCompensateFiatAndFxWhenChainTransferFails(WorkflowClient workflowClie }); // Chain transfer fails - given(chainTransferActivity.submitTransfer(any())) + given(chainTransferActivity.submitTransfer(expectedChainTransferRequest())) .willReturn(new ChainTransferResult(null, null, null, FAILED, "Insufficient balance")); @@ -296,7 +338,7 @@ void shouldCompensateFiatAndFxWhenChainTransferFails(WorkflowClient workflowClie then(fxLockActivity).should().releaseLock( new FxReleaseRequest(LOCK_ID, PAYMENT_ID, "Chain transfer failed: Insufficient balance")); - then(offRampActivity).should(never()).initiatePayout(any()); + then(offRampActivity).shouldHaveNoInteractions(); } } @@ -308,12 +350,12 @@ class OffRampFailure { @DisplayName("should fail and compensate chain + fiat + FX when off-ramp is rejected") void shouldCompensateAllWhenOffRampFails(WorkflowClient workflowClient, Worker worker) { - given(complianceActivity.checkCompliance(any())) + given(complianceActivity.checkCompliance(expectedComplianceRequest())) .willReturn(new ComplianceResult(CHECK_ID, PASSED, null)); - given(fxLockActivity.lockFxRate(any())) + given(fxLockActivity.lockFxRate(expectedFxLockRequest())) .willReturn(aLockedFxResult()); - given(fiatCollectionActivity.initiateCollection(any())) + given(fiatCollectionActivity.initiateCollection(expectedFiatCollectionRequest())) .willAnswer(invocation -> { var stub = workflowClient.newWorkflowStub( PaymentWorkflow.class, "payment-" + PAYMENT_ID); @@ -321,7 +363,7 @@ void shouldCompensateAllWhenOffRampFails(WorkflowClient workflowClient, return anInitiatedCollectionResult(); }); - given(chainTransferActivity.submitTransfer(any())) + given(chainTransferActivity.submitTransfer(expectedChainTransferRequest())) .willAnswer(invocation -> { var stub = workflowClient.newWorkflowStub( PaymentWorkflow.class, "payment-" + PAYMENT_ID); @@ -330,7 +372,7 @@ void shouldCompensateAllWhenOffRampFails(WorkflowClient workflowClient, }); // Off-ramp fails - given(offRampActivity.initiatePayout(any())) + given(offRampActivity.initiatePayout(expectedOffRampRequest())) .willReturn(new OffRampResult(null, OffRampStatus.FAILED, "Payout partner rejected")); @@ -366,10 +408,10 @@ class CancelSignal { @DisplayName("should release FX lock when cancel received after FX lock succeeds") void shouldReleaseFxLockOnCancelAfterFxLock(WorkflowClient workflowClient, Worker worker) { - given(complianceActivity.checkCompliance(any())) + given(complianceActivity.checkCompliance(expectedComplianceRequest())) .willReturn(new ComplianceResult(CHECK_ID, PASSED, null)); - given(fxLockActivity.lockFxRate(any())) + given(fxLockActivity.lockFxRate(expectedFxLockRequest())) .willAnswer(invocation -> { var stub = workflowClient.newWorkflowStub( PaymentWorkflow.class, "payment-" + PAYMENT_ID); @@ -389,7 +431,7 @@ void shouldReleaseFxLockOnCancelAfterFxLock(WorkflowClient workflowClient, then(fxLockActivity).should().releaseLock(new FxReleaseRequest( LOCK_ID, PAYMENT_ID, "Customer requested cancellation")); - then(fiatCollectionActivity).should(never()).initiateCollection(any()); + then(fiatCollectionActivity).shouldHaveNoInteractions(); var expectedEvent = PaymentEventRequest.cancelled( PAYMENT_ID, aPaymentRequest().correlationId(), @@ -401,7 +443,7 @@ PAYMENT_ID, aPaymentRequest().correlationId(), @DisplayName("should not call releaseLock when cancel before FX lock") void shouldNotReleaseLockWhenCancelBeforeFxLock(WorkflowClient workflowClient, Worker worker) { - given(complianceActivity.checkCompliance(any())) + given(complianceActivity.checkCompliance(expectedComplianceRequest())) .willAnswer(invocation -> { var stub = workflowClient.newWorkflowStub( PaymentWorkflow.class, "payment-" + PAYMENT_ID); @@ -418,9 +460,8 @@ void shouldNotReleaseLockWhenCancelBeforeFxLock(WorkflowClient workflowClient, .usingRecursiveComparison() .isEqualTo(expected); - then(fxLockActivity).should(never()).lockFxRate(any()); - then(fxLockActivity).should(never()).releaseLock(any()); - then(fiatCollectionActivity).should(never()).initiateCollection(any()); + then(fxLockActivity).shouldHaveNoInteractions(); + then(fiatCollectionActivity).shouldHaveNoInteractions(); var expectedEvent = PaymentEventRequest.cancelled( PAYMENT_ID, aPaymentRequest().correlationId(), "Changed mind"); @@ -431,10 +472,10 @@ void shouldNotReleaseLockWhenCancelBeforeFxLock(WorkflowClient workflowClient, @DisplayName("should return FAILED even when compensation activity throws") void shouldReturnFailedWhenCompensationThrows(WorkflowClient workflowClient, Worker worker) { - given(complianceActivity.checkCompliance(any())) + given(complianceActivity.checkCompliance(expectedComplianceRequest())) .willReturn(new ComplianceResult(CHECK_ID, PASSED, null)); - given(fxLockActivity.lockFxRate(any())) + given(fxLockActivity.lockFxRate(expectedFxLockRequest())) .willAnswer(invocation -> { var stub = workflowClient.newWorkflowStub( PaymentWorkflow.class, "payment-" + PAYMENT_ID); @@ -444,7 +485,8 @@ void shouldReturnFailedWhenCompensationThrows(WorkflowClient workflowClient, }); willThrow(new RuntimeException("FX engine unavailable")) - .given(fxLockActivity).releaseLock(any()); + .given(fxLockActivity).releaseLock( + new FxReleaseRequest(LOCK_ID, PAYMENT_ID, "Timeout")); startWorkflow(workflowClient, worker); var result = getResult(workflowClient); @@ -467,12 +509,12 @@ class QueryMethod { @DisplayName("should return current state via query after completion") void shouldReturnCurrentStateViaQuery(WorkflowClient workflowClient, Worker worker) { - given(complianceActivity.checkCompliance(any())) + given(complianceActivity.checkCompliance(expectedComplianceRequest())) .willReturn(new ComplianceResult(CHECK_ID, PASSED, null)); - given(fxLockActivity.lockFxRate(any())) + given(fxLockActivity.lockFxRate(expectedFxLockRequest())) .willReturn(aLockedFxResult()); - given(fiatCollectionActivity.initiateCollection(any())) + given(fiatCollectionActivity.initiateCollection(expectedFiatCollectionRequest())) .willAnswer(invocation -> { var stub = workflowClient.newWorkflowStub( PaymentWorkflow.class, "payment-" + PAYMENT_ID); @@ -480,7 +522,7 @@ void shouldReturnCurrentStateViaQuery(WorkflowClient workflowClient, return anInitiatedCollectionResult(); }); - given(chainTransferActivity.submitTransfer(any())) + given(chainTransferActivity.submitTransfer(expectedChainTransferRequest())) .willAnswer(invocation -> { var stub = workflowClient.newWorkflowStub( PaymentWorkflow.class, "payment-" + PAYMENT_ID); @@ -488,7 +530,7 @@ void shouldReturnCurrentStateViaQuery(WorkflowClient workflowClient, return aSubmittedTransferResult(); }); - given(offRampActivity.initiatePayout(any())) + given(offRampActivity.initiatePayout(expectedOffRampRequest())) .willReturn(anInitiatedPayoutResult()); startWorkflow(workflowClient, worker); diff --git a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/infrastructure/activity/ComplianceCheckActivityImplTest.java b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/infrastructure/activity/ComplianceCheckActivityImplTest.java index 7307597d..5afee05d 100644 --- a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/infrastructure/activity/ComplianceCheckActivityImplTest.java +++ b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/infrastructure/activity/ComplianceCheckActivityImplTest.java @@ -1,5 +1,6 @@ package com.stablecoin.payments.orchestrator.infrastructure.activity; +import com.stablecoin.payments.compliance.api.request.InitiateComplianceCheckRequest; import com.stablecoin.payments.compliance.client.ComplianceCheckClient; import com.stablecoin.payments.orchestrator.domain.workflow.activity.ComplianceCheckActivity; import com.stablecoin.payments.orchestrator.domain.workflow.activity.ComplianceResult; @@ -12,15 +13,19 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import java.math.BigDecimal; + import static com.stablecoin.payments.orchestrator.domain.workflow.activity.ComplianceResult.ComplianceStatus.FAILED; import static com.stablecoin.payments.orchestrator.domain.workflow.activity.ComplianceResult.ComplianceStatus.PASSED; import static com.stablecoin.payments.orchestrator.fixtures.ComplianceActivityFixtures.aComplianceRequest; import static com.stablecoin.payments.orchestrator.fixtures.ComplianceActivityFixtures.aComplianceResponse; import static com.stablecoin.payments.orchestrator.fixtures.ComplianceActivityFixtures.aSanctionsHitResponse; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.RECIPIENT_ID; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.SENDER_ID; import static com.stablecoin.payments.orchestrator.fixtures.WorkflowFixtures.CHECK_ID; +import static com.stablecoin.payments.orchestrator.fixtures.WorkflowFixtures.PAYMENT_ID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; @@ -46,6 +51,12 @@ void tearDown() { testActivityEnvironment.close(); } + private static InitiateComplianceCheckRequest expectedS2Request() { + return new InitiateComplianceCheckRequest( + PAYMENT_ID, SENDER_ID, RECIPIENT_ID, + new BigDecimal("1000.00"), "USD", "US", "DE", "EUR"); + } + @Nested @DisplayName("compliance check passes") class ComplianceCheckPasses { @@ -53,7 +64,7 @@ class ComplianceCheckPasses { @Test @DisplayName("should return PASSED when S2 returns PASSED immediately") void shouldReturnPassedWhenS2ReturnsPassed() { - given(complianceCheckClient.initiateCheck(any())) + given(complianceCheckClient.initiateCheck(expectedS2Request())) .willReturn(aComplianceResponse("PASSED", "PASSED")); var result = activity.checkCompliance(aComplianceRequest()); @@ -72,7 +83,7 @@ class ComplianceCheckFails { @Test @DisplayName("should return FAILED when S2 returns FAILED") void shouldReturnFailedWhenS2ReturnsFailed() { - given(complianceCheckClient.initiateCheck(any())) + given(complianceCheckClient.initiateCheck(expectedS2Request())) .willReturn(aComplianceResponse("FAILED", "FAILED")); var result = activity.checkCompliance(aComplianceRequest()); @@ -86,7 +97,7 @@ void shouldReturnFailedWhenS2ReturnsFailed() { @Test @DisplayName("should return FAILED when S2 returns MANUAL_REVIEW") void shouldReturnFailedWhenManualReview() { - given(complianceCheckClient.initiateCheck(any())) + given(complianceCheckClient.initiateCheck(expectedS2Request())) .willReturn(aComplianceResponse("MANUAL_REVIEW", "MANUAL_REVIEW")); var result = activity.checkCompliance(aComplianceRequest()); @@ -105,7 +116,7 @@ class SanctionsHit { @Test @DisplayName("should throw non-retryable ApplicationFailure on SANCTIONS_HIT") void shouldThrowNonRetryableOnSanctionsHit() { - given(complianceCheckClient.initiateCheck(any())) + given(complianceCheckClient.initiateCheck(expectedS2Request())) .willReturn(aSanctionsHitResponse()); assertThatThrownBy(() -> activity.checkCompliance(aComplianceRequest())) @@ -129,7 +140,7 @@ void shouldPollUntilTerminalState() { var pendingResponse = aComplianceResponse("KYC_IN_PROGRESS", null); var passedResponse = aComplianceResponse("PASSED", "PASSED"); - given(complianceCheckClient.initiateCheck(any())).willReturn(pendingResponse); + given(complianceCheckClient.initiateCheck(expectedS2Request())).willReturn(pendingResponse); given(complianceCheckClient.getCheck(CHECK_ID)) .willReturn(aComplianceResponse("SANCTIONS_SCREENING", null)) .willReturn(passedResponse); diff --git a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/infrastructure/activity/FxLockActivityImplTest.java b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/infrastructure/activity/FxLockActivityImplTest.java index b8d39177..97ca231f 100644 --- a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/infrastructure/activity/FxLockActivityImplTest.java +++ b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/infrastructure/activity/FxLockActivityImplTest.java @@ -1,5 +1,6 @@ package com.stablecoin.payments.orchestrator.infrastructure.activity; +import com.stablecoin.payments.fx.api.request.FxRateLockRequest; import com.stablecoin.payments.fx.client.FxEngineClient; import com.stablecoin.payments.orchestrator.domain.workflow.activity.FxLockActivity; import com.stablecoin.payments.orchestrator.domain.workflow.activity.FxLockResult; @@ -28,8 +29,6 @@ import static com.stablecoin.payments.orchestrator.fixtures.WorkflowFixtures.QUOTE_ID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; @@ -63,7 +62,7 @@ class HappyPath { void shouldReturnLockedAfterQuoteAndLock() { given(fxEngineClient.getQuote("USD", "EUR", new BigDecimal("1000.00"))) .willReturn(aQuoteResponse()); - given(fxEngineClient.lockRate(eq(QUOTE_ID), any())) + given(fxEngineClient.lockRate(QUOTE_ID, new FxRateLockRequest(PAYMENT_ID, PAYMENT_ID, "US", "DE"))) .willReturn(aLockResponse()); var result = activity.lockFxRate(aFxLockRequest()); @@ -87,7 +86,7 @@ class IdempotentLock { void shouldReturnLockedOnConflict() { given(fxEngineClient.getQuote("USD", "EUR", new BigDecimal("1000.00"))) .willReturn(aQuoteResponse()); - given(fxEngineClient.lockRate(eq(QUOTE_ID), any())) + given(fxEngineClient.lockRate(QUOTE_ID, new FxRateLockRequest(PAYMENT_ID, PAYMENT_ID, "US", "DE"))) .willThrow(new FeignException.Conflict("Conflict", dummyRequest(), null, null)); var result = activity.lockFxRate(aFxLockRequest()); @@ -109,7 +108,7 @@ class InsufficientLiquidity { void shouldThrowNonRetryableOnInsufficientLiquidity() { given(fxEngineClient.getQuote("USD", "EUR", new BigDecimal("1000.00"))) .willReturn(aQuoteResponse()); - given(fxEngineClient.lockRate(eq(QUOTE_ID), any())) + given(fxEngineClient.lockRate(QUOTE_ID, new FxRateLockRequest(PAYMENT_ID, PAYMENT_ID, "US", "DE"))) .willThrow(new FeignException.UnprocessableEntity( "Insufficient liquidity for corridor", dummyRequest(), null, null)); From aa89f6ee7f6e3a0a95677720240a1defd7b514f5 Mon Sep 17 00:00:00 2001 From: Puneethkumar CK Date: Sat, 11 Apr 2026 17:10:40 +0200 Subject: [PATCH 4/6] refactor(infra): collapse ApplicationService layer into CommandHandlers (STA-249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Controllers now call CommandHandler / QueryHandler directly per CLAUDE.md "Controller → CommandHandler directly — no intermediate service layer". compliance-travel-rule: delete ComplianceCheckApplicationService (thin pass-through). ComplianceCheckController and CustomerRiskProfileController inject ComplianceCheckCommandHandler + ComplianceCheckResponseMapper directly. Tests rewritten to mock the handler. fx-liquidity-engine: promote FxQuoteApplicationService, FxRateLockApplicationService, and LiquidityPoolApplicationService to FxQuoteCommandHandler, FxRateLockCommandHandler, and LiquidityPoolQueryHandler under domain/service/. Handlers now take primitives and return domain objects (+ idempotency records), so the hexagonal rule domain-may-not-depend-on-application stays green. Controllers own response mapping via FxResponseMapper (added CorridorSnapshot overload). New per-handler unit tests preserve intent from the removed ApplicationService tests. Dead-code removal: drop orphaned ApplicationService classes left from STA-243 — ApiKeyApplicationService, AuthApplicationService, OAuthClientApplicationService, MerchantApplicationService (api-gateway-iam) and MerchantApplicationService (merchant-onboarding). Confirmed zero references in main or test before deletion. Net: -1505 lines. 13 files deleted, 13 modified, 6 new handlers/tests. Verified: compliance-travel-rule (268 tests), fx-liquidity-engine (202 tests), full compileJava + compileTestJava BUILD SUCCESSFUL. --- .../service/AuthApplicationService.java | 44 --- .../service/MerchantApplicationService.java | 80 ----- .../OAuthClientApplicationService.java | 51 ---- .../controller/ComplianceCheckController.java | 15 +- .../CustomerRiskProfileController.java | 8 +- .../ComplianceCheckApplicationService.java | 40 --- .../ComplianceCheckControllerTest.java | 59 ++-- .../CustomerRiskProfileControllerTest.java | 26 +- ...ComplianceCheckApplicationServiceTest.java | 173 ----------- .../controller/CorridorController.java | 10 +- .../controller/FxQuoteController.java | 24 +- .../controller/LiquidityPoolController.java | 12 +- .../application/mapper/FxResponseMapper.java | 18 ++ .../LiquidityPoolApplicationService.java | 59 ---- .../domain/service/FxQuoteCommandHandler.java | 57 ++++ .../service/FxRateLockCommandHandler.java | 133 ++++++++ .../service/LiquidityPoolQueryHandler.java | 48 +++ .../controller/CorridorControllerTest.java | 49 +-- .../controller/FxQuoteControllerTest.java | 112 +++---- .../LiquidityPoolControllerTest.java | 78 ++--- .../LiquidityPoolApplicationServiceTest.java | 183 ----------- .../service/FxQuoteCommandHandlerTest.java | 129 ++++++++ .../service/FxRateLockCommandHandlerTest.java | 214 +++++++++++++ .../LiquidityPoolQueryHandlerTest.java | 130 ++++++++ .../service/MerchantApplicationService.java | 285 ------------------ 25 files changed, 963 insertions(+), 1074 deletions(-) delete mode 100644 api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/service/AuthApplicationService.java delete mode 100644 api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/service/MerchantApplicationService.java delete mode 100644 api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/service/OAuthClientApplicationService.java delete mode 100644 compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/application/service/ComplianceCheckApplicationService.java delete mode 100644 compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/application/service/ComplianceCheckApplicationServiceTest.java delete mode 100644 fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/service/LiquidityPoolApplicationService.java create mode 100644 fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/domain/service/FxQuoteCommandHandler.java create mode 100644 fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/domain/service/FxRateLockCommandHandler.java create mode 100644 fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/domain/service/LiquidityPoolQueryHandler.java delete mode 100644 fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/LiquidityPoolApplicationServiceTest.java create mode 100644 fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/domain/service/FxQuoteCommandHandlerTest.java create mode 100644 fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/domain/service/FxRateLockCommandHandlerTest.java create mode 100644 fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/domain/service/LiquidityPoolQueryHandlerTest.java delete mode 100644 merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/service/MerchantApplicationService.java diff --git a/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/service/AuthApplicationService.java b/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/service/AuthApplicationService.java deleted file mode 100644 index 7d26e7a4..00000000 --- a/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/service/AuthApplicationService.java +++ /dev/null @@ -1,44 +0,0 @@ -package com.stablecoin.payments.gateway.iam.application.service; - -import com.stablecoin.payments.gateway.iam.api.request.TokenRequest; -import com.stablecoin.payments.gateway.iam.api.response.TokenResponse; -import com.stablecoin.payments.gateway.iam.domain.service.AuthService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Arrays; -import java.util.List; -import java.util.UUID; - -@Service -@Transactional -@RequiredArgsConstructor -public class AuthApplicationService { - - private final AuthService authService; - - public TokenResponse issueToken(TokenRequest request) { - List scopes = request.scope() != null - ? Arrays.asList(request.scope().split(" ")) - : List.of(); - - var result = authService.issueToken( - request.clientId(), request.clientSecret(), scopes); - - return new TokenResponse( - result.accessToken(), - "Bearer", - (int) result.expiresIn(), - String.join(" ", result.scopes())); - } - - public void revokeToken(UUID jti) { - authService.revokeToken(jti); - } - - @Transactional(readOnly = true) - public String jwksJson() { - return authService.jwksJson(); - } -} diff --git a/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/service/MerchantApplicationService.java b/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/service/MerchantApplicationService.java deleted file mode 100644 index 56937204..00000000 --- a/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/service/MerchantApplicationService.java +++ /dev/null @@ -1,80 +0,0 @@ -package com.stablecoin.payments.gateway.iam.application.service; - -import com.stablecoin.payments.gateway.iam.api.request.CreateMerchantRequest; -import com.stablecoin.payments.gateway.iam.api.response.MerchantResponse; -import com.stablecoin.payments.gateway.iam.application.controller.mapper.GatewayResponseMapper; -import com.stablecoin.payments.gateway.iam.domain.event.OAuthClientProvisionedEvent; -import com.stablecoin.payments.gateway.iam.domain.model.Corridor; -import com.stablecoin.payments.gateway.iam.domain.port.EventPublisher; -import com.stablecoin.payments.gateway.iam.domain.service.MerchantService; -import com.stablecoin.payments.gateway.iam.domain.service.OAuthClientService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Collections; -import java.util.List; -import java.util.UUID; - -@Slf4j -@Service -@Transactional -@RequiredArgsConstructor -public class MerchantApplicationService { - - private final MerchantService merchantService; - private final OAuthClientService oauthClientService; - private final EventPublisher eventPublisher; - private final GatewayResponseMapper mapper; - - public MerchantResponse createMerchant(CreateMerchantRequest request) { - List corridors = request.corridors() != null - ? request.corridors().stream() - .map(c -> new Corridor(c.sourceCountry(), c.targetCountry())) - .toList() - : Collections.emptyList(); - - var merchant = merchantService.register( - request.externalId(), - request.name(), - request.country(), - request.scopes() != null ? request.scopes() : Collections.emptyList(), - corridors); - - return mapper.toMerchantResponse(merchant); - } - - @Transactional(readOnly = true) - public MerchantResponse getMerchant(UUID merchantId) { - var merchant = merchantService.findById(merchantId); - return mapper.toMerchantResponse(merchant); - } - - public void activateAndProvisionOAuthClient(UUID externalId, String companyName, - List scopes) { - var merchant = merchantService.activate(externalId); - - var effectiveScopes = (scopes != null && !scopes.isEmpty()) - ? scopes : merchant.getScopes(); - - var result = oauthClientService.create( - merchant.getMerchantId(), - companyName + " Default Client", - effectiveScopes, - List.of("client_credentials")); - - var client = result.client(); - eventPublisher.publish(new OAuthClientProvisionedEvent( - client.getClientId(), - client.getMerchantId(), - result.rawSecret(), - client.getName(), - client.getScopes(), - client.getGrantTypes(), - client.getCreatedAt())); - - log.info("Activated merchant and provisioned default OAuth client externalId={} clientId={}", - externalId, client.getClientId()); - } -} diff --git a/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/service/OAuthClientApplicationService.java b/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/service/OAuthClientApplicationService.java deleted file mode 100644 index aefd024f..00000000 --- a/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/service/OAuthClientApplicationService.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.stablecoin.payments.gateway.iam.application.service; - -import com.stablecoin.payments.gateway.iam.api.request.CreateOAuthClientRequest; -import com.stablecoin.payments.gateway.iam.api.response.OAuthClientResponse; -import com.stablecoin.payments.gateway.iam.domain.event.OAuthClientProvisionedEvent; -import com.stablecoin.payments.gateway.iam.domain.port.EventPublisher; -import com.stablecoin.payments.gateway.iam.domain.service.OAuthClientService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.Collections; -import java.util.List; -import java.util.UUID; - -@Service -@Transactional -@RequiredArgsConstructor -public class OAuthClientApplicationService { - - private final OAuthClientService oauthClientService; - private final EventPublisher eventPublisher; - - public OAuthClientResponse createOAuthClient(UUID merchantId, CreateOAuthClientRequest request) { - var result = oauthClientService.create( - merchantId, - request.name(), - request.scopes() != null ? request.scopes() : Collections.emptyList(), - request.grantTypes() != null ? request.grantTypes() : List.of("client_credentials")); - - var client = result.client(); - - eventPublisher.publish(new OAuthClientProvisionedEvent( - client.getClientId(), - client.getMerchantId(), - result.rawSecret(), - client.getName(), - client.getScopes(), - client.getGrantTypes(), - client.getCreatedAt())); - - return new OAuthClientResponse( - client.getClientId(), - result.rawSecret(), - client.getMerchantId(), - client.getName(), - client.getScopes(), - client.getGrantTypes(), - client.getCreatedAt()); - } -} diff --git a/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/application/controller/ComplianceCheckController.java b/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/application/controller/ComplianceCheckController.java index b8f07fd0..7b27a54a 100644 --- a/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/application/controller/ComplianceCheckController.java +++ b/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/application/controller/ComplianceCheckController.java @@ -2,7 +2,9 @@ import com.stablecoin.payments.compliance.api.request.InitiateComplianceCheckRequest; import com.stablecoin.payments.compliance.api.response.ComplianceCheckResponse; -import com.stablecoin.payments.compliance.application.service.ComplianceCheckApplicationService; +import com.stablecoin.payments.compliance.application.mapper.ComplianceCheckResponseMapper; +import com.stablecoin.payments.compliance.domain.model.Money; +import com.stablecoin.payments.compliance.domain.service.ComplianceCheckCommandHandler; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -23,19 +25,24 @@ @RequiredArgsConstructor public class ComplianceCheckController { - private final ComplianceCheckApplicationService complianceCheckApplicationService; + private final ComplianceCheckCommandHandler commandHandler; + private final ComplianceCheckResponseMapper responseMapper; @PostMapping("/check") @ResponseStatus(HttpStatus.ACCEPTED) public ComplianceCheckResponse initiateCheck( @Valid @RequestBody InitiateComplianceCheckRequest request) { log.info("POST /v1/compliance/check paymentId={}", request.paymentId()); - return complianceCheckApplicationService.initiateCheck(request); + var check = commandHandler.initiateCheck( + request.paymentId(), request.senderId(), request.recipientId(), + new Money(request.amount(), request.currency()), + request.sourceCountry(), request.targetCountry(), request.targetCurrency()); + return responseMapper.toResponse(check); } @GetMapping("/checks/{checkId}") public ComplianceCheckResponse getCheck(@PathVariable UUID checkId) { log.info("GET /v1/compliance/checks/{}", checkId); - return complianceCheckApplicationService.getCheck(checkId); + return responseMapper.toResponse(commandHandler.getCheck(checkId)); } } diff --git a/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/application/controller/CustomerRiskProfileController.java b/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/application/controller/CustomerRiskProfileController.java index 32eb91dc..01d022f3 100644 --- a/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/application/controller/CustomerRiskProfileController.java +++ b/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/application/controller/CustomerRiskProfileController.java @@ -1,7 +1,8 @@ package com.stablecoin.payments.compliance.application.controller; import com.stablecoin.payments.compliance.api.response.CustomerRiskProfileResponse; -import com.stablecoin.payments.compliance.application.service.ComplianceCheckApplicationService; +import com.stablecoin.payments.compliance.application.mapper.ComplianceCheckResponseMapper; +import com.stablecoin.payments.compliance.domain.service.ComplianceCheckCommandHandler; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.GetMapping; @@ -17,11 +18,12 @@ @RequiredArgsConstructor public class CustomerRiskProfileController { - private final ComplianceCheckApplicationService complianceCheckApplicationService; + private final ComplianceCheckCommandHandler commandHandler; + private final ComplianceCheckResponseMapper responseMapper; @GetMapping("/{customerId}/risk-profile") public CustomerRiskProfileResponse getRiskProfile(@PathVariable UUID customerId) { log.info("GET /v1/customers/{}/risk-profile", customerId); - return complianceCheckApplicationService.getCustomerRiskProfile(customerId); + return responseMapper.toResponse(commandHandler.getCustomerRiskProfile(customerId)); } } diff --git a/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/application/service/ComplianceCheckApplicationService.java b/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/application/service/ComplianceCheckApplicationService.java deleted file mode 100644 index 6d9d39e1..00000000 --- a/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/application/service/ComplianceCheckApplicationService.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.stablecoin.payments.compliance.application.service; - -import com.stablecoin.payments.compliance.api.request.InitiateComplianceCheckRequest; -import com.stablecoin.payments.compliance.api.response.ComplianceCheckResponse; -import com.stablecoin.payments.compliance.api.response.CustomerRiskProfileResponse; -import com.stablecoin.payments.compliance.application.mapper.ComplianceCheckResponseMapper; -import com.stablecoin.payments.compliance.domain.model.Money; -import com.stablecoin.payments.compliance.domain.service.ComplianceCheckCommandHandler; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -@Service -@RequiredArgsConstructor -public class ComplianceCheckApplicationService { - - private final ComplianceCheckCommandHandler commandHandler; - private final ComplianceCheckResponseMapper responseMapper; - - @Transactional - public ComplianceCheckResponse initiateCheck(InitiateComplianceCheckRequest request) { - var check = commandHandler.initiateCheck( - request.paymentId(), request.senderId(), request.recipientId(), - new Money(request.amount(), request.currency()), - request.sourceCountry(), request.targetCountry(), request.targetCurrency()); - return responseMapper.toResponse(check); - } - - @Transactional(readOnly = true) - public ComplianceCheckResponse getCheck(UUID checkId) { - return responseMapper.toResponse(commandHandler.getCheck(checkId)); - } - - @Transactional(readOnly = true) - public CustomerRiskProfileResponse getCustomerRiskProfile(UUID customerId) { - return responseMapper.toResponse(commandHandler.getCustomerRiskProfile(customerId)); - } -} diff --git a/compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/application/controller/ComplianceCheckControllerTest.java b/compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/application/controller/ComplianceCheckControllerTest.java index 874afcee..a7aeda60 100644 --- a/compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/application/controller/ComplianceCheckControllerTest.java +++ b/compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/application/controller/ComplianceCheckControllerTest.java @@ -6,9 +6,11 @@ import com.stablecoin.payments.compliance.api.response.ComplianceCheckResponse.RiskScoreResponse; import com.stablecoin.payments.compliance.api.response.ComplianceCheckResponse.SanctionsResultResponse; import com.stablecoin.payments.compliance.api.response.ComplianceCheckResponse.TravelRuleResponse; -import com.stablecoin.payments.compliance.application.service.ComplianceCheckApplicationService; +import com.stablecoin.payments.compliance.application.mapper.ComplianceCheckResponseMapper; import com.stablecoin.payments.compliance.domain.exception.CheckNotFoundException; import com.stablecoin.payments.compliance.domain.exception.DuplicatePaymentException; +import com.stablecoin.payments.compliance.domain.model.Money; +import com.stablecoin.payments.compliance.domain.service.ComplianceCheckCommandHandler; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -22,16 +24,21 @@ import java.util.List; import java.util.UUID; +import static com.stablecoin.payments.compliance.fixtures.ComplianceCheckFixtures.aPendingCheck; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; @ExtendWith(MockitoExtension.class) @DisplayName("ComplianceCheckController") class ComplianceCheckControllerTest { @Mock - private ComplianceCheckApplicationService applicationService; + private ComplianceCheckCommandHandler commandHandler; + + @Mock + private ComplianceCheckResponseMapper responseMapper; @InjectMocks private ComplianceCheckController controller; @@ -41,47 +48,56 @@ class ComplianceCheckControllerTest { class InitiateCheck { @Test - @DisplayName("should delegate to application service and return response") + @DisplayName("should delegate to command handler and map response") void shouldInitiateCheck() { - // given var request = new InitiateComplianceCheckRequest( UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), new BigDecimal("1000.00"), "USD", "US", "DE", "EUR"); - var checkId = UUID.randomUUID(); + var domainCheck = aPendingCheck(); var now = Instant.now(); var expectedResponse = new ComplianceCheckResponse( - checkId, request.paymentId(), "PASSED", "PASSED", + domainCheck.checkId(), request.paymentId(), "PASSED", "PASSED", new RiskScoreResponse(18, "LOW", List.of("ESTABLISHED_CUSTOMER")), new KycResultResponse("VERIFIED", "VERIFIED", "KYC_TIER_2"), new SanctionsResultResponse(false, false, List.of("OFAC", "EU", "UN")), new TravelRuleResponse("IVMS101", "TRANSMITTED"), null, null, now, now); - given(applicationService.initiateCheck(request)).willReturn(expectedResponse); + given(commandHandler.initiateCheck( + request.paymentId(), request.senderId(), request.recipientId(), + new Money(request.amount(), request.currency()), + request.sourceCountry(), request.targetCountry(), request.targetCurrency())) + .willReturn(domainCheck); + given(responseMapper.toResponse(domainCheck)).willReturn(expectedResponse); - // when var result = controller.initiateCheck(request); - // then assertThat(result) .usingRecursiveComparison() .isEqualTo(expectedResponse); + + then(commandHandler).should().initiateCheck( + request.paymentId(), request.senderId(), request.recipientId(), + new Money(request.amount(), request.currency()), + request.sourceCountry(), request.targetCountry(), request.targetCurrency()); + then(responseMapper).should().toResponse(domainCheck); } @Test @DisplayName("should propagate DuplicatePaymentException") void shouldPropagateOnDuplicate() { - // given var paymentId = UUID.randomUUID(); var request = new InitiateComplianceCheckRequest( paymentId, UUID.randomUUID(), UUID.randomUUID(), new BigDecimal("1000.00"), "USD", "US", "DE", "EUR"); - given(applicationService.initiateCheck(request)) + given(commandHandler.initiateCheck( + request.paymentId(), request.senderId(), request.recipientId(), + new Money(request.amount(), request.currency()), + request.sourceCountry(), request.targetCountry(), request.targetCurrency())) .willThrow(new DuplicatePaymentException(paymentId)); - // when/then assertThatThrownBy(() -> controller.initiateCheck(request)) .isInstanceOf(DuplicatePaymentException.class) .hasMessageContaining(paymentId.toString()); @@ -93,37 +109,36 @@ void shouldPropagateOnDuplicate() { class GetCheck { @Test - @DisplayName("should delegate to application service and return response") + @DisplayName("should delegate to command handler and map response") void shouldGetCheck() { - // given var checkId = UUID.randomUUID(); - var paymentId = UUID.randomUUID(); + var domainCheck = aPendingCheck(); var now = Instant.now(); var expectedResponse = new ComplianceCheckResponse( - checkId, paymentId, "PENDING", null, + checkId, domainCheck.paymentId(), "PENDING", null, null, null, null, null, null, null, now, null); - given(applicationService.getCheck(checkId)).willReturn(expectedResponse); + given(commandHandler.getCheck(checkId)).willReturn(domainCheck); + given(responseMapper.toResponse(domainCheck)).willReturn(expectedResponse); - // when var result = controller.getCheck(checkId); - // then assertThat(result) .usingRecursiveComparison() .isEqualTo(expectedResponse); + + then(commandHandler).should().getCheck(checkId); + then(responseMapper).should().toResponse(domainCheck); } @Test @DisplayName("should propagate CheckNotFoundException") void shouldPropagateOnNotFound() { - // given var checkId = UUID.randomUUID(); - given(applicationService.getCheck(checkId)) + given(commandHandler.getCheck(checkId)) .willThrow(new CheckNotFoundException(checkId)); - // when/then assertThatThrownBy(() -> controller.getCheck(checkId)) .isInstanceOf(CheckNotFoundException.class) .hasMessageContaining(checkId.toString()); diff --git a/compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/application/controller/CustomerRiskProfileControllerTest.java b/compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/application/controller/CustomerRiskProfileControllerTest.java index e645ec15..48b20d13 100644 --- a/compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/application/controller/CustomerRiskProfileControllerTest.java +++ b/compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/application/controller/CustomerRiskProfileControllerTest.java @@ -1,8 +1,9 @@ package com.stablecoin.payments.compliance.application.controller; import com.stablecoin.payments.compliance.api.response.CustomerRiskProfileResponse; -import com.stablecoin.payments.compliance.application.service.ComplianceCheckApplicationService; +import com.stablecoin.payments.compliance.application.mapper.ComplianceCheckResponseMapper; import com.stablecoin.payments.compliance.domain.exception.CustomerNotFoundException; +import com.stablecoin.payments.compliance.domain.service.ComplianceCheckCommandHandler; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -14,51 +15,56 @@ import java.time.Instant; import java.util.UUID; +import static com.stablecoin.payments.compliance.fixtures.CustomerRiskProfileFixtures.aRiskProfile; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; @ExtendWith(MockitoExtension.class) @DisplayName("CustomerRiskProfileController") class CustomerRiskProfileControllerTest { @Mock - private ComplianceCheckApplicationService applicationService; + private ComplianceCheckCommandHandler commandHandler; + + @Mock + private ComplianceCheckResponseMapper responseMapper; @InjectMocks private CustomerRiskProfileController controller; @Test - @DisplayName("should delegate to application service and return risk profile") + @DisplayName("should delegate to command handler and return risk profile response") void shouldGetRiskProfile() { - // given var customerId = UUID.randomUUID(); + var profile = aRiskProfile().toBuilder().customerId(customerId).build(); var now = Instant.now(); var expectedResponse = new CustomerRiskProfileResponse( customerId, "KYC_TIER_2", now, "LOW", 20, new BigDecimal("10000.00"), new BigDecimal("50000.00"), new BigDecimal("500000.00"), now); - given(applicationService.getCustomerRiskProfile(customerId)).willReturn(expectedResponse); + given(commandHandler.getCustomerRiskProfile(customerId)).willReturn(profile); + given(responseMapper.toResponse(profile)).willReturn(expectedResponse); - // when var result = controller.getRiskProfile(customerId); - // then assertThat(result) .usingRecursiveComparison() .isEqualTo(expectedResponse); + + then(commandHandler).should().getCustomerRiskProfile(customerId); + then(responseMapper).should().toResponse(profile); } @Test @DisplayName("should propagate CustomerNotFoundException") void shouldPropagateOnNotFound() { - // given var customerId = UUID.randomUUID(); - given(applicationService.getCustomerRiskProfile(customerId)) + given(commandHandler.getCustomerRiskProfile(customerId)) .willThrow(new CustomerNotFoundException(customerId)); - // when/then assertThatThrownBy(() -> controller.getRiskProfile(customerId)) .isInstanceOf(CustomerNotFoundException.class) .hasMessageContaining(customerId.toString()); diff --git a/compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/application/service/ComplianceCheckApplicationServiceTest.java b/compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/application/service/ComplianceCheckApplicationServiceTest.java deleted file mode 100644 index f36f3b43..00000000 --- a/compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/application/service/ComplianceCheckApplicationServiceTest.java +++ /dev/null @@ -1,173 +0,0 @@ -package com.stablecoin.payments.compliance.application.service; - -import com.stablecoin.payments.compliance.api.request.InitiateComplianceCheckRequest; -import com.stablecoin.payments.compliance.api.response.ComplianceCheckResponse; -import com.stablecoin.payments.compliance.api.response.CustomerRiskProfileResponse; -import com.stablecoin.payments.compliance.application.mapper.ComplianceCheckResponseMapper; -import com.stablecoin.payments.compliance.domain.exception.CheckNotFoundException; -import com.stablecoin.payments.compliance.domain.exception.CustomerNotFoundException; -import com.stablecoin.payments.compliance.domain.exception.DuplicatePaymentException; -import com.stablecoin.payments.compliance.domain.model.Money; -import com.stablecoin.payments.compliance.domain.service.ComplianceCheckCommandHandler; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -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.UUID; - -import static com.stablecoin.payments.compliance.fixtures.ComplianceCheckFixtures.aPendingCheck; -import static com.stablecoin.payments.compliance.fixtures.CustomerRiskProfileFixtures.aRiskProfile; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.then; - -@ExtendWith(MockitoExtension.class) -class ComplianceCheckApplicationServiceTest { - - @Mock private ComplianceCheckCommandHandler commandHandler; - @Mock private ComplianceCheckResponseMapper responseMapper; - - @InjectMocks - private ComplianceCheckApplicationService service; - - @Nested - @DisplayName("initiateCheck") - class InitiateCheck { - - @Test - @DisplayName("should delegate to commandHandler with correct domain mapping and return mapped response") - void shouldDelegateAndMap() { - var request = new InitiateComplianceCheckRequest( - UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), - new BigDecimal("1000.00"), "USD", "US", "DE", "EUR"); - var domainCheck = aPendingCheck(); - var expectedResponse = new ComplianceCheckResponse( - domainCheck.checkId(), domainCheck.paymentId(), "PENDING", - null, null, null, null, null, null, null, - domainCheck.createdAt(), null); - - given(commandHandler.initiateCheck( - request.paymentId(), request.senderId(), request.recipientId(), - new Money(request.amount(), request.currency()), - request.sourceCountry(), request.targetCountry(), request.targetCurrency())) - .willReturn(domainCheck); - given(responseMapper.toResponse(domainCheck)).willReturn(expectedResponse); - - var result = service.initiateCheck(request); - - assertThat(result) - .usingRecursiveComparison() - .isEqualTo(expectedResponse); - - then(commandHandler).should().initiateCheck( - request.paymentId(), request.senderId(), request.recipientId(), - new Money(request.amount(), request.currency()), - request.sourceCountry(), request.targetCountry(), request.targetCurrency()); - then(responseMapper).should().toResponse(domainCheck); - } - - @Test - @DisplayName("should propagate DuplicatePaymentException from commandHandler") - void shouldPropagateDuplicatePaymentException() { - var paymentId = UUID.randomUUID(); - var request = new InitiateComplianceCheckRequest( - paymentId, UUID.randomUUID(), UUID.randomUUID(), - new BigDecimal("1000.00"), "USD", "US", "DE", "EUR"); - - given(commandHandler.initiateCheck( - request.paymentId(), request.senderId(), request.recipientId(), - new Money(request.amount(), request.currency()), - request.sourceCountry(), request.targetCountry(), request.targetCurrency())) - .willThrow(new DuplicatePaymentException(paymentId)); - - assertThatThrownBy(() -> service.initiateCheck(request)) - .isInstanceOf(DuplicatePaymentException.class) - .hasMessageContaining(paymentId.toString()); - } - } - - @Nested - @DisplayName("getCheck") - class GetCheck { - - @Test - @DisplayName("should delegate to commandHandler and map response") - void shouldDelegateAndMap() { - var checkId = UUID.randomUUID(); - var domainCheck = aPendingCheck(); - var expectedResponse = new ComplianceCheckResponse( - domainCheck.checkId(), domainCheck.paymentId(), "PENDING", - null, null, null, null, null, null, null, - domainCheck.createdAt(), null); - - given(commandHandler.getCheck(checkId)).willReturn(domainCheck); - given(responseMapper.toResponse(domainCheck)).willReturn(expectedResponse); - - var result = service.getCheck(checkId); - - assertThat(result) - .usingRecursiveComparison() - .isEqualTo(expectedResponse); - then(commandHandler).should().getCheck(checkId); - } - - @Test - @DisplayName("should propagate CheckNotFoundException from commandHandler") - void shouldPropagateCheckNotFoundException() { - var checkId = UUID.randomUUID(); - - given(commandHandler.getCheck(checkId)) - .willThrow(new CheckNotFoundException(checkId)); - - assertThatThrownBy(() -> service.getCheck(checkId)) - .isInstanceOf(CheckNotFoundException.class) - .hasMessageContaining(checkId.toString()); - } - } - - @Nested - @DisplayName("getCustomerRiskProfile") - class GetCustomerRiskProfile { - - @Test - @DisplayName("should delegate to commandHandler and map response") - void shouldDelegateAndMap() { - var customerId = UUID.randomUUID(); - var profile = aRiskProfile().toBuilder().customerId(customerId).build(); - var expectedResponse = new CustomerRiskProfileResponse( - customerId, "KYC_TIER_2", profile.kycVerifiedAt(), - "LOW", 20, - profile.perTxnLimitUsd(), profile.dailyLimitUsd(), - profile.monthlyLimitUsd(), profile.lastScoredAt()); - - given(commandHandler.getCustomerRiskProfile(customerId)).willReturn(profile); - given(responseMapper.toResponse(profile)).willReturn(expectedResponse); - - var result = service.getCustomerRiskProfile(customerId); - - assertThat(result) - .usingRecursiveComparison() - .isEqualTo(expectedResponse); - then(commandHandler).should().getCustomerRiskProfile(customerId); - } - - @Test - @DisplayName("should propagate CustomerNotFoundException from commandHandler") - void shouldPropagateCustomerNotFoundException() { - var customerId = UUID.randomUUID(); - - given(commandHandler.getCustomerRiskProfile(customerId)) - .willThrow(new CustomerNotFoundException(customerId)); - - assertThatThrownBy(() -> service.getCustomerRiskProfile(customerId)) - .isInstanceOf(CustomerNotFoundException.class) - .hasMessageContaining(customerId.toString()); - } - } -} diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/controller/CorridorController.java b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/controller/CorridorController.java index 6747f20d..8084be54 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/controller/CorridorController.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/controller/CorridorController.java @@ -1,7 +1,8 @@ package com.stablecoin.payments.fx.application.controller; import com.stablecoin.payments.fx.api.response.CorridorResponse; -import com.stablecoin.payments.fx.application.service.LiquidityPoolApplicationService; +import com.stablecoin.payments.fx.application.mapper.FxResponseMapper; +import com.stablecoin.payments.fx.domain.service.LiquidityPoolQueryHandler; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.GetMapping; @@ -16,11 +17,14 @@ @RequiredArgsConstructor public class CorridorController { - private final LiquidityPoolApplicationService liquidityPoolApplicationService; + private final LiquidityPoolQueryHandler queryHandler; + private final FxResponseMapper responseMapper; @GetMapping("/corridors") public List listCorridors() { log.info("GET /v1/fx/corridors"); - return liquidityPoolApplicationService.listCorridors(); + return queryHandler.listCorridors().stream() + .map(responseMapper::toResponse) + .toList(); } } diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/controller/FxQuoteController.java b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/controller/FxQuoteController.java index 04cebb5c..5460b242 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/controller/FxQuoteController.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/controller/FxQuoteController.java @@ -4,8 +4,9 @@ import com.stablecoin.payments.fx.api.request.FxRateLockRequest; import com.stablecoin.payments.fx.api.response.FxQuoteResponse; import com.stablecoin.payments.fx.api.response.FxRateLockResponse; -import com.stablecoin.payments.fx.application.service.FxQuoteApplicationService; -import com.stablecoin.payments.fx.application.service.FxRateLockApplicationService; +import com.stablecoin.payments.fx.application.mapper.FxResponseMapper; +import com.stablecoin.payments.fx.domain.service.FxQuoteCommandHandler; +import com.stablecoin.payments.fx.domain.service.FxRateLockCommandHandler; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -27,35 +28,40 @@ @RequiredArgsConstructor public class FxQuoteController { - private final FxQuoteApplicationService quoteApplicationService; - private final FxRateLockApplicationService rateLockApplicationService; + private final FxQuoteCommandHandler quoteCommandHandler; + private final FxRateLockCommandHandler rateLockCommandHandler; + private final FxResponseMapper responseMapper; @GetMapping("/quote") public FxQuoteResponse getQuote(@Valid FxQuoteRequest request) { log.info("GET /v1/fx/quote fromCurrency={} toCurrency={} amount={}", request.fromCurrency(), request.toCurrency(), request.amount()); - return quoteApplicationService.getQuote(request); + var quote = quoteCommandHandler.getQuote( + request.fromCurrency(), request.toCurrency(), request.amount()); + return responseMapper.toResponse(quote); } @GetMapping("/quote/{quoteId}") public FxQuoteResponse getQuoteById(@PathVariable UUID quoteId) { log.info("GET /v1/fx/quote/{}", quoteId); - return quoteApplicationService.getQuoteById(quoteId); + return responseMapper.toResponse(quoteCommandHandler.getQuoteById(quoteId)); } @PostMapping("/lock/{quoteId}") public ResponseEntity lockRate(@PathVariable UUID quoteId, @Valid @RequestBody FxRateLockRequest request) { log.info("POST /v1/fx/lock/{} paymentId={}", quoteId, request.paymentId()); - var result = rateLockApplicationService.lockRate(quoteId, request); + var result = rateLockCommandHandler.lockRate( + quoteId, request.paymentId(), request.correlationId(), + request.sourceCountry(), request.targetCountry()); var status = result.created() ? HttpStatus.CREATED : HttpStatus.OK; - return ResponseEntity.status(status).body(result.response()); + return ResponseEntity.status(status).body(responseMapper.toResponse(result.lock())); } @DeleteMapping("/lock/{lockId}") public ResponseEntity releaseLock(@PathVariable UUID lockId) { log.info("DELETE /v1/fx/lock/{}", lockId); - rateLockApplicationService.releaseLock(lockId); + rateLockCommandHandler.releaseLock(lockId); return ResponseEntity.noContent().build(); } } diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/controller/LiquidityPoolController.java b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/controller/LiquidityPoolController.java index 8caa87b0..4cb60893 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/controller/LiquidityPoolController.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/controller/LiquidityPoolController.java @@ -1,7 +1,8 @@ package com.stablecoin.payments.fx.application.controller; import com.stablecoin.payments.fx.api.response.LiquidityPoolResponse; -import com.stablecoin.payments.fx.application.service.LiquidityPoolApplicationService; +import com.stablecoin.payments.fx.application.mapper.FxResponseMapper; +import com.stablecoin.payments.fx.domain.service.LiquidityPoolQueryHandler; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.web.bind.annotation.GetMapping; @@ -18,17 +19,20 @@ @RequiredArgsConstructor public class LiquidityPoolController { - private final LiquidityPoolApplicationService liquidityPoolApplicationService; + private final LiquidityPoolQueryHandler queryHandler; + private final FxResponseMapper responseMapper; @GetMapping("/pools") public List listPools() { log.info("GET /v1/liquidity/pools"); - return liquidityPoolApplicationService.listPools(); + return queryHandler.listPools().stream() + .map(responseMapper::toResponse) + .toList(); } @GetMapping("/pools/{poolId}") public LiquidityPoolResponse getPool(@PathVariable UUID poolId) { log.info("GET /v1/liquidity/pools/{}", poolId); - return liquidityPoolApplicationService.getPool(poolId); + return responseMapper.toResponse(queryHandler.getPool(poolId)); } } diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/mapper/FxResponseMapper.java b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/mapper/FxResponseMapper.java index 527b05ff..aceec5be 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/mapper/FxResponseMapper.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/mapper/FxResponseMapper.java @@ -8,6 +8,7 @@ import com.stablecoin.payments.fx.domain.model.FxQuote; import com.stablecoin.payments.fx.domain.model.FxRateLock; import com.stablecoin.payments.fx.domain.model.LiquidityPool; +import com.stablecoin.payments.fx.domain.service.LiquidityPoolQueryHandler.CorridorSnapshot; import org.springframework.stereotype.Component; import java.math.BigDecimal; @@ -87,4 +88,21 @@ public CorridorResponse toResponse(CorridorRate corridorRate) { Instant.now().minusMillis(corridorRate.ageMs()) ); } + + public CorridorResponse toResponse(CorridorSnapshot snapshot) { + var pool = snapshot.pool(); + var rate = snapshot.rate(); + if (rate == null) { + return new CorridorResponse( + pool.fromCurrency(), + pool.toCurrency(), + null, + 0, + 0, + "unavailable", + null + ); + } + return toResponse(rate); + } } diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/service/LiquidityPoolApplicationService.java b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/service/LiquidityPoolApplicationService.java deleted file mode 100644 index 96de5017..00000000 --- a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/service/LiquidityPoolApplicationService.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.stablecoin.payments.fx.application.service; - -import com.stablecoin.payments.fx.api.response.CorridorResponse; -import com.stablecoin.payments.fx.api.response.LiquidityPoolResponse; -import com.stablecoin.payments.fx.application.mapper.FxResponseMapper; -import com.stablecoin.payments.fx.domain.exception.PoolNotFoundException; -import com.stablecoin.payments.fx.domain.port.LiquidityPoolRepository; -import com.stablecoin.payments.fx.domain.port.RateCache; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.List; -import java.util.UUID; - -@Slf4j -@Service -@RequiredArgsConstructor -public class LiquidityPoolApplicationService { - - private final LiquidityPoolRepository poolRepository; - private final RateCache rateCache; - private final FxResponseMapper responseMapper; - - @Transactional(readOnly = true) - public List listPools() { - log.info("Listing all liquidity pools"); - return poolRepository.findAll().stream() - .map(responseMapper::toResponse) - .toList(); - } - - @Transactional(readOnly = true) - public LiquidityPoolResponse getPool(UUID poolId) { - log.info("Getting liquidity pool: {}", poolId); - var pool = poolRepository.findById(poolId) - .orElseThrow(() -> PoolNotFoundException.withId(poolId)); - return responseMapper.toResponse(pool); - } - - @Transactional(readOnly = true) - public List listCorridors() { - log.info("Listing supported corridors with current rates"); - return poolRepository.findAll().stream() - .map(pool -> rateCache.get(pool.fromCurrency(), pool.toCurrency()) - .map(responseMapper::toResponse) - .orElse(new CorridorResponse( - pool.fromCurrency(), - pool.toCurrency(), - null, - 0, - 0, - "unavailable", - null - ))) - .toList(); - } -} diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/domain/service/FxQuoteCommandHandler.java b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/domain/service/FxQuoteCommandHandler.java new file mode 100644 index 00000000..c0591733 --- /dev/null +++ b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/domain/service/FxQuoteCommandHandler.java @@ -0,0 +1,57 @@ +package com.stablecoin.payments.fx.domain.service; + +import com.stablecoin.payments.fx.domain.exception.QuoteNotFoundException; +import com.stablecoin.payments.fx.domain.exception.RateUnavailableException; +import com.stablecoin.payments.fx.domain.model.FxQuote; +import com.stablecoin.payments.fx.domain.port.FxQuoteRepository; +import com.stablecoin.payments.fx.domain.port.RateCache; +import com.stablecoin.payments.fx.domain.port.RateProvider; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FxQuoteCommandHandler { + + private final RateProvider rateProvider; + private final RateCache rateCache; + private final QuoteService quoteService; + private final FxQuoteRepository quoteRepository; + + @Transactional + public FxQuote getQuote(String fromCurrency, String toCurrency, BigDecimal amount) { + log.info("Getting FX quote for {}:{} amount={}", fromCurrency, toCurrency, amount); + + var corridorRate = rateCache.get(fromCurrency, toCurrency) + .or(() -> { + log.info("Cache miss for {}:{}, fetching from provider", fromCurrency, toCurrency); + var providerRate = rateProvider.getRate(fromCurrency, toCurrency); + providerRate.ifPresent(rate -> rateCache.put(fromCurrency, toCurrency, rate)); + return providerRate; + }) + .orElseThrow(() -> { + log.warn("No rate available for {}:{}", fromCurrency, toCurrency); + return RateUnavailableException.forCorridor(fromCurrency, toCurrency); + }); + + var quote = quoteService.createQuote(fromCurrency, toCurrency, amount, corridorRate); + + var saved = quoteRepository.save(quote); + log.info("Quote created: quoteId={} rate={} expires={}", + saved.quoteId(), saved.rate(), saved.expiresAt()); + + return saved; + } + + @Transactional(readOnly = true) + public FxQuote getQuoteById(UUID quoteId) { + return quoteRepository.findById(quoteId) + .orElseThrow(() -> QuoteNotFoundException.withId(quoteId)); + } +} diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/domain/service/FxRateLockCommandHandler.java b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/domain/service/FxRateLockCommandHandler.java new file mode 100644 index 00000000..b0daadce --- /dev/null +++ b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/domain/service/FxRateLockCommandHandler.java @@ -0,0 +1,133 @@ +package com.stablecoin.payments.fx.domain.service; + +import com.stablecoin.payments.fx.domain.event.FxRateLocked; +import com.stablecoin.payments.fx.domain.exception.InsufficientLiquidityException; +import com.stablecoin.payments.fx.domain.exception.LockNotFoundException; +import com.stablecoin.payments.fx.domain.exception.PoolNotFoundException; +import com.stablecoin.payments.fx.domain.exception.QuoteAlreadyLockedException; +import com.stablecoin.payments.fx.domain.exception.QuoteExpiredException; +import com.stablecoin.payments.fx.domain.exception.QuoteNotFoundException; +import com.stablecoin.payments.fx.domain.model.FxQuote; +import com.stablecoin.payments.fx.domain.model.FxQuoteStatus; +import com.stablecoin.payments.fx.domain.model.FxRateLock; +import com.stablecoin.payments.fx.domain.model.FxRateLockStatus; +import com.stablecoin.payments.fx.domain.port.EventPublisher; +import com.stablecoin.payments.fx.domain.port.FxQuoteRepository; +import com.stablecoin.payments.fx.domain.port.FxRateLockRepository; +import com.stablecoin.payments.fx.domain.port.LiquidityPoolRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class FxRateLockCommandHandler { + + private final FxQuoteRepository quoteRepository; + private final FxRateLockRepository lockRepository; + private final LiquidityPoolRepository poolRepository; + private final LockService lockService; + private final LiquidityService liquidityService; + private final EventPublisher eventPublisher; + + public record LockRateResult(FxRateLock lock, boolean created) {} + + @Transactional + public LockRateResult lockRate(UUID quoteId, UUID paymentId, UUID correlationId, + String sourceCountry, String targetCountry) { + log.info("Locking rate for quote={} payment={}", quoteId, paymentId); + + var existingLock = lockRepository.findByPaymentId(paymentId); + if (existingLock.isPresent()) { + log.info("Idempotent lock return for payment={} lockId={}", + paymentId, existingLock.get().lockId()); + return new LockRateResult(existingLock.get(), false); + } + + var quote = quoteRepository.findById(quoteId) + .orElseThrow(() -> QuoteNotFoundException.withId(quoteId)); + + validateQuote(quote); + + var pool = poolRepository.findByCorridor(quote.fromCurrency(), quote.toCurrency()) + .orElseThrow(() -> PoolNotFoundException.forCorridor( + quote.fromCurrency(), quote.toCurrency())); + + if (!pool.hasSufficientLiquidity(quote.targetAmount())) { + throw InsufficientLiquidityException.forCorridor( + quote.fromCurrency(), quote.toCurrency(), + quote.targetAmount(), pool.availableBalance()); + } + + var lockResult = lockService.lockRate( + quote, paymentId, correlationId, sourceCountry, targetCountry, pool); + + quoteRepository.save(lockResult.lockedQuote()); + var savedLock = lockRepository.save(lockResult.lock()); + poolRepository.save(lockResult.updatedPool()); + + publishFxRateLockedEvent(savedLock, correlationId); + + log.info("Rate locked: lockId={} rate={} expires={}", + savedLock.lockId(), savedLock.lockedRate(), savedLock.expiresAt()); + + return new LockRateResult(savedLock, true); + } + + @Transactional + public void releaseLock(UUID lockId) { + log.info("Releasing lock lockId={}", lockId); + + var lock = lockRepository.findById(lockId) + .orElseThrow(() -> LockNotFoundException.withId(lockId)); + + if (lock.status() != FxRateLockStatus.ACTIVE) { + log.info("Lock {} already in status {}, skipping release", lockId, lock.status()); + return; + } + + var expiredLock = lock.expire(); + lockRepository.save(expiredLock); + + poolRepository.findByCorridor(lock.fromCurrency(), lock.toCurrency()) + .ifPresentOrElse( + pool -> { + var releasedPool = liquidityService.release(pool, lock.targetAmount()); + poolRepository.save(releasedPool); + log.info("Lock {} released, liquidity returned to pool", lockId); + }, + () -> log.warn("Lock {} released but pool not found for {}/{}", + lockId, lock.fromCurrency(), lock.toCurrency())); + } + + private void validateQuote(FxQuote quote) { + if (quote.isExpired()) { + throw QuoteExpiredException.withId(quote.quoteId()); + } + if (quote.status() == FxQuoteStatus.LOCKED) { + throw QuoteAlreadyLockedException.withId(quote.quoteId()); + } + } + + private void publishFxRateLockedEvent(FxRateLock lock, UUID correlationId) { + var event = new FxRateLocked( + lock.lockId(), + lock.quoteId(), + lock.paymentId(), + correlationId, + lock.fromCurrency(), + lock.toCurrency(), + lock.sourceAmount(), + lock.targetAmount(), + lock.lockedRate(), + lock.feeBps(), + lock.lockedAt(), + lock.expiresAt() + ); + eventPublisher.publish(event); + } +} diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/domain/service/LiquidityPoolQueryHandler.java b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/domain/service/LiquidityPoolQueryHandler.java new file mode 100644 index 00000000..00b309f7 --- /dev/null +++ b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/domain/service/LiquidityPoolQueryHandler.java @@ -0,0 +1,48 @@ +package com.stablecoin.payments.fx.domain.service; + +import com.stablecoin.payments.fx.domain.exception.PoolNotFoundException; +import com.stablecoin.payments.fx.domain.model.CorridorRate; +import com.stablecoin.payments.fx.domain.model.LiquidityPool; +import com.stablecoin.payments.fx.domain.port.LiquidityPoolRepository; +import com.stablecoin.payments.fx.domain.port.RateCache; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class LiquidityPoolQueryHandler { + + private final LiquidityPoolRepository poolRepository; + private final RateCache rateCache; + + public record CorridorSnapshot(LiquidityPool pool, CorridorRate rate) {} + + @Transactional(readOnly = true) + public List listPools() { + log.info("Listing all liquidity pools"); + return poolRepository.findAll(); + } + + @Transactional(readOnly = true) + public LiquidityPool getPool(UUID poolId) { + log.info("Getting liquidity pool: {}", poolId); + return poolRepository.findById(poolId) + .orElseThrow(() -> PoolNotFoundException.withId(poolId)); + } + + @Transactional(readOnly = true) + public List listCorridors() { + log.info("Listing supported corridors with current rates"); + return poolRepository.findAll().stream() + .map(pool -> new CorridorSnapshot( + pool, + rateCache.get(pool.fromCurrency(), pool.toCurrency()).orElse(null))) + .toList(); + } +} diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/controller/CorridorControllerTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/controller/CorridorControllerTest.java index 2de579df..055b4770 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/controller/CorridorControllerTest.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/controller/CorridorControllerTest.java @@ -1,7 +1,9 @@ package com.stablecoin.payments.fx.application.controller; import com.stablecoin.payments.fx.api.response.CorridorResponse; -import com.stablecoin.payments.fx.application.service.LiquidityPoolApplicationService; +import com.stablecoin.payments.fx.application.mapper.FxResponseMapper; +import com.stablecoin.payments.fx.domain.service.LiquidityPoolQueryHandler; +import com.stablecoin.payments.fx.domain.service.LiquidityPoolQueryHandler.CorridorSnapshot; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -13,6 +15,9 @@ import java.time.Instant; import java.util.List; +import static com.stablecoin.payments.fx.fixtures.CorridorRateFixtures.aUsdEurRate; +import static com.stablecoin.payments.fx.fixtures.LiquidityPoolFixtures.aGbpEurPool; +import static com.stablecoin.payments.fx.fixtures.LiquidityPoolFixtures.aUsdEurPool; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; @@ -21,41 +26,51 @@ class CorridorControllerTest { @Mock - private LiquidityPoolApplicationService liquidityPoolApplicationService; + private LiquidityPoolQueryHandler queryHandler; + + @Mock + private FxResponseMapper responseMapper; @InjectMocks private CorridorController controller; @Test - @DisplayName("should delegate to application service and return corridor list") + @DisplayName("should delegate to query handler and map each snapshot") void shouldListCorridors() { - // given var now = Instant.now(); - var corridors = List.of( - new CorridorResponse("USD", "EUR", new BigDecimal("0.92"), 30, 30, "REFINITIV", now), - new CorridorResponse("GBP", "EUR", new BigDecimal("1.16"), 25, 25, "REFINITIV", now) - ); - given(liquidityPoolApplicationService.listCorridors()).willReturn(corridors); + var usdEurPool = aUsdEurPool(); + var gbpEurPool = aGbpEurPool(); + var usdEurRate = aUsdEurRate(); + var usdEurSnapshot = new CorridorSnapshot(usdEurPool, usdEurRate); + var gbpEurSnapshot = new CorridorSnapshot(gbpEurPool, null); + + var usdEurResponse = new CorridorResponse( + "USD", "EUR", new BigDecimal("0.92"), 30, 30, "REFINITIV", now); + var gbpEurResponse = new CorridorResponse( + "GBP", "EUR", null, 0, 0, "unavailable", null); + + given(queryHandler.listCorridors()).willReturn(List.of(usdEurSnapshot, gbpEurSnapshot)); + given(responseMapper.toResponse(usdEurSnapshot)).willReturn(usdEurResponse); + given(responseMapper.toResponse(gbpEurSnapshot)).willReturn(gbpEurResponse); - // when var result = controller.listCorridors(); - // then - assertThat(result) + assertThat(result).hasSize(2); + assertThat(result.get(0)) + .usingRecursiveComparison() + .isEqualTo(usdEurResponse); + assertThat(result.get(1)) .usingRecursiveComparison() - .isEqualTo(corridors); + .isEqualTo(gbpEurResponse); } @Test @DisplayName("should return empty list when no corridors available") void shouldReturnEmptyList() { - // given - given(liquidityPoolApplicationService.listCorridors()).willReturn(List.of()); + given(queryHandler.listCorridors()).willReturn(List.of()); - // when var result = controller.listCorridors(); - // then assertThat(result).isEmpty(); } } diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/controller/FxQuoteControllerTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/controller/FxQuoteControllerTest.java index 7befa792..bbb8ab0a 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/controller/FxQuoteControllerTest.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/controller/FxQuoteControllerTest.java @@ -4,13 +4,14 @@ import com.stablecoin.payments.fx.api.request.FxRateLockRequest; import com.stablecoin.payments.fx.api.response.FxQuoteResponse; import com.stablecoin.payments.fx.api.response.FxRateLockResponse; -import com.stablecoin.payments.fx.application.service.FxQuoteApplicationService; -import com.stablecoin.payments.fx.application.service.FxRateLockApplicationService; -import com.stablecoin.payments.fx.application.service.FxRateLockApplicationService.LockRateResult; +import com.stablecoin.payments.fx.application.mapper.FxResponseMapper; import com.stablecoin.payments.fx.domain.exception.QuoteAlreadyLockedException; import com.stablecoin.payments.fx.domain.exception.QuoteExpiredException; import com.stablecoin.payments.fx.domain.exception.QuoteNotFoundException; import com.stablecoin.payments.fx.domain.exception.RateUnavailableException; +import com.stablecoin.payments.fx.domain.service.FxQuoteCommandHandler; +import com.stablecoin.payments.fx.domain.service.FxRateLockCommandHandler; +import com.stablecoin.payments.fx.domain.service.FxRateLockCommandHandler.LockRateResult; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -24,19 +25,29 @@ import java.time.Instant; import java.util.UUID; +import static com.stablecoin.payments.fx.fixtures.FxQuoteFixtures.anActiveQuote; +import static com.stablecoin.payments.fx.fixtures.FxRateLockFixtures.CORRELATION_ID; +import static com.stablecoin.payments.fx.fixtures.FxRateLockFixtures.PAYMENT_ID; +import static com.stablecoin.payments.fx.fixtures.FxRateLockFixtures.SOURCE_COUNTRY; +import static com.stablecoin.payments.fx.fixtures.FxRateLockFixtures.TARGET_COUNTRY; +import static com.stablecoin.payments.fx.fixtures.FxRateLockFixtures.anActiveLock; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; @ExtendWith(MockitoExtension.class) @DisplayName("FxQuoteController") class FxQuoteControllerTest { @Mock - private FxQuoteApplicationService quoteApplicationService; + private FxQuoteCommandHandler quoteCommandHandler; @Mock - private FxRateLockApplicationService rateLockApplicationService; + private FxRateLockCommandHandler rateLockCommandHandler; + + @Mock + private FxResponseMapper responseMapper; @InjectMocks private FxQuoteController controller; @@ -46,38 +57,39 @@ class FxQuoteControllerTest { class GetQuote { @Test - @DisplayName("should delegate to application service and return quote response") + @DisplayName("should delegate to command handler and map response") void shouldGetQuote() { - // given var request = new FxQuoteRequest("USD", "EUR", new BigDecimal("10000.00")); + var quote = anActiveQuote(); var now = Instant.now(); var expectedResponse = new FxQuoteResponse( - UUID.randomUUID(), "USD", "EUR", + quote.quoteId(), "USD", "EUR", new BigDecimal("10000.00"), new BigDecimal("9200.00"), new BigDecimal("0.92"), new BigDecimal("1.087"), 30, new BigDecimal("30.00"), "REFINITIV", now, now.plusSeconds(300)); - given(quoteApplicationService.getQuote(request)).willReturn(expectedResponse); + given(quoteCommandHandler.getQuote("USD", "EUR", new BigDecimal("10000.00"))) + .willReturn(quote); + given(responseMapper.toResponse(quote)).willReturn(expectedResponse); - // when var result = controller.getQuote(request); - // then assertThat(result) .usingRecursiveComparison() .isEqualTo(expectedResponse); + + then(quoteCommandHandler).should().getQuote("USD", "EUR", new BigDecimal("10000.00")); + then(responseMapper).should().toResponse(quote); } @Test @DisplayName("should propagate RateUnavailableException") void shouldPropagateRateUnavailable() { - // given var request = new FxQuoteRequest("JPY", "BRL", new BigDecimal("10000.00")); - given(quoteApplicationService.getQuote(request)) + given(quoteCommandHandler.getQuote("JPY", "BRL", new BigDecimal("10000.00"))) .willThrow(RateUnavailableException.forCorridor("JPY", "BRL")); - // when/then assertThatThrownBy(() -> controller.getQuote(request)) .isInstanceOf(RateUnavailableException.class) .hasMessageContaining("JPY") @@ -90,10 +102,10 @@ void shouldPropagateRateUnavailable() { class GetQuoteById { @Test - @DisplayName("should delegate to application service and return quote response") + @DisplayName("should delegate to command handler and map response") void shouldGetQuoteById() { - // given var quoteId = UUID.randomUUID(); + var quote = anActiveQuote(); var now = Instant.now(); var expectedResponse = new FxQuoteResponse( quoteId, "USD", "EUR", @@ -102,26 +114,26 @@ void shouldGetQuoteById() { 30, new BigDecimal("30.00"), "REFINITIV", now, now.plusSeconds(300)); - given(quoteApplicationService.getQuoteById(quoteId)).willReturn(expectedResponse); + given(quoteCommandHandler.getQuoteById(quoteId)).willReturn(quote); + given(responseMapper.toResponse(quote)).willReturn(expectedResponse); - // when var result = controller.getQuoteById(quoteId); - // then assertThat(result) .usingRecursiveComparison() .isEqualTo(expectedResponse); + + then(quoteCommandHandler).should().getQuoteById(quoteId); + then(responseMapper).should().toResponse(quote); } @Test @DisplayName("should propagate QuoteNotFoundException") void shouldPropagateQuoteNotFound() { - // given var quoteId = UUID.randomUUID(); - given(quoteApplicationService.getQuoteById(quoteId)) + given(quoteCommandHandler.getQuoteById(quoteId)) .willThrow(QuoteNotFoundException.withId(quoteId)); - // when/then assertThatThrownBy(() -> controller.getQuoteById(quoteId)) .isInstanceOf(QuoteNotFoundException.class) .hasMessageContaining(quoteId.toString()); @@ -135,26 +147,24 @@ class LockRate { @Test @DisplayName("should return 201 Created for new lock") void shouldReturn201ForNewLock() { - // given var quoteId = UUID.randomUUID(); - var paymentId = UUID.randomUUID(); - var correlationId = UUID.randomUUID(); - var request = new FxRateLockRequest(paymentId, correlationId, "US", "DE"); + var request = new FxRateLockRequest(PAYMENT_ID, CORRELATION_ID, SOURCE_COUNTRY, TARGET_COUNTRY); + var lock = anActiveLock(quoteId, PAYMENT_ID); var now = Instant.now(); var lockResponse = new FxRateLockResponse( - UUID.randomUUID(), quoteId, paymentId, + lock.lockId(), quoteId, PAYMENT_ID, "USD", "EUR", new BigDecimal("10000.00"), new BigDecimal("9200.00"), new BigDecimal("0.92"), 30, new BigDecimal("30.00"), now, now.plusSeconds(30)); - given(rateLockApplicationService.lockRate(quoteId, request)) - .willReturn(new LockRateResult(lockResponse, true)); + given(rateLockCommandHandler.lockRate( + quoteId, PAYMENT_ID, CORRELATION_ID, SOURCE_COUNTRY, TARGET_COUNTRY)) + .willReturn(new LockRateResult(lock, true)); + given(responseMapper.toResponse(lock)).willReturn(lockResponse); - // when var result = controller.lockRate(quoteId, request); - // then assertThat(result.getStatusCode()).isEqualTo(HttpStatus.CREATED); assertThat(result.getBody()) .usingRecursiveComparison() @@ -164,26 +174,24 @@ void shouldReturn201ForNewLock() { @Test @DisplayName("should return 200 OK for idempotent lock") void shouldReturn200ForIdempotentLock() { - // given var quoteId = UUID.randomUUID(); - var paymentId = UUID.randomUUID(); - var correlationId = UUID.randomUUID(); - var request = new FxRateLockRequest(paymentId, correlationId, "US", "DE"); + var request = new FxRateLockRequest(PAYMENT_ID, CORRELATION_ID, SOURCE_COUNTRY, TARGET_COUNTRY); + var lock = anActiveLock(quoteId, PAYMENT_ID); var now = Instant.now(); var lockResponse = new FxRateLockResponse( - UUID.randomUUID(), quoteId, paymentId, + lock.lockId(), quoteId, PAYMENT_ID, "USD", "EUR", new BigDecimal("10000.00"), new BigDecimal("9200.00"), new BigDecimal("0.92"), 30, new BigDecimal("30.00"), now, now.plusSeconds(30)); - given(rateLockApplicationService.lockRate(quoteId, request)) - .willReturn(new LockRateResult(lockResponse, false)); + given(rateLockCommandHandler.lockRate( + quoteId, PAYMENT_ID, CORRELATION_ID, SOURCE_COUNTRY, TARGET_COUNTRY)) + .willReturn(new LockRateResult(lock, false)); + given(responseMapper.toResponse(lock)).willReturn(lockResponse); - // when var result = controller.lockRate(quoteId, request); - // then assertThat(result.getStatusCode()).isEqualTo(HttpStatus.OK); assertThat(result.getBody()) .usingRecursiveComparison() @@ -193,13 +201,13 @@ void shouldReturn200ForIdempotentLock() { @Test @DisplayName("should propagate QuoteNotFoundException") void shouldPropagateQuoteNotFound() { - // given var quoteId = UUID.randomUUID(); - var request = new FxRateLockRequest(UUID.randomUUID(), UUID.randomUUID(), "US", "DE"); - given(rateLockApplicationService.lockRate(quoteId, request)) + var paymentId = UUID.randomUUID(); + var correlationId = UUID.randomUUID(); + var request = new FxRateLockRequest(paymentId, correlationId, "US", "DE"); + given(rateLockCommandHandler.lockRate(quoteId, paymentId, correlationId, "US", "DE")) .willThrow(QuoteNotFoundException.withId(quoteId)); - // when/then assertThatThrownBy(() -> controller.lockRate(quoteId, request)) .isInstanceOf(QuoteNotFoundException.class) .hasMessageContaining(quoteId.toString()); @@ -208,13 +216,13 @@ void shouldPropagateQuoteNotFound() { @Test @DisplayName("should propagate QuoteExpiredException") void shouldPropagateQuoteExpired() { - // given var quoteId = UUID.randomUUID(); - var request = new FxRateLockRequest(UUID.randomUUID(), UUID.randomUUID(), "US", "DE"); - given(rateLockApplicationService.lockRate(quoteId, request)) + var paymentId = UUID.randomUUID(); + var correlationId = UUID.randomUUID(); + var request = new FxRateLockRequest(paymentId, correlationId, "US", "DE"); + given(rateLockCommandHandler.lockRate(quoteId, paymentId, correlationId, "US", "DE")) .willThrow(QuoteExpiredException.withId(quoteId)); - // when/then assertThatThrownBy(() -> controller.lockRate(quoteId, request)) .isInstanceOf(QuoteExpiredException.class) .hasMessageContaining(quoteId.toString()); @@ -223,13 +231,13 @@ void shouldPropagateQuoteExpired() { @Test @DisplayName("should propagate QuoteAlreadyLockedException") void shouldPropagateQuoteAlreadyLocked() { - // given var quoteId = UUID.randomUUID(); - var request = new FxRateLockRequest(UUID.randomUUID(), UUID.randomUUID(), "US", "DE"); - given(rateLockApplicationService.lockRate(quoteId, request)) + var paymentId = UUID.randomUUID(); + var correlationId = UUID.randomUUID(); + var request = new FxRateLockRequest(paymentId, correlationId, "US", "DE"); + given(rateLockCommandHandler.lockRate(quoteId, paymentId, correlationId, "US", "DE")) .willThrow(QuoteAlreadyLockedException.withId(quoteId)); - // when/then assertThatThrownBy(() -> controller.lockRate(quoteId, request)) .isInstanceOf(QuoteAlreadyLockedException.class) .hasMessageContaining(quoteId.toString()); diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/controller/LiquidityPoolControllerTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/controller/LiquidityPoolControllerTest.java index 10de7269..0e0f595b 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/controller/LiquidityPoolControllerTest.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/controller/LiquidityPoolControllerTest.java @@ -1,8 +1,9 @@ package com.stablecoin.payments.fx.application.controller; import com.stablecoin.payments.fx.api.response.LiquidityPoolResponse; -import com.stablecoin.payments.fx.application.service.LiquidityPoolApplicationService; +import com.stablecoin.payments.fx.application.mapper.FxResponseMapper; import com.stablecoin.payments.fx.domain.exception.PoolNotFoundException; +import com.stablecoin.payments.fx.domain.service.LiquidityPoolQueryHandler; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -16,16 +17,22 @@ import java.util.List; import java.util.UUID; +import static com.stablecoin.payments.fx.fixtures.LiquidityPoolFixtures.aGbpEurPool; +import static com.stablecoin.payments.fx.fixtures.LiquidityPoolFixtures.aUsdEurPool; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; @ExtendWith(MockitoExtension.class) @DisplayName("LiquidityPoolController") class LiquidityPoolControllerTest { @Mock - private LiquidityPoolApplicationService liquidityPoolApplicationService; + private LiquidityPoolQueryHandler queryHandler; + + @Mock + private FxResponseMapper responseMapper; @InjectMocks private LiquidityPoolController controller; @@ -35,43 +42,44 @@ class LiquidityPoolControllerTest { class ListPools { @Test - @DisplayName("should delegate to application service and return pool list") + @DisplayName("should delegate to query handler and map each pool to response") void shouldListPools() { - // given + var pool1 = aUsdEurPool(); + var pool2 = aGbpEurPool(); var now = Instant.now(); - var pools = List.of( - new LiquidityPoolResponse( - UUID.randomUUID(), "USD", "EUR", - new BigDecimal("1000000.00"), BigDecimal.ZERO, - new BigDecimal("100000.00"), new BigDecimal("5000000.00"), - BigDecimal.ZERO, "HEALTHY", now), - new LiquidityPoolResponse( - UUID.randomUUID(), "GBP", "EUR", - new BigDecimal("500000.00"), BigDecimal.ZERO, - new BigDecimal("50000.00"), new BigDecimal("2000000.00"), - BigDecimal.ZERO, "HEALTHY", now) - ); - given(liquidityPoolApplicationService.listPools()).willReturn(pools); - - // when + var response1 = new LiquidityPoolResponse( + pool1.poolId(), "USD", "EUR", + new BigDecimal("1000000.00"), BigDecimal.ZERO, + new BigDecimal("100000.00"), new BigDecimal("5000000.00"), + BigDecimal.ZERO, "HEALTHY", now); + var response2 = new LiquidityPoolResponse( + pool2.poolId(), "GBP", "EUR", + new BigDecimal("500000.00"), BigDecimal.ZERO, + new BigDecimal("50000.00"), new BigDecimal("2000000.00"), + BigDecimal.ZERO, "HEALTHY", now); + + given(queryHandler.listPools()).willReturn(List.of(pool1, pool2)); + given(responseMapper.toResponse(pool1)).willReturn(response1); + given(responseMapper.toResponse(pool2)).willReturn(response2); + var result = controller.listPools(); - // then - assertThat(result) + assertThat(result).hasSize(2); + assertThat(result.get(0)) .usingRecursiveComparison() - .isEqualTo(pools); + .isEqualTo(response1); + assertThat(result.get(1)) + .usingRecursiveComparison() + .isEqualTo(response2); } @Test @DisplayName("should return empty list when no pools exist") void shouldReturnEmptyList() { - // given - given(liquidityPoolApplicationService.listPools()).willReturn(List.of()); + given(queryHandler.listPools()).willReturn(List.of()); - // when var result = controller.listPools(); - // then assertThat(result).isEmpty(); } } @@ -81,10 +89,10 @@ void shouldReturnEmptyList() { class GetPool { @Test - @DisplayName("should delegate to application service and return pool response") + @DisplayName("should delegate to query handler and map response") void shouldGetPool() { - // given - var poolId = UUID.randomUUID(); + var pool = aUsdEurPool(); + var poolId = pool.poolId(); var now = Instant.now(); var expectedResponse = new LiquidityPoolResponse( poolId, "USD", "EUR", @@ -92,26 +100,26 @@ void shouldGetPool() { new BigDecimal("100000.00"), new BigDecimal("5000000.00"), BigDecimal.ZERO, "HEALTHY", now); - given(liquidityPoolApplicationService.getPool(poolId)).willReturn(expectedResponse); + given(queryHandler.getPool(poolId)).willReturn(pool); + given(responseMapper.toResponse(pool)).willReturn(expectedResponse); - // when var result = controller.getPool(poolId); - // then assertThat(result) .usingRecursiveComparison() .isEqualTo(expectedResponse); + + then(queryHandler).should().getPool(poolId); + then(responseMapper).should().toResponse(pool); } @Test @DisplayName("should propagate PoolNotFoundException") void shouldPropagatePoolNotFound() { - // given var poolId = UUID.randomUUID(); - given(liquidityPoolApplicationService.getPool(poolId)) + given(queryHandler.getPool(poolId)) .willThrow(PoolNotFoundException.withId(poolId)); - // when/then assertThatThrownBy(() -> controller.getPool(poolId)) .isInstanceOf(PoolNotFoundException.class) .hasMessageContaining(poolId.toString()); diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/LiquidityPoolApplicationServiceTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/LiquidityPoolApplicationServiceTest.java deleted file mode 100644 index b94c089c..00000000 --- a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/LiquidityPoolApplicationServiceTest.java +++ /dev/null @@ -1,183 +0,0 @@ -package com.stablecoin.payments.fx.application.service; - -import com.stablecoin.payments.fx.api.response.CorridorResponse; -import com.stablecoin.payments.fx.api.response.LiquidityPoolResponse; -import com.stablecoin.payments.fx.application.mapper.FxResponseMapper; -import com.stablecoin.payments.fx.domain.exception.PoolNotFoundException; -import com.stablecoin.payments.fx.domain.port.LiquidityPoolRepository; -import com.stablecoin.payments.fx.domain.port.RateCache; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -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.time.Instant; -import java.util.List; -import java.util.Optional; -import java.util.UUID; - -import static com.stablecoin.payments.fx.fixtures.CorridorRateFixtures.aUsdEurRate; -import static com.stablecoin.payments.fx.fixtures.LiquidityPoolFixtures.aGbpEurPool; -import static com.stablecoin.payments.fx.fixtures.LiquidityPoolFixtures.aUsdEurPool; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.BDDMockito.given; - -@ExtendWith(MockitoExtension.class) -@DisplayName("LiquidityPoolApplicationService") -class LiquidityPoolApplicationServiceTest { - - @Mock - private LiquidityPoolRepository poolRepository; - - @Mock - private RateCache rateCache; - - @Mock - private FxResponseMapper responseMapper; - - @InjectMocks - private LiquidityPoolApplicationService service; - - @Nested - @DisplayName("listPools") - class ListPools { - - @Test - void shouldReturnAllPools() { - // given - var pool1 = aUsdEurPool(); - var pool2 = aGbpEurPool(); - var response1 = new LiquidityPoolResponse( - pool1.poolId(), pool1.fromCurrency(), pool1.toCurrency(), - pool1.availableBalance(), pool1.reservedBalance(), - pool1.minimumThreshold(), pool1.maximumCapacity(), - BigDecimal.ZERO, "HEALTHY", pool1.updatedAt()); - var response2 = new LiquidityPoolResponse( - pool2.poolId(), pool2.fromCurrency(), pool2.toCurrency(), - pool2.availableBalance(), pool2.reservedBalance(), - pool2.minimumThreshold(), pool2.maximumCapacity(), - BigDecimal.ZERO, "HEALTHY", pool2.updatedAt()); - - given(poolRepository.findAll()).willReturn(List.of(pool1, pool2)); - given(responseMapper.toResponse(pool1)).willReturn(response1); - given(responseMapper.toResponse(pool2)).willReturn(response2); - - // when - var result = service.listPools(); - - // then - assertThat(result).hasSize(2); - assertThat(result.get(0)) - .usingRecursiveComparison() - .isEqualTo(response1); - assertThat(result.get(1)) - .usingRecursiveComparison() - .isEqualTo(response2); - } - - @Test - void shouldReturnEmptyListWhenNoPools() { - // given - given(poolRepository.findAll()).willReturn(List.of()); - - // when - var result = service.listPools(); - - // then - assertThat(result).isEmpty(); - } - } - - @Nested - @DisplayName("getPool") - class GetPool { - - @Test - void shouldReturnPoolById() { - // given - var pool = aUsdEurPool(); - var poolId = pool.poolId(); - var expectedResponse = new LiquidityPoolResponse( - pool.poolId(), pool.fromCurrency(), pool.toCurrency(), - pool.availableBalance(), pool.reservedBalance(), - pool.minimumThreshold(), pool.maximumCapacity(), - BigDecimal.ZERO, "HEALTHY", pool.updatedAt()); - - given(poolRepository.findById(poolId)).willReturn(Optional.of(pool)); - given(responseMapper.toResponse(pool)).willReturn(expectedResponse); - - // when - var result = service.getPool(poolId); - - // then - assertThat(result) - .usingRecursiveComparison() - .isEqualTo(expectedResponse); - } - - @Test - void shouldThrowWhenPoolNotFound() { - // given - var poolId = UUID.randomUUID(); - given(poolRepository.findById(poolId)).willReturn(Optional.empty()); - - // when/then - assertThatThrownBy(() -> service.getPool(poolId)) - .isInstanceOf(PoolNotFoundException.class) - .hasMessageContaining(poolId.toString()); - } - } - - @Nested - @DisplayName("listCorridors") - class ListCorridors { - - @Test - void shouldReturnCorridorsWithRates() { - // given - var pool = aUsdEurPool(); - var corridorRate = aUsdEurRate(); - var corridorResponse = new CorridorResponse( - "USD", "EUR", corridorRate.rate(), - corridorRate.feeBps(), corridorRate.spreadBps(), - corridorRate.provider(), Instant.now()); - - given(poolRepository.findAll()).willReturn(List.of(pool)); - given(rateCache.get("USD", "EUR")).willReturn(Optional.of(corridorRate)); - given(responseMapper.toResponse(corridorRate)).willReturn(corridorResponse); - - // when - var result = service.listCorridors(); - - // then - assertThat(result).hasSize(1); - assertThat(result.getFirst()) - .usingRecursiveComparison() - .isEqualTo(corridorResponse); - } - - @Test - void shouldReturnCorridorWithNullRateWhenCacheMisses() { - // given - var pool = aUsdEurPool(); - given(poolRepository.findAll()).willReturn(List.of(pool)); - given(rateCache.get("USD", "EUR")).willReturn(Optional.empty()); - - // when - var result = service.listCorridors(); - - // then - assertThat(result).hasSize(1); - var expected = new CorridorResponse( - "USD", "EUR", null, 0, 0, "unavailable", null); - assertThat(result.getFirst()) - .usingRecursiveComparison() - .isEqualTo(expected); - } - } -} diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/domain/service/FxQuoteCommandHandlerTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/domain/service/FxQuoteCommandHandlerTest.java new file mode 100644 index 00000000..202ab78d --- /dev/null +++ b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/domain/service/FxQuoteCommandHandlerTest.java @@ -0,0 +1,129 @@ +package com.stablecoin.payments.fx.domain.service; + +import com.stablecoin.payments.fx.domain.exception.QuoteNotFoundException; +import com.stablecoin.payments.fx.domain.exception.RateUnavailableException; +import com.stablecoin.payments.fx.domain.port.FxQuoteRepository; +import com.stablecoin.payments.fx.domain.port.RateCache; +import com.stablecoin.payments.fx.domain.port.RateProvider; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.Optional; +import java.util.UUID; + +import static com.stablecoin.payments.fx.fixtures.CorridorRateFixtures.aUsdEurRate; +import static com.stablecoin.payments.fx.fixtures.FxQuoteFixtures.anActiveQuote; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("FxQuoteCommandHandler") +class FxQuoteCommandHandlerTest { + + @Mock + private RateProvider rateProvider; + + @Mock + private RateCache rateCache; + + @Mock + private QuoteService quoteService; + + @Mock + private FxQuoteRepository quoteRepository; + + @InjectMocks + private FxQuoteCommandHandler handler; + + @Nested + @DisplayName("getQuote") + class GetQuote { + + @Test + void shouldCreateQuoteFromCachedRate() { + var amount = new BigDecimal("10000.00"); + var corridorRate = aUsdEurRate(); + var quote = anActiveQuote(); + + given(rateCache.get("USD", "EUR")).willReturn(Optional.of(corridorRate)); + given(quoteService.createQuote("USD", "EUR", amount, corridorRate)).willReturn(quote); + given(quoteRepository.save(quote)).willReturn(quote); + + var result = handler.getQuote("USD", "EUR", amount); + + assertThat(result) + .usingRecursiveComparison() + .isEqualTo(quote); + + then(rateProvider).shouldHaveNoInteractions(); + } + + @Test + void shouldCreateQuoteFromProviderWhenCacheMisses() { + var amount = new BigDecimal("10000.00"); + var corridorRate = aUsdEurRate(); + var quote = anActiveQuote(); + + given(rateCache.get("USD", "EUR")).willReturn(Optional.empty()); + given(rateProvider.getRate("USD", "EUR")).willReturn(Optional.of(corridorRate)); + given(quoteService.createQuote("USD", "EUR", amount, corridorRate)).willReturn(quote); + given(quoteRepository.save(quote)).willReturn(quote); + + var result = handler.getQuote("USD", "EUR", amount); + + assertThat(result) + .usingRecursiveComparison() + .isEqualTo(quote); + + then(rateCache).should().put("USD", "EUR", corridorRate); + } + + @Test + void shouldThrowWhenNoRateAvailable() { + var amount = new BigDecimal("10000.00"); + given(rateCache.get("USD", "EUR")).willReturn(Optional.empty()); + given(rateProvider.getRate("USD", "EUR")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> handler.getQuote("USD", "EUR", amount)) + .isInstanceOf(RateUnavailableException.class) + .hasMessageContaining("USD:EUR"); + } + } + + @Nested + @DisplayName("getQuoteById") + class GetQuoteById { + + @Test + void shouldReturnQuoteById() { + var quoteId = UUID.randomUUID(); + var quote = anActiveQuote(); + + given(quoteRepository.findById(quoteId)).willReturn(Optional.of(quote)); + + var result = handler.getQuoteById(quoteId); + + assertThat(result) + .usingRecursiveComparison() + .isEqualTo(quote); + } + + @Test + void shouldThrowWhenQuoteNotFound() { + var quoteId = UUID.randomUUID(); + given(quoteRepository.findById(quoteId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> handler.getQuoteById(quoteId)) + .isInstanceOf(QuoteNotFoundException.class) + .hasMessageContaining(quoteId.toString()); + } + } +} diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/domain/service/FxRateLockCommandHandlerTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/domain/service/FxRateLockCommandHandlerTest.java new file mode 100644 index 00000000..ec29ea8e --- /dev/null +++ b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/domain/service/FxRateLockCommandHandlerTest.java @@ -0,0 +1,214 @@ +package com.stablecoin.payments.fx.domain.service; + +import com.stablecoin.payments.fx.domain.event.FxRateLocked; +import com.stablecoin.payments.fx.domain.exception.InsufficientLiquidityException; +import com.stablecoin.payments.fx.domain.exception.PoolNotFoundException; +import com.stablecoin.payments.fx.domain.exception.QuoteAlreadyLockedException; +import com.stablecoin.payments.fx.domain.exception.QuoteExpiredException; +import com.stablecoin.payments.fx.domain.exception.QuoteNotFoundException; +import com.stablecoin.payments.fx.domain.port.EventPublisher; +import com.stablecoin.payments.fx.domain.port.FxQuoteRepository; +import com.stablecoin.payments.fx.domain.port.FxRateLockRepository; +import com.stablecoin.payments.fx.domain.port.LiquidityPoolRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.Optional; +import java.util.UUID; + +import static com.stablecoin.payments.fx.fixtures.FxQuoteFixtures.aLockedQuote; +import static com.stablecoin.payments.fx.fixtures.FxQuoteFixtures.anActiveQuote; +import static com.stablecoin.payments.fx.fixtures.FxQuoteFixtures.anExpiredQuote; +import static com.stablecoin.payments.fx.fixtures.FxRateLockFixtures.CORRELATION_ID; +import static com.stablecoin.payments.fx.fixtures.FxRateLockFixtures.PAYMENT_ID; +import static com.stablecoin.payments.fx.fixtures.FxRateLockFixtures.SOURCE_COUNTRY; +import static com.stablecoin.payments.fx.fixtures.FxRateLockFixtures.TARGET_COUNTRY; +import static com.stablecoin.payments.fx.fixtures.FxRateLockFixtures.anActiveLock; +import static com.stablecoin.payments.fx.fixtures.LiquidityPoolFixtures.aPoolWithLowBalance; +import static com.stablecoin.payments.fx.fixtures.LiquidityPoolFixtures.aUsdEurPool; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +@ExtendWith(MockitoExtension.class) +@DisplayName("FxRateLockCommandHandler") +class FxRateLockCommandHandlerTest { + + @Mock + private FxQuoteRepository quoteRepository; + + @Mock + private FxRateLockRepository lockRepository; + + @Mock + private LiquidityPoolRepository poolRepository; + + @Mock + private LockService lockService; + + @Mock + private LiquidityService liquidityService; + + @Mock + private EventPublisher eventPublisher; + + @InjectMocks + private FxRateLockCommandHandler handler; + + @Nested + @DisplayName("lockRate") + class LockRate { + + @Test + void shouldLockRateSuccessfully() { + var quote = anActiveQuote(); + var quoteId = quote.quoteId(); + var pool = aUsdEurPool(); + + var lockedQuote = aLockedQuote(); + var lock = anActiveLock(quoteId, PAYMENT_ID); + var updatedPool = aUsdEurPool(); + var lockResult = new LockService.LockResult(lockedQuote, lock, updatedPool); + + given(lockRepository.findByPaymentId(PAYMENT_ID)).willReturn(Optional.empty()); + given(quoteRepository.findById(quoteId)).willReturn(Optional.of(quote)); + given(poolRepository.findByCorridor("USD", "EUR")).willReturn(Optional.of(pool)); + given(lockService.lockRate(quote, PAYMENT_ID, CORRELATION_ID, + SOURCE_COUNTRY, TARGET_COUNTRY, pool)).willReturn(lockResult); + given(quoteRepository.save(lockedQuote)).willReturn(lockedQuote); + given(lockRepository.save(lock)).willReturn(lock); + given(poolRepository.save(updatedPool)).willReturn(updatedPool); + + var result = handler.lockRate(quoteId, PAYMENT_ID, CORRELATION_ID, + SOURCE_COUNTRY, TARGET_COUNTRY); + + var expected = new FxRateLockCommandHandler.LockRateResult(lock, true); + assertThat(result) + .usingRecursiveComparison() + .isEqualTo(expected); + + var expectedEvent = new FxRateLocked( + lock.lockId(), lock.quoteId(), lock.paymentId(), CORRELATION_ID, + lock.fromCurrency(), lock.toCurrency(), + lock.sourceAmount(), lock.targetAmount(), lock.lockedRate(), + lock.feeBps(), lock.lockedAt(), lock.expiresAt()); + then(eventPublisher).should().publish(eqIgnoring(expectedEvent)); + } + + @Test + void shouldReturnExistingLockForSamePaymentId() { + var quoteId = UUID.randomUUID(); + var existingLock = anActiveLock(quoteId, PAYMENT_ID); + + given(lockRepository.findByPaymentId(PAYMENT_ID)).willReturn(Optional.of(existingLock)); + + var result = handler.lockRate(quoteId, PAYMENT_ID, CORRELATION_ID, + SOURCE_COUNTRY, TARGET_COUNTRY); + + var expected = new FxRateLockCommandHandler.LockRateResult(existingLock, false); + assertThat(result) + .usingRecursiveComparison() + .isEqualTo(expected); + + then(quoteRepository).shouldHaveNoInteractions(); + then(lockService).shouldHaveNoInteractions(); + then(lockRepository).should().findByPaymentId(PAYMENT_ID); + then(lockRepository).shouldHaveNoMoreInteractions(); + then(eventPublisher).shouldHaveNoInteractions(); + } + + @Test + void shouldThrowWhenQuoteNotFound() { + var quoteId = UUID.randomUUID(); + given(lockRepository.findByPaymentId(PAYMENT_ID)).willReturn(Optional.empty()); + given(quoteRepository.findById(quoteId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> handler.lockRate(quoteId, PAYMENT_ID, CORRELATION_ID, + SOURCE_COUNTRY, TARGET_COUNTRY)) + .isInstanceOf(QuoteNotFoundException.class) + .hasMessageContaining(quoteId.toString()); + + then(lockRepository).should().findByPaymentId(PAYMENT_ID); + then(lockRepository).shouldHaveNoMoreInteractions(); + then(eventPublisher).shouldHaveNoInteractions(); + } + + @Test + void shouldThrowWhenQuoteExpired() { + var expiredQuote = anExpiredQuote(); + var quoteId = expiredQuote.quoteId(); + given(lockRepository.findByPaymentId(PAYMENT_ID)).willReturn(Optional.empty()); + given(quoteRepository.findById(quoteId)).willReturn(Optional.of(expiredQuote)); + + assertThatThrownBy(() -> handler.lockRate(quoteId, PAYMENT_ID, CORRELATION_ID, + SOURCE_COUNTRY, TARGET_COUNTRY)) + .isInstanceOf(QuoteExpiredException.class) + .hasMessageContaining(quoteId.toString()); + + then(lockRepository).should().findByPaymentId(PAYMENT_ID); + then(lockRepository).shouldHaveNoMoreInteractions(); + then(eventPublisher).shouldHaveNoInteractions(); + } + + @Test + void shouldThrowWhenQuoteAlreadyLocked() { + var lockedQuote = aLockedQuote(); + var quoteId = lockedQuote.quoteId(); + given(lockRepository.findByPaymentId(PAYMENT_ID)).willReturn(Optional.empty()); + given(quoteRepository.findById(quoteId)).willReturn(Optional.of(lockedQuote)); + + assertThatThrownBy(() -> handler.lockRate(quoteId, PAYMENT_ID, CORRELATION_ID, + SOURCE_COUNTRY, TARGET_COUNTRY)) + .isInstanceOf(QuoteAlreadyLockedException.class) + .hasMessageContaining(quoteId.toString()); + + then(lockRepository).should().findByPaymentId(PAYMENT_ID); + then(lockRepository).shouldHaveNoMoreInteractions(); + then(eventPublisher).shouldHaveNoInteractions(); + } + + @Test + void shouldThrowWhenPoolNotFound() { + var quote = anActiveQuote(); + var quoteId = quote.quoteId(); + given(lockRepository.findByPaymentId(PAYMENT_ID)).willReturn(Optional.empty()); + given(quoteRepository.findById(quoteId)).willReturn(Optional.of(quote)); + given(poolRepository.findByCorridor("USD", "EUR")).willReturn(Optional.empty()); + + assertThatThrownBy(() -> handler.lockRate(quoteId, PAYMENT_ID, CORRELATION_ID, + SOURCE_COUNTRY, TARGET_COUNTRY)) + .isInstanceOf(PoolNotFoundException.class) + .hasMessageContaining("USD:EUR"); + + then(lockRepository).should().findByPaymentId(PAYMENT_ID); + then(lockRepository).shouldHaveNoMoreInteractions(); + then(eventPublisher).shouldHaveNoInteractions(); + } + + @Test + void shouldThrowWhenInsufficientLiquidity() { + var quote = anActiveQuote(); + var quoteId = quote.quoteId(); + var lowPool = aPoolWithLowBalance(); + given(lockRepository.findByPaymentId(PAYMENT_ID)).willReturn(Optional.empty()); + given(quoteRepository.findById(quoteId)).willReturn(Optional.of(quote)); + given(poolRepository.findByCorridor("USD", "EUR")).willReturn(Optional.of(lowPool)); + + assertThatThrownBy(() -> handler.lockRate(quoteId, PAYMENT_ID, CORRELATION_ID, + SOURCE_COUNTRY, TARGET_COUNTRY)) + .isInstanceOf(InsufficientLiquidityException.class) + .hasMessageContaining("USD:EUR"); + + then(lockRepository).should().findByPaymentId(PAYMENT_ID); + then(lockRepository).shouldHaveNoMoreInteractions(); + then(eventPublisher).shouldHaveNoInteractions(); + } + } +} diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/domain/service/LiquidityPoolQueryHandlerTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/domain/service/LiquidityPoolQueryHandlerTest.java new file mode 100644 index 00000000..551250c0 --- /dev/null +++ b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/domain/service/LiquidityPoolQueryHandlerTest.java @@ -0,0 +1,130 @@ +package com.stablecoin.payments.fx.domain.service; + +import com.stablecoin.payments.fx.domain.exception.PoolNotFoundException; +import com.stablecoin.payments.fx.domain.port.LiquidityPoolRepository; +import com.stablecoin.payments.fx.domain.port.RateCache; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +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.Optional; +import java.util.UUID; + +import static com.stablecoin.payments.fx.fixtures.CorridorRateFixtures.aUsdEurRate; +import static com.stablecoin.payments.fx.fixtures.LiquidityPoolFixtures.aGbpEurPool; +import static com.stablecoin.payments.fx.fixtures.LiquidityPoolFixtures.aUsdEurPool; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +@ExtendWith(MockitoExtension.class) +@DisplayName("LiquidityPoolQueryHandler") +class LiquidityPoolQueryHandlerTest { + + @Mock + private LiquidityPoolRepository poolRepository; + + @Mock + private RateCache rateCache; + + @InjectMocks + private LiquidityPoolQueryHandler handler; + + @Nested + @DisplayName("listPools") + class ListPools { + + @Test + void shouldReturnAllPools() { + var pool1 = aUsdEurPool(); + var pool2 = aGbpEurPool(); + given(poolRepository.findAll()).willReturn(List.of(pool1, pool2)); + + var result = handler.listPools(); + + assertThat(result).hasSize(2); + assertThat(result.get(0)) + .usingRecursiveComparison() + .isEqualTo(pool1); + assertThat(result.get(1)) + .usingRecursiveComparison() + .isEqualTo(pool2); + } + + @Test + void shouldReturnEmptyListWhenNoPools() { + given(poolRepository.findAll()).willReturn(List.of()); + + var result = handler.listPools(); + + assertThat(result).isEmpty(); + } + } + + @Nested + @DisplayName("getPool") + class GetPool { + + @Test + void shouldReturnPoolById() { + var pool = aUsdEurPool(); + var poolId = pool.poolId(); + given(poolRepository.findById(poolId)).willReturn(Optional.of(pool)); + + var result = handler.getPool(poolId); + + assertThat(result) + .usingRecursiveComparison() + .isEqualTo(pool); + } + + @Test + void shouldThrowWhenPoolNotFound() { + var poolId = UUID.randomUUID(); + given(poolRepository.findById(poolId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> handler.getPool(poolId)) + .isInstanceOf(PoolNotFoundException.class) + .hasMessageContaining(poolId.toString()); + } + } + + @Nested + @DisplayName("listCorridors") + class ListCorridors { + + @Test + void shouldReturnSnapshotsWithRates() { + var pool = aUsdEurPool(); + var rate = aUsdEurRate(); + given(poolRepository.findAll()).willReturn(List.of(pool)); + given(rateCache.get("USD", "EUR")).willReturn(Optional.of(rate)); + + var result = handler.listCorridors(); + + assertThat(result).hasSize(1); + var snapshot = result.getFirst(); + assertThat(snapshot.pool()).isEqualTo(pool); + assertThat(snapshot.rate()).isEqualTo(rate); + } + + @Test + void shouldReturnSnapshotWithNullRateWhenCacheMisses() { + var pool = aUsdEurPool(); + given(poolRepository.findAll()).willReturn(List.of(pool)); + given(rateCache.get("USD", "EUR")).willReturn(Optional.empty()); + + var result = handler.listCorridors(); + + assertThat(result).hasSize(1); + var snapshot = result.getFirst(); + assertThat(snapshot.pool()).isEqualTo(pool); + assertThat(snapshot.rate()).isNull(); + } + } +} diff --git a/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/service/MerchantApplicationService.java b/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/service/MerchantApplicationService.java deleted file mode 100644 index feee9263..00000000 --- a/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/service/MerchantApplicationService.java +++ /dev/null @@ -1,285 +0,0 @@ -package com.stablecoin.payments.merchant.onboarding.application.service; - -import com.stablecoin.payments.merchant.onboarding.api.request.ActivateMerchantRequest; -import com.stablecoin.payments.merchant.onboarding.api.request.ApproveCorridorRequest; -import com.stablecoin.payments.merchant.onboarding.api.request.CloseMerchantRequest; -import com.stablecoin.payments.merchant.onboarding.api.request.DocumentUploadRequest; -import com.stablecoin.payments.merchant.onboarding.api.request.MerchantApplicationRequest; -import com.stablecoin.payments.merchant.onboarding.api.request.SuspendMerchantRequest; -import com.stablecoin.payments.merchant.onboarding.api.request.UpdateMerchantRequest; -import com.stablecoin.payments.merchant.onboarding.api.request.UpdateRateLimitTierRequest; -import com.stablecoin.payments.merchant.onboarding.api.response.CorridorResponse; -import com.stablecoin.payments.merchant.onboarding.api.response.DocumentUploadResponse; -import com.stablecoin.payments.merchant.onboarding.api.response.KybStatusResponse; -import com.stablecoin.payments.merchant.onboarding.api.response.MerchantApplicationResponse; -import com.stablecoin.payments.merchant.onboarding.api.response.MerchantResponse; -import com.stablecoin.payments.merchant.onboarding.application.controller.MerchantRequestResponseMapper; -import com.stablecoin.payments.merchant.onboarding.domain.EventPublisher; -import com.stablecoin.payments.merchant.onboarding.domain.exceptions.MerchantAlreadyExistsException; -import com.stablecoin.payments.merchant.onboarding.domain.exceptions.MerchantNotFoundException; -import com.stablecoin.payments.merchant.onboarding.domain.merchant.ApprovedCorridorRepository; -import com.stablecoin.payments.merchant.onboarding.domain.merchant.CorridorEntitlementService; -import com.stablecoin.payments.merchant.onboarding.domain.merchant.DocumentStore; -import com.stablecoin.payments.merchant.onboarding.domain.merchant.KybProvider; -import com.stablecoin.payments.merchant.onboarding.domain.merchant.Merchant; -import com.stablecoin.payments.merchant.onboarding.domain.merchant.MerchantActivationPolicy; -import com.stablecoin.payments.merchant.onboarding.domain.merchant.MerchantRepository; -import com.stablecoin.payments.merchant.onboarding.domain.merchant.model.core.ApprovedCorridor; -import com.stablecoin.payments.merchant.onboarding.domain.merchant.model.core.EntityType; -import com.stablecoin.payments.merchant.onboarding.domain.merchant.model.core.RateLimitTier; -import com.stablecoin.payments.merchant.onboarding.domain.merchant.model.events.MerchantActivatedEvent; -import com.stablecoin.payments.merchant.onboarding.domain.merchant.model.events.MerchantAppliedEvent; -import com.stablecoin.payments.merchant.onboarding.domain.merchant.model.events.MerchantClosedEvent; -import com.stablecoin.payments.merchant.onboarding.domain.merchant.model.events.MerchantCorridorApprovedEvent; -import com.stablecoin.payments.merchant.onboarding.domain.merchant.model.events.MerchantSuspendedEvent; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.slf4j.MDC; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.Instant; -import java.util.UUID; - -@Slf4j -@Service -@RequiredArgsConstructor -public class MerchantApplicationService { - - private final MerchantRepository merchantRepository; - private final KybProvider kybProvider; - private final EventPublisher eventPublisher; - private final MerchantRequestResponseMapper responseMapper; - private final MerchantActivationPolicy activationPolicy; - private final CorridorEntitlementService corridorEntitlementService; - private final DocumentStore documentStore; - private final ApprovedCorridorRepository approvedCorridorRepository; - - @Transactional - public MerchantApplicationResponse apply(MerchantApplicationRequest request) { - if (merchantRepository.existsByRegistrationNumberAndCountry( - request.registrationNumber(), request.registrationCountry())) { - throw MerchantAlreadyExistsException.withRegistration( - request.registrationNumber(), request.registrationCountry()); - } - - var merchant = Merchant.createNew( - request.legalName(), - request.tradingName(), - request.registrationNumber(), - request.registrationCountry(), - EntityType.valueOf(request.entityType()), - request.websiteUrl(), - request.primaryCurrency(), - request.primaryContactEmail(), - request.primaryContactName(), - responseMapper.toBusinessAddress(request.registeredAddress()), - responseMapper.toBeneficialOwners(request.beneficialOwners()), - request.requestedCorridors() - ); - - var saved = merchantRepository.save(merchant); - log.info("Merchant applied merchantId={} legalName={}", saved.getMerchantId(), saved.getLegalName()); - - eventPublisher.publish(MerchantAppliedEvent.builder() - .eventId(UUID.randomUUID().toString()) - .eventType(MerchantAppliedEvent.EVENT_TYPE) - .merchantId(saved.getMerchantId()) - .correlationId(correlationId()) - .legalName(saved.getLegalName()) - .registrationCountry(saved.getRegistrationCountry()) - .entityType(saved.getEntityType().name()) - .appliedAt(saved.getCreatedAt()) - .build()); - - return responseMapper.toApplicationResponse(saved); - } - - @Transactional - public void startKyb(UUID merchantId) { - var merchant = findOrThrow(merchantId); - merchant.startKyb(); - - var kyb = kybProvider.submit( - merchant.getMerchantId(), - merchant.getLegalName(), - merchant.getRegistrationNumber(), - merchant.getRegistrationCountry()); - - merchantRepository.save(merchant); - log.info("KYB started merchantId={} kybId={} providerRef={}", merchantId, kyb.kybId(), kyb.providerRef()); - } - - @Transactional(readOnly = true) - public KybStatusResponse getKybStatus(UUID merchantId) { - var merchant = findOrThrow(merchantId); - var kybResult = kybProvider.getResult("merchant-" + merchantId); - if (kybResult.isPresent()) { - var kyb = kybResult.get(); - return new KybStatusResponse( - kyb.kybId(), kyb.status().name(), kyb.provider(), - kyb.providerRef(), kyb.initiatedAt(), kyb.completedAt(), - kyb.riskSignals()); - } - return new KybStatusResponse( - null, merchant.getKybStatus().name(), null, - null, null, null, null); - } - - @Transactional - public MerchantResponse activate(UUID merchantId, ActivateMerchantRequest request) { - var merchant = findOrThrow(merchantId); - activationPolicy.validate(merchant); - merchant.activate(request.approvedBy(), request.scopes()); - var saved = merchantRepository.save(merchant); - log.info("Merchant activated merchantId={}", merchantId); - - eventPublisher.publish(MerchantActivatedEvent.builder() - .eventId(UUID.randomUUID().toString()) - .eventType(MerchantActivatedEvent.EVENT_TYPE) - .merchantId(saved.getMerchantId()) - .correlationId(correlationId()) - .legalName(saved.getLegalName()) - .riskTier(saved.getRiskTier() != null ? saved.getRiskTier().name() : null) - .rateLimitTier(saved.getRateLimitTier().name()) - .allowedScopes(saved.getAllowedScopes()) - .primaryCurrency(saved.getPrimaryCurrency()) - .activatedAt(saved.getActivatedAt()) - .build()); - - return responseMapper.toMerchantResponse(saved); - } - - @Transactional - public void suspend(UUID merchantId, SuspendMerchantRequest request) { - var merchant = findOrThrow(merchantId); - merchant.suspend(); - merchantRepository.save(merchant); - log.info("Merchant suspended merchantId={} reason={}", merchantId, request.reason()); - - eventPublisher.publish(MerchantSuspendedEvent.builder() - .eventId(UUID.randomUUID().toString()) - .eventType(MerchantSuspendedEvent.EVENT_TYPE) - .merchantId(merchantId) - .correlationId(correlationId()) - .reason(request.reason()) - .suspendedBy(request.suspendedBy()) - .suspendedAt(merchant.getSuspendedAt()) - .build()); - } - - @Transactional - public void reactivate(UUID merchantId) { - var merchant = findOrThrow(merchantId); - merchant.reactivate(); - merchantRepository.save(merchant); - log.info("Merchant reactivated merchantId={}", merchantId); - } - - @Transactional - public void close(UUID merchantId, CloseMerchantRequest request) { - var merchant = findOrThrow(merchantId); - merchant.close(); - merchantRepository.save(merchant); - var reason = request != null ? request.reason() : null; - log.info("Merchant closed merchantId={} reason={}", merchantId, reason); - - eventPublisher.publish(MerchantClosedEvent.builder() - .eventId(UUID.randomUUID().toString()) - .eventType(MerchantClosedEvent.EVENT_TYPE) - .merchantId(merchantId) - .correlationId(correlationId()) - .reason(reason) - .closedBy(request != null ? request.closedBy() : null) - .closedAt(merchant.getClosedAt()) - .build()); - } - - @Transactional - public MerchantResponse updateMerchant(UUID merchantId, UpdateMerchantRequest request) { - var merchant = findOrThrow(merchantId); - var builder = merchant.toBuilder(); - if (request.tradingName() != null) { - builder.tradingName(request.tradingName()); - } - if (request.websiteUrl() != null) { - builder.websiteUrl(request.websiteUrl()); - } - if (request.registeredAddress() != null) { - builder.registeredAddress(responseMapper.toBusinessAddress(request.registeredAddress())); - } - var updated = builder.updatedAt(Instant.now()).build(); - var saved = merchantRepository.save(updated); - log.info("Merchant updated merchantId={}", merchantId); - return responseMapper.toMerchantResponse(saved); - } - - @Transactional - public DocumentUploadResponse uploadDocument(UUID merchantId, DocumentUploadRequest request) { - findOrThrow(merchantId); - var uploadUrl = documentStore.generateUploadUrl(merchantId, request.documentType(), request.fileName()); - log.info("Document upload URL generated merchantId={} documentType={}", merchantId, request.documentType()); - return new DocumentUploadResponse(uploadUrl, Instant.now().plusSeconds(3600)); - } - - @Transactional - public MerchantResponse updateRateLimitTier(UUID merchantId, UpdateRateLimitTierRequest request) { - var merchant = findOrThrow(merchantId); - var newTier = RateLimitTier.valueOf(request.newTier()); - merchant.upgradeRateLimitTier(newTier); - var saved = merchantRepository.save(merchant); - log.info("Rate limit tier updated merchantId={} newTier={}", merchantId, newTier); - return responseMapper.toMerchantResponse(saved); - } - - @Transactional - public CorridorResponse approveCorridor(UUID merchantId, ApproveCorridorRequest request, UUID approvedBy) { - var merchant = findOrThrow(merchantId); - corridorEntitlementService.validate(merchant, request.sourceCountry(), request.targetCountry()); - - var corridor = ApprovedCorridor.builder() - .corridorId(UUID.randomUUID()) - .merchantId(merchantId) - .sourceCountry(request.sourceCountry()) - .targetCountry(request.targetCountry()) - .currencies(request.currencies()) - .maxAmountUsd(request.maxAmountUsd()) - .approvedBy(approvedBy) - .approvedAt(Instant.now()) - .expiresAt(request.expiresAt()) - .isActive(true) - .build(); - - var saved = approvedCorridorRepository.save(corridor); - log.info("Corridor approved merchantId={} corridorId={}", merchantId, saved.corridorId()); - - eventPublisher.publish(MerchantCorridorApprovedEvent.builder() - .eventId(UUID.randomUUID().toString()) - .eventType(MerchantCorridorApprovedEvent.EVENT_TYPE) - .merchantId(merchantId) - .correlationId(correlationId()) - .corridorId(saved.corridorId()) - .sourceCountry(corridor.sourceCountry()) - .targetCountry(corridor.targetCountry()) - .maxAmountUsd(corridor.maxAmountUsd().toPlainString()) - .approvedAt(corridor.approvedAt()) - .build()); - - return responseMapper.toCorridorResponse(saved); - } - - @Transactional(readOnly = true) - public MerchantResponse findById(UUID merchantId) { - return responseMapper.toMerchantResponse(findOrThrow(merchantId)); - } - - private Merchant findOrThrow(UUID merchantId) { - return merchantRepository.findById(merchantId) - .orElseThrow(() -> MerchantNotFoundException.withId(merchantId)); - } - - private String correlationId() { - var id = MDC.get("correlationId"); - return id != null ? id : UUID.randomUUID().toString(); - } -} From dbb95564d0bef90df394e1111438486cc01f368c Mon Sep 17 00:00:00 2001 From: Puneethkumar CK Date: Sat, 11 Apr 2026 17:31:50 +0200 Subject: [PATCH 5/6] refactor(infra): delete orphan ApplicationService classes missed in collapse (STA-249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Code-review follow-up — the initial handler-collapse commit (aa89f6e) added FxQuoteCommandHandler and FxRateLockCommandHandler alongside the old @Service classes instead of replacing them, and left ApiKeyApplicationService in place despite the PR description claiming it was deleted. Net effect before this commit: three @Service-annotated classes were still being instantiated by Spring component scan with no callers, and two duplicate test files (FxQuoteApplicationServiceTest, FxRateLockApplicationServiceTest) still exercised them. Deletes: - api-gateway-iam/.../application/service/ApiKeyApplicationService.java - fx-liquidity-engine/.../application/service/FxQuoteApplicationService.java - fx-liquidity-engine/.../application/service/FxRateLockApplicationService.java - fx-liquidity-engine/.../test/.../application/service/FxQuoteApplicationServiceTest.java - fx-liquidity-engine/.../test/.../application/service/FxRateLockApplicationServiceTest.java Verified: api-gateway-iam + fx-liquidity-engine unit tests BUILD SUCCESSFUL (rerun-tasks). Rule 3 now fully resolved. --- .../service/ApiKeyApplicationService.java | 51 ---- .../service/FxQuoteApplicationService.java | 68 ----- .../service/FxRateLockApplicationService.java | 139 ---------- .../FxQuoteApplicationServiceTest.java | 169 ------------ .../FxRateLockApplicationServiceTest.java | 256 ------------------ 5 files changed, 683 deletions(-) delete mode 100644 api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/service/ApiKeyApplicationService.java delete mode 100644 fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/service/FxQuoteApplicationService.java delete mode 100644 fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/service/FxRateLockApplicationService.java delete mode 100644 fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/FxQuoteApplicationServiceTest.java delete mode 100644 fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/FxRateLockApplicationServiceTest.java diff --git a/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/service/ApiKeyApplicationService.java b/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/service/ApiKeyApplicationService.java deleted file mode 100644 index ce9d6b39..00000000 --- a/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/service/ApiKeyApplicationService.java +++ /dev/null @@ -1,51 +0,0 @@ -package com.stablecoin.payments.gateway.iam.application.service; - -import com.stablecoin.payments.gateway.iam.api.request.CreateApiKeyRequest; -import com.stablecoin.payments.gateway.iam.api.response.ApiKeyResponse; -import com.stablecoin.payments.gateway.iam.domain.model.ApiKeyEnvironment; -import com.stablecoin.payments.gateway.iam.domain.service.ApiKeyService; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.time.Instant; -import java.util.Collections; -import java.util.UUID; - -@Service -@Transactional -@RequiredArgsConstructor -public class ApiKeyApplicationService { - - private final ApiKeyService apiKeyService; - - public ApiKeyResponse createApiKey(CreateApiKeyRequest request) { - var environment = ApiKeyEnvironment.valueOf(request.environment().toUpperCase()); - var expiresAt = request.expiresInSeconds() != null - ? Instant.now().plusSeconds(request.expiresInSeconds()) - : null; - - var result = apiKeyService.create( - request.merchantId(), - request.name(), - environment, - request.scopes() != null ? request.scopes() : Collections.emptyList(), - request.allowedIps() != null ? request.allowedIps() : Collections.emptyList(), - expiresAt); - - return new ApiKeyResponse( - result.apiKey().getKeyId(), - result.rawKey(), - result.apiKey().getKeyPrefix(), - result.apiKey().getName(), - result.apiKey().getEnvironment().name(), - result.apiKey().getScopes(), - result.apiKey().getAllowedIps(), - result.apiKey().getExpiresAt(), - result.apiKey().getCreatedAt()); - } - - public void revokeApiKey(UUID keyId) { - apiKeyService.revoke(keyId); - } -} diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/service/FxQuoteApplicationService.java b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/service/FxQuoteApplicationService.java deleted file mode 100644 index bdb30fb2..00000000 --- a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/service/FxQuoteApplicationService.java +++ /dev/null @@ -1,68 +0,0 @@ -package com.stablecoin.payments.fx.application.service; - -import com.stablecoin.payments.fx.api.request.FxQuoteRequest; -import com.stablecoin.payments.fx.api.response.FxQuoteResponse; -import com.stablecoin.payments.fx.application.mapper.FxResponseMapper; -import com.stablecoin.payments.fx.domain.exception.QuoteNotFoundException; -import com.stablecoin.payments.fx.domain.exception.RateUnavailableException; -import com.stablecoin.payments.fx.domain.port.FxQuoteRepository; -import com.stablecoin.payments.fx.domain.port.RateCache; -import com.stablecoin.payments.fx.domain.port.RateProvider; -import com.stablecoin.payments.fx.domain.service.QuoteService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -@Slf4j -@Service -@RequiredArgsConstructor -public class FxQuoteApplicationService { - - private final RateProvider rateProvider; - private final RateCache rateCache; - private final QuoteService quoteService; - private final FxQuoteRepository quoteRepository; - private final FxResponseMapper responseMapper; - - @Transactional - public FxQuoteResponse getQuote(FxQuoteRequest request) { - log.info("Getting FX quote for {}:{} amount={}", request.fromCurrency(), - request.toCurrency(), request.amount()); - - var corridorRate = rateCache.get(request.fromCurrency(), request.toCurrency()) - .or(() -> { - log.info("Cache miss for {}:{}, fetching from provider", - request.fromCurrency(), request.toCurrency()); - var providerRate = rateProvider.getRate(request.fromCurrency(), request.toCurrency()); - providerRate.ifPresent(rate -> - rateCache.put(request.fromCurrency(), request.toCurrency(), rate)); - return providerRate; - }) - .orElseThrow(() -> { - // Distinguish between unsupported corridor and temporary rate unavailability - log.warn("No rate available for {}:{}", request.fromCurrency(), request.toCurrency()); - return RateUnavailableException.forCorridor( - request.fromCurrency(), request.toCurrency()); - }); - - var quote = quoteService.createQuote( - request.fromCurrency(), request.toCurrency(), - request.amount(), corridorRate); - - var saved = quoteRepository.save(quote); - log.info("Quote created: quoteId={} rate={} expires={}", - saved.quoteId(), saved.rate(), saved.expiresAt()); - - return responseMapper.toResponse(saved); - } - - @Transactional(readOnly = true) - public FxQuoteResponse getQuoteById(UUID quoteId) { - var quote = quoteRepository.findById(quoteId) - .orElseThrow(() -> QuoteNotFoundException.withId(quoteId)); - return responseMapper.toResponse(quote); - } -} diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/service/FxRateLockApplicationService.java b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/service/FxRateLockApplicationService.java deleted file mode 100644 index 1857f930..00000000 --- a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/service/FxRateLockApplicationService.java +++ /dev/null @@ -1,139 +0,0 @@ -package com.stablecoin.payments.fx.application.service; - -import com.stablecoin.payments.fx.api.request.FxRateLockRequest; -import com.stablecoin.payments.fx.api.response.FxRateLockResponse; -import com.stablecoin.payments.fx.application.mapper.FxResponseMapper; -import com.stablecoin.payments.fx.domain.event.FxRateLocked; -import com.stablecoin.payments.fx.domain.exception.InsufficientLiquidityException; -import com.stablecoin.payments.fx.domain.exception.LockNotFoundException; -import com.stablecoin.payments.fx.domain.exception.PoolNotFoundException; -import com.stablecoin.payments.fx.domain.exception.QuoteAlreadyLockedException; -import com.stablecoin.payments.fx.domain.exception.QuoteExpiredException; -import com.stablecoin.payments.fx.domain.exception.QuoteNotFoundException; -import com.stablecoin.payments.fx.domain.model.FxQuote; -import com.stablecoin.payments.fx.domain.model.FxQuoteStatus; -import com.stablecoin.payments.fx.domain.model.FxRateLockStatus; -import com.stablecoin.payments.fx.domain.port.EventPublisher; -import com.stablecoin.payments.fx.domain.port.FxQuoteRepository; -import com.stablecoin.payments.fx.domain.port.FxRateLockRepository; -import com.stablecoin.payments.fx.domain.port.LiquidityPoolRepository; -import com.stablecoin.payments.fx.domain.service.LiquidityService; -import com.stablecoin.payments.fx.domain.service.LockService; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import java.util.UUID; - -@Slf4j -@Service -@RequiredArgsConstructor -public class FxRateLockApplicationService { - - private final FxQuoteRepository quoteRepository; - private final FxRateLockRepository lockRepository; - private final LiquidityPoolRepository poolRepository; - private final LockService lockService; - private final LiquidityService liquidityService; - private final EventPublisher eventPublisher; - private final FxResponseMapper responseMapper; - - public record LockRateResult(FxRateLockResponse response, boolean created) {} - - @Transactional - public LockRateResult lockRate(UUID quoteId, FxRateLockRequest request) { - log.info("Locking rate for quote={} payment={}", quoteId, request.paymentId()); - - var existingLock = lockRepository.findByPaymentId(request.paymentId()); - if (existingLock.isPresent()) { - log.info("Idempotent lock return for payment={} lockId={}", - request.paymentId(), existingLock.get().lockId()); - return new LockRateResult(responseMapper.toResponse(existingLock.get()), false); - } - - var quote = quoteRepository.findById(quoteId) - .orElseThrow(() -> QuoteNotFoundException.withId(quoteId)); - - validateQuote(quote); - - var pool = poolRepository.findByCorridor(quote.fromCurrency(), quote.toCurrency()) - .orElseThrow(() -> PoolNotFoundException.forCorridor( - quote.fromCurrency(), quote.toCurrency())); - - if (!pool.hasSufficientLiquidity(quote.targetAmount())) { - throw InsufficientLiquidityException.forCorridor( - quote.fromCurrency(), quote.toCurrency(), - quote.targetAmount(), pool.availableBalance()); - } - - var lockResult = lockService.lockRate( - quote, request.paymentId(), request.correlationId(), - request.sourceCountry(), request.targetCountry(), pool); - - quoteRepository.save(lockResult.lockedQuote()); - var savedLock = lockRepository.save(lockResult.lock()); - poolRepository.save(lockResult.updatedPool()); - - publishFxRateLockedEvent(savedLock, request.correlationId()); - - log.info("Rate locked: lockId={} rate={} expires={}", - savedLock.lockId(), savedLock.lockedRate(), savedLock.expiresAt()); - - return new LockRateResult(responseMapper.toResponse(savedLock), true); - } - - @Transactional - public void releaseLock(UUID lockId) { - log.info("Releasing lock lockId={}", lockId); - - var lock = lockRepository.findById(lockId) - .orElseThrow(() -> LockNotFoundException.withId(lockId)); - - if (lock.status() != FxRateLockStatus.ACTIVE) { - log.info("Lock {} already in status {}, skipping release", lockId, lock.status()); - return; - } - - var expiredLock = lock.expire(); - lockRepository.save(expiredLock); - - poolRepository.findByCorridor(lock.fromCurrency(), lock.toCurrency()) - .ifPresentOrElse( - pool -> { - var releasedPool = liquidityService.release(pool, lock.targetAmount()); - poolRepository.save(releasedPool); - log.info("Lock {} released, liquidity returned to pool", lockId); - }, - () -> log.warn("Lock {} released but pool not found for {}/{}", - lockId, lock.fromCurrency(), lock.toCurrency())); - } - - private void validateQuote(FxQuote quote) { - if (quote.isExpired()) { - throw QuoteExpiredException.withId(quote.quoteId()); - } - if (quote.status() == FxQuoteStatus.LOCKED) { - throw QuoteAlreadyLockedException.withId(quote.quoteId()); - } - } - - private void publishFxRateLockedEvent(com.stablecoin.payments.fx.domain.model.FxRateLock lock, - UUID correlationId) { - var event = new FxRateLocked( - lock.lockId(), - lock.quoteId(), - lock.paymentId(), - correlationId, - lock.fromCurrency(), - lock.toCurrency(), - lock.sourceAmount(), - lock.targetAmount(), - lock.lockedRate(), - lock.feeBps(), - lock.lockedAt(), - lock.expiresAt() - ); - eventPublisher.publish(event); - } -} diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/FxQuoteApplicationServiceTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/FxQuoteApplicationServiceTest.java deleted file mode 100644 index e529b697..00000000 --- a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/FxQuoteApplicationServiceTest.java +++ /dev/null @@ -1,169 +0,0 @@ -package com.stablecoin.payments.fx.application.service; - -import com.stablecoin.payments.fx.api.request.FxQuoteRequest; -import com.stablecoin.payments.fx.api.response.FxQuoteResponse; -import com.stablecoin.payments.fx.application.mapper.FxResponseMapper; -import com.stablecoin.payments.fx.domain.exception.QuoteNotFoundException; -import com.stablecoin.payments.fx.domain.exception.RateUnavailableException; -import com.stablecoin.payments.fx.domain.port.FxQuoteRepository; -import com.stablecoin.payments.fx.domain.port.RateCache; -import com.stablecoin.payments.fx.domain.port.RateProvider; -import com.stablecoin.payments.fx.domain.service.QuoteService; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -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.Optional; -import java.util.UUID; - -import static com.stablecoin.payments.fx.fixtures.CorridorRateFixtures.aUsdEurRate; -import static com.stablecoin.payments.fx.fixtures.FxQuoteFixtures.anActiveQuote; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.then; - -@ExtendWith(MockitoExtension.class) -@DisplayName("FxQuoteApplicationService") -class FxQuoteApplicationServiceTest { - - @Mock - private RateProvider rateProvider; - - @Mock - private RateCache rateCache; - - @Mock - private QuoteService quoteService; - - @Mock - private FxQuoteRepository quoteRepository; - - @Mock - private FxResponseMapper responseMapper; - - @InjectMocks - private FxQuoteApplicationService service; - - @Nested - @DisplayName("getQuote") - class GetQuote { - - @Test - void shouldCreateQuoteFromCachedRate() { - // given - var request = new FxQuoteRequest("USD", "EUR", new BigDecimal("10000.00")); - var corridorRate = aUsdEurRate(); - var quote = anActiveQuote(); - var expectedResponse = new FxQuoteResponse( - quote.quoteId(), quote.fromCurrency(), quote.toCurrency(), - quote.sourceAmount(), quote.targetAmount(), quote.rate(), quote.inverseRate(), - quote.feeBps(), quote.feeAmount(), quote.provider(), - quote.createdAt(), quote.expiresAt()); - - given(rateCache.get("USD", "EUR")).willReturn(Optional.of(corridorRate)); - given(quoteService.createQuote("USD", "EUR", request.amount(), corridorRate)) - .willReturn(quote); - given(quoteRepository.save(quote)).willReturn(quote); - given(responseMapper.toResponse(quote)).willReturn(expectedResponse); - - // when - var result = service.getQuote(request); - - // then - assertThat(result) - .usingRecursiveComparison() - .isEqualTo(expectedResponse); - - then(rateProvider).shouldHaveNoInteractions(); - } - - @Test - void shouldCreateQuoteFromProviderWhenCacheMisses() { - // given - var request = new FxQuoteRequest("USD", "EUR", new BigDecimal("10000.00")); - var corridorRate = aUsdEurRate(); - var quote = anActiveQuote(); - var expectedResponse = new FxQuoteResponse( - quote.quoteId(), quote.fromCurrency(), quote.toCurrency(), - quote.sourceAmount(), quote.targetAmount(), quote.rate(), quote.inverseRate(), - quote.feeBps(), quote.feeAmount(), quote.provider(), - quote.createdAt(), quote.expiresAt()); - - given(rateCache.get("USD", "EUR")).willReturn(Optional.empty()); - given(rateProvider.getRate("USD", "EUR")).willReturn(Optional.of(corridorRate)); - given(quoteService.createQuote("USD", "EUR", request.amount(), corridorRate)) - .willReturn(quote); - given(quoteRepository.save(quote)).willReturn(quote); - given(responseMapper.toResponse(quote)).willReturn(expectedResponse); - - // when - var result = service.getQuote(request); - - // then - assertThat(result) - .usingRecursiveComparison() - .isEqualTo(expectedResponse); - - then(rateCache).should().put("USD", "EUR", corridorRate); - } - - @Test - void shouldThrowWhenNoRateAvailable() { - // given - var request = new FxQuoteRequest("USD", "EUR", new BigDecimal("10000.00")); - given(rateCache.get("USD", "EUR")).willReturn(Optional.empty()); - given(rateProvider.getRate("USD", "EUR")).willReturn(Optional.empty()); - - // when/then - assertThatThrownBy(() -> service.getQuote(request)) - .isInstanceOf(RateUnavailableException.class) - .hasMessageContaining("USD:EUR"); - } - } - - @Nested - @DisplayName("getQuoteById") - class GetQuoteById { - - @Test - void shouldReturnQuoteById() { - // given - var quoteId = UUID.randomUUID(); - var quote = anActiveQuote(); - var expectedResponse = new FxQuoteResponse( - quote.quoteId(), quote.fromCurrency(), quote.toCurrency(), - quote.sourceAmount(), quote.targetAmount(), quote.rate(), quote.inverseRate(), - quote.feeBps(), quote.feeAmount(), quote.provider(), - quote.createdAt(), quote.expiresAt()); - - given(quoteRepository.findById(quoteId)).willReturn(Optional.of(quote)); - given(responseMapper.toResponse(quote)).willReturn(expectedResponse); - - // when - var result = service.getQuoteById(quoteId); - - // then - assertThat(result) - .usingRecursiveComparison() - .isEqualTo(expectedResponse); - } - - @Test - void shouldThrowWhenQuoteNotFound() { - // given - var quoteId = UUID.randomUUID(); - given(quoteRepository.findById(quoteId)).willReturn(Optional.empty()); - - // when/then - assertThatThrownBy(() -> service.getQuoteById(quoteId)) - .isInstanceOf(QuoteNotFoundException.class) - .hasMessageContaining(quoteId.toString()); - } - } -} diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/FxRateLockApplicationServiceTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/FxRateLockApplicationServiceTest.java deleted file mode 100644 index 0025791f..00000000 --- a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/FxRateLockApplicationServiceTest.java +++ /dev/null @@ -1,256 +0,0 @@ -package com.stablecoin.payments.fx.application.service; - -import com.stablecoin.payments.fx.api.request.FxRateLockRequest; -import com.stablecoin.payments.fx.api.response.FxRateLockResponse; -import com.stablecoin.payments.fx.application.mapper.FxResponseMapper; -import com.stablecoin.payments.fx.domain.event.FxRateLocked; -import com.stablecoin.payments.fx.domain.exception.InsufficientLiquidityException; -import com.stablecoin.payments.fx.domain.exception.PoolNotFoundException; -import com.stablecoin.payments.fx.domain.exception.QuoteAlreadyLockedException; -import com.stablecoin.payments.fx.domain.exception.QuoteExpiredException; -import com.stablecoin.payments.fx.domain.exception.QuoteNotFoundException; -import com.stablecoin.payments.fx.domain.port.EventPublisher; -import com.stablecoin.payments.fx.domain.port.FxQuoteRepository; -import com.stablecoin.payments.fx.domain.port.FxRateLockRepository; -import com.stablecoin.payments.fx.domain.port.LiquidityPoolRepository; -import com.stablecoin.payments.fx.domain.service.LockService; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -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.Optional; -import java.util.UUID; - -import static com.stablecoin.payments.fx.fixtures.FxQuoteFixtures.aLockedQuote; -import static com.stablecoin.payments.fx.fixtures.FxQuoteFixtures.anActiveQuote; -import static com.stablecoin.payments.fx.fixtures.FxQuoteFixtures.anExpiredQuote; -import static com.stablecoin.payments.fx.fixtures.FxRateLockFixtures.CORRELATION_ID; -import static com.stablecoin.payments.fx.fixtures.FxRateLockFixtures.PAYMENT_ID; -import static com.stablecoin.payments.fx.fixtures.FxRateLockFixtures.SOURCE_COUNTRY; -import static com.stablecoin.payments.fx.fixtures.FxRateLockFixtures.TARGET_COUNTRY; -import static com.stablecoin.payments.fx.fixtures.FxRateLockFixtures.anActiveLock; -import static com.stablecoin.payments.fx.fixtures.LiquidityPoolFixtures.aPoolWithLowBalance; -import static com.stablecoin.payments.fx.fixtures.LiquidityPoolFixtures.aUsdEurPool; -import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.BDDMockito.given; -import static org.mockito.BDDMockito.then; - -@ExtendWith(MockitoExtension.class) -@DisplayName("FxRateLockApplicationService") -class FxRateLockApplicationServiceTest { - - @Mock - private FxQuoteRepository quoteRepository; - - @Mock - private FxRateLockRepository lockRepository; - - @Mock - private LiquidityPoolRepository poolRepository; - - @Mock - private LockService lockService; - - @Mock - private EventPublisher eventPublisher; - - @Mock - private FxResponseMapper responseMapper; - - @InjectMocks - private FxRateLockApplicationService service; - - @Nested - @DisplayName("lockRate") - class LockRate { - - @Test - void shouldLockRateSuccessfully() { - // given - var quote = anActiveQuote(); - var quoteId = quote.quoteId(); - var pool = aUsdEurPool(); - var request = new FxRateLockRequest(PAYMENT_ID, CORRELATION_ID, - SOURCE_COUNTRY, TARGET_COUNTRY); - - var lockedQuote = aLockedQuote(); - var lock = anActiveLock(quoteId, PAYMENT_ID); - var updatedPool = aUsdEurPool(); - var lockResult = new LockService.LockResult(lockedQuote, lock, updatedPool); - - var expectedResponse = new FxRateLockResponse( - lock.lockId(), lock.quoteId(), lock.paymentId(), - lock.fromCurrency(), lock.toCurrency(), - lock.sourceAmount(), lock.targetAmount(), lock.lockedRate(), - lock.feeBps(), lock.feeAmount(), lock.lockedAt(), lock.expiresAt()); - - given(lockRepository.findByPaymentId(PAYMENT_ID)).willReturn(Optional.empty()); - given(quoteRepository.findById(quoteId)).willReturn(Optional.of(quote)); - given(poolRepository.findByCorridor("USD", "EUR")).willReturn(Optional.of(pool)); - given(lockService.lockRate(quote, PAYMENT_ID, CORRELATION_ID, - SOURCE_COUNTRY, TARGET_COUNTRY, pool)).willReturn(lockResult); - given(quoteRepository.save(lockedQuote)).willReturn(lockedQuote); - given(lockRepository.save(lock)).willReturn(lock); - given(poolRepository.save(updatedPool)).willReturn(updatedPool); - given(responseMapper.toResponse(lock)).willReturn(expectedResponse); - - // when - var result = service.lockRate(quoteId, request); - - // then - var expected = new FxRateLockApplicationService.LockRateResult(expectedResponse, true); - assertThat(result) - .usingRecursiveComparison() - .isEqualTo(expected); - - var expectedEvent = new FxRateLocked( - lock.lockId(), lock.quoteId(), lock.paymentId(), CORRELATION_ID, - lock.fromCurrency(), lock.toCurrency(), - lock.sourceAmount(), lock.targetAmount(), lock.lockedRate(), - lock.feeBps(), lock.lockedAt(), lock.expiresAt()); - then(eventPublisher).should().publish(eqIgnoring(expectedEvent)); - } - - @Test - void shouldReturnExistingLockForSamePaymentId() { - // given - var quoteId = UUID.randomUUID(); - var existingLock = anActiveLock(quoteId, PAYMENT_ID); - var request = new FxRateLockRequest(PAYMENT_ID, CORRELATION_ID, - SOURCE_COUNTRY, TARGET_COUNTRY); - - var expectedResponse = new FxRateLockResponse( - existingLock.lockId(), existingLock.quoteId(), existingLock.paymentId(), - existingLock.fromCurrency(), existingLock.toCurrency(), - existingLock.sourceAmount(), existingLock.targetAmount(), - existingLock.lockedRate(), existingLock.feeBps(), existingLock.feeAmount(), - existingLock.lockedAt(), existingLock.expiresAt()); - - given(lockRepository.findByPaymentId(PAYMENT_ID)).willReturn(Optional.of(existingLock)); - given(responseMapper.toResponse(existingLock)).willReturn(expectedResponse); - - // when - var result = service.lockRate(quoteId, request); - - // then - var expected = new FxRateLockApplicationService.LockRateResult(expectedResponse, false); - assertThat(result) - .usingRecursiveComparison() - .isEqualTo(expected); - - then(quoteRepository).shouldHaveNoInteractions(); - then(lockService).shouldHaveNoInteractions(); - then(lockRepository).should().findByPaymentId(PAYMENT_ID); - then(lockRepository).shouldHaveNoMoreInteractions(); - then(eventPublisher).shouldHaveNoInteractions(); - } - - @Test - void shouldThrowWhenQuoteNotFound() { - // given - var quoteId = UUID.randomUUID(); - var request = new FxRateLockRequest(PAYMENT_ID, CORRELATION_ID, - SOURCE_COUNTRY, TARGET_COUNTRY); - given(lockRepository.findByPaymentId(PAYMENT_ID)).willReturn(Optional.empty()); - given(quoteRepository.findById(quoteId)).willReturn(Optional.empty()); - - // when/then - assertThatThrownBy(() -> service.lockRate(quoteId, request)) - .isInstanceOf(QuoteNotFoundException.class) - .hasMessageContaining(quoteId.toString()); - - then(lockRepository).should().findByPaymentId(PAYMENT_ID); - then(lockRepository).shouldHaveNoMoreInteractions(); - then(eventPublisher).shouldHaveNoInteractions(); - } - - @Test - void shouldThrowWhenQuoteExpired() { - // given - var expiredQuote = anExpiredQuote(); - var quoteId = expiredQuote.quoteId(); - var request = new FxRateLockRequest(PAYMENT_ID, CORRELATION_ID, - SOURCE_COUNTRY, TARGET_COUNTRY); - given(lockRepository.findByPaymentId(PAYMENT_ID)).willReturn(Optional.empty()); - given(quoteRepository.findById(quoteId)).willReturn(Optional.of(expiredQuote)); - - // when/then - assertThatThrownBy(() -> service.lockRate(quoteId, request)) - .isInstanceOf(QuoteExpiredException.class) - .hasMessageContaining(quoteId.toString()); - - then(lockRepository).should().findByPaymentId(PAYMENT_ID); - then(lockRepository).shouldHaveNoMoreInteractions(); - then(eventPublisher).shouldHaveNoInteractions(); - } - - @Test - void shouldThrowWhenQuoteAlreadyLocked() { - // given - var lockedQuote = aLockedQuote(); - var quoteId = lockedQuote.quoteId(); - var request = new FxRateLockRequest(PAYMENT_ID, CORRELATION_ID, - SOURCE_COUNTRY, TARGET_COUNTRY); - given(lockRepository.findByPaymentId(PAYMENT_ID)).willReturn(Optional.empty()); - given(quoteRepository.findById(quoteId)).willReturn(Optional.of(lockedQuote)); - - // when/then - assertThatThrownBy(() -> service.lockRate(quoteId, request)) - .isInstanceOf(QuoteAlreadyLockedException.class) - .hasMessageContaining(quoteId.toString()); - - then(lockRepository).should().findByPaymentId(PAYMENT_ID); - then(lockRepository).shouldHaveNoMoreInteractions(); - then(eventPublisher).shouldHaveNoInteractions(); - } - - @Test - void shouldThrowWhenPoolNotFound() { - // given - var quote = anActiveQuote(); - var quoteId = quote.quoteId(); - var request = new FxRateLockRequest(PAYMENT_ID, CORRELATION_ID, - SOURCE_COUNTRY, TARGET_COUNTRY); - given(lockRepository.findByPaymentId(PAYMENT_ID)).willReturn(Optional.empty()); - given(quoteRepository.findById(quoteId)).willReturn(Optional.of(quote)); - given(poolRepository.findByCorridor("USD", "EUR")).willReturn(Optional.empty()); - - // when/then - assertThatThrownBy(() -> service.lockRate(quoteId, request)) - .isInstanceOf(PoolNotFoundException.class) - .hasMessageContaining("USD:EUR"); - - then(lockRepository).should().findByPaymentId(PAYMENT_ID); - then(lockRepository).shouldHaveNoMoreInteractions(); - then(eventPublisher).shouldHaveNoInteractions(); - } - - @Test - void shouldThrowWhenInsufficientLiquidity() { - // given - var quote = anActiveQuote(); - var quoteId = quote.quoteId(); - var lowPool = aPoolWithLowBalance(); - var request = new FxRateLockRequest(PAYMENT_ID, CORRELATION_ID, - SOURCE_COUNTRY, TARGET_COUNTRY); - given(lockRepository.findByPaymentId(PAYMENT_ID)).willReturn(Optional.empty()); - given(quoteRepository.findById(quoteId)).willReturn(Optional.of(quote)); - given(poolRepository.findByCorridor("USD", "EUR")).willReturn(Optional.of(lowPool)); - - // when/then - assertThatThrownBy(() -> service.lockRate(quoteId, request)) - .isInstanceOf(InsufficientLiquidityException.class) - .hasMessageContaining("USD:EUR"); - - then(lockRepository).should().findByPaymentId(PAYMENT_ID); - then(lockRepository).shouldHaveNoMoreInteractions(); - then(eventPublisher).shouldHaveNoInteractions(); - } - } -} From a61a1847be6314e8053e3f4953bbbcdd901bddb6 Mon Sep 17 00:00:00 2001 From: Puneethkumar CK Date: Sat, 11 Apr 2026 18:52:19 +0200 Subject: [PATCH 6/6] test(infra): address CodeRabbit review feedback on PR #270 (STA-249) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 12 fixes addressing CodeRabbit review comments: Production: - merchant-iam: delete unused countActiveUsersByRoleId stub from RoleRepository + adapter (role deletion validates in-memory via MerchantTeam.deleteCustomRole). Tests: - MerchantCommandHandlerTest: replace hardcoded "raw-secret" literal with UUID.randomUUID() reused between stub and expected event. - ComplianceCheckCommandHandlerTest: use Mockito InOrder over all six providers/repo/publisher to enforce cross-mock invocation sequence (shouldInvokeAllProvidersInOrder). - PaymentEventConsumerTest: assert shouldHaveNoInteractions() on eventPublisher and poolRepository for all early-return branches (idempotentConsumed, noLockFound, idempotentExpired, noLockFoundFailed). - FxQuoteControllerTest: new @Nested ReleaseLock with success (204) + LockNotFoundException propagation. - AuditLogFilterTest: extract shared expectedAuditEntry(...) helper; add missing then(auditLogRepository).should().save(...) assertion in shouldNotFailWhenRepositoryThrows so exception-handling path is actually exercised. - FxRateLockCommandHandlerTest: new @Nested ReleaseLock with 4 tests — LockNotFoundException, skip-when-already-expired, happy path with liquidity return, pool-missing graceful handling. - MerchantTeamServiceTest: replace argThat(Instant i -> i != null) with ArgumentCaptor + isAfter(now) semantic check; replace publish(argThat( Objects::nonNull)) with eqIgnoring on a fully-built expected event. - KybWebhookControllerTest: add then(workflowClient).shouldHaveNoInteractions() to invalid-signature test. - PaymentCommandHandlerTest: strengthen idempotency-replay assertion — verify findByIdempotencyKey was called + shouldHaveNoMoreInteractions(). - PaymentControllerTest: extract private stubInitiatePayment helper to DRY up repeated given(commandHandler.initiatePayment(...)) blocks. - LedgerOutboxEventPublisherTest: switch from eqIgnoring to ArgumentCaptor so completedAt/detectedAt Instants are actually asserted (TestUtils.eqIgnoring type-ignores all Instants by design — not suitable for timestamp verification). Not fixed, documented in PR reply: - JournalCommandHandler NPE hardening: CLAUDE.md forbids validation for scenarios that cannot happen; BalanceUpdate key is guaranteed by BalanceCalculator contract. - PaymentWorkflowImpl hard-coded wallet/chain: pre-existing sandbox placeholder from STA-243, out of scope for STA-249 cleanup. - LockService duplicate liquidity check: pre-existing duplication inherited from deleted FxRateLockApplicationService; requires touching LockService which is out of scope. Follow-up ticket. - CorridorSnapshot extraction to top-level: style preference. Inner record is the minimal surface area for an internal projection. Verified: ./gradlew test -x :phase2-integration-tests:test -x :phase3-integration-tests:test BUILD SUCCESSFUL (128 tasks). --- .../security/AuditLogFilterTest.java | 66 ++++++---------- .../service/MerchantCommandHandlerTest.java | 5 +- .../ComplianceCheckCommandHandlerTest.java | 16 ++-- .../controller/FxQuoteControllerTest.java | 30 ++++++++ .../service/FxRateLockCommandHandlerTest.java | 76 +++++++++++++++++++ .../messaging/PaymentEventConsumerTest.java | 5 ++ .../LedgerOutboxEventPublisherTest.java | 30 ++++++-- .../iam/domain/team/RoleRepository.java | 2 - .../adapter/RoleRepositoryAdapter.java | 5 -- .../domain/team/MerchantTeamServiceTest.java | 22 +++++- .../controller/KybWebhookControllerTest.java | 1 + .../controller/PaymentControllerTest.java | 40 ++++------ .../service/PaymentCommandHandlerTest.java | 4 +- 13 files changed, 210 insertions(+), 92 deletions(-) diff --git a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/security/AuditLogFilterTest.java b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/security/AuditLogFilterTest.java index 0b43e52d..7627f5ef 100644 --- a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/security/AuditLogFilterTest.java +++ b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/application/security/AuditLogFilterTest.java @@ -66,6 +66,21 @@ void shouldSkipAuditWhenNotAuthenticated() throws ServletException, IOException then(auditLogRepository).shouldHaveNoInteractions(); } + private static AuditLogEntry expectedAuditEntry( + UUID merchantId, String action, String resource, String sourceIp, + int statusCode, String authMethod, UUID clientId) { + return AuditLogEntry.builder() + .merchantId(merchantId) + .action(action) + .resource(resource) + .sourceIp(sourceIp) + .detail(Map.of( + "status_code", statusCode, + "auth_method", authMethod, + "client_id", clientId.toString())) + .build(); + } + @Nested class WhenAuthenticated { @@ -85,16 +100,8 @@ void shouldPersistAuditLogEntry() throws ServletException, IOException { filter.doFilterInternal(request, response, filterChain); - var expectedEntry = AuditLogEntry.builder() - .merchantId(merchantId) - .action("POST") - .resource("/v1/payments") - .sourceIp("10.0.0.1") - .detail(Map.of( - "status_code", 201, - "auth_method", "API_KEY", - "client_id", clientId.toString())) - .build(); + var expectedEntry = expectedAuditEntry( + merchantId, "POST", "/v1/payments", "10.0.0.1", 201, "API_KEY", clientId); then(auditLogRepository).should().save(eqIgnoring(expectedEntry, "logId", "occurredAt")); } @@ -106,37 +113,22 @@ void shouldRecordJwtAuthMethod() throws ServletException, IOException { filter.doFilterInternal(request, response, filterChain); - var expectedEntry = AuditLogEntry.builder() - .merchantId(merchantId) - .action("POST") - .resource("/v1/payments") - .sourceIp("10.0.0.1") - .detail(Map.of( - "status_code", 200, - "auth_method", "JWT", - "client_id", clientId.toString())) - .build(); + var expectedEntry = expectedAuditEntry( + merchantId, "POST", "/v1/payments", "10.0.0.1", 200, "JWT", clientId); then(auditLogRepository).should().save(eqIgnoring(expectedEntry, "logId", "occurredAt")); } @Test void shouldNotFailWhenRepositoryThrows() throws ServletException, IOException { - var throwingEntry = AuditLogEntry.builder() - .merchantId(merchantId) - .action("POST") - .resource("/v1/payments") - .sourceIp("10.0.0.1") - .detail(Map.of( - "status_code", 200, - "auth_method", "API_KEY", - "client_id", clientId.toString())) - .build(); + var expectedEntry = expectedAuditEntry( + merchantId, "POST", "/v1/payments", "10.0.0.1", 200, "API_KEY", clientId); willThrow(new RuntimeException("DB down")) - .given(auditLogRepository).save(eqIgnoring(throwingEntry, "logId", "occurredAt")); + .given(auditLogRepository).save(eqIgnoring(expectedEntry, "logId", "occurredAt")); filter.doFilterInternal(request, response, filterChain); then(filterChain).should().doFilter(request, response); + then(auditLogRepository).should().save(eqIgnoring(expectedEntry, "logId", "occurredAt")); } @Test @@ -147,16 +139,8 @@ void shouldAuditEvenWhenFilterChainThrows() throws ServletException, IOException assertThatThrownBy(() -> filter.doFilterInternal(request, response, filterChain)) .isInstanceOf(ServletException.class); - var expectedEntry = AuditLogEntry.builder() - .merchantId(merchantId) - .action("POST") - .resource("/v1/payments") - .sourceIp("10.0.0.1") - .detail(Map.of( - "status_code", 200, - "auth_method", "API_KEY", - "client_id", clientId.toString())) - .build(); + var expectedEntry = expectedAuditEntry( + merchantId, "POST", "/v1/payments", "10.0.0.1", 200, "API_KEY", clientId); then(auditLogRepository).should().save(eqIgnoring(expectedEntry, "logId", "occurredAt")); } } diff --git a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/MerchantCommandHandlerTest.java b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/MerchantCommandHandlerTest.java index fee95e79..93879b53 100644 --- a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/MerchantCommandHandlerTest.java +++ b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/MerchantCommandHandlerTest.java @@ -260,6 +260,7 @@ void shouldActivateAndProvisionDefaultClient() { var externalId = UUID.randomUUID(); var merchantId = UUID.randomUUID(); var clientId = UUID.randomUUID(); + var rawSecret = UUID.randomUUID().toString(); var merchant = Merchant.builder() .merchantId(merchantId) .externalId(externalId) @@ -299,7 +300,7 @@ void shouldActivateAndProvisionDefaultClient() { given(oauthClientCommandHandler.create( merchantId, "Acme Corp Default Client", List.of("payments:read"), List.of("client_credentials"))) - .willReturn(new OAuthClientCommandHandler.CreateOAuthClientResult(oauthClient, "raw-secret")); + .willReturn(new OAuthClientCommandHandler.CreateOAuthClientResult(oauthClient, rawSecret)); merchantCommandHandler.activateAndProvisionOAuthClient( externalId, "Acme Corp", "US", List.of("payments:read")); @@ -308,7 +309,7 @@ void shouldActivateAndProvisionDefaultClient() { merchantId, "Acme Corp Default Client", List.of("payments:read"), List.of("client_credentials")); var expectedEvent = new OAuthClientProvisionedEvent( - clientId, merchantId, "raw-secret", "Acme Corp Default Client", + clientId, merchantId, rawSecret, "Acme Corp Default Client", List.of("payments:read"), List.of("client_credentials"), createdAt); then(eventPublisher).should().publish(expectedEvent); } diff --git a/compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/domain/service/ComplianceCheckCommandHandlerTest.java b/compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/domain/service/ComplianceCheckCommandHandlerTest.java index 3a2624a8..d891504a 100644 --- a/compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/domain/service/ComplianceCheckCommandHandlerTest.java +++ b/compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/domain/service/ComplianceCheckCommandHandlerTest.java @@ -25,6 +25,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -46,6 +47,7 @@ import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.times; @ExtendWith(MockitoExtension.class) @@ -233,12 +235,14 @@ void shouldInvokeAllProvidersInOrder() { handler.initiateCheck( paymentId, senderId, recipientId, ABOVE_THRESHOLD, "US", "DE", "EUR"); - then(kycProvider).should().verify(senderId, recipientId); - then(sanctionsProvider).should().screen(senderId, recipientId); - then(amlProvider).should().analyze(senderId, recipientId); - then(travelRuleProvider).should().transmit(argThat((TravelRulePackage p) -> p != null)); - then(checkRepository).should().save(argThat((ComplianceCheck c) -> c != null)); - then(eventPublisher).should().publish(argThat(o -> o instanceof ComplianceCheckPassed)); + InOrder inOrder = inOrder( + kycProvider, sanctionsProvider, amlProvider, travelRuleProvider, checkRepository, eventPublisher); + inOrder.verify(kycProvider).verify(senderId, recipientId); + inOrder.verify(sanctionsProvider).screen(senderId, recipientId); + inOrder.verify(amlProvider).analyze(senderId, recipientId); + inOrder.verify(travelRuleProvider).transmit(argThat((TravelRulePackage p) -> p != null)); + inOrder.verify(checkRepository).save(argThat((ComplianceCheck c) -> c != null)); + inOrder.verify(eventPublisher).publish(argThat(o -> o instanceof ComplianceCheckPassed)); } } diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/controller/FxQuoteControllerTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/controller/FxQuoteControllerTest.java index bbb8ab0a..a78dc1e5 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/controller/FxQuoteControllerTest.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/controller/FxQuoteControllerTest.java @@ -5,6 +5,7 @@ import com.stablecoin.payments.fx.api.response.FxQuoteResponse; import com.stablecoin.payments.fx.api.response.FxRateLockResponse; import com.stablecoin.payments.fx.application.mapper.FxResponseMapper; +import com.stablecoin.payments.fx.domain.exception.LockNotFoundException; import com.stablecoin.payments.fx.domain.exception.QuoteAlreadyLockedException; import com.stablecoin.payments.fx.domain.exception.QuoteExpiredException; import com.stablecoin.payments.fx.domain.exception.QuoteNotFoundException; @@ -35,6 +36,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; @ExtendWith(MockitoExtension.class) @DisplayName("FxQuoteController") @@ -243,4 +245,32 @@ void shouldPropagateQuoteAlreadyLocked() { .hasMessageContaining(quoteId.toString()); } } + + @Nested + @DisplayName("DELETE /v1/fx/lock/{lockId}") + class ReleaseLock { + + @Test + @DisplayName("should return 204 No Content when release succeeds") + void shouldReturnNoContentWhenReleaseSucceeds() { + var lockId = UUID.randomUUID(); + + var result = controller.releaseLock(lockId); + + assertThat(result.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + then(rateLockCommandHandler).should().releaseLock(lockId); + } + + @Test + @DisplayName("should propagate LockNotFoundException") + void shouldPropagateExceptionWhenLockNotFound() { + var lockId = UUID.randomUUID(); + willThrow(LockNotFoundException.withId(lockId)) + .given(rateLockCommandHandler).releaseLock(lockId); + + assertThatThrownBy(() -> controller.releaseLock(lockId)) + .isInstanceOf(LockNotFoundException.class) + .hasMessageContaining(lockId.toString()); + } + } } diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/domain/service/FxRateLockCommandHandlerTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/domain/service/FxRateLockCommandHandlerTest.java index ec29ea8e..0c5f32e2 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/domain/service/FxRateLockCommandHandlerTest.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/domain/service/FxRateLockCommandHandlerTest.java @@ -2,6 +2,7 @@ import com.stablecoin.payments.fx.domain.event.FxRateLocked; import com.stablecoin.payments.fx.domain.exception.InsufficientLiquidityException; +import com.stablecoin.payments.fx.domain.exception.LockNotFoundException; import com.stablecoin.payments.fx.domain.exception.PoolNotFoundException; import com.stablecoin.payments.fx.domain.exception.QuoteAlreadyLockedException; import com.stablecoin.payments.fx.domain.exception.QuoteExpiredException; @@ -211,4 +212,79 @@ void shouldThrowWhenInsufficientLiquidity() { then(eventPublisher).shouldHaveNoInteractions(); } } + + @Nested + @DisplayName("releaseLock") + class ReleaseLock { + + @Test + void shouldThrowWhenLockNotFound() { + var lockId = UUID.randomUUID(); + given(lockRepository.findById(lockId)).willReturn(Optional.empty()); + + assertThatThrownBy(() -> handler.releaseLock(lockId)) + .isInstanceOf(LockNotFoundException.class) + .hasMessageContaining(lockId.toString()); + + then(lockRepository).should().findById(lockId); + then(lockRepository).shouldHaveNoMoreInteractions(); + then(poolRepository).shouldHaveNoInteractions(); + then(liquidityService).shouldHaveNoInteractions(); + then(eventPublisher).shouldHaveNoInteractions(); + } + + @Test + void shouldSkipReleaseWhenLockIsNotActive() { + var activeLock = anActiveLock(UUID.randomUUID()); + var expiredLock = activeLock.expire(); + var lockId = expiredLock.lockId(); + given(lockRepository.findById(lockId)).willReturn(Optional.of(expiredLock)); + + handler.releaseLock(lockId); + + then(lockRepository).should().findById(lockId); + then(lockRepository).shouldHaveNoMoreInteractions(); + then(poolRepository).shouldHaveNoInteractions(); + then(liquidityService).shouldHaveNoInteractions(); + then(eventPublisher).shouldHaveNoInteractions(); + } + + @Test + void shouldReleaseActiveLockAndReturnLiquidityToPool() { + var activeLock = anActiveLock(UUID.randomUUID()); + var lockId = activeLock.lockId(); + var pool = aUsdEurPool(); + var releasedPool = aUsdEurPool(); + + given(lockRepository.findById(lockId)).willReturn(Optional.of(activeLock)); + given(poolRepository.findByCorridor("USD", "EUR")).willReturn(Optional.of(pool)); + given(liquidityService.release(pool, activeLock.targetAmount())).willReturn(releasedPool); + + handler.releaseLock(lockId); + + var expectedExpiredLock = activeLock.expire(); + then(lockRepository).should().save(eqIgnoring(expectedExpiredLock)); + then(poolRepository).should().save(releasedPool); + then(liquidityService).should().release(pool, activeLock.targetAmount()); + then(eventPublisher).shouldHaveNoInteractions(); + } + + @Test + void shouldReleaseActiveLockButLogWarnWhenPoolMissing() { + var activeLock = anActiveLock(UUID.randomUUID()); + var lockId = activeLock.lockId(); + + given(lockRepository.findById(lockId)).willReturn(Optional.of(activeLock)); + given(poolRepository.findByCorridor("USD", "EUR")).willReturn(Optional.empty()); + + handler.releaseLock(lockId); + + var expectedExpiredLock = activeLock.expire(); + then(lockRepository).should().save(eqIgnoring(expectedExpiredLock)); + then(poolRepository).should().findByCorridor("USD", "EUR"); + then(poolRepository).shouldHaveNoMoreInteractions(); + then(liquidityService).shouldHaveNoInteractions(); + then(eventPublisher).shouldHaveNoInteractions(); + } + } } diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/infrastructure/messaging/PaymentEventConsumerTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/infrastructure/messaging/PaymentEventConsumerTest.java index 42bd5170..b9de8a23 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/infrastructure/messaging/PaymentEventConsumerTest.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/infrastructure/messaging/PaymentEventConsumerTest.java @@ -143,6 +143,7 @@ void idempotentConsumed() { then(lockRepository).should().findByPaymentId(paymentId); then(lockRepository).shouldHaveNoMoreInteractions(); then(poolRepository).shouldHaveNoInteractions(); + then(eventPublisher).shouldHaveNoInteractions(); } @Test @@ -156,6 +157,7 @@ void noLockFound() { then(lockRepository).should().findByPaymentId(paymentId); then(lockRepository).shouldHaveNoMoreInteractions(); then(poolRepository).shouldHaveNoInteractions(); + then(eventPublisher).shouldHaveNoInteractions(); } } @@ -213,6 +215,7 @@ void idempotentExpired() { then(lockRepository).should().findByPaymentId(paymentId); then(lockRepository).shouldHaveNoMoreInteractions(); then(poolRepository).shouldHaveNoInteractions(); + then(eventPublisher).shouldHaveNoInteractions(); } @Test @@ -225,6 +228,8 @@ void noLockFoundFailed() { then(lockRepository).should().findByPaymentId(paymentId); then(lockRepository).shouldHaveNoMoreInteractions(); + then(poolRepository).shouldHaveNoInteractions(); + then(eventPublisher).shouldHaveNoInteractions(); } } } diff --git a/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/infrastructure/messaging/LedgerOutboxEventPublisherTest.java b/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/infrastructure/messaging/LedgerOutboxEventPublisherTest.java index 7e6f99cc..aaf8efb1 100644 --- a/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/infrastructure/messaging/LedgerOutboxEventPublisherTest.java +++ b/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/infrastructure/messaging/LedgerOutboxEventPublisherTest.java @@ -10,13 +10,13 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import java.math.BigDecimal; import java.time.Instant; import java.util.UUID; -import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; -import static org.mockito.ArgumentMatchers.argThat; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; @@ -47,13 +47,22 @@ void schedulesOutboxEvent() { publisher.publishReconciliationCompleted(domainEvent); + var eventCaptor = ArgumentCaptor.forClass(ReconciliationCompletedEvent.class); + var keyCaptor = ArgumentCaptor.forClass(String.class); + then(outbox).should().schedule(eventCaptor.capture(), keyCaptor.capture()); + var expectedEvent = new ReconciliationCompletedEvent( ReconciliationCompletedEvent.SCHEMA_VERSION, null, ReconciliationCompletedEvent.EVENT_TYPE, REC_ID, PAYMENT_ID, "RECONCILED", NOW); - then(outbox).should().schedule(eqIgnoring(expectedEvent, "eventId"), - argThat((String s) -> PAYMENT_ID.toString().equals(s))); + assertThat(eventCaptor.getValue()) + .usingRecursiveComparison() + .ignoringFields("eventId") + .isEqualTo(expectedEvent); + assertThat(eventCaptor.getValue().completedAt()).isEqualTo(NOW); + assertThat(eventCaptor.getValue().eventId()).isNotNull(); + assertThat(keyCaptor.getValue()).isEqualTo(PAYMENT_ID.toString()); } } @@ -70,14 +79,23 @@ REC_ID, PAYMENT_ID, new BigDecimal("1.50"), "USDC", publisher.publishReconciliationDiscrepancy(domainEvent); + var eventCaptor = ArgumentCaptor.forClass(ReconciliationDiscrepancyEvent.class); + var keyCaptor = ArgumentCaptor.forClass(String.class); + then(outbox).should().schedule(eventCaptor.capture(), keyCaptor.capture()); + var expectedEvent = new ReconciliationDiscrepancyEvent( ReconciliationDiscrepancyEvent.SCHEMA_VERSION, null, ReconciliationDiscrepancyEvent.EVENT_TYPE, REC_ID, PAYMENT_ID, new BigDecimal("1.50"), "USDC", "Stablecoin discrepancy", NOW); - then(outbox).should().schedule(eqIgnoring(expectedEvent, "eventId"), - argThat((String s) -> PAYMENT_ID.toString().equals(s))); + assertThat(eventCaptor.getValue()) + .usingRecursiveComparison() + .ignoringFields("eventId") + .isEqualTo(expectedEvent); + assertThat(eventCaptor.getValue().detectedAt()).isEqualTo(NOW); + assertThat(eventCaptor.getValue().eventId()).isNotNull(); + assertThat(keyCaptor.getValue()).isEqualTo(PAYMENT_ID.toString()); } } } diff --git a/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/domain/team/RoleRepository.java b/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/domain/team/RoleRepository.java index 6bbcd9b1..376408a6 100644 --- a/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/domain/team/RoleRepository.java +++ b/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/domain/team/RoleRepository.java @@ -14,8 +14,6 @@ public interface RoleRepository { List findByMerchantId(UUID merchantId); - long countActiveUsersByRoleId(UUID roleId); - Role save(Role role); List saveAll(List roles); diff --git a/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/infrastructure/persistence/adapter/RoleRepositoryAdapter.java b/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/infrastructure/persistence/adapter/RoleRepositoryAdapter.java index 7d460181..c2bc80ce 100644 --- a/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/infrastructure/persistence/adapter/RoleRepositoryAdapter.java +++ b/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/infrastructure/persistence/adapter/RoleRepositoryAdapter.java @@ -41,11 +41,6 @@ public List findByMerchantId(UUID merchantId) { .toList(); } - @Override - public long countActiveUsersByRoleId(UUID roleId) { - return 0L; - } - @Override @Transactional public Role save(Role role) { diff --git a/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/MerchantTeamServiceTest.java b/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/MerchantTeamServiceTest.java index f1ef3e46..6ba735bc 100644 --- a/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/MerchantTeamServiceTest.java +++ b/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/MerchantTeamServiceTest.java @@ -12,10 +12,12 @@ import com.stablecoin.payments.merchant.iam.domain.team.model.core.InvitationStatus; import com.stablecoin.payments.merchant.iam.domain.team.model.core.Permission; import com.stablecoin.payments.merchant.iam.domain.team.model.core.UserStatus; +import com.stablecoin.payments.merchant.iam.domain.team.model.events.MerchantUserInvitedEvent; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -23,7 +25,6 @@ import java.time.Instant; import java.util.ArrayList; import java.util.List; -import java.util.Objects; import java.util.Optional; import java.util.UUID; @@ -130,13 +131,24 @@ void invites_user_saves_and_sends_email() { then(userRepository).should().save(eqIgnoring(expectedUser, "userId")); then(invitationRepository).should().save(eqIgnoring(expectedInvitation, "invitationId")); + var expiresAtCaptor = ArgumentCaptor.forClass(Instant.class); then(emailSenderProvider).should().sendInvitationEmail( argThat((String s) -> "new@test.com".equals(s)), argThat((String s) -> "New User".equals(s)), argThat((String s) -> "ACME Corp".equals(s)), argThat((String s) -> "plain-token".equals(s)), - argThat((Instant i) -> i != null)); - then(eventPublisher).should().publish(argThat(Objects::nonNull)); + expiresAtCaptor.capture()); + assertThat(expiresAtCaptor.getValue()).isAfter(Instant.now()); + var expectedEvent = MerchantUserInvitedEvent.builder() + .schemaVersion(MerchantUserInvitedEvent.SCHEMA_VERSION) + .eventType(MerchantUserInvitedEvent.EVENT_TYPE) + .merchantId(MERCHANT_ID) + .emailHash("new-hash") + .roleId(VIEWER_ROLE_ID) + .roleName(viewerRole.roleName()) + .invitedBy(adminUser.userId()) + .build(); + then(eventPublisher).should().publish(eqIgnoring(expectedEvent, "eventId", "invitationId", "userId")); } @Test @@ -455,12 +467,14 @@ void should_save_invitation_record_for_first_admin() { then(invitationRepository).should().save(eqIgnoring(expectedInvitation, "invitationId", "roleId", "invitedBy")); + var expiresAtCaptor = ArgumentCaptor.forClass(Instant.class); then(emailSenderProvider).should().sendInvitationEmail( argThat((String s) -> "admin@test.com".equals(s)), argThat((String s) -> "Admin User".equals(s)), argThat((String s) -> "ACME Corp".equals(s)), argThat((String s) -> "invite-token".equals(s)), - argThat((Instant i) -> i != null)); + expiresAtCaptor.capture()); + assertThat(expiresAtCaptor.getValue()).isAfter(Instant.now()); } } } diff --git a/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/application/controller/KybWebhookControllerTest.java b/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/application/controller/KybWebhookControllerTest.java index b923943a..8f7c173e 100644 --- a/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/application/controller/KybWebhookControllerTest.java +++ b/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/application/controller/KybWebhookControllerTest.java @@ -50,6 +50,7 @@ void shouldReturn401WhenSignatureInvalid() { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); then(kybProvider).shouldHaveNoInteractions(); + then(workflowClient).shouldHaveNoInteractions(); } @Test diff --git a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/application/controller/PaymentControllerTest.java b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/application/controller/PaymentControllerTest.java index 47e2dfd7..3287adeb 100644 --- a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/application/controller/PaymentControllerTest.java +++ b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/application/controller/PaymentControllerTest.java @@ -41,6 +41,20 @@ class PaymentControllerTest { @InjectMocks private PaymentController controller; + private void stubInitiatePayment(PaymentCommandHandler.InitiateResult result) { + given(commandHandler.initiatePayment( + argThat((String k) -> IDEMPOTENCY_KEY.equals(k)), + argThat((UUID id) -> id != null), + argThat((UUID id) -> SENDER_ID.equals(id)), + argThat((UUID id) -> RECIPIENT_ID.equals(id)), + argThat(amt -> SOURCE_AMOUNT_VALUE.compareTo(amt) == 0), + argThat((String c) -> SOURCE_CURRENCY.equals(c)), + argThat((String c) -> TARGET_CURRENCY.equals(c)), + argThat((String c) -> SOURCE_COUNTRY.equals(c)), + argThat((String c) -> TARGET_COUNTRY.equals(c)))) + .willReturn(result); + } + @Nested @DisplayName("POST /v1/payments") class InitiatePayment { @@ -55,18 +69,7 @@ void shouldReturn201ForNewPayment() { SOURCE_COUNTRY, TARGET_COUNTRY ); var initiateResult = anInitiateResult(); - - given(commandHandler.initiatePayment( - argThat((String k) -> IDEMPOTENCY_KEY.equals(k)), - argThat((UUID id) -> id != null), - argThat((UUID id) -> SENDER_ID.equals(id)), - argThat((UUID id) -> RECIPIENT_ID.equals(id)), - argThat(amt -> SOURCE_AMOUNT_VALUE.compareTo(amt) == 0), - argThat((String c) -> SOURCE_CURRENCY.equals(c)), - argThat((String c) -> TARGET_CURRENCY.equals(c)), - argThat((String c) -> SOURCE_COUNTRY.equals(c)), - argThat((String c) -> TARGET_COUNTRY.equals(c)))) - .willReturn(initiateResult); + stubInitiatePayment(initiateResult); // when var response = controller.initiatePayment(IDEMPOTENCY_KEY, request); @@ -90,18 +93,7 @@ void shouldReturn200ForReplay() { SOURCE_COUNTRY, TARGET_COUNTRY ); var replayResult = anIdempotentReplayResult(); - - given(commandHandler.initiatePayment( - argThat((String k) -> IDEMPOTENCY_KEY.equals(k)), - argThat((UUID id) -> id != null), - argThat((UUID id) -> SENDER_ID.equals(id)), - argThat((UUID id) -> RECIPIENT_ID.equals(id)), - argThat(amt -> SOURCE_AMOUNT_VALUE.compareTo(amt) == 0), - argThat((String c) -> SOURCE_CURRENCY.equals(c)), - argThat((String c) -> TARGET_CURRENCY.equals(c)), - argThat((String c) -> SOURCE_COUNTRY.equals(c)), - argThat((String c) -> TARGET_COUNTRY.equals(c)))) - .willReturn(replayResult); + stubInitiatePayment(replayResult); // when var response = controller.initiatePayment(IDEMPOTENCY_KEY, request); diff --git a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/service/PaymentCommandHandlerTest.java b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/service/PaymentCommandHandlerTest.java index 021439b1..e20ff6e6 100644 --- a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/service/PaymentCommandHandlerTest.java +++ b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/service/PaymentCommandHandlerTest.java @@ -45,7 +45,6 @@ import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; @ExtendWith(MockitoExtension.class) @DisplayName("PaymentCommandHandler") @@ -142,7 +141,8 @@ void shouldReturnExistingOnReplay() { .usingRecursiveComparison() .isEqualTo(existingPayment); - then(paymentRepository).should(never()).save(existingPayment); + then(paymentRepository).should().findByIdempotencyKey(existingPayment.idempotencyKey()); + then(paymentRepository).shouldHaveNoMoreInteractions(); then(eventPublisher).shouldHaveNoInteractions(); then(workflowClient).shouldHaveNoInteractions(); }