diff --git a/api-gateway-iam/api-gateway-iam-api/build.gradle.kts b/api-gateway-iam/api-gateway-iam-api/build.gradle.kts index 20c8280f..97d4baa4 100644 --- a/api-gateway-iam/api-gateway-iam-api/build.gradle.kts +++ b/api-gateway-iam/api-gateway-iam-api/build.gradle.kts @@ -1,16 +1,3 @@ plugins { - `java-library` -} - -dependencies { - api("jakarta.validation:jakarta.validation-api") - api("com.fasterxml.jackson.core:jackson-annotations") - - testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation("org.assertj:assertj-core") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { - useJUnitPlatform() + id("stablebridge.api-library") } diff --git a/api-gateway-iam/api-gateway-iam-api/src/main/java/com/stablecoin/payments/gateway/iam/api/response/ApiError.java b/api-gateway-iam/api-gateway-iam-api/src/main/java/com/stablecoin/payments/gateway/iam/api/response/ApiError.java deleted file mode 100644 index a6b8d124..00000000 --- a/api-gateway-iam/api-gateway-iam-api/src/main/java/com/stablecoin/payments/gateway/iam/api/response/ApiError.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.stablecoin.payments.gateway.iam.api.response; - -import java.util.List; -import java.util.Map; - -public record ApiError( - String code, - String status, - String message, - Map> errors -) { - public static ApiError of(String code, String status, String message) { - return new ApiError(code, status, message, Map.of()); - } - - public static ApiError withErrors(String code, String status, String message, - Map> errors) { - return new ApiError(code, status, message, errors); - } -} diff --git a/api-gateway-iam/api-gateway-iam-client/build.gradle.kts b/api-gateway-iam/api-gateway-iam-client/build.gradle.kts index c46e8121..611cb783 100644 --- a/api-gateway-iam/api-gateway-iam-client/build.gradle.kts +++ b/api-gateway-iam/api-gateway-iam-client/build.gradle.kts @@ -1,16 +1,7 @@ plugins { - `java-library` + id("stablebridge.client-library") } dependencies { api(project(":api-gateway-iam:api-gateway-iam-api")) - implementation("org.springframework.cloud:spring-cloud-starter-openfeign") - - testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation("org.assertj:assertj-core") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { - useJUnitPlatform() } diff --git a/api-gateway-iam/api-gateway-iam/build.gradle.kts b/api-gateway-iam/api-gateway-iam/build.gradle.kts index 4b49ed8e..42844450 100644 --- a/api-gateway-iam/api-gateway-iam/build.gradle.kts +++ b/api-gateway-iam/api-gateway-iam/build.gradle.kts @@ -1,202 +1,18 @@ plugins { - id("org.springframework.boot") - id("com.google.cloud.tools.jib") - java - `java-test-fixtures` - jacoco + id("stablebridge.service") } -jib { - from { - image = "docker://eclipse-temurin:25-jre" - } - to { - image = "stablebridge/api-gateway-iam" - tags = setOf("latest") - } - container { - creationTime.set("USE_CURRENT_TIMESTAMP") - } +stablebridge { + jibImageName.set("stablebridge/api-gateway-iam") + jacocoMinimum.set("0.45") } -val integrationTestSourceSet: SourceSet = sourceSets.create("integrationTest") { - java.srcDir("src/integration-test/java") - resources.srcDir("src/integration-test/resources") - compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output - runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output -} - -configurations { - named("integrationTestImplementation") { extendsFrom(configurations.testImplementation.get()) } - named("integrationTestRuntimeOnly") { extendsFrom(configurations.testRuntimeOnly.get()) } -} - -tasks.register("integrationTest") { - testClassesDirs = integrationTestSourceSet.output.classesDirs - classpath = integrationTestSourceSet.runtimeClasspath - shouldRunAfter(tasks.test) - configure { isEnabled = false } -} - -val businessTestSourceSet: SourceSet = sourceSets.create("businessTest") { - java.srcDir("src/business-test/java") - resources.srcDir("src/business-test/resources") - compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output + integrationTestSourceSet.output - runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output + integrationTestSourceSet.output -} - -configurations { - named("businessTestImplementation") { extendsFrom(configurations.named("integrationTestImplementation").get()) } - named("businessTestRuntimeOnly") { extendsFrom(configurations.named("integrationTestRuntimeOnly").get()) } -} - -tasks.register("businessTest") { - testClassesDirs = businessTestSourceSet.output.classesDirs - classpath = businessTestSourceSet.runtimeClasspath - shouldRunAfter(tasks.named("integrationTest")) - configure { isEnabled = false } -} - -val lombokVersion: String by project -val mapstructVersion: String by project -val lombokMapstructBindingVersion: String by project -val resilience4jVersion: String by project -val flywayVersion: String by project -val archunitVersion: String by project -val testcontainersVersion: String by project -val wiremockVersion: String by project -val springdocVersion: String by project - dependencies { implementation(project(":api-gateway-iam:api-gateway-iam-api")) - // Spring Boot - implementation("org.springframework.boot:spring-boot-starter-web") - implementation("org.springframework.boot:spring-boot-starter-validation") - implementation("org.springframework.boot:spring-boot-starter-data-jpa") - implementation("org.springframework.boot:spring-boot-starter-actuator") - - // OpenAPI / Swagger UI - implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$springdocVersion") - runtimeOnly("io.micrometer:micrometer-registry-prometheus") - implementation("io.micrometer:micrometer-tracing-bridge-otel") - implementation("io.opentelemetry:opentelemetry-exporter-otlp") - implementation("org.springframework.boot:spring-boot-starter-security") + // Redis implementation("org.springframework.boot:spring-boot-starter-data-redis") - // Kafka via Spring Cloud Stream - implementation("org.springframework.cloud:spring-cloud-stream") - implementation("org.springframework.cloud:spring-cloud-stream-binder-kafka") - implementation("org.springframework.kafka:spring-kafka") - - // Feign - implementation("org.springframework.cloud:spring-cloud-starter-openfeign") - - // Resilience4j - implementation("io.github.resilience4j:resilience4j-spring-boot3:$resilience4jVersion") - implementation("io.github.resilience4j:resilience4j-circuitbreaker:$resilience4jVersion") - - // MapStruct - implementation("org.mapstruct:mapstruct:$mapstructVersion") - annotationProcessor("org.mapstruct:mapstruct-processor:$mapstructVersion") - annotationProcessor("org.projectlombok:lombok-mapstruct-binding:$lombokMapstructBindingVersion") - // Auth — JWT ES256 (Nimbus, BOM-managed via oauth2-jose) implementation("org.springframework.security:spring-security-oauth2-jose") - - // Database - runtimeOnly("org.postgresql:postgresql") - implementation("org.springframework.boot:spring-boot-starter-flyway") - implementation("org.flywaydb:flyway-database-postgresql:$flywayVersion") - - // Outbox (namastack) - implementation("io.namastack:namastack-outbox-starter-jdbc:1.1.0") - - // Test fixtures - testFixturesImplementation("org.assertj:assertj-core") - testFixturesImplementation("org.mockito:mockito-core") - - // Test - testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("org.springframework.kafka:spring-kafka-test") - testImplementation("com.tngtech.archunit:archunit-junit5:$archunitVersion") - "integrationTestImplementation"(testFixtures(project)) - "integrationTestImplementation"("org.testcontainers:postgresql:$testcontainersVersion") - "integrationTestImplementation"("org.testcontainers:kafka:$testcontainersVersion") - "integrationTestImplementation"("org.testcontainers:junit-jupiter:$testcontainersVersion") - "integrationTestImplementation"("org.wiremock:wiremock-standalone:$wiremockVersion") - "integrationTestImplementation"("org.springframework.boot:spring-boot-starter-webmvc-test") - "integrationTestImplementation"("org.springframework.boot:spring-boot-starter-security-test") -} - -tasks.withType { - options.compilerArgs.addAll(listOf( - "-Amapstruct.defaultComponentModel=spring", - "-Amapstruct.unmappedTargetPolicy=IGNORE" - )) -} - -tasks.withType { - jvmArgs("-Dnet.bytebuddy.experimental=true") - useJUnitPlatform() - testLogging { - events("passed", "skipped", "failed") - showExceptions = true - showCauses = true - showStackTraces = true - exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL - } -} - -jacoco { - toolVersion = "0.8.14" -} - -tasks.test { - configure { - excludes = listOf("sun.*", "jdk.*", "com.sun.*", "java.*", "javax.*") - } - finalizedBy(tasks.jacocoTestReport) -} - -val jacocoExclusions = listOf( - "**/entity/**", - "**/mapper/**", - "**/config/**", - "**/*Application*", - "**/generated/**", - "**/*MapperImpl*" -) - -tasks.jacocoTestReport { - dependsOn(tasks.test) - reports { - xml.required.set(true) - html.required.set(true) - } - classDirectories.setFrom(files(classDirectories.files.map { - fileTree(it) { exclude(jacocoExclusions) } - })) -} - -tasks.jacocoTestCoverageVerification { - dependsOn(tasks.jacocoTestReport) - violationRules { - rule { - limit { - minimum = "0.45".toBigDecimal() - } - } - } - classDirectories.setFrom(files(classDirectories.files.map { - fileTree(it) { exclude(jacocoExclusions) } - })) -} - -tasks.named("check") { - dependsOn( - tasks.named("integrationTest"), - tasks.named("businessTest"), - tasks.jacocoTestCoverageVerification - ) } diff --git a/api-gateway-iam/api-gateway-iam/src/integration-test/java/com/stablecoin/payments/gateway/iam/AbstractIntegrationTest.java b/api-gateway-iam/api-gateway-iam/src/integration-test/java/com/stablecoin/payments/gateway/iam/AbstractIntegrationTest.java index fd1c0c20..b2d98c84 100644 --- a/api-gateway-iam/api-gateway-iam/src/integration-test/java/com/stablecoin/payments/gateway/iam/AbstractIntegrationTest.java +++ b/api-gateway-iam/api-gateway-iam/src/integration-test/java/com/stablecoin/payments/gateway/iam/AbstractIntegrationTest.java @@ -11,8 +11,14 @@ import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.KafkaContainer; import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.lifecycle.Startable; -import org.testcontainers.utility.DockerImageName; + +import static com.stablecoin.payments.platform.test.TestContainerSupport.kafka; +import static com.stablecoin.payments.platform.test.TestContainerSupport.postgres; +import static com.stablecoin.payments.platform.test.TestContainerSupport.redis; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerKafkaProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerPostgresProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerRedisProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.startAll; @SuppressWarnings("resource") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -20,45 +26,12 @@ @AutoConfigureMockMvc public abstract class AbstractIntegrationTest { - static final PostgreSQLContainer POSTGRES = - new PostgreSQLContainer<>("postgres:16-alpine") - .withDatabaseName("s10_api_gateway_iam") - .withUsername("test") - .withPassword("test"); - - protected static final KafkaContainer KAFKA = - new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0")); - - protected static final GenericContainer REDIS = - new GenericContainer<>(DockerImageName.parse("redis:7-alpine")) - .withExposedPorts(6379); + static final PostgreSQLContainer POSTGRES = postgres("s10_api_gateway_iam"); + protected static final KafkaContainer KAFKA = kafka(); + protected static final GenericContainer REDIS = redis(); static { - try { - POSTGRES.start(); - KAFKA.start(); - REDIS.start(); - } catch (RuntimeException ex) { - safeStop(REDIS); - safeStop(KAFKA); - safeStop(POSTGRES); - throw ex; - } - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - safeStop(REDIS); - safeStop(KAFKA); - safeStop(POSTGRES); - }, "testcontainers-shutdown")); - } - - private static void safeStop(Startable container) { - try { - if (container != null) { - container.stop(); - } - } catch (Exception ignored) { - // best-effort cleanup - } + startAll(POSTGRES, KAFKA, REDIS); } @Autowired @@ -81,12 +54,8 @@ void cleanDatabase() { @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); - registry.add("spring.datasource.username", POSTGRES::getUsername); - registry.add("spring.datasource.password", POSTGRES::getPassword); - registry.add("spring.kafka.bootstrap-servers", KAFKA::getBootstrapServers); - registry.add("spring.cloud.stream.kafka.binder.brokers", KAFKA::getBootstrapServers); - registry.add("spring.data.redis.host", REDIS::getHost); - registry.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379)); + registerPostgresProperties(registry, POSTGRES); + registerKafkaProperties(registry, KAFKA); + registerRedisProperties(registry, REDIS); } } diff --git a/api-gateway-iam/api-gateway-iam/src/integration-test/java/com/stablecoin/payments/gateway/iam/config/TestSecurityConfig.java b/api-gateway-iam/api-gateway-iam/src/integration-test/java/com/stablecoin/payments/gateway/iam/config/TestSecurityConfig.java deleted file mode 100644 index 10807490..00000000 --- a/api-gateway-iam/api-gateway-iam/src/integration-test/java/com/stablecoin/payments/gateway/iam/config/TestSecurityConfig.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.stablecoin.payments.gateway.iam.config; - -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.core.Ordered; -import org.springframework.core.annotation.Order; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; -import org.springframework.security.web.SecurityFilterChain; - -/** - * Test-only security configuration that disables authentication/authorization - * to allow integration tests to focus on business logic verification. - */ -@TestConfiguration -public class TestSecurityConfig { - - @Bean - @Order(Ordered.HIGHEST_PRECEDENCE) - public SecurityFilterChain testSecurityFilterChain(HttpSecurity http) throws Exception { - http - .csrf(AbstractHttpConfigurer::disable) - .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); - return http.build(); - } -} diff --git a/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/controller/GlobalExceptionHandler.java b/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/controller/GlobalExceptionHandler.java index 84a4b7d5..d2d51d2c 100644 --- a/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/controller/GlobalExceptionHandler.java +++ b/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/application/controller/GlobalExceptionHandler.java @@ -1,6 +1,5 @@ package com.stablecoin.payments.gateway.iam.application.controller; -import com.stablecoin.payments.gateway.iam.api.response.ApiError; import com.stablecoin.payments.gateway.iam.domain.exception.ApiKeyExpiredException; import com.stablecoin.payments.gateway.iam.domain.exception.ApiKeyNotFoundException; import com.stablecoin.payments.gateway.iam.domain.exception.ApiKeyRevokedException; @@ -13,51 +12,26 @@ import com.stablecoin.payments.gateway.iam.domain.exception.RateLimitExceededException; import com.stablecoin.payments.gateway.iam.domain.exception.ScopeExceededException; import com.stablecoin.payments.gateway.iam.domain.exception.TokenRevokedException; -import jakarta.validation.ConstraintViolationException; +import com.stablecoin.payments.platform.api.ApiError; +import com.stablecoin.payments.platform.infrastructure.exception.BaseGlobalExceptionHandler; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.validation.FieldError; -import org.springframework.validation.ObjectError; -import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; -import java.util.stream.Collectors; - import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.FORBIDDEN; -import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; import static org.springframework.http.HttpStatus.NOT_FOUND; import static org.springframework.http.HttpStatus.TOO_MANY_REQUESTS; import static org.springframework.http.HttpStatus.UNAUTHORIZED; @Slf4j @RestControllerAdvice -public class GlobalExceptionHandler { +public class GlobalExceptionHandler extends BaseGlobalExceptionHandler { - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(MethodArgumentNotValidException.class) - public ApiError handleValidation(MethodArgumentNotValidException ex) { - var errors = ex.getBindingResult().getFieldErrors().stream() - .collect(Collectors.groupingBy( - FieldError::getField, - Collectors.mapping(ObjectError::getDefaultMessage, Collectors.toList()))); - log.info("Validation failed: {}", errors); - return ApiError.withErrors("GW-0001", BAD_REQUEST.getReasonPhrase(), - "Invalid request content", errors); - } - - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(ConstraintViolationException.class) - public ApiError handleConstraintViolation(ConstraintViolationException ex) { - var errors = ex.getConstraintViolations().stream() - .collect(Collectors.groupingBy( - v -> v.getPropertyPath().toString(), - Collectors.mapping(jakarta.validation.ConstraintViolation::getMessage, - Collectors.toList()))); - return ApiError.withErrors("GW-0001", BAD_REQUEST.getReasonPhrase(), - "Invalid request content", errors); + @Override + protected String errorCodePrefix() { + return "GW"; } @ResponseStatus(FORBIDDEN) @@ -133,12 +107,4 @@ public ApiError handleOAuthClientNotFound(OAuthClientNotFoundException ex) { public ApiError handleRateLimitExceeded(RateLimitExceededException ex) { return ApiError.of("GW-6001", TOO_MANY_REQUESTS.getReasonPhrase(), ex.getMessage()); } - - @ResponseStatus(INTERNAL_SERVER_ERROR) - @ExceptionHandler(Exception.class) - public ApiError handleUnexpected(Exception ex) { - log.error("Unexpected error: ", ex); - return ApiError.of("GW-9999", INTERNAL_SERVER_ERROR.getReasonPhrase(), - HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase()); - } } diff --git a/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/infrastructure/messaging/GatewayIamOutboxHandler.java b/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/infrastructure/messaging/GatewayIamOutboxHandler.java index 73a07427..5ba0969a 100644 --- a/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/infrastructure/messaging/GatewayIamOutboxHandler.java +++ b/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/infrastructure/messaging/GatewayIamOutboxHandler.java @@ -1,42 +1,13 @@ package com.stablecoin.payments.gateway.iam.infrastructure.messaging; -import io.namastack.outbox.annotation.OutboxHandler; -import io.namastack.outbox.handler.OutboxRecordMetadata; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import com.stablecoin.payments.platform.infrastructure.messaging.AbstractOutboxHandler; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; -import java.util.concurrent.TimeUnit; - -@Slf4j @Component -@RequiredArgsConstructor -public class GatewayIamOutboxHandler { - - private final KafkaTemplate kafkaTemplate; - - @OutboxHandler - public void handle(Object event, OutboxRecordMetadata metadata) { - var topic = resolveField(event, "TOPIC"); - var key = metadata.getKey(); - try { - kafkaTemplate.send(topic, key, event).get(10, TimeUnit.SECONDS); - log.debug("Published outbox event type={} topic={} key={}", - event.getClass().getSimpleName(), topic, key); - } catch (Exception e) { - log.error("Failed to publish event type={} topic={}: {}", - event.getClass().getSimpleName(), topic, e.getMessage()); - throw new RuntimeException("Kafka send failed for event " + event.getClass().getSimpleName(), e); - } - } +public class GatewayIamOutboxHandler extends AbstractOutboxHandler { - private String resolveField(Object event, String fieldName) { - try { - return (String) event.getClass().getField(fieldName).get(null); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new IllegalArgumentException( - "Event class missing static " + fieldName + " field: " + event.getClass().getName(), e); - } + public GatewayIamOutboxHandler(KafkaTemplate kafkaTemplate) { + super(kafkaTemplate); } } diff --git a/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/infrastructure/messaging/OutboxEventPublisher.java b/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/infrastructure/messaging/OutboxEventPublisher.java index f486bba5..34a63aa8 100644 --- a/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/infrastructure/messaging/OutboxEventPublisher.java +++ b/api-gateway-iam/api-gateway-iam/src/main/java/com/stablecoin/payments/gateway/iam/infrastructure/messaging/OutboxEventPublisher.java @@ -1,36 +1,17 @@ package com.stablecoin.payments.gateway.iam.infrastructure.messaging; import com.stablecoin.payments.gateway.iam.domain.port.EventPublisher; +import com.stablecoin.payments.platform.infrastructure.messaging.AbstractOutboxEventPublisher; import io.namastack.outbox.Outbox; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -@Slf4j -@Component -@RequiredArgsConstructor -public class OutboxEventPublisher implements EventPublisher { - - private final Outbox outbox; +import java.util.List; - @Override - @Transactional(propagation = Propagation.MANDATORY) - public void publish(Object event) { - var key = resolveField(event, "merchantId"); - outbox.schedule(event, key); - log.debug("Scheduled outbox event type={} key={}", event.getClass().getSimpleName(), key); - } +@Component +public class OutboxEventPublisher extends AbstractOutboxEventPublisher + implements EventPublisher { - private String resolveField(Object event, String fieldName) { - try { - var method = event.getClass().getMethod(fieldName); - return String.valueOf(method.invoke(event)); - } catch (Exception e) { - throw new IllegalArgumentException( - "Event class missing accessor for field '" + fieldName + "': " - + event.getClass().getName(), e); - } + public OutboxEventPublisher(Outbox outbox) { + super(outbox, List.of("merchantId")); } } diff --git a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/ArchitectureTest.java b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/ArchitectureTest.java deleted file mode 100644 index dcd46318..00000000 --- a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/ArchitectureTest.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.stablecoin.payments.gateway.iam; - -import com.tngtech.archunit.core.importer.ClassFileImporter; -import com.tngtech.archunit.core.importer.ImportOption; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; -import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; - -@DisplayName("Architecture Rules") -class ArchitectureTest { - - private static com.tngtech.archunit.core.domain.JavaClasses importedClasses; - - @BeforeAll - static void setUp() { - importedClasses = new ClassFileImporter() - .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) - .importPackages("com.stablecoin.payments.gateway.iam"); - } - - @Test - @DisplayName("Domain should not depend on Spring (except stereotype and transaction)") - void domainShouldNotDependOnSpring() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat( - resideInAPackage("org.springframework..") - .and(resideOutsideOfPackage("org.springframework.stereotype..")) - .and(resideOutsideOfPackage("org.springframework.transaction..")) - .and(resideOutsideOfPackage("org.springframework.beans.factory.annotation..")) - ) - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on JPA") - void domainShouldNotDependOnJpa() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("jakarta.persistence..") - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on infrastructure") - void domainShouldNotDependOnInfrastructure() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("..infrastructure..") - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on application layer") - void domainShouldNotDependOnApplication() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("..application..") - .check(importedClasses); - } - - @Test - @DisplayName("Ports should be interfaces") - void portsShouldBeInterfaces() { - classes() - .that().resideInAPackage("..domain.port..") - .and().areNotRecords() - .should().beInterfaces() - .check(importedClasses); - } - - @Test - @DisplayName("Domain events should be records") - void domainEventsShouldBeRecords() { - classes() - .that().resideInAPackage("..domain.event..") - .should().beRecords() - .check(importedClasses); - } - - @Test - @DisplayName("Controllers should reside in application.controller package") - void controllersShouldResideInApplicationController() { - noClasses() - .that().haveSimpleNameEndingWith("Controller") - .should().resideOutsideOfPackage("..application.controller..") - .allowEmptyShould(true) - .check(importedClasses); - } -} diff --git a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/arch/ArchitectureTest.java b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/arch/ArchitectureTest.java new file mode 100644 index 00000000..70308d8a --- /dev/null +++ b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/arch/ArchitectureTest.java @@ -0,0 +1,15 @@ +package com.stablecoin.payments.gateway.iam.arch; + +import com.stablecoin.payments.platform.test.HexagonalArchitectureRules; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Architecture Rules") +class ArchitectureTest { + + @Test + @DisplayName("Verify hexagonal architecture rules") + void verifyHexagonalArchitecture() { + HexagonalArchitectureRules.verifyAll("com.stablecoin.payments.gateway.iam"); + } +} diff --git a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/ApiKeyCommandHandlerTest.java b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/ApiKeyCommandHandlerTest.java index b61adbdf..ed97765b 100644 --- a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/ApiKeyCommandHandlerTest.java +++ b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/ApiKeyCommandHandlerTest.java @@ -32,8 +32,8 @@ import java.util.Optional; import java.util.UUID; -import static com.stablecoin.payments.gateway.iam.fixtures.TestUtils.eqIgnoring; -import static com.stablecoin.payments.gateway.iam.fixtures.TestUtils.eqIgnoringTimestamps; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; diff --git a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/AuthCommandHandlerTest.java b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/AuthCommandHandlerTest.java index 49f998b2..8f0bf76a 100644 --- a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/AuthCommandHandlerTest.java +++ b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/AuthCommandHandlerTest.java @@ -28,7 +28,7 @@ import java.util.Optional; import java.util.UUID; -import static com.stablecoin.payments.gateway.iam.fixtures.TestUtils.eqIgnoring; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; diff --git a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/MerchantCommandHandlerTest.java b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/MerchantCommandHandlerTest.java index 83fa0939..7df0445e 100644 --- a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/MerchantCommandHandlerTest.java +++ b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/MerchantCommandHandlerTest.java @@ -26,8 +26,8 @@ import java.util.Optional; import java.util.UUID; -import static com.stablecoin.payments.gateway.iam.fixtures.TestUtils.eqIgnoring; -import static com.stablecoin.payments.gateway.iam.fixtures.TestUtils.eqIgnoringTimestamps; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; diff --git a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/OAuthClientCommandHandlerTest.java b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/OAuthClientCommandHandlerTest.java index 14014167..52ed210f 100644 --- a/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/OAuthClientCommandHandlerTest.java +++ b/api-gateway-iam/api-gateway-iam/src/test/java/com/stablecoin/payments/gateway/iam/domain/service/OAuthClientCommandHandlerTest.java @@ -25,7 +25,7 @@ import java.util.Optional; import java.util.UUID; -import static com.stablecoin.payments.gateway.iam.fixtures.TestUtils.eqIgnoring; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; diff --git a/api-gateway-iam/api-gateway-iam/src/testFixtures/java/com/stablecoin/payments/gateway/iam/fixtures/TestUtils.java b/api-gateway-iam/api-gateway-iam/src/testFixtures/java/com/stablecoin/payments/gateway/iam/fixtures/TestUtils.java deleted file mode 100644 index ed6223d5..00000000 --- a/api-gateway-iam/api-gateway-iam/src/testFixtures/java/com/stablecoin/payments/gateway/iam/fixtures/TestUtils.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.stablecoin.payments.gateway.iam.fixtures; - -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.ZonedDateTime; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.argThat; - -public final class TestUtils { - - private TestUtils() {} - - public static T eqIgnoringTimestamps(T expected) { - return eqIgnoring(expected); - } - - public static T eqIgnoring(T expected, String... fieldsToIgnore) { - return argThat(it -> isEqualIgnoring(it, expected, fieldsToIgnore)); - } - - private static boolean isEqualIgnoring(T original, T expected, String... fieldsToIgnore) { - try { - assertThat(original) - .usingRecursiveComparison() - .ignoringFieldsOfTypes(ZonedDateTime.class, LocalDateTime.class, LocalDate.class, Instant.class) - .ignoringFields(fieldsToIgnore) - .isEqualTo(expected); - return true; - } catch (Throwable t) { - return false; - } - } -} diff --git a/blockchain-custody/blockchain-custody-api/build.gradle.kts b/blockchain-custody/blockchain-custody-api/build.gradle.kts index 20c8280f..97d4baa4 100644 --- a/blockchain-custody/blockchain-custody-api/build.gradle.kts +++ b/blockchain-custody/blockchain-custody-api/build.gradle.kts @@ -1,16 +1,3 @@ plugins { - `java-library` -} - -dependencies { - api("jakarta.validation:jakarta.validation-api") - api("com.fasterxml.jackson.core:jackson-annotations") - - testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation("org.assertj:assertj-core") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { - useJUnitPlatform() + id("stablebridge.api-library") } diff --git a/blockchain-custody/blockchain-custody-api/src/main/java/com/stablecoin/payments/custody/api/ApiError.java b/blockchain-custody/blockchain-custody-api/src/main/java/com/stablecoin/payments/custody/api/ApiError.java deleted file mode 100644 index 6b0083a4..00000000 --- a/blockchain-custody/blockchain-custody-api/src/main/java/com/stablecoin/payments/custody/api/ApiError.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.stablecoin.payments.custody.api; - -import java.util.List; -import java.util.Map; - -public record ApiError( - String code, - String status, - String message, - Map> errors -) { - public static ApiError of(String code, String status, String message) { - return new ApiError(code, status, message, Map.of()); - } - - public static ApiError withErrors(String code, String status, String message, - Map> errors) { - return new ApiError(code, status, message, errors); - } -} diff --git a/blockchain-custody/blockchain-custody-client/build.gradle.kts b/blockchain-custody/blockchain-custody-client/build.gradle.kts index b704e6d7..f1bd45e4 100644 --- a/blockchain-custody/blockchain-custody-client/build.gradle.kts +++ b/blockchain-custody/blockchain-custody-client/build.gradle.kts @@ -1,16 +1,7 @@ plugins { - `java-library` + id("stablebridge.client-library") } dependencies { api(project(":blockchain-custody:blockchain-custody-api")) - implementation("org.springframework.cloud:spring-cloud-starter-openfeign") - - testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation("org.assertj:assertj-core") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { - useJUnitPlatform() } diff --git a/blockchain-custody/blockchain-custody/build.gradle.kts b/blockchain-custody/blockchain-custody/build.gradle.kts index 4001ce44..ebddbc2b 100644 --- a/blockchain-custody/blockchain-custody/build.gradle.kts +++ b/blockchain-custody/blockchain-custody/build.gradle.kts @@ -1,105 +1,21 @@ plugins { - id("org.springframework.boot") - id("com.google.cloud.tools.jib") - java - `java-test-fixtures` - jacoco + id("stablebridge.service") } -jib { - from { - image = "docker://eclipse-temurin:25-jre" - } - to { - image = "stablebridge/blockchain-custody" - tags = setOf("latest") - } - container { - creationTime.set("USE_CURRENT_TIMESTAMP") - } -} - -val integrationTestSourceSet: SourceSet = sourceSets.create("integrationTest") { - java.srcDir("src/integration-test/java") - resources.srcDir("src/integration-test/resources") - compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output - runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output -} - -configurations { - named("integrationTestImplementation") { extendsFrom(configurations.testImplementation.get()) } - named("integrationTestRuntimeOnly") { extendsFrom(configurations.testRuntimeOnly.get()) } -} - -tasks.register("integrationTest") { - testClassesDirs = integrationTestSourceSet.output.classesDirs - classpath = integrationTestSourceSet.runtimeClasspath - shouldRunAfter(tasks.test) - configure { isEnabled = false } - failOnNoDiscoveredTests = false - exclude("**/Abstract*", "**/config/**") -} - -val businessTestSourceSet: SourceSet = sourceSets.create("businessTest") { - java.srcDir("src/business-test/java") - resources.srcDir("src/business-test/resources") - compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output + integrationTestSourceSet.output - runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output + integrationTestSourceSet.output -} - -configurations { - named("businessTestImplementation") { extendsFrom(configurations.named("integrationTestImplementation").get()) } - named("businessTestRuntimeOnly") { extendsFrom(configurations.named("integrationTestRuntimeOnly").get()) } -} - -tasks.register("businessTest") { - testClassesDirs = businessTestSourceSet.output.classesDirs - classpath = businessTestSourceSet.runtimeClasspath - shouldRunAfter(tasks.named("integrationTest")) - failOnNoDiscoveredTests = false - configure { isEnabled = false } -} - -val lombokVersion: String by project -val mapstructVersion: String by project -val lombokMapstructBindingVersion: String by project val resilience4jVersion: String by project -val flywayVersion: String by project -val archunitVersion: String by project -val testcontainersVersion: String by project -val wiremockVersion: String by project -val springdocVersion: String by project val web3jVersion: String by project +stablebridge { + jibImageName.set("stablebridge/blockchain-custody") +} + dependencies { implementation(project(":blockchain-custody:blockchain-custody-api")) - // Spring Boot - implementation("org.springframework.boot:spring-boot-starter-web") - implementation("org.springframework.boot:spring-boot-starter-validation") - implementation("org.springframework.boot:spring-boot-starter-data-jpa") - implementation("org.springframework.boot:spring-boot-starter-actuator") - - // OpenAPI / Swagger UI - implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$springdocVersion") - runtimeOnly("io.micrometer:micrometer-registry-prometheus") - implementation("io.micrometer:micrometer-tracing-bridge-otel") - implementation("io.opentelemetry:opentelemetry-exporter-otlp") - implementation("org.springframework.boot:spring-boot-starter-security") - // Redis — nonce locks, balance cache implementation("org.springframework.boot:spring-boot-starter-data-redis") - // Kafka via Spring Kafka - implementation("org.springframework.cloud:spring-cloud-stream-binder-kafka") - implementation("org.springframework.kafka:spring-kafka") - - // Feign - implementation("org.springframework.cloud:spring-cloud-starter-openfeign") - - // Resilience4j - implementation("io.github.resilience4j:resilience4j-spring-boot3:$resilience4jVersion") - implementation("io.github.resilience4j:resilience4j-circuitbreaker:$resilience4jVersion") + // Resilience4j retry (service-specific) implementation("io.github.resilience4j:resilience4j-retry:$resilience4jVersion") // Dev custody adapter — EVM transaction signing @@ -107,107 +23,8 @@ dependencies { exclude(group = "org.slf4j") // avoid SLF4J conflicts with Spring Boot } - // MapStruct (compiler args set below in JavaCompile task) - implementation("org.mapstruct:mapstruct:$mapstructVersion") - annotationProcessor("org.mapstruct:mapstruct-processor:$mapstructVersion") - annotationProcessor("org.projectlombok:lombok-mapstruct-binding:$lombokMapstructBindingVersion") - - // Outbox (namastack) - implementation("io.namastack:namastack-outbox-starter-jdbc:1.1.0") - - // Database - runtimeOnly("org.postgresql:postgresql") - implementation("org.springframework.boot:spring-boot-starter-flyway") - implementation("org.flywaydb:flyway-database-postgresql:$flywayVersion") - - // Test Fixtures - testFixturesImplementation("org.assertj:assertj-core") - testFixturesImplementation("org.mockito:mockito-core") - - // Test - testImplementation("org.springframework.boot:spring-boot-starter-test") + // Test — WireMock for adapter unit tests + testImplementation("org.wiremock:wiremock-standalone:${project.property("wiremockVersion")}") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") testImplementation("org.springframework.boot:spring-boot-starter-security-test") - testImplementation("org.springframework.kafka:spring-kafka-test") - testImplementation("com.tngtech.archunit:archunit-junit5:$archunitVersion") - testImplementation("org.wiremock:wiremock-standalone:$wiremockVersion") - "integrationTestImplementation"(testFixtures(project)) - "integrationTestImplementation"("org.testcontainers:postgresql:$testcontainersVersion") - "integrationTestImplementation"("org.testcontainers:kafka:$testcontainersVersion") - "integrationTestImplementation"("org.testcontainers:junit-jupiter:$testcontainersVersion") - "integrationTestImplementation"("org.wiremock:wiremock-standalone:$wiremockVersion") - "integrationTestImplementation"("org.springframework.boot:spring-boot-starter-webmvc-test") - "integrationTestImplementation"("org.springframework.boot:spring-boot-starter-security-test") -} - -tasks.withType { - options.compilerArgs.addAll(listOf( - "-Amapstruct.defaultComponentModel=spring", - "-Amapstruct.unmappedTargetPolicy=IGNORE" - )) -} - -tasks.withType { - jvmArgs("-Dnet.bytebuddy.experimental=true") - useJUnitPlatform() - testLogging { - events("passed", "skipped", "failed") - showExceptions = true - showCauses = true - showStackTraces = true - exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL - } -} - -jacoco { - toolVersion = "0.8.14" -} - -tasks.test { - configure { - excludes = listOf("sun.*", "jdk.*", "com.sun.*", "java.*", "javax.*") - } - finalizedBy(tasks.jacocoTestReport) -} - -val jacocoExclusions = listOf( - "**/entity/**", - "**/mapper/**", - "**/config/**", - "**/*Application*", - "**/generated/**", - "**/*MapperImpl*" -) - -tasks.jacocoTestReport { - dependsOn(tasks.test) - reports { - xml.required.set(true) - html.required.set(true) - } - classDirectories.setFrom(files(classDirectories.files.map { - fileTree(it) { exclude(jacocoExclusions) } - })) -} - -tasks.jacocoTestCoverageVerification { - dependsOn(tasks.jacocoTestReport) - violationRules { - rule { - limit { - minimum = "0.50".toBigDecimal() - } - } - } - classDirectories.setFrom(files(classDirectories.files.map { - fileTree(it) { exclude(jacocoExclusions) } - })) -} - -tasks.named("check") { - dependsOn( - tasks.named("integrationTest"), - tasks.named("businessTest"), - tasks.jacocoTestCoverageVerification - ) } diff --git a/blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/AbstractIntegrationTest.java b/blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/AbstractIntegrationTest.java index c87e715b..87e4401f 100644 --- a/blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/AbstractIntegrationTest.java +++ b/blockchain-custody/blockchain-custody/src/integration-test/java/com/stablecoin/payments/custody/AbstractIntegrationTest.java @@ -4,14 +4,22 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.KafkaContainer; import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.lifecycle.Startable; -import org.testcontainers.utility.DockerImageName; + +import static com.stablecoin.payments.platform.test.TestContainerSupport.kafka; +import static com.stablecoin.payments.platform.test.TestContainerSupport.postgres; +import static com.stablecoin.payments.platform.test.TestContainerSupport.redis; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerKafkaProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerPostgresProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerRedisProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.startAll; @SuppressWarnings("resource") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -19,43 +27,20 @@ @AutoConfigureMockMvc public abstract class AbstractIntegrationTest { - static final PostgreSQLContainer POSTGRES = - new PostgreSQLContainer<>("postgres:16-alpine") - .withDatabaseName("s4_blockchain_custody") - .withUsername("test") - .withPassword("test"); - - protected static final KafkaContainer KAFKA = - new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0")); + static final PostgreSQLContainer POSTGRES = postgres("s4_blockchain_custody"); + protected static final KafkaContainer KAFKA = kafka(); + protected static final GenericContainer REDIS = redis(); static { - try { - POSTGRES.start(); - KAFKA.start(); - } catch (RuntimeException ex) { - safeStop(KAFKA); - safeStop(POSTGRES); - throw ex; - } - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - safeStop(KAFKA); - safeStop(POSTGRES); - }, "testcontainers-shutdown")); - } - - private static void safeStop(Startable container) { - try { - if (container != null) { - container.stop(); - } - } catch (Exception ignored) { - // best-effort cleanup - } + startAll(POSTGRES, KAFKA, REDIS); } @Autowired private JdbcTemplate jdbcTemplate; + @Autowired + private StringRedisTemplate redisTemplate; + @BeforeEach void cleanDatabase() { jdbcTemplate.execute(""" @@ -71,13 +56,17 @@ void cleanDatabase() { custody_outbox_record CASCADE """); + + // Flush Redis cache between tests + var connection = redisTemplate.getConnectionFactory().getConnection(); + connection.serverCommands().flushAll(); + connection.close(); } @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); - registry.add("spring.datasource.username", POSTGRES::getUsername); - registry.add("spring.datasource.password", POSTGRES::getPassword); - registry.add("spring.kafka.bootstrap-servers", KAFKA::getBootstrapServers); + registerPostgresProperties(registry, POSTGRES); + registerKafkaProperties(registry, KAFKA); + registerRedisProperties(registry, REDIS); } } diff --git a/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/application/controller/GlobalExceptionHandler.java b/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/application/controller/GlobalExceptionHandler.java index 244141a0..b4a00538 100644 --- a/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/application/controller/GlobalExceptionHandler.java +++ b/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/application/controller/GlobalExceptionHandler.java @@ -1,55 +1,30 @@ package com.stablecoin.payments.custody.application.controller; -import com.stablecoin.payments.custody.api.ApiError; import com.stablecoin.payments.custody.domain.exception.ChainUnavailableException; import com.stablecoin.payments.custody.domain.exception.CustodySigningException; import com.stablecoin.payments.custody.domain.exception.InsufficientBalanceException; import com.stablecoin.payments.custody.domain.exception.TransferNotFoundException; import com.stablecoin.payments.custody.domain.exception.WalletNotFoundException; +import com.stablecoin.payments.platform.api.ApiError; +import com.stablecoin.payments.platform.infrastructure.exception.BaseGlobalExceptionHandler; import lombok.extern.slf4j.Slf4j; -import org.springframework.validation.FieldError; -import org.springframework.validation.ObjectError; -import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; -import java.util.stream.Collectors; - -import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.CONFLICT; import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; import static org.springframework.http.HttpStatus.NOT_FOUND; import static org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE; import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY; -/** - * Global exception handler for the Blockchain & Custody service. - * Maps domain exceptions to appropriate HTTP responses with BC-XXXX error codes. - */ @Slf4j @RestControllerAdvice -public class GlobalExceptionHandler { - - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(MethodArgumentNotValidException.class) - public ApiError handleValidation(MethodArgumentNotValidException ex) { - var errors = ex.getBindingResult().getFieldErrors().stream() - .collect(Collectors.groupingBy( - FieldError::getField, - Collectors.mapping(ObjectError::getDefaultMessage, Collectors.toList()))); - log.info("Validation failed: {}", errors); - return ApiError.withErrors("BC-0001", BAD_REQUEST.getReasonPhrase(), - "Invalid request content", errors); - } +public class GlobalExceptionHandler extends BaseGlobalExceptionHandler { - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(MethodArgumentTypeMismatchException.class) - public ApiError handleTypeMismatch(MethodArgumentTypeMismatchException ex) { - log.info("Type mismatch for parameter '{}': {}", ex.getName(), ex.getMessage()); - return ApiError.of("BC-0001", BAD_REQUEST.getReasonPhrase(), - "Invalid value for parameter '" + ex.getName() + "'"); + @Override + protected String errorCodePrefix() { + return "BC"; } @ResponseStatus(UNPROCESSABLE_ENTITY) @@ -92,25 +67,11 @@ public ApiError handleCustodySigning(CustodySigningException ex) { INTERNAL_SERVER_ERROR.getReasonPhrase(), "Custody signing failed"); } + @Override @ResponseStatus(CONFLICT) @ExceptionHandler(IllegalStateException.class) - public ApiError handleIllegalState(IllegalStateException ex) { + public ApiError handleInvalidState(IllegalStateException ex) { log.info("Invalid state transition: {}", ex.getMessage()); return ApiError.of("BC-0002", CONFLICT.getReasonPhrase(), ex.getMessage()); } - - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(IllegalArgumentException.class) - public ApiError handleIllegalArgument(IllegalArgumentException ex) { - log.info("Illegal argument: {}", ex.getMessage()); - return ApiError.of("BC-0001", BAD_REQUEST.getReasonPhrase(), ex.getMessage()); - } - - @ResponseStatus(INTERNAL_SERVER_ERROR) - @ExceptionHandler(Exception.class) - public ApiError handleUnexpected(Exception ex) { - log.error("Unexpected error: ", ex); - return ApiError.of("BC-9999", INTERNAL_SERVER_ERROR.getReasonPhrase(), - INTERNAL_SERVER_ERROR.getReasonPhrase()); - } } diff --git a/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/infrastructure/messaging/CustodyOutboxEventPublisher.java b/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/infrastructure/messaging/CustodyOutboxEventPublisher.java index 031e66a6..44eed567 100644 --- a/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/infrastructure/messaging/CustodyOutboxEventPublisher.java +++ b/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/infrastructure/messaging/CustodyOutboxEventPublisher.java @@ -1,46 +1,17 @@ package com.stablecoin.payments.custody.infrastructure.messaging; import com.stablecoin.payments.custody.domain.port.TransferEventPublisher; +import com.stablecoin.payments.platform.infrastructure.messaging.AbstractOutboxEventPublisher; import io.namastack.outbox.Outbox; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -/** - * Outbox-based event publisher for the blockchain custody service. - * Schedules domain events into the Namastack outbox within - * the caller's existing transaction. - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class CustodyOutboxEventPublisher implements TransferEventPublisher { - - private final Outbox outbox; +import java.util.List; - @Override - @Transactional(propagation = Propagation.MANDATORY) - public void publish(Object event) { - var key = resolveKey(event); - outbox.schedule(event, key); - log.debug("Scheduled outbox event type={} key={}", event.getClass().getSimpleName(), key); - } +@Component +public class CustodyOutboxEventPublisher extends AbstractOutboxEventPublisher + implements TransferEventPublisher { - private String resolveKey(Object event) { - try { - var method = event.getClass().getMethod("paymentId"); - return String.valueOf(method.invoke(event)); - } catch (Exception e1) { - try { - var method = event.getClass().getMethod("transferId"); - return String.valueOf(method.invoke(event)); - } catch (Exception e2) { - throw new IllegalArgumentException( - "Event class missing accessor for 'paymentId' or 'transferId': " - + event.getClass().getName(), e2); - } - } + public CustodyOutboxEventPublisher(Outbox outbox) { + super(outbox, List.of("paymentId", "transferId")); } } diff --git a/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/infrastructure/messaging/CustodyOutboxHandler.java b/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/infrastructure/messaging/CustodyOutboxHandler.java index c6d921e3..26ffd28c 100644 --- a/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/infrastructure/messaging/CustodyOutboxHandler.java +++ b/blockchain-custody/blockchain-custody/src/main/java/com/stablecoin/payments/custody/infrastructure/messaging/CustodyOutboxHandler.java @@ -1,51 +1,13 @@ package com.stablecoin.payments.custody.infrastructure.messaging; -import io.namastack.outbox.annotation.OutboxHandler; -import io.namastack.outbox.handler.OutboxRecordMetadata; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import com.stablecoin.payments.platform.infrastructure.messaging.AbstractOutboxHandler; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; -import java.util.concurrent.TimeUnit; - -/** - * Outbox handler that publishes events from the Namastack outbox - * to Kafka topics. Resolves the topic from the event's static TOPIC field. - */ -@Slf4j @Component -@RequiredArgsConstructor -public class CustodyOutboxHandler { - - private final KafkaTemplate kafkaTemplate; - - @OutboxHandler - public void handle(Object event, OutboxRecordMetadata metadata) { - var topic = resolveStaticField(event, "TOPIC"); - var key = metadata.getKey(); - try { - kafkaTemplate.send(topic, key, event).get(10, TimeUnit.SECONDS); - log.debug("Published outbox event type={} topic={} key={}", - event.getClass().getSimpleName(), topic, key); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.error("Interrupted publishing event type={} topic={} key={}", - event.getClass().getSimpleName(), topic, key); - throw new RuntimeException("Kafka send interrupted for event " + event.getClass().getSimpleName(), e); - } catch (Exception e) { - log.error("Failed to publish event type={} topic={}: {}", - event.getClass().getSimpleName(), topic, e.getMessage()); - throw new RuntimeException("Kafka send failed for event " + event.getClass().getSimpleName(), e); - } - } +public class CustodyOutboxHandler extends AbstractOutboxHandler { - private String resolveStaticField(Object event, String fieldName) { - try { - return (String) event.getClass().getField(fieldName).get(null); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new IllegalArgumentException( - "Event class missing static " + fieldName + " field: " + event.getClass().getName(), e); - } + public CustodyOutboxHandler(KafkaTemplate kafkaTemplate) { + super(kafkaTemplate); } } diff --git a/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/arch/ArchitectureTest.java b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/arch/ArchitectureTest.java index 2be0d36e..5ce62f28 100644 --- a/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/arch/ArchitectureTest.java +++ b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/arch/ArchitectureTest.java @@ -1,112 +1,15 @@ package com.stablecoin.payments.custody.arch; -import com.tngtech.archunit.core.domain.JavaClasses; -import com.tngtech.archunit.core.importer.ClassFileImporter; -import com.tngtech.archunit.core.importer.ImportOption; -import org.junit.jupiter.api.BeforeAll; +import com.stablecoin.payments.platform.test.HexagonalArchitectureRules; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; -import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; - @DisplayName("Architecture Rules") class ArchitectureTest { - private static JavaClasses importedClasses; - - @BeforeAll - static void setUp() { - importedClasses = new ClassFileImporter() - .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) - .importPackages("com.stablecoin.payments.custody"); - } - - @Test - @DisplayName("Domain should not depend on Spring (except stereotype and transaction)") - void domainShouldNotDependOnSpring() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat( - resideInAPackage("org.springframework..") - .and(resideOutsideOfPackage("org.springframework.stereotype..")) - .and(resideOutsideOfPackage("org.springframework.transaction..")) - .and(resideOutsideOfPackage("org.springframework.beans.factory.annotation..")) - ) - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on JPA") - void domainShouldNotDependOnJpa() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("jakarta.persistence..") - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on infrastructure") - void domainShouldNotDependOnInfrastructure() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("..infrastructure..") - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on application layer") - void domainShouldNotDependOnApplication() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("..application..") - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Infrastructure should not depend on application controller") - void infrastructureShouldNotDependOnApplicationController() { - noClasses() - .that().resideInAPackage("..infrastructure..") - .should().dependOnClassesThat().resideInAPackage("..application.controller..") - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Ports should be interfaces") - void portsShouldBeInterfaces() { - classes() - .that().resideInAPackage("..domain.port..") - .and().areNotRecords() - .should().beInterfaces() - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Domain events should be records") - void domainEventsShouldBeRecords() { - classes() - .that().resideInAPackage("..domain.event..") - .should().beRecords() - .allowEmptyShould(true) - .check(importedClasses); - } - @Test - @DisplayName("Controllers should reside in application.controller package") - void controllersShouldResideInApplicationController() { - noClasses() - .that().haveSimpleNameEndingWith("Controller") - .should().resideOutsideOfPackage("..application.controller..") - .allowEmptyShould(true) - .check(importedClasses); + @DisplayName("Verify hexagonal architecture rules") + void verifyHexagonalArchitecture() { + HexagonalArchitectureRules.verifyAll("com.stablecoin.payments.custody"); } } diff --git a/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/service/BalanceSyncCommandHandlerTest.java b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/service/BalanceSyncCommandHandlerTest.java index 05aaa924..ec9cf548 100644 --- a/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/service/BalanceSyncCommandHandlerTest.java +++ b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/service/BalanceSyncCommandHandlerTest.java @@ -21,9 +21,9 @@ import java.util.List; import java.util.Optional; -import static com.stablecoin.payments.custody.fixtures.TestUtils.eqIgnoringTimestamps; import static com.stablecoin.payments.custody.fixtures.TransferMonitorFixtures.USDC_BASE_CONTRACT; import static com.stablecoin.payments.custody.fixtures.TransferMonitorFixtures.defaultTokenContractResolver; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.never; diff --git a/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/service/TransferCommandHandlerTest.java b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/service/TransferCommandHandlerTest.java index 386f48bc..260fd158 100644 --- a/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/service/TransferCommandHandlerTest.java +++ b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/service/TransferCommandHandlerTest.java @@ -45,10 +45,10 @@ import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.TX_HASH; import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.USDC; import static com.stablecoin.payments.custody.fixtures.ChainTransferFixtures.aSubmittedTransfer; -import static com.stablecoin.payments.custody.fixtures.TestUtils.eqIgnoring; -import static com.stablecoin.payments.custody.fixtures.TestUtils.eqIgnoringTimestamps; import static com.stablecoin.payments.custody.fixtures.WalletBalanceFixtures.aBalanceWith; import static com.stablecoin.payments.custody.fixtures.WalletFixtures.anActiveWallet; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; diff --git a/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/service/TransferMonitorCommandHandlerTest.java b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/service/TransferMonitorCommandHandlerTest.java index de59480f..2a0fc215 100644 --- a/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/service/TransferMonitorCommandHandlerTest.java +++ b/blockchain-custody/blockchain-custody/src/test/java/com/stablecoin/payments/custody/domain/service/TransferMonitorCommandHandlerTest.java @@ -28,8 +28,6 @@ import java.util.List; import java.util.Optional; -import static com.stablecoin.payments.custody.fixtures.TestUtils.eqIgnoring; -import static com.stablecoin.payments.custody.fixtures.TestUtils.eqIgnoringTimestamps; import static com.stablecoin.payments.custody.fixtures.TransferMonitorFixtures.CHAIN_BASE; import static com.stablecoin.payments.custody.fixtures.TransferMonitorFixtures.FROM_WALLET_ID; import static com.stablecoin.payments.custody.fixtures.TransferMonitorFixtures.GAS_PRICE; @@ -50,6 +48,8 @@ import static com.stablecoin.payments.custody.fixtures.TransferMonitorFixtures.aSuccessfulReceipt; import static com.stablecoin.payments.custody.fixtures.TransferMonitorFixtures.defaultChainConfirmationProperties; import static com.stablecoin.payments.custody.fixtures.TransferMonitorFixtures.defaultMonitorProperties; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.never; diff --git a/build.gradle.kts b/build.gradle.kts index fe45482d..42d11275 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,10 +2,10 @@ import org.gradle.api.tasks.testing.logging.TestLogEvent plugins { - id("org.springframework.boot") version "4.0.3" apply false - id("io.spring.dependency-management") version "1.1.7" apply false - id("com.diffplug.spotless") version "8.3.0" apply false - id("com.google.cloud.tools.jib") version "3.5.3" apply false + id("org.springframework.boot") apply false + id("io.spring.dependency-management") apply false + id("com.diffplug.spotless") apply false + id("com.google.cloud.tools.jib") apply false id("org.sonarqube") version "7.2.3.7755" java } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 00000000..fe95ae90 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() + gradlePluginPortal() +} + +dependencies { + implementation("org.springframework.boot:spring-boot-gradle-plugin:4.0.3") + implementation("io.spring.gradle:dependency-management-plugin:1.1.7") + implementation("com.diffplug.spotless:spotless-plugin-gradle:8.3.0") + implementation("com.google.cloud.tools:jib-gradle-plugin:3.5.3") +} diff --git a/buildSrc/settings.gradle.kts b/buildSrc/settings.gradle.kts new file mode 100644 index 00000000..34e12cf1 --- /dev/null +++ b/buildSrc/settings.gradle.kts @@ -0,0 +1,5 @@ +buildCache { + local { + isEnabled = true + } +} diff --git a/buildSrc/src/main/kotlin/stablebridge.api-library.gradle.kts b/buildSrc/src/main/kotlin/stablebridge.api-library.gradle.kts new file mode 100644 index 00000000..199483a2 --- /dev/null +++ b/buildSrc/src/main/kotlin/stablebridge.api-library.gradle.kts @@ -0,0 +1,17 @@ +plugins { + `java-library` +} + +dependencies { + api(project(":platform-api")) + api("jakarta.validation:jakarta.validation-api") + api("com.fasterxml.jackson.core:jackson-annotations") + + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.assertj:assertj-core") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/buildSrc/src/main/kotlin/stablebridge.client-library.gradle.kts b/buildSrc/src/main/kotlin/stablebridge.client-library.gradle.kts new file mode 100644 index 00000000..53b95621 --- /dev/null +++ b/buildSrc/src/main/kotlin/stablebridge.client-library.gradle.kts @@ -0,0 +1,15 @@ +plugins { + `java-library` +} + +dependencies { + implementation("org.springframework.cloud:spring-cloud-starter-openfeign") + + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation("org.assertj:assertj-core") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/buildSrc/src/main/kotlin/stablebridge.service.gradle.kts b/buildSrc/src/main/kotlin/stablebridge.service.gradle.kts new file mode 100644 index 00000000..6eeed677 --- /dev/null +++ b/buildSrc/src/main/kotlin/stablebridge.service.gradle.kts @@ -0,0 +1,259 @@ +import org.gradle.api.tasks.testing.logging.TestExceptionFormat + +plugins { + id("org.springframework.boot") + id("com.google.cloud.tools.jib") + java + `java-test-fixtures` + jacoco +} + +// --------------------------------------------------------------------------- +// Extension for per-service customization +// --------------------------------------------------------------------------- +interface StablebridgeServiceExtension { + val jibImageName: Property + val jacocoMinimum: Property + val extraJacocoExclusions: ListProperty +} + +val stablebridge = extensions.create("stablebridge") +stablebridge.jacocoMinimum.convention("0.50") +stablebridge.extraJacocoExclusions.convention(emptyList()) + +// --------------------------------------------------------------------------- +// JIB Docker image configuration +// --------------------------------------------------------------------------- +afterEvaluate { + extensions.configure { + from { + image = "docker://eclipse-temurin:25-jre" + } + to { + image = stablebridge.jibImageName.get() + tags = setOf("latest") + } + container { + creationTime.set("USE_CURRENT_TIMESTAMP") + } + } +} + +// --------------------------------------------------------------------------- +// Integration test source set +// --------------------------------------------------------------------------- +val integrationTestSourceSet: SourceSet = sourceSets.create("integrationTest") { + java.srcDir("src/integration-test/java") + resources.srcDir("src/integration-test/resources") + compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output + runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output +} + +configurations { + named("integrationTestImplementation") { extendsFrom(configurations.testImplementation.get()) } + named("integrationTestRuntimeOnly") { extendsFrom(configurations.testRuntimeOnly.get()) } +} + +tasks.register("integrationTest") { + testClassesDirs = integrationTestSourceSet.output.classesDirs + classpath = integrationTestSourceSet.runtimeClasspath + shouldRunAfter(tasks.test) + configure { isEnabled = false } + exclude("**/Abstract*", "**/config/**") +} + +// --------------------------------------------------------------------------- +// Business test source set +// --------------------------------------------------------------------------- +val businessTestSourceSet: SourceSet = sourceSets.create("businessTest") { + java.srcDir("src/business-test/java") + resources.srcDir("src/business-test/resources") + compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output + integrationTestSourceSet.output + runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output + integrationTestSourceSet.output +} + +configurations { + named("businessTestImplementation") { extendsFrom(configurations.named("integrationTestImplementation").get()) } + named("businessTestRuntimeOnly") { extendsFrom(configurations.named("integrationTestRuntimeOnly").get()) } +} + +tasks.register("businessTest") { + testClassesDirs = businessTestSourceSet.output.classesDirs + classpath = businessTestSourceSet.runtimeClasspath + shouldRunAfter(tasks.named("integrationTest")) + failOnNoDiscoveredTests = false + configure { isEnabled = false } +} + +// --------------------------------------------------------------------------- +// Version properties from gradle.properties +// --------------------------------------------------------------------------- +val lombokVersion: String by project +val mapstructVersion: String by project +val lombokMapstructBindingVersion: String by project +val resilience4jVersion: String by project +val flywayVersion: String by project +val archunitVersion: String by project +val testcontainersVersion: String by project +val wiremockVersion: String by project +val springdocVersion: String by project +val namastackVersion: String by project + +// --------------------------------------------------------------------------- +// Common dependencies — every service gets these +// --------------------------------------------------------------------------- +dependencies { + // Shared runtime infrastructure (AbstractOutboxHandler, AbstractOutboxEventPublisher, BaseGlobalExceptionHandler) + implementation(project(":platform-infra")) + + // Spring Boot + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-data-jpa") + implementation("org.springframework.boot:spring-boot-starter-actuator") + + // OpenAPI / Swagger UI + implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$springdocVersion") + + // Observability + runtimeOnly("io.micrometer:micrometer-registry-prometheus") + implementation("io.micrometer:micrometer-tracing-bridge-otel") + implementation("io.opentelemetry:opentelemetry-exporter-otlp") + + // Security + implementation("org.springframework.boot:spring-boot-starter-security") + + // Kafka via Spring Cloud Stream + implementation("org.springframework.cloud:spring-cloud-stream") + implementation("org.springframework.cloud:spring-cloud-stream-binder-kafka") + implementation("org.springframework.kafka:spring-kafka") + + // Feign + implementation("org.springframework.cloud:spring-cloud-starter-openfeign") + + // Resilience4j (circuit breaker — retry is per-service opt-in) + implementation("io.github.resilience4j:resilience4j-spring-boot3:$resilience4jVersion") + implementation("io.github.resilience4j:resilience4j-circuitbreaker:$resilience4jVersion") + + // MapStruct + implementation("org.mapstruct:mapstruct:$mapstructVersion") + annotationProcessor("org.mapstruct:mapstruct-processor:$mapstructVersion") + annotationProcessor("org.projectlombok:lombok-mapstruct-binding:$lombokMapstructBindingVersion") + + // Outbox (namastack) + implementation("io.namastack:namastack-outbox-starter-jdbc:$namastackVersion") + + // Database + runtimeOnly("org.postgresql:postgresql") + implementation("org.springframework.boot:spring-boot-starter-flyway") + implementation("org.flywaydb:flyway-database-postgresql:$flywayVersion") + + // Shared test infrastructure (TestUtils, HexagonalArchitectureRules, TestContainerSupport, TestSecurityConfig) + testFixturesImplementation(testFixtures(project(":platform-test"))) + testFixturesImplementation("org.assertj:assertj-core") + testFixturesImplementation("org.mockito:mockito-core") + + // Test + testImplementation(testFixtures(project(":platform-test"))) + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.kafka:spring-kafka-test") + testImplementation("com.tngtech.archunit:archunit-junit5:$archunitVersion") + + // Integration Test + "integrationTestImplementation"(testFixtures(project)) + "integrationTestImplementation"(testFixtures(project(":platform-test"))) + "integrationTestImplementation"("org.testcontainers:postgresql:$testcontainersVersion") + "integrationTestImplementation"("org.testcontainers:kafka:$testcontainersVersion") + "integrationTestImplementation"("org.testcontainers:junit-jupiter:$testcontainersVersion") + "integrationTestImplementation"("org.wiremock:wiremock-standalone:$wiremockVersion") + "integrationTestImplementation"("org.springframework.boot:spring-boot-starter-webmvc-test") + "integrationTestImplementation"("org.springframework.boot:spring-boot-starter-security-test") +} + +// --------------------------------------------------------------------------- +// MapStruct compiler args +// --------------------------------------------------------------------------- +tasks.withType { + options.compilerArgs.addAll(listOf( + "-Amapstruct.defaultComponentModel=spring", + "-Amapstruct.unmappedTargetPolicy=IGNORE" + )) +} + +// --------------------------------------------------------------------------- +// Test configuration +// --------------------------------------------------------------------------- +tasks.withType { + jvmArgs("-Dnet.bytebuddy.experimental=true") + useJUnitPlatform() + testLogging { + events("passed", "skipped", "failed") + showExceptions = true + showCauses = true + showStackTraces = true + exceptionFormat = TestExceptionFormat.FULL + } +} + +// --------------------------------------------------------------------------- +// JaCoCo +// --------------------------------------------------------------------------- +jacoco { + toolVersion = "0.8.14" +} + +tasks.test { + configure { + excludes = listOf("sun.*", "jdk.*", "com.sun.*", "java.*", "javax.*") + } + finalizedBy(tasks.jacocoTestReport) +} + +val baseJacocoExclusions = listOf( + "**/entity/**", + "**/mapper/**", + "**/config/**", + "**/*Application*", + "**/generated/**", + "**/*MapperImpl*" +) + +afterEvaluate { + val allExclusions = baseJacocoExclusions + stablebridge.extraJacocoExclusions.get() + + tasks.jacocoTestReport { + dependsOn(tasks.test) + reports { + xml.required.set(true) + html.required.set(true) + } + classDirectories.setFrom(files(classDirectories.files.map { + fileTree(it) { exclude(allExclusions) } + })) + } + + tasks.jacocoTestCoverageVerification { + dependsOn(tasks.jacocoTestReport) + violationRules { + rule { + limit { + minimum = stablebridge.jacocoMinimum.get().toBigDecimal() + } + } + } + classDirectories.setFrom(files(classDirectories.files.map { + fileTree(it) { exclude(allExclusions) } + })) + } +} + +// --------------------------------------------------------------------------- +// Wire check task +// --------------------------------------------------------------------------- +tasks.named("check") { + dependsOn( + tasks.named("integrationTest"), + tasks.named("businessTest"), + tasks.jacocoTestCoverageVerification + ) +} diff --git a/compliance-travel-rule/compliance-travel-rule-api/build.gradle.kts b/compliance-travel-rule/compliance-travel-rule-api/build.gradle.kts index 20c8280f..97d4baa4 100644 --- a/compliance-travel-rule/compliance-travel-rule-api/build.gradle.kts +++ b/compliance-travel-rule/compliance-travel-rule-api/build.gradle.kts @@ -1,16 +1,3 @@ plugins { - `java-library` -} - -dependencies { - api("jakarta.validation:jakarta.validation-api") - api("com.fasterxml.jackson.core:jackson-annotations") - - testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation("org.assertj:assertj-core") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { - useJUnitPlatform() + id("stablebridge.api-library") } diff --git a/compliance-travel-rule/compliance-travel-rule-api/src/main/java/com/stablecoin/payments/compliance/api/response/ApiError.java b/compliance-travel-rule/compliance-travel-rule-api/src/main/java/com/stablecoin/payments/compliance/api/response/ApiError.java deleted file mode 100644 index b877f5ba..00000000 --- a/compliance-travel-rule/compliance-travel-rule-api/src/main/java/com/stablecoin/payments/compliance/api/response/ApiError.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.stablecoin.payments.compliance.api.response; - -import java.util.List; -import java.util.Map; - -public record ApiError( - String code, - String status, - String message, - Map> errors -) { - public static ApiError of(String code, String status, String message) { - return new ApiError(code, status, message, Map.of()); - } - - public static ApiError withErrors(String code, String status, String message, - Map> errors) { - return new ApiError(code, status, message, errors); - } -} diff --git a/compliance-travel-rule/compliance-travel-rule-client/build.gradle.kts b/compliance-travel-rule/compliance-travel-rule-client/build.gradle.kts index 4a5d4e1e..eda9333c 100644 --- a/compliance-travel-rule/compliance-travel-rule-client/build.gradle.kts +++ b/compliance-travel-rule/compliance-travel-rule-client/build.gradle.kts @@ -1,16 +1,7 @@ plugins { - `java-library` + id("stablebridge.client-library") } dependencies { api(project(":compliance-travel-rule:compliance-travel-rule-api")) - implementation("org.springframework.cloud:spring-cloud-starter-openfeign") - - testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation("org.assertj:assertj-core") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { - useJUnitPlatform() } diff --git a/compliance-travel-rule/compliance-travel-rule/build.gradle.kts b/compliance-travel-rule/compliance-travel-rule/build.gradle.kts index 2cfb4af1..c21e8206 100644 --- a/compliance-travel-rule/compliance-travel-rule/build.gradle.kts +++ b/compliance-travel-rule/compliance-travel-rule/build.gradle.kts @@ -1,207 +1,22 @@ plugins { - id("org.springframework.boot") - id("com.google.cloud.tools.jib") - java - `java-test-fixtures` - jacoco + id("stablebridge.service") } -jib { - from { - image = "docker://eclipse-temurin:25-jre" - } - to { - image = "stablebridge/compliance-travel-rule" - tags = setOf("latest") - } - container { - creationTime.set("USE_CURRENT_TIMESTAMP") - } -} - -val integrationTestSourceSet: SourceSet = sourceSets.create("integrationTest") { - java.srcDir("src/integration-test/java") - resources.srcDir("src/integration-test/resources") - compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output - runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output -} - -configurations { - named("integrationTestImplementation") { extendsFrom(configurations.testImplementation.get()) } - named("integrationTestRuntimeOnly") { extendsFrom(configurations.testRuntimeOnly.get()) } -} - -tasks.register("integrationTest") { - testClassesDirs = integrationTestSourceSet.output.classesDirs - classpath = integrationTestSourceSet.runtimeClasspath - shouldRunAfter(tasks.test) - configure { isEnabled = false } - failOnNoDiscoveredTests = false - exclude("**/Abstract*", "**/config/**") -} - -val businessTestSourceSet: SourceSet = sourceSets.create("businessTest") { - java.srcDir("src/business-test/java") - resources.srcDir("src/business-test/resources") - compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output + integrationTestSourceSet.output - runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output + integrationTestSourceSet.output -} - -configurations { - named("businessTestImplementation") { extendsFrom(configurations.named("integrationTestImplementation").get()) } - named("businessTestRuntimeOnly") { extendsFrom(configurations.named("integrationTestRuntimeOnly").get()) } -} +val resilience4jVersion: String by project -tasks.register("businessTest") { - testClassesDirs = businessTestSourceSet.output.classesDirs - classpath = businessTestSourceSet.runtimeClasspath - shouldRunAfter(tasks.named("integrationTest")) - failOnNoDiscoveredTests = false - configure { isEnabled = false } - failOnNoDiscoveredTests = false +stablebridge { + jibImageName.set("stablebridge/compliance-travel-rule") } -val lombokVersion: String by project -val mapstructVersion: String by project -val lombokMapstructBindingVersion: String by project -val resilience4jVersion: String by project -val flywayVersion: String by project -val archunitVersion: String by project -val testcontainersVersion: String by project -val wiremockVersion: String by project -val springdocVersion: String by project - dependencies { implementation(project(":compliance-travel-rule:compliance-travel-rule-api")) - // Spring Boot - implementation("org.springframework.boot:spring-boot-starter-web") - implementation("org.springframework.boot:spring-boot-starter-validation") - implementation("org.springframework.boot:spring-boot-starter-data-jpa") - implementation("org.springframework.boot:spring-boot-starter-actuator") - - // OpenAPI / Swagger UI - implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$springdocVersion") - runtimeOnly("io.micrometer:micrometer-registry-prometheus") - implementation("io.micrometer:micrometer-tracing-bridge-otel") - implementation("io.opentelemetry:opentelemetry-exporter-otlp") - implementation("org.springframework.boot:spring-boot-starter-security") - // Redis — KYC cache implementation("org.springframework.boot:spring-boot-starter-data-redis") - // Kafka via Spring Cloud Stream - implementation("org.springframework.cloud:spring-cloud-stream") - implementation("org.springframework.cloud:spring-cloud-stream-binder-kafka") - implementation("org.springframework.kafka:spring-kafka") - - // Feign - implementation("org.springframework.cloud:spring-cloud-starter-openfeign") - - // Resilience4j - implementation("io.github.resilience4j:resilience4j-spring-boot3:$resilience4jVersion") - implementation("io.github.resilience4j:resilience4j-circuitbreaker:$resilience4jVersion") + // Resilience4j retry (service-specific) implementation("io.github.resilience4j:resilience4j-retry:$resilience4jVersion") - // MapStruct (compiler args set below in JavaCompile task) - implementation("org.mapstruct:mapstruct:$mapstructVersion") - annotationProcessor("org.mapstruct:mapstruct-processor:$mapstructVersion") - annotationProcessor("org.projectlombok:lombok-mapstruct-binding:$lombokMapstructBindingVersion") - - // Outbox (namastack) - implementation("io.namastack:namastack-outbox-starter-jdbc:1.1.0") - - // Database - runtimeOnly("org.postgresql:postgresql") - implementation("org.springframework.boot:spring-boot-starter-flyway") - implementation("org.flywaydb:flyway-database-postgresql:$flywayVersion") - - // Test Fixtures - testFixturesImplementation("org.assertj:assertj-core") - testFixturesImplementation("org.mockito:mockito-core") - - // Test - testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("org.springframework.kafka:spring-kafka-test") - testImplementation("com.tngtech.archunit:archunit-junit5:$archunitVersion") - testImplementation("org.wiremock:wiremock-standalone:$wiremockVersion") - "integrationTestImplementation"(testFixtures(project)) - "integrationTestImplementation"("org.testcontainers:postgresql:$testcontainersVersion") - "integrationTestImplementation"("org.testcontainers:kafka:$testcontainersVersion") - "integrationTestImplementation"("org.testcontainers:junit-jupiter:$testcontainersVersion") - "integrationTestImplementation"("org.wiremock:wiremock-standalone:$wiremockVersion") - "integrationTestImplementation"("org.springframework.boot:spring-boot-starter-webmvc-test") - "integrationTestImplementation"("org.springframework.boot:spring-boot-starter-security-test") -} - -tasks.withType { - options.compilerArgs.addAll(listOf( - "-Amapstruct.defaultComponentModel=spring", - "-Amapstruct.unmappedTargetPolicy=IGNORE" - )) -} - -tasks.withType { - jvmArgs("-Dnet.bytebuddy.experimental=true") - useJUnitPlatform() - testLogging { - events("passed", "skipped", "failed") - showExceptions = true - showCauses = true - showStackTraces = true - exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL - } -} - -jacoco { - toolVersion = "0.8.14" -} - -tasks.test { - configure { - excludes = listOf("sun.*", "jdk.*", "com.sun.*", "java.*", "javax.*") - } - finalizedBy(tasks.jacocoTestReport) -} - -val jacocoExclusions = listOf( - "**/entity/**", - "**/mapper/**", - "**/config/**", - "**/*Application*", - "**/generated/**", - "**/*MapperImpl*" -) - -tasks.jacocoTestReport { - dependsOn(tasks.test) - reports { - xml.required.set(true) - html.required.set(true) - } - classDirectories.setFrom(files(classDirectories.files.map { - fileTree(it) { exclude(jacocoExclusions) } - })) -} - -tasks.jacocoTestCoverageVerification { - dependsOn(tasks.jacocoTestReport) - violationRules { - rule { - limit { - minimum = "0.50".toBigDecimal() - } - } - } - classDirectories.setFrom(files(classDirectories.files.map { - fileTree(it) { exclude(jacocoExclusions) } - })) -} - -tasks.named("check") { - dependsOn( - tasks.named("integrationTest"), - tasks.named("businessTest"), - tasks.jacocoTestCoverageVerification - ) + // Test — WireMock for adapter unit tests + testImplementation("org.wiremock:wiremock-standalone:${project.property("wiremockVersion")}") } diff --git a/compliance-travel-rule/compliance-travel-rule/src/integration-test/java/com/stablecoin/payments/compliance/AbstractIntegrationTest.java b/compliance-travel-rule/compliance-travel-rule/src/integration-test/java/com/stablecoin/payments/compliance/AbstractIntegrationTest.java index 6a83ac5b..70d89e56 100644 --- a/compliance-travel-rule/compliance-travel-rule/src/integration-test/java/com/stablecoin/payments/compliance/AbstractIntegrationTest.java +++ b/compliance-travel-rule/compliance-travel-rule/src/integration-test/java/com/stablecoin/payments/compliance/AbstractIntegrationTest.java @@ -10,8 +10,12 @@ import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.KafkaContainer; import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.lifecycle.Startable; -import org.testcontainers.utility.DockerImageName; + +import static com.stablecoin.payments.platform.test.TestContainerSupport.kafka; +import static com.stablecoin.payments.platform.test.TestContainerSupport.postgres; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerKafkaProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerPostgresProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.startAll; @SuppressWarnings("resource") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -19,38 +23,11 @@ @AutoConfigureMockMvc public abstract class AbstractIntegrationTest { - static final PostgreSQLContainer POSTGRES = - new PostgreSQLContainer<>("postgres:16-alpine") - .withDatabaseName("s2_compliance") - .withUsername("test") - .withPassword("test"); - - protected static final KafkaContainer KAFKA = - new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0")); + static final PostgreSQLContainer POSTGRES = postgres("s2_compliance"); + protected static final KafkaContainer KAFKA = kafka(); static { - try { - POSTGRES.start(); - KAFKA.start(); - } catch (RuntimeException ex) { - safeStop(KAFKA); - safeStop(POSTGRES); - throw ex; - } - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - safeStop(KAFKA); - safeStop(POSTGRES); - }, "testcontainers-shutdown")); - } - - private static void safeStop(Startable container) { - try { - if (container != null) { - container.stop(); - } - } catch (Exception ignored) { - // best-effort cleanup - } + startAll(POSTGRES, KAFKA); } @Autowired @@ -73,10 +50,7 @@ void cleanDatabase() { @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); - registry.add("spring.datasource.username", POSTGRES::getUsername); - registry.add("spring.datasource.password", POSTGRES::getPassword); - registry.add("spring.kafka.bootstrap-servers", KAFKA::getBootstrapServers); - registry.add("spring.cloud.stream.kafka.binder.brokers", KAFKA::getBootstrapServers); + registerPostgresProperties(registry, POSTGRES); + registerKafkaProperties(registry, KAFKA); } } diff --git a/compliance-travel-rule/compliance-travel-rule/src/integration-test/java/com/stablecoin/payments/compliance/config/TestSecurityConfig.java b/compliance-travel-rule/compliance-travel-rule/src/integration-test/java/com/stablecoin/payments/compliance/config/TestSecurityConfig.java deleted file mode 100644 index 6ce01d3c..00000000 --- a/compliance-travel-rule/compliance-travel-rule/src/integration-test/java/com/stablecoin/payments/compliance/config/TestSecurityConfig.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.stablecoin.payments.compliance.config; - -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.web.SecurityFilterChain; - -@TestConfiguration -@EnableMethodSecurity -public class TestSecurityConfig { - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .csrf(csrf -> csrf.disable()) - .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); - return http.build(); - } -} diff --git a/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/application/controller/GlobalExceptionHandler.java b/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/application/controller/GlobalExceptionHandler.java index c480fd31..790e5caf 100644 --- a/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/application/controller/GlobalExceptionHandler.java +++ b/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/application/controller/GlobalExceptionHandler.java @@ -1,58 +1,28 @@ package com.stablecoin.payments.compliance.application.controller; -import com.stablecoin.payments.compliance.api.response.ApiError; import com.stablecoin.payments.compliance.domain.exception.CheckNotFoundException; import com.stablecoin.payments.compliance.domain.exception.CustomerNotFoundException; import com.stablecoin.payments.compliance.domain.exception.DuplicatePaymentException; -import jakarta.validation.ConstraintViolationException; +import com.stablecoin.payments.platform.api.ApiError; +import com.stablecoin.payments.platform.infrastructure.exception.BaseGlobalExceptionHandler; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.validation.FieldError; -import org.springframework.validation.ObjectError; -import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; - -import java.util.stream.Collectors; import static com.stablecoin.payments.compliance.application.controller.ErrorCodes.CHECK_NOT_FOUND; import static com.stablecoin.payments.compliance.application.controller.ErrorCodes.CUSTOMER_NOT_FOUND; import static com.stablecoin.payments.compliance.application.controller.ErrorCodes.DUPLICATE_PAYMENT; -import static com.stablecoin.payments.compliance.application.controller.ErrorCodes.VALIDATION_ERROR; -import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.CONFLICT; -import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; import static org.springframework.http.HttpStatus.NOT_FOUND; -import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY; @Slf4j @RestControllerAdvice -public class GlobalExceptionHandler { - - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(MethodArgumentNotValidException.class) - public ApiError handleValidation(MethodArgumentNotValidException ex) { - var errors = ex.getBindingResult().getFieldErrors().stream() - .collect(Collectors.groupingBy( - FieldError::getField, - Collectors.mapping(ObjectError::getDefaultMessage, Collectors.toList()))); - log.info("Validation failed: {}", errors); - return ApiError.withErrors("CO-0001", BAD_REQUEST.getReasonPhrase(), - "Invalid request content", errors); - } +public class GlobalExceptionHandler extends BaseGlobalExceptionHandler { - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(ConstraintViolationException.class) - public ApiError handleConstraintViolation(ConstraintViolationException ex) { - var errors = ex.getConstraintViolations().stream() - .collect(Collectors.groupingBy( - v -> v.getPropertyPath().toString(), - Collectors.mapping(jakarta.validation.ConstraintViolation::getMessage, - Collectors.toList()))); - return ApiError.withErrors("CO-0001", BAD_REQUEST.getReasonPhrase(), - "Invalid request content", errors); + @Override + protected String errorCodePrefix() { + return "CO"; } @ResponseStatus(NOT_FOUND) @@ -75,34 +45,4 @@ public ApiError handleDuplicatePayment(DuplicatePaymentException ex) { log.info("Duplicate payment: {}", ex.getMessage()); return ApiError.of(DUPLICATE_PAYMENT, CONFLICT.getReasonPhrase(), ex.getMessage()); } - - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(MethodArgumentTypeMismatchException.class) - public ApiError handleTypeMismatch(MethodArgumentTypeMismatchException ex) { - log.info("Type mismatch for parameter '{}': {}", ex.getName(), ex.getMessage()); - return ApiError.of(VALIDATION_ERROR, BAD_REQUEST.getReasonPhrase(), - "Invalid value for parameter '" + ex.getName() + "'"); - } - - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(IllegalArgumentException.class) - public ApiError handleIllegalArgument(IllegalArgumentException ex) { - log.info("Illegal argument: {}", ex.getMessage()); - return ApiError.of("CO-0001", BAD_REQUEST.getReasonPhrase(), ex.getMessage()); - } - - @ResponseStatus(UNPROCESSABLE_ENTITY) - @ExceptionHandler(IllegalStateException.class) - public ApiError handleInvalidState(IllegalStateException ex) { - log.info("Invalid state: {}", ex.getMessage()); - return ApiError.of("CO-0004", UNPROCESSABLE_ENTITY.getReasonPhrase(), ex.getMessage()); - } - - @ResponseStatus(INTERNAL_SERVER_ERROR) - @ExceptionHandler(Exception.class) - public ApiError handleUnexpected(Exception ex) { - log.error("Unexpected error: ", ex); - return ApiError.of("CO-9999", INTERNAL_SERVER_ERROR.getReasonPhrase(), - HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase()); - } } diff --git a/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/infrastructure/messaging/ComplianceOutboxHandler.java b/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/infrastructure/messaging/ComplianceOutboxHandler.java index b741477b..bb5ef986 100644 --- a/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/infrastructure/messaging/ComplianceOutboxHandler.java +++ b/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/infrastructure/messaging/ComplianceOutboxHandler.java @@ -1,42 +1,13 @@ package com.stablecoin.payments.compliance.infrastructure.messaging; -import io.namastack.outbox.annotation.OutboxHandler; -import io.namastack.outbox.handler.OutboxRecordMetadata; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import com.stablecoin.payments.platform.infrastructure.messaging.AbstractOutboxHandler; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; -import java.util.concurrent.TimeUnit; - -@Slf4j @Component -@RequiredArgsConstructor -public class ComplianceOutboxHandler { - - private final KafkaTemplate kafkaTemplate; - - @OutboxHandler - public void handle(Object event, OutboxRecordMetadata metadata) { - var topic = resolveField(event, "TOPIC"); - var key = metadata.getKey(); - try { - kafkaTemplate.send(topic, key, event).get(10, TimeUnit.SECONDS); - log.debug("Published outbox event type={} topic={} key={}", - event.getClass().getSimpleName(), topic, key); - } catch (Exception e) { - log.error("Failed to publish event type={} topic={}: {}", - event.getClass().getSimpleName(), topic, e.getMessage()); - throw new RuntimeException("Kafka send failed for event " + event.getClass().getSimpleName(), e); - } - } +public class ComplianceOutboxHandler extends AbstractOutboxHandler { - private String resolveField(Object event, String fieldName) { - try { - return (String) event.getClass().getField(fieldName).get(null); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new IllegalArgumentException( - "Event class missing static " + fieldName + " field: " + event.getClass().getName(), e); - } + public ComplianceOutboxHandler(KafkaTemplate kafkaTemplate) { + super(kafkaTemplate); } } diff --git a/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/infrastructure/messaging/OutboxEventPublisher.java b/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/infrastructure/messaging/OutboxEventPublisher.java index c303a8a9..2ee352ac 100644 --- a/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/infrastructure/messaging/OutboxEventPublisher.java +++ b/compliance-travel-rule/compliance-travel-rule/src/main/java/com/stablecoin/payments/compliance/infrastructure/messaging/OutboxEventPublisher.java @@ -1,36 +1,17 @@ package com.stablecoin.payments.compliance.infrastructure.messaging; import com.stablecoin.payments.compliance.domain.port.EventPublisher; +import com.stablecoin.payments.platform.infrastructure.messaging.AbstractOutboxEventPublisher; import io.namastack.outbox.Outbox; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -@Slf4j -@Component -@RequiredArgsConstructor -public class OutboxEventPublisher implements EventPublisher { - - private final Outbox outbox; +import java.util.List; - @Override - @Transactional(propagation = Propagation.MANDATORY) - public void publish(Object event) { - var key = resolveField(event, "paymentId"); - outbox.schedule(event, key); - log.debug("Scheduled outbox event type={} key={}", event.getClass().getSimpleName(), key); - } +@Component +public class OutboxEventPublisher extends AbstractOutboxEventPublisher + implements EventPublisher { - private String resolveField(Object event, String fieldName) { - try { - var method = event.getClass().getMethod(fieldName); - return String.valueOf(method.invoke(event)); - } catch (Exception e) { - throw new IllegalArgumentException( - "Event class missing accessor for field '" + fieldName + "': " - + event.getClass().getName(), e); - } + public OutboxEventPublisher(Outbox outbox) { + super(outbox, List.of("paymentId")); } } diff --git a/compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/arch/ArchitectureTest.java b/compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/arch/ArchitectureTest.java index 37e7af35..9ff79657 100644 --- a/compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/arch/ArchitectureTest.java +++ b/compliance-travel-rule/compliance-travel-rule/src/test/java/com/stablecoin/payments/compliance/arch/ArchitectureTest.java @@ -1,105 +1,15 @@ package com.stablecoin.payments.compliance.arch; -import com.tngtech.archunit.core.domain.JavaClasses; -import com.tngtech.archunit.core.importer.ClassFileImporter; -import com.tngtech.archunit.core.importer.ImportOption; -import org.junit.jupiter.api.BeforeAll; +import com.stablecoin.payments.platform.test.HexagonalArchitectureRules; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; -import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; - @DisplayName("Architecture Rules") class ArchitectureTest { - private static JavaClasses importedClasses; - - @BeforeAll - static void setUp() { - importedClasses = new ClassFileImporter() - .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) - .importPackages("com.stablecoin.payments.compliance"); - } - - @Test - @DisplayName("Domain should not depend on Spring (except stereotype and transaction)") - void domainShouldNotDependOnSpring() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat( - resideInAPackage("org.springframework..") - .and(resideOutsideOfPackage("org.springframework.stereotype..")) - .and(resideOutsideOfPackage("org.springframework.transaction..")) - .and(resideOutsideOfPackage("org.springframework.beans.factory.annotation..")) - ) - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on JPA") - void domainShouldNotDependOnJpa() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("jakarta.persistence..") - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on infrastructure") - void domainShouldNotDependOnInfrastructure() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("..infrastructure..") - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on application layer") - void domainShouldNotDependOnApplication() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("..application..") - .check(importedClasses); - } - - @Test - @DisplayName("Infrastructure should not depend on application controller") - void infrastructureShouldNotDependOnApplicationController() { - noClasses() - .that().resideInAPackage("..infrastructure..") - .should().dependOnClassesThat().resideInAPackage("..application.controller..") - .check(importedClasses); - } - - @Test - @DisplayName("Ports should be interfaces") - void portsShouldBeInterfaces() { - classes() - .that().resideInAPackage("..domain.port..") - .and().areNotRecords() - .should().beInterfaces() - .check(importedClasses); - } - - @Test - @DisplayName("Domain events should be records") - void domainEventsShouldBeRecords() { - classes() - .that().resideInAPackage("..domain.event..") - .should().beRecords() - .check(importedClasses); - } - @Test - @DisplayName("Controllers should reside in application.controller package") - void controllersShouldResideInApplicationController() { - noClasses() - .that().haveSimpleNameEndingWith("Controller") - .should().resideOutsideOfPackage("..application.controller..") - .allowEmptyShould(true) - .check(importedClasses); + @DisplayName("Verify hexagonal architecture rules") + void verifyHexagonalArchitecture() { + HexagonalArchitectureRules.verifyAll("com.stablecoin.payments.compliance"); } } diff --git a/compliance-travel-rule/compliance-travel-rule/src/testFixtures/java/com/stablecoin/payments/compliance/fixtures/TestUtils.java b/compliance-travel-rule/compliance-travel-rule/src/testFixtures/java/com/stablecoin/payments/compliance/fixtures/TestUtils.java deleted file mode 100644 index 3684247b..00000000 --- a/compliance-travel-rule/compliance-travel-rule/src/testFixtures/java/com/stablecoin/payments/compliance/fixtures/TestUtils.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.stablecoin.payments.compliance.fixtures; - -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.ZonedDateTime; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.argThat; - -public final class TestUtils { - - private TestUtils() {} - - public static T eqIgnoringTimestamps(T expected) { - return eqIgnoring(expected); - } - - public static T eqIgnoring(T expected, String... fieldsToIgnore) { - return argThat(it -> isEqualIgnoring(it, expected, fieldsToIgnore)); - } - - private static boolean isEqualIgnoring(T original, T expected, String... fieldsToIgnore) { - try { - assertThat(original) - .usingRecursiveComparison() - .ignoringFieldsOfTypes(ZonedDateTime.class, LocalDateTime.class, LocalDate.class, Instant.class) - .ignoringFields(fieldsToIgnore) - .isEqualTo(expected); - return true; - } catch (Throwable t) { - return false; - } - } -} diff --git a/fiat-off-ramp/fiat-off-ramp-api/build.gradle.kts b/fiat-off-ramp/fiat-off-ramp-api/build.gradle.kts index 20c8280f..97d4baa4 100644 --- a/fiat-off-ramp/fiat-off-ramp-api/build.gradle.kts +++ b/fiat-off-ramp/fiat-off-ramp-api/build.gradle.kts @@ -1,16 +1,3 @@ plugins { - `java-library` -} - -dependencies { - api("jakarta.validation:jakarta.validation-api") - api("com.fasterxml.jackson.core:jackson-annotations") - - testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation("org.assertj:assertj-core") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { - useJUnitPlatform() + id("stablebridge.api-library") } diff --git a/fiat-off-ramp/fiat-off-ramp-api/src/main/java/com/stablecoin/payments/offramp/api/ApiError.java b/fiat-off-ramp/fiat-off-ramp-api/src/main/java/com/stablecoin/payments/offramp/api/ApiError.java deleted file mode 100644 index a9f90a8e..00000000 --- a/fiat-off-ramp/fiat-off-ramp-api/src/main/java/com/stablecoin/payments/offramp/api/ApiError.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.stablecoin.payments.offramp.api; - -import java.util.List; -import java.util.Map; - -public record ApiError( - String code, - String status, - String message, - Map> errors -) { - public static ApiError of(String code, String status, String message) { - return new ApiError(code, status, message, Map.of()); - } - - public static ApiError withErrors(String code, String status, String message, - Map> errors) { - return new ApiError(code, status, message, errors); - } -} diff --git a/fiat-off-ramp/fiat-off-ramp-client/build.gradle.kts b/fiat-off-ramp/fiat-off-ramp-client/build.gradle.kts index 1263100b..6aec7b0c 100644 --- a/fiat-off-ramp/fiat-off-ramp-client/build.gradle.kts +++ b/fiat-off-ramp/fiat-off-ramp-client/build.gradle.kts @@ -1,16 +1,7 @@ plugins { - `java-library` + id("stablebridge.client-library") } dependencies { api(project(":fiat-off-ramp:fiat-off-ramp-api")) - implementation("org.springframework.cloud:spring-cloud-starter-openfeign") - - testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation("org.assertj:assertj-core") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { - useJUnitPlatform() } diff --git a/fiat-off-ramp/fiat-off-ramp/build.gradle.kts b/fiat-off-ramp/fiat-off-ramp/build.gradle.kts index 158cd8f0..748a18e4 100644 --- a/fiat-off-ramp/fiat-off-ramp/build.gradle.kts +++ b/fiat-off-ramp/fiat-off-ramp/build.gradle.kts @@ -1,206 +1,23 @@ plugins { - id("org.springframework.boot") - id("com.google.cloud.tools.jib") - java - `java-test-fixtures` - jacoco + id("stablebridge.service") } -jib { - from { - image = "docker://eclipse-temurin:25-jre" - } - to { - image = "stablebridge/fiat-off-ramp" - tags = setOf("latest") - } - container { - creationTime.set("USE_CURRENT_TIMESTAMP") - } -} - -val integrationTestSourceSet: SourceSet = sourceSets.create("integrationTest") { - java.srcDir("src/integration-test/java") - resources.srcDir("src/integration-test/resources") - compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output - runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output -} - -configurations { - named("integrationTestImplementation") { extendsFrom(configurations.testImplementation.get()) } - named("integrationTestRuntimeOnly") { extendsFrom(configurations.testRuntimeOnly.get()) } -} - -tasks.register("integrationTest") { - testClassesDirs = integrationTestSourceSet.output.classesDirs - classpath = integrationTestSourceSet.runtimeClasspath - shouldRunAfter(tasks.test) - configure { isEnabled = false } - failOnNoDiscoveredTests = false - exclude("**/Abstract*", "**/config/**") -} - -val businessTestSourceSet: SourceSet = sourceSets.create("businessTest") { - java.srcDir("src/business-test/java") - resources.srcDir("src/business-test/resources") - compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output + integrationTestSourceSet.output - runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output + integrationTestSourceSet.output -} - -configurations { - named("businessTestImplementation") { extendsFrom(configurations.named("integrationTestImplementation").get()) } - named("businessTestRuntimeOnly") { extendsFrom(configurations.named("integrationTestRuntimeOnly").get()) } -} +val resilience4jVersion: String by project -tasks.register("businessTest") { - testClassesDirs = businessTestSourceSet.output.classesDirs - classpath = businessTestSourceSet.runtimeClasspath - shouldRunAfter(tasks.named("integrationTest")) - failOnNoDiscoveredTests = false - configure { isEnabled = false } +stablebridge { + jibImageName.set("stablebridge/fiat-off-ramp") + extraJacocoExclusions.set(listOf("**/filter/**", "**/controller/GlobalExceptionHandler*")) } -val lombokVersion: String by project -val mapstructVersion: String by project -val lombokMapstructBindingVersion: String by project -val resilience4jVersion: String by project -val flywayVersion: String by project -val archunitVersion: String by project -val testcontainersVersion: String by project -val wiremockVersion: String by project -val springdocVersion: String by project - dependencies { implementation(project(":fiat-off-ramp:fiat-off-ramp-api")) - // Spring Boot - implementation("org.springframework.boot:spring-boot-starter-web") - implementation("org.springframework.boot:spring-boot-starter-validation") - implementation("org.springframework.boot:spring-boot-starter-data-jpa") - implementation("org.springframework.boot:spring-boot-starter-actuator") - - // OpenAPI / Swagger UI - implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$springdocVersion") - runtimeOnly("io.micrometer:micrometer-registry-prometheus") - implementation("io.micrometer:micrometer-tracing-bridge-otel") - implementation("io.opentelemetry:opentelemetry-exporter-otlp") - implementation("org.springframework.boot:spring-boot-starter-security") - - // Kafka via Spring Cloud Stream - implementation("org.springframework.cloud:spring-cloud-stream") - implementation("org.springframework.cloud:spring-cloud-stream-binder-kafka") - implementation("org.springframework.kafka:spring-kafka") - - // Feign - implementation("org.springframework.cloud:spring-cloud-starter-openfeign") - - // Resilience4j - implementation("io.github.resilience4j:resilience4j-spring-boot3:$resilience4jVersion") - implementation("io.github.resilience4j:resilience4j-circuitbreaker:$resilience4jVersion") + // Resilience4j retry (service-specific) implementation("io.github.resilience4j:resilience4j-retry:$resilience4jVersion") - // MapStruct (compiler args set below in JavaCompile task) - implementation("org.mapstruct:mapstruct:$mapstructVersion") - annotationProcessor("org.mapstruct:mapstruct-processor:$mapstructVersion") - annotationProcessor("org.projectlombok:lombok-mapstruct-binding:$lombokMapstructBindingVersion") - - // Outbox (namastack) - implementation("io.namastack:namastack-outbox-starter-jdbc:1.1.0") - - // Database - runtimeOnly("org.postgresql:postgresql") - implementation("org.springframework.boot:spring-boot-starter-flyway") - implementation("org.flywaydb:flyway-database-postgresql:$flywayVersion") - // Test Fixtures testFixturesImplementation(project(":fiat-off-ramp:fiat-off-ramp-api")) - testFixturesImplementation("org.assertj:assertj-core") - testFixturesImplementation("org.mockito:mockito-core") - - // Test - testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("org.springframework.kafka:spring-kafka-test") - testImplementation("com.tngtech.archunit:archunit-junit5:$archunitVersion") - testImplementation("org.wiremock:wiremock-standalone:$wiremockVersion") - "integrationTestImplementation"(testFixtures(project)) - "integrationTestImplementation"("org.testcontainers:postgresql:$testcontainersVersion") - "integrationTestImplementation"("org.testcontainers:kafka:$testcontainersVersion") - "integrationTestImplementation"("org.testcontainers:junit-jupiter:$testcontainersVersion") - "integrationTestImplementation"("org.wiremock:wiremock-standalone:$wiremockVersion") - "integrationTestImplementation"("org.springframework.boot:spring-boot-starter-webmvc-test") - "integrationTestImplementation"("org.springframework.boot:spring-boot-starter-security-test") -} - -tasks.withType { - options.compilerArgs.addAll(listOf( - "-Amapstruct.defaultComponentModel=spring", - "-Amapstruct.unmappedTargetPolicy=IGNORE" - )) -} - -tasks.withType { - jvmArgs("-Dnet.bytebuddy.experimental=true") - useJUnitPlatform() - testLogging { - events("passed", "skipped", "failed") - showExceptions = true - showCauses = true - showStackTraces = true - exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL - } -} - -jacoco { - toolVersion = "0.8.14" -} - -tasks.test { - configure { - excludes = listOf("sun.*", "jdk.*", "com.sun.*", "java.*", "javax.*") - } - finalizedBy(tasks.jacocoTestReport) -} - -val jacocoExclusions = listOf( - "**/entity/**", - "**/mapper/**", - "**/config/**", - "**/filter/**", - "**/controller/GlobalExceptionHandler*", - "**/*Application*", - "**/generated/**", - "**/*MapperImpl*" -) - -tasks.jacocoTestReport { - dependsOn(tasks.test) - reports { - xml.required.set(true) - html.required.set(true) - } - classDirectories.setFrom(files(classDirectories.files.map { - fileTree(it) { exclude(jacocoExclusions) } - })) -} - -tasks.jacocoTestCoverageVerification { - dependsOn(tasks.jacocoTestReport) - violationRules { - rule { - limit { - minimum = "0.50".toBigDecimal() - } - } - } - classDirectories.setFrom(files(classDirectories.files.map { - fileTree(it) { exclude(jacocoExclusions) } - })) -} -tasks.named("check") { - dependsOn( - tasks.named("integrationTest"), - tasks.named("businessTest"), - tasks.jacocoTestCoverageVerification - ) + // Test — WireMock for adapter unit tests + testImplementation("org.wiremock:wiremock-standalone:${project.property("wiremockVersion")}") } diff --git a/fiat-off-ramp/fiat-off-ramp/src/integration-test/java/com/stablecoin/payments/offramp/AbstractIntegrationTest.java b/fiat-off-ramp/fiat-off-ramp/src/integration-test/java/com/stablecoin/payments/offramp/AbstractIntegrationTest.java index e6ea27ff..1fb8157c 100644 --- a/fiat-off-ramp/fiat-off-ramp/src/integration-test/java/com/stablecoin/payments/offramp/AbstractIntegrationTest.java +++ b/fiat-off-ramp/fiat-off-ramp/src/integration-test/java/com/stablecoin/payments/offramp/AbstractIntegrationTest.java @@ -10,8 +10,12 @@ import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.KafkaContainer; import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.lifecycle.Startable; -import org.testcontainers.utility.DockerImageName; + +import static com.stablecoin.payments.platform.test.TestContainerSupport.kafka; +import static com.stablecoin.payments.platform.test.TestContainerSupport.postgres; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerKafkaProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerPostgresProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.startAll; @SuppressWarnings("resource") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -19,38 +23,11 @@ @AutoConfigureMockMvc public abstract class AbstractIntegrationTest { - static final PostgreSQLContainer POSTGRES = - new PostgreSQLContainer<>("postgres:16-alpine") - .withDatabaseName("s5_fiat_off_ramp") - .withUsername("test") - .withPassword("test"); - - protected static final KafkaContainer KAFKA = - new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0")); + static final PostgreSQLContainer POSTGRES = postgres("s5_fiat_off_ramp"); + protected static final KafkaContainer KAFKA = kafka(); static { - try { - POSTGRES.start(); - KAFKA.start(); - } catch (RuntimeException ex) { - safeStop(KAFKA); - safeStop(POSTGRES); - throw ex; - } - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - safeStop(KAFKA); - safeStop(POSTGRES); - }, "testcontainers-shutdown")); - } - - private static void safeStop(Startable container) { - try { - if (container != null) { - container.stop(); - } - } catch (Exception ignored) { - // best-effort cleanup - } + startAll(POSTGRES, KAFKA); } @Autowired @@ -72,10 +49,7 @@ void cleanDatabase() { @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); - registry.add("spring.datasource.username", POSTGRES::getUsername); - registry.add("spring.datasource.password", POSTGRES::getPassword); - registry.add("spring.kafka.bootstrap-servers", KAFKA::getBootstrapServers); - registry.add("spring.cloud.stream.kafka.binder.brokers", KAFKA::getBootstrapServers); + registerPostgresProperties(registry, POSTGRES); + registerKafkaProperties(registry, KAFKA); } } diff --git a/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/application/controller/GlobalExceptionHandler.java b/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/application/controller/GlobalExceptionHandler.java index 3bf2ccf2..4173eba0 100644 --- a/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/application/controller/GlobalExceptionHandler.java +++ b/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/application/controller/GlobalExceptionHandler.java @@ -1,54 +1,28 @@ package com.stablecoin.payments.offramp.application.controller; -import com.stablecoin.payments.offramp.api.ApiError; import com.stablecoin.payments.offramp.domain.exception.PayoutNotFoundException; import com.stablecoin.payments.offramp.domain.exception.PayoutNotRefundableException; import com.stablecoin.payments.offramp.domain.exception.PayoutPartnerException; import com.stablecoin.payments.offramp.domain.exception.RedemptionFailedException; +import com.stablecoin.payments.platform.api.ApiError; +import com.stablecoin.payments.platform.infrastructure.exception.BaseGlobalExceptionHandler; import lombok.extern.slf4j.Slf4j; -import org.springframework.validation.FieldError; -import org.springframework.validation.ObjectError; -import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; - -import java.util.stream.Collectors; import static org.springframework.http.HttpStatus.BAD_GATEWAY; -import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.CONFLICT; -import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; import static org.springframework.http.HttpStatus.NOT_FOUND; import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY; -/** - * Global exception handler for the Fiat Off-Ramp service. - * Maps domain exceptions to appropriate HTTP responses with error codes. - */ @Slf4j @RestControllerAdvice -public class GlobalExceptionHandler { - - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(MethodArgumentNotValidException.class) - public ApiError handleValidation(MethodArgumentNotValidException ex) { - var errors = ex.getBindingResult().getFieldErrors().stream() - .collect(Collectors.groupingBy( - FieldError::getField, - Collectors.mapping(ObjectError::getDefaultMessage, Collectors.toList()))); - log.info("Validation failed: {}", errors); - return ApiError.withErrors("OF-0001", BAD_REQUEST.getReasonPhrase(), - "Invalid request content", errors); - } +public class GlobalExceptionHandler extends BaseGlobalExceptionHandler { - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(MethodArgumentTypeMismatchException.class) - public ApiError handleTypeMismatch(MethodArgumentTypeMismatchException ex) { - log.info("Type mismatch for parameter '{}': {}", ex.getName(), ex.getMessage()); - return ApiError.of("OF-0001", BAD_REQUEST.getReasonPhrase(), - "Invalid value for parameter '" + ex.getName() + "'"); + @Override + protected String errorCodePrefix() { + return "OF"; } @ResponseStatus(NOT_FOUND) @@ -83,25 +57,11 @@ public ApiError handlePayoutNotRefundable(PayoutNotRefundableException ex) { ex.getMessage()); } + @Override @ResponseStatus(CONFLICT) @ExceptionHandler(IllegalStateException.class) - public ApiError handleIllegalState(IllegalStateException ex) { + public ApiError handleInvalidState(IllegalStateException ex) { log.info("Invalid state transition: {}", ex.getMessage()); return ApiError.of("OF-0002", CONFLICT.getReasonPhrase(), ex.getMessage()); } - - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(IllegalArgumentException.class) - public ApiError handleIllegalArgument(IllegalArgumentException ex) { - log.info("Illegal argument: {}", ex.getMessage()); - return ApiError.of("OF-0001", BAD_REQUEST.getReasonPhrase(), ex.getMessage()); - } - - @ResponseStatus(INTERNAL_SERVER_ERROR) - @ExceptionHandler(Exception.class) - public ApiError handleUnexpected(Exception ex) { - log.error("Unexpected error: ", ex); - return ApiError.of("OF-9999", INTERNAL_SERVER_ERROR.getReasonPhrase(), - INTERNAL_SERVER_ERROR.getReasonPhrase()); - } } diff --git a/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/messaging/OffRampOutboxEventPublisher.java b/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/messaging/OffRampOutboxEventPublisher.java index db72701a..cf908231 100644 --- a/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/messaging/OffRampOutboxEventPublisher.java +++ b/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/messaging/OffRampOutboxEventPublisher.java @@ -1,46 +1,17 @@ package com.stablecoin.payments.offramp.infrastructure.messaging; import com.stablecoin.payments.offramp.domain.port.PayoutEventPublisher; +import com.stablecoin.payments.platform.infrastructure.messaging.AbstractOutboxEventPublisher; import io.namastack.outbox.Outbox; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -/** - * Outbox-based event publisher for the off-ramp service. - * Schedules domain events into the namastack outbox within - * the caller's existing transaction. - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class OffRampOutboxEventPublisher implements PayoutEventPublisher { - - private final Outbox outbox; +import java.util.List; - @Override - @Transactional(propagation = Propagation.MANDATORY) - public void publish(Object event) { - var key = resolveKey(event); - outbox.schedule(event, key); - log.debug("Scheduled outbox event type={} key={}", event.getClass().getSimpleName(), key); - } +@Component +public class OffRampOutboxEventPublisher extends AbstractOutboxEventPublisher + implements PayoutEventPublisher { - private String resolveKey(Object event) { - try { - var method = event.getClass().getMethod("paymentId"); - return String.valueOf(method.invoke(event)); - } catch (Exception e1) { - try { - var method = event.getClass().getMethod("payoutId"); - return String.valueOf(method.invoke(event)); - } catch (Exception e2) { - throw new IllegalArgumentException( - "Event class missing accessor for 'paymentId' or 'payoutId': " - + event.getClass().getName(), e2); - } - } + public OffRampOutboxEventPublisher(Outbox outbox) { + super(outbox, List.of("paymentId", "payoutId")); } } diff --git a/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/messaging/OffRampOutboxHandler.java b/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/messaging/OffRampOutboxHandler.java index 0271d2fe..867d0645 100644 --- a/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/messaging/OffRampOutboxHandler.java +++ b/fiat-off-ramp/fiat-off-ramp/src/main/java/com/stablecoin/payments/offramp/infrastructure/messaging/OffRampOutboxHandler.java @@ -1,46 +1,13 @@ package com.stablecoin.payments.offramp.infrastructure.messaging; -import io.namastack.outbox.annotation.OutboxHandler; -import io.namastack.outbox.handler.OutboxRecordMetadata; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import com.stablecoin.payments.platform.infrastructure.messaging.AbstractOutboxHandler; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; -import java.util.concurrent.TimeUnit; - -/** - * Outbox handler that publishes events from the namastack outbox - * to Kafka topics. Resolves the topic from the event's static TOPIC field. - */ -@Slf4j @Component -@RequiredArgsConstructor -public class OffRampOutboxHandler { - - private final KafkaTemplate kafkaTemplate; - - @OutboxHandler - public void handle(Object event, OutboxRecordMetadata metadata) { - var topic = resolveStaticField(event, "TOPIC"); - var key = metadata.getKey(); - try { - kafkaTemplate.send(topic, key, event).get(10, TimeUnit.SECONDS); - log.debug("Published outbox event type={} topic={} key={}", - event.getClass().getSimpleName(), topic, key); - } catch (Exception e) { - log.error("Failed to publish event type={} topic={}: {}", - event.getClass().getSimpleName(), topic, e.getMessage()); - throw new RuntimeException("Kafka send failed for event " + event.getClass().getSimpleName(), e); - } - } +public class OffRampOutboxHandler extends AbstractOutboxHandler { - private String resolveStaticField(Object event, String fieldName) { - try { - return (String) event.getClass().getField(fieldName).get(null); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new IllegalArgumentException( - "Event class missing static " + fieldName + " field: " + event.getClass().getName(), e); - } + public OffRampOutboxHandler(KafkaTemplate kafkaTemplate) { + super(kafkaTemplate); } } diff --git a/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/arch/ArchitectureTest.java b/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/arch/ArchitectureTest.java index c4236d7f..88db5735 100644 --- a/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/arch/ArchitectureTest.java +++ b/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/arch/ArchitectureTest.java @@ -1,112 +1,15 @@ package com.stablecoin.payments.offramp.arch; -import com.tngtech.archunit.core.domain.JavaClasses; -import com.tngtech.archunit.core.importer.ClassFileImporter; -import com.tngtech.archunit.core.importer.ImportOption; -import org.junit.jupiter.api.BeforeAll; +import com.stablecoin.payments.platform.test.HexagonalArchitectureRules; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; -import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; - @DisplayName("Architecture Rules") class ArchitectureTest { - private static JavaClasses importedClasses; - - @BeforeAll - static void setUp() { - importedClasses = new ClassFileImporter() - .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) - .importPackages("com.stablecoin.payments.offramp"); - } - - @Test - @DisplayName("Domain should not depend on Spring (except stereotype and transaction)") - void domainShouldNotDependOnSpring() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat( - resideInAPackage("org.springframework..") - .and(resideOutsideOfPackage("org.springframework.stereotype..")) - .and(resideOutsideOfPackage("org.springframework.transaction..")) - .and(resideOutsideOfPackage("org.springframework.beans.factory.annotation..")) - ) - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on JPA") - void domainShouldNotDependOnJpa() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("jakarta.persistence..") - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on infrastructure") - void domainShouldNotDependOnInfrastructure() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("..infrastructure..") - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on application layer") - void domainShouldNotDependOnApplication() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("..application..") - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Infrastructure should not depend on application controller") - void infrastructureShouldNotDependOnApplicationController() { - noClasses() - .that().resideInAPackage("..infrastructure..") - .should().dependOnClassesThat().resideInAPackage("..application.controller..") - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Ports should be interfaces") - void portsShouldBeInterfaces() { - classes() - .that().resideInAPackage("..domain.port..") - .and().areNotRecords() - .should().beInterfaces() - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Domain events should be records") - void domainEventsShouldBeRecords() { - classes() - .that().resideInAPackage("..domain.event..") - .should().beRecords() - .allowEmptyShould(true) - .check(importedClasses); - } - @Test - @DisplayName("Controllers should reside in application.controller package") - void controllersShouldResideInApplicationController() { - noClasses() - .that().haveSimpleNameEndingWith("Controller") - .should().resideOutsideOfPackage("..application.controller..") - .allowEmptyShould(true) - .check(importedClasses); + @DisplayName("Verify hexagonal architecture rules") + void verifyHexagonalArchitecture() { + HexagonalArchitectureRules.verifyAll("com.stablecoin.payments.offramp"); } } diff --git a/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/domain/service/PayoutCommandHandlerTest.java b/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/domain/service/PayoutCommandHandlerTest.java index 4d19184b..e052e8ab 100644 --- a/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/domain/service/PayoutCommandHandlerTest.java +++ b/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/domain/service/PayoutCommandHandlerTest.java @@ -43,8 +43,8 @@ import static com.stablecoin.payments.offramp.fixtures.PayoutOrderFixtures.aPartnerIdentifier; import static com.stablecoin.payments.offramp.fixtures.PayoutOrderFixtures.aPendingOrder; import static com.stablecoin.payments.offramp.fixtures.PayoutOrderFixtures.aStablecoinTicker; -import static com.stablecoin.payments.offramp.fixtures.TestUtils.eqIgnoring; -import static com.stablecoin.payments.offramp.fixtures.TestUtils.eqIgnoringTimestamps; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; diff --git a/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/domain/service/PayoutMonitorCommandHandlerTest.java b/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/domain/service/PayoutMonitorCommandHandlerTest.java index ade6eb85..55d0ed72 100644 --- a/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/domain/service/PayoutMonitorCommandHandlerTest.java +++ b/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/domain/service/PayoutMonitorCommandHandlerTest.java @@ -29,7 +29,7 @@ import static com.stablecoin.payments.offramp.fixtures.PayoutOrderFixtures.aCompletedOrder; import static com.stablecoin.payments.offramp.fixtures.PayoutOrderFixtures.aPayoutInitiatedOrder; import static com.stablecoin.payments.offramp.fixtures.PayoutOrderFixtures.aPayoutProcessingOrder; -import static com.stablecoin.payments.offramp.fixtures.TestUtils.eqIgnoringTimestamps; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; diff --git a/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/domain/service/WebhookCommandHandlerTest.java b/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/domain/service/WebhookCommandHandlerTest.java index 4b7ed8ab..6bddf9ba 100644 --- a/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/domain/service/WebhookCommandHandlerTest.java +++ b/fiat-off-ramp/fiat-off-ramp/src/test/java/com/stablecoin/payments/offramp/domain/service/WebhookCommandHandlerTest.java @@ -24,11 +24,11 @@ import static com.stablecoin.payments.offramp.fixtures.PayoutOrderFixtures.aPayoutFailedOrder; import static com.stablecoin.payments.offramp.fixtures.PayoutOrderFixtures.aPayoutInitiatedOrder; import static com.stablecoin.payments.offramp.fixtures.PayoutOrderFixtures.aPayoutProcessingOrder; -import static com.stablecoin.payments.offramp.fixtures.TestUtils.eqIgnoringTimestamps; import static com.stablecoin.payments.offramp.fixtures.WebhookFixtures.SETTLED_AT; import static com.stablecoin.payments.offramp.fixtures.WebhookFixtures.aFailureCommand; import static com.stablecoin.payments.offramp.fixtures.WebhookFixtures.aSettlementCommand; import static com.stablecoin.payments.offramp.fixtures.WebhookFixtures.anUnknownEventCommand; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; diff --git a/fiat-off-ramp/fiat-off-ramp/src/testFixtures/java/com/stablecoin/payments/offramp/fixtures/TestUtils.java b/fiat-off-ramp/fiat-off-ramp/src/testFixtures/java/com/stablecoin/payments/offramp/fixtures/TestUtils.java deleted file mode 100644 index cef2f25a..00000000 --- a/fiat-off-ramp/fiat-off-ramp/src/testFixtures/java/com/stablecoin/payments/offramp/fixtures/TestUtils.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.stablecoin.payments.offramp.fixtures; - -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.ZonedDateTime; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.argThat; - -public final class TestUtils { - - private TestUtils() {} - - public static T eqIgnoringTimestamps(T expected) { - return eqIgnoring(expected); - } - - public static T eqIgnoring(T expected, String... fieldsToIgnore) { - return argThat(it -> isEqualIgnoring(it, expected, fieldsToIgnore)); - } - - private static boolean isEqualIgnoring(T original, T expected, String... fieldsToIgnore) { - try { - assertThat(original) - .usingRecursiveComparison() - .ignoringFieldsOfTypes(ZonedDateTime.class, LocalDateTime.class, LocalDate.class, Instant.class) - .ignoringFields(fieldsToIgnore) - .isEqualTo(expected); - return true; - } catch (Throwable t) { - return false; - } - } -} diff --git a/fiat-on-ramp/fiat-on-ramp-api/build.gradle.kts b/fiat-on-ramp/fiat-on-ramp-api/build.gradle.kts index 20c8280f..97d4baa4 100644 --- a/fiat-on-ramp/fiat-on-ramp-api/build.gradle.kts +++ b/fiat-on-ramp/fiat-on-ramp-api/build.gradle.kts @@ -1,16 +1,3 @@ plugins { - `java-library` -} - -dependencies { - api("jakarta.validation:jakarta.validation-api") - api("com.fasterxml.jackson.core:jackson-annotations") - - testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation("org.assertj:assertj-core") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { - useJUnitPlatform() + id("stablebridge.api-library") } diff --git a/fiat-on-ramp/fiat-on-ramp-client/build.gradle.kts b/fiat-on-ramp/fiat-on-ramp-client/build.gradle.kts index e4984147..2bfca093 100644 --- a/fiat-on-ramp/fiat-on-ramp-client/build.gradle.kts +++ b/fiat-on-ramp/fiat-on-ramp-client/build.gradle.kts @@ -1,16 +1,7 @@ plugins { - `java-library` + id("stablebridge.client-library") } dependencies { api(project(":fiat-on-ramp:fiat-on-ramp-api")) - implementation("org.springframework.cloud:spring-cloud-starter-openfeign") - - testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation("org.assertj:assertj-core") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { - useJUnitPlatform() } diff --git a/fiat-on-ramp/fiat-on-ramp/build.gradle.kts b/fiat-on-ramp/fiat-on-ramp/build.gradle.kts index 511b0d32..5e882bcd 100644 --- a/fiat-on-ramp/fiat-on-ramp/build.gradle.kts +++ b/fiat-on-ramp/fiat-on-ramp/build.gradle.kts @@ -1,204 +1,22 @@ plugins { - id("org.springframework.boot") - id("com.google.cloud.tools.jib") - java - `java-test-fixtures` - jacoco + id("stablebridge.service") } -jib { - from { - image = "docker://eclipse-temurin:25-jre" - } - to { - image = "stablebridge/fiat-on-ramp" - tags = setOf("latest") - } - container { - creationTime.set("USE_CURRENT_TIMESTAMP") - } -} - -val integrationTestSourceSet: SourceSet = sourceSets.create("integrationTest") { - java.srcDir("src/integration-test/java") - resources.srcDir("src/integration-test/resources") - compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output - runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output -} - -configurations { - named("integrationTestImplementation") { extendsFrom(configurations.testImplementation.get()) } - named("integrationTestRuntimeOnly") { extendsFrom(configurations.testRuntimeOnly.get()) } -} - -tasks.register("integrationTest") { - testClassesDirs = integrationTestSourceSet.output.classesDirs - classpath = integrationTestSourceSet.runtimeClasspath - shouldRunAfter(tasks.test) - configure { isEnabled = false } - failOnNoDiscoveredTests = false - exclude("**/Abstract*", "**/config/**") -} - -val businessTestSourceSet: SourceSet = sourceSets.create("businessTest") { - java.srcDir("src/business-test/java") - resources.srcDir("src/business-test/resources") - compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output + integrationTestSourceSet.output - runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output + integrationTestSourceSet.output -} - -configurations { - named("businessTestImplementation") { extendsFrom(configurations.named("integrationTestImplementation").get()) } - named("businessTestRuntimeOnly") { extendsFrom(configurations.named("integrationTestRuntimeOnly").get()) } -} +val resilience4jVersion: String by project -tasks.register("businessTest") { - testClassesDirs = businessTestSourceSet.output.classesDirs - classpath = businessTestSourceSet.runtimeClasspath - shouldRunAfter(tasks.named("integrationTest")) - failOnNoDiscoveredTests = false - configure { isEnabled = false } +stablebridge { + jibImageName.set("stablebridge/fiat-on-ramp") } -val lombokVersion: String by project -val mapstructVersion: String by project -val lombokMapstructBindingVersion: String by project -val resilience4jVersion: String by project -val flywayVersion: String by project -val archunitVersion: String by project -val testcontainersVersion: String by project -val wiremockVersion: String by project -val springdocVersion: String by project - dependencies { implementation(project(":fiat-on-ramp:fiat-on-ramp-api")) - // Spring Boot - implementation("org.springframework.boot:spring-boot-starter-web") - implementation("org.springframework.boot:spring-boot-starter-validation") - implementation("org.springframework.boot:spring-boot-starter-data-jpa") - implementation("org.springframework.boot:spring-boot-starter-actuator") - - // OpenAPI / Swagger UI - implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$springdocVersion") - runtimeOnly("io.micrometer:micrometer-registry-prometheus") - implementation("io.micrometer:micrometer-tracing-bridge-otel") - implementation("io.opentelemetry:opentelemetry-exporter-otlp") - implementation("org.springframework.boot:spring-boot-starter-security") - - // Kafka via Spring Cloud Stream - implementation("org.springframework.cloud:spring-cloud-stream") - implementation("org.springframework.cloud:spring-cloud-stream-binder-kafka") - implementation("org.springframework.kafka:spring-kafka") - - // Feign - implementation("org.springframework.cloud:spring-cloud-starter-openfeign") - - // Resilience4j - implementation("io.github.resilience4j:resilience4j-spring-boot3:$resilience4jVersion") - implementation("io.github.resilience4j:resilience4j-circuitbreaker:$resilience4jVersion") + // Resilience4j retry (service-specific) implementation("io.github.resilience4j:resilience4j-retry:$resilience4jVersion") - // MapStruct (compiler args set below in JavaCompile task) - implementation("org.mapstruct:mapstruct:$mapstructVersion") - annotationProcessor("org.mapstruct:mapstruct-processor:$mapstructVersion") - annotationProcessor("org.projectlombok:lombok-mapstruct-binding:$lombokMapstructBindingVersion") - - // Outbox (namastack) - implementation("io.namastack:namastack-outbox-starter-jdbc:1.1.0") - - // Database - runtimeOnly("org.postgresql:postgresql") - implementation("org.springframework.boot:spring-boot-starter-flyway") - implementation("org.flywaydb:flyway-database-postgresql:$flywayVersion") - // Test Fixtures testFixturesImplementation(project(":fiat-on-ramp:fiat-on-ramp-api")) - testFixturesImplementation("org.assertj:assertj-core") - testFixturesImplementation("org.mockito:mockito-core") - - // Test - testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("org.springframework.kafka:spring-kafka-test") - testImplementation("com.tngtech.archunit:archunit-junit5:$archunitVersion") - testImplementation("org.wiremock:wiremock-standalone:$wiremockVersion") - "integrationTestImplementation"(testFixtures(project)) - "integrationTestImplementation"("org.testcontainers:postgresql:$testcontainersVersion") - "integrationTestImplementation"("org.testcontainers:kafka:$testcontainersVersion") - "integrationTestImplementation"("org.testcontainers:junit-jupiter:$testcontainersVersion") - "integrationTestImplementation"("org.wiremock:wiremock-standalone:$wiremockVersion") - "integrationTestImplementation"("org.springframework.boot:spring-boot-starter-webmvc-test") - "integrationTestImplementation"("org.springframework.boot:spring-boot-starter-security-test") -} - -tasks.withType { - options.compilerArgs.addAll(listOf( - "-Amapstruct.defaultComponentModel=spring", - "-Amapstruct.unmappedTargetPolicy=IGNORE" - )) -} - -tasks.withType { - jvmArgs("-Dnet.bytebuddy.experimental=true") - useJUnitPlatform() - testLogging { - events("passed", "skipped", "failed") - showExceptions = true - showCauses = true - showStackTraces = true - exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL - } -} - -jacoco { - toolVersion = "0.8.14" -} - -tasks.test { - configure { - excludes = listOf("sun.*", "jdk.*", "com.sun.*", "java.*", "javax.*") - } - finalizedBy(tasks.jacocoTestReport) -} - -val jacocoExclusions = listOf( - "**/entity/**", - "**/mapper/**", - "**/config/**", - "**/*Application*", - "**/generated/**", - "**/*MapperImpl*" -) - -tasks.jacocoTestReport { - dependsOn(tasks.test) - reports { - xml.required.set(true) - html.required.set(true) - } - classDirectories.setFrom(files(classDirectories.files.map { - fileTree(it) { exclude(jacocoExclusions) } - })) -} - -tasks.jacocoTestCoverageVerification { - dependsOn(tasks.jacocoTestReport) - violationRules { - rule { - limit { - minimum = "0.50".toBigDecimal() - } - } - } - classDirectories.setFrom(files(classDirectories.files.map { - fileTree(it) { exclude(jacocoExclusions) } - })) -} -tasks.named("check") { - dependsOn( - tasks.named("integrationTest"), - tasks.named("businessTest"), - tasks.jacocoTestCoverageVerification - ) + // Test — WireMock for adapter unit tests + testImplementation("org.wiremock:wiremock-standalone:${project.property("wiremockVersion")}") } diff --git a/fiat-on-ramp/fiat-on-ramp/src/integration-test/java/com/stablecoin/payments/onramp/AbstractIntegrationTest.java b/fiat-on-ramp/fiat-on-ramp/src/integration-test/java/com/stablecoin/payments/onramp/AbstractIntegrationTest.java index 42dd772e..3b4e6a51 100644 --- a/fiat-on-ramp/fiat-on-ramp/src/integration-test/java/com/stablecoin/payments/onramp/AbstractIntegrationTest.java +++ b/fiat-on-ramp/fiat-on-ramp/src/integration-test/java/com/stablecoin/payments/onramp/AbstractIntegrationTest.java @@ -10,8 +10,12 @@ import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.KafkaContainer; import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.lifecycle.Startable; -import org.testcontainers.utility.DockerImageName; + +import static com.stablecoin.payments.platform.test.TestContainerSupport.kafka; +import static com.stablecoin.payments.platform.test.TestContainerSupport.postgres; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerKafkaProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerPostgresProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.startAll; @SuppressWarnings("resource") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -19,38 +23,11 @@ @AutoConfigureMockMvc public abstract class AbstractIntegrationTest { - static final PostgreSQLContainer POSTGRES = - new PostgreSQLContainer<>("postgres:16-alpine") - .withDatabaseName("s3_fiat_on_ramp") - .withUsername("test") - .withPassword("test"); - - protected static final KafkaContainer KAFKA = - new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0")); + static final PostgreSQLContainer POSTGRES = postgres("s3_fiat_on_ramp"); + protected static final KafkaContainer KAFKA = kafka(); static { - try { - POSTGRES.start(); - KAFKA.start(); - } catch (RuntimeException ex) { - safeStop(KAFKA); - safeStop(POSTGRES); - throw ex; - } - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - safeStop(KAFKA); - safeStop(POSTGRES); - }, "testcontainers-shutdown")); - } - - private static void safeStop(Startable container) { - try { - if (container != null) { - container.stop(); - } - } catch (Exception ignored) { - // best-effort cleanup - } + startAll(POSTGRES, KAFKA); } @Autowired @@ -71,10 +48,7 @@ void cleanDatabase() { @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); - registry.add("spring.datasource.username", POSTGRES::getUsername); - registry.add("spring.datasource.password", POSTGRES::getPassword); - registry.add("spring.kafka.bootstrap-servers", KAFKA::getBootstrapServers); - registry.add("spring.cloud.stream.kafka.binder.brokers", KAFKA::getBootstrapServers); + registerPostgresProperties(registry, POSTGRES); + registerKafkaProperties(registry, KAFKA); } } diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/application/controller/GlobalExceptionHandler.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/application/controller/GlobalExceptionHandler.java index cc1f6857..64795e1a 100644 --- a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/application/controller/GlobalExceptionHandler.java +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/application/controller/GlobalExceptionHandler.java @@ -1,54 +1,27 @@ package com.stablecoin.payments.onramp.application.controller; -import com.stablecoin.payments.onramp.api.ApiError; import com.stablecoin.payments.onramp.domain.exception.CollectionOrderNotFoundException; import com.stablecoin.payments.onramp.domain.exception.RefundAmountExceededException; import com.stablecoin.payments.onramp.domain.exception.RefundNotAllowedException; import com.stablecoin.payments.onramp.domain.exception.RefundNotFoundException; +import com.stablecoin.payments.platform.api.ApiError; +import com.stablecoin.payments.platform.infrastructure.exception.BaseGlobalExceptionHandler; import lombok.extern.slf4j.Slf4j; -import org.springframework.validation.FieldError; -import org.springframework.validation.ObjectError; -import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; -import java.util.stream.Collectors; - -import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.CONFLICT; -import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; import static org.springframework.http.HttpStatus.NOT_FOUND; import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY; -/** - * Global exception handler for the Fiat On-Ramp service. - *

- * Maps domain exceptions to appropriate HTTP responses with error codes. - */ @Slf4j @RestControllerAdvice -public class GlobalExceptionHandler { - - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(MethodArgumentNotValidException.class) - public ApiError handleValidation(MethodArgumentNotValidException ex) { - var errors = ex.getBindingResult().getFieldErrors().stream() - .collect(Collectors.groupingBy( - FieldError::getField, - Collectors.mapping(ObjectError::getDefaultMessage, Collectors.toList()))); - log.info("Validation failed: {}", errors); - return ApiError.withErrors("OR-0001", BAD_REQUEST.getReasonPhrase(), - "Invalid request content", errors); - } +public class GlobalExceptionHandler extends BaseGlobalExceptionHandler { - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(MethodArgumentTypeMismatchException.class) - public ApiError handleTypeMismatch(MethodArgumentTypeMismatchException ex) { - log.info("Type mismatch for parameter '{}': {}", ex.getName(), ex.getMessage()); - return ApiError.of("OR-0001", BAD_REQUEST.getReasonPhrase(), - "Invalid value for parameter '" + ex.getName() + "'"); + @Override + protected String errorCodePrefix() { + return "OR"; } @ResponseStatus(NOT_FOUND) @@ -83,25 +56,11 @@ public ApiError handleRefundAmountExceeded(RefundAmountExceededException ex) { ex.getMessage()); } + @Override @ResponseStatus(CONFLICT) @ExceptionHandler(IllegalStateException.class) - public ApiError handleIllegalState(IllegalStateException ex) { + public ApiError handleInvalidState(IllegalStateException ex) { log.info("Invalid state transition: {}", ex.getMessage()); return ApiError.of("OR-0002", CONFLICT.getReasonPhrase(), ex.getMessage()); } - - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(IllegalArgumentException.class) - public ApiError handleIllegalArgument(IllegalArgumentException ex) { - log.info("Illegal argument: {}", ex.getMessage()); - return ApiError.of("OR-0001", BAD_REQUEST.getReasonPhrase(), ex.getMessage()); - } - - @ResponseStatus(INTERNAL_SERVER_ERROR) - @ExceptionHandler(Exception.class) - public ApiError handleUnexpected(Exception ex) { - log.error("Unexpected error: ", ex); - return ApiError.of("OR-9999", INTERNAL_SERVER_ERROR.getReasonPhrase(), - INTERNAL_SERVER_ERROR.getReasonPhrase()); - } } diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/infrastructure/messaging/OnRampOutboxEventPublisher.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/infrastructure/messaging/OnRampOutboxEventPublisher.java index 2df034e9..3e88937d 100644 --- a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/infrastructure/messaging/OnRampOutboxEventPublisher.java +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/infrastructure/messaging/OnRampOutboxEventPublisher.java @@ -1,46 +1,17 @@ package com.stablecoin.payments.onramp.infrastructure.messaging; import com.stablecoin.payments.onramp.domain.port.CollectionEventPublisher; +import com.stablecoin.payments.platform.infrastructure.messaging.AbstractOutboxEventPublisher; import io.namastack.outbox.Outbox; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -/** - * Outbox-based event publisher for the on-ramp service. - * Schedules domain events into the namastack outbox within - * the caller's existing transaction. - */ -@Slf4j -@Component -@RequiredArgsConstructor -public class OnRampOutboxEventPublisher implements CollectionEventPublisher { - - private final Outbox outbox; +import java.util.List; - @Override - @Transactional(propagation = Propagation.MANDATORY) - public void publish(Object event) { - var key = resolveKey(event); - outbox.schedule(event, key); - log.debug("Scheduled outbox event type={} key={}", event.getClass().getSimpleName(), key); - } +@Component +public class OnRampOutboxEventPublisher extends AbstractOutboxEventPublisher + implements CollectionEventPublisher { - private String resolveKey(Object event) { - try { - var method = event.getClass().getMethod("paymentId"); - return String.valueOf(method.invoke(event)); - } catch (Exception e1) { - try { - var method = event.getClass().getMethod("collectionId"); - return String.valueOf(method.invoke(event)); - } catch (Exception e2) { - throw new IllegalArgumentException( - "Event class missing accessor for 'paymentId' or 'collectionId': " - + event.getClass().getName(), e2); - } - } + public OnRampOutboxEventPublisher(Outbox outbox) { + super(outbox, List.of("paymentId", "collectionId")); } } diff --git a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/infrastructure/messaging/OnRampOutboxHandler.java b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/infrastructure/messaging/OnRampOutboxHandler.java index 67212f62..edd001de 100644 --- a/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/infrastructure/messaging/OnRampOutboxHandler.java +++ b/fiat-on-ramp/fiat-on-ramp/src/main/java/com/stablecoin/payments/onramp/infrastructure/messaging/OnRampOutboxHandler.java @@ -1,46 +1,13 @@ package com.stablecoin.payments.onramp.infrastructure.messaging; -import io.namastack.outbox.annotation.OutboxHandler; -import io.namastack.outbox.handler.OutboxRecordMetadata; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import com.stablecoin.payments.platform.infrastructure.messaging.AbstractOutboxHandler; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; -import java.util.concurrent.TimeUnit; - -/** - * Outbox handler that publishes events from the namastack outbox - * to Kafka topics. Resolves the topic from the event's static TOPIC field. - */ -@Slf4j @Component -@RequiredArgsConstructor -public class OnRampOutboxHandler { - - private final KafkaTemplate kafkaTemplate; - - @OutboxHandler - public void handle(Object event, OutboxRecordMetadata metadata) { - var topic = resolveStaticField(event, "TOPIC"); - var key = metadata.getKey(); - try { - kafkaTemplate.send(topic, key, event).get(10, TimeUnit.SECONDS); - log.debug("Published outbox event type={} topic={} key={}", - event.getClass().getSimpleName(), topic, key); - } catch (Exception e) { - log.error("Failed to publish event type={} topic={}: {}", - event.getClass().getSimpleName(), topic, e.getMessage()); - throw new RuntimeException("Kafka send failed for event " + event.getClass().getSimpleName(), e); - } - } +public class OnRampOutboxHandler extends AbstractOutboxHandler { - private String resolveStaticField(Object event, String fieldName) { - try { - return (String) event.getClass().getField(fieldName).get(null); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new IllegalArgumentException( - "Event class missing static " + fieldName + " field: " + event.getClass().getName(), e); - } + public OnRampOutboxHandler(KafkaTemplate kafkaTemplate) { + super(kafkaTemplate); } } diff --git a/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/application/controller/StripeWebhookControllerTest.java b/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/application/controller/StripeWebhookControllerTest.java index 2ff45c8a..42876c23 100644 --- a/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/application/controller/StripeWebhookControllerTest.java +++ b/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/application/controller/StripeWebhookControllerTest.java @@ -18,8 +18,8 @@ import static com.stablecoin.payments.onramp.fixtures.CollectionOrderFixtures.PSP_REFERENCE; import static com.stablecoin.payments.onramp.fixtures.CollectionOrderFixtures.anAwaitingConfirmationOrder; -import static com.stablecoin.payments.onramp.fixtures.TestUtils.eqIgnoringTimestamps; import static com.stablecoin.payments.onramp.fixtures.WebhookFixtures.aSucceededEventJson; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; diff --git a/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/arch/ArchitectureTest.java b/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/arch/ArchitectureTest.java index c2054465..a77c20e8 100644 --- a/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/arch/ArchitectureTest.java +++ b/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/arch/ArchitectureTest.java @@ -1,112 +1,15 @@ package com.stablecoin.payments.onramp.arch; -import com.tngtech.archunit.core.domain.JavaClasses; -import com.tngtech.archunit.core.importer.ClassFileImporter; -import com.tngtech.archunit.core.importer.ImportOption; -import org.junit.jupiter.api.BeforeAll; +import com.stablecoin.payments.platform.test.HexagonalArchitectureRules; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; -import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; - @DisplayName("Architecture Rules") class ArchitectureTest { - private static JavaClasses importedClasses; - - @BeforeAll - static void setUp() { - importedClasses = new ClassFileImporter() - .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) - .importPackages("com.stablecoin.payments.onramp"); - } - - @Test - @DisplayName("Domain should not depend on Spring (except stereotype and transaction)") - void domainShouldNotDependOnSpring() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat( - resideInAPackage("org.springframework..") - .and(resideOutsideOfPackage("org.springframework.stereotype..")) - .and(resideOutsideOfPackage("org.springframework.transaction..")) - .and(resideOutsideOfPackage("org.springframework.beans.factory.annotation..")) - ) - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on JPA") - void domainShouldNotDependOnJpa() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("jakarta.persistence..") - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on infrastructure") - void domainShouldNotDependOnInfrastructure() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("..infrastructure..") - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on application layer") - void domainShouldNotDependOnApplication() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("..application..") - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Infrastructure should not depend on application controller") - void infrastructureShouldNotDependOnApplicationController() { - noClasses() - .that().resideInAPackage("..infrastructure..") - .should().dependOnClassesThat().resideInAPackage("..application.controller..") - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Ports should be interfaces") - void portsShouldBeInterfaces() { - classes() - .that().resideInAPackage("..domain.port..") - .and().areNotRecords() - .should().beInterfaces() - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Domain events should be records") - void domainEventsShouldBeRecords() { - classes() - .that().resideInAPackage("..domain.event..") - .should().beRecords() - .allowEmptyShould(true) - .check(importedClasses); - } - @Test - @DisplayName("Controllers should reside in application.controller package") - void controllersShouldResideInApplicationController() { - noClasses() - .that().haveSimpleNameEndingWith("Controller") - .should().resideOutsideOfPackage("..application.controller..") - .allowEmptyShould(true) - .check(importedClasses); + @DisplayName("Verify hexagonal architecture rules") + void verifyHexagonalArchitecture() { + HexagonalArchitectureRules.verifyAll("com.stablecoin.payments.onramp"); } } diff --git a/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/domain/service/CollectionCommandHandlerTest.java b/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/domain/service/CollectionCommandHandlerTest.java index df6239f8..81e900bf 100644 --- a/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/domain/service/CollectionCommandHandlerTest.java +++ b/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/domain/service/CollectionCommandHandlerTest.java @@ -32,8 +32,8 @@ import static com.stablecoin.payments.onramp.fixtures.CollectionOrderFixtures.aPaymentRail; import static com.stablecoin.payments.onramp.fixtures.CollectionOrderFixtures.aPendingOrder; import static com.stablecoin.payments.onramp.fixtures.CollectionOrderFixtures.aPspIdentifier; -import static com.stablecoin.payments.onramp.fixtures.TestUtils.eqIgnoring; -import static com.stablecoin.payments.onramp.fixtures.TestUtils.eqIgnoringTimestamps; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; diff --git a/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/domain/service/ReconciliationCommandHandlerTest.java b/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/domain/service/ReconciliationCommandHandlerTest.java index e2c30773..79a0c2bd 100644 --- a/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/domain/service/ReconciliationCommandHandlerTest.java +++ b/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/domain/service/ReconciliationCommandHandlerTest.java @@ -19,8 +19,8 @@ import static com.stablecoin.payments.onramp.fixtures.ReconciliationFixtures.aCollectedOrderWithDifferentAmount; import static com.stablecoin.payments.onramp.fixtures.ReconciliationFixtures.aCollectedOrderWithinTolerance; import static com.stablecoin.payments.onramp.fixtures.ReconciliationFixtures.aMatchedReconciliation; -import static com.stablecoin.payments.onramp.fixtures.TestUtils.eqIgnoring; -import static com.stablecoin.payments.onramp.fixtures.TestUtils.eqIgnoringTimestamps; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.never; diff --git a/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/domain/service/RefundCommandHandlerTest.java b/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/domain/service/RefundCommandHandlerTest.java index 30f52ccf..4b95799f 100644 --- a/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/domain/service/RefundCommandHandlerTest.java +++ b/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/domain/service/RefundCommandHandlerTest.java @@ -34,8 +34,8 @@ import static com.stablecoin.payments.onramp.fixtures.RefundFixtures.REFUND_REASON; import static com.stablecoin.payments.onramp.fixtures.RefundFixtures.aCompletedRefund; import static com.stablecoin.payments.onramp.fixtures.RefundFixtures.aRefundAmount; -import static com.stablecoin.payments.onramp.fixtures.TestUtils.eqIgnoring; -import static com.stablecoin.payments.onramp.fixtures.TestUtils.eqIgnoringTimestamps; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; diff --git a/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/domain/service/WebhookCommandHandlerTest.java b/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/domain/service/WebhookCommandHandlerTest.java index 28a5b20f..29e6db95 100644 --- a/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/domain/service/WebhookCommandHandlerTest.java +++ b/fiat-on-ramp/fiat-on-ramp/src/test/java/com/stablecoin/payments/onramp/domain/service/WebhookCommandHandlerTest.java @@ -23,12 +23,12 @@ import static com.stablecoin.payments.onramp.fixtures.CollectionOrderFixtures.aCollectionFailedOrder; import static com.stablecoin.payments.onramp.fixtures.CollectionOrderFixtures.aPaymentInitiatedOrder; import static com.stablecoin.payments.onramp.fixtures.CollectionOrderFixtures.anAwaitingConfirmationOrder; -import static com.stablecoin.payments.onramp.fixtures.TestUtils.eqIgnoring; -import static com.stablecoin.payments.onramp.fixtures.TestUtils.eqIgnoringTimestamps; import static com.stablecoin.payments.onramp.fixtures.WebhookFixtures.aChargeSucceededCommand; import static com.stablecoin.payments.onramp.fixtures.WebhookFixtures.aFailedCommand; import static com.stablecoin.payments.onramp.fixtures.WebhookFixtures.aMismatchCommand; import static com.stablecoin.payments.onramp.fixtures.WebhookFixtures.aSucceededCommand; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; diff --git a/fiat-on-ramp/fiat-on-ramp/src/testFixtures/java/com/stablecoin/payments/onramp/fixtures/TestUtils.java b/fiat-on-ramp/fiat-on-ramp/src/testFixtures/java/com/stablecoin/payments/onramp/fixtures/TestUtils.java deleted file mode 100644 index a9052062..00000000 --- a/fiat-on-ramp/fiat-on-ramp/src/testFixtures/java/com/stablecoin/payments/onramp/fixtures/TestUtils.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.stablecoin.payments.onramp.fixtures; - -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.ZonedDateTime; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.argThat; - -public final class TestUtils { - - private TestUtils() {} - - public static T eqIgnoringTimestamps(T expected) { - return eqIgnoring(expected); - } - - public static T eqIgnoring(T expected, String... fieldsToIgnore) { - return argThat(it -> isEqualIgnoring(it, expected, fieldsToIgnore)); - } - - private static boolean isEqualIgnoring(T original, T expected, String... fieldsToIgnore) { - try { - assertThat(original) - .usingRecursiveComparison() - .ignoringFieldsOfTypes(ZonedDateTime.class, LocalDateTime.class, LocalDate.class, Instant.class) - .ignoringFields(fieldsToIgnore) - .isEqualTo(expected); - return true; - } catch (Throwable t) { - return false; - } - } -} diff --git a/fx-liquidity-engine/fx-liquidity-engine-api/build.gradle.kts b/fx-liquidity-engine/fx-liquidity-engine-api/build.gradle.kts index 20c8280f..97d4baa4 100644 --- a/fx-liquidity-engine/fx-liquidity-engine-api/build.gradle.kts +++ b/fx-liquidity-engine/fx-liquidity-engine-api/build.gradle.kts @@ -1,16 +1,3 @@ plugins { - `java-library` -} - -dependencies { - api("jakarta.validation:jakarta.validation-api") - api("com.fasterxml.jackson.core:jackson-annotations") - - testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation("org.assertj:assertj-core") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { - useJUnitPlatform() + id("stablebridge.api-library") } diff --git a/fx-liquidity-engine/fx-liquidity-engine-api/src/main/java/com/stablecoin/payments/fx/api/response/ApiError.java b/fx-liquidity-engine/fx-liquidity-engine-api/src/main/java/com/stablecoin/payments/fx/api/response/ApiError.java deleted file mode 100644 index b5e0186c..00000000 --- a/fx-liquidity-engine/fx-liquidity-engine-api/src/main/java/com/stablecoin/payments/fx/api/response/ApiError.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.stablecoin.payments.fx.api.response; - -import java.util.List; -import java.util.Map; - -public record ApiError( - String code, - String status, - String message, - Map> errors -) { - public static ApiError of(String code, String status, String message) { - return new ApiError(code, status, message, Map.of()); - } - - public static ApiError withErrors(String code, String status, String message, - Map> errors) { - return new ApiError(code, status, message, errors); - } -} diff --git a/fx-liquidity-engine/fx-liquidity-engine-client/build.gradle.kts b/fx-liquidity-engine/fx-liquidity-engine-client/build.gradle.kts index 6ab22252..b6582ff5 100644 --- a/fx-liquidity-engine/fx-liquidity-engine-client/build.gradle.kts +++ b/fx-liquidity-engine/fx-liquidity-engine-client/build.gradle.kts @@ -1,16 +1,7 @@ plugins { - `java-library` + id("stablebridge.client-library") } dependencies { api(project(":fx-liquidity-engine:fx-liquidity-engine-api")) - implementation("org.springframework.cloud:spring-cloud-starter-openfeign") - - testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation("org.assertj:assertj-core") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { - useJUnitPlatform() } diff --git a/fx-liquidity-engine/fx-liquidity-engine/build.gradle.kts b/fx-liquidity-engine/fx-liquidity-engine/build.gradle.kts index 86ae72a7..adb4817d 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/build.gradle.kts +++ b/fx-liquidity-engine/fx-liquidity-engine/build.gradle.kts @@ -1,208 +1,26 @@ plugins { - id("org.springframework.boot") - id("com.google.cloud.tools.jib") - java - `java-test-fixtures` - jacoco + id("stablebridge.service") } -jib { - from { - image = "docker://eclipse-temurin:25-jre" - } - to { - image = "stablebridge/fx-liquidity-engine" - tags = setOf("latest") - } - container { - creationTime.set("USE_CURRENT_TIMESTAMP") - } -} - -val integrationTestSourceSet: SourceSet = sourceSets.create("integrationTest") { - java.srcDir("src/integration-test/java") - resources.srcDir("src/integration-test/resources") - compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output - runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output -} - -configurations { - named("integrationTestImplementation") { extendsFrom(configurations.testImplementation.get()) } - named("integrationTestRuntimeOnly") { extendsFrom(configurations.testRuntimeOnly.get()) } -} - -tasks.register("integrationTest") { - testClassesDirs = integrationTestSourceSet.output.classesDirs - classpath = integrationTestSourceSet.runtimeClasspath - shouldRunAfter(tasks.test) - configure { isEnabled = false } - failOnNoDiscoveredTests = false - exclude("**/Abstract*", "**/config/**") -} - -val businessTestSourceSet: SourceSet = sourceSets.create("businessTest") { - java.srcDir("src/business-test/java") - resources.srcDir("src/business-test/resources") - compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output + integrationTestSourceSet.output - runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output + integrationTestSourceSet.output -} - -configurations { - named("businessTestImplementation") { extendsFrom(configurations.named("integrationTestImplementation").get()) } - named("businessTestRuntimeOnly") { extendsFrom(configurations.named("integrationTestRuntimeOnly").get()) } -} +val resilience4jVersion: String by project -tasks.register("businessTest") { - testClassesDirs = businessTestSourceSet.output.classesDirs - classpath = businessTestSourceSet.runtimeClasspath - shouldRunAfter(tasks.named("integrationTest")) - failOnNoDiscoveredTests = false - configure { isEnabled = false } - failOnNoDiscoveredTests = false +stablebridge { + jibImageName.set("stablebridge/fx-liquidity-engine") + jacocoMinimum.set("0.45") } -val lombokVersion: String by project -val mapstructVersion: String by project -val lombokMapstructBindingVersion: String by project -val resilience4jVersion: String by project -val flywayVersion: String by project -val archunitVersion: String by project -val testcontainersVersion: String by project -val wiremockVersion: String by project -val springdocVersion: String by project - dependencies { implementation(project(":fx-liquidity-engine:fx-liquidity-engine-api")) - // Spring Boot - implementation("org.springframework.boot:spring-boot-starter-web") - implementation("org.springframework.boot:spring-boot-starter-validation") - implementation("org.springframework.boot:spring-boot-starter-data-jpa") - implementation("org.springframework.boot:spring-boot-starter-actuator") - - // OpenAPI / Swagger UI - implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$springdocVersion") - runtimeOnly("io.micrometer:micrometer-registry-prometheus") - implementation("io.micrometer:micrometer-tracing-bridge-otel") - implementation("io.opentelemetry:opentelemetry-exporter-otlp") - implementation("org.springframework.boot:spring-boot-starter-security") - // Redis — rate cache implementation("org.springframework.boot:spring-boot-starter-data-redis") - // Kafka via Spring Cloud Stream - implementation("org.springframework.cloud:spring-cloud-stream") - implementation("org.springframework.cloud:spring-cloud-stream-binder-kafka") - implementation("org.springframework.kafka:spring-kafka") - - // Feign - implementation("org.springframework.cloud:spring-cloud-starter-openfeign") - - // Resilience4j - implementation("io.github.resilience4j:resilience4j-spring-boot3:$resilience4jVersion") - implementation("io.github.resilience4j:resilience4j-circuitbreaker:$resilience4jVersion") + // Resilience4j retry (service-specific) implementation("io.github.resilience4j:resilience4j-retry:$resilience4jVersion") - // MapStruct - implementation("org.mapstruct:mapstruct:$mapstructVersion") - annotationProcessor("org.mapstruct:mapstruct-processor:$mapstructVersion") - annotationProcessor("org.projectlombok:lombok-mapstruct-binding:$lombokMapstructBindingVersion") - - // Database — TimescaleDB (PostgreSQL extension) - runtimeOnly("org.postgresql:postgresql") - implementation("org.springframework.boot:spring-boot-starter-flyway") - implementation("org.flywaydb:flyway-database-postgresql:$flywayVersion") - - // Outbox (namastack) - implementation("io.namastack:namastack-outbox-starter-jdbc:1.1.0") - - // Test fixtures - testFixturesImplementation("org.assertj:assertj-core") - testFixturesImplementation("org.mockito:mockito-core") + // Test Fixtures — JPA for test helpers testFixturesImplementation("org.springframework.boot:spring-boot-starter-data-jpa") - // Test - testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("org.springframework.kafka:spring-kafka-test") - testImplementation("com.tngtech.archunit:archunit-junit5:$archunitVersion") - testImplementation("org.wiremock:wiremock-standalone:$wiremockVersion") - "integrationTestImplementation"(testFixtures(project)) - "integrationTestImplementation"("org.testcontainers:postgresql:$testcontainersVersion") - "integrationTestImplementation"("org.testcontainers:kafka:$testcontainersVersion") - "integrationTestImplementation"("org.testcontainers:junit-jupiter:$testcontainersVersion") - "integrationTestImplementation"("org.wiremock:wiremock-standalone:$wiremockVersion") - "integrationTestImplementation"("org.springframework.boot:spring-boot-starter-webmvc-test") - "integrationTestImplementation"("org.springframework.boot:spring-boot-starter-security-test") -} - -tasks.withType { - options.compilerArgs.addAll(listOf( - "-Amapstruct.defaultComponentModel=spring", - "-Amapstruct.unmappedTargetPolicy=IGNORE" - )) -} - -tasks.withType { - jvmArgs("-Dnet.bytebuddy.experimental=true") - useJUnitPlatform() - testLogging { - events("passed", "skipped", "failed") - showExceptions = true - showCauses = true - showStackTraces = true - exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL - } -} - -jacoco { - toolVersion = "0.8.14" -} - -tasks.test { - configure { - excludes = listOf("sun.*", "jdk.*", "com.sun.*", "java.*", "javax.*") - } - finalizedBy(tasks.jacocoTestReport) -} - -val jacocoExclusions = listOf( - "**/entity/**", - "**/mapper/**", - "**/config/**", - "**/*Application*", - "**/generated/**", - "**/*MapperImpl*" -) - -tasks.jacocoTestReport { - dependsOn(tasks.test) - reports { - xml.required.set(true) - html.required.set(true) - } - classDirectories.setFrom(files(classDirectories.files.map { - fileTree(it) { exclude(jacocoExclusions) } - })) -} - -tasks.jacocoTestCoverageVerification { - dependsOn(tasks.jacocoTestReport) - violationRules { - rule { - limit { - minimum = "0.45".toBigDecimal() - } - } - } - classDirectories.setFrom(files(classDirectories.files.map { - fileTree(it) { exclude(jacocoExclusions) } - })) -} - -tasks.named("check") { - dependsOn( - tasks.named("integrationTest"), - tasks.named("businessTest"), - tasks.jacocoTestCoverageVerification - ) + // Test — WireMock for adapter unit tests + testImplementation("org.wiremock:wiremock-standalone:${project.property("wiremockVersion")}") } diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/integration-test/java/com/stablecoin/payments/fx/AbstractIntegrationTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/integration-test/java/com/stablecoin/payments/fx/AbstractIntegrationTest.java index 2e4347e4..a1376cd2 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/integration-test/java/com/stablecoin/payments/fx/AbstractIntegrationTest.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/integration-test/java/com/stablecoin/payments/fx/AbstractIntegrationTest.java @@ -8,11 +8,18 @@ import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.DynamicPropertyRegistry; import org.springframework.test.context.DynamicPropertySource; +import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.KafkaContainer; import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.lifecycle.Startable; import org.testcontainers.utility.DockerImageName; +import static com.stablecoin.payments.platform.test.TestContainerSupport.kafka; +import static com.stablecoin.payments.platform.test.TestContainerSupport.redis; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerKafkaProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerPostgresProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerRedisProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.startAll; + @SuppressWarnings("resource") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("integration-test") @@ -23,36 +30,15 @@ public abstract class AbstractIntegrationTest { new PostgreSQLContainer<>( DockerImageName.parse("timescale/timescaledb:latest-pg17") .asCompatibleSubstituteFor("postgres")) - .withDatabaseName("fx_rates") + .withDatabaseName("s6_fx_engine") .withUsername("test") .withPassword("test"); - protected static final KafkaContainer KAFKA = - new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0")); + protected static final KafkaContainer KAFKA = kafka(); + protected static final GenericContainer REDIS = redis(); static { - try { - POSTGRES.start(); - KAFKA.start(); - } catch (RuntimeException ex) { - safeStop(KAFKA); - safeStop(POSTGRES); - throw ex; - } - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - safeStop(KAFKA); - safeStop(POSTGRES); - }, "testcontainers-shutdown")); - } - - private static void safeStop(Startable container) { - try { - if (container != null) { - container.stop(); - } - } catch (Exception ignored) { - // best-effort cleanup - } + startAll(POSTGRES, KAFKA, REDIS); } @Autowired @@ -74,10 +60,8 @@ void cleanDatabase() { @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); - registry.add("spring.datasource.username", POSTGRES::getUsername); - registry.add("spring.datasource.password", POSTGRES::getPassword); - registry.add("spring.kafka.bootstrap-servers", KAFKA::getBootstrapServers); - registry.add("spring.cloud.stream.kafka.binder.brokers", KAFKA::getBootstrapServers); + registerPostgresProperties(registry, POSTGRES); + registerKafkaProperties(registry, KAFKA); + registerRedisProperties(registry, REDIS); } } diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/controller/GlobalExceptionHandler.java b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/controller/GlobalExceptionHandler.java index 7eec8ebe..ccb82143 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/controller/GlobalExceptionHandler.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/application/controller/GlobalExceptionHandler.java @@ -1,6 +1,5 @@ package com.stablecoin.payments.fx.application.controller; -import com.stablecoin.payments.fx.api.response.ApiError; import com.stablecoin.payments.fx.domain.exception.CorridorNotSupportedException; import com.stablecoin.payments.fx.domain.exception.InsufficientLiquidityException; import com.stablecoin.payments.fx.domain.exception.LockNotFoundException; @@ -9,61 +8,33 @@ import com.stablecoin.payments.fx.domain.exception.QuoteExpiredException; import com.stablecoin.payments.fx.domain.exception.QuoteNotFoundException; import com.stablecoin.payments.fx.domain.exception.RateUnavailableException; -import jakarta.validation.ConstraintViolationException; +import com.stablecoin.payments.platform.api.ApiError; +import com.stablecoin.payments.platform.infrastructure.exception.BaseGlobalExceptionHandler; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; -import org.springframework.validation.FieldError; -import org.springframework.validation.ObjectError; -import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; -import java.util.stream.Collectors; - import static com.stablecoin.payments.fx.application.controller.ErrorCodes.CORRIDOR_NOT_SUPPORTED; import static com.stablecoin.payments.fx.application.controller.ErrorCodes.INSUFFICIENT_LIQUIDITY; -import static com.stablecoin.payments.fx.application.controller.ErrorCodes.INTERNAL_ERROR; import static com.stablecoin.payments.fx.application.controller.ErrorCodes.LOCK_NOT_FOUND; import static com.stablecoin.payments.fx.application.controller.ErrorCodes.QUOTE_ALREADY_LOCKED; import static com.stablecoin.payments.fx.application.controller.ErrorCodes.QUOTE_EXPIRED; import static com.stablecoin.payments.fx.application.controller.ErrorCodes.QUOTE_NOT_FOUND; import static com.stablecoin.payments.fx.application.controller.ErrorCodes.RATE_UNAVAILABLE; -import static com.stablecoin.payments.fx.application.controller.ErrorCodes.VALIDATION_ERROR; -import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.CONFLICT; import static org.springframework.http.HttpStatus.GONE; -import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; import static org.springframework.http.HttpStatus.NOT_FOUND; import static org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE; import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY; @Slf4j @RestControllerAdvice -public class GlobalExceptionHandler { - - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(MethodArgumentNotValidException.class) - public ApiError handleValidation(MethodArgumentNotValidException ex) { - var errors = ex.getBindingResult().getFieldErrors().stream() - .collect(Collectors.groupingBy( - FieldError::getField, - Collectors.mapping(ObjectError::getDefaultMessage, Collectors.toList()))); - log.info("Validation failed: {}", errors); - return ApiError.withErrors(VALIDATION_ERROR, BAD_REQUEST.getReasonPhrase(), - "Invalid request content", errors); - } +public class GlobalExceptionHandler extends BaseGlobalExceptionHandler { - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(ConstraintViolationException.class) - public ApiError handleConstraintViolation(ConstraintViolationException ex) { - var errors = ex.getConstraintViolations().stream() - .collect(Collectors.groupingBy( - v -> v.getPropertyPath().toString(), - Collectors.mapping(jakarta.validation.ConstraintViolation::getMessage, - Collectors.toList()))); - return ApiError.withErrors(VALIDATION_ERROR, BAD_REQUEST.getReasonPhrase(), - "Invalid request content", errors); + @Override + protected String errorCodePrefix() { + return "FX"; } @ResponseStatus(NOT_FOUND) @@ -124,12 +95,4 @@ public ApiError handleRateUnavailable(RateUnavailableException ex) { return ApiError.of(RATE_UNAVAILABLE, SERVICE_UNAVAILABLE.getReasonPhrase(), ex.getMessage()); } - - @ResponseStatus(INTERNAL_SERVER_ERROR) - @ExceptionHandler(Exception.class) - public ApiError handleUnexpected(Exception ex) { - log.error("Unexpected error: ", ex); - return ApiError.of(INTERNAL_ERROR, INTERNAL_SERVER_ERROR.getReasonPhrase(), - HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase()); - } } diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/messaging/FxOutboxHandler.java b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/messaging/FxOutboxHandler.java index ed6d1103..536fa21d 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/messaging/FxOutboxHandler.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/messaging/FxOutboxHandler.java @@ -1,42 +1,13 @@ package com.stablecoin.payments.fx.infrastructure.messaging; -import io.namastack.outbox.annotation.OutboxHandler; -import io.namastack.outbox.handler.OutboxRecordMetadata; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import com.stablecoin.payments.platform.infrastructure.messaging.AbstractOutboxHandler; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; -import java.util.concurrent.TimeUnit; - -@Slf4j @Component -@RequiredArgsConstructor -public class FxOutboxHandler { - - private final KafkaTemplate kafkaTemplate; - - @OutboxHandler - public void handle(Object event, OutboxRecordMetadata metadata) { - var topic = resolveField(event, "TOPIC"); - var key = metadata.getKey(); - try { - kafkaTemplate.send(topic, key, event).get(10, TimeUnit.SECONDS); - log.debug("Published outbox event type={} topic={} key={}", - event.getClass().getSimpleName(), topic, key); - } catch (Exception e) { - log.error("Failed to publish event type={} topic={}: {}", - event.getClass().getSimpleName(), topic, e.getMessage()); - throw new RuntimeException("Kafka send failed for event " + event.getClass().getSimpleName(), e); - } - } +public class FxOutboxHandler extends AbstractOutboxHandler { - private String resolveField(Object event, String fieldName) { - try { - return (String) event.getClass().getField(fieldName).get(null); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new IllegalArgumentException( - "Event class missing static " + fieldName + " field: " + event.getClass().getName(), e); - } + public FxOutboxHandler(KafkaTemplate kafkaTemplate) { + super(kafkaTemplate); } } diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/messaging/OutboxEventPublisher.java b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/messaging/OutboxEventPublisher.java index 782a0898..08e0827f 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/messaging/OutboxEventPublisher.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/main/java/com/stablecoin/payments/fx/infrastructure/messaging/OutboxEventPublisher.java @@ -1,45 +1,17 @@ package com.stablecoin.payments.fx.infrastructure.messaging; import com.stablecoin.payments.fx.domain.port.EventPublisher; +import com.stablecoin.payments.platform.infrastructure.messaging.AbstractOutboxEventPublisher; import io.namastack.outbox.Outbox; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; import java.util.List; -@Slf4j @Component -@RequiredArgsConstructor -public class OutboxEventPublisher implements EventPublisher { +public class OutboxEventPublisher extends AbstractOutboxEventPublisher + implements EventPublisher { - private static final List KEY_FIELDS = List.of("paymentId", "poolId"); - - private final Outbox outbox; - - @Override - @Transactional(propagation = Propagation.MANDATORY) - public void publish(Object event) { - var key = resolveKey(event); - outbox.schedule(event, key); - log.debug("Scheduled outbox event type={} key={}", event.getClass().getSimpleName(), key); - } - - private String resolveKey(Object event) { - for (String field : KEY_FIELDS) { - try { - var method = event.getClass().getMethod(field); - return String.valueOf(method.invoke(event)); - } catch (NoSuchMethodException e) { - // try next field - } catch (Exception e) { - throw new IllegalArgumentException( - "Failed to invoke key accessor '" + field + "' on " + event.getClass().getName(), e); - } - } - throw new IllegalArgumentException( - "Event class has no key field (paymentId or poolId): " + event.getClass().getName()); + public OutboxEventPublisher(Outbox outbox) { + super(outbox, List.of("paymentId", "poolId")); } } diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/ArchitectureTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/ArchitectureTest.java deleted file mode 100644 index 63b60946..00000000 --- a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/ArchitectureTest.java +++ /dev/null @@ -1,95 +0,0 @@ -package com.stablecoin.payments.fx; - -import com.tngtech.archunit.core.importer.ClassFileImporter; -import com.tngtech.archunit.core.importer.ImportOption; -import org.junit.jupiter.api.BeforeAll; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; -import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; - -@DisplayName("Architecture Rules") -class ArchitectureTest { - - private static com.tngtech.archunit.core.domain.JavaClasses importedClasses; - - @BeforeAll - static void setUp() { - importedClasses = new ClassFileImporter() - .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) - .importPackages("com.stablecoin.payments.fx"); - } - - @Test - @DisplayName("Domain should not depend on Spring (except stereotype and transaction)") - void domainShouldNotDependOnSpring() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat( - resideInAPackage("org.springframework..") - .and(resideOutsideOfPackage("org.springframework.stereotype..")) - .and(resideOutsideOfPackage("org.springframework.transaction..")) - .and(resideOutsideOfPackage("org.springframework.beans.factory.annotation..")) - ) - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on JPA") - void domainShouldNotDependOnJpa() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("jakarta.persistence..") - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on infrastructure") - void domainShouldNotDependOnInfrastructure() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("..infrastructure..") - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on application layer") - void domainShouldNotDependOnApplication() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("..application..") - .check(importedClasses); - } - - @Test - @DisplayName("Ports should be interfaces") - void portsShouldBeInterfaces() { - classes() - .that().resideInAPackage("..domain.port..") - .and().areNotRecords() - .should().beInterfaces() - .check(importedClasses); - } - - @Test - @DisplayName("Domain events should be records") - void domainEventsShouldBeRecords() { - classes() - .that().resideInAPackage("..domain.event..") - .should().beRecords() - .check(importedClasses); - } - - @Test - @DisplayName("Controllers should reside in application.controller package") - void controllersShouldResideInApplicationController() { - noClasses() - .that().haveSimpleNameEndingWith("Controller") - .should().resideOutsideOfPackage("..application.controller..") - .allowEmptyShould(true) - .check(importedClasses); - } -} diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/controller/GlobalExceptionHandlerTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/controller/GlobalExceptionHandlerTest.java index ab6ed5b6..78be0f7c 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/controller/GlobalExceptionHandlerTest.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/controller/GlobalExceptionHandlerTest.java @@ -1,6 +1,5 @@ package com.stablecoin.payments.fx.application.controller; -import com.stablecoin.payments.fx.api.response.ApiError; import com.stablecoin.payments.fx.domain.exception.CorridorNotSupportedException; import com.stablecoin.payments.fx.domain.exception.InsufficientLiquidityException; import com.stablecoin.payments.fx.domain.exception.PoolNotFoundException; @@ -8,6 +7,7 @@ import com.stablecoin.payments.fx.domain.exception.QuoteExpiredException; import com.stablecoin.payments.fx.domain.exception.QuoteNotFoundException; import com.stablecoin.payments.fx.domain.exception.RateUnavailableException; +import com.stablecoin.payments.platform.api.ApiError; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/FxQuoteApplicationServiceTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/FxQuoteApplicationServiceTest.java index a5efcb78..9dd3deb7 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/FxQuoteApplicationServiceTest.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/application/service/FxQuoteApplicationServiceTest.java @@ -23,7 +23,7 @@ import static com.stablecoin.payments.fx.fixtures.CorridorRateFixtures.aUsdEurRate; import static com.stablecoin.payments.fx.fixtures.FxQuoteFixtures.anActiveQuote; -import static com.stablecoin.payments.fx.fixtures.TestUtils.eqIgnoringTimestamps; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/arch/ArchitectureTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/arch/ArchitectureTest.java new file mode 100644 index 00000000..bb7d7a1e --- /dev/null +++ b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/arch/ArchitectureTest.java @@ -0,0 +1,15 @@ +package com.stablecoin.payments.fx.arch; + +import com.stablecoin.payments.platform.test.HexagonalArchitectureRules; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("Architecture Rules") +class ArchitectureTest { + + @Test + @DisplayName("Verify hexagonal architecture rules") + void verifyHexagonalArchitecture() { + HexagonalArchitectureRules.verifyAll("com.stablecoin.payments.fx"); + } +} diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/infrastructure/scheduling/LockExpiryJobTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/infrastructure/scheduling/LockExpiryJobTest.java index e9ed79d6..f463b633 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/infrastructure/scheduling/LockExpiryJobTest.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/infrastructure/scheduling/LockExpiryJobTest.java @@ -24,7 +24,7 @@ import static com.stablecoin.payments.fx.fixtures.FxRateLockFixtures.anActiveLock; import static com.stablecoin.payments.fx.fixtures.LiquidityPoolFixtures.aUsdEurPool; -import static com.stablecoin.payments.fx.fixtures.TestUtils.eqIgnoringTimestamps; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/infrastructure/scheduling/RateRefreshJobTest.java b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/infrastructure/scheduling/RateRefreshJobTest.java index 9922b606..febb9b96 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/infrastructure/scheduling/RateRefreshJobTest.java +++ b/fx-liquidity-engine/fx-liquidity-engine/src/test/java/com/stablecoin/payments/fx/infrastructure/scheduling/RateRefreshJobTest.java @@ -16,7 +16,7 @@ import java.util.Optional; import static com.stablecoin.payments.fx.fixtures.CorridorRateFixtures.aUsdEurRate; -import static com.stablecoin.payments.fx.fixtures.TestUtils.eqIgnoringTimestamps; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.never; diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/testFixtures/java/com/stablecoin/payments/fx/fixtures/TestUtils.java b/fx-liquidity-engine/fx-liquidity-engine/src/testFixtures/java/com/stablecoin/payments/fx/fixtures/TestUtils.java deleted file mode 100644 index 6db99dff..00000000 --- a/fx-liquidity-engine/fx-liquidity-engine/src/testFixtures/java/com/stablecoin/payments/fx/fixtures/TestUtils.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.stablecoin.payments.fx.fixtures; - -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.ZonedDateTime; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.argThat; - -public final class TestUtils { - - private TestUtils() {} - - public static T eqIgnoringTimestamps(T expected) { - return eqIgnoring(expected); - } - - public static T eqIgnoring(T expected, String... fieldsToIgnore) { - return argThat(it -> isEqualIgnoring(it, expected, fieldsToIgnore)); - } - - private static boolean isEqualIgnoring(T original, T expected, String... fieldsToIgnore) { - try { - assertThat(original) - .usingRecursiveComparison() - .ignoringFieldsOfTypes(ZonedDateTime.class, LocalDateTime.class, LocalDate.class, Instant.class) - .ignoringFields(fieldsToIgnore) - .isEqualTo(expected); - return true; - } catch (Throwable t) { - return false; - } - } -} diff --git a/gradle.properties b/gradle.properties index bdfb3d19..c70723d2 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,3 +18,4 @@ springdocVersion=2.8.6 spotlessVersion=7.0.2 jibVersion=3.5.3 web3jVersion=4.12.3 +namastackVersion=1.1.0 diff --git a/ledger-accounting/ledger-accounting-api/build.gradle.kts b/ledger-accounting/ledger-accounting-api/build.gradle.kts index 20c8280f..97d4baa4 100644 --- a/ledger-accounting/ledger-accounting-api/build.gradle.kts +++ b/ledger-accounting/ledger-accounting-api/build.gradle.kts @@ -1,16 +1,3 @@ plugins { - `java-library` -} - -dependencies { - api("jakarta.validation:jakarta.validation-api") - api("com.fasterxml.jackson.core:jackson-annotations") - - testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation("org.assertj:assertj-core") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { - useJUnitPlatform() + id("stablebridge.api-library") } diff --git a/ledger-accounting/ledger-accounting-api/src/main/java/com/stablecoin/payments/ledger/api/ApiError.java b/ledger-accounting/ledger-accounting-api/src/main/java/com/stablecoin/payments/ledger/api/ApiError.java deleted file mode 100644 index 8c5f19b3..00000000 --- a/ledger-accounting/ledger-accounting-api/src/main/java/com/stablecoin/payments/ledger/api/ApiError.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.stablecoin.payments.ledger.api; - -import java.util.List; -import java.util.Map; - -public record ApiError( - String code, - String status, - String message, - Map> errors -) { - public static ApiError of(String code, String status, String message) { - return new ApiError(code, status, message, Map.of()); - } - - public static ApiError withErrors(String code, String status, String message, - Map> errors) { - return new ApiError(code, status, message, errors); - } -} diff --git a/ledger-accounting/ledger-accounting-client/build.gradle.kts b/ledger-accounting/ledger-accounting-client/build.gradle.kts index 110c060e..948aea01 100644 --- a/ledger-accounting/ledger-accounting-client/build.gradle.kts +++ b/ledger-accounting/ledger-accounting-client/build.gradle.kts @@ -1,16 +1,7 @@ plugins { - `java-library` + id("stablebridge.client-library") } dependencies { api(project(":ledger-accounting:ledger-accounting-api")) - implementation("org.springframework.cloud:spring-cloud-starter-openfeign") - - testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation("org.assertj:assertj-core") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { - useJUnitPlatform() } diff --git a/ledger-accounting/ledger-accounting/build.gradle.kts b/ledger-accounting/ledger-accounting/build.gradle.kts index c94e49a4..a8141519 100644 --- a/ledger-accounting/ledger-accounting/build.gradle.kts +++ b/ledger-accounting/ledger-accounting/build.gradle.kts @@ -1,203 +1,15 @@ plugins { - id("org.springframework.boot") - id("com.google.cloud.tools.jib") - java - `java-test-fixtures` - jacoco + id("stablebridge.service") } -jib { - from { - image = "docker://eclipse-temurin:25-jre" - } - to { - image = "stablebridge/ledger-accounting" - tags = setOf("latest") - } - container { - creationTime.set("USE_CURRENT_TIMESTAMP") - } +stablebridge { + jibImageName.set("stablebridge/ledger-accounting") + extraJacocoExclusions.set(listOf("**/filter/**", "**/controller/GlobalExceptionHandler*")) } -val integrationTestSourceSet: SourceSet = sourceSets.create("integrationTest") { - java.srcDir("src/integration-test/java") - resources.srcDir("src/integration-test/resources") - compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output - runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output -} - -configurations { - named("integrationTestImplementation") { extendsFrom(configurations.testImplementation.get()) } - named("integrationTestRuntimeOnly") { extendsFrom(configurations.testRuntimeOnly.get()) } -} - -tasks.register("integrationTest") { - testClassesDirs = integrationTestSourceSet.output.classesDirs - classpath = integrationTestSourceSet.runtimeClasspath - shouldRunAfter(tasks.test) - configure { isEnabled = false } - failOnNoDiscoveredTests = false - exclude("**/Abstract*", "**/config/**") -} - -val businessTestSourceSet: SourceSet = sourceSets.create("businessTest") { - java.srcDir("src/business-test/java") - resources.srcDir("src/business-test/resources") - compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output + integrationTestSourceSet.output - runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output + integrationTestSourceSet.output -} - -configurations { - named("businessTestImplementation") { extendsFrom(configurations.named("integrationTestImplementation").get()) } - named("businessTestRuntimeOnly") { extendsFrom(configurations.named("integrationTestRuntimeOnly").get()) } -} - -tasks.register("businessTest") { - testClassesDirs = businessTestSourceSet.output.classesDirs - classpath = businessTestSourceSet.runtimeClasspath - shouldRunAfter(tasks.named("integrationTest")) - failOnNoDiscoveredTests = false - configure { isEnabled = false } -} - -val lombokVersion: String by project -val mapstructVersion: String by project -val lombokMapstructBindingVersion: String by project -val resilience4jVersion: String by project -val flywayVersion: String by project -val archunitVersion: String by project -val testcontainersVersion: String by project -val wiremockVersion: String by project -val springdocVersion: String by project - dependencies { implementation(project(":ledger-accounting:ledger-accounting-api")) - // Spring Boot - implementation("org.springframework.boot:spring-boot-starter-web") - implementation("org.springframework.boot:spring-boot-starter-validation") - implementation("org.springframework.boot:spring-boot-starter-data-jpa") - implementation("org.springframework.boot:spring-boot-starter-actuator") - - // OpenAPI / Swagger UI - implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$springdocVersion") - runtimeOnly("io.micrometer:micrometer-registry-prometheus") - implementation("io.micrometer:micrometer-tracing-bridge-otel") - implementation("io.opentelemetry:opentelemetry-exporter-otlp") - implementation("org.springframework.boot:spring-boot-starter-security") - - // Kafka via Spring Cloud Stream - implementation("org.springframework.cloud:spring-cloud-stream") - implementation("org.springframework.cloud:spring-cloud-stream-binder-kafka") - implementation("org.springframework.kafka:spring-kafka") - - // Feign - implementation("org.springframework.cloud:spring-cloud-starter-openfeign") - - // Resilience4j - implementation("io.github.resilience4j:resilience4j-spring-boot3:$resilience4jVersion") - implementation("io.github.resilience4j:resilience4j-circuitbreaker:$resilience4jVersion") - - // MapStruct (compiler args set below in JavaCompile task) - implementation("org.mapstruct:mapstruct:$mapstructVersion") - annotationProcessor("org.mapstruct:mapstruct-processor:$mapstructVersion") - annotationProcessor("org.projectlombok:lombok-mapstruct-binding:$lombokMapstructBindingVersion") - - // Outbox (namastack) - implementation("io.namastack:namastack-outbox-starter-jdbc:1.1.0") - - // Database - runtimeOnly("org.postgresql:postgresql") - implementation("org.springframework.boot:spring-boot-starter-flyway") - implementation("org.flywaydb:flyway-database-postgresql:$flywayVersion") - // Test Fixtures testFixturesImplementation(project(":ledger-accounting:ledger-accounting-api")) - testFixturesImplementation("org.assertj:assertj-core") - testFixturesImplementation("org.mockito:mockito-core") - - // Test - testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("org.springframework.kafka:spring-kafka-test") - testImplementation("com.tngtech.archunit:archunit-junit5:$archunitVersion") - "integrationTestImplementation"(testFixtures(project)) - "integrationTestImplementation"("org.testcontainers:postgresql:$testcontainersVersion") - "integrationTestImplementation"("org.testcontainers:kafka:$testcontainersVersion") - "integrationTestImplementation"("org.testcontainers:junit-jupiter:$testcontainersVersion") - "integrationTestImplementation"("org.springframework.boot:spring-boot-starter-webmvc-test") - "integrationTestImplementation"("org.springframework.boot:spring-boot-starter-security-test") -} - -tasks.withType { - options.compilerArgs.addAll(listOf( - "-Amapstruct.defaultComponentModel=spring", - "-Amapstruct.unmappedTargetPolicy=IGNORE" - )) -} - -tasks.withType { - jvmArgs("-Dnet.bytebuddy.experimental=true") - useJUnitPlatform() - testLogging { - events("passed", "skipped", "failed") - showExceptions = true - showCauses = true - showStackTraces = true - exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL - } -} - -jacoco { - toolVersion = "0.8.14" -} - -tasks.test { - configure { - excludes = listOf("sun.*", "jdk.*", "com.sun.*", "java.*", "javax.*") - } - finalizedBy(tasks.jacocoTestReport) -} - -val jacocoExclusions = listOf( - "**/entity/**", - "**/mapper/**", - "**/config/**", - "**/filter/**", - "**/controller/GlobalExceptionHandler*", - "**/*Application*", - "**/generated/**", - "**/*MapperImpl*" -) - -tasks.jacocoTestReport { - dependsOn(tasks.test) - reports { - xml.required.set(true) - html.required.set(true) - } - classDirectories.setFrom(files(classDirectories.files.map { - fileTree(it) { exclude(jacocoExclusions) } - })) -} - -tasks.jacocoTestCoverageVerification { - dependsOn(tasks.jacocoTestReport) - violationRules { - rule { - limit { - minimum = "0.50".toBigDecimal() - } - } - } - classDirectories.setFrom(files(classDirectories.files.map { - fileTree(it) { exclude(jacocoExclusions) } - })) -} - -tasks.named("check") { - dependsOn( - tasks.named("integrationTest"), - tasks.named("businessTest"), - tasks.jacocoTestCoverageVerification - ) } diff --git a/ledger-accounting/ledger-accounting/src/integration-test/java/com/stablecoin/payments/ledger/AbstractIntegrationTest.java b/ledger-accounting/ledger-accounting/src/integration-test/java/com/stablecoin/payments/ledger/AbstractIntegrationTest.java index 8613a8de..2c2b1fa6 100644 --- a/ledger-accounting/ledger-accounting/src/integration-test/java/com/stablecoin/payments/ledger/AbstractIntegrationTest.java +++ b/ledger-accounting/ledger-accounting/src/integration-test/java/com/stablecoin/payments/ledger/AbstractIntegrationTest.java @@ -10,8 +10,12 @@ import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.KafkaContainer; import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.lifecycle.Startable; -import org.testcontainers.utility.DockerImageName; + +import static com.stablecoin.payments.platform.test.TestContainerSupport.kafka; +import static com.stablecoin.payments.platform.test.TestContainerSupport.postgres; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerKafkaProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerPostgresProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.startAll; @SuppressWarnings("resource") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -19,38 +23,11 @@ @AutoConfigureMockMvc public abstract class AbstractIntegrationTest { - static final PostgreSQLContainer POSTGRES = - new PostgreSQLContainer<>("postgres:16-alpine") - .withDatabaseName("s7_ledger_accounting") - .withUsername("test") - .withPassword("test"); - - protected static final KafkaContainer KAFKA = - new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0")); + static final PostgreSQLContainer POSTGRES = postgres("s7_ledger"); + protected static final KafkaContainer KAFKA = kafka(); static { - try { - POSTGRES.start(); - KAFKA.start(); - } catch (RuntimeException ex) { - safeStop(KAFKA); - safeStop(POSTGRES); - throw ex; - } - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - safeStop(KAFKA); - safeStop(POSTGRES); - }, "testcontainers-shutdown")); - } - - private static void safeStop(Startable container) { - try { - if (container != null) { - container.stop(); - } - } catch (Exception ignored) { - // best-effort cleanup - } + startAll(POSTGRES, KAFKA); } @Autowired @@ -75,10 +52,7 @@ void cleanDatabase() { @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); - registry.add("spring.datasource.username", POSTGRES::getUsername); - registry.add("spring.datasource.password", POSTGRES::getPassword); - registry.add("spring.kafka.bootstrap-servers", KAFKA::getBootstrapServers); - registry.add("spring.cloud.stream.kafka.binder.brokers", KAFKA::getBootstrapServers); + registerPostgresProperties(registry, POSTGRES); + registerKafkaProperties(registry, KAFKA); } } diff --git a/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/application/controller/GlobalExceptionHandler.java b/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/application/controller/GlobalExceptionHandler.java index cf97a159..17a2019c 100644 --- a/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/application/controller/GlobalExceptionHandler.java +++ b/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/application/controller/GlobalExceptionHandler.java @@ -1,48 +1,26 @@ package com.stablecoin.payments.ledger.application.controller; -import com.stablecoin.payments.ledger.api.ApiError; import com.stablecoin.payments.ledger.domain.exception.AccountNotFoundException; import com.stablecoin.payments.ledger.domain.exception.DuplicateTransactionException; import com.stablecoin.payments.ledger.domain.exception.JournalNotFoundException; import com.stablecoin.payments.ledger.domain.exception.ReconciliationNotFoundException; +import com.stablecoin.payments.platform.api.ApiError; +import com.stablecoin.payments.platform.infrastructure.exception.BaseGlobalExceptionHandler; import lombok.extern.slf4j.Slf4j; -import org.springframework.validation.FieldError; -import org.springframework.validation.ObjectError; -import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; -import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; -import java.util.stream.Collectors; - -import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.CONFLICT; -import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; import static org.springframework.http.HttpStatus.NOT_FOUND; @Slf4j @RestControllerAdvice -public class GlobalExceptionHandler { - - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(MethodArgumentNotValidException.class) - public ApiError handleValidation(MethodArgumentNotValidException ex) { - var errors = ex.getBindingResult().getFieldErrors().stream() - .collect(Collectors.groupingBy( - FieldError::getField, - Collectors.mapping(ObjectError::getDefaultMessage, Collectors.toList()))); - log.info("Validation failed: {}", errors); - return ApiError.withErrors("LD-0001", BAD_REQUEST.getReasonPhrase(), - "Invalid request content", errors); - } +public class GlobalExceptionHandler extends BaseGlobalExceptionHandler { - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(MethodArgumentTypeMismatchException.class) - public ApiError handleTypeMismatch(MethodArgumentTypeMismatchException ex) { - log.info("Type mismatch for parameter '{}': {}", ex.getName(), ex.getMessage()); - return ApiError.of("LD-0001", BAD_REQUEST.getReasonPhrase(), - "Invalid value for parameter '" + ex.getName() + "'"); + @Override + protected String errorCodePrefix() { + return "LD"; } @ResponseStatus(NOT_FOUND) @@ -72,19 +50,4 @@ public ApiError handleDuplicateTransaction(DuplicateTransactionException ex) { log.info("Duplicate transaction: {}", ex.getMessage()); return ApiError.of(ex.errorCode(), CONFLICT.getReasonPhrase(), ex.getMessage()); } - - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(IllegalArgumentException.class) - public ApiError handleIllegalArgument(IllegalArgumentException ex) { - log.warn("Illegal argument: {}", ex.getMessage()); - return ApiError.of("LD-0001", BAD_REQUEST.getReasonPhrase(), "Invalid request parameter"); - } - - @ResponseStatus(INTERNAL_SERVER_ERROR) - @ExceptionHandler(Exception.class) - public ApiError handleUnexpected(Exception ex) { - log.error("Unexpected error: ", ex); - return ApiError.of("LD-9999", INTERNAL_SERVER_ERROR.getReasonPhrase(), - INTERNAL_SERVER_ERROR.getReasonPhrase()); - } } diff --git a/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/infrastructure/messaging/LedgerOutboxHandler.java b/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/infrastructure/messaging/LedgerOutboxHandler.java index 8a6bf7af..01842196 100644 --- a/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/infrastructure/messaging/LedgerOutboxHandler.java +++ b/ledger-accounting/ledger-accounting/src/main/java/com/stablecoin/payments/ledger/infrastructure/messaging/LedgerOutboxHandler.java @@ -1,47 +1,13 @@ package com.stablecoin.payments.ledger.infrastructure.messaging; -import io.namastack.outbox.annotation.OutboxHandler; -import io.namastack.outbox.handler.OutboxRecordMetadata; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import com.stablecoin.payments.platform.infrastructure.messaging.AbstractOutboxHandler; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; -import java.util.concurrent.TimeUnit; - -@Slf4j @Component -@RequiredArgsConstructor -public class LedgerOutboxHandler { - - private static final long SEND_TIMEOUT_SECONDS = 10; - - private final KafkaTemplate kafkaTemplate; - - @OutboxHandler - public void handle(Object event, OutboxRecordMetadata metadata) { - var topic = resolveStaticField(event, "TOPIC"); - var key = metadata.getKey(); - try { - kafkaTemplate.send(topic, key, event).get(SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS); - log.debug("Published outbox event type={} topic={} key={}", - event.getClass().getSimpleName(), topic, key); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new RuntimeException("Kafka send interrupted for " + event.getClass().getSimpleName(), e); - } catch (Exception e) { - log.error("Failed to publish event type={} topic={}: {}", - event.getClass().getSimpleName(), topic, e.getMessage()); - throw new RuntimeException("Kafka send failed for " + event.getClass().getSimpleName(), e); - } - } +public class LedgerOutboxHandler extends AbstractOutboxHandler { - private String resolveStaticField(Object event, String fieldName) { - try { - return (String) event.getClass().getField(fieldName).get(null); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new IllegalArgumentException( - "Event class missing static " + fieldName + " field: " + event.getClass().getName(), e); - } + public LedgerOutboxHandler(KafkaTemplate kafkaTemplate) { + super(kafkaTemplate); } } diff --git a/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/arch/ArchitectureTest.java b/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/arch/ArchitectureTest.java index 8c953476..721741cb 100644 --- a/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/arch/ArchitectureTest.java +++ b/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/arch/ArchitectureTest.java @@ -1,112 +1,15 @@ package com.stablecoin.payments.ledger.arch; -import com.tngtech.archunit.core.domain.JavaClasses; -import com.tngtech.archunit.core.importer.ClassFileImporter; -import com.tngtech.archunit.core.importer.ImportOption; -import org.junit.jupiter.api.BeforeAll; +import com.stablecoin.payments.platform.test.HexagonalArchitectureRules; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; -import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; - @DisplayName("Architecture Rules") class ArchitectureTest { - private static JavaClasses importedClasses; - - @BeforeAll - static void setUp() { - importedClasses = new ClassFileImporter() - .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) - .importPackages("com.stablecoin.payments.ledger"); - } - - @Test - @DisplayName("Domain should not depend on Spring (except stereotype and transaction)") - void domainShouldNotDependOnSpring() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat( - resideInAPackage("org.springframework..") - .and(resideOutsideOfPackage("org.springframework.stereotype..")) - .and(resideOutsideOfPackage("org.springframework.transaction..")) - .and(resideOutsideOfPackage("org.springframework.beans.factory.annotation..")) - ) - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on JPA") - void domainShouldNotDependOnJpa() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("jakarta.persistence..") - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on infrastructure") - void domainShouldNotDependOnInfrastructure() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("..infrastructure..") - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on application layer") - void domainShouldNotDependOnApplication() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("..application..") - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Infrastructure should not depend on application controller") - void infrastructureShouldNotDependOnApplicationController() { - noClasses() - .that().resideInAPackage("..infrastructure..") - .should().dependOnClassesThat().resideInAPackage("..application.controller..") - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Ports should be interfaces") - void portsShouldBeInterfaces() { - classes() - .that().resideInAPackage("..domain.port..") - .and().areNotRecords() - .should().beInterfaces() - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Domain events should be records") - void domainEventsShouldBeRecords() { - classes() - .that().resideInAPackage("..domain.event..") - .should().beRecords() - .allowEmptyShould(true) - .check(importedClasses); - } - @Test - @DisplayName("Controllers should reside in application.controller package") - void controllersShouldResideInApplicationController() { - noClasses() - .that().haveSimpleNameEndingWith("Controller") - .should().resideOutsideOfPackage("..application.controller..") - .allowEmptyShould(true) - .check(importedClasses); + @DisplayName("Verify hexagonal architecture rules") + void verifyHexagonalArchitecture() { + HexagonalArchitectureRules.verifyAll("com.stablecoin.payments.ledger"); } } diff --git a/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/domain/service/JournalCommandHandlerTest.java b/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/domain/service/JournalCommandHandlerTest.java index f2cab565..52078cef 100644 --- a/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/domain/service/JournalCommandHandlerTest.java +++ b/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/domain/service/JournalCommandHandlerTest.java @@ -34,8 +34,8 @@ import static com.stablecoin.payments.ledger.fixtures.LedgerFixtures.SOURCE_EVENT_ID; import static com.stablecoin.payments.ledger.fixtures.LedgerFixtures.STABLECOIN_REDEEMED_ACCT; import static com.stablecoin.payments.ledger.fixtures.LedgerFixtures.aBalancedTransaction; -import static com.stablecoin.payments.ledger.fixtures.TestUtils.eqIgnoring; -import static com.stablecoin.payments.ledger.fixtures.TestUtils.eqIgnoringTimestamps; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; diff --git a/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/domain/service/ReconciliationCommandHandlerTest.java b/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/domain/service/ReconciliationCommandHandlerTest.java index 2f7d32c3..58e71f91 100644 --- a/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/domain/service/ReconciliationCommandHandlerTest.java +++ b/ledger-accounting/ledger-accounting/src/test/java/com/stablecoin/payments/ledger/domain/service/ReconciliationCommandHandlerTest.java @@ -25,7 +25,7 @@ import static com.stablecoin.payments.ledger.fixtures.LedgerFixtures.PAYMENT_ID; import static com.stablecoin.payments.ledger.fixtures.LedgerFixtures.SOURCE_EVENT_ID; import static com.stablecoin.payments.ledger.fixtures.ReconciliationFixtures.aRecordWithAllRequiredLegs; -import static com.stablecoin.payments.ledger.fixtures.TestUtils.eqIgnoring; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; diff --git a/ledger-accounting/ledger-accounting/src/testFixtures/java/com/stablecoin/payments/ledger/fixtures/TestUtils.java b/ledger-accounting/ledger-accounting/src/testFixtures/java/com/stablecoin/payments/ledger/fixtures/TestUtils.java deleted file mode 100644 index 211d6d03..00000000 --- a/ledger-accounting/ledger-accounting/src/testFixtures/java/com/stablecoin/payments/ledger/fixtures/TestUtils.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.stablecoin.payments.ledger.fixtures; - -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.ZonedDateTime; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.argThat; - -public final class TestUtils { - - private TestUtils() {} - - public static T eqIgnoringTimestamps(T expected) { - return eqIgnoring(expected); - } - - public static T eqIgnoring(T expected, String... fieldsToIgnore) { - return argThat(it -> isEqualIgnoring(it, expected, fieldsToIgnore)); - } - - private static boolean isEqualIgnoring(T original, T expected, String... fieldsToIgnore) { - try { - assertThat(original) - .usingRecursiveComparison() - .ignoringFieldsOfTypes(ZonedDateTime.class, LocalDateTime.class, LocalDate.class, Instant.class) - .ignoringFields(fieldsToIgnore) - .isEqualTo(expected); - return true; - } catch (Throwable t) { - return false; - } - } -} diff --git a/merchant-iam/merchant-iam-api/build.gradle.kts b/merchant-iam/merchant-iam-api/build.gradle.kts index 20c8280f..97d4baa4 100644 --- a/merchant-iam/merchant-iam-api/build.gradle.kts +++ b/merchant-iam/merchant-iam-api/build.gradle.kts @@ -1,16 +1,3 @@ plugins { - `java-library` -} - -dependencies { - api("jakarta.validation:jakarta.validation-api") - api("com.fasterxml.jackson.core:jackson-annotations") - - testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation("org.assertj:assertj-core") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { - useJUnitPlatform() + id("stablebridge.api-library") } diff --git a/merchant-iam/merchant-iam-client/build.gradle.kts b/merchant-iam/merchant-iam-client/build.gradle.kts index 734d33ac..5547474f 100644 --- a/merchant-iam/merchant-iam-client/build.gradle.kts +++ b/merchant-iam/merchant-iam-client/build.gradle.kts @@ -1,16 +1,7 @@ plugins { - `java-library` + id("stablebridge.client-library") } dependencies { api(project(":merchant-iam:merchant-iam-api")) - api("org.springframework.cloud:spring-cloud-starter-openfeign") - - testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation("org.assertj:assertj-core") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { - useJUnitPlatform() } diff --git a/merchant-iam/merchant-iam/build.gradle.kts b/merchant-iam/merchant-iam/build.gradle.kts index fbf23ef0..223a0462 100644 --- a/merchant-iam/merchant-iam/build.gradle.kts +++ b/merchant-iam/merchant-iam/build.gradle.kts @@ -1,106 +1,17 @@ plugins { - id("org.springframework.boot") - id("com.google.cloud.tools.jib") - java - `java-test-fixtures` - jacoco + id("stablebridge.service") } -jib { - from { - image = "docker://eclipse-temurin:25-jre" - } - to { - image = "stablebridge/merchant-iam" - tags = setOf("latest") - } - container { - creationTime.set("USE_CURRENT_TIMESTAMP") - } +stablebridge { + jibImageName.set("stablebridge/merchant-iam") } -val integrationTestSourceSet: SourceSet = sourceSets.create("integrationTest") { - java.srcDir("src/integration-test/java") - resources.srcDir("src/integration-test/resources") - compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output - runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output -} - -configurations { - named("integrationTestImplementation") { extendsFrom(configurations.testImplementation.get()) } - named("integrationTestRuntimeOnly") { extendsFrom(configurations.testRuntimeOnly.get()) } -} - -tasks.register("integrationTest") { - testClassesDirs = integrationTestSourceSet.output.classesDirs - classpath = integrationTestSourceSet.runtimeClasspath - shouldRunAfter(tasks.test) - configure { isEnabled = false } -} - -val businessTestSourceSet: SourceSet = sourceSets.create("businessTest") { - java.srcDir("src/business-test/java") - resources.srcDir("src/business-test/resources") - compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output + integrationTestSourceSet.output - runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output + integrationTestSourceSet.output -} - -configurations { - named("businessTestImplementation") { extendsFrom(configurations.named("integrationTestImplementation").get()) } - named("businessTestRuntimeOnly") { extendsFrom(configurations.named("integrationTestRuntimeOnly").get()) } -} - -tasks.register("businessTest") { - testClassesDirs = businessTestSourceSet.output.classesDirs - classpath = businessTestSourceSet.runtimeClasspath - shouldRunAfter(tasks.named("integrationTest")) - configure { isEnabled = false } -} - -val lombokVersion: String by project -val mapstructVersion: String by project -val lombokMapstructBindingVersion: String by project -val resilience4jVersion: String by project -val flywayVersion: String by project -val archunitVersion: String by project -val testcontainersVersion: String by project -val wiremockVersion: String by project -val springdocVersion: String by project - dependencies { implementation(project(":merchant-iam:merchant-iam-api")) - // Spring Boot - implementation("org.springframework.boot:spring-boot-starter-web") - implementation("org.springframework.boot:spring-boot-starter-validation") - implementation("org.springframework.boot:spring-boot-starter-data-jpa") - implementation("org.springframework.boot:spring-boot-starter-actuator") - - // OpenAPI / Swagger UI - implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$springdocVersion") - runtimeOnly("io.micrometer:micrometer-registry-prometheus") - implementation("io.micrometer:micrometer-tracing-bridge-otel") - implementation("io.opentelemetry:opentelemetry-exporter-otlp") - implementation("org.springframework.boot:spring-boot-starter-security") + // Redis implementation("org.springframework.boot:spring-boot-starter-data-redis") - // Kafka via Spring Cloud Stream - implementation("org.springframework.cloud:spring-cloud-stream") - implementation("org.springframework.cloud:spring-cloud-stream-binder-kafka") - implementation("org.springframework.kafka:spring-kafka") - - // Feign - implementation("org.springframework.cloud:spring-cloud-starter-openfeign") - - // Resilience4j - implementation("io.github.resilience4j:resilience4j-spring-boot3:$resilience4jVersion") - implementation("io.github.resilience4j:resilience4j-circuitbreaker:$resilience4jVersion") - - // MapStruct - implementation("org.mapstruct:mapstruct:$mapstructVersion") - annotationProcessor("org.mapstruct:mapstruct-processor:$mapstructVersion") - annotationProcessor("org.projectlombok:lombok-mapstruct-binding:$lombokMapstructBindingVersion") - // Email implementation("org.springframework.boot:spring-boot-starter-mail") @@ -109,101 +20,4 @@ dependencies { // Auth — TOTP RFC 6238 (Google Authenticator compatible) implementation("dev.samstevens.totp:totp:1.7.1") - - // Database - runtimeOnly("org.postgresql:postgresql") - implementation("org.springframework.boot:spring-boot-starter-flyway") - implementation("org.flywaydb:flyway-database-postgresql:$flywayVersion") - // hypersistence-utils removed: using native Hibernate 7 @JdbcTypeCode(SqlTypes.JSON) for JSONB - - // Outbox (namastack) - implementation("io.namastack:namastack-outbox-starter-jdbc:1.1.0") - - // Test fixtures - testFixturesImplementation("org.assertj:assertj-core") - testFixturesImplementation("org.mockito:mockito-core") - - // Test - testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("org.springframework.kafka:spring-kafka-test") - testImplementation("com.tngtech.archunit:archunit-junit5:$archunitVersion") - "integrationTestImplementation"(testFixtures(project)) - "integrationTestImplementation"("org.testcontainers:postgresql:$testcontainersVersion") - "integrationTestImplementation"("org.testcontainers:kafka:$testcontainersVersion") - "integrationTestImplementation"("org.testcontainers:junit-jupiter:$testcontainersVersion") - "integrationTestImplementation"("org.wiremock:wiremock-standalone:$wiremockVersion") - "integrationTestImplementation"("org.springframework.boot:spring-boot-starter-webmvc-test") - "integrationTestImplementation"("org.springframework.boot:spring-boot-starter-security-test") -} - -tasks.withType { - options.compilerArgs.addAll(listOf( - "-Amapstruct.defaultComponentModel=spring", - "-Amapstruct.unmappedTargetPolicy=IGNORE" - )) -} - -tasks.withType { - jvmArgs("-Dnet.bytebuddy.experimental=true") - useJUnitPlatform() - testLogging { - events("passed", "skipped", "failed") - showExceptions = true - showCauses = true - showStackTraces = true - exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL - } -} - -jacoco { - toolVersion = "0.8.14" -} - -tasks.test { - configure { - excludes = listOf("sun.*", "jdk.*", "com.sun.*", "java.*", "javax.*") - } - finalizedBy(tasks.jacocoTestReport) -} - -val jacocoExclusions = listOf( - "**/entity/**", - "**/mapper/**", - "**/config/**", - "**/*Application*", - "**/generated/**", - "**/*MapperImpl*" -) - -tasks.jacocoTestReport { - dependsOn(tasks.test) - reports { - xml.required.set(true) - html.required.set(true) - } - classDirectories.setFrom(files(classDirectories.files.map { - fileTree(it) { exclude(jacocoExclusions) } - })) -} - -tasks.jacocoTestCoverageVerification { - dependsOn(tasks.jacocoTestReport) - violationRules { - rule { - limit { - minimum = "0.50".toBigDecimal() - } - } - } - classDirectories.setFrom(files(classDirectories.files.map { - fileTree(it) { exclude(jacocoExclusions) } - })) -} - -tasks.named("check") { - dependsOn( - tasks.named("integrationTest"), - tasks.named("businessTest"), - tasks.jacocoTestCoverageVerification - ) } diff --git a/merchant-iam/merchant-iam/src/integration-test/java/com/stablecoin/payments/merchant/iam/AbstractIntegrationTest.java b/merchant-iam/merchant-iam/src/integration-test/java/com/stablecoin/payments/merchant/iam/AbstractIntegrationTest.java index d3a1e80a..10d7a5c5 100644 --- a/merchant-iam/merchant-iam/src/integration-test/java/com/stablecoin/payments/merchant/iam/AbstractIntegrationTest.java +++ b/merchant-iam/merchant-iam/src/integration-test/java/com/stablecoin/payments/merchant/iam/AbstractIntegrationTest.java @@ -14,11 +14,18 @@ import org.testcontainers.containers.GenericContainer; import org.testcontainers.containers.KafkaContainer; import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.lifecycle.Startable; import org.testcontainers.utility.DockerImageName; import java.util.UUID; +import static com.stablecoin.payments.platform.test.TestContainerSupport.kafka; +import static com.stablecoin.payments.platform.test.TestContainerSupport.postgres; +import static com.stablecoin.payments.platform.test.TestContainerSupport.redis; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerKafkaProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerPostgresProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerRedisProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.startAll; + @SuppressWarnings("resource") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -27,54 +34,18 @@ @Import(TestSecurityConfig.class) public abstract class AbstractIntegrationTest { - static final PostgreSQLContainer POSTGRES = - new PostgreSQLContainer<>("postgres:16-alpine") - .withDatabaseName("s13_merchant_iam") - .withUsername("test") - .withPassword("test"); - - protected static final KafkaContainer KAFKA = - new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0")); + static final PostgreSQLContainer POSTGRES = postgres("s13_merchant_iam"); + protected static final KafkaContainer KAFKA = kafka(); /** Mailpit: SMTP on 1025, HTTP API on 8025. */ protected static final GenericContainer MAILPIT = new GenericContainer<>(DockerImageName.parse("axllent/mailpit:v1.24")) .withExposedPorts(1025, 8025); - /** Redis: default port 6379. */ - protected static final GenericContainer REDIS = - new GenericContainer<>(DockerImageName.parse("redis:7-alpine")) - .withExposedPorts(6379); + protected static final GenericContainer REDIS = redis(); static { - try { - POSTGRES.start(); - KAFKA.start(); - MAILPIT.start(); - REDIS.start(); - } catch (RuntimeException ex) { - safeStop(REDIS); - safeStop(MAILPIT); - safeStop(KAFKA); - safeStop(POSTGRES); - throw ex; - } - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - safeStop(REDIS); - safeStop(MAILPIT); - safeStop(KAFKA); - safeStop(POSTGRES); - }, "testcontainers-shutdown")); - } - - private static void safeStop(Startable container) { - try { - if (container != null) { - container.stop(); - } - } catch (Exception ignored) { - // best-effort cleanup - } + startAll(POSTGRES, KAFKA, MAILPIT, REDIS); } @Autowired @@ -103,14 +74,10 @@ protected static MockHttpServletRequestBuilder withIdempotencyKey(MockHttpServle @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); - registry.add("spring.datasource.username", POSTGRES::getUsername); - registry.add("spring.datasource.password", POSTGRES::getPassword); - registry.add("spring.kafka.bootstrap-servers", KAFKA::getBootstrapServers); - registry.add("spring.cloud.stream.kafka.binder.brokers", KAFKA::getBootstrapServers); + registerPostgresProperties(registry, POSTGRES); + registerKafkaProperties(registry, KAFKA); registry.add("spring.mail.host", MAILPIT::getHost); registry.add("spring.mail.port", () -> MAILPIT.getMappedPort(1025)); - registry.add("spring.data.redis.host", REDIS::getHost); - registry.add("spring.data.redis.port", () -> REDIS.getMappedPort(6379)); + registerRedisProperties(registry, REDIS); } } diff --git a/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/application/controller/ApiError.java b/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/application/controller/ApiError.java deleted file mode 100644 index 68a82ebd..00000000 --- a/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/application/controller/ApiError.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.stablecoin.payments.merchant.iam.application.controller; - -import java.util.List; -import java.util.Map; - -public record ApiError( - String code, - String status, - String message, - Map> errors -) { - - public static ApiError of(String code, String status, String message) { - return new ApiError(code, status, message, Map.of()); - } - - public static ApiError withErrors(String code, String status, String message, - Map> errors) { - return new ApiError(code, status, message, errors); - } -} diff --git a/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/application/controller/GlobalExceptionHandler.java b/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/application/controller/GlobalExceptionHandler.java index a3428aea..d331c8dc 100644 --- a/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/application/controller/GlobalExceptionHandler.java +++ b/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/application/controller/GlobalExceptionHandler.java @@ -12,19 +12,14 @@ import com.stablecoin.payments.merchant.iam.domain.exceptions.UserAlreadyExistsException; import com.stablecoin.payments.merchant.iam.domain.exceptions.UserNotFoundException; import com.stablecoin.payments.merchant.iam.domain.statemachine.StateMachineException; -import jakarta.validation.ConstraintViolationException; +import com.stablecoin.payments.platform.api.ApiError; +import com.stablecoin.payments.platform.infrastructure.exception.BaseGlobalExceptionHandler; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; -import org.springframework.validation.FieldError; -import org.springframework.validation.ObjectError; -import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; -import java.util.stream.Collectors; - -import static com.stablecoin.payments.merchant.iam.application.controller.ErrorCodes.BAD_REQUEST_CODE; import static com.stablecoin.payments.merchant.iam.application.controller.ErrorCodes.BUILTIN_ROLE_MODIFY_CODE; import static com.stablecoin.payments.merchant.iam.application.controller.ErrorCodes.INTERNAL_ERROR_CODE; import static com.stablecoin.payments.merchant.iam.application.controller.ErrorCodes.INVALID_CREDENTIALS_CODE; @@ -37,7 +32,6 @@ import static com.stablecoin.payments.merchant.iam.application.controller.ErrorCodes.ROLE_NOT_FOUND_CODE; import static com.stablecoin.payments.merchant.iam.application.controller.ErrorCodes.USER_ALREADY_EXISTS_CODE; import static com.stablecoin.payments.merchant.iam.application.controller.ErrorCodes.USER_NOT_FOUND_CODE; -import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.CONFLICT; import static org.springframework.http.HttpStatus.FORBIDDEN; import static org.springframework.http.HttpStatus.GONE; @@ -48,36 +42,13 @@ @Slf4j @RestControllerAdvice -public class GlobalExceptionHandler { - - // ── Validation ─────────────────────────────────────────────────────────── - - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(MethodArgumentNotValidException.class) - public ApiError handleValidation(MethodArgumentNotValidException ex) { - var errors = ex.getBindingResult().getFieldErrors().stream() - .collect(Collectors.groupingBy( - FieldError::getField, - Collectors.mapping(ObjectError::getDefaultMessage, Collectors.toList()))); - log.info("Validation failed: {}", errors); - return ApiError.withErrors(BAD_REQUEST_CODE, BAD_REQUEST.getReasonPhrase(), - "Invalid request content", errors); - } +public class GlobalExceptionHandler extends BaseGlobalExceptionHandler { - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(ConstraintViolationException.class) - public ApiError handleConstraintViolation(ConstraintViolationException ex) { - var errors = ex.getConstraintViolations().stream() - .collect(Collectors.groupingBy( - v -> v.getPropertyPath().toString(), - Collectors.mapping(v -> v.getMessage(), Collectors.toList()))); - log.info("Constraint violation: {}", errors); - return ApiError.withErrors(BAD_REQUEST_CODE, BAD_REQUEST.getReasonPhrase(), - "Invalid request content", errors); + @Override + protected String errorCodePrefix() { + return "IAM"; } - // ── 401 ────────────────────────────────────────────────────────────────── - @ResponseStatus(UNAUTHORIZED) @ExceptionHandler(InvalidCredentialsException.class) public ApiError handleInvalidCredentials(InvalidCredentialsException ex) { @@ -92,8 +63,6 @@ public ApiError handleMfaRequired(MfaRequiredException ex) { return ApiError.of(MFA_REQUIRED_CODE, UNPROCESSABLE_ENTITY.getReasonPhrase(), ex.getMessage()); } - // ── 403 ────────────────────────────────────────────────────────────────── - @ResponseStatus(FORBIDDEN) @ExceptionHandler(BuiltInRoleModificationException.class) public ApiError handleBuiltInRoleModification(BuiltInRoleModificationException ex) { @@ -108,8 +77,6 @@ public ApiError handleLastAdmin(LastAdminException ex) { return ApiError.of(LAST_ADMIN_CODE, FORBIDDEN.getReasonPhrase(), ex.getMessage()); } - // ── 404 ────────────────────────────────────────────────────────────────── - @ResponseStatus(NOT_FOUND) @ExceptionHandler(UserNotFoundException.class) public ApiError handleUserNotFound(UserNotFoundException ex) { @@ -131,8 +98,6 @@ public ApiError handleInvitationNotFound(InvitationNotFoundException ex) { return ApiError.of(INVITATION_NOT_FOUND_CODE, NOT_FOUND.getReasonPhrase(), ex.getMessage()); } - // ── 409 / 410 ──────────────────────────────────────────────────────────── - @ResponseStatus(CONFLICT) @ExceptionHandler(UserAlreadyExistsException.class) public ApiError handleUserAlreadyExists(UserAlreadyExistsException ex) { @@ -154,8 +119,6 @@ public ApiError handleInvitationExpired(InvitationExpiredException ex) { return ApiError.of(INVITATION_EXPIRED_CODE, GONE.getReasonPhrase(), ex.getMessage()); } - // ── 422 ────────────────────────────────────────────────────────────────── - @ResponseStatus(UNPROCESSABLE_ENTITY) @ExceptionHandler({InvalidUserStateException.class, StateMachineException.class}) public ApiError handleInvalidState(RuntimeException ex) { @@ -163,8 +126,7 @@ public ApiError handleInvalidState(RuntimeException ex) { return ApiError.of(INVALID_USER_STATE_CODE, UNPROCESSABLE_ENTITY.getReasonPhrase(), ex.getMessage()); } - // ── 500 ────────────────────────────────────────────────────────────────── - + @Override @ResponseStatus(INTERNAL_SERVER_ERROR) @ExceptionHandler(Exception.class) public ApiError handleUnexpected(Exception ex) { diff --git a/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/infrastructure/messaging/MerchantIamOutboxHandler.java b/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/infrastructure/messaging/MerchantIamOutboxHandler.java index 01433c64..d097c6d8 100644 --- a/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/infrastructure/messaging/MerchantIamOutboxHandler.java +++ b/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/infrastructure/messaging/MerchantIamOutboxHandler.java @@ -1,40 +1,13 @@ package com.stablecoin.payments.merchant.iam.infrastructure.messaging; -import io.namastack.outbox.annotation.OutboxHandler; -import io.namastack.outbox.handler.OutboxRecordMetadata; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import com.stablecoin.payments.platform.infrastructure.messaging.AbstractOutboxHandler; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; -import java.util.concurrent.TimeUnit; - -@Slf4j @Component -@RequiredArgsConstructor -public class MerchantIamOutboxHandler { - - private final KafkaTemplate kafkaTemplate; - - @OutboxHandler - public void handle(Object event, OutboxRecordMetadata metadata) { - var topic = resolveField(event, "TOPIC"); - var key = metadata.getKey(); - try { - kafkaTemplate.send(topic, key, event).get(10, TimeUnit.SECONDS); - log.debug("Published outbox event type={} topic={} key={}", event.getClass().getSimpleName(), topic, key); - } catch (Exception e) { - log.error("Failed to publish event type={} topic={}: {}", event.getClass().getSimpleName(), topic, e.getMessage()); - throw new RuntimeException("Kafka send failed for event " + event.getClass().getSimpleName(), e); - } - } +public class MerchantIamOutboxHandler extends AbstractOutboxHandler { - private String resolveField(Object event, String fieldName) { - try { - return (String) event.getClass().getField(fieldName).get(null); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new IllegalArgumentException( - "Event class missing static " + fieldName + " field: " + event.getClass().getName(), e); - } + public MerchantIamOutboxHandler(KafkaTemplate kafkaTemplate) { + super(kafkaTemplate); } } diff --git a/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/infrastructure/messaging/OutboxEventPublisher.java b/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/infrastructure/messaging/OutboxEventPublisher.java index aaec9ae3..8243449d 100644 --- a/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/infrastructure/messaging/OutboxEventPublisher.java +++ b/merchant-iam/merchant-iam/src/main/java/com/stablecoin/payments/merchant/iam/infrastructure/messaging/OutboxEventPublisher.java @@ -1,36 +1,17 @@ package com.stablecoin.payments.merchant.iam.infrastructure.messaging; import com.stablecoin.payments.merchant.iam.domain.EventPublisher; +import com.stablecoin.payments.platform.infrastructure.messaging.AbstractOutboxEventPublisher; import io.namastack.outbox.Outbox; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -@Slf4j -@Component -@RequiredArgsConstructor -public class OutboxEventPublisher implements EventPublisher { - - private final Outbox outbox; +import java.util.List; - @Override - @Transactional(propagation = Propagation.MANDATORY) - public void publish(Object event) { - var key = resolveField(event, "merchantId"); - outbox.schedule(event, key); - log.debug("Scheduled outbox event type={} key={}", event.getClass().getSimpleName(), key); - } +@Component +public class OutboxEventPublisher extends AbstractOutboxEventPublisher + implements EventPublisher { - private String resolveField(Object event, String fieldName) { - try { - var method = event.getClass().getMethod(fieldName); - return String.valueOf(method.invoke(event)); - } catch (Exception e) { - throw new IllegalArgumentException( - "Event class missing accessor for field '" + fieldName + "': " - + event.getClass().getName(), e); - } + public OutboxEventPublisher(Outbox outbox) { + super(outbox, List.of("merchantId")); } } diff --git a/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/arch/ArchitectureTest.java b/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/arch/ArchitectureTest.java index d6745686..17776351 100644 --- a/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/arch/ArchitectureTest.java +++ b/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/arch/ArchitectureTest.java @@ -1,56 +1,64 @@ package com.stablecoin.payments.merchant.iam.arch; +import com.stablecoin.payments.platform.test.HexagonalArchitectureRules; +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; import com.tngtech.archunit.core.importer.ImportOption; -import com.tngtech.archunit.junit.AnalyzeClasses; -import com.tngtech.archunit.junit.ArchTest; -import com.tngtech.archunit.lang.ArchRule; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; - -@AnalyzeClasses( - packages = "com.stablecoin.payments.merchant.iam", - importOptions = ImportOption.DoNotIncludeTests.class) +@DisplayName("Architecture Rules") class ArchitectureTest { - @ArchTest - static final ArchRule domain_must_not_depend_on_infrastructure = noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("..infrastructure..") - .allowEmptyShould(true); - - @ArchTest - static final ArchRule domain_must_not_depend_on_application = noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("..application..") - .allowEmptyShould(true); - - @ArchTest - static final ArchRule domain_must_not_import_spring_web = noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("org.springframework.web..") - .allowEmptyShould(true); - - @ArchTest - static final ArchRule domain_must_not_import_spring_data = noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("org.springframework.data..") - .allowEmptyShould(true); - - @ArchTest - static final ArchRule domain_must_not_import_spring_security = noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("org.springframework.security..") - .allowEmptyShould(true); - - @ArchTest - static final ArchRule domain_must_not_import_jpa = noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("jakarta.persistence..") - .allowEmptyShould(true); - - @ArchTest - static final ArchRule infrastructure_must_not_depend_on_application_controller = noClasses() - .that().resideInAPackage("..infrastructure..") - .should().dependOnClassesThat().resideInAPackage("..application.controller..") - .allowEmptyShould(true); + private static JavaClasses importedClasses; + + @BeforeAll + static void setUp() { + importedClasses = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages("com.stablecoin.payments.merchant.iam"); + } + + @Test + @DisplayName("Domain should not depend on Spring (except stereotype and transaction)") + void domainShouldNotDependOnSpring() { + HexagonalArchitectureRules.domainShouldNotDependOnSpring().check(importedClasses); + } + + @Test + @DisplayName("Domain should not depend on infrastructure") + void domainShouldNotDependOnInfrastructure() { + HexagonalArchitectureRules.domainShouldNotDependOnInfrastructure().check(importedClasses); + } + + @Test + @DisplayName("Domain should not depend on application layer") + void domainShouldNotDependOnApplication() { + HexagonalArchitectureRules.domainShouldNotDependOnApplication().check(importedClasses); + } + + @Test + @DisplayName("Infrastructure should not depend on application controller") + void infrastructureShouldNotDependOnController() { + HexagonalArchitectureRules.infrastructureShouldNotDependOnController().check(importedClasses); + } + + @Test + @DisplayName("Ports should be interfaces") + void portsShouldBeInterfaces() { + HexagonalArchitectureRules.portsShouldBeInterfaces().check(importedClasses); + } + + @Test + @DisplayName("Domain events should be records") + void domainEventsShouldBeRecords() { + HexagonalArchitectureRules.domainEventsShouldBeRecords().check(importedClasses); + } + + @Test + @DisplayName("Controllers should reside in application.controller package") + void controllersShouldResideInApplicationController() { + HexagonalArchitectureRules.controllersShouldResideInApplicationController().check(importedClasses); + } } diff --git a/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/AuthServiceTest.java b/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/AuthServiceTest.java index cbeccfa9..bd170baa 100644 --- a/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/AuthServiceTest.java +++ b/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/AuthServiceTest.java @@ -20,7 +20,7 @@ import java.util.Optional; import java.util.UUID; -import static com.stablecoin.payments.merchant.iam.fixtures.TestUtils.eqIgnoringTimestamps; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyBoolean; diff --git a/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/MerchantTeamServiceTest.java b/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/MerchantTeamServiceTest.java index 0a6d6e67..8004f756 100644 --- a/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/MerchantTeamServiceTest.java +++ b/merchant-iam/merchant-iam/src/test/java/com/stablecoin/payments/merchant/iam/domain/team/MerchantTeamServiceTest.java @@ -26,8 +26,8 @@ import java.util.Optional; import java.util.UUID; -import static com.stablecoin.payments.merchant.iam.fixtures.TestUtils.eqIgnoring; -import static com.stablecoin.payments.merchant.iam.fixtures.TestUtils.eqIgnoringTimestamps; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; diff --git a/merchant-iam/merchant-iam/src/testFixtures/java/com/stablecoin/payments/merchant/iam/fixtures/TestUtils.java b/merchant-iam/merchant-iam/src/testFixtures/java/com/stablecoin/payments/merchant/iam/fixtures/TestUtils.java deleted file mode 100644 index 8094b12d..00000000 --- a/merchant-iam/merchant-iam/src/testFixtures/java/com/stablecoin/payments/merchant/iam/fixtures/TestUtils.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.stablecoin.payments.merchant.iam.fixtures; - -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.ZonedDateTime; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.argThat; - -public final class TestUtils { - - private TestUtils() {} - - public static T eqIgnoringTimestamps(T expected) { - return eqIgnoring(expected); - } - - public static T eqIgnoring(T expected, String... fieldsToIgnore) { - return argThat(it -> isEqualIgnoring(it, expected, fieldsToIgnore)); - } - - private static boolean isEqualIgnoring(T original, T expected, String... fieldsToIgnore) { - try { - assertThat(original) - .usingRecursiveComparison() - .ignoringFieldsOfTypes(ZonedDateTime.class, LocalDateTime.class, LocalDate.class, Instant.class) - .ignoringFields(fieldsToIgnore) - .isEqualTo(expected); - return true; - } catch (Throwable t) { - return false; - } - } -} diff --git a/merchant-onboarding/merchant-onboarding-api/build.gradle.kts b/merchant-onboarding/merchant-onboarding-api/build.gradle.kts index 6c562491..97d4baa4 100644 --- a/merchant-onboarding/merchant-onboarding-api/build.gradle.kts +++ b/merchant-onboarding/merchant-onboarding-api/build.gradle.kts @@ -1,8 +1,3 @@ plugins { - `java-library` -} - -dependencies { - api("jakarta.validation:jakarta.validation-api") - api("com.fasterxml.jackson.core:jackson-annotations") + id("stablebridge.api-library") } diff --git a/merchant-onboarding/merchant-onboarding-client/build.gradle.kts b/merchant-onboarding/merchant-onboarding-client/build.gradle.kts index 197db2b7..c055c5a1 100644 --- a/merchant-onboarding/merchant-onboarding-client/build.gradle.kts +++ b/merchant-onboarding/merchant-onboarding-client/build.gradle.kts @@ -1,8 +1,7 @@ plugins { - `java-library` + id("stablebridge.client-library") } dependencies { api(project(":merchant-onboarding:merchant-onboarding-api")) - api("org.springframework.cloud:spring-cloud-starter-openfeign") } diff --git a/merchant-onboarding/merchant-onboarding/build.gradle.kts b/merchant-onboarding/merchant-onboarding/build.gradle.kts index f086f953..dc56faf7 100644 --- a/merchant-onboarding/merchant-onboarding/build.gradle.kts +++ b/merchant-onboarding/merchant-onboarding/build.gradle.kts @@ -1,204 +1,19 @@ plugins { - id("org.springframework.boot") - id("com.google.cloud.tools.jib") - java - `java-test-fixtures` - jacoco + id("stablebridge.service") } -jib { - from { - image = "docker://eclipse-temurin:25-jre" - } - to { - image = "stablebridge/merchant-onboarding" - tags = setOf("latest") - } - container { - creationTime.set("USE_CURRENT_TIMESTAMP") - } -} - -val integrationTestSourceSet: SourceSet = sourceSets.create("integrationTest") { - java.srcDir("src/integration-test/java") - resources.srcDir("src/integration-test/resources") - compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output - runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output -} - -configurations { - named("integrationTestImplementation") { extendsFrom(configurations.testImplementation.get()) } - named("integrationTestRuntimeOnly") { extendsFrom(configurations.testRuntimeOnly.get()) } -} - -tasks.register("integrationTest") { - testClassesDirs = integrationTestSourceSet.output.classesDirs - classpath = integrationTestSourceSet.runtimeClasspath - shouldRunAfter(tasks.test) - configure { isEnabled = false } -} - -val businessTestSourceSet: SourceSet = sourceSets.create("businessTest") { - java.srcDir("src/business-test/java") - resources.srcDir("src/business-test/resources") - compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output + integrationTestSourceSet.output - runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output + integrationTestSourceSet.output -} - -configurations { - named("businessTestImplementation") { extendsFrom(configurations.named("integrationTestImplementation").get()) } - named("businessTestRuntimeOnly") { extendsFrom(configurations.named("integrationTestRuntimeOnly").get()) } -} +val temporalVersion: String by project -tasks.register("businessTest") { - testClassesDirs = businessTestSourceSet.output.classesDirs - classpath = businessTestSourceSet.runtimeClasspath - shouldRunAfter(tasks.named("integrationTest")) - configure { isEnabled = false } +stablebridge { + jibImageName.set("stablebridge/merchant-onboarding") } -val lombokVersion: String by project -val mapstructVersion: String by project -val lombokMapstructBindingVersion: String by project -val resilience4jVersion: String by project -val temporalVersion: String by project -val flywayVersion: String by project -val archunitVersion: String by project -val testcontainersVersion: String by project -val wiremockVersion: String by project -val springdocVersion: String by project - dependencies { implementation(project(":merchant-onboarding:merchant-onboarding-api")) - // Spring Boot - implementation("org.springframework.boot:spring-boot-starter-web") - implementation("org.springframework.boot:spring-boot-starter-validation") - implementation("org.springframework.boot:spring-boot-starter-data-jpa") - implementation("org.springframework.boot:spring-boot-starter-actuator") - - // OpenAPI / Swagger UI - implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$springdocVersion") - runtimeOnly("io.micrometer:micrometer-registry-prometheus") - implementation("io.micrometer:micrometer-tracing-bridge-otel") - implementation("io.opentelemetry:opentelemetry-exporter-otlp") - implementation("org.springframework.boot:spring-boot-starter-security") - - // Kafka via Spring Cloud Stream - implementation("org.springframework.cloud:spring-cloud-stream") - implementation("org.springframework.cloud:spring-cloud-stream-binder-kafka") - implementation("org.springframework.kafka:spring-kafka") - - // Feign - implementation("org.springframework.cloud:spring-cloud-starter-openfeign") - - // Resilience4j - implementation("io.github.resilience4j:resilience4j-spring-boot3:$resilience4jVersion") - implementation("io.github.resilience4j:resilience4j-circuitbreaker:$resilience4jVersion") - // Temporal implementation("io.temporal:temporal-spring-boot-starter:$temporalVersion") - // MapStruct (compiler args set below in JavaCompile task) - implementation("org.mapstruct:mapstruct:$mapstructVersion") - annotationProcessor("org.mapstruct:mapstruct-processor:$mapstructVersion") - annotationProcessor("org.projectlombok:lombok-mapstruct-binding:$lombokMapstructBindingVersion") - - // Outbox (namastack) - implementation("io.namastack:namastack-outbox-starter-jdbc:1.1.0") - - // Database - runtimeOnly("org.postgresql:postgresql") - implementation("org.springframework.boot:spring-boot-starter-flyway") - implementation("org.flywaydb:flyway-database-postgresql:$flywayVersion") - // hypersistence-utils removed: using native Hibernate 7 @JdbcTypeCode(SqlTypes.JSON) for JSONB - - // Test Fixtures - testFixturesImplementation("org.assertj:assertj-core") - testFixturesImplementation("org.mockito:mockito-core") - // Test - testImplementation("org.springframework.boot:spring-boot-starter-test") - testImplementation("org.springframework.kafka:spring-kafka-test") - testImplementation("com.tngtech.archunit:archunit-junit5:$archunitVersion") testImplementation("io.temporal:temporal-testing:$temporalVersion") - "integrationTestImplementation"(testFixtures(project)) - "integrationTestImplementation"("org.testcontainers:postgresql:$testcontainersVersion") - "integrationTestImplementation"("org.testcontainers:kafka:$testcontainersVersion") - "integrationTestImplementation"("org.testcontainers:junit-jupiter:$testcontainersVersion") - "integrationTestImplementation"("org.wiremock:wiremock-standalone:$wiremockVersion") - "integrationTestImplementation"("org.springframework.boot:spring-boot-starter-webmvc-test") - "integrationTestImplementation"("org.springframework.boot:spring-boot-starter-security-test") -} - -tasks.withType { - options.compilerArgs.addAll(listOf( - "-Amapstruct.defaultComponentModel=spring", - "-Amapstruct.unmappedTargetPolicy=IGNORE" - )) -} - -tasks.withType { - jvmArgs("-Dnet.bytebuddy.experimental=true") - useJUnitPlatform() - testLogging { - events("passed", "skipped", "failed") - showExceptions = true - showCauses = true - showStackTraces = true - exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL - } -} - -jacoco { - toolVersion = "0.8.14" -} - -tasks.test { - configure { - excludes = listOf("sun.*", "jdk.*", "com.sun.*", "java.*", "javax.*") - } - finalizedBy(tasks.jacocoTestReport) -} - -val jacocoExclusions = listOf( - "**/entity/**", - "**/mapper/**", - "**/config/**", - "**/*Application*", - "**/generated/**", - "**/*MapperImpl*" -) - -tasks.jacocoTestReport { - dependsOn(tasks.test) - reports { - xml.required.set(true) - html.required.set(true) - } - classDirectories.setFrom(files(classDirectories.files.map { - fileTree(it) { exclude(jacocoExclusions) } - })) -} - -tasks.jacocoTestCoverageVerification { - dependsOn(tasks.jacocoTestReport) - violationRules { - rule { - limit { - minimum = "0.50".toBigDecimal() - } - } - } - classDirectories.setFrom(files(classDirectories.files.map { - fileTree(it) { exclude(jacocoExclusions) } - })) -} - -tasks.named("check") { - dependsOn( - tasks.named("integrationTest"), - tasks.named("businessTest"), - tasks.jacocoTestCoverageVerification - ) } diff --git a/merchant-onboarding/merchant-onboarding/src/integration-test/java/com/stablecoin/payments/merchant/onboarding/AbstractIntegrationTest.java b/merchant-onboarding/merchant-onboarding/src/integration-test/java/com/stablecoin/payments/merchant/onboarding/AbstractIntegrationTest.java index c66f6e03..0dc9ff85 100644 --- a/merchant-onboarding/merchant-onboarding/src/integration-test/java/com/stablecoin/payments/merchant/onboarding/AbstractIntegrationTest.java +++ b/merchant-onboarding/merchant-onboarding/src/integration-test/java/com/stablecoin/payments/merchant/onboarding/AbstractIntegrationTest.java @@ -1,7 +1,7 @@ package com.stablecoin.payments.merchant.onboarding; -import com.stablecoin.payments.merchant.onboarding.config.TestSecurityConfig; import com.stablecoin.payments.merchant.onboarding.config.TestTemporalConfig; +import com.stablecoin.payments.platform.test.TestSecurityConfig; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.context.annotation.Import; @@ -10,8 +10,12 @@ import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.KafkaContainer; import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.lifecycle.Startable; -import org.testcontainers.utility.DockerImageName; + +import static com.stablecoin.payments.platform.test.TestContainerSupport.kafka; +import static com.stablecoin.payments.platform.test.TestContainerSupport.postgres; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerKafkaProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerPostgresProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.startAll; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @ActiveProfiles("integration-test") @@ -19,46 +23,16 @@ @Import({TestSecurityConfig.class, TestTemporalConfig.class}) public abstract class AbstractIntegrationTest { - static final PostgreSQLContainer POSTGRES = - new PostgreSQLContainer<>("postgres:16-alpine") - .withDatabaseName("merchant_onboarding") - .withUsername("test") - .withPassword("test"); - - protected static final KafkaContainer KAFKA = - new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0")); + static final PostgreSQLContainer POSTGRES = postgres("s11_merchant_onboarding"); + protected static final KafkaContainer KAFKA = kafka(); static { - try { - POSTGRES.start(); - KAFKA.start(); - } catch (RuntimeException ex) { - safeStop(KAFKA); - safeStop(POSTGRES); - throw ex; - } - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - safeStop(KAFKA); - safeStop(POSTGRES); - }, "testcontainers-shutdown")); - } - - private static void safeStop(Startable container) { - try { - if (container != null) { - container.stop(); - } - } catch (Exception ignored) { - // best-effort cleanup - } + startAll(POSTGRES, KAFKA); } @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); - registry.add("spring.datasource.username", POSTGRES::getUsername); - registry.add("spring.datasource.password", POSTGRES::getPassword); - registry.add("spring.kafka.bootstrap-servers", KAFKA::getBootstrapServers); - registry.add("spring.cloud.stream.kafka.binder.brokers", KAFKA::getBootstrapServers); + registerPostgresProperties(registry, POSTGRES); + registerKafkaProperties(registry, KAFKA); } } diff --git a/merchant-onboarding/merchant-onboarding/src/integration-test/java/com/stablecoin/payments/merchant/onboarding/config/TestSecurityConfig.java b/merchant-onboarding/merchant-onboarding/src/integration-test/java/com/stablecoin/payments/merchant/onboarding/config/TestSecurityConfig.java deleted file mode 100644 index 662af7bf..00000000 --- a/merchant-onboarding/merchant-onboarding/src/integration-test/java/com/stablecoin/payments/merchant/onboarding/config/TestSecurityConfig.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.stablecoin.payments.merchant.onboarding.config; - -import org.springframework.boot.test.context.TestConfiguration; -import org.springframework.context.annotation.Bean; -import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; -import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.web.SecurityFilterChain; - -@TestConfiguration -@EnableMethodSecurity -public class TestSecurityConfig { - - @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http - .csrf(csrf -> csrf.disable()) - .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()); - return http.build(); - } -} diff --git a/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/controller/ApiError.java b/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/controller/ApiError.java deleted file mode 100644 index a0f4bee3..00000000 --- a/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/controller/ApiError.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.stablecoin.payments.merchant.onboarding.application.controller; - -import java.util.List; -import java.util.Map; - -public record ApiError( - String code, - String status, - String message, - Map> errors -) { - - public static ApiError of(String code, String status, String message) { - return new ApiError(code, status, message, Map.of()); - } - - public static ApiError withErrors(String code, String status, String message, - Map> errors) { - return new ApiError(code, status, message, errors); - } -} diff --git a/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/controller/GlobalExceptionHandler.java b/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/controller/GlobalExceptionHandler.java index 4ea27f6c..74b0d2d8 100644 --- a/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/controller/GlobalExceptionHandler.java +++ b/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/application/controller/GlobalExceptionHandler.java @@ -4,24 +4,18 @@ import com.stablecoin.payments.merchant.onboarding.domain.exceptions.MerchantAlreadyExistsException; import com.stablecoin.payments.merchant.onboarding.domain.exceptions.MerchantNotFoundException; import com.stablecoin.payments.merchant.onboarding.domain.statemachine.StateMachineException; -import jakarta.validation.ConstraintViolationException; +import com.stablecoin.payments.platform.api.ApiError; +import com.stablecoin.payments.platform.infrastructure.exception.BaseGlobalExceptionHandler; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; -import org.springframework.validation.FieldError; -import org.springframework.validation.ObjectError; -import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; -import java.util.stream.Collectors; - -import static com.stablecoin.payments.merchant.onboarding.application.controller.ErrorCodes.BAD_REQUEST_CODE; import static com.stablecoin.payments.merchant.onboarding.application.controller.ErrorCodes.INTERNAL_ERROR_CODE; import static com.stablecoin.payments.merchant.onboarding.application.controller.ErrorCodes.INVALID_STATE_CODE; import static com.stablecoin.payments.merchant.onboarding.application.controller.ErrorCodes.MERCHANT_ALREADY_EXISTS_CODE; import static com.stablecoin.payments.merchant.onboarding.application.controller.ErrorCodes.MERCHANT_NOT_FOUND_CODE; -import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.CONFLICT; import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; import static org.springframework.http.HttpStatus.NOT_FOUND; @@ -29,43 +23,13 @@ @Slf4j @RestControllerAdvice -public class GlobalExceptionHandler { - - // ── Validation ─────────────────────────────────────────────────────────── - - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(MethodArgumentNotValidException.class) - public ApiError handleValidation(MethodArgumentNotValidException ex) { - var errors = ex.getBindingResult().getFieldErrors().stream() - .collect(Collectors.groupingBy( - FieldError::getField, - Collectors.mapping(ObjectError::getDefaultMessage, Collectors.toList()))); - log.info("Validation failed: {}", errors); - return ApiError.withErrors(BAD_REQUEST_CODE, BAD_REQUEST.getReasonPhrase(), - "Invalid request content", errors); - } - - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(ConstraintViolationException.class) - public ApiError handleConstraintViolation(ConstraintViolationException ex) { - var errors = ex.getConstraintViolations().stream() - .collect(Collectors.groupingBy( - v -> v.getPropertyPath().toString(), - Collectors.mapping(v -> v.getMessage(), Collectors.toList()))); - log.info("Constraint violation: {}", errors); - return ApiError.withErrors(BAD_REQUEST_CODE, BAD_REQUEST.getReasonPhrase(), - "Invalid request content", errors); - } +public class GlobalExceptionHandler extends BaseGlobalExceptionHandler { - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(IllegalArgumentException.class) - public ApiError handleIllegalArgument(IllegalArgumentException ex) { - log.info("Illegal argument: {}", ex.getMessage()); - return ApiError.of(BAD_REQUEST_CODE, BAD_REQUEST.getReasonPhrase(), ex.getMessage()); + @Override + protected String errorCodePrefix() { + return "MO"; } - // ── 404 ────────────────────────────────────────────────────────────────── - @ResponseStatus(NOT_FOUND) @ExceptionHandler(MerchantNotFoundException.class) public ApiError handleNotFound(MerchantNotFoundException ex) { @@ -73,8 +37,6 @@ public ApiError handleNotFound(MerchantNotFoundException ex) { return ApiError.of(MERCHANT_NOT_FOUND_CODE, NOT_FOUND.getReasonPhrase(), ex.getMessage()); } - // ── 409 ────────────────────────────────────────────────────────────────── - @ResponseStatus(CONFLICT) @ExceptionHandler(MerchantAlreadyExistsException.class) public ApiError handleAlreadyExists(MerchantAlreadyExistsException ex) { @@ -82,18 +44,14 @@ public ApiError handleAlreadyExists(MerchantAlreadyExistsException ex) { return ApiError.of(MERCHANT_ALREADY_EXISTS_CODE, CONFLICT.getReasonPhrase(), ex.getMessage()); } - // ── 422 ────────────────────────────────────────────────────────────────── - @ResponseStatus(UNPROCESSABLE_ENTITY) - @ExceptionHandler({InvalidMerchantStateException.class, StateMachineException.class, - IllegalStateException.class}) - public ApiError handleInvalidState(RuntimeException ex) { + @ExceptionHandler({InvalidMerchantStateException.class, StateMachineException.class}) + public ApiError handleInvalidMerchantState(RuntimeException ex) { log.info("Invalid merchant state: {}", ex.getMessage()); return ApiError.of(INVALID_STATE_CODE, UNPROCESSABLE_ENTITY.getReasonPhrase(), ex.getMessage()); } - // ── 500 ────────────────────────────────────────────────────────────────── - + @Override @ResponseStatus(INTERNAL_SERVER_ERROR) @ExceptionHandler(Exception.class) public ApiError handleUnexpected(Exception ex) { diff --git a/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/infrastructure/messaging/OnboardingOutboxHandler.java b/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/infrastructure/messaging/OnboardingOutboxHandler.java index 5e7fcf3f..307eec0e 100644 --- a/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/infrastructure/messaging/OnboardingOutboxHandler.java +++ b/merchant-onboarding/merchant-onboarding/src/main/java/com/stablecoin/payments/merchant/onboarding/infrastructure/messaging/OnboardingOutboxHandler.java @@ -1,42 +1,13 @@ package com.stablecoin.payments.merchant.onboarding.infrastructure.messaging; -import io.namastack.outbox.annotation.OutboxHandler; -import io.namastack.outbox.handler.OutboxRecordMetadata; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import com.stablecoin.payments.platform.infrastructure.messaging.AbstractOutboxHandler; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; -import java.util.concurrent.TimeUnit; - -@Slf4j @Component -@RequiredArgsConstructor -public class OnboardingOutboxHandler { - - private final KafkaTemplate kafkaTemplate; - - @OutboxHandler - public void handle(Object event, OutboxRecordMetadata metadata) { - var topic = resolveField(event, "TOPIC"); - var key = metadata.getKey(); - try { - kafkaTemplate.send(topic, key, event).get(10, TimeUnit.SECONDS); - log.debug("Published outbox event type={} topic={} key={}", - event.getClass().getSimpleName(), topic, key); - } catch (Exception e) { - log.error("Failed to publish event type={} topic={}: {}", - event.getClass().getSimpleName(), topic, e.getMessage()); - throw new RuntimeException("Kafka send failed for event " + event.getClass().getSimpleName(), e); - } - } +public class OnboardingOutboxHandler extends AbstractOutboxHandler { - private String resolveField(Object event, String fieldName) { - try { - return (String) event.getClass().getField(fieldName).get(null); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new IllegalArgumentException( - "Event class missing static " + fieldName + " field: " + event.getClass().getName(), e); - } + public OnboardingOutboxHandler(KafkaTemplate kafkaTemplate) { + super(kafkaTemplate); } } diff --git a/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/arch/ArchitectureTest.java b/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/arch/ArchitectureTest.java index cebcb944..a10ab9bc 100644 --- a/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/arch/ArchitectureTest.java +++ b/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/arch/ArchitectureTest.java @@ -1,80 +1,64 @@ package com.stablecoin.payments.merchant.onboarding.arch; +import com.stablecoin.payments.platform.test.HexagonalArchitectureRules; import com.tngtech.archunit.core.domain.JavaClasses; import com.tngtech.archunit.core.importer.ClassFileImporter; import com.tngtech.archunit.core.importer.ImportOption; -import com.tngtech.archunit.lang.ArchRule; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; -import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; - -@DisplayName("Architecture rules") +@DisplayName("Architecture Rules") class ArchitectureTest { - private static final String BASE = "com.stablecoin.payments.merchant.onboarding"; - private static JavaClasses classes; + private static JavaClasses importedClasses; @BeforeAll - static void importClasses() { - classes = new ClassFileImporter() + static void setUp() { + importedClasses = new ClassFileImporter() .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) - .importPackages(BASE); + .importPackages("com.stablecoin.payments.merchant.onboarding"); + } + + @Test + @DisplayName("Domain should not depend on Spring (except stereotype and transaction)") + void domainShouldNotDependOnSpring() { + HexagonalArchitectureRules.domainShouldNotDependOnSpring().check(importedClasses); + } + + @Test + @DisplayName("Domain should not depend on infrastructure") + void domainShouldNotDependOnInfrastructure() { + HexagonalArchitectureRules.domainShouldNotDependOnInfrastructure().check(importedClasses); } @Test - @DisplayName("domain must not depend on infrastructure") - void domainMustNotDependOnInfrastructure() { - ArchRule rule = noClasses() - .that().resideInAPackage(BASE + ".domain..") - .should().dependOnClassesThat() - .resideInAPackage(BASE + ".infrastructure.."); - rule.check(classes); + @DisplayName("Domain should not depend on application layer") + void domainShouldNotDependOnApplication() { + HexagonalArchitectureRules.domainShouldNotDependOnApplication().check(importedClasses); } @Test - @DisplayName("domain must not depend on application layer") - void domainMustNotDependOnApplication() { - ArchRule rule = noClasses() - .that().resideInAPackage(BASE + ".domain..") - .should().dependOnClassesThat() - .resideInAPackage(BASE + ".application.."); - rule.check(classes); + @DisplayName("Infrastructure should not depend on application controller") + void infrastructureShouldNotDependOnController() { + HexagonalArchitectureRules.infrastructureShouldNotDependOnController().check(importedClasses); } @Test - @DisplayName("domain must not import Spring Framework classes except stereotype annotations") - void domainMustNotImportSpring() { - ArchRule rule = noClasses() - .that().resideInAPackage(BASE + ".domain..") - .should().dependOnClassesThat( - resideInAPackage("org.springframework..") - .and(resideOutsideOfPackage("org.springframework.stereotype..")) - .and(resideOutsideOfPackage("org.springframework.transaction..")) - ); - rule.check(classes); + @DisplayName("Ports should be interfaces") + void portsShouldBeInterfaces() { + HexagonalArchitectureRules.portsShouldBeInterfaces().check(importedClasses); } @Test - @DisplayName("domain must not import Jakarta Persistence classes") - void domainMustNotImportJpa() { - ArchRule rule = noClasses() - .that().resideInAPackage(BASE + ".domain..") - .should().dependOnClassesThat() - .resideInAPackage("jakarta.persistence.."); - rule.check(classes); + @DisplayName("Domain events should be records") + void domainEventsShouldBeRecords() { + HexagonalArchitectureRules.domainEventsShouldBeRecords().check(importedClasses); } @Test - @DisplayName("infrastructure must not depend on application controller") - void infrastructureMustNotDependOnApplicationController() { - ArchRule rule = noClasses() - .that().resideInAPackage(BASE + ".infrastructure..") - .should().dependOnClassesThat() - .resideInAPackage(BASE + ".application.controller.."); - rule.check(classes); + @DisplayName("Controllers should reside in application.controller package") + void controllersShouldResideInApplicationController() { + HexagonalArchitectureRules.controllersShouldResideInApplicationController().check(importedClasses); } } diff --git a/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/domain/merchant/MerchantCommandHandlerTest.java b/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/domain/merchant/MerchantCommandHandlerTest.java index a5323533..30137512 100644 --- a/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/domain/merchant/MerchantCommandHandlerTest.java +++ b/merchant-onboarding/merchant-onboarding/src/test/java/com/stablecoin/payments/merchant/onboarding/domain/merchant/MerchantCommandHandlerTest.java @@ -25,8 +25,8 @@ import java.util.Optional; import java.util.UUID; -import static com.stablecoin.payments.merchant.onboarding.fixtures.TestUtils.eqIgnoring; -import static com.stablecoin.payments.merchant.onboarding.fixtures.TestUtils.eqIgnoringTimestamps; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; diff --git a/merchant-onboarding/merchant-onboarding/src/testFixtures/java/com/stablecoin/payments/merchant/onboarding/fixtures/TestUtils.java b/merchant-onboarding/merchant-onboarding/src/testFixtures/java/com/stablecoin/payments/merchant/onboarding/fixtures/TestUtils.java deleted file mode 100644 index f98d7c59..00000000 --- a/merchant-onboarding/merchant-onboarding/src/testFixtures/java/com/stablecoin/payments/merchant/onboarding/fixtures/TestUtils.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.stablecoin.payments.merchant.onboarding.fixtures; - -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.ZonedDateTime; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.argThat; - -public final class TestUtils { - - private TestUtils() {} - - public static T eqIgnoringTimestamps(T expected) { - return eqIgnoring(expected); - } - - public static T eqIgnoring(T expected, String... fieldsToIgnore) { - return argThat(it -> isEqualIgnoring(it, expected, fieldsToIgnore)); - } - - private static boolean isEqualIgnoring(T original, T expected, String... fieldsToIgnore) { - try { - assertThat(original) - .usingRecursiveComparison() - .ignoringFieldsOfTypes(ZonedDateTime.class, LocalDateTime.class, LocalDate.class, Instant.class) - .ignoringFields(fieldsToIgnore) - .isEqualTo(expected); - return true; - } catch (Throwable t) { - return false; - } - } -} diff --git a/payment-orchestrator/payment-orchestrator-api/build.gradle.kts b/payment-orchestrator/payment-orchestrator-api/build.gradle.kts index 20c8280f..97d4baa4 100644 --- a/payment-orchestrator/payment-orchestrator-api/build.gradle.kts +++ b/payment-orchestrator/payment-orchestrator-api/build.gradle.kts @@ -1,16 +1,3 @@ plugins { - `java-library` -} - -dependencies { - api("jakarta.validation:jakarta.validation-api") - api("com.fasterxml.jackson.core:jackson-annotations") - - testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation("org.assertj:assertj-core") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { - useJUnitPlatform() + id("stablebridge.api-library") } diff --git a/payment-orchestrator/payment-orchestrator-api/src/main/java/com/stablecoin/payments/orchestrator/api/ApiError.java b/payment-orchestrator/payment-orchestrator-api/src/main/java/com/stablecoin/payments/orchestrator/api/ApiError.java deleted file mode 100644 index a61c2726..00000000 --- a/payment-orchestrator/payment-orchestrator-api/src/main/java/com/stablecoin/payments/orchestrator/api/ApiError.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.stablecoin.payments.orchestrator.api; - -import java.util.List; -import java.util.Map; - -public record ApiError( - String code, - String status, - String message, - Map> errors -) { - public static ApiError of(String code, String status, String message) { - return new ApiError(code, status, message, Map.of()); - } - - public static ApiError withErrors(String code, String status, String message, - Map> errors) { - return new ApiError(code, status, message, errors); - } -} diff --git a/payment-orchestrator/payment-orchestrator-client/build.gradle.kts b/payment-orchestrator/payment-orchestrator-client/build.gradle.kts index 72529044..dab422d7 100644 --- a/payment-orchestrator/payment-orchestrator-client/build.gradle.kts +++ b/payment-orchestrator/payment-orchestrator-client/build.gradle.kts @@ -1,16 +1,7 @@ plugins { - `java-library` + id("stablebridge.client-library") } dependencies { api(project(":payment-orchestrator:payment-orchestrator-api")) - implementation("org.springframework.cloud:spring-cloud-starter-openfeign") - - testImplementation("org.junit.jupiter:junit-jupiter") - testImplementation("org.assertj:assertj-core") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") -} - -tasks.withType { - useJUnitPlatform() } diff --git a/payment-orchestrator/payment-orchestrator/build.gradle.kts b/payment-orchestrator/payment-orchestrator/build.gradle.kts index ef3d1ea8..70d946cf 100644 --- a/payment-orchestrator/payment-orchestrator/build.gradle.kts +++ b/payment-orchestrator/payment-orchestrator/build.gradle.kts @@ -1,75 +1,13 @@ plugins { - id("org.springframework.boot") - id("com.google.cloud.tools.jib") - java - `java-test-fixtures` - jacoco + id("stablebridge.service") } -jib { - from { - image = "docker://eclipse-temurin:25-jre" - } - to { - image = "stablebridge/payment-orchestrator" - tags = setOf("latest") - } - container { - creationTime.set("USE_CURRENT_TIMESTAMP") - } -} - -val integrationTestSourceSet: SourceSet = sourceSets.create("integrationTest") { - java.srcDir("src/integration-test/java") - resources.srcDir("src/integration-test/resources") - compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output - runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output -} - -configurations { - named("integrationTestImplementation") { extendsFrom(configurations.testImplementation.get()) } - named("integrationTestRuntimeOnly") { extendsFrom(configurations.testRuntimeOnly.get()) } -} - -tasks.register("integrationTest") { - testClassesDirs = integrationTestSourceSet.output.classesDirs - classpath = integrationTestSourceSet.runtimeClasspath - shouldRunAfter(tasks.test) - configure { isEnabled = false } - failOnNoDiscoveredTests = false - exclude("**/Abstract*", "**/config/**") -} - -val businessTestSourceSet: SourceSet = sourceSets.create("businessTest") { - java.srcDir("src/business-test/java") - resources.srcDir("src/business-test/resources") - compileClasspath += sourceSets.main.get().output + sourceSets.test.get().output + integrationTestSourceSet.output - runtimeClasspath += sourceSets.main.get().output + sourceSets.test.get().output + integrationTestSourceSet.output -} - -configurations { - named("businessTestImplementation") { extendsFrom(configurations.named("integrationTestImplementation").get()) } - named("businessTestRuntimeOnly") { extendsFrom(configurations.named("integrationTestRuntimeOnly").get()) } -} - -tasks.register("businessTest") { - testClassesDirs = businessTestSourceSet.output.classesDirs - classpath = businessTestSourceSet.runtimeClasspath - shouldRunAfter(tasks.named("integrationTest")) - failOnNoDiscoveredTests = false - configure { isEnabled = false } -} - -val lombokVersion: String by project -val mapstructVersion: String by project -val lombokMapstructBindingVersion: String by project -val resilience4jVersion: String by project val temporalVersion: String by project -val flywayVersion: String by project -val archunitVersion: String by project -val testcontainersVersion: String by project val wiremockVersion: String by project -val springdocVersion: String by project + +stablebridge { + jibImageName.set("stablebridge/payment-orchestrator") +} dependencies { implementation(project(":payment-orchestrator:payment-orchestrator-api")) @@ -79,141 +17,20 @@ dependencies { implementation(project(":blockchain-custody:blockchain-custody-client")) implementation(project(":fiat-off-ramp:fiat-off-ramp-client")) - // Spring Boot - implementation("org.springframework.boot:spring-boot-starter-web") - implementation("org.springframework.boot:spring-boot-starter-validation") - implementation("org.springframework.boot:spring-boot-starter-data-jpa") - implementation("org.springframework.boot:spring-boot-starter-actuator") - - // OpenAPI / Swagger UI - implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:$springdocVersion") - runtimeOnly("io.micrometer:micrometer-registry-prometheus") - implementation("io.micrometer:micrometer-tracing-bridge-otel") - implementation("io.opentelemetry:opentelemetry-exporter-otlp") - implementation("org.springframework.boot:spring-boot-starter-security") - // Redis — payment state cache implementation("org.springframework.boot:spring-boot-starter-data-redis") - // Kafka via Spring Kafka - implementation("org.springframework.boot:spring-boot-starter-kafka") - implementation("org.springframework.kafka:spring-kafka") - - // Feign - implementation("org.springframework.cloud:spring-cloud-starter-openfeign") - - // Resilience4j - implementation("io.github.resilience4j:resilience4j-spring-boot3:$resilience4jVersion") - implementation("io.github.resilience4j:resilience4j-circuitbreaker:$resilience4jVersion") - // Temporal SDK implementation("io.temporal:temporal-sdk:$temporalVersion") implementation("io.temporal:temporal-spring-boot-starter:$temporalVersion") - // MapStruct (compiler args set below in JavaCompile task) - implementation("org.mapstruct:mapstruct:$mapstructVersion") - annotationProcessor("org.mapstruct:mapstruct-processor:$mapstructVersion") - annotationProcessor("org.projectlombok:lombok-mapstruct-binding:$lombokMapstructBindingVersion") - - // Outbox (namastack) - implementation("io.namastack:namastack-outbox-starter-jdbc:1.1.0") - - // Database - runtimeOnly("org.postgresql:postgresql") - implementation("org.springframework.boot:spring-boot-starter-flyway") - implementation("org.flywaydb:flyway-database-postgresql:$flywayVersion") - - // Test Fixtures - testFixturesImplementation("org.assertj:assertj-core") - testFixturesImplementation("org.mockito:mockito-core") + // Test Fixtures — cross-service client deps testFixturesImplementation(project(":compliance-travel-rule:compliance-travel-rule-client")) testFixturesImplementation(project(":fx-liquidity-engine:fx-liquidity-engine-client")) // Test - testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-webmvc-test") testImplementation("org.springframework.boot:spring-boot-starter-security-test") - testImplementation("org.springframework.kafka:spring-kafka-test") - testImplementation("com.tngtech.archunit:archunit-junit5:$archunitVersion") testImplementation("org.wiremock:wiremock-standalone:$wiremockVersion") testImplementation("io.temporal:temporal-testing:$temporalVersion") - "integrationTestImplementation"(testFixtures(project)) - "integrationTestImplementation"("org.testcontainers:postgresql:$testcontainersVersion") - "integrationTestImplementation"("org.testcontainers:kafka:$testcontainersVersion") - "integrationTestImplementation"("org.testcontainers:junit-jupiter:$testcontainersVersion") - "integrationTestImplementation"("org.wiremock:wiremock-standalone:$wiremockVersion") - "integrationTestImplementation"("org.springframework.boot:spring-boot-starter-webmvc-test") - "integrationTestImplementation"("org.springframework.boot:spring-boot-starter-security-test") -} - -tasks.withType { - options.compilerArgs.addAll(listOf( - "-Amapstruct.defaultComponentModel=spring", - "-Amapstruct.unmappedTargetPolicy=IGNORE" - )) -} - -tasks.withType { - jvmArgs("-Dnet.bytebuddy.experimental=true") - useJUnitPlatform() - testLogging { - events("passed", "skipped", "failed") - showExceptions = true - showCauses = true - showStackTraces = true - exceptionFormat = org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL - } -} - -jacoco { - toolVersion = "0.8.14" -} - -tasks.test { - configure { - excludes = listOf("sun.*", "jdk.*", "com.sun.*", "java.*", "javax.*") - } - finalizedBy(tasks.jacocoTestReport) -} - -val jacocoExclusions = listOf( - "**/entity/**", - "**/mapper/**", - "**/config/**", - "**/*Application*", - "**/generated/**", - "**/*MapperImpl*" -) - -tasks.jacocoTestReport { - dependsOn(tasks.test) - reports { - xml.required.set(true) - html.required.set(true) - } - classDirectories.setFrom(files(classDirectories.files.map { - fileTree(it) { exclude(jacocoExclusions) } - })) -} - -tasks.jacocoTestCoverageVerification { - dependsOn(tasks.jacocoTestReport) - violationRules { - rule { - limit { - minimum = "0.50".toBigDecimal() - } - } - } - classDirectories.setFrom(files(classDirectories.files.map { - fileTree(it) { exclude(jacocoExclusions) } - })) -} - -tasks.named("check") { - dependsOn( - tasks.named("integrationTest"), - tasks.named("businessTest"), - tasks.jacocoTestCoverageVerification - ) } 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 09aed137..958b8e80 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 @@ -11,8 +11,12 @@ import org.springframework.test.context.DynamicPropertySource; import org.testcontainers.containers.KafkaContainer; import org.testcontainers.containers.PostgreSQLContainer; -import org.testcontainers.lifecycle.Startable; -import org.testcontainers.utility.DockerImageName; + +import static com.stablecoin.payments.platform.test.TestContainerSupport.kafka; +import static com.stablecoin.payments.platform.test.TestContainerSupport.postgres; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerKafkaProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.registerPostgresProperties; +import static com.stablecoin.payments.platform.test.TestContainerSupport.startAll; @SuppressWarnings("resource") @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @@ -20,38 +24,11 @@ @AutoConfigureMockMvc public abstract class AbstractIntegrationTest { - static final PostgreSQLContainer POSTGRES = - new PostgreSQLContainer<>("postgres:16-alpine") - .withDatabaseName("s1_payment_orchestrator") - .withUsername("test") - .withPassword("test"); - - protected static final KafkaContainer KAFKA = - new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0")); + static final PostgreSQLContainer POSTGRES = postgres("s1_payment_orchestrator"); + protected static final KafkaContainer KAFKA = kafka(); static { - try { - POSTGRES.start(); - KAFKA.start(); - } catch (RuntimeException ex) { - safeStop(KAFKA); - safeStop(POSTGRES); - throw ex; - } - Runtime.getRuntime().addShutdownHook(new Thread(() -> { - safeStop(KAFKA); - safeStop(POSTGRES); - }, "testcontainers-shutdown")); - } - - private static void safeStop(Startable container) { - try { - if (container != null) { - container.stop(); - } - } catch (Exception ignored) { - // best-effort cleanup - } + startAll(POSTGRES, KAFKA); } @Autowired @@ -74,10 +51,8 @@ void cleanDatabase() { @DynamicPropertySource static void configureProperties(DynamicPropertyRegistry registry) { - registry.add("spring.datasource.url", POSTGRES::getJdbcUrl); - registry.add("spring.datasource.username", POSTGRES::getUsername); - registry.add("spring.datasource.password", POSTGRES::getPassword); - registry.add("spring.kafka.bootstrap-servers", KAFKA::getBootstrapServers); + registerPostgresProperties(registry, POSTGRES); + registerKafkaProperties(registry, KAFKA); registry.add("app.security.enabled", () -> "false"); } } 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 8329725e..3235da8c 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 @@ -19,6 +19,9 @@ spring: autoconfigure: exclude: - io.temporal.spring.boot.autoconfigure.AutoDiscoveryProperties + - org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration + - org.springframework.boot.autoconfigure.data.redis.RedisReactiveAutoConfiguration + - org.springframework.boot.autoconfigure.data.redis.RedisRepositoriesAutoConfiguration namastack: outbox: diff --git a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/GlobalExceptionHandler.java b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/GlobalExceptionHandler.java index 12dda40e..4eb5d8dc 100644 --- a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/GlobalExceptionHandler.java +++ b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/application/controller/GlobalExceptionHandler.java @@ -1,49 +1,35 @@ package com.stablecoin.payments.orchestrator.application.controller; -import com.stablecoin.payments.orchestrator.api.ApiError; import com.stablecoin.payments.orchestrator.domain.model.PaymentNotCancellableException; import com.stablecoin.payments.orchestrator.domain.model.PaymentNotFoundException; -import jakarta.validation.ConstraintViolationException; +import com.stablecoin.payments.platform.api.ApiError; +import com.stablecoin.payments.platform.infrastructure.exception.BaseGlobalExceptionHandler; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.HttpStatus; import org.springframework.validation.FieldError; -import org.springframework.validation.ObjectError; import org.springframework.validation.method.ParameterValidationResult; -import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.web.method.annotation.HandlerMethodValidationException; -import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import java.util.List; import java.util.Map; import java.util.stream.Collectors; -import static com.stablecoin.payments.orchestrator.application.controller.ErrorCodes.INTERNAL_ERROR; import static com.stablecoin.payments.orchestrator.application.controller.ErrorCodes.PAYMENT_NOT_CANCELLABLE; import static com.stablecoin.payments.orchestrator.application.controller.ErrorCodes.PAYMENT_NOT_FOUND; import static com.stablecoin.payments.orchestrator.application.controller.ErrorCodes.VALIDATION_ERROR; import static org.springframework.http.HttpStatus.BAD_REQUEST; import static org.springframework.http.HttpStatus.CONFLICT; -import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; import static org.springframework.http.HttpStatus.NOT_FOUND; -import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY; @Slf4j @RestControllerAdvice -public class GlobalExceptionHandler { +public class GlobalExceptionHandler extends BaseGlobalExceptionHandler { - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(MethodArgumentNotValidException.class) - public ApiError handleValidation(MethodArgumentNotValidException ex) { - var errors = ex.getBindingResult().getFieldErrors().stream() - .collect(Collectors.groupingBy( - FieldError::getField, - Collectors.mapping(ObjectError::getDefaultMessage, Collectors.toList()))); - log.info("Validation failed: {}", errors); - return ApiError.withErrors(VALIDATION_ERROR, BAD_REQUEST.getReasonPhrase(), - "Invalid request content", errors); + @Override + protected String errorCodePrefix() { + return "PO"; } @ResponseStatus(BAD_REQUEST) @@ -71,25 +57,6 @@ private static String resolveParameterName(ParameterValidationResult result, return paramName != null ? paramName : "unknown"; } - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(ConstraintViolationException.class) - public ApiError handleConstraintViolation(ConstraintViolationException ex) { - var errors = ex.getConstraintViolations().stream() - .collect(Collectors.groupingBy( - v -> v.getPropertyPath().toString(), - Collectors.mapping(jakarta.validation.ConstraintViolation::getMessage, - Collectors.toList()))); - return ApiError.withErrors(VALIDATION_ERROR, BAD_REQUEST.getReasonPhrase(), - "Invalid request content", errors); - } - - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(IllegalArgumentException.class) - public ApiError handleIllegalArgument(IllegalArgumentException ex) { - log.info("Illegal argument: {}", ex.getMessage()); - return ApiError.of(VALIDATION_ERROR, BAD_REQUEST.getReasonPhrase(), ex.getMessage()); - } - @ResponseStatus(NOT_FOUND) @ExceptionHandler(PaymentNotFoundException.class) public ApiError handlePaymentNotFound(PaymentNotFoundException ex) { @@ -103,27 +70,4 @@ public ApiError handlePaymentNotCancellable(PaymentNotCancellableException ex) { log.info("Payment not cancellable: {}", ex.getMessage()); return ApiError.of(PAYMENT_NOT_CANCELLABLE, CONFLICT.getReasonPhrase(), ex.getMessage()); } - - @ResponseStatus(BAD_REQUEST) - @ExceptionHandler(MethodArgumentTypeMismatchException.class) - public ApiError handleTypeMismatch(MethodArgumentTypeMismatchException ex) { - log.info("Type mismatch for parameter '{}': {}", ex.getName(), ex.getMessage()); - return ApiError.of(VALIDATION_ERROR, BAD_REQUEST.getReasonPhrase(), - "Invalid value for parameter '" + ex.getName() + "'"); - } - - @ResponseStatus(UNPROCESSABLE_ENTITY) - @ExceptionHandler(IllegalStateException.class) - public ApiError handleInvalidState(IllegalStateException ex) { - log.info("Invalid state: {}", ex.getMessage()); - return ApiError.of(ErrorCodes.INVALID_STATE, UNPROCESSABLE_ENTITY.getReasonPhrase(), ex.getMessage()); - } - - @ResponseStatus(INTERNAL_SERVER_ERROR) - @ExceptionHandler(Exception.class) - public ApiError handleUnexpected(Exception ex) { - log.error("Unexpected error: ", ex); - return ApiError.of(INTERNAL_ERROR, INTERNAL_SERVER_ERROR.getReasonPhrase(), - HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase()); - } } diff --git a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/infrastructure/messaging/OrchestratorOutboxHandler.java b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/infrastructure/messaging/OrchestratorOutboxHandler.java index b5c57c5f..f28d8c9c 100644 --- a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/infrastructure/messaging/OrchestratorOutboxHandler.java +++ b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/infrastructure/messaging/OrchestratorOutboxHandler.java @@ -1,42 +1,13 @@ package com.stablecoin.payments.orchestrator.infrastructure.messaging; -import io.namastack.outbox.annotation.OutboxHandler; -import io.namastack.outbox.handler.OutboxRecordMetadata; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; +import com.stablecoin.payments.platform.infrastructure.messaging.AbstractOutboxHandler; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Component; -import java.util.concurrent.TimeUnit; - -@Slf4j @Component -@RequiredArgsConstructor -public class OrchestratorOutboxHandler { - - private final KafkaTemplate kafkaTemplate; - - @OutboxHandler - public void handle(Object event, OutboxRecordMetadata metadata) { - var topic = resolveStaticField(event, "TOPIC"); - var key = metadata.getKey(); - try { - kafkaTemplate.send(topic, key, event).get(10, TimeUnit.SECONDS); - log.debug("Published outbox event type={} topic={} key={}", - event.getClass().getSimpleName(), topic, key); - } catch (Exception e) { - log.error("Failed to publish event type={} topic={}: {}", - event.getClass().getSimpleName(), topic, e.getMessage()); - throw new RuntimeException("Kafka send failed for event " + event.getClass().getSimpleName(), e); - } - } +public class OrchestratorOutboxHandler extends AbstractOutboxHandler { - private String resolveStaticField(Object event, String fieldName) { - try { - return (String) event.getClass().getField(fieldName).get(null); - } catch (NoSuchFieldException | IllegalAccessException e) { - throw new IllegalArgumentException( - "Event class missing static " + fieldName + " field: " + event.getClass().getName(), e); - } + public OrchestratorOutboxHandler(KafkaTemplate kafkaTemplate) { + super(kafkaTemplate); } } diff --git a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/infrastructure/messaging/OutboxEventPublisher.java b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/infrastructure/messaging/OutboxEventPublisher.java index 8f3639d4..f164b895 100644 --- a/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/infrastructure/messaging/OutboxEventPublisher.java +++ b/payment-orchestrator/payment-orchestrator/src/main/java/com/stablecoin/payments/orchestrator/infrastructure/messaging/OutboxEventPublisher.java @@ -1,36 +1,17 @@ package com.stablecoin.payments.orchestrator.infrastructure.messaging; import com.stablecoin.payments.orchestrator.domain.port.PaymentEventPublisher; +import com.stablecoin.payments.platform.infrastructure.messaging.AbstractOutboxEventPublisher; import io.namastack.outbox.Outbox; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -@Slf4j -@Component -@RequiredArgsConstructor -public class OutboxEventPublisher implements PaymentEventPublisher { - - private final Outbox outbox; +import java.util.List; - @Override - @Transactional(propagation = Propagation.MANDATORY) - public void publish(Object event) { - var key = resolveKey(event); - outbox.schedule(event, key); - log.debug("Scheduled outbox event type={} key={}", event.getClass().getSimpleName(), key); - } +@Component +public class OutboxEventPublisher extends AbstractOutboxEventPublisher + implements PaymentEventPublisher { - private String resolveKey(Object event) { - try { - var method = event.getClass().getMethod("paymentId"); - return String.valueOf(method.invoke(event)); - } catch (Exception e) { - throw new IllegalArgumentException( - "Event class missing accessor for field 'paymentId': " - + event.getClass().getName(), e); - } + public OutboxEventPublisher(Outbox outbox) { + super(outbox, List.of("paymentId")); } } diff --git a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/arch/ArchitectureTest.java b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/arch/ArchitectureTest.java index ed29e40a..522fbe4a 100644 --- a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/arch/ArchitectureTest.java +++ b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/arch/ArchitectureTest.java @@ -1,111 +1,15 @@ package com.stablecoin.payments.orchestrator.arch; -import com.tngtech.archunit.core.domain.JavaClasses; -import com.tngtech.archunit.core.importer.ClassFileImporter; -import com.tngtech.archunit.core.importer.ImportOption; -import org.junit.jupiter.api.BeforeAll; +import com.stablecoin.payments.platform.test.HexagonalArchitectureRules; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; -import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; -import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; - @DisplayName("Architecture Rules") class ArchitectureTest { - private static JavaClasses importedClasses; - - @BeforeAll - static void setUp() { - importedClasses = new ClassFileImporter() - .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) - .importPackages("com.stablecoin.payments.orchestrator"); - } - - @Test - @DisplayName("Domain should not depend on Spring (except stereotype, transaction, and beans.factory.annotation)") - void domainShouldNotDependOnSpring() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat( - resideInAPackage("org.springframework..") - .and(resideOutsideOfPackage("org.springframework.stereotype..")) - .and(resideOutsideOfPackage("org.springframework.transaction..")) - .and(resideOutsideOfPackage("org.springframework.beans.factory.annotation..")) - ) - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on JPA") - void domainShouldNotDependOnJpa() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("jakarta.persistence..") - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on infrastructure") - void domainShouldNotDependOnInfrastructure() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("..infrastructure..") - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Domain should not depend on application layer") - void domainShouldNotDependOnApplication() { - noClasses() - .that().resideInAPackage("..domain..") - .should().dependOnClassesThat().resideInAPackage("..application..") - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Infrastructure should not depend on application controller") - void infrastructureShouldNotDependOnApplicationController() { - noClasses() - .that().resideInAPackage("..infrastructure..") - .should().dependOnClassesThat().resideInAPackage("..application.controller..") - .check(importedClasses); - } - - @Test - @DisplayName("Ports should be interfaces") - void portsShouldBeInterfaces() { - classes() - .that().resideInAPackage("..domain.port..") - .and().areNotRecords() - .should().beInterfaces() - .allowEmptyShould(true) - .check(importedClasses); - } - - @Test - @DisplayName("Domain events should be records") - void domainEventsShouldBeRecords() { - classes() - .that().resideInAPackage("..domain.event..") - .should().beRecords() - .allowEmptyShould(true) - .check(importedClasses); - } - @Test - @DisplayName("Controllers should reside in application.controller package") - void controllersShouldResideInApplicationController() { - noClasses() - .that().haveSimpleNameEndingWith("Controller") - .should().resideOutsideOfPackage("..application.controller..") - .allowEmptyShould(true) - .check(importedClasses); + @DisplayName("Verify hexagonal architecture rules") + void verifyHexagonalArchitecture() { + HexagonalArchitectureRules.verifyAll("com.stablecoin.payments.orchestrator"); } } diff --git a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/service/PaymentCommandHandlerTest.java b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/service/PaymentCommandHandlerTest.java index 813fc6ba..3c1c1d23 100644 --- a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/service/PaymentCommandHandlerTest.java +++ b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/domain/service/PaymentCommandHandlerTest.java @@ -38,7 +38,7 @@ import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.aCompletedPayment; import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.aFailedPayment; import static com.stablecoin.payments.orchestrator.fixtures.PaymentFixtures.anInitiatedPayment; -import static com.stablecoin.payments.orchestrator.fixtures.TestUtils.eqIgnoring; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoring; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; diff --git a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/infrastructure/activity/EventPublishingActivityImplTest.java b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/infrastructure/activity/EventPublishingActivityImplTest.java index 42c6cc85..61531a95 100644 --- a/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/infrastructure/activity/EventPublishingActivityImplTest.java +++ b/payment-orchestrator/payment-orchestrator/src/test/java/com/stablecoin/payments/orchestrator/infrastructure/activity/EventPublishingActivityImplTest.java @@ -15,7 +15,7 @@ import java.time.Instant; import java.util.UUID; -import static com.stablecoin.payments.orchestrator.fixtures.TestUtils.eqIgnoringTimestamps; +import static com.stablecoin.payments.platform.test.TestUtils.eqIgnoringTimestamps; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.then; diff --git a/payment-orchestrator/payment-orchestrator/src/testFixtures/java/com/stablecoin/payments/orchestrator/fixtures/TestUtils.java b/payment-orchestrator/payment-orchestrator/src/testFixtures/java/com/stablecoin/payments/orchestrator/fixtures/TestUtils.java deleted file mode 100644 index 59431564..00000000 --- a/payment-orchestrator/payment-orchestrator/src/testFixtures/java/com/stablecoin/payments/orchestrator/fixtures/TestUtils.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.stablecoin.payments.orchestrator.fixtures; - -import java.time.Instant; -import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.ZonedDateTime; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.argThat; - -public final class TestUtils { - - private TestUtils() {} - - public static T eqIgnoringTimestamps(T expected) { - return eqIgnoring(expected); - } - - public static T eqIgnoring(T expected, String... fieldsToIgnore) { - return argThat(it -> isEqualIgnoring(it, expected, fieldsToIgnore)); - } - - private static boolean isEqualIgnoring(T original, T expected, String... fieldsToIgnore) { - try { - assertThat(original) - .usingRecursiveComparison() - .ignoringFieldsOfTypes(ZonedDateTime.class, LocalDateTime.class, LocalDate.class, Instant.class) - .ignoringFields(fieldsToIgnore) - .isEqualTo(expected); - return true; - } catch (Throwable t) { - return false; - } - } -} diff --git a/platform-api/build.gradle.kts b/platform-api/build.gradle.kts new file mode 100644 index 00000000..f6727627 --- /dev/null +++ b/platform-api/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + `java-library` +} diff --git a/fiat-on-ramp/fiat-on-ramp-api/src/main/java/com/stablecoin/payments/onramp/api/ApiError.java b/platform-api/src/main/java/com/stablecoin/payments/platform/api/ApiError.java similarity index 92% rename from fiat-on-ramp/fiat-on-ramp-api/src/main/java/com/stablecoin/payments/onramp/api/ApiError.java rename to platform-api/src/main/java/com/stablecoin/payments/platform/api/ApiError.java index 0d5d181f..8bae3725 100644 --- a/fiat-on-ramp/fiat-on-ramp-api/src/main/java/com/stablecoin/payments/onramp/api/ApiError.java +++ b/platform-api/src/main/java/com/stablecoin/payments/platform/api/ApiError.java @@ -1,4 +1,4 @@ -package com.stablecoin.payments.onramp.api; +package com.stablecoin.payments.platform.api; import java.util.List; import java.util.Map; diff --git a/platform-infra/build.gradle.kts b/platform-infra/build.gradle.kts new file mode 100644 index 00000000..b8d26278 --- /dev/null +++ b/platform-infra/build.gradle.kts @@ -0,0 +1,18 @@ +plugins { + `java-library` +} + +val namastackVersion: String by project + +dependencies { + api(project(":platform-api")) + + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-security") + implementation("org.springframework.kafka:spring-kafka") + implementation("io.namastack:namastack-outbox-starter-jdbc:$namastackVersion") + + compileOnly("org.projectlombok:lombok") + annotationProcessor("org.projectlombok:lombok") +} diff --git a/platform-infra/src/main/java/com/stablecoin/payments/platform/infrastructure/exception/BaseGlobalExceptionHandler.java b/platform-infra/src/main/java/com/stablecoin/payments/platform/infrastructure/exception/BaseGlobalExceptionHandler.java new file mode 100644 index 00000000..0f781c31 --- /dev/null +++ b/platform-infra/src/main/java/com/stablecoin/payments/platform/infrastructure/exception/BaseGlobalExceptionHandler.java @@ -0,0 +1,85 @@ +package com.stablecoin.payments.platform.infrastructure.exception; + +import com.stablecoin.payments.platform.api.ApiError; +import jakarta.validation.ConstraintViolationException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.util.stream.Collectors; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; +import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR; +import static org.springframework.http.HttpStatus.UNPROCESSABLE_ENTITY; + +@Slf4j +public abstract class BaseGlobalExceptionHandler { + + protected abstract String errorCodePrefix(); + + private String code(String suffix) { + return errorCodePrefix() + "-" + suffix; + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(MethodArgumentNotValidException.class) + public ApiError handleValidation(MethodArgumentNotValidException ex) { + var errors = ex.getBindingResult().getFieldErrors().stream() + .collect(Collectors.groupingBy( + FieldError::getField, + Collectors.mapping(ObjectError::getDefaultMessage, Collectors.toList()))); + log.info("Validation failed: field count={}", errors.size()); + return ApiError.withErrors(code("0001"), BAD_REQUEST.getReasonPhrase(), + "Invalid request content", errors); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(ConstraintViolationException.class) + public ApiError handleConstraintViolation(ConstraintViolationException ex) { + var errors = ex.getConstraintViolations().stream() + .collect(Collectors.groupingBy( + v -> v.getPropertyPath().toString(), + Collectors.mapping(jakarta.validation.ConstraintViolation::getMessage, + Collectors.toList()))); + log.info("Constraint violation: {} violations", errors.size()); + return ApiError.withErrors(code("0001"), BAD_REQUEST.getReasonPhrase(), + "Invalid request content", errors); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ApiError handleTypeMismatch(MethodArgumentTypeMismatchException ex) { + log.info("Type mismatch for parameter '{}': {}", ex.getName(), ex.getMessage()); + return ApiError.of(code("0001"), BAD_REQUEST.getReasonPhrase(), + "Invalid value for parameter '" + ex.getName() + "'"); + } + + @ResponseStatus(BAD_REQUEST) + @ExceptionHandler(IllegalArgumentException.class) + public ApiError handleIllegalArgument(IllegalArgumentException ex) { + // Note: message may contain domain identifiers but not user PII + log.info("Illegal argument: {}", ex.getMessage()); + return ApiError.of(code("0001"), BAD_REQUEST.getReasonPhrase(), ex.getMessage()); + } + + @ResponseStatus(UNPROCESSABLE_ENTITY) + @ExceptionHandler(IllegalStateException.class) + public ApiError handleInvalidState(IllegalStateException ex) { + // Note: message may contain domain identifiers but not user PII + log.info("Invalid state: {}", ex.getMessage()); + return ApiError.of(code("0004"), UNPROCESSABLE_ENTITY.getReasonPhrase(), ex.getMessage()); + } + + @ResponseStatus(INTERNAL_SERVER_ERROR) + @ExceptionHandler(Exception.class) + public ApiError handleUnexpected(Exception ex) { + log.error("Unexpected error: ", ex); + return ApiError.of(code("9999"), INTERNAL_SERVER_ERROR.getReasonPhrase(), + HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase()); + } +} diff --git a/platform-infra/src/main/java/com/stablecoin/payments/platform/infrastructure/messaging/AbstractOutboxEventPublisher.java b/platform-infra/src/main/java/com/stablecoin/payments/platform/infrastructure/messaging/AbstractOutboxEventPublisher.java new file mode 100644 index 00000000..c2e86d56 --- /dev/null +++ b/platform-infra/src/main/java/com/stablecoin/payments/platform/infrastructure/messaging/AbstractOutboxEventPublisher.java @@ -0,0 +1,43 @@ +package com.stablecoin.payments.platform.infrastructure.messaging; + +import io.namastack.outbox.Outbox; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Slf4j +@RequiredArgsConstructor +public abstract class AbstractOutboxEventPublisher { + + private final Outbox outbox; + private final List keyFieldNames; + + @Transactional(propagation = Propagation.MANDATORY) + public void publish(Object event) { + var key = resolveKey(event); + outbox.schedule(event, key); + log.debug("Scheduled outbox event type={} key={}", event.getClass().getSimpleName(), key); + } + + private String resolveKey(Object event) { + for (String fieldName : keyFieldNames) { + try { + var method = event.getClass().getMethod(fieldName); + var value = method.invoke(event); + if (value != null) { + return String.valueOf(value); + } + } catch (NoSuchMethodException ignored) { + // try next field name + } catch (Exception e) { + throw new IllegalArgumentException( + "Error invoking accessor '" + fieldName + "' on " + event.getClass().getName(), e); + } + } + throw new IllegalArgumentException( + "Event class has no non-null value for any of " + keyFieldNames + ": " + event.getClass().getName()); + } +} diff --git a/platform-infra/src/main/java/com/stablecoin/payments/platform/infrastructure/messaging/AbstractOutboxHandler.java b/platform-infra/src/main/java/com/stablecoin/payments/platform/infrastructure/messaging/AbstractOutboxHandler.java new file mode 100644 index 00000000..6eb89c72 --- /dev/null +++ b/platform-infra/src/main/java/com/stablecoin/payments/platform/infrastructure/messaging/AbstractOutboxHandler.java @@ -0,0 +1,43 @@ +package com.stablecoin.payments.platform.infrastructure.messaging; + +import io.namastack.outbox.handler.OutboxRecordMetadata; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; + +import java.util.concurrent.TimeUnit; + +@Slf4j +@RequiredArgsConstructor +public abstract class AbstractOutboxHandler { + + private final KafkaTemplate kafkaTemplate; + + @io.namastack.outbox.annotation.OutboxHandler + public void handle(Object event, OutboxRecordMetadata metadata) { + var topic = resolveStaticField(event, "TOPIC"); + var key = metadata.getKey(); + try { + kafkaTemplate.send(topic, key, event).get(10, TimeUnit.SECONDS); + log.debug("Published outbox event type={} topic={} key={}", + event.getClass().getSimpleName(), topic, key); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Kafka send interrupted for event " + event.getClass().getSimpleName(), e); + } catch (Exception e) { + // Kafka errors don't contain user PII — safe to log + log.error("Failed to publish event type={} topic={}: {}", + event.getClass().getSimpleName(), topic, e.getMessage()); + throw new RuntimeException("Kafka send failed for event " + event.getClass().getSimpleName(), e); + } + } + + private String resolveStaticField(Object event, String fieldName) { + try { + return (String) event.getClass().getField(fieldName).get(null); + } catch (NoSuchFieldException | IllegalAccessException e) { + throw new IllegalArgumentException( + "Event class missing static " + fieldName + " field: " + event.getClass().getName(), e); + } + } +} diff --git a/platform-test/build.gradle.kts b/platform-test/build.gradle.kts new file mode 100644 index 00000000..9c76cde2 --- /dev/null +++ b/platform-test/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + `java-library` + `java-test-fixtures` +} + +val archunitVersion: String by project +val testcontainersVersion: String by project + +dependencies { + testFixturesApi("org.assertj:assertj-core") + testFixturesApi("org.mockito:mockito-core") + testFixturesApi("com.tngtech.archunit:archunit-junit5:$archunitVersion") + testFixturesApi("org.testcontainers:postgresql:$testcontainersVersion") + testFixturesApi("org.testcontainers:kafka:$testcontainersVersion") + testFixturesApi("org.testcontainers:junit-jupiter:$testcontainersVersion") + testFixturesApi("org.springframework.boot:spring-boot-starter-test") + testFixturesApi("org.springframework.boot:spring-boot-starter-webmvc-test") + testFixturesApi("org.springframework.boot:spring-boot-starter-security-test") + testFixturesApi("org.springframework.boot:spring-boot-starter-security") +} diff --git a/platform-test/src/testFixtures/java/com/stablecoin/payments/platform/test/HexagonalArchitectureRules.java b/platform-test/src/testFixtures/java/com/stablecoin/payments/platform/test/HexagonalArchitectureRules.java new file mode 100644 index 00000000..184abb56 --- /dev/null +++ b/platform-test/src/testFixtures/java/com/stablecoin/payments/platform/test/HexagonalArchitectureRules.java @@ -0,0 +1,96 @@ +package com.stablecoin.payments.platform.test; + +import com.tngtech.archunit.core.domain.JavaClasses; +import com.tngtech.archunit.core.importer.ClassFileImporter; +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.lang.ArchRule; + +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideInAPackage; +import static com.tngtech.archunit.core.domain.JavaClass.Predicates.resideOutsideOfPackage; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.classes; +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +public final class HexagonalArchitectureRules { + + private HexagonalArchitectureRules() {} + + public static void verifyAll(String basePackage) { + JavaClasses importedClasses = new ClassFileImporter() + .withImportOption(ImportOption.Predefined.DO_NOT_INCLUDE_TESTS) + .importPackages(basePackage); + + domainShouldNotDependOnSpring().check(importedClasses); + domainShouldNotDependOnJpa().check(importedClasses); + domainShouldNotDependOnInfrastructure().check(importedClasses); + domainShouldNotDependOnApplication().check(importedClasses); + infrastructureShouldNotDependOnController().check(importedClasses); + portsShouldBeInterfaces().check(importedClasses); + domainEventsShouldBeRecords().check(importedClasses); + controllersShouldResideInApplicationController().check(importedClasses); + } + + public static ArchRule domainShouldNotDependOnSpring() { + return noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat( + resideInAPackage("org.springframework..") + .and(resideOutsideOfPackage("org.springframework.stereotype..")) + .and(resideOutsideOfPackage("org.springframework.transaction..")) + .and(resideOutsideOfPackage("org.springframework.beans.factory.annotation..")) + ) + .as("Domain should not depend on Spring (except stereotype and transaction)"); + } + + public static ArchRule domainShouldNotDependOnJpa() { + return noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat().resideInAPackage("jakarta.persistence..") + .as("Domain should not depend on JPA"); + } + + public static ArchRule domainShouldNotDependOnInfrastructure() { + return noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat().resideInAPackage("..infrastructure..") + .as("Domain should not depend on infrastructure"); + } + + public static ArchRule domainShouldNotDependOnApplication() { + return noClasses() + .that().resideInAPackage("..domain..") + .should().dependOnClassesThat().resideInAPackage("..application..") + .as("Domain should not depend on application layer"); + } + + public static ArchRule infrastructureShouldNotDependOnController() { + return noClasses() + .that().resideInAPackage("..infrastructure..") + .should().dependOnClassesThat().resideInAPackage("..application.controller..") + .as("Infrastructure should not depend on application controller"); + } + + public static ArchRule portsShouldBeInterfaces() { + return classes() + .that().resideInAPackage("..domain.port..") + .and().areNotRecords() + .should().beInterfaces() + .allowEmptyShould(true) + .as("Ports should be interfaces"); + } + + public static ArchRule domainEventsShouldBeRecords() { + return classes() + .that().resideInAPackage("..domain.event..") + .should().beRecords() + .allowEmptyShould(true) + .as("Domain events should be records"); + } + + public static ArchRule controllersShouldResideInApplicationController() { + return noClasses() + .that().haveSimpleNameEndingWith("Controller") + .should().resideOutsideOfPackage("..application.controller..") + .allowEmptyShould(true) + .as("Controllers should reside in application.controller package"); + } +} diff --git a/platform-test/src/testFixtures/java/com/stablecoin/payments/platform/test/TestContainerSupport.java b/platform-test/src/testFixtures/java/com/stablecoin/payments/platform/test/TestContainerSupport.java new file mode 100644 index 00000000..6c5c2375 --- /dev/null +++ b/platform-test/src/testFixtures/java/com/stablecoin/payments/platform/test/TestContainerSupport.java @@ -0,0 +1,80 @@ +package com.stablecoin.payments.platform.test; + +import org.springframework.test.context.DynamicPropertyRegistry; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.KafkaContainer; +import org.testcontainers.containers.PostgreSQLContainer; +import org.testcontainers.lifecycle.Startable; +import org.testcontainers.utility.DockerImageName; + +public final class TestContainerSupport { + + private TestContainerSupport() {} + + @SuppressWarnings("resource") + public static PostgreSQLContainer postgres(String databaseName) { + return new PostgreSQLContainer<>("postgres:16-alpine") + .withDatabaseName(databaseName) + .withUsername("test") + .withPassword("test"); + } + + public static KafkaContainer kafka() { + return new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.6.0")); + } + + public static GenericContainer redis() { + return new GenericContainer<>(DockerImageName.parse("redis:7-alpine")) + .withExposedPorts(6379); + } + + public static void startAll(Startable... containers) { + for (Startable container : containers) { + try { + container.start(); + } catch (RuntimeException ex) { + safeStop(containers); + throw ex; + } + } + registerShutdownHook(containers); + } + + public static void safeStop(Startable... containers) { + for (Startable container : containers) { + try { + if (container != null) { + container.stop(); + } + } catch (Exception ignored) { + // best-effort cleanup + } + } + } + + public static void registerShutdownHook(Startable... containers) { + Runtime.getRuntime().addShutdownHook(new Thread( + () -> safeStop(containers), + "testcontainers-shutdown" + )); + } + + public static void registerPostgresProperties(DynamicPropertyRegistry registry, + PostgreSQLContainer postgres) { + registry.add("spring.datasource.url", postgres::getJdbcUrl); + registry.add("spring.datasource.username", postgres::getUsername); + registry.add("spring.datasource.password", postgres::getPassword); + } + + public static void registerKafkaProperties(DynamicPropertyRegistry registry, + KafkaContainer kafka) { + registry.add("spring.kafka.bootstrap-servers", kafka::getBootstrapServers); + registry.add("spring.cloud.stream.kafka.binder.brokers", kafka::getBootstrapServers); + } + + public static void registerRedisProperties(DynamicPropertyRegistry registry, + GenericContainer redis) { + registry.add("spring.data.redis.host", redis::getHost); + registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379)); + } +} diff --git a/fx-liquidity-engine/fx-liquidity-engine/src/integration-test/java/com/stablecoin/payments/fx/config/TestSecurityConfig.java b/platform-test/src/testFixtures/java/com/stablecoin/payments/platform/test/TestSecurityConfig.java similarity index 93% rename from fx-liquidity-engine/fx-liquidity-engine/src/integration-test/java/com/stablecoin/payments/fx/config/TestSecurityConfig.java rename to platform-test/src/testFixtures/java/com/stablecoin/payments/platform/test/TestSecurityConfig.java index 958ff251..4077efe9 100644 --- a/fx-liquidity-engine/fx-liquidity-engine/src/integration-test/java/com/stablecoin/payments/fx/config/TestSecurityConfig.java +++ b/platform-test/src/testFixtures/java/com/stablecoin/payments/platform/test/TestSecurityConfig.java @@ -1,4 +1,4 @@ -package com.stablecoin.payments.fx.config; +package com.stablecoin.payments.platform.test; import org.springframework.boot.test.context.TestConfiguration; import org.springframework.context.annotation.Bean; diff --git a/blockchain-custody/blockchain-custody/src/testFixtures/java/com/stablecoin/payments/custody/fixtures/TestUtils.java b/platform-test/src/testFixtures/java/com/stablecoin/payments/platform/test/TestUtils.java similarity index 95% rename from blockchain-custody/blockchain-custody/src/testFixtures/java/com/stablecoin/payments/custody/fixtures/TestUtils.java rename to platform-test/src/testFixtures/java/com/stablecoin/payments/platform/test/TestUtils.java index 55e303a9..7b935697 100644 --- a/blockchain-custody/blockchain-custody/src/testFixtures/java/com/stablecoin/payments/custody/fixtures/TestUtils.java +++ b/platform-test/src/testFixtures/java/com/stablecoin/payments/platform/test/TestUtils.java @@ -1,4 +1,4 @@ -package com.stablecoin.payments.custody.fixtures; +package com.stablecoin.payments.platform.test; import java.math.BigDecimal; import java.time.Instant; diff --git a/settings.gradle.kts b/settings.gradle.kts index 0697a1a1..a936c14e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -6,6 +6,11 @@ buildCache { } } +// Shared platform modules +include("platform-api") +include("platform-infra") +include("platform-test") + include("merchant-onboarding:merchant-onboarding-api") include("merchant-onboarding:merchant-onboarding-client") include("merchant-onboarding:merchant-onboarding")