diff --git a/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/ChainConfigTest.java b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/ChainConfigTest.java new file mode 100644 index 00000000..cb339d9e --- /dev/null +++ b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/ChainConfigTest.java @@ -0,0 +1,166 @@ +package com.stablecoin.payments.custody.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("ChainConfig") +class ChainConfigTest { + + private static final ChainId BASE = new ChainId("base"); + private static final List RPC_ENDPOINTS = List.of("https://rpc.base.org", "https://rpc2.base.org"); + + @Nested + @DisplayName("Valid Construction") + class ValidConstruction { + + @Test + @DisplayName("creates chain config with all fields") + void createsChainConfig() { + var result = new ChainConfig( + BASE, 12, 2, "ETH", RPC_ENDPOINTS, "https://basescan.org" + ); + + var expected = new ChainConfig( + BASE, 12, 2, "ETH", RPC_ENDPOINTS, "https://basescan.org" + ); + assertThat(result).usingRecursiveComparison().isEqualTo(expected); + } + + @Test + @DisplayName("accepts zero minConfirmations") + void acceptsZeroConfirmations() { + var config = new ChainConfig( + BASE, 0, 2, "ETH", RPC_ENDPOINTS, "https://basescan.org" + ); + + assertThat(config.minConfirmations()).isZero(); + } + + @Test + @DisplayName("accepts zero avgFinalitySeconds") + void acceptsZeroFinality() { + var config = new ChainConfig( + BASE, 12, 0, "ETH", RPC_ENDPOINTS, "https://basescan.org" + ); + + assertThat(config.avgFinalitySeconds()).isZero(); + } + + @Test + @DisplayName("accepts null explorerUrl") + void acceptsNullExplorerUrl() { + var config = new ChainConfig( + BASE, 12, 2, "ETH", RPC_ENDPOINTS, null + ); + + assertThat(config.explorerUrl()).isNull(); + } + + @Test + @DisplayName("accepts single RPC endpoint") + void acceptsSingleEndpoint() { + var config = new ChainConfig( + BASE, 12, 2, "ETH", List.of("https://rpc.base.org"), null + ); + + assertThat(config.rpcEndpoints()).hasSize(1); + } + } + + @Nested + @DisplayName("Validation") + class Validation { + + @Test + @DisplayName("rejects null chainId") + void rejectsNullChainId() { + assertThatThrownBy(() -> new ChainConfig( + null, 12, 2, "ETH", RPC_ENDPOINTS, null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Chain ID is required"); + } + + @Test + @DisplayName("rejects negative minConfirmations") + void rejectsNegativeConfirmations() { + assertThatThrownBy(() -> new ChainConfig( + BASE, -1, 2, "ETH", RPC_ENDPOINTS, null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Min confirmations must be non-negative"); + } + + @Test + @DisplayName("rejects negative avgFinalitySeconds") + void rejectsNegativeFinality() { + assertThatThrownBy(() -> new ChainConfig( + BASE, 12, -1, "ETH", RPC_ENDPOINTS, null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Avg finality seconds must be non-negative"); + } + + @Test + @DisplayName("rejects null nativeToken") + void rejectsNullNativeToken() { + assertThatThrownBy(() -> new ChainConfig( + BASE, 12, 2, null, RPC_ENDPOINTS, null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Native token is required"); + } + + @Test + @DisplayName("rejects blank nativeToken") + void rejectsBlankNativeToken() { + assertThatThrownBy(() -> new ChainConfig( + BASE, 12, 2, " ", RPC_ENDPOINTS, null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Native token is required"); + } + + @Test + @DisplayName("rejects null rpcEndpoints") + void rejectsNullEndpoints() { + assertThatThrownBy(() -> new ChainConfig( + BASE, 12, 2, "ETH", null, null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("At least one RPC endpoint is required"); + } + + @Test + @DisplayName("rejects empty rpcEndpoints") + void rejectsEmptyEndpoints() { + assertThatThrownBy(() -> new ChainConfig( + BASE, 12, 2, "ETH", List.of(), null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("At least one RPC endpoint is required"); + } + } + + @Nested + @DisplayName("Defensive Copy") + class DefensiveCopy { + + @Test + @DisplayName("rpcEndpoints are defensively copied") + void endpointsDefensivelyCopied() { + var mutableList = new ArrayList<>(List.of("https://rpc.base.org")); + var config = new ChainConfig(BASE, 12, 2, "ETH", mutableList, null); + + assertThatThrownBy(() -> config.rpcEndpoints().add("https://evil.com")) + .isInstanceOf(UnsupportedOperationException.class); + } + } +} diff --git a/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/ChainIdTest.java b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/ChainIdTest.java new file mode 100644 index 00000000..79a4ebe1 --- /dev/null +++ b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/ChainIdTest.java @@ -0,0 +1,123 @@ +package com.stablecoin.payments.custody.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("ChainId") +class ChainIdTest { + + @Nested + @DisplayName("Valid Chains") + class ValidChains { + + @Test + @DisplayName("accepts 'ethereum'") + void acceptsEthereum() { + var chainId = new ChainId("ethereum"); + assertThat(chainId.value()).isEqualTo("ethereum"); + } + + @Test + @DisplayName("accepts 'solana'") + void acceptsSolana() { + var chainId = new ChainId("solana"); + assertThat(chainId.value()).isEqualTo("solana"); + } + + @Test + @DisplayName("accepts 'stellar'") + void acceptsStellar() { + var chainId = new ChainId("stellar"); + assertThat(chainId.value()).isEqualTo("stellar"); + } + + @Test + @DisplayName("accepts 'tron'") + void acceptsTron() { + var chainId = new ChainId("tron"); + assertThat(chainId.value()).isEqualTo("tron"); + } + + @Test + @DisplayName("accepts 'base'") + void acceptsBase() { + var chainId = new ChainId("base"); + assertThat(chainId.value()).isEqualTo("base"); + } + + @Test + @DisplayName("accepts 'polygon'") + void acceptsPolygon() { + var chainId = new ChainId("polygon"); + assertThat(chainId.value()).isEqualTo("polygon"); + } + + @Test + @DisplayName("accepts 'avalanche'") + void acceptsAvalanche() { + var chainId = new ChainId("avalanche"); + assertThat(chainId.value()).isEqualTo("avalanche"); + } + } + + @Nested + @DisplayName("Invalid Values") + class InvalidValues { + + @Test + @DisplayName("rejects null value") + void rejectsNull() { + assertThatThrownBy(() -> new ChainId(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Chain ID value is required"); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "\t"}) + @DisplayName("rejects blank values") + void rejectsBlank(String value) { + assertThatThrownBy(() -> new ChainId(value)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Chain ID value is required"); + } + + @ParameterizedTest + @ValueSource(strings = {"bitcoin", "cardano", "ripple", "ETHEREUM", "Base", "SOLANA"}) + @DisplayName("rejects unsupported chains") + void rejectsUnsupported(String value) { + assertThatThrownBy(() -> new ChainId(value)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unsupported chain"); + } + } + + @Nested + @DisplayName("Equality") + class Equality { + + @Test + @DisplayName("two ChainId with same value are equal") + void equalWhenSameValue() { + var a = new ChainId("base"); + var b = new ChainId("base"); + + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + @DisplayName("two ChainId with different values are not equal") + void notEqualWhenDifferentValue() { + var a = new ChainId("base"); + var b = new ChainId("ethereum"); + + assertThat(a).isNotEqualTo(b); + } + } +} diff --git a/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/ChainTransferTest.java b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/ChainTransferTest.java new file mode 100644 index 00000000..67efe2f3 --- /dev/null +++ b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/ChainTransferTest.java @@ -0,0 +1,889 @@ +package com.stablecoin.payments.custody.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.math.BigDecimal; +import java.util.stream.Stream; + +import static com.stablecoin.payments.custody.domain.model.TransferStatus.CHAIN_SELECTED; +import static com.stablecoin.payments.custody.domain.model.TransferStatus.CONFIRMED; +import static com.stablecoin.payments.custody.domain.model.TransferStatus.CONFIRMING; +import static com.stablecoin.payments.custody.domain.model.TransferStatus.FAILED; +import static com.stablecoin.payments.custody.domain.model.TransferStatus.PENDING; +import static com.stablecoin.payments.custody.domain.model.TransferStatus.RESUBMITTING; +import static com.stablecoin.payments.custody.domain.model.TransferStatus.SIGNING; +import static com.stablecoin.payments.custody.domain.model.TransferStatus.SUBMITTED; +import static com.stablecoin.payments.custody.domain.model.TransferTrigger.CONFIRM; +import static com.stablecoin.payments.custody.domain.model.TransferTrigger.CONFIRM_SUBMISSION; +import static com.stablecoin.payments.custody.domain.model.TransferTrigger.FAIL; +import static com.stablecoin.payments.custody.domain.model.TransferTrigger.RESUBMIT; +import static com.stablecoin.payments.custody.domain.model.TransferTrigger.SELECT_CHAIN; +import static com.stablecoin.payments.custody.domain.model.TransferTrigger.START_CONFIRMING; +import static com.stablecoin.payments.custody.domain.model.TransferTrigger.START_SIGNING; +import static com.stablecoin.payments.custody.domain.model.TransferTrigger.SUBMIT; +import static com.stablecoin.payments.custody.domain.model.TransferType.FORWARD; +import static com.stablecoin.payments.custody.domain.model.TransferType.RETURN; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.AMOUNT; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.CHAIN_BASE; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.CORRELATION_ID; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.FROM_ADDRESS; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.FROM_WALLET_ID; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.PARENT_TRANSFER_ID; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.PAYMENT_ID; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.TO_ADDRESS; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.TX_HASH; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.TX_HASH_RESUBMIT; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.USDC; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.aChainSelectedTransfer; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.aConfirmedTransfer; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.aConfirmingTransfer; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.aFailedTransfer; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.aPendingReturnTransfer; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.aPendingTransfer; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.aResubmittingTransfer; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.aSigningTransfer; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.aSubmittedTransfer; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("ChainTransfer") +class ChainTransferTest { + + @Nested + @DisplayName("Factory Method") + class FactoryMethod { + + @Test + @DisplayName("creates PENDING FORWARD transfer with correct fields") + void createsPendingForwardTransfer() { + var result = aPendingTransfer(); + + var expected = ChainTransfer.initiate( + PAYMENT_ID, CORRELATION_ID, FORWARD, null, + USDC, AMOUNT, FROM_WALLET_ID, TO_ADDRESS, FROM_ADDRESS + ); + assertThat(result) + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFields("transferId", "createdAt", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("creates RETURN transfer with parentTransferId") + void createsReturnTransferWithParent() { + var result = aPendingReturnTransfer(); + + var expected = ChainTransfer.initiate( + PAYMENT_ID, CORRELATION_ID, RETURN, PARENT_TRANSFER_ID, + USDC, AMOUNT, FROM_WALLET_ID, TO_ADDRESS, FROM_ADDRESS + ); + assertThat(result) + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFields("transferId", "createdAt", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("FORWARD transfer allows null parentTransferId") + void forwardTransferAllowsNullParent() { + var result = ChainTransfer.initiate( + PAYMENT_ID, CORRELATION_ID, FORWARD, null, + USDC, AMOUNT, FROM_WALLET_ID, TO_ADDRESS, FROM_ADDRESS + ); + + assertThat(result.parentTransferId()).isNull(); + } + + @Test + @DisplayName("rejects null paymentId") + void rejectsNullPaymentId() { + assertThatThrownBy(() -> ChainTransfer.initiate( + null, CORRELATION_ID, FORWARD, null, + USDC, AMOUNT, FROM_WALLET_ID, TO_ADDRESS, FROM_ADDRESS + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("paymentId is required"); + } + + @Test + @DisplayName("rejects null correlationId") + void rejectsNullCorrelationId() { + assertThatThrownBy(() -> ChainTransfer.initiate( + PAYMENT_ID, null, FORWARD, null, + USDC, AMOUNT, FROM_WALLET_ID, TO_ADDRESS, FROM_ADDRESS + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("correlationId is required"); + } + + @Test + @DisplayName("rejects null transferType") + void rejectsNullTransferType() { + assertThatThrownBy(() -> ChainTransfer.initiate( + PAYMENT_ID, CORRELATION_ID, null, null, + USDC, AMOUNT, FROM_WALLET_ID, TO_ADDRESS, FROM_ADDRESS + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("transferType is required"); + } + + @Test + @DisplayName("rejects null stablecoin") + void rejectsNullStablecoin() { + assertThatThrownBy(() -> ChainTransfer.initiate( + PAYMENT_ID, CORRELATION_ID, FORWARD, null, + null, AMOUNT, FROM_WALLET_ID, TO_ADDRESS, FROM_ADDRESS + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("stablecoin is required"); + } + + @Test + @DisplayName("rejects null amount") + void rejectsNullAmount() { + assertThatThrownBy(() -> ChainTransfer.initiate( + PAYMENT_ID, CORRELATION_ID, FORWARD, null, + USDC, null, FROM_WALLET_ID, TO_ADDRESS, FROM_ADDRESS + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("amount must be positive"); + } + + @Test + @DisplayName("rejects zero amount") + void rejectsZeroAmount() { + assertThatThrownBy(() -> ChainTransfer.initiate( + PAYMENT_ID, CORRELATION_ID, FORWARD, null, + USDC, BigDecimal.ZERO, FROM_WALLET_ID, TO_ADDRESS, FROM_ADDRESS + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("amount must be positive"); + } + + @Test + @DisplayName("rejects negative amount") + void rejectsNegativeAmount() { + assertThatThrownBy(() -> ChainTransfer.initiate( + PAYMENT_ID, CORRELATION_ID, FORWARD, null, + USDC, new BigDecimal("-10.00"), FROM_WALLET_ID, TO_ADDRESS, FROM_ADDRESS + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("amount must be positive"); + } + + @Test + @DisplayName("rejects null fromWalletId") + void rejectsNullFromWalletId() { + assertThatThrownBy(() -> ChainTransfer.initiate( + PAYMENT_ID, CORRELATION_ID, FORWARD, null, + USDC, AMOUNT, null, TO_ADDRESS, FROM_ADDRESS + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("fromWalletId is required"); + } + + @Test + @DisplayName("rejects null toWalletAddress") + void rejectsNullToWalletAddress() { + assertThatThrownBy(() -> ChainTransfer.initiate( + PAYMENT_ID, CORRELATION_ID, FORWARD, null, + USDC, AMOUNT, FROM_WALLET_ID, null, FROM_ADDRESS + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("toWalletAddress is required"); + } + + @Test + @DisplayName("rejects blank toWalletAddress") + void rejectsBlankToWalletAddress() { + assertThatThrownBy(() -> ChainTransfer.initiate( + PAYMENT_ID, CORRELATION_ID, FORWARD, null, + USDC, AMOUNT, FROM_WALLET_ID, " ", FROM_ADDRESS + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("toWalletAddress is required"); + } + + @Test + @DisplayName("rejects null fromAddress") + void rejectsNullFromAddress() { + assertThatThrownBy(() -> ChainTransfer.initiate( + PAYMENT_ID, CORRELATION_ID, FORWARD, null, + USDC, AMOUNT, FROM_WALLET_ID, TO_ADDRESS, null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("fromAddress is required"); + } + + @Test + @DisplayName("rejects blank fromAddress") + void rejectsBlankFromAddress() { + assertThatThrownBy(() -> ChainTransfer.initiate( + PAYMENT_ID, CORRELATION_ID, FORWARD, null, + USDC, AMOUNT, FROM_WALLET_ID, TO_ADDRESS, " " + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("fromAddress is required"); + } + } + + @Nested + @DisplayName("Happy Path Transitions") + class HappyPathTransitions { + + @Test + @DisplayName("PENDING -> CHAIN_SELECTED via selectChain()") + void pendingToChainSelected() { + var pending = aPendingTransfer(); + + var result = pending.selectChain(CHAIN_BASE); + + var expected = aPendingTransfer().selectChain(CHAIN_BASE); + assertThat(result) + .usingRecursiveComparison() + .ignoringFields("transferId", "createdAt", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("CHAIN_SELECTED -> SIGNING via startSigning()") + void chainSelectedToSigning() { + var chainSelected = aChainSelectedTransfer(); + + var result = chainSelected.startSigning(42L); + + var expected = aChainSelectedTransfer().startSigning(42L); + assertThat(result) + .usingRecursiveComparison() + .ignoringFields("transferId", "createdAt", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("SIGNING -> SUBMITTED via submit() — attemptCount increments to 1") + void signingToSubmitted() { + var signing = aSigningTransfer(); + + var result = signing.submit(TX_HASH); + + var expected = aSigningTransfer().submit(TX_HASH); + assertThat(result) + .usingRecursiveComparison() + .ignoringFields("transferId", "createdAt", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("SUBMITTED -> CONFIRMING via startConfirming()") + void submittedToConfirming() { + var submitted = aSubmittedTransfer(); + + var result = submitted.startConfirming(); + + var expected = aSubmittedTransfer().startConfirming(); + assertThat(result) + .usingRecursiveComparison() + .ignoringFields("transferId", "createdAt", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("CONFIRMING -> CONFIRMED via confirm()") + void confirmingToConfirmed() { + var confirming = aConfirmingTransfer(); + var gasUsed = new BigDecimal("0.002100"); + var gasPriceGwei = new BigDecimal("25.5"); + + var result = confirming.confirm(12345L, 15, gasUsed, gasPriceGwei); + + var expected = aConfirmingTransfer().confirm(12345L, 15, gasUsed, gasPriceGwei); + assertThat(result) + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFields("transferId", "createdAt", "updatedAt", "blockConfirmedAt") + .isEqualTo(expected); + } + } + + @Nested + @DisplayName("Resubmission Path") + class ResubmissionPath { + + @Test + @DisplayName("SUBMITTED -> RESUBMITTING via markForResubmission()") + void submittedToResubmitting() { + var submitted = aSubmittedTransfer(); + + var result = submitted.markForResubmission(); + + var expected = aSubmittedTransfer().markForResubmission(); + assertThat(result) + .usingRecursiveComparison() + .ignoringFields("transferId", "createdAt", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("RESUBMITTING -> SUBMITTED via resubmit() — attemptCount increments") + void resubmittingToSubmitted() { + var resubmitting = aResubmittingTransfer(); + + var result = resubmitting.resubmit(TX_HASH_RESUBMIT); + + var expected = aResubmittingTransfer().resubmit(TX_HASH_RESUBMIT); + assertThat(result) + .usingRecursiveComparison() + .ignoringFields("transferId", "createdAt", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("full resubmission cycle: SUBMITTED -> RESUBMITTING -> SUBMITTED increments attemptCount twice") + void fullResubmissionCycleIncrementsAttemptCount() { + var resubmitted = aSubmittedTransfer() + .markForResubmission() + .resubmit(TX_HASH_RESUBMIT); + + assertThat(resubmitted.attemptCount()).isEqualTo(2); + } + + @Test + @DisplayName("resubmit() rejects null txHash") + void resubmitRejectsNullTxHash() { + var resubmitting = aResubmittingTransfer(); + + assertThatThrownBy(() -> resubmitting.resubmit(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Transaction hash is required for resubmission"); + } + + @Test + @DisplayName("resubmit() rejects blank txHash") + void resubmitRejectsBlankTxHash() { + var resubmitting = aResubmittingTransfer(); + + assertThatThrownBy(() -> resubmitting.resubmit(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Transaction hash is required for resubmission"); + } + } + + @Nested + @DisplayName("Failure Transitions") + class FailureTransitions { + + @Test + @DisplayName("CHAIN_SELECTED -> FAILED via fail()") + void chainSelectedToFailed() { + var transfer = aChainSelectedTransfer(); + + var result = transfer.fail("Chain unavailable", "CHAIN_DOWN"); + + var expected = aChainSelectedTransfer().fail("Chain unavailable", "CHAIN_DOWN"); + assertThat(result) + .usingRecursiveComparison() + .ignoringFields("transferId", "createdAt", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("SIGNING -> FAILED via fail()") + void signingToFailed() { + var transfer = aSigningTransfer(); + + var result = transfer.fail("Key not found", "SIGNING_ERROR"); + + var expected = aSigningTransfer().fail("Key not found", "SIGNING_ERROR"); + assertThat(result) + .usingRecursiveComparison() + .ignoringFields("transferId", "createdAt", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("SUBMITTED -> FAILED via fail()") + void submittedToFailed() { + var transfer = aSubmittedTransfer(); + + var result = transfer.fail("Transaction reverted", "REVERTED"); + + var expected = aSubmittedTransfer().fail("Transaction reverted", "REVERTED"); + assertThat(result) + .usingRecursiveComparison() + .ignoringFields("transferId", "createdAt", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("RESUBMITTING -> FAILED via fail()") + void resubmittingToFailed() { + var transfer = aResubmittingTransfer(); + + var result = transfer.fail("Max retries exceeded", "MAX_RETRIES"); + + var expected = aResubmittingTransfer().fail("Max retries exceeded", "MAX_RETRIES"); + assertThat(result) + .usingRecursiveComparison() + .ignoringFields("transferId", "createdAt", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("CONFIRMING -> FAILED via fail()") + void confirmingToFailed() { + var transfer = aConfirmingTransfer(); + + var result = transfer.fail("Chain reorg", "REORG"); + + var expected = aConfirmingTransfer().fail("Chain reorg", "REORG"); + assertThat(result) + .usingRecursiveComparison() + .ignoringFields("transferId", "createdAt", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("fail() allows null reason and code") + void failAllowsNullReasonAndCode() { + var transfer = aChainSelectedTransfer(); + + var result = transfer.fail(null, null); + + var expected = aChainSelectedTransfer().fail(null, null); + assertThat(result) + .usingRecursiveComparison() + .ignoringFields("transferId", "createdAt", "updatedAt") + .isEqualTo(expected); + } + } + + @Nested + @DisplayName("Terminal State Guard") + class TerminalStateGuard { + + @Test + @DisplayName("CONFIRMED rejects selectChain()") + void confirmedRejectsSelectChain() { + var confirmed = aConfirmedTransfer(); + + assertThatThrownBy(() -> confirmed.selectChain(CHAIN_BASE)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("terminal state"); + } + + @Test + @DisplayName("CONFIRMED rejects startSigning()") + void confirmedRejectsStartSigning() { + var confirmed = aConfirmedTransfer(); + + assertThatThrownBy(() -> confirmed.startSigning(1L)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("terminal state"); + } + + @Test + @DisplayName("CONFIRMED rejects submit()") + void confirmedRejectsSubmit() { + var confirmed = aConfirmedTransfer(); + + assertThatThrownBy(() -> confirmed.submit(TX_HASH)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("terminal state"); + } + + @Test + @DisplayName("CONFIRMED rejects startConfirming()") + void confirmedRejectsStartConfirming() { + var confirmed = aConfirmedTransfer(); + + assertThatThrownBy(() -> confirmed.startConfirming()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("terminal state"); + } + + @Test + @DisplayName("CONFIRMED rejects confirm()") + void confirmedRejectsConfirm() { + var confirmed = aConfirmedTransfer(); + + assertThatThrownBy(() -> confirmed.confirm(999L, 20, BigDecimal.ONE, BigDecimal.ONE)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("terminal state"); + } + + @Test + @DisplayName("CONFIRMED rejects markForResubmission()") + void confirmedRejectsMarkForResubmission() { + var confirmed = aConfirmedTransfer(); + + assertThatThrownBy(() -> confirmed.markForResubmission()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("terminal state"); + } + + @Test + @DisplayName("CONFIRMED rejects resubmit()") + void confirmedRejectsResubmit() { + var confirmed = aConfirmedTransfer(); + + assertThatThrownBy(() -> confirmed.resubmit(TX_HASH_RESUBMIT)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("terminal state"); + } + + @Test + @DisplayName("FAILED rejects selectChain()") + void failedRejectsSelectChain() { + var failed = aFailedTransfer(); + + assertThatThrownBy(() -> failed.selectChain(CHAIN_BASE)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("terminal state"); + } + + @Test + @DisplayName("FAILED rejects startSigning()") + void failedRejectsStartSigning() { + var failed = aFailedTransfer(); + + assertThatThrownBy(() -> failed.startSigning(1L)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("terminal state"); + } + + @Test + @DisplayName("FAILED rejects submit()") + void failedRejectsSubmit() { + var failed = aFailedTransfer(); + + assertThatThrownBy(() -> failed.submit(TX_HASH)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("terminal state"); + } + + @Test + @DisplayName("FAILED rejects startConfirming()") + void failedRejectsStartConfirming() { + var failed = aFailedTransfer(); + + assertThatThrownBy(() -> failed.startConfirming()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("terminal state"); + } + + @Test + @DisplayName("FAILED rejects confirm()") + void failedRejectsConfirm() { + var failed = aFailedTransfer(); + + assertThatThrownBy(() -> failed.confirm(999L, 20, BigDecimal.ONE, BigDecimal.ONE)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("terminal state"); + } + + @Test + @DisplayName("FAILED rejects markForResubmission()") + void failedRejectsMarkForResubmission() { + var failed = aFailedTransfer(); + + assertThatThrownBy(() -> failed.markForResubmission()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("terminal state"); + } + + @Test + @DisplayName("FAILED rejects resubmit()") + void failedRejectsResubmit() { + var failed = aFailedTransfer(); + + assertThatThrownBy(() -> failed.resubmit(TX_HASH_RESUBMIT)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("terminal state"); + } + } + + @Nested + @DisplayName("Invalid Transitions") + class InvalidTransitions { + + static Stream invalidTransitions() { + return Stream.of( + // PENDING only allows SELECT_CHAIN + Arguments.of(PENDING, START_SIGNING, "PENDING + START_SIGNING"), + Arguments.of(PENDING, SUBMIT, "PENDING + SUBMIT"), + Arguments.of(PENDING, START_CONFIRMING, "PENDING + START_CONFIRMING"), + Arguments.of(PENDING, CONFIRM, "PENDING + CONFIRM"), + Arguments.of(PENDING, RESUBMIT, "PENDING + RESUBMIT"), + Arguments.of(PENDING, CONFIRM_SUBMISSION, "PENDING + CONFIRM_SUBMISSION"), + Arguments.of(PENDING, FAIL, "PENDING + FAIL"), + + // CHAIN_SELECTED only allows START_SIGNING and FAIL + Arguments.of(CHAIN_SELECTED, SELECT_CHAIN, "CHAIN_SELECTED + SELECT_CHAIN"), + Arguments.of(CHAIN_SELECTED, SUBMIT, "CHAIN_SELECTED + SUBMIT"), + Arguments.of(CHAIN_SELECTED, START_CONFIRMING, "CHAIN_SELECTED + START_CONFIRMING"), + Arguments.of(CHAIN_SELECTED, CONFIRM, "CHAIN_SELECTED + CONFIRM"), + Arguments.of(CHAIN_SELECTED, RESUBMIT, "CHAIN_SELECTED + RESUBMIT"), + Arguments.of(CHAIN_SELECTED, CONFIRM_SUBMISSION, "CHAIN_SELECTED + CONFIRM_SUBMISSION"), + + // SIGNING only allows SUBMIT and FAIL + Arguments.of(SIGNING, SELECT_CHAIN, "SIGNING + SELECT_CHAIN"), + Arguments.of(SIGNING, START_SIGNING, "SIGNING + START_SIGNING"), + Arguments.of(SIGNING, START_CONFIRMING, "SIGNING + START_CONFIRMING"), + Arguments.of(SIGNING, CONFIRM, "SIGNING + CONFIRM"), + Arguments.of(SIGNING, RESUBMIT, "SIGNING + RESUBMIT"), + Arguments.of(SIGNING, CONFIRM_SUBMISSION, "SIGNING + CONFIRM_SUBMISSION"), + + // SUBMITTED only allows START_CONFIRMING, RESUBMIT, and FAIL + Arguments.of(SUBMITTED, SELECT_CHAIN, "SUBMITTED + SELECT_CHAIN"), + Arguments.of(SUBMITTED, START_SIGNING, "SUBMITTED + START_SIGNING"), + Arguments.of(SUBMITTED, SUBMIT, "SUBMITTED + SUBMIT"), + Arguments.of(SUBMITTED, CONFIRM, "SUBMITTED + CONFIRM"), + Arguments.of(SUBMITTED, CONFIRM_SUBMISSION, "SUBMITTED + CONFIRM_SUBMISSION"), + + // RESUBMITTING only allows CONFIRM_SUBMISSION and FAIL + Arguments.of(RESUBMITTING, SELECT_CHAIN, "RESUBMITTING + SELECT_CHAIN"), + Arguments.of(RESUBMITTING, START_SIGNING, "RESUBMITTING + START_SIGNING"), + Arguments.of(RESUBMITTING, SUBMIT, "RESUBMITTING + SUBMIT"), + Arguments.of(RESUBMITTING, START_CONFIRMING, "RESUBMITTING + START_CONFIRMING"), + Arguments.of(RESUBMITTING, CONFIRM, "RESUBMITTING + CONFIRM"), + Arguments.of(RESUBMITTING, RESUBMIT, "RESUBMITTING + RESUBMIT"), + + // CONFIRMING only allows CONFIRM and FAIL + Arguments.of(CONFIRMING, SELECT_CHAIN, "CONFIRMING + SELECT_CHAIN"), + Arguments.of(CONFIRMING, START_SIGNING, "CONFIRMING + START_SIGNING"), + Arguments.of(CONFIRMING, SUBMIT, "CONFIRMING + SUBMIT"), + Arguments.of(CONFIRMING, START_CONFIRMING, "CONFIRMING + START_CONFIRMING"), + Arguments.of(CONFIRMING, RESUBMIT, "CONFIRMING + RESUBMIT"), + Arguments.of(CONFIRMING, CONFIRM_SUBMISSION, "CONFIRMING + CONFIRM_SUBMISSION") + ); + } + + @ParameterizedTest(name = "{2} is invalid") + @MethodSource("invalidTransitions") + @DisplayName("rejects invalid state-trigger combination") + void rejectsInvalidTransition(TransferStatus status, TransferTrigger trigger, String description) { + // Build a transfer at the given status using the fixture helpers + var transfer = transferInStatus(status); + + // canApply should return false for these combinations + assertThat(transfer.canApply(trigger)).isFalse(); + } + + private ChainTransfer transferInStatus(TransferStatus status) { + return switch (status) { + case PENDING -> aPendingTransfer(); + case CHAIN_SELECTED -> aChainSelectedTransfer(); + case SIGNING -> aSigningTransfer(); + case SUBMITTED -> aSubmittedTransfer(); + case RESUBMITTING -> aResubmittingTransfer(); + case CONFIRMING -> aConfirmingTransfer(); + case CONFIRMED -> aConfirmedTransfer(); + case FAILED -> aFailedTransfer(); + }; + } + } + + @Nested + @DisplayName("Input Validation on Transition Methods") + class InputValidation { + + @Test + @DisplayName("selectChain() rejects null chainId") + void selectChainRejectsNull() { + var pending = aPendingTransfer(); + + assertThatThrownBy(() -> pending.selectChain(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Chain ID is required"); + } + + @Test + @DisplayName("submit() rejects null txHash") + void submitRejectsNullTxHash() { + var signing = aSigningTransfer(); + + assertThatThrownBy(() -> signing.submit(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Transaction hash is required"); + } + + @Test + @DisplayName("submit() rejects blank txHash") + void submitRejectsBlankTxHash() { + var signing = aSigningTransfer(); + + assertThatThrownBy(() -> signing.submit(" ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Transaction hash is required"); + } + + @Test + @DisplayName("submit() rejects empty txHash") + void submitRejectsEmptyTxHash() { + var signing = aSigningTransfer(); + + assertThatThrownBy(() -> signing.submit("")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Transaction hash is required"); + } + + @Test + @DisplayName("startSigning() accepts null nonce") + void startSigningAcceptsNullNonce() { + var chainSelected = aChainSelectedTransfer(); + + var result = chainSelected.startSigning(null); + + var expected = aChainSelectedTransfer().startSigning(null); + assertThat(result) + .usingRecursiveComparison() + .ignoringFields("transferId", "createdAt", "updatedAt") + .isEqualTo(expected); + } + } + + @Nested + @DisplayName("Query Methods") + class QueryMethods { + + @Test + @DisplayName("isTerminal() returns true for CONFIRMED") + void isTerminalForConfirmed() { + assertThat(aConfirmedTransfer().isTerminal()).isTrue(); + } + + @Test + @DisplayName("isTerminal() returns true for FAILED") + void isTerminalForFailed() { + assertThat(aFailedTransfer().isTerminal()).isTrue(); + } + + @Test + @DisplayName("isTerminal() returns false for PENDING") + void isTerminalFalseForPending() { + assertThat(aPendingTransfer().isTerminal()).isFalse(); + } + + @Test + @DisplayName("isTerminal() returns false for CHAIN_SELECTED") + void isTerminalFalseForChainSelected() { + assertThat(aChainSelectedTransfer().isTerminal()).isFalse(); + } + + @Test + @DisplayName("isTerminal() returns false for SIGNING") + void isTerminalFalseForSigning() { + assertThat(aSigningTransfer().isTerminal()).isFalse(); + } + + @Test + @DisplayName("isTerminal() returns false for SUBMITTED") + void isTerminalFalseForSubmitted() { + assertThat(aSubmittedTransfer().isTerminal()).isFalse(); + } + + @Test + @DisplayName("isTerminal() returns false for RESUBMITTING") + void isTerminalFalseForResubmitting() { + assertThat(aResubmittingTransfer().isTerminal()).isFalse(); + } + + @Test + @DisplayName("isTerminal() returns false for CONFIRMING") + void isTerminalFalseForConfirming() { + assertThat(aConfirmingTransfer().isTerminal()).isFalse(); + } + + @Test + @DisplayName("canApply(SELECT_CHAIN) returns true for PENDING") + void canApplySelectChainFromPending() { + assertThat(aPendingTransfer().canApply(SELECT_CHAIN)).isTrue(); + } + + @Test + @DisplayName("canApply(START_SIGNING) returns true for CHAIN_SELECTED") + void canApplyStartSigningFromChainSelected() { + assertThat(aChainSelectedTransfer().canApply(START_SIGNING)).isTrue(); + } + + @Test + @DisplayName("canApply(FAIL) returns true for CHAIN_SELECTED") + void canApplyFailFromChainSelected() { + assertThat(aChainSelectedTransfer().canApply(FAIL)).isTrue(); + } + + @Test + @DisplayName("canApply(SUBMIT) returns false for PENDING") + void canApplySubmitFromPendingIsFalse() { + assertThat(aPendingTransfer().canApply(SUBMIT)).isFalse(); + } + + @Test + @DisplayName("canApply(RESUBMIT) returns true for SUBMITTED") + void canApplyResubmitFromSubmitted() { + assertThat(aSubmittedTransfer().canApply(RESUBMIT)).isTrue(); + } + + @Test + @DisplayName("canApply(CONFIRM_SUBMISSION) returns true for RESUBMITTING") + void canApplyConfirmSubmissionFromResubmitting() { + assertThat(aResubmittingTransfer().canApply(CONFIRM_SUBMISSION)).isTrue(); + } + + @Test + @DisplayName("canApply returns false for all triggers from CONFIRMED") + void canApplyReturnsFalseForConfirmed() { + var confirmed = aConfirmedTransfer(); + for (var trigger : TransferTrigger.values()) { + assertThat(confirmed.canApply(trigger)) + .as("CONFIRMED + %s should be false", trigger) + .isFalse(); + } + } + + @Test + @DisplayName("canApply returns false for all triggers from FAILED") + void canApplyReturnsFalseForFailed() { + var failed = aFailedTransfer(); + for (var trigger : TransferTrigger.values()) { + assertThat(failed.canApply(trigger)) + .as("FAILED + %s should be false", trigger) + .isFalse(); + } + } + } + + @Nested + @DisplayName("Immutability") + class Immutability { + + @Test + @DisplayName("selectChain returns a new instance, original unchanged") + void selectChainPreservesOriginal() { + var original = aPendingTransfer(); + + var updated = original.selectChain(CHAIN_BASE); + + assertThat(original.status()).isEqualTo(PENDING); + assertThat(updated.status()).isEqualTo(CHAIN_SELECTED); + } + + @Test + @DisplayName("fail returns a new instance, original unchanged") + void failPreservesOriginal() { + var original = aChainSelectedTransfer(); + + var failed = original.fail("reason", "code"); + + assertThat(original.status()).isEqualTo(CHAIN_SELECTED); + assertThat(failed.status()).isEqualTo(FAILED); + } + } +} diff --git a/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/StablecoinTickerTest.java b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/StablecoinTickerTest.java new file mode 100644 index 00000000..2f0caf6e --- /dev/null +++ b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/StablecoinTickerTest.java @@ -0,0 +1,172 @@ +package com.stablecoin.payments.custody.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("StablecoinTicker") +class StablecoinTickerTest { + + @Nested + @DisplayName("Valid Tickers via of()") + class ValidTickers { + + @Test + @DisplayName("USDC maps to circle issuer with 6 decimals") + void usdcMapsCorrectly() { + var result = StablecoinTicker.of("USDC"); + + var expected = new StablecoinTicker("USDC", "circle", 6); + assertThat(result).usingRecursiveComparison().isEqualTo(expected); + } + + @Test + @DisplayName("USDT maps to tether issuer with 6 decimals") + void usdtMapsCorrectly() { + var result = StablecoinTicker.of("USDT"); + + var expected = new StablecoinTicker("USDT", "tether", 6); + assertThat(result).usingRecursiveComparison().isEqualTo(expected); + } + + @Test + @DisplayName("EURC maps to circle_euro issuer with 6 decimals") + void eurcMapsCorrectly() { + var result = StablecoinTicker.of("EURC"); + + var expected = new StablecoinTicker("EURC", "circle_euro", 6); + assertThat(result).usingRecursiveComparison().isEqualTo(expected); + } + + @Test + @DisplayName("PYUSD maps to paypal issuer with 6 decimals") + void pyusdMapsCorrectly() { + var result = StablecoinTicker.of("PYUSD"); + + var expected = new StablecoinTicker("PYUSD", "paypal", 6); + assertThat(result).usingRecursiveComparison().isEqualTo(expected); + } + + @Test + @DisplayName("RLUSD maps to ripple issuer with 6 decimals") + void rlusdMapsCorrectly() { + var result = StablecoinTicker.of("RLUSD"); + + var expected = new StablecoinTicker("RLUSD", "ripple", 6); + assertThat(result).usingRecursiveComparison().isEqualTo(expected); + } + } + + @Nested + @DisplayName("Constructor with explicit issuer and decimals") + class ExplicitConstructor { + + @Test + @DisplayName("uses provided issuer when non-null and non-blank") + void usesProvidedIssuer() { + var ticker = new StablecoinTicker("USDC", "custom-issuer", 6); + + assertThat(ticker.issuer()).isEqualTo("custom-issuer"); + } + + @Test + @DisplayName("uses provided decimals when positive") + void usesProvidedDecimals() { + var ticker = new StablecoinTicker("USDC", "circle", 18); + + assertThat(ticker.decimals()).isEqualTo(18); + } + + @Test + @DisplayName("falls back to default issuer when null provided") + void fallsBackWhenIssuerNull() { + var ticker = new StablecoinTicker("USDC", null, 6); + + assertThat(ticker.issuer()).isEqualTo("circle"); + } + + @Test + @DisplayName("falls back to default issuer when blank provided") + void fallsBackWhenIssuerBlank() { + var ticker = new StablecoinTicker("USDC", " ", 6); + + assertThat(ticker.issuer()).isEqualTo("circle"); + } + + @Test + @DisplayName("falls back to default decimals when zero provided") + void fallsBackWhenDecimalsZero() { + var ticker = new StablecoinTicker("USDC", "circle", 0); + + assertThat(ticker.decimals()).isEqualTo(6); + } + + @Test + @DisplayName("falls back to default decimals when negative provided") + void fallsBackWhenDecimalsNegative() { + var ticker = new StablecoinTicker("USDC", "circle", -1); + + assertThat(ticker.decimals()).isEqualTo(6); + } + } + + @Nested + @DisplayName("Invalid Values") + class InvalidValues { + + @Test + @DisplayName("rejects null ticker") + void rejectsNullTicker() { + assertThatThrownBy(() -> StablecoinTicker.of(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Ticker is required"); + } + + @ParameterizedTest + @ValueSource(strings = {"", " ", "\t"}) + @DisplayName("rejects blank ticker") + void rejectsBlankTicker(String value) { + assertThatThrownBy(() -> StablecoinTicker.of(value)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Ticker is required"); + } + + @ParameterizedTest + @ValueSource(strings = {"DAI", "BUSD", "UST", "usdc", "Usdc"}) + @DisplayName("rejects unsupported tickers") + void rejectsUnsupported(String value) { + assertThatThrownBy(() -> StablecoinTicker.of(value)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Unsupported stablecoin"); + } + } + + @Nested + @DisplayName("Equality") + class Equality { + + @Test + @DisplayName("two tickers with same values are equal") + void equalWhenSameValues() { + var a = StablecoinTicker.of("USDC"); + var b = StablecoinTicker.of("USDC"); + + assertThat(a).isEqualTo(b); + assertThat(a.hashCode()).isEqualTo(b.hashCode()); + } + + @Test + @DisplayName("two tickers with different values are not equal") + void notEqualWhenDifferent() { + var a = StablecoinTicker.of("USDC"); + var b = StablecoinTicker.of("USDT"); + + assertThat(a).isNotEqualTo(b); + } + } +} diff --git a/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/TransferLifecycleEventTest.java b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/TransferLifecycleEventTest.java new file mode 100644 index 00000000..0442987d --- /dev/null +++ b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/TransferLifecycleEventTest.java @@ -0,0 +1,150 @@ +package com.stablecoin.payments.custody.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("TransferLifecycleEvent") +class TransferLifecycleEventTest { + + private static final UUID TRANSFER_ID = UUID.fromString("88888888-8888-8888-8888-888888888888"); + + @Nested + @DisplayName("Simple Factory — record(transferId, state)") + class SimpleFactory { + + @Test + @DisplayName("creates event with correct fields") + void createsEvent() { + var result = TransferLifecycleEvent.record(TRANSFER_ID, "PENDING"); + + var expected = TransferLifecycleEvent.record(TRANSFER_ID, "PENDING"); + assertThat(result) + .usingRecursiveComparison() + .ignoringFields("eventId", "occurredAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("rejects null transferId") + void rejectsNullTransferId() { + assertThatThrownBy(() -> TransferLifecycleEvent.record(null, "PENDING")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("transferId is required"); + } + + @Test + @DisplayName("rejects null state") + void rejectsNullState() { + assertThatThrownBy(() -> TransferLifecycleEvent.record(TRANSFER_ID, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("state is required"); + } + + @Test + @DisplayName("rejects blank state") + void rejectsBlankState() { + assertThatThrownBy(() -> TransferLifecycleEvent.record(TRANSFER_ID, " ")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("state is required"); + } + + @Test + @DisplayName("rejects empty state") + void rejectsEmptyState() { + assertThatThrownBy(() -> TransferLifecycleEvent.record(TRANSFER_ID, "")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("state is required"); + } + } + + @Nested + @DisplayName("Participant Factory — record(transferId, state, participantType, address)") + class ParticipantFactory { + + @Test + @DisplayName("creates event with participant details") + void createsEventWithParticipant() { + var result = TransferLifecycleEvent.record( + TRANSFER_ID, "SUBMITTED", "INPUT", "0xSenderAddress" + ); + + var expected = TransferLifecycleEvent.record( + TRANSFER_ID, "SUBMITTED", "INPUT", "0xSenderAddress" + ); + assertThat(result) + .usingRecursiveComparison() + .ignoringFields("eventId", "occurredAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("allows null participantType") + void allowsNullParticipantType() { + var result = TransferLifecycleEvent.record( + TRANSFER_ID, "CONFIRMED", null, "0xAddress" + ); + + assertThat(result.participantType()).isNull(); + } + + @Test + @DisplayName("allows null address") + void allowsNullAddress() { + var result = TransferLifecycleEvent.record( + TRANSFER_ID, "CONFIRMED", "OUTPUT", null + ); + + assertThat(result.address()).isNull(); + } + + @Test + @DisplayName("rejects null transferId") + void rejectsNullTransferId() { + assertThatThrownBy(() -> TransferLifecycleEvent.record( + null, "SUBMITTED", "INPUT", "0x" + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("transferId is required"); + } + + @Test + @DisplayName("rejects null state") + void rejectsNullState() { + assertThatThrownBy(() -> TransferLifecycleEvent.record( + TRANSFER_ID, null, "INPUT", "0x" + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("state is required"); + } + + @Test + @DisplayName("rejects blank state") + void rejectsBlankState() { + assertThatThrownBy(() -> TransferLifecycleEvent.record( + TRANSFER_ID, " ", "INPUT", "0x" + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("state is required"); + } + } + + @Nested + @DisplayName("Unique Event IDs") + class UniqueEventIds { + + @Test + @DisplayName("generates unique eventId for each call") + void generatesUniqueIds() { + var event1 = TransferLifecycleEvent.record(TRANSFER_ID, "PENDING"); + var event2 = TransferLifecycleEvent.record(TRANSFER_ID, "PENDING"); + + assertThat(event1.eventId()).isNotEqualTo(event2.eventId()); + } + } +} diff --git a/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/TransferParticipantTest.java b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/TransferParticipantTest.java new file mode 100644 index 00000000..36ed7209 --- /dev/null +++ b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/TransferParticipantTest.java @@ -0,0 +1,178 @@ +package com.stablecoin.payments.custody.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.UUID; + +import static com.stablecoin.payments.custody.domain.model.ParticipantType.FEE; +import static com.stablecoin.payments.custody.domain.model.ParticipantType.INPUT; +import static com.stablecoin.payments.custody.domain.model.ParticipantType.OUTPUT; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("TransferParticipant") +class TransferParticipantTest { + + private static final UUID TRANSFER_ID = UUID.fromString("66666666-6666-6666-6666-666666666666"); + private static final UUID WALLET_ID = UUID.fromString("77777777-7777-7777-7777-777777777777"); + private static final String ADDRESS = "0xParticipantAddress"; + private static final BigDecimal AMOUNT = new BigDecimal("250.00"); + private static final String ASSET_CODE = "USDC"; + + @Nested + @DisplayName("Factory Method") + class FactoryMethod { + + @Test + @DisplayName("creates participant with correct fields") + void createsParticipant() { + var result = TransferParticipant.create( + TRANSFER_ID, INPUT, ADDRESS, WALLET_ID, AMOUNT, ASSET_CODE + ); + + var expected = TransferParticipant.create( + TRANSFER_ID, INPUT, ADDRESS, WALLET_ID, AMOUNT, ASSET_CODE + ); + assertThat(result) + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFields("participantId") + .isEqualTo(expected); + } + + @Test + @DisplayName("creates OUTPUT participant") + void createsOutputParticipant() { + var result = TransferParticipant.create( + TRANSFER_ID, OUTPUT, ADDRESS, WALLET_ID, AMOUNT, ASSET_CODE + ); + + assertThat(result.participantType()).isEqualTo(OUTPUT); + } + + @Test + @DisplayName("creates FEE participant") + void createsFeeParticipant() { + var result = TransferParticipant.create( + TRANSFER_ID, FEE, ADDRESS, null, new BigDecimal("0.50"), "ETH" + ); + + var expected = TransferParticipant.create( + TRANSFER_ID, FEE, ADDRESS, null, new BigDecimal("0.50"), "ETH" + ); + assertThat(result) + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFields("participantId") + .isEqualTo(expected); + } + } + + @Nested + @DisplayName("Validation") + class Validation { + + @Test + @DisplayName("rejects null transferId") + void rejectsNullTransferId() { + assertThatThrownBy(() -> TransferParticipant.create( + null, INPUT, ADDRESS, WALLET_ID, AMOUNT, ASSET_CODE + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("transferId is required"); + } + + @Test + @DisplayName("rejects null participantType") + void rejectsNullParticipantType() { + assertThatThrownBy(() -> TransferParticipant.create( + TRANSFER_ID, null, ADDRESS, WALLET_ID, AMOUNT, ASSET_CODE + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("participantType is required"); + } + + @Test + @DisplayName("rejects null address") + void rejectsNullAddress() { + assertThatThrownBy(() -> TransferParticipant.create( + TRANSFER_ID, INPUT, null, WALLET_ID, AMOUNT, ASSET_CODE + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("address is required"); + } + + @Test + @DisplayName("rejects blank address") + void rejectsBlankAddress() { + assertThatThrownBy(() -> TransferParticipant.create( + TRANSFER_ID, INPUT, " ", WALLET_ID, AMOUNT, ASSET_CODE + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("address is required"); + } + + @Test + @DisplayName("rejects null amount") + void rejectsNullAmount() { + assertThatThrownBy(() -> TransferParticipant.create( + TRANSFER_ID, INPUT, ADDRESS, WALLET_ID, null, ASSET_CODE + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("amount must be positive"); + } + + @Test + @DisplayName("rejects zero amount") + void rejectsZeroAmount() { + assertThatThrownBy(() -> TransferParticipant.create( + TRANSFER_ID, INPUT, ADDRESS, WALLET_ID, BigDecimal.ZERO, ASSET_CODE + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("amount must be positive"); + } + + @Test + @DisplayName("rejects negative amount") + void rejectsNegativeAmount() { + assertThatThrownBy(() -> TransferParticipant.create( + TRANSFER_ID, INPUT, ADDRESS, WALLET_ID, new BigDecimal("-5.00"), ASSET_CODE + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("amount must be positive"); + } + + @Test + @DisplayName("rejects null assetCode") + void rejectsNullAssetCode() { + assertThatThrownBy(() -> TransferParticipant.create( + TRANSFER_ID, INPUT, ADDRESS, WALLET_ID, AMOUNT, null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("assetCode is required"); + } + + @Test + @DisplayName("rejects blank assetCode") + void rejectsBlankAssetCode() { + assertThatThrownBy(() -> TransferParticipant.create( + TRANSFER_ID, INPUT, ADDRESS, WALLET_ID, AMOUNT, " " + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("assetCode is required"); + } + + @Test + @DisplayName("allows null walletId") + void allowsNullWalletId() { + var result = TransferParticipant.create( + TRANSFER_ID, FEE, ADDRESS, null, AMOUNT, ASSET_CODE + ); + + assertThat(result.walletId()).isNull(); + } + } +} diff --git a/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/WalletBalanceTest.java b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/WalletBalanceTest.java new file mode 100644 index 00000000..b94b4df3 --- /dev/null +++ b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/WalletBalanceTest.java @@ -0,0 +1,512 @@ +package com.stablecoin.payments.custody.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static com.stablecoin.payments.custody.fixtures.WalletBalanceFixtures.CHAIN_BASE; +import static com.stablecoin.payments.custody.fixtures.WalletBalanceFixtures.USDC; +import static com.stablecoin.payments.custody.fixtures.WalletBalanceFixtures.WALLET_ID; +import static com.stablecoin.payments.custody.fixtures.WalletBalanceFixtures.aBalanceWith; +import static com.stablecoin.payments.custody.fixtures.WalletBalanceFixtures.aZeroBalance; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("WalletBalance") +class WalletBalanceTest { + + @Nested + @DisplayName("Factory Method — initialize()") + class Initialize { + + @Test + @DisplayName("creates balance with all zeros") + void createsZeroBalance() { + var result = aZeroBalance(); + + var expected = WalletBalance.initialize(WALLET_ID, CHAIN_BASE, USDC); + assertThat(result) + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFields("balanceId", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("rejects null walletId") + void rejectsNullWalletId() { + assertThatThrownBy(() -> WalletBalance.initialize(null, CHAIN_BASE, USDC)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("walletId is required"); + } + + @Test + @DisplayName("rejects null chainId") + void rejectsNullChainId() { + assertThatThrownBy(() -> WalletBalance.initialize(WALLET_ID, null, USDC)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("chainId is required"); + } + + @Test + @DisplayName("rejects null stablecoin") + void rejectsNullStablecoin() { + assertThatThrownBy(() -> WalletBalance.initialize(WALLET_ID, CHAIN_BASE, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("stablecoin is required"); + } + } + + @Nested + @DisplayName("reserve()") + class Reserve { + + @Test + @DisplayName("moves amount from available to reserved") + void movesAvailableToReserved() { + var balance = aBalanceWith(new BigDecimal("500.00"), BigDecimal.ZERO); + + var result = balance.reserve(new BigDecimal("200.00")); + + var expected = aBalanceWith(new BigDecimal("500.00"), BigDecimal.ZERO) + .reserve(new BigDecimal("200.00")); + assertThat(result) + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFields("balanceId", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("reserves entire available balance") + void reservesEntireAvailableBalance() { + var balance = aBalanceWith(new BigDecimal("500.00"), BigDecimal.ZERO); + + var result = balance.reserve(new BigDecimal("500.00")); + + var expected = aBalanceWith(new BigDecimal("500.00"), BigDecimal.ZERO) + .reserve(new BigDecimal("500.00")); + assertThat(result) + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFields("balanceId", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("adds to existing reserved balance") + void addsToExistingReserved() { + var balance = aBalanceWith(new BigDecimal("300.00"), new BigDecimal("200.00")); + + var result = balance.reserve(new BigDecimal("100.00")); + + var expected = aBalanceWith(new BigDecimal("300.00"), new BigDecimal("200.00")) + .reserve(new BigDecimal("100.00")); + assertThat(result) + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFields("balanceId", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("throws when insufficient available balance") + void throwsWhenInsufficientAvailable() { + var balance = aBalanceWith(new BigDecimal("100.00"), BigDecimal.ZERO); + + assertThatThrownBy(() -> balance.reserve(new BigDecimal("200.00"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Insufficient available balance"); + } + + @Test + @DisplayName("throws when amount is null") + void throwsWhenAmountNull() { + var balance = aBalanceWith(new BigDecimal("100.00"), BigDecimal.ZERO); + + assertThatThrownBy(() -> balance.reserve(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Amount must be positive"); + } + + @Test + @DisplayName("throws when amount is zero") + void throwsWhenAmountZero() { + var balance = aBalanceWith(new BigDecimal("100.00"), BigDecimal.ZERO); + + assertThatThrownBy(() -> balance.reserve(BigDecimal.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Amount must be positive"); + } + + @Test + @DisplayName("throws when amount is negative") + void throwsWhenAmountNegative() { + var balance = aBalanceWith(new BigDecimal("100.00"), BigDecimal.ZERO); + + assertThatThrownBy(() -> balance.reserve(new BigDecimal("-10.00"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Amount must be positive"); + } + } + + @Nested + @DisplayName("release()") + class Release { + + @Test + @DisplayName("moves amount from reserved to available") + void movesReservedToAvailable() { + var balance = aBalanceWith(new BigDecimal("300.00"), new BigDecimal("200.00")); + + var result = balance.release(new BigDecimal("100.00")); + + var expected = aBalanceWith(new BigDecimal("300.00"), new BigDecimal("200.00")) + .release(new BigDecimal("100.00")); + assertThat(result) + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFields("balanceId", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("releases entire reserved balance") + void releasesEntireReserved() { + var balance = aBalanceWith(new BigDecimal("300.00"), new BigDecimal("200.00")); + + var result = balance.release(new BigDecimal("200.00")); + + var expected = aBalanceWith(new BigDecimal("300.00"), new BigDecimal("200.00")) + .release(new BigDecimal("200.00")); + assertThat(result) + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFields("balanceId", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("throws when insufficient reserved balance") + void throwsWhenInsufficientReserved() { + var balance = aBalanceWith(new BigDecimal("300.00"), new BigDecimal("50.00")); + + assertThatThrownBy(() -> balance.release(new BigDecimal("100.00"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Insufficient reserved balance"); + } + + @Test + @DisplayName("throws when amount is null") + void throwsWhenAmountNull() { + var balance = aBalanceWith(new BigDecimal("100.00"), new BigDecimal("50.00")); + + assertThatThrownBy(() -> balance.release(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Amount must be positive"); + } + + @Test + @DisplayName("throws when amount is zero") + void throwsWhenAmountZero() { + var balance = aBalanceWith(new BigDecimal("100.00"), new BigDecimal("50.00")); + + assertThatThrownBy(() -> balance.release(BigDecimal.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Amount must be positive"); + } + + @Test + @DisplayName("throws when amount is negative") + void throwsWhenAmountNegative() { + var balance = aBalanceWith(new BigDecimal("100.00"), new BigDecimal("50.00")); + + assertThatThrownBy(() -> balance.release(new BigDecimal("-5.00"))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Amount must be positive"); + } + } + + @Nested + @DisplayName("confirmDebit()") + class ConfirmDebit { + + @Test + @DisplayName("reduces reserved balance") + void reducesReserved() { + var balance = aBalanceWith(new BigDecimal("300.00"), new BigDecimal("200.00")); + + var result = balance.confirmDebit(new BigDecimal("100.00")); + + var expected = aBalanceWith(new BigDecimal("300.00"), new BigDecimal("200.00")) + .confirmDebit(new BigDecimal("100.00")); + assertThat(result) + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFields("balanceId", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("confirms full reserved amount") + void confirmsFullReserved() { + var balance = aBalanceWith(new BigDecimal("300.00"), new BigDecimal("200.00")); + + var result = balance.confirmDebit(new BigDecimal("200.00")); + + var expected = aBalanceWith(new BigDecimal("300.00"), new BigDecimal("200.00")) + .confirmDebit(new BigDecimal("200.00")); + assertThat(result) + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFields("balanceId", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("throws when insufficient reserved balance") + void throwsWhenInsufficientReserved() { + var balance = aBalanceWith(new BigDecimal("300.00"), new BigDecimal("50.00")); + + assertThatThrownBy(() -> balance.confirmDebit(new BigDecimal("100.00"))) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Insufficient reserved balance for debit"); + } + + @Test + @DisplayName("throws when amount is null") + void throwsWhenAmountNull() { + var balance = aBalanceWith(new BigDecimal("100.00"), new BigDecimal("50.00")); + + assertThatThrownBy(() -> balance.confirmDebit(null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Amount must be positive"); + } + + @Test + @DisplayName("throws when amount is zero") + void throwsWhenAmountZero() { + var balance = aBalanceWith(new BigDecimal("100.00"), new BigDecimal("50.00")); + + assertThatThrownBy(() -> balance.confirmDebit(BigDecimal.ZERO)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Amount must be positive"); + } + } + + @Nested + @DisplayName("syncFromChain()") + class SyncFromChain { + + @Test + @DisplayName("updates blockchain balance and recalculates available") + void updatesBlockchainBalance() { + var balance = aZeroBalance(); + + var result = balance.syncFromChain(new BigDecimal("1000.00"), 1L); + + var expected = aZeroBalance().syncFromChain(new BigDecimal("1000.00"), 1L); + assertThat(result) + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFields("balanceId", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("recalculates available as onChain - reserved") + void recalculatesAvailableWithReserved() { + var balance = aBalanceWith(new BigDecimal("300.00"), new BigDecimal("200.00")); + + var result = balance.syncFromChain(new BigDecimal("600.00"), 101L); + + var expected = aBalanceWith(new BigDecimal("300.00"), new BigDecimal("200.00")) + .syncFromChain(new BigDecimal("600.00"), 101L); + assertThat(result) + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFields("balanceId", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("clamps available to zero when onChain < reserved") + void clampsAvailableToZero() { + var balance = aBalanceWith(new BigDecimal("300.00"), new BigDecimal("200.00")); + + var result = balance.syncFromChain(new BigDecimal("100.00"), 101L); + + var expected = aBalanceWith(new BigDecimal("300.00"), new BigDecimal("200.00")) + .syncFromChain(new BigDecimal("100.00"), 101L); + assertThat(result) + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFields("balanceId", "updatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("throws when blockNumber is stale (equal)") + void throwsWhenBlockNumberEqual() { + var balance = aBalanceWith(new BigDecimal("300.00"), BigDecimal.ZERO); + + assertThatThrownBy(() -> balance.syncFromChain(new BigDecimal("500.00"), 100L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be greater than last indexed block"); + } + + @Test + @DisplayName("throws when blockNumber is stale (less)") + void throwsWhenBlockNumberLess() { + var balance = aBalanceWith(new BigDecimal("300.00"), BigDecimal.ZERO); + + assertThatThrownBy(() -> balance.syncFromChain(new BigDecimal("500.00"), 50L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("must be greater than last indexed block"); + } + + @Test + @DisplayName("throws when onChainBalance is null") + void throwsWhenOnChainNull() { + var balance = aZeroBalance(); + + assertThatThrownBy(() -> balance.syncFromChain(null, 1L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("On-chain balance must be non-negative"); + } + + @Test + @DisplayName("throws when onChainBalance is negative") + void throwsWhenOnChainNegative() { + var balance = aZeroBalance(); + + assertThatThrownBy(() -> balance.syncFromChain(new BigDecimal("-1.00"), 1L)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("On-chain balance must be non-negative"); + } + + @Test + @DisplayName("accepts zero onChainBalance") + void acceptsZeroOnChain() { + var balance = aZeroBalance(); + + var result = balance.syncFromChain(BigDecimal.ZERO, 1L); + + var expected = aZeroBalance().syncFromChain(BigDecimal.ZERO, 1L); + assertThat(result) + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFields("balanceId", "updatedAt") + .isEqualTo(expected); + } + } + + @Nested + @DisplayName("hasSufficientBalance()") + class HasSufficientBalance { + + @Test + @DisplayName("returns true when available >= amount") + void returnsTrueWhenSufficient() { + var balance = aBalanceWith(new BigDecimal("500.00"), BigDecimal.ZERO); + + assertThat(balance.hasSufficientBalance(new BigDecimal("500.00"))).isTrue(); + } + + @Test + @DisplayName("returns true when available > amount") + void returnsTrueWhenMoreThanSufficient() { + var balance = aBalanceWith(new BigDecimal("500.00"), BigDecimal.ZERO); + + assertThat(balance.hasSufficientBalance(new BigDecimal("100.00"))).isTrue(); + } + + @Test + @DisplayName("returns false when available < amount") + void returnsFalseWhenInsufficient() { + var balance = aBalanceWith(new BigDecimal("100.00"), BigDecimal.ZERO); + + assertThat(balance.hasSufficientBalance(new BigDecimal("200.00"))).isFalse(); + } + + @Test + @DisplayName("returns false for null amount") + void returnsFalseForNull() { + var balance = aBalanceWith(new BigDecimal("500.00"), BigDecimal.ZERO); + + assertThat(balance.hasSufficientBalance(null)).isFalse(); + } + + @Test + @DisplayName("returns false for zero amount") + void returnsFalseForZero() { + var balance = aBalanceWith(new BigDecimal("500.00"), BigDecimal.ZERO); + + assertThat(balance.hasSufficientBalance(BigDecimal.ZERO)).isFalse(); + } + + @Test + @DisplayName("returns false for negative amount") + void returnsFalseForNegative() { + var balance = aBalanceWith(new BigDecimal("500.00"), BigDecimal.ZERO); + + assertThat(balance.hasSufficientBalance(new BigDecimal("-10.00"))).isFalse(); + } + } + + @Nested + @DisplayName("Compact Constructor Validation") + class CompactConstructor { + + @Test + @DisplayName("rejects negative available balance") + void rejectsNegativeAvailable() { + assertThatThrownBy(() -> aZeroBalance().toBuilder() + .availableBalance(new BigDecimal("-1.00")) + .build() + ) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Available balance must be non-negative"); + } + + @Test + @DisplayName("rejects negative reserved balance") + void rejectsNegativeReserved() { + assertThatThrownBy(() -> aZeroBalance().toBuilder() + .reservedBalance(new BigDecimal("-1.00")) + .build() + ) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Reserved balance must be non-negative"); + } + + @Test + @DisplayName("rejects negative blockchain balance") + void rejectsNegativeBlockchain() { + assertThatThrownBy(() -> aZeroBalance().toBuilder() + .blockchainBalance(new BigDecimal("-1.00")) + .build() + ) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Blockchain balance must be non-negative"); + } + } + + @Nested + @DisplayName("Immutability") + class Immutability { + + @Test + @DisplayName("reserve returns new instance, original unchanged") + void reservePreservesOriginal() { + var original = aBalanceWith(new BigDecimal("500.00"), BigDecimal.ZERO); + + var reserved = original.reserve(new BigDecimal("200.00")); + + assertThat(original.availableBalance()).isEqualByComparingTo(new BigDecimal("500.00")); + assertThat(reserved.availableBalance()).isEqualByComparingTo(new BigDecimal("300.00")); // immutability requires checking both + } + } +} diff --git a/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/WalletTest.java b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/WalletTest.java new file mode 100644 index 00000000..eb242cd8 --- /dev/null +++ b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/model/WalletTest.java @@ -0,0 +1,236 @@ +package com.stablecoin.payments.custody.domain.model; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static com.stablecoin.payments.custody.fixtures.WalletFixtures.ADDRESS; +import static com.stablecoin.payments.custody.fixtures.WalletFixtures.ADDRESS_CHECKSUM; +import static com.stablecoin.payments.custody.fixtures.WalletFixtures.CHAIN_BASE; +import static com.stablecoin.payments.custody.fixtures.WalletFixtures.CUSTODIAN; +import static com.stablecoin.payments.custody.fixtures.WalletFixtures.PURPOSE_ON_RAMP; +import static com.stablecoin.payments.custody.fixtures.WalletFixtures.TIER_HOT; +import static com.stablecoin.payments.custody.fixtures.WalletFixtures.USDC; +import static com.stablecoin.payments.custody.fixtures.WalletFixtures.VAULT_ACCOUNT_ID; +import static com.stablecoin.payments.custody.fixtures.WalletFixtures.aDeactivatedWallet; +import static com.stablecoin.payments.custody.fixtures.WalletFixtures.anActiveWallet; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("Wallet") +class WalletTest { + + @Nested + @DisplayName("Factory Method") + class FactoryMethod { + + @Test + @DisplayName("creates an active wallet with correct fields") + void createsActiveWallet() { + var result = anActiveWallet(); + + var expected = Wallet.create( + CHAIN_BASE, ADDRESS, ADDRESS_CHECKSUM, + TIER_HOT, PURPOSE_ON_RAMP, CUSTODIAN, VAULT_ACCOUNT_ID, USDC + ); + assertThat(result) + .usingRecursiveComparison() + .ignoringFields("walletId", "createdAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("rejects null chainId") + void rejectsNullChainId() { + assertThatThrownBy(() -> Wallet.create( + null, ADDRESS, ADDRESS_CHECKSUM, + TIER_HOT, PURPOSE_ON_RAMP, CUSTODIAN, VAULT_ACCOUNT_ID, USDC + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("chainId is required"); + } + + @Test + @DisplayName("rejects null address") + void rejectsNullAddress() { + assertThatThrownBy(() -> Wallet.create( + CHAIN_BASE, null, ADDRESS_CHECKSUM, + TIER_HOT, PURPOSE_ON_RAMP, CUSTODIAN, VAULT_ACCOUNT_ID, USDC + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("address is required"); + } + + @Test + @DisplayName("rejects blank address") + void rejectsBlankAddress() { + assertThatThrownBy(() -> Wallet.create( + CHAIN_BASE, " ", ADDRESS_CHECKSUM, + TIER_HOT, PURPOSE_ON_RAMP, CUSTODIAN, VAULT_ACCOUNT_ID, USDC + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("address is required"); + } + + @Test + @DisplayName("rejects null addressChecksum") + void rejectsNullAddressChecksum() { + assertThatThrownBy(() -> Wallet.create( + CHAIN_BASE, ADDRESS, null, + TIER_HOT, PURPOSE_ON_RAMP, CUSTODIAN, VAULT_ACCOUNT_ID, USDC + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("addressChecksum is required"); + } + + @Test + @DisplayName("rejects blank addressChecksum") + void rejectsBlankAddressChecksum() { + assertThatThrownBy(() -> Wallet.create( + CHAIN_BASE, ADDRESS, " ", + TIER_HOT, PURPOSE_ON_RAMP, CUSTODIAN, VAULT_ACCOUNT_ID, USDC + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("addressChecksum is required"); + } + + @Test + @DisplayName("rejects null tier") + void rejectsNullTier() { + assertThatThrownBy(() -> Wallet.create( + CHAIN_BASE, ADDRESS, ADDRESS_CHECKSUM, + null, PURPOSE_ON_RAMP, CUSTODIAN, VAULT_ACCOUNT_ID, USDC + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("tier is required"); + } + + @Test + @DisplayName("rejects null purpose") + void rejectsNullPurpose() { + assertThatThrownBy(() -> Wallet.create( + CHAIN_BASE, ADDRESS, ADDRESS_CHECKSUM, + TIER_HOT, null, CUSTODIAN, VAULT_ACCOUNT_ID, USDC + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("purpose is required"); + } + + @Test + @DisplayName("rejects null custodian") + void rejectsNullCustodian() { + assertThatThrownBy(() -> Wallet.create( + CHAIN_BASE, ADDRESS, ADDRESS_CHECKSUM, + TIER_HOT, PURPOSE_ON_RAMP, null, VAULT_ACCOUNT_ID, USDC + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("custodian is required"); + } + + @Test + @DisplayName("rejects blank custodian") + void rejectsBlankCustodian() { + assertThatThrownBy(() -> Wallet.create( + CHAIN_BASE, ADDRESS, ADDRESS_CHECKSUM, + TIER_HOT, PURPOSE_ON_RAMP, " ", VAULT_ACCOUNT_ID, USDC + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("custodian is required"); + } + + @Test + @DisplayName("rejects null vaultAccountId") + void rejectsNullVaultAccountId() { + assertThatThrownBy(() -> Wallet.create( + CHAIN_BASE, ADDRESS, ADDRESS_CHECKSUM, + TIER_HOT, PURPOSE_ON_RAMP, CUSTODIAN, null, USDC + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("vaultAccountId is required"); + } + + @Test + @DisplayName("rejects blank vaultAccountId") + void rejectsBlankVaultAccountId() { + assertThatThrownBy(() -> Wallet.create( + CHAIN_BASE, ADDRESS, ADDRESS_CHECKSUM, + TIER_HOT, PURPOSE_ON_RAMP, CUSTODIAN, " ", USDC + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("vaultAccountId is required"); + } + + @Test + @DisplayName("rejects null stablecoin") + void rejectsNullStablecoin() { + assertThatThrownBy(() -> Wallet.create( + CHAIN_BASE, ADDRESS, ADDRESS_CHECKSUM, + TIER_HOT, PURPOSE_ON_RAMP, CUSTODIAN, VAULT_ACCOUNT_ID, null + )) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("stablecoin is required"); + } + } + + @Nested + @DisplayName("Deactivate") + class Deactivate { + + @Test + @DisplayName("sets active to false and deactivatedAt") + void setsActiveToFalse() { + var active = anActiveWallet(); + + var result = active.deactivate(); + + var expected = anActiveWallet().deactivate(); + assertThat(result) + .usingRecursiveComparison() + .ignoringFields("walletId", "createdAt", "deactivatedAt") + .isEqualTo(expected); + } + + @Test + @DisplayName("throws when already deactivated") + void throwsWhenAlreadyDeactivated() { + var deactivated = aDeactivatedWallet(); + + assertThatThrownBy(deactivated::deactivate) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("already deactivated"); + } + } + + @Nested + @DisplayName("Query Methods") + class QueryMethods { + + @Test + @DisplayName("isActive() returns true for active wallet") + void isActiveReturnsTrue() { + assertThat(anActiveWallet().isActive()).isTrue(); + } + + @Test + @DisplayName("isActive() returns false for deactivated wallet") + void isActiveReturnsFalse() { + assertThat(aDeactivatedWallet().isActive()).isFalse(); + } + } + + @Nested + @DisplayName("Immutability") + class Immutability { + + @Test + @DisplayName("deactivate returns new instance, original unchanged") + void deactivatePreservesOriginal() { + var original = anActiveWallet(); + + var deactivated = original.deactivate(); + + assertThat(original.active()).isTrue(); + assertThat(deactivated.active()).isFalse(); + } + } +} diff --git a/blockchain-custody/blockchain-custody/src/testFixtures/java/com/stablecoin/payments/custody/fixtures/ChainTransferFixtures.java b/blockchain-custody/blockchain-custody/src/testFixtures/java/com/stablecoin/payments/custody/fixtures/ChainTransferFixtures.java new file mode 100644 index 00000000..45c0ef0f --- /dev/null +++ b/blockchain-custody/blockchain-custody/src/testFixtures/java/com/stablecoin/payments/custody/fixtures/ChainTransferFixtures.java @@ -0,0 +1,97 @@ +package com.stablecoin.payments.custody.fixtures; + +import com.stablecoin.payments.custody.domain.model.ChainId; +import com.stablecoin.payments.custody.domain.model.ChainTransfer; +import com.stablecoin.payments.custody.domain.model.StablecoinTicker; +import com.stablecoin.payments.custody.domain.model.TransferType; + +import java.math.BigDecimal; +import java.util.UUID; + +public final class ChainTransferFixtures { + + private ChainTransferFixtures() {} + + public static final UUID PAYMENT_ID = UUID.fromString("11111111-1111-1111-1111-111111111111"); + public static final UUID CORRELATION_ID = UUID.fromString("22222222-2222-2222-2222-222222222222"); + public static final UUID FROM_WALLET_ID = UUID.fromString("33333333-3333-3333-3333-333333333333"); + public static final UUID PARENT_TRANSFER_ID = UUID.fromString("44444444-4444-4444-4444-444444444444"); + public static final String TO_ADDRESS = "0xRecipientAddress1234567890abcdef"; + public static final String FROM_ADDRESS = "0xSenderAddress1234567890abcdef"; + public static final String TX_HASH = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890"; + public static final String TX_HASH_RESUBMIT = "0xresubmit234567890abcdef1234567890abcdef1234567890abcdef1234567890"; + public static final ChainId CHAIN_BASE = new ChainId("base"); + public static final StablecoinTicker USDC = StablecoinTicker.of("USDC"); + public static final BigDecimal AMOUNT = new BigDecimal("1000.00"); + + /** + * A fresh PENDING transfer (FORWARD type, no parent). + */ + public static ChainTransfer aPendingTransfer() { + return ChainTransfer.initiate( + PAYMENT_ID, CORRELATION_ID, TransferType.FORWARD, null, + USDC, AMOUNT, FROM_WALLET_ID, TO_ADDRESS, FROM_ADDRESS + ); + } + + /** + * A PENDING RETURN transfer with parentTransferId. + */ + public static ChainTransfer aPendingReturnTransfer() { + return ChainTransfer.initiate( + PAYMENT_ID, CORRELATION_ID, TransferType.RETURN, PARENT_TRANSFER_ID, + USDC, AMOUNT, FROM_WALLET_ID, TO_ADDRESS, FROM_ADDRESS + ); + } + + /** + * A transfer in CHAIN_SELECTED state. + */ + public static ChainTransfer aChainSelectedTransfer() { + return aPendingTransfer().selectChain(CHAIN_BASE); + } + + /** + * A transfer in SIGNING state. + */ + public static ChainTransfer aSigningTransfer() { + return aChainSelectedTransfer().startSigning(42L); + } + + /** + * A transfer in SUBMITTED state (attemptCount = 1). + */ + public static ChainTransfer aSubmittedTransfer() { + return aSigningTransfer().submit(TX_HASH); + } + + /** + * A transfer in CONFIRMING state. + */ + public static ChainTransfer aConfirmingTransfer() { + return aSubmittedTransfer().startConfirming(); + } + + /** + * A transfer in CONFIRMED (terminal) state. + */ + public static ChainTransfer aConfirmedTransfer() { + return aConfirmingTransfer().confirm( + 12345L, 15, new BigDecimal("0.002100"), new BigDecimal("25.5") + ); + } + + /** + * A transfer in RESUBMITTING state. + */ + public static ChainTransfer aResubmittingTransfer() { + return aSubmittedTransfer().markForResubmission(); + } + + /** + * A transfer in FAILED (terminal) state. + */ + public static ChainTransfer aFailedTransfer() { + return aChainSelectedTransfer().fail("Insufficient gas", "GAS_LIMIT_EXCEEDED"); + } +} diff --git a/blockchain-custody/blockchain-custody/src/testFixtures/java/com/stablecoin/payments/custody/fixtures/WalletBalanceFixtures.java b/blockchain-custody/blockchain-custody/src/testFixtures/java/com/stablecoin/payments/custody/fixtures/WalletBalanceFixtures.java new file mode 100644 index 00000000..40eaaf80 --- /dev/null +++ b/blockchain-custody/blockchain-custody/src/testFixtures/java/com/stablecoin/payments/custody/fixtures/WalletBalanceFixtures.java @@ -0,0 +1,37 @@ +package com.stablecoin.payments.custody.fixtures; + +import com.stablecoin.payments.custody.domain.model.ChainId; +import com.stablecoin.payments.custody.domain.model.StablecoinTicker; +import com.stablecoin.payments.custody.domain.model.WalletBalance; + +import java.math.BigDecimal; +import java.util.UUID; + +public final class WalletBalanceFixtures { + + private WalletBalanceFixtures() {} + + public static final UUID WALLET_ID = UUID.fromString("55555555-5555-5555-5555-555555555555"); + public static final ChainId CHAIN_BASE = new ChainId("base"); + public static final StablecoinTicker USDC = StablecoinTicker.of("USDC"); + + /** + * A freshly initialized balance with all zeros. + */ + public static WalletBalance aZeroBalance() { + return WalletBalance.initialize(WALLET_ID, CHAIN_BASE, USDC); + } + + /** + * A balance with specific available and reserved amounts. + * Achieved by syncing from chain and then reserving. + */ + public static WalletBalance aBalanceWith(BigDecimal available, BigDecimal reserved) { + var total = available.add(reserved); + var synced = aZeroBalance().syncFromChain(total, 100L); + if (reserved.compareTo(BigDecimal.ZERO) > 0) { + return synced.reserve(reserved); + } + return synced; + } +} diff --git a/blockchain-custody/blockchain-custody/src/testFixtures/java/com/stablecoin/payments/custody/fixtures/WalletFixtures.java b/blockchain-custody/blockchain-custody/src/testFixtures/java/com/stablecoin/payments/custody/fixtures/WalletFixtures.java new file mode 100644 index 00000000..35b8d410 --- /dev/null +++ b/blockchain-custody/blockchain-custody/src/testFixtures/java/com/stablecoin/payments/custody/fixtures/WalletFixtures.java @@ -0,0 +1,39 @@ +package com.stablecoin.payments.custody.fixtures; + +import com.stablecoin.payments.custody.domain.model.ChainId; +import com.stablecoin.payments.custody.domain.model.StablecoinTicker; +import com.stablecoin.payments.custody.domain.model.Wallet; +import com.stablecoin.payments.custody.domain.model.WalletPurpose; +import com.stablecoin.payments.custody.domain.model.WalletTier; + +public final class WalletFixtures { + + private WalletFixtures() {} + + public static final ChainId CHAIN_BASE = new ChainId("base"); + public static final String ADDRESS = "0xWalletAddress1234567890abcdef"; + public static final String ADDRESS_CHECKSUM = "0xWalletAddress1234567890AbCdEf"; + public static final WalletTier TIER_HOT = WalletTier.HOT; + public static final WalletPurpose PURPOSE_ON_RAMP = WalletPurpose.ON_RAMP; + public static final String CUSTODIAN = "fireblocks"; + public static final String VAULT_ACCOUNT_ID = "vault-001"; + public static final StablecoinTicker USDC = StablecoinTicker.of("USDC"); + + /** + * An active wallet with default values. + */ + public static Wallet anActiveWallet() { + return Wallet.create( + CHAIN_BASE, ADDRESS, ADDRESS_CHECKSUM, + TIER_HOT, PURPOSE_ON_RAMP, + CUSTODIAN, VAULT_ACCOUNT_ID, USDC + ); + } + + /** + * A deactivated wallet. + */ + public static Wallet aDeactivatedWallet() { + return anActiveWallet().deactivate(); + } +}