From ae06ee3b612933b703bd9bf685db7df7fb39d6ef Mon Sep 17 00:00:00 2001 From: Puneethkumar CK Date: Sun, 8 Mar 2026 19:20:21 +0100 Subject: [PATCH] =?UTF-8?q?feat(s4):=20infrastructure=20persistence=20test?= =?UTF-8?q?s=20=E2=80=94=2032=20adapter=20ITs=20(STA-135)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChainTransferPersistenceAdapterIT (10 tests): CRUD, findByPaymentIdAndType, findByStatus, full happy path, resubmission path, failure path, return transfer with parent id, nullable chain id. WalletPersistenceAdapterIT (7 tests): CRUD, findByAddress, findByChainIdAndPurpose (only active), deactivate update, unique constraint. WalletBalancePersistenceAdapterIT (8 tests): CRUD, findByWalletIdAndStablecoin, findByWalletId, reserve, release + confirmDebit, unique constraint, decimal precision (8 places). TransferParticipantPersistenceAdapterIT (4 tests): CRUD, multiple participants (INPUT/OUTPUT/FEE), all participant types enum round-trip. TransferLifecycleEventPersistenceAdapterIT (4 tests): CRUD, multiple events, event with participant details. V4 migration: fix NUMERIC(20,0) → BIGINT for block_number, nonce, last_indexed_block, current_nonce (Hibernate maps Long to BIGINT). Closes STA-135 Co-Authored-By: Claude Opus 4.6 --- .../ChainTransferPersistenceAdapterIT.java | 183 ++++++++++++++++++ ...ferLifecycleEventPersistenceAdapterIT.java | 110 +++++++++++ ...ansferParticipantPersistenceAdapterIT.java | 126 ++++++++++++ .../WalletBalancePersistenceAdapterIT.java | 157 +++++++++++++++ .../WalletPersistenceAdapterIT.java | 113 +++++++++++ .../migration/V4__fix_numeric_to_bigint.sql | 11 ++ 6 files changed, 700 insertions(+) create mode 100644 blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/infrastructure/persistence/ChainTransferPersistenceAdapterIT.java create mode 100644 blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/infrastructure/persistence/TransferLifecycleEventPersistenceAdapterIT.java create mode 100644 blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/infrastructure/persistence/TransferParticipantPersistenceAdapterIT.java create mode 100644 blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/infrastructure/persistence/WalletBalancePersistenceAdapterIT.java create mode 100644 blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/infrastructure/persistence/WalletPersistenceAdapterIT.java create mode 100644 blockchain-custody/blockchain-custody/src/main/resources/db/migration/V4__fix_numeric_to_bigint.sql diff --git a/blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/infrastructure/persistence/ChainTransferPersistenceAdapterIT.java b/blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/infrastructure/persistence/ChainTransferPersistenceAdapterIT.java new file mode 100644 index 00000000..846bf0ca --- /dev/null +++ b/blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/infrastructure/persistence/ChainTransferPersistenceAdapterIT.java @@ -0,0 +1,183 @@ +package com.stablecoin.payments.custody.infrastructure.persistence; + +import com.stablecoin.payments.custody.AbstractIntegrationTest; +import com.stablecoin.payments.custody.domain.model.ChainTransfer; +import com.stablecoin.payments.custody.domain.model.TransferStatus; +import com.stablecoin.payments.custody.domain.model.TransferType; +import com.stablecoin.payments.custody.domain.model.Wallet; +import com.stablecoin.payments.custody.domain.port.ChainTransferRepository; +import com.stablecoin.payments.custody.domain.port.WalletRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +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.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.WalletFixtures.anActiveWallet; +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("ChainTransferPersistenceAdapter IT") +class ChainTransferPersistenceAdapterIT extends AbstractIntegrationTest { + + @Autowired + private ChainTransferRepository adapter; + + @Autowired + private WalletRepository walletAdapter; + + // ── Save & Retrieve ────────────────────────────────────────────────── + + @Test + @DisplayName("should save and retrieve pending transfer with null chain id") + void shouldSaveAndRetrievePendingTransfer() { + var wallet = saveWallet(); + var transfer = createPendingTransfer(wallet.walletId()); + var saved = adapter.save(transfer); + + assertThat(adapter.findById(saved.transferId())).isPresent().get() + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFieldsOfTypes(Instant.class) + .isEqualTo(saved); + } + + @Test + @DisplayName("should find by payment id and type") + void shouldFindByPaymentIdAndType() { + var wallet = saveWallet(); + var transfer = createPendingTransfer(wallet.walletId()); + var saved = adapter.save(transfer); + + assertThat(adapter.findByPaymentIdAndType(PAYMENT_ID, TransferType.FORWARD)).isPresent().get() + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFieldsOfTypes(Instant.class) + .isEqualTo(saved); + } + + @Test + @DisplayName("should return empty when id not found") + void shouldReturnEmptyWhenIdNotFound() { + assertThat(adapter.findById(UUID.randomUUID())).isEmpty(); + } + + @Test + @DisplayName("should return empty when payment id and type not found") + void shouldReturnEmptyWhenPaymentIdAndTypeNotFound() { + assertThat(adapter.findByPaymentIdAndType(UUID.randomUUID(), TransferType.FORWARD)).isEmpty(); + } + + @Test + @DisplayName("should find by status") + void shouldFindByStatus() { + var wallet = saveWallet(); + var transfer = createPendingTransfer(wallet.walletId()); + adapter.save(transfer); + + assertThat(adapter.findByStatus(TransferStatus.PENDING)).hasSize(1); + assertThat(adapter.findByStatus(TransferStatus.CONFIRMED)).isEmpty(); + } + + // ── State Machine Happy Path ───────────────────────────────────────── + + @Test + @DisplayName("should update transfer through full happy path to CONFIRMED") + void shouldUpdateTransferThroughFullHappyPath() { + var wallet = saveWallet(); + var transfer = createPendingTransfer(wallet.walletId()); + var saved = adapter.save(transfer); + + saved = adapter.save(saved.selectChain(CHAIN_BASE)); + saved = adapter.save(saved.startSigning(42L)); + saved = adapter.save(saved.submit(TX_HASH)); + saved = adapter.save(saved.startConfirming()); + var expected = adapter.save( + saved.confirm(12345L, 15, new BigDecimal("21000"), new BigDecimal("25.500000000"))); + + assertThat(adapter.findById(transfer.transferId())).isPresent().get() + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFieldsOfTypes(Instant.class) + .isEqualTo(expected); + } + + @Test + @DisplayName("should update transfer through resubmission path") + void shouldUpdateTransferThroughResubmissionPath() { + var wallet = saveWallet(); + var transfer = createPendingTransfer(wallet.walletId()); + var saved = adapter.save(transfer); + + saved = adapter.save(saved.selectChain(CHAIN_BASE)); + saved = adapter.save(saved.startSigning(42L)); + saved = adapter.save(saved.submit(TX_HASH)); + saved = adapter.save(saved.markForResubmission()); + var expected = adapter.save(saved.resubmit(TX_HASH_RESUBMIT)); + + assertThat(adapter.findById(transfer.transferId())).isPresent().get() + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFieldsOfTypes(Instant.class) + .isEqualTo(expected); + } + + // ── Failure Path ───────────────────────────────────────────────────── + + @Test + @DisplayName("should update transfer through failure path") + void shouldUpdateTransferThroughFailurePath() { + var wallet = saveWallet(); + var transfer = createPendingTransfer(wallet.walletId()); + var saved = adapter.save(transfer); + saved = adapter.save(saved.selectChain(CHAIN_BASE)); + var expected = adapter.save(saved.fail("Insufficient gas", "GAS_LIMIT_EXCEEDED")); + + assertThat(adapter.findById(transfer.transferId())).isPresent().get() + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFieldsOfTypes(Instant.class) + .isEqualTo(expected); + } + + // ── Return Transfer ────────────────────────────────────────────────── + + @Test + @DisplayName("should persist return transfer with parent id") + void shouldPersistReturnTransferWithParentId() { + var wallet = saveWallet(); + var transfer = ChainTransfer.initiate( + PAYMENT_ID, CORRELATION_ID, TransferType.RETURN, PARENT_TRANSFER_ID, + USDC, AMOUNT, wallet.walletId(), TO_ADDRESS, FROM_ADDRESS); + var saved = adapter.save(transfer); + + assertThat(adapter.findByPaymentIdAndType(PAYMENT_ID, TransferType.RETURN)).isPresent().get() + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFieldsOfTypes(Instant.class) + .isEqualTo(saved); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private Wallet saveWallet() { + return walletAdapter.save(anActiveWallet()); + } + + private ChainTransfer createPendingTransfer(UUID fromWalletId) { + return ChainTransfer.initiate( + PAYMENT_ID, CORRELATION_ID, TransferType.FORWARD, null, + USDC, AMOUNT, fromWalletId, TO_ADDRESS, FROM_ADDRESS); + } +} diff --git a/blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/infrastructure/persistence/TransferLifecycleEventPersistenceAdapterIT.java b/blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/infrastructure/persistence/TransferLifecycleEventPersistenceAdapterIT.java new file mode 100644 index 00000000..28c73445 --- /dev/null +++ b/blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/infrastructure/persistence/TransferLifecycleEventPersistenceAdapterIT.java @@ -0,0 +1,110 @@ +package com.stablecoin.payments.custody.infrastructure.persistence; + +import com.stablecoin.payments.custody.AbstractIntegrationTest; +import com.stablecoin.payments.custody.domain.model.ChainTransfer; +import com.stablecoin.payments.custody.domain.model.TransferLifecycleEvent; +import com.stablecoin.payments.custody.domain.model.TransferType; +import com.stablecoin.payments.custody.domain.model.Wallet; +import com.stablecoin.payments.custody.domain.port.ChainTransferRepository; +import com.stablecoin.payments.custody.domain.port.TransferLifecycleEventRepository; +import com.stablecoin.payments.custody.domain.port.WalletRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.time.Instant; +import java.util.UUID; + +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.AMOUNT; +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.PAYMENT_ID; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.TO_ADDRESS; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.USDC; +import static com.stablecoin.payments.custody.fixtures.WalletFixtures.anActiveWallet; +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("TransferLifecycleEventPersistenceAdapter IT") +class TransferLifecycleEventPersistenceAdapterIT extends AbstractIntegrationTest { + + @Autowired + private TransferLifecycleEventRepository adapter; + + @Autowired + private ChainTransferRepository transferAdapter; + + @Autowired + private WalletRepository walletAdapter; + + // ── Save & Retrieve ────────────────────────────────────────────────── + + @Test + @DisplayName("should save and retrieve by transfer id") + void shouldSaveAndRetrieveByTransferId() { + var wallet = saveWallet(); + var transfer = saveTransfer(wallet.walletId()); + + var event = TransferLifecycleEvent.record(transfer.transferId(), "PENDING"); + var saved = adapter.save(event); + + var results = adapter.findByTransferId(transfer.transferId()); + assertThat(results).hasSize(1); + assertThat(results.getFirst()) + .usingRecursiveComparison() + .ignoringFieldsOfTypes(Instant.class) + .isEqualTo(saved); + } + + @Test + @DisplayName("should return empty list for unknown transfer id") + void shouldReturnEmptyListForUnknownTransferId() { + assertThat(adapter.findByTransferId(UUID.randomUUID())).isEmpty(); + } + + // ── Multiple Events ────────────────────────────────────────────────── + + @Test + @DisplayName("should save multiple events for same transfer") + void shouldSaveMultipleEventsForSameTransfer() { + var wallet = saveWallet(); + var transfer = saveTransfer(wallet.walletId()); + + adapter.save(TransferLifecycleEvent.record(transfer.transferId(), "PENDING")); + adapter.save(TransferLifecycleEvent.record(transfer.transferId(), "CHAIN_SELECTED")); + adapter.save(TransferLifecycleEvent.record(transfer.transferId(), "SIGNING")); + + assertThat(adapter.findByTransferId(transfer.transferId())).hasSize(3); + } + + // ── Event with Participant Details ──────────────────────────────────── + + @Test + @DisplayName("should persist event with participant details") + void shouldPersistEventWithParticipantDetails() { + var wallet = saveWallet(); + var transfer = saveTransfer(wallet.walletId()); + + var event = TransferLifecycleEvent.record( + transfer.transferId(), "SUBMITTED", "INPUT", FROM_ADDRESS); + var saved = adapter.save(event); + + var results = adapter.findByTransferId(transfer.transferId()); + assertThat(results).hasSize(1); + assertThat(results.getFirst()) + .usingRecursiveComparison() + .ignoringFieldsOfTypes(Instant.class) + .isEqualTo(saved); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private Wallet saveWallet() { + return walletAdapter.save(anActiveWallet()); + } + + private ChainTransfer saveTransfer(UUID fromWalletId) { + return transferAdapter.save(ChainTransfer.initiate( + PAYMENT_ID, CORRELATION_ID, TransferType.FORWARD, null, + USDC, AMOUNT, fromWalletId, TO_ADDRESS, FROM_ADDRESS)); + } +} diff --git a/blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/infrastructure/persistence/TransferParticipantPersistenceAdapterIT.java b/blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/infrastructure/persistence/TransferParticipantPersistenceAdapterIT.java new file mode 100644 index 00000000..17d9a69e --- /dev/null +++ b/blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/infrastructure/persistence/TransferParticipantPersistenceAdapterIT.java @@ -0,0 +1,126 @@ +package com.stablecoin.payments.custody.infrastructure.persistence; + +import com.stablecoin.payments.custody.AbstractIntegrationTest; +import com.stablecoin.payments.custody.domain.model.ChainTransfer; +import com.stablecoin.payments.custody.domain.model.ParticipantType; +import com.stablecoin.payments.custody.domain.model.TransferParticipant; +import com.stablecoin.payments.custody.domain.model.TransferType; +import com.stablecoin.payments.custody.domain.model.Wallet; +import com.stablecoin.payments.custody.domain.port.ChainTransferRepository; +import com.stablecoin.payments.custody.domain.port.TransferParticipantRepository; +import com.stablecoin.payments.custody.domain.port.WalletRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.AMOUNT; +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.PAYMENT_ID; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.TO_ADDRESS; +import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.USDC; +import static com.stablecoin.payments.custody.fixtures.WalletFixtures.anActiveWallet; +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("TransferParticipantPersistenceAdapter IT") +class TransferParticipantPersistenceAdapterIT extends AbstractIntegrationTest { + + @Autowired + private TransferParticipantRepository adapter; + + @Autowired + private ChainTransferRepository transferAdapter; + + @Autowired + private WalletRepository walletAdapter; + + // ── Save & Retrieve ────────────────────────────────────────────────── + + @Test + @DisplayName("should save and retrieve by transfer id") + void shouldSaveAndRetrieveByTransferId() { + var wallet = saveWallet(); + var transfer = saveTransfer(wallet.walletId()); + + var participant = TransferParticipant.create( + transfer.transferId(), ParticipantType.INPUT, + FROM_ADDRESS, wallet.walletId(), + new BigDecimal("1000.00"), "USDC"); + var saved = adapter.save(participant); + + var results = adapter.findByTransferId(transfer.transferId()); + assertThat(results).hasSize(1); + assertThat(results.getFirst()) + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFieldsOfTypes(Instant.class) + .isEqualTo(saved); + } + + @Test + @DisplayName("should return empty list for unknown transfer id") + void shouldReturnEmptyListForUnknownTransferId() { + assertThat(adapter.findByTransferId(UUID.randomUUID())).isEmpty(); + } + + // ── Multiple Participants ──────────────────────────────────────────── + + @Test + @DisplayName("should save multiple participants for same transfer") + void shouldSaveMultipleParticipantsForSameTransfer() { + var wallet = saveWallet(); + var transfer = saveTransfer(wallet.walletId()); + + adapter.save(TransferParticipant.create( + transfer.transferId(), ParticipantType.INPUT, + FROM_ADDRESS, wallet.walletId(), + new BigDecimal("1000.00"), "USDC")); + + adapter.save(TransferParticipant.create( + transfer.transferId(), ParticipantType.OUTPUT, + TO_ADDRESS, null, + new BigDecimal("1000.00"), "USDC")); + + adapter.save(TransferParticipant.create( + transfer.transferId(), ParticipantType.FEE, + FROM_ADDRESS, wallet.walletId(), + new BigDecimal("0.50"), "ETH")); + + assertThat(adapter.findByTransferId(transfer.transferId())).hasSize(3); + } + + // ── Enum Round-Trip ────────────────────────────────────────────────── + + @Test + @DisplayName("should persist all participant types") + void shouldPersistAllParticipantTypes() { + var wallet = saveWallet(); + var transfer = saveTransfer(wallet.walletId()); + + for (var type : ParticipantType.values()) { + adapter.save(TransferParticipant.create( + transfer.transferId(), type, + FROM_ADDRESS, wallet.walletId(), + new BigDecimal("100.00"), "USDC")); + } + + assertThat(adapter.findByTransferId(transfer.transferId())) + .hasSize(ParticipantType.values().length); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private Wallet saveWallet() { + return walletAdapter.save(anActiveWallet()); + } + + private ChainTransfer saveTransfer(UUID fromWalletId) { + return transferAdapter.save(ChainTransfer.initiate( + PAYMENT_ID, CORRELATION_ID, TransferType.FORWARD, null, + USDC, AMOUNT, fromWalletId, TO_ADDRESS, FROM_ADDRESS)); + } +} diff --git a/blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/infrastructure/persistence/WalletBalancePersistenceAdapterIT.java b/blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/infrastructure/persistence/WalletBalancePersistenceAdapterIT.java new file mode 100644 index 00000000..e61e81d6 --- /dev/null +++ b/blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/infrastructure/persistence/WalletBalancePersistenceAdapterIT.java @@ -0,0 +1,157 @@ +package com.stablecoin.payments.custody.infrastructure.persistence; + +import com.stablecoin.payments.custody.AbstractIntegrationTest; +import com.stablecoin.payments.custody.domain.model.Wallet; +import com.stablecoin.payments.custody.domain.model.WalletBalance; +import com.stablecoin.payments.custody.domain.port.WalletBalanceRepository; +import com.stablecoin.payments.custody.domain.port.WalletRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.UUID; + +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.WalletFixtures.anActiveWallet; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("WalletBalancePersistenceAdapter IT") +class WalletBalancePersistenceAdapterIT extends AbstractIntegrationTest { + + @Autowired + private WalletBalanceRepository adapter; + + @Autowired + private WalletRepository walletAdapter; + + // ── Save & Retrieve ────────────────────────────────────────────────── + + @Test + @DisplayName("should save and retrieve zero balance") + void shouldSaveAndRetrieveZeroBalance() { + var wallet = saveWallet(); + var balance = WalletBalance.initialize(wallet.walletId(), CHAIN_BASE, USDC); + var saved = adapter.save(balance); + + assertThat(adapter.findByWalletIdAndStablecoin(wallet.walletId(), USDC)).isPresent().get() + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFieldsOfTypes(Instant.class) + .ignoringFields("version") + .isEqualTo(saved); + } + + @Test + @DisplayName("should find by wallet id and stablecoin") + void shouldFindByWalletIdAndStablecoin() { + var wallet = saveWallet(); + var balance = WalletBalance.initialize(wallet.walletId(), CHAIN_BASE, USDC); + var saved = adapter.save(balance); + + assertThat(adapter.findByWalletIdAndStablecoin(wallet.walletId(), USDC)).isPresent().get() + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFieldsOfTypes(Instant.class) + .ignoringFields("version") + .isEqualTo(saved); + } + + @Test + @DisplayName("should find by wallet id") + void shouldFindByWalletId() { + var wallet = saveWallet(); + adapter.save(WalletBalance.initialize(wallet.walletId(), CHAIN_BASE, USDC)); + + assertThat(adapter.findByWalletId(wallet.walletId())).hasSize(1); + } + + @Test + @DisplayName("should return empty when wallet id and stablecoin not found") + void shouldReturnEmptyWhenNotFound() { + assertThat(adapter.findByWalletIdAndStablecoin(UUID.randomUUID(), USDC)).isEmpty(); + } + + // ── Balance Operations ─────────────────────────────────────────────── + + @Test + @DisplayName("should update balance after reserve") + void shouldUpdateBalanceAfterReserve() { + var wallet = saveWallet(); + var balance = WalletBalance.initialize(wallet.walletId(), CHAIN_BASE, USDC); + var saved = adapter.save(balance); + + var synced = adapter.save(saved.syncFromChain(new BigDecimal("500000.00"), 100L)); + var expected = adapter.save(synced.reserve(new BigDecimal("1000.00"))); + + assertThat(adapter.findByWalletIdAndStablecoin(wallet.walletId(), USDC)).isPresent().get() + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFieldsOfTypes(Instant.class) + .ignoringFields("version") + .isEqualTo(expected); + } + + @Test + @DisplayName("should update balance after release and confirm debit") + void shouldUpdateBalanceAfterReleaseAndConfirmDebit() { + var wallet = saveWallet(); + var balance = WalletBalance.initialize(wallet.walletId(), CHAIN_BASE, USDC); + var saved = adapter.save(balance); + + saved = adapter.save(saved.syncFromChain(new BigDecimal("500000.00"), 100L)); + saved = adapter.save(saved.reserve(new BigDecimal("1000.00"))); + saved = adapter.save(saved.release(new BigDecimal("200.00"))); + var expected = adapter.save(saved.confirmDebit(new BigDecimal("800.00"))); + + assertThat(adapter.findByWalletIdAndStablecoin(wallet.walletId(), USDC)).isPresent().get() + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFieldsOfTypes(Instant.class) + .ignoringFields("version") + .isEqualTo(expected); + } + + // ── Unique Constraints ─────────────────────────────────────────────── + + @Test + @DisplayName("should enforce unique wallet stablecoin constraint") + void shouldEnforceUniqueWalletStablecoinConstraint() { + var wallet = saveWallet(); + adapter.save(WalletBalance.initialize(wallet.walletId(), CHAIN_BASE, USDC)); + + var duplicate = WalletBalance.initialize(wallet.walletId(), CHAIN_BASE, USDC); + + assertThatThrownBy(() -> adapter.save(duplicate)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + // ── Decimal Precision ──────────────────────────────────────────────── + + @Test + @DisplayName("should persist decimal precision with 8 decimal places") + void shouldPersistDecimalPrecision() { + var wallet = saveWallet(); + var balance = WalletBalance.initialize(wallet.walletId(), CHAIN_BASE, USDC); + var saved = adapter.save(balance); + var expected = adapter.save( + saved.syncFromChain(new BigDecimal("123456.12345678"), 200L)); + + assertThat(adapter.findByWalletIdAndStablecoin(wallet.walletId(), USDC)).isPresent().get() + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFieldsOfTypes(Instant.class) + .ignoringFields("version") + .isEqualTo(expected); + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private Wallet saveWallet() { + return walletAdapter.save(anActiveWallet()); + } +} diff --git a/blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/infrastructure/persistence/WalletPersistenceAdapterIT.java b/blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/infrastructure/persistence/WalletPersistenceAdapterIT.java new file mode 100644 index 00000000..01326865 --- /dev/null +++ b/blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/infrastructure/persistence/WalletPersistenceAdapterIT.java @@ -0,0 +1,113 @@ +package com.stablecoin.payments.custody.infrastructure.persistence; + +import com.stablecoin.payments.custody.AbstractIntegrationTest; +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; +import com.stablecoin.payments.custody.domain.port.WalletRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; + +import java.time.Instant; +import java.util.UUID; + +import static com.stablecoin.payments.custody.fixtures.WalletFixtures.ADDRESS; +import static com.stablecoin.payments.custody.fixtures.WalletFixtures.CHAIN_BASE; +import static com.stablecoin.payments.custody.fixtures.WalletFixtures.PURPOSE_ON_RAMP; +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("WalletPersistenceAdapter IT") +class WalletPersistenceAdapterIT extends AbstractIntegrationTest { + + @Autowired + private WalletRepository adapter; + + // ── Save & Retrieve ────────────────────────────────────────────────── + + @Test + @DisplayName("should save and retrieve active wallet") + void shouldSaveAndRetrieveActiveWallet() { + var wallet = anActiveWallet(); + var saved = adapter.save(wallet); + + assertThat(adapter.findById(saved.walletId())).isPresent().get() + .usingRecursiveComparison() + .ignoringFieldsOfTypes(Instant.class) + .isEqualTo(saved); + } + + @Test + @DisplayName("should find by address") + void shouldFindByAddress() { + var saved = adapter.save(anActiveWallet()); + + assertThat(adapter.findByAddress(ADDRESS)).isPresent().get() + .usingRecursiveComparison() + .ignoringFieldsOfTypes(Instant.class) + .isEqualTo(saved); + } + + @Test + @DisplayName("should return empty when id not found") + void shouldReturnEmptyWhenIdNotFound() { + assertThat(adapter.findById(UUID.randomUUID())).isEmpty(); + } + + @Test + @DisplayName("should return empty when address not found") + void shouldReturnEmptyWhenAddressNotFound() { + assertThat(adapter.findByAddress("0xNonExistent")).isEmpty(); + } + + // ── Query by Chain & Purpose ───────────────────────────────────────── + + @Test + @DisplayName("should find by chain id and purpose only active wallets") + void shouldFindByChainIdAndPurposeOnlyActiveWallets() { + var active = adapter.save(anActiveWallet()); + var deactivated = Wallet.create( + CHAIN_BASE, "0xDeactivatedWallet999", "0xDeactivatedWallet999", + WalletTier.HOT, WalletPurpose.ON_RAMP, "fireblocks", "vault-002", + StablecoinTicker.of("USDC")); + var savedDeactivated = adapter.save(deactivated); + adapter.save(savedDeactivated.deactivate()); + + var results = adapter.findByChainIdAndPurpose(CHAIN_BASE, PURPOSE_ON_RAMP); + assertThat(results).hasSize(1); + } + + // ── Update (Deactivate) ────────────────────────────────────────────── + + @Test + @DisplayName("should update wallet to deactivated") + void shouldUpdateWalletToDeactivated() { + var saved = adapter.save(anActiveWallet()); + var expected = adapter.save(saved.deactivate()); + + assertThat(adapter.findById(saved.walletId())).isPresent().get() + .usingRecursiveComparison() + .ignoringFieldsOfTypes(Instant.class) + .isEqualTo(expected); + } + + // ── Unique Constraints ─────────────────────────────────────────────── + + @Test + @DisplayName("should enforce unique address and chain constraint") + void shouldEnforceUniqueAddressAndChainConstraint() { + adapter.save(anActiveWallet()); + + var duplicate = Wallet.create( + CHAIN_BASE, ADDRESS, "0xDifferentChecksum", + WalletTier.WARM, WalletPurpose.OFF_RAMP, "fireblocks", "vault-003", + StablecoinTicker.of("USDT")); + + assertThatThrownBy(() -> adapter.save(duplicate)) + .isInstanceOf(DataIntegrityViolationException.class); + } +} diff --git a/blockchain-custody/blockchain-custody/src/main/resources/db/migration/V4__fix_numeric_to_bigint.sql b/blockchain-custody/blockchain-custody/src/main/resources/db/migration/V4__fix_numeric_to_bigint.sql new file mode 100644 index 00000000..741e289c --- /dev/null +++ b/blockchain-custody/blockchain-custody/src/main/resources/db/migration/V4__fix_numeric_to_bigint.sql @@ -0,0 +1,11 @@ +-- ============================================================ +-- V4: Fix NUMERIC(20,0) → BIGINT for Long-mapped columns +-- Hibernate maps Java Long to BIGINT, not NUMERIC. +-- ============================================================ + +ALTER TABLE chain_transfers ALTER COLUMN block_number TYPE BIGINT; +ALTER TABLE chain_transfers ALTER COLUMN nonce TYPE BIGINT; + +ALTER TABLE wallet_balances ALTER COLUMN last_indexed_block TYPE BIGINT; + +ALTER TABLE wallet_nonces ALTER COLUMN current_nonce TYPE BIGINT;