-
Notifications
You must be signed in to change notification settings - Fork 0
feat(s1): infrastructure persistence tests -- adapter + audit log ITs (STA-108) #109
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f3cd7dd
fa94806
70a8e7f
5544d0e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<String, Object> 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<String, Object> kafkaTemplate( | ||
| ProducerFactory<String, Object> producerFactory) { | ||
| return new KafkaTemplate<>(producerFactory); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
|
Comment on lines
+52
to
+55
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Donβt exclude This still passes if As per coding guidelines, "Assert on meaningful values β avoid assertTrue(result != null)". π€ Prompt for AI Agents |
||
| } | ||
|
|
||
| @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"); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
π§Ή Nitpick | π΅ Trivial
entityManager.clear()here likely is not clearing what this test expects.Spring's shared
EntityManagerproxy delegates to the current transactionalEntityManagerand otherwise creates a new one per operation, so callingclear()in@BeforeEachis unlikely to evict the persistence context later used by repository calls unless the test itself is transactional. If the goal is to invalidate cached entities after theTRUNCATE, either do the clear inside the same active transaction or drop this line to avoid a false sense of isolation. That's an inference from Spring's sharedEntityManagercontract. (docs.spring.io)π€ Prompt for AI Agents