diff --git a/payment-orchestrator/payment-orchestrator/src/integration-test/java/com/stablecoin/payments/orchestrator/AbstractIntegrationTest.java b/payment-orchestrator/payment-orchestrator/src/integration-test/java/com/stablecoin/payments/orchestrator/AbstractIntegrationTest.java index 23f61c6a..418be834 100644 --- a/payment-orchestrator/payment-orchestrator/src/integration-test/java/com/stablecoin/payments/orchestrator/AbstractIntegrationTest.java +++ b/payment-orchestrator/payment-orchestrator/src/integration-test/java/com/stablecoin/payments/orchestrator/AbstractIntegrationTest.java @@ -1,5 +1,7 @@ package com.stablecoin.payments.orchestrator; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; @@ -34,6 +36,21 @@ public abstract class AbstractIntegrationTest { @Autowired private JdbcTemplate jdbcTemplate; + @Autowired + private EntityManager entityManager; + + @BeforeEach + void cleanDatabase() { + jdbcTemplate.execute(""" + TRUNCATE TABLE + payment_audit_log, + payments, + orchestrator_outbox_record + CASCADE + """); + entityManager.clear(); + } + @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); diff --git a/payment-orchestrator/payment-orchestrator/src/integration-test/java/com/stablecoin/payments/orchestrator/config/TestKafkaConfig.java b/payment-orchestrator/payment-orchestrator/src/integration-test/java/com/stablecoin/payments/orchestrator/config/TestKafkaConfig.java new file mode 100644 index 00000000..ca24c092 --- /dev/null +++ b/payment-orchestrator/payment-orchestrator/src/integration-test/java/com/stablecoin/payments/orchestrator/config/TestKafkaConfig.java @@ -0,0 +1,35 @@ +package com.stablecoin.payments.orchestrator.config; + +import org.apache.kafka.clients.producer.ProducerConfig; +import org.apache.kafka.common.serialization.StringSerializer; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.kafka.core.DefaultKafkaProducerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.core.ProducerFactory; +import org.springframework.kafka.support.serializer.JsonSerializer; + +import java.util.Map; + +@Configuration +public class TestKafkaConfig { + + @Bean + @Primary + public ProducerFactory producerFactory( + @Value("${spring.kafka.bootstrap-servers}") String bootstrapServers) { + return new DefaultKafkaProducerFactory<>(Map.of( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers, + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class, + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, JsonSerializer.class)); + } + + @Bean + @Primary + public KafkaTemplate kafkaTemplate( + ProducerFactory producerFactory) { + return new KafkaTemplate<>(producerFactory); + } +} diff --git a/payment-orchestrator/payment-orchestrator/src/integration-test/java/com/stablecoin/payments/orchestrator/infrastructure/persistence/PaymentAuditLogRepositoryIT.java b/payment-orchestrator/payment-orchestrator/src/integration-test/java/com/stablecoin/payments/orchestrator/infrastructure/persistence/PaymentAuditLogRepositoryIT.java new file mode 100644 index 00000000..3c31c7dc --- /dev/null +++ b/payment-orchestrator/payment-orchestrator/src/integration-test/java/com/stablecoin/payments/orchestrator/infrastructure/persistence/PaymentAuditLogRepositoryIT.java @@ -0,0 +1,232 @@ +package com.stablecoin.payments.orchestrator.infrastructure.persistence; + +import com.stablecoin.payments.orchestrator.AbstractIntegrationTest; +import com.stablecoin.payments.orchestrator.infrastructure.persistence.entity.PaymentAuditLogEntity; +import com.stablecoin.payments.orchestrator.infrastructure.persistence.entity.PaymentAuditLogJpaRepository; +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.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("PaymentAuditLogRepository IT") +class PaymentAuditLogRepositoryIT extends AbstractIntegrationTest { + + @Autowired + private PaymentAuditLogJpaRepository auditLogRepository; + + // ── Append-only insert ────────────────────────────────────────────── + + @Test + @DisplayName("should insert audit log entry and retrieve by id") + void shouldInsertAuditLogEntryAndRetrieveById() { + var paymentId = UUID.randomUUID(); + var logEntry = PaymentAuditLogEntity.builder() + .logId(UUID.randomUUID()) + .paymentId(paymentId) + .fromState(null) + .toState("INITIATED") + .triggeredBy("SYSTEM") + .actor("payment-orchestrator") + .metadata(Map.of("source", "api")) + .occurredAt(Instant.now()) + .build(); + + var saved = auditLogRepository.save(logEntry); + + var expected = PaymentAuditLogEntity.builder() + .logId(saved.getLogId()) + .paymentId(paymentId) + .fromState(null) + .toState("INITIATED") + .triggeredBy("SYSTEM") + .actor("payment-orchestrator") + .metadata(Map.of("source", "api")) + .occurredAt(saved.getOccurredAt()) + .build(); + + assertThat(auditLogRepository.findById(saved.getLogId())).isPresent().get() + .usingRecursiveComparison() + .ignoringFieldsOfTypes(Instant.class) + .isEqualTo(expected); + } + + @Test + @DisplayName("should insert multiple audit log entries for same payment") + void shouldInsertMultipleAuditLogEntriesForSamePayment() { + var paymentId = UUID.randomUUID(); + var now = Instant.now(); + + var entry1 = PaymentAuditLogEntity.builder() + .logId(UUID.randomUUID()) + .paymentId(paymentId) + .fromState(null) + .toState("INITIATED") + .triggeredBy("INITIATE") + .actor("system") + .metadata(Map.of()) + .occurredAt(now.minusSeconds(30)) + .build(); + + var entry2 = PaymentAuditLogEntity.builder() + .logId(UUID.randomUUID()) + .paymentId(paymentId) + .fromState("INITIATED") + .toState("COMPLIANCE_CHECK") + .triggeredBy("START_COMPLIANCE") + .actor("system") + .metadata(Map.of()) + .occurredAt(now.minusSeconds(20)) + .build(); + + var entry3 = PaymentAuditLogEntity.builder() + .logId(UUID.randomUUID()) + .paymentId(paymentId) + .fromState("COMPLIANCE_CHECK") + .toState("FX_LOCKED") + .triggeredBy("COMPLIANCE_PASSED") + .actor("compliance-service") + .metadata(Map.of("provider", "ecb")) + .occurredAt(now.minusSeconds(10)) + .build(); + + auditLogRepository.save(entry1); + auditLogRepository.save(entry2); + auditLogRepository.save(entry3); + + var results = auditLogRepository.findByPaymentIdOrderByOccurredAtDesc(paymentId); + assertThat(results).hasSize(3); + } + + // ── Query by payment_id ordered by occurred_at desc ───────────────── + + @Test + @DisplayName("should return audit log entries ordered by occurred_at descending") + void shouldReturnAuditLogEntriesOrderedByOccurredAtDesc() { + var paymentId = UUID.randomUUID(); + var now = Instant.now(); + + var earliest = PaymentAuditLogEntity.builder() + .logId(UUID.randomUUID()) + .paymentId(paymentId) + .fromState(null) + .toState("INITIATED") + .triggeredBy("INITIATE") + .actor("system") + .metadata(Map.of()) + .occurredAt(now.minusSeconds(60)) + .build(); + + var middle = PaymentAuditLogEntity.builder() + .logId(UUID.randomUUID()) + .paymentId(paymentId) + .fromState("INITIATED") + .toState("COMPLIANCE_CHECK") + .triggeredBy("START_COMPLIANCE") + .actor("system") + .metadata(Map.of()) + .occurredAt(now.minusSeconds(30)) + .build(); + + var latest = PaymentAuditLogEntity.builder() + .logId(UUID.randomUUID()) + .paymentId(paymentId) + .fromState("COMPLIANCE_CHECK") + .toState("FX_LOCKED") + .triggeredBy("COMPLIANCE_PASSED") + .actor("compliance-service") + .metadata(Map.of()) + .occurredAt(now) + .build(); + + // Insert in non-chronological order to verify sorting + auditLogRepository.save(middle); + auditLogRepository.save(latest); + auditLogRepository.save(earliest); + + var results = auditLogRepository.findByPaymentIdOrderByOccurredAtDesc(paymentId); + assertThat(results).hasSize(3); + assertThat(results.get(0).getToState()).isEqualTo("FX_LOCKED"); + assertThat(results.get(1).getToState()).isEqualTo("COMPLIANCE_CHECK"); + assertThat(results.get(2).getToState()).isEqualTo("INITIATED"); + } + + @Test + @DisplayName("should return empty list when no audit logs exist for payment") + void shouldReturnEmptyListWhenNoAuditLogsExistForPayment() { + var results = auditLogRepository.findByPaymentIdOrderByOccurredAtDesc(UUID.randomUUID()); + assertThat(results).isEmpty(); + } + + @Test + @DisplayName("should not return audit logs for different payment id") + void shouldNotReturnAuditLogsForDifferentPaymentId() { + var paymentId1 = UUID.randomUUID(); + var paymentId2 = UUID.randomUUID(); + + var entry1 = PaymentAuditLogEntity.builder() + .logId(UUID.randomUUID()) + .paymentId(paymentId1) + .fromState(null) + .toState("INITIATED") + .triggeredBy("INITIATE") + .actor("system") + .metadata(Map.of()) + .occurredAt(Instant.now()) + .build(); + + var entry2 = PaymentAuditLogEntity.builder() + .logId(UUID.randomUUID()) + .paymentId(paymentId2) + .fromState(null) + .toState("INITIATED") + .triggeredBy("INITIATE") + .actor("system") + .metadata(Map.of()) + .occurredAt(Instant.now()) + .build(); + + auditLogRepository.save(entry1); + auditLogRepository.save(entry2); + + var results = auditLogRepository.findByPaymentIdOrderByOccurredAtDesc(paymentId1); + assertThat(results).hasSize(1); + assertThat(results.get(0).getPaymentId()).isEqualTo(paymentId1); + } + + // ── JSONB metadata round-trip ─────────────────────────────────────── + + @Test + @DisplayName("should persist and retrieve JSONB metadata in audit log") + void shouldPersistAndRetrieveJsonbMetadataInAuditLog() { + var paymentId = UUID.randomUUID(); + var metadata = Map.of( + "reason", "compliance_passed", + "riskScore", "25", + "provider", "chainalysis" + ); + + var entry = PaymentAuditLogEntity.builder() + .logId(UUID.randomUUID()) + .paymentId(paymentId) + .fromState("COMPLIANCE_CHECK") + .toState("FX_LOCKED") + .triggeredBy("COMPLIANCE_PASSED") + .actor("compliance-service") + .metadata(metadata) + .occurredAt(Instant.now()) + .build(); + + auditLogRepository.save(entry); + + var results = auditLogRepository.findByPaymentIdOrderByOccurredAtDesc(paymentId); + assertThat(results).hasSize(1); + assertThat(results.get(0).getMetadata()).containsEntry("reason", "compliance_passed"); + assertThat(results.get(0).getMetadata()).containsEntry("riskScore", "25"); + assertThat(results.get(0).getMetadata()).containsEntry("provider", "chainalysis"); + } +} diff --git a/payment-orchestrator/payment-orchestrator/src/integration-test/java/com/stablecoin/payments/orchestrator/infrastructure/persistence/PaymentPersistenceAdapterIT.java b/payment-orchestrator/payment-orchestrator/src/integration-test/java/com/stablecoin/payments/orchestrator/infrastructure/persistence/PaymentPersistenceAdapterIT.java new file mode 100644 index 00000000..354056ac --- /dev/null +++ b/payment-orchestrator/payment-orchestrator/src/integration-test/java/com/stablecoin/payments/orchestrator/infrastructure/persistence/PaymentPersistenceAdapterIT.java @@ -0,0 +1,308 @@ +package com.stablecoin.payments.orchestrator.infrastructure.persistence; + +import com.stablecoin.payments.orchestrator.AbstractIntegrationTest; +import com.stablecoin.payments.orchestrator.domain.model.Money; +import com.stablecoin.payments.orchestrator.domain.model.Payment; +import com.stablecoin.payments.orchestrator.domain.model.PaymentState; +import com.stablecoin.payments.orchestrator.infrastructure.persistence.entity.PaymentJpaRepository; +import com.stablecoin.payments.orchestrator.infrastructure.persistence.mapper.PaymentPersistenceMapper; +import jakarta.persistence.EntityManager; +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 org.springframework.orm.ObjectOptimisticLockingFailureException; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.Map; +import java.util.UUID; + +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.BASE_CHAIN; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.CORRELATION_ID; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.IDEMPOTENCY_KEY; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.RECIPIENT_ID; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.SENDER_ID; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.SOURCE_AMOUNT; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.SOURCE_CURRENCY; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.TARGET_CURRENCY; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.TX_HASH; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.US_TO_DE; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.aCompletedPayment; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.aValidFxRate; +import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.anInitiatedPayment; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("PaymentPersistenceAdapter IT") +class PaymentPersistenceAdapterIT extends AbstractIntegrationTest { + + @Autowired + private PaymentPersistenceAdapter adapter; + + @Autowired + private PaymentJpaRepository jpaRepository; + + @Autowired + private PaymentPersistenceMapper mapper; + + @Autowired + private EntityManager entityManager; + + // ── Save & Retrieve ───────────────────────────────────────────────── + + @Test + @DisplayName("should save and retrieve payment with all value objects") + void shouldSaveAndRetrievePaymentWithAllValueObjects() { + var payment = aCompletedPayment(); + var saved = adapter.save(payment); + + assertThat(adapter.findById(saved.paymentId())).isPresent().get() + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFieldsOfTypes(Instant.class) + .isEqualTo(saved); + } + + @Test + @DisplayName("should save initiated payment and retrieve by id") + void shouldSaveInitiatedPaymentAndRetrieveById() { + var payment = anInitiatedPayment(); + var saved = adapter.save(payment); + + assertThat(adapter.findById(saved.paymentId())).isPresent().get() + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFieldsOfTypes(Instant.class) + .isEqualTo(saved); + } + + @Test + @DisplayName("should return empty when payment id not found") + void shouldReturnEmptyWhenPaymentIdNotFound() { + assertThat(adapter.findById(UUID.randomUUID())).isEmpty(); + } + + // ── findByIdempotencyKey ──────────────────────────────────────────── + + @Test + @DisplayName("should find payment by idempotency key") + void shouldFindPaymentByIdempotencyKey() { + var payment = anInitiatedPayment(); + var saved = adapter.save(payment); + + assertThat(adapter.findByIdempotencyKey(IDEMPOTENCY_KEY)).isPresent().get() + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFieldsOfTypes(Instant.class) + .isEqualTo(saved); + } + + @Test + @DisplayName("should return empty when idempotency key not found") + void shouldReturnEmptyWhenIdempotencyKeyNotFound() { + assertThat(adapter.findByIdempotencyKey("non-existent-key")).isEmpty(); + } + + // ── findBySenderIdAndState ────────────────────────────────────────── + + @Test + @DisplayName("should find payments by sender id and state") + void shouldFindPaymentsBySenderIdAndState() { + var payment1 = Payment.initiate( + "idem-sender-1", CORRELATION_ID, SENDER_ID, RECIPIENT_ID, + SOURCE_AMOUNT, SOURCE_CURRENCY, TARGET_CURRENCY, US_TO_DE); + var payment2 = Payment.initiate( + "idem-sender-2", CORRELATION_ID, SENDER_ID, RECIPIENT_ID, + SOURCE_AMOUNT, SOURCE_CURRENCY, TARGET_CURRENCY, US_TO_DE); + adapter.save(payment1); + adapter.save(payment2); + + var results = adapter.findBySenderIdAndState(SENDER_ID, PaymentState.INITIATED); + assertThat(results).hasSize(2); + } + + @Test + @DisplayName("should return empty list when no payments match sender and state") + void shouldReturnEmptyListWhenNoPaymentsMatchSenderAndState() { + var results = adapter.findBySenderIdAndState(UUID.randomUUID(), PaymentState.COMPLETED); + assertThat(results).isEmpty(); + } + + // ── JSONB metadata round-trip ─────────────────────────────────────── + + @Test + @DisplayName("should persist and retrieve JSONB metadata") + void shouldPersistAndRetrieveJsonbMetadata() { + var payment = anInitiatedPayment(); + var withMetadata = new Payment( + payment.paymentId(), + "idem-metadata-" + UUID.randomUUID(), + payment.correlationId(), + payment.state(), + payment.senderId(), + payment.recipientId(), + payment.sourceAmount(), + payment.sourceCurrency(), + payment.targetCurrency(), + payment.lockedFxRate(), + payment.targetAmount(), + payment.corridor(), + payment.chainSelected(), + payment.txHash(), + payment.failureReason(), + payment.createdAt(), + payment.updatedAt(), + payment.expiresAt(), + Map.of("source", "api", "priority", "high", "merchantRef", "M-12345") + ); + adapter.save(withMetadata); + + assertThat(adapter.findById(withMetadata.paymentId())).isPresent().get() + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFieldsOfTypes(Instant.class) + .isEqualTo(withMetadata); + } + + @Test + @DisplayName("should persist empty metadata as empty JSON object") + void shouldPersistEmptyMetadataAsEmptyJsonObject() { + var payment = anInitiatedPayment(); + var saved = adapter.save(payment); + + assertThat(adapter.findById(saved.paymentId())).isPresent().get() + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFieldsOfTypes(Instant.class) + .isEqualTo(saved); + } + + // ── Optimistic locking ────────────────────────────────────────────── + + @Test + @DisplayName("should detect optimistic locking conflict on concurrent update") + void shouldDetectOptimisticLockingConflict() { + var payment = anInitiatedPayment(); + adapter.save(payment); + + // Load entity and detach to simulate a separate session + var entity1 = jpaRepository.findById(payment.paymentId()).orElseThrow(); + var staleVersion = entity1.getVersion(); + + // First update via adapter increments the version + var transitioned = payment.startComplianceCheck(); + adapter.save(transitioned); + + // Build a stale entity with the old version to simulate concurrent access + var staleEntity = mapper.toEntity(payment); + staleEntity.setVersion(staleVersion); + staleEntity.setState(PaymentState.FAILED); + staleEntity.setUpdatedAt(Instant.now()); + + // Clear persistence context so Hibernate treats staleEntity as detached + entityManager.clear(); + + assertThatThrownBy(() -> jpaRepository.saveAndFlush(staleEntity)) + .isInstanceOf(ObjectOptimisticLockingFailureException.class); + } + + // ── Unique constraint ─────────────────────────────────────────────── + + @Test + @DisplayName("should enforce unique constraint on idempotency_key") + void shouldEnforceUniqueConstraintOnIdempotencyKey() { + var idempotencyKey = "unique-idem-" + UUID.randomUUID(); + var payment1 = Payment.initiate( + idempotencyKey, CORRELATION_ID, SENDER_ID, RECIPIENT_ID, + SOURCE_AMOUNT, SOURCE_CURRENCY, TARGET_CURRENCY, US_TO_DE); + adapter.save(payment1); + + var payment2 = Payment.initiate( + idempotencyKey, UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), + new Money(new BigDecimal("500.00"), "USD"), SOURCE_CURRENCY, TARGET_CURRENCY, US_TO_DE); + + assertThatThrownBy(() -> adapter.save(payment2)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + // ── Update existing payment ───────────────────────────────────────── + + @Test + @DisplayName("should update existing payment state transition") + void shouldUpdateExistingPaymentStateTransition() { + var payment = anInitiatedPayment(); + var saved = adapter.save(payment); + + // Transition to COMPLIANCE_CHECK + var transitioned = saved.startComplianceCheck(); + var updated = adapter.save(transitioned); + + var expected = transitioned; + assertThat(adapter.findById(updated.paymentId())).isPresent().get() + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFieldsOfTypes(Instant.class) + .isEqualTo(expected); + } + + @Test + @DisplayName("should update payment with FX rate lock") + void shouldUpdatePaymentWithFxRateLock() { + var payment = anInitiatedPayment(); + adapter.save(payment); + + var complianceCheck = payment.startComplianceCheck(); + adapter.save(complianceCheck); + + var fxLocked = complianceCheck.lockFxRate(aValidFxRate()); + adapter.save(fxLocked); + + assertThat(adapter.findById(payment.paymentId())).isPresent().get() + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFieldsOfTypes(Instant.class) + .isEqualTo(fxLocked); + } + + @Test + @DisplayName("should update payment through full happy path to COMPLETED") + void shouldUpdatePaymentThroughFullHappyPath() { + // Start from INITIATED and walk through the full state machine + var payment = anInitiatedPayment(); + adapter.save(payment); + + var complianceCheck = payment.startComplianceCheck(); + adapter.save(complianceCheck); + + var fxLocked = complianceCheck.lockFxRate(aValidFxRate()); + adapter.save(fxLocked); + + var fiatPending = fxLocked.startFiatCollection(); + adapter.save(fiatPending); + + var fiatCollected = fiatPending.confirmFiatCollected(); + adapter.save(fiatCollected); + + var onChainSubmitted = fiatCollected.submitOnChain(BASE_CHAIN); + adapter.save(onChainSubmitted); + + var onChainConfirmed = onChainSubmitted.confirmOnChain(TX_HASH); + adapter.save(onChainConfirmed); + + var offRampInitiated = onChainConfirmed.initiateOffRamp(); + adapter.save(offRampInitiated); + + var settled = offRampInitiated.settle(); + adapter.save(settled); + + var completed = settled.complete(); + adapter.save(completed); + + assertThat(adapter.findById(payment.paymentId())).isPresent().get() + .usingRecursiveComparison() + .withComparatorForType(BigDecimal::compareTo, BigDecimal.class) + .ignoringFieldsOfTypes(Instant.class) + .isEqualTo(completed); + } +} diff --git a/payment-orchestrator/payment-orchestrator/src/integration-test/resources/application-integration-test.yml b/payment-orchestrator/payment-orchestrator/src/integration-test/resources/application-integration-test.yml index 0705c4c2..e5ba95cd 100644 --- a/payment-orchestrator/payment-orchestrator/src/integration-test/resources/application-integration-test.yml +++ b/payment-orchestrator/payment-orchestrator/src/integration-test/resources/application-integration-test.yml @@ -1,8 +1,37 @@ spring: + main: + allow-bean-definition-overriding: true + jpa: + hibernate: + ddl-auto: validate + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + format_sql: true + open-in-view: false + flyway: + enabled: true + locations: classpath:db/migration + kafka: + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer autoconfigure: exclude: - io.temporal.spring.boot.autoconfigure.AutoDiscoveryProperties +namastack: + outbox: + poll-interval: 50 + batch-size: 20 + jdbc: + table-prefix: "orchestrator_" + schema-initialization: + enabled: false + +server: + port: 0 + app: security: enabled: false @@ -10,3 +39,5 @@ app: logging: level: com.stablecoin.payments: DEBUG + org.springframework.test: INFO + org.testcontainers: INFO diff --git a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/port/PaymentRepository.java b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/port/PaymentRepository.java index 673b1fab..eaec41b9 100644 --- a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/port/PaymentRepository.java +++ b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/domain/port/PaymentRepository.java @@ -1,7 +1,9 @@ package com.stablecoin.payments.orchestrator.domain.port; import com.stablecoin.payments.orchestrator.domain.model.Payment; +import com.stablecoin.payments.orchestrator.domain.model.PaymentState; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -12,4 +14,6 @@ public interface PaymentRepository { Optional findById(UUID paymentId); Optional findByIdempotencyKey(String idempotencyKey); + + List findBySenderIdAndState(UUID senderId, PaymentState state); } diff --git a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/infrastructure/persistence/PaymentPersistenceAdapter.java b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/infrastructure/persistence/PaymentPersistenceAdapter.java index 22e63d53..1f8c87fe 100644 --- a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/infrastructure/persistence/PaymentPersistenceAdapter.java +++ b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/infrastructure/persistence/PaymentPersistenceAdapter.java @@ -1,6 +1,7 @@ package com.stablecoin.payments.orchestrator.infrastructure.persistence; import com.stablecoin.payments.orchestrator.domain.model.Payment; +import com.stablecoin.payments.orchestrator.domain.model.PaymentState; import com.stablecoin.payments.orchestrator.domain.port.PaymentRepository; import com.stablecoin.payments.orchestrator.infrastructure.persistence.entity.PaymentJpaRepository; import com.stablecoin.payments.orchestrator.infrastructure.persistence.mapper.PaymentEntityUpdater; @@ -9,6 +10,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Repository; +import java.util.List; import java.util.Optional; import java.util.UUID; @@ -40,4 +42,11 @@ public Optional findById(UUID paymentId) { public Optional findByIdempotencyKey(String idempotencyKey) { return jpa.findByIdempotencyKey(idempotencyKey).map(mapper::toDomain); } + + @Override + public List findBySenderIdAndState(UUID senderId, PaymentState state) { + return jpa.findBySenderIdAndState(senderId, state).stream() + .map(mapper::toDomain) + .toList(); + } } diff --git a/payment-orchestrator/payment-orchestrator/src/main/resources/db/migration/V3__fix_version_column_type.sql b/payment-orchestrator/payment-orchestrator/src/main/resources/db/migration/V3__fix_version_column_type.sql new file mode 100644 index 00000000..c2c6dbda --- /dev/null +++ b/payment-orchestrator/payment-orchestrator/src/main/resources/db/migration/V3__fix_version_column_type.sql @@ -0,0 +1,7 @@ +-- ============================================================ +-- V3: Fix version column type — INT -> BIGINT +-- Hibernate maps Long to BIGINT; schema had INT causing +-- validation mismatch. +-- ============================================================ + +ALTER TABLE payments ALTER COLUMN version TYPE BIGINT;