diff --git a/build.gradle.kts b/build.gradle.kts index 703553490..0b3780e1e 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -21,8 +21,10 @@ apiValidation { "ktor-example", "micronaut-example", "spring-example", + "spring-4x-example", "spring-standalone-example", - "spring-streams-example" + "spring-streams-example", + "tests" ) } kover { @@ -45,10 +47,10 @@ kover { } } } -val related = subprojects.of("lib", "spring", "examples", "ktor", "micronaut") +val related = subprojects.of("lib", "spring", "examples", "ktor", "micronaut", "tests") dependencies { related.forEach { kover(it) } } -subprojects.of("lib", "spring", "examples", "ktor", "micronaut") { +subprojects.of("lib", "spring", "examples", "ktor", "micronaut", "tests") { apply { plugin("kotlin") plugin(rootProject.libs.plugins.spotless.get().pluginId) diff --git a/examples/spring-4x-example/build.gradle.kts b/examples/spring-4x-example/build.gradle.kts new file mode 100644 index 000000000..24cd95de2 --- /dev/null +++ b/examples/spring-4x-example/build.gradle.kts @@ -0,0 +1,29 @@ +plugins { + alias(libs.plugins.spring.plugin) + alias(libs.plugins.spring.boot.four) + idea + application +} + +dependencies { + implementation(libs.spring.boot.four) + implementation(libs.spring.boot.four.autoconfigure) + implementation(libs.spring.boot.four.webflux) + implementation(libs.spring.boot.four.actuator) + annotationProcessor(libs.spring.boot.four.annotationProcessor) + implementation(libs.spring.boot.four.kafka) + implementation(libs.kotlinx.reactor) + implementation(libs.kotlinx.core) + implementation(libs.kotlinx.reactive) + implementation(libs.kotlinx.slf4j) +} + +dependencies { + testImplementation(libs.jackson3.kotlin) + testImplementation(projects.stove.lib.stoveTestingE2eHttp) + testImplementation(projects.stove.lib.stoveTestingE2eWiremock) + testImplementation(projects.stove.starters.spring.stoveSpringTestingE2e) + testImplementation(projects.stove.starters.spring.stoveSpringTestingE2eKafka) +} + +application { mainClass.set("stove.spring.example4x.ExampleAppkt") } diff --git a/examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/ExampleApp.kt b/examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/ExampleApp.kt new file mode 100644 index 000000000..6f7ad860a --- /dev/null +++ b/examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/ExampleApp.kt @@ -0,0 +1,24 @@ +package stove.spring.example4x + +import org.springframework.boot.SpringApplication +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication +import org.springframework.context.ConfigurableApplicationContext + +@SpringBootApplication +class ExampleApp + +fun main(args: Array) { + run(args) +} + +/** + * This is the point where spring application gets run. + * run(args, init) method is the important point for the testing configuration. + * init allows us to override any dependency from the testing side that is being time related or configuration related. + * Spring itself opens this configuration higher order function to the outside. + */ +fun run( + args: Array, + init: SpringApplication.() -> Unit = {} +): ConfigurableApplicationContext = runApplication(*args, init = init) diff --git a/examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/application/handlers/ProductCreator.kt b/examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/application/handlers/ProductCreator.kt new file mode 100644 index 000000000..b86862772 --- /dev/null +++ b/examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/application/handlers/ProductCreator.kt @@ -0,0 +1,18 @@ +package stove.spring.example4x.application.handlers + +import org.springframework.stereotype.Service +import stove.spring.example4x.infrastructure.api.ProductCreateRequest + +@Service +class ProductCreator { + suspend fun create(request: ProductCreateRequest) { + // In a real application, this would persist the product + println("Creating product: ${request.name} with id ${request.id}") + } +} + +data class ProductCreatedEvent( + val id: Long, + val name: String, + val supplierId: Long +) diff --git a/examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/infrastructure/api/ProductController.kt b/examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/infrastructure/api/ProductController.kt new file mode 100644 index 000000000..466dd0399 --- /dev/null +++ b/examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/infrastructure/api/ProductController.kt @@ -0,0 +1,33 @@ +package stove.spring.example4x.infrastructure.api + +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import stove.spring.example4x.application.handlers.* +import stove.spring.example4x.infrastructure.messaging.kafka.KafkaProducer + +@RestController +@RequestMapping("/api") +class ProductController( + private val productCreator: ProductCreator, + private val kafkaProducer: KafkaProducer +) { + @GetMapping("/index") + suspend fun index( + @RequestParam(required = false) keyword: String? + ): ResponseEntity = ResponseEntity.ok("Hi from Stove framework with ${keyword ?: "no keyword"}") + + @PostMapping("/product/create") + suspend fun create( + @RequestBody request: ProductCreateRequest + ): ResponseEntity { + productCreator.create(request) + kafkaProducer.send(ProductCreatedEvent(request.id, request.name, request.supplierId)) + return ResponseEntity.ok().build() + } +} + +data class ProductCreateRequest( + val id: Long, + val name: String, + val supplierId: Long +) diff --git a/examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/infrastructure/messaging/kafka/KafkaConfiguration.kt b/examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/infrastructure/messaging/kafka/KafkaConfiguration.kt new file mode 100644 index 000000000..1359f1ae0 --- /dev/null +++ b/examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/infrastructure/messaging/kafka/KafkaConfiguration.kt @@ -0,0 +1,72 @@ +@file:Suppress("DEPRECATION") + +package stove.spring.example4x.infrastructure.messaging.kafka + +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.clients.producer.ProducerConfig +import org.apache.kafka.common.serialization.* +import org.springframework.boot.context.properties.* +import org.springframework.context.annotation.* +import org.springframework.kafka.annotation.EnableKafka +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.* +import org.springframework.kafka.listener.RecordInterceptor +import org.springframework.kafka.support.serializer.* + +@Configuration +@EnableKafka +@EnableConfigurationProperties(KafkaProperties::class) +class KafkaConfiguration { + @Bean + fun kafkaListenerContainerFactory( + consumerFactory: ConsumerFactory, + interceptor: RecordInterceptor? + ): ConcurrentKafkaListenerContainerFactory { + val factory = ConcurrentKafkaListenerContainerFactory() + factory.setConsumerFactory(consumerFactory) + interceptor?.let { factory.setRecordInterceptor(it) } + return factory + } + + @Bean + @Suppress("MagicNumber") + fun consumerFactory( + config: KafkaProperties + ): ConsumerFactory = DefaultKafkaConsumerFactory( + mapOf( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, + ConsumerConfig.GROUP_ID_CONFIG to config.groupId, + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to config.offset, + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to ErrorHandlingDeserializer::class.java, + ErrorHandlingDeserializer.VALUE_DESERIALIZER_CLASS to StringDeserializer::class.java, + ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG to config.heartbeatInSeconds * 1000, + ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG to config.heartbeatInSeconds * 3000, + ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG to config.heartbeatInSeconds * 3000 + ) + ) + + @Bean + fun kafkaTemplate( + config: KafkaProperties + ): KafkaTemplate = KafkaTemplate( + DefaultKafkaProducerFactory( + mapOf( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to JacksonJsonSerializer::class.java, + ProducerConfig.ACKS_CONFIG to config.acks + ) + ) + ) +} + +@ConfigurationProperties(prefix = "kafka") +data class KafkaProperties( + val bootstrapServers: String, + val groupId: String = "spring-4x-example", + val offset: String = "earliest", + val acks: String = "1", + val heartbeatInSeconds: Int = 3, + val topicPrefix: String = "trendyol.stove.service" +) diff --git a/examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/infrastructure/messaging/kafka/KafkaProducer.kt b/examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/infrastructure/messaging/kafka/KafkaProducer.kt new file mode 100644 index 000000000..79b3702c1 --- /dev/null +++ b/examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/infrastructure/messaging/kafka/KafkaProducer.kt @@ -0,0 +1,16 @@ +package stove.spring.example4x.infrastructure.messaging.kafka + +import org.springframework.kafka.core.KafkaTemplate +import org.springframework.stereotype.Component +import stove.spring.example4x.application.handlers.ProductCreatedEvent + +@Component +class KafkaProducer( + private val kafkaTemplate: KafkaTemplate, + private val kafkaProperties: KafkaProperties +) { + suspend fun send(event: ProductCreatedEvent) { + val topic = "${kafkaProperties.topicPrefix}.productCreated.1" + kafkaTemplate.send(topic, event.id.toString(), event) + } +} diff --git a/examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/infrastructure/messaging/kafka/ProductCreateConsumer.kt b/examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/infrastructure/messaging/kafka/ProductCreateConsumer.kt new file mode 100644 index 000000000..d73448dd2 --- /dev/null +++ b/examples/spring-4x-example/src/main/kotlin/stove/spring/example4x/infrastructure/messaging/kafka/ProductCreateConsumer.kt @@ -0,0 +1,33 @@ +package stove.spring.example4x.infrastructure.messaging.kafka + +import org.slf4j.* +import org.springframework.kafka.annotation.KafkaListener +import org.springframework.messaging.handler.annotation.* +import org.springframework.stereotype.Component +import stove.spring.example4x.application.handlers.ProductCreator +import stove.spring.example4x.infrastructure.api.ProductCreateRequest +import tools.jackson.databind.json.JsonMapper + +@Component +class ProductCreateConsumer( + private val productCreator: ProductCreator, + private val jsonMapper: JsonMapper +) { + private val logger: Logger = LoggerFactory.getLogger(javaClass) + + @KafkaListener(topics = ["trendyol.stove.service.product.create.0"], groupId = "\${kafka.groupId}") + suspend fun consume( + @Payload message: String, + @Header("X-UserEmail", required = false) userEmail: String? + ) { + logger.info("Received message: $message with userEmail: $userEmail") + val command = jsonMapper.readValue(message, CreateProductCommand::class.java) + productCreator.create(ProductCreateRequest(command.id, command.name, command.supplierId)) + } +} + +data class CreateProductCommand( + val id: Long, + val name: String, + val supplierId: Long +) diff --git a/examples/spring-4x-example/src/main/resources/application.yml b/examples/spring-4x-example/src/main/resources/application.yml new file mode 100644 index 000000000..448972ad0 --- /dev/null +++ b/examples/spring-4x-example/src/main/resources/application.yml @@ -0,0 +1,14 @@ +spring: + application: + name: "stove-spring-4x-example" + +server: + port: 8001 + +kafka: + bootstrapServers: localhost:9092 + topicPrefix: trendyol.stove.service + acks: "1" + offset: "latest" + heartbeatInSeconds: 30 + groupId: spring-4x-example diff --git a/examples/spring-4x-example/src/test/kotlin/com/stove/spring/example4x/e2e/ExampleTest.kt b/examples/spring-4x-example/src/test/kotlin/com/stove/spring/example4x/e2e/ExampleTest.kt new file mode 100644 index 000000000..cfdcf9f52 --- /dev/null +++ b/examples/spring-4x-example/src/test/kotlin/com/stove/spring/example4x/e2e/ExampleTest.kt @@ -0,0 +1,66 @@ +package com.stove.spring.example4x.e2e + +import arrow.core.some +import com.trendyol.stove.testing.e2e.http.* +import com.trendyol.stove.testing.e2e.kafka.kafka +import com.trendyol.stove.testing.e2e.system.* +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain +import stove.spring.example4x.application.handlers.ProductCreatedEvent +import stove.spring.example4x.infrastructure.api.ProductCreateRequest +import stove.spring.example4x.infrastructure.messaging.kafka.CreateProductCommand +import kotlin.time.Duration.Companion.seconds + +class ExampleTest : + FunSpec({ + test("index should be reachable") { + TestSystem.validate { + http { + get("/api/index", queryParams = mapOf("keyword" to testCase.name.name)) { actual -> + actual shouldContain "Hi from Stove framework with ${testCase.name.name}" + println(actual) + } + get("/api/index") { actual -> + actual shouldContain "Hi from Stove framework with" + println(actual) + } + } + } + } + + test("should create new product when send product create request from api") { + TestSystem.validate { + val productCreateRequest = ProductCreateRequest(1L, name = "product name", 99L) + + http { + postAndExpectBodilessResponse(uri = "/api/product/create", body = productCreateRequest.some()) { actual -> + actual.status shouldBe 200 + } + } + + kafka { + shouldBePublished { + actual.id == productCreateRequest.id && + actual.name == productCreateRequest.name && + actual.supplierId == productCreateRequest.supplierId + } + } + } + } + + test("should consume product create command from kafka") { + TestSystem.validate { + val createProductCommand = CreateProductCommand(2L, name = "product from kafka", 100L) + + kafka { + publish("trendyol.stove.service.product.create.0", createProductCommand) + shouldBeConsumed(10.seconds) { + actual.id == createProductCommand.id && + actual.name == createProductCommand.name && + actual.supplierId == createProductCommand.supplierId + } + } + } + } + }) diff --git a/examples/spring-4x-example/src/test/kotlin/com/stove/spring/example4x/e2e/Stove.kt b/examples/spring-4x-example/src/test/kotlin/com/stove/spring/example4x/e2e/Stove.kt new file mode 100644 index 000000000..1dcf1a504 --- /dev/null +++ b/examples/spring-4x-example/src/test/kotlin/com/stove/spring/example4x/e2e/Stove.kt @@ -0,0 +1,68 @@ +package com.stove.spring.example4x.e2e + +import com.trendyol.stove.testing.e2e.* +import com.trendyol.stove.testing.e2e.http.* +import com.trendyol.stove.testing.e2e.kafka.* +import com.trendyol.stove.testing.e2e.system.TestSystem +import com.trendyol.stove.testing.e2e.wiremock.* +import io.kotest.core.config.AbstractProjectConfig +import org.slf4j.* +import tools.jackson.databind.json.JsonMapper + +class Stove : AbstractProjectConfig() { + private val logger: Logger = LoggerFactory.getLogger("WireMockMonitor") + + override suspend fun beforeProject(): Unit = + TestSystem() + .with { + httpClient { + HttpClientSystemOptions( + baseUrl = "http://localhost:8005" + ) + } + kafka { + KafkaSystemOptions( + containerOptions = KafkaContainerOptions(tag = "7.8.1"), + configureExposedConfiguration = { + listOf( + "kafka.bootstrapServers=${it.bootstrapServers}", + "kafka.groupId=spring-4x-example" + ) + } + ) + } + bridge() + wiremock { + WireMockSystemOptions( + port = 7080, + removeStubAfterRequestMatched = true, + afterRequest = { e, _ -> + logger.info(e.request.toString()) + } + ) + } + springBoot( + runner = { parameters -> + stove.spring.example4x.run(parameters) { + addTestDependencies4x { + registerBean>(primary = true) + registerBean { + val jsonMapper = this.bean() + StoveJackson3ThroughIfStringSerde(jsonMapper) + } + } + } + }, + withParameters = listOf( + "server.port=8005", + "logging.level.root=info", + "logging.level.org.springframework.web=info", + "spring.profiles.active=default", + "kafka.heartbeatInSeconds=2", + "kafka.offset=earliest" + ) + ) + }.run() + + override suspend fun afterProject(): Unit = TestSystem.stop() +} diff --git a/examples/spring-4x-example/src/test/kotlin/com/stove/spring/example4x/e2e/jackson3.kt b/examples/spring-4x-example/src/test/kotlin/com/stove/spring/example4x/e2e/jackson3.kt new file mode 100644 index 000000000..dc19cd1f0 --- /dev/null +++ b/examples/spring-4x-example/src/test/kotlin/com/stove/spring/example4x/e2e/jackson3.kt @@ -0,0 +1,41 @@ +package com.stove.spring.example4x.e2e + +import com.trendyol.stove.testing.e2e.serialization.StoveSerde +import org.slf4j.LoggerFactory +import tools.jackson.databind.json.JsonMapper + +class StoveJackson3ThroughIfStringSerde( + private val jsonMapper: JsonMapper +) : StoveSerde { + private val logger = LoggerFactory.getLogger(javaClass) + + override fun serialize(value: Any): ByteArray = when (value) { + is ByteArray -> { + logger.info("Value is already a ByteArray, returning as is.") + value + } + + is String -> { + logger.info("Serializing String value.") + val byteArray = value.toByteArray() + byteArray + } + + else -> { + logger.info("Serializing value of type: {}", value::class.java.name) + val byteArray = runCatching { jsonMapper.writeValueAsBytes(value) } + .onFailure { logger.error("Serialization failed", it) } + .getOrThrow() + byteArray + } + } + + override fun deserialize(value: ByteArray, clazz: Class): T { + logger.info("Deserializing to class: {}", clazz.name) + val value = runCatching { + jsonMapper.readValue(value, clazz) + }.onFailure { logger.error("Deserialization failed", it) }.getOrThrow() + logger.info("Deserialized value: {}", value) + return value + } +} diff --git a/examples/spring-4x-example/src/test/resources/kotest.properties b/examples/spring-4x-example/src/test/resources/kotest.properties new file mode 100644 index 000000000..2fa893cd3 --- /dev/null +++ b/examples/spring-4x-example/src/test/resources/kotest.properties @@ -0,0 +1 @@ +kotest.framework.config.fqn=com.stove.spring.example4x.e2e.Stove diff --git a/starters/spring/stove-spring-testing-e2e-kafka/src/test/resources/logback-test.xml b/examples/spring-4x-example/src/test/resources/logback-test.xml similarity index 100% rename from starters/spring/stove-spring-testing-e2e-kafka/src/test/resources/logback-test.xml rename to examples/spring-4x-example/src/test/resources/logback-test.xml diff --git a/examples/spring-example/src/test/kotlin/com/stove/spring/example/e2e/Stove.kt b/examples/spring-example/src/test/kotlin/com/stove/spring/example/e2e/Stove.kt index 11a061db4..6190e7e8e 100644 --- a/examples/spring-example/src/test/kotlin/com/stove/spring/example/e2e/Stove.kt +++ b/examples/spring-example/src/test/kotlin/com/stove/spring/example/e2e/Stove.kt @@ -38,13 +38,14 @@ class Stove : AbstractProjectConfig() { } kafka { KafkaSystemOptions( - containerOptions = KafkaContainerOptions(tag = "7.8.1") - ) { - listOf( - "kafka.bootstrapServers=${it.bootstrapServers}", - "kafka.isSecure=false" - ) - } + containerOptions = KafkaContainerOptions(tag = "7.8.1"), + configureExposedConfiguration = { + listOf( + "kafka.bootstrapServers=${it.bootstrapServers}", + "kafka.isSecure=false" + ) + } + ) } bridge() wiremock { diff --git a/examples/spring-example/src/test/kotlin/com/stove/spring/example/e2e/TestSystemInitializer.kt b/examples/spring-example/src/test/kotlin/com/stove/spring/example/e2e/TestSystemInitializer.kt index bbcad7347..87e981847 100644 --- a/examples/spring-example/src/test/kotlin/com/stove/spring/example/e2e/TestSystemInitializer.kt +++ b/examples/spring-example/src/test/kotlin/com/stove/spring/example/e2e/TestSystemInitializer.kt @@ -1,17 +1,16 @@ package com.stove.spring.example.e2e -import com.trendyol.stove.testing.e2e.BaseApplicationContextInitializer import com.trendyol.stove.testing.e2e.kafka.TestSystemKafkaInterceptor import com.trendyol.stove.testing.e2e.serialization.* +import com.trendyol.stove.testing.e2e.stoveSpringRegistrar import org.springframework.boot.SpringApplication import stove.spring.example.infrastructure.ObjectMapperConfig fun SpringApplication.addTestSystemDependencies() { - this.addInitializers(TestSystemInitializer()) + this.addInitializers( + stoveSpringRegistrar { + bean>(isPrimary = true) + bean { StoveSerde.jackson.anyByteArraySerde(ObjectMapperConfig.default()) } + } + ) } - -class TestSystemInitializer : - BaseApplicationContextInitializer({ - bean>(isPrimary = true) - bean { StoveSerde.jackson.anyByteArraySerde(ObjectMapperConfig.default()) } - }) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c4a025d29..132715225 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,13 +3,16 @@ kotlin = "2.2.21" kotlinx = "1.10.2" spring-boot = "2.7.18" spring-boot-3x = "3.5.8" +spring-boot-4x = "4.0.0" spring-dependency-management = "1.1.7" spring-kafka = "2.9.13" spring-kafka-3x = "3.3.11" +spring-kafka-4x = "4.0.0" couchbase-client = "3.10.0" couchbase-client-metrics = "3.10.0" couchbase-kotlin = "3.10.0" jackson = "2.20.1" +jackson3 = "3.0.0" arrow = "2.2.0" io-reactor = "3.8.0" io-reactor-extensions = "1.3.0" @@ -88,6 +91,9 @@ jackson-databind = { module = "com.fasterxml.jackson.core:jackson-databind", ver jackson-arrow = { module = "io.arrow-kt:arrow-integrations-jackson-module", version = "0.15.1" } jackson-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310", version.ref = "jackson" } +# Jackson 3 +jackson3-kotlin = { module = "tools.jackson.module:jackson-module-kotlin", version.ref = "jackson3" } + # Slfj4 slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" } @@ -154,6 +160,14 @@ spring-boot-three-autoconfigure = { module = "org.springframework.boot:spring-bo spring-boot-three-annotationProcessor = { module = "org.springframework.boot:spring-boot-configuration-processor", version.ref = "spring-boot-3x" } spring-boot-three-kafka = { module = "org.springframework.kafka:spring-kafka", version.ref = "spring-kafka-3x" } +# spring-boot 4x +spring-boot-four = { module = "org.springframework.boot:spring-boot", version.ref = "spring-boot-4x" } +spring-boot-four-webflux = { module = "org.springframework.boot:spring-boot-starter-webflux", version.ref = "spring-boot-4x" } +spring-boot-four-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "spring-boot-4x" } +spring-boot-four-autoconfigure = { module = "org.springframework.boot:spring-boot-autoconfigure", version.ref = "spring-boot-4x" } +spring-boot-four-annotationProcessor = { module = "org.springframework.boot:spring-boot-configuration-processor", version.ref = "spring-boot-4x" } +spring-boot-four-kafka = { module = "org.springframework.kafka:spring-kafka", version.ref = "spring-kafka-4x" } + detekt-formatting = { module = "io.gitlab.arturbosch.detekt:detekt-formatting", version.ref = "detekt" } wire-grpc-server = { module = "com.squareup.wiregrpcserver:server", version = "1.0.0-alpha04" } wire-grpc-server-generator = { module = "com.squareup.wiregrpcserver:server-generator", version = "1.0.0-alpha04" } @@ -184,6 +198,7 @@ allopen = { id = "org.jetbrains.kotlin.plugin.allopen", version.ref = "kotlin" } spring-plugin = { id = "org.jetbrains.kotlin.plugin.spring", version.ref = "kotlin" } spring-boot = { id = "org.springframework.boot", version.ref = "spring-boot" } spring-boot-three = { id = "org.springframework.boot", version.ref = "spring-boot-3x" } +spring-boot-four = { id = "org.springframework.boot", version.ref = "spring-boot-4x" } spring-dependencyManagement = { id = "io.spring.dependency-management", version.ref = "spring-dependency-management" } kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } diff --git a/settings.gradle.kts b/settings.gradle.kts index ba3bec2a1..8e7340118 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -19,11 +19,18 @@ include( "starters:ktor:stove-ktor-testing-e2e", "starters:spring:stove-spring-testing-e2e", "starters:spring:stove-spring-testing-e2e-kafka", + "starters:spring:tests:spring-2x-tests", + "starters:spring:tests:spring-2x-kafka-tests", + "starters:spring:tests:spring-3x-tests", + "starters:spring:tests:spring-3x-kafka-tests", + "starters:spring:tests:spring-4x-tests", + "starters:spring:tests:spring-4x-kafka-tests", "starters:micronaut:stove-micronaut-testing-e2e" ) include( "examples:spring-example", "examples:spring-standalone-example", + "examples:spring-4x-example", "examples:ktor-example", "examples:spring-streams-example", "examples:micronaut-example" diff --git a/starters/spring/stove-spring-testing-e2e-kafka/api/stove-spring-testing-e2e-kafka-common.api b/starters/spring/stove-spring-testing-e2e-kafka/api/stove-spring-testing-e2e-kafka-common.api new file mode 100644 index 000000000..8f954fd6d --- /dev/null +++ b/starters/spring/stove-spring-testing-e2e-kafka/api/stove-spring-testing-e2e-kafka-common.api @@ -0,0 +1,189 @@ +public final class com/trendyol/stove/testing/e2e/kafka/Caching { + public static final field INSTANCE Lcom/trendyol/stove/testing/e2e/kafka/Caching; + public final fun of ()Lcom/github/benmanes/caffeine/cache/Cache; +} + +public final class com/trendyol/stove/testing/e2e/kafka/FallbackTemplateSerde { + public fun ()V + public fun (Lorg/apache/kafka/common/serialization/Serializer;Lorg/apache/kafka/common/serialization/Serializer;)V + public synthetic fun (Lorg/apache/kafka/common/serialization/Serializer;Lorg/apache/kafka/common/serialization/Serializer;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Lorg/apache/kafka/common/serialization/Serializer; + public final fun component2 ()Lorg/apache/kafka/common/serialization/Serializer; + public final fun copy (Lorg/apache/kafka/common/serialization/Serializer;Lorg/apache/kafka/common/serialization/Serializer;)Lcom/trendyol/stove/testing/e2e/kafka/FallbackTemplateSerde; + public static synthetic fun copy$default (Lcom/trendyol/stove/testing/e2e/kafka/FallbackTemplateSerde;Lorg/apache/kafka/common/serialization/Serializer;Lorg/apache/kafka/common/serialization/Serializer;ILjava/lang/Object;)Lcom/trendyol/stove/testing/e2e/kafka/FallbackTemplateSerde; + public fun equals (Ljava/lang/Object;)Z + public final fun getKeySerializer ()Lorg/apache/kafka/common/serialization/Serializer; + public final fun getValueSerializer ()Lorg/apache/kafka/common/serialization/Serializer; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/trendyol/stove/testing/e2e/kafka/KafkaContainerOptions : com/trendyol/stove/testing/e2e/containers/ContainerOptions { + public fun ()V + public fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun component3 ()Ljava/lang/String; + public final fun component4 ()Ljava/lang/String; + public final fun component5 ()Lkotlin/jvm/functions/Function1; + public final fun component6 ()Lkotlin/jvm/functions/Function1; + public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaContainerOptions; + public static synthetic fun copy$default (Lcom/trendyol/stove/testing/e2e/kafka/KafkaContainerOptions;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function1;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaContainerOptions; + public fun equals (Ljava/lang/Object;)Z + public fun getCompatibleSubstitute ()Ljava/lang/String; + public fun getContainerFn ()Lkotlin/jvm/functions/Function1; + public fun getImage ()Ljava/lang/String; + public fun getImageWithTag ()Ljava/lang/String; + public fun getRegistry ()Ljava/lang/String; + public fun getTag ()Ljava/lang/String; + public fun getUseContainerFn ()Lkotlin/jvm/functions/Function1; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/trendyol/stove/testing/e2e/kafka/KafkaContext { + public fun (Lcom/trendyol/stove/testing/e2e/system/abstractions/SystemRuntime;Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions;)V + public final fun component1 ()Lcom/trendyol/stove/testing/e2e/system/abstractions/SystemRuntime; + public final fun component2 ()Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions; + public final fun copy (Lcom/trendyol/stove/testing/e2e/system/abstractions/SystemRuntime;Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaContext; + public static synthetic fun copy$default (Lcom/trendyol/stove/testing/e2e/kafka/KafkaContext;Lcom/trendyol/stove/testing/e2e/system/abstractions/SystemRuntime;Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions;ILjava/lang/Object;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaContext; + public fun equals (Ljava/lang/Object;)Z + public final fun getOptions ()Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions; + public final fun getRuntime ()Lcom/trendyol/stove/testing/e2e/system/abstractions/SystemRuntime; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface annotation class com/trendyol/stove/testing/e2e/kafka/KafkaDsl : java/lang/annotation/Annotation { +} + +public final class com/trendyol/stove/testing/e2e/kafka/KafkaExposedConfiguration : com/trendyol/stove/testing/e2e/system/abstractions/ExposedConfiguration { + public fun (Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaExposedConfiguration; + public static synthetic fun copy$default (Lcom/trendyol/stove/testing/e2e/kafka/KafkaExposedConfiguration;Ljava/lang/String;ILjava/lang/Object;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaExposedConfiguration; + public fun equals (Ljava/lang/Object;)Z + public final fun getBootstrapServers ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/trendyol/stove/testing/e2e/kafka/KafkaMigrationContext { + public fun (Lorg/apache/kafka/clients/admin/Admin;Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions;)V + public final fun component1 ()Lorg/apache/kafka/clients/admin/Admin; + public final fun component2 ()Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions; + public final fun copy (Lorg/apache/kafka/clients/admin/Admin;Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaMigrationContext; + public static synthetic fun copy$default (Lcom/trendyol/stove/testing/e2e/kafka/KafkaMigrationContext;Lorg/apache/kafka/clients/admin/Admin;Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions;ILjava/lang/Object;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaMigrationContext; + public fun equals (Ljava/lang/Object;)Z + public final fun getAdmin ()Lorg/apache/kafka/clients/admin/Admin; + public final fun getOptions ()Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/trendyol/stove/testing/e2e/kafka/KafkaOps { + public fun (Lkotlin/jvm/functions/Function3;)V + public final fun component1 ()Lkotlin/jvm/functions/Function3; + public final fun copy (Lkotlin/jvm/functions/Function3;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps; + public static synthetic fun copy$default (Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps;Lkotlin/jvm/functions/Function3;ILjava/lang/Object;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps; + public fun equals (Ljava/lang/Object;)Z + public final fun getSend ()Lkotlin/jvm/functions/Function3; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/trendyol/stove/testing/e2e/kafka/KafkaSystem : com/trendyol/stove/testing/e2e/system/abstractions/ExposesConfiguration, com/trendyol/stove/testing/e2e/system/abstractions/PluggedSystem, com/trendyol/stove/testing/e2e/system/abstractions/RunnableSystemWithContext { + public static final field Companion Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystem$Companion; + public fun (Lcom/trendyol/stove/testing/e2e/system/TestSystem;Lcom/trendyol/stove/testing/e2e/kafka/KafkaContext;)V + public final fun adminOperations (Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public synthetic fun afterRun (Ljava/lang/Object;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun afterRun (Lorg/springframework/context/ApplicationContext;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun beforeRun (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun close ()V + public fun configuration ()Ljava/util/List; + public fun executeWithReuseCheck (Lkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun getGetInterceptor ()Lkotlin/jvm/functions/Function0; + public fun getTestSystem ()Lcom/trendyol/stove/testing/e2e/system/TestSystem; + public final fun pause ()Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystem; + public final fun publish (Ljava/lang/String;Ljava/lang/Object;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static synthetic fun publish$default (Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystem;Ljava/lang/String;Ljava/lang/Object;Larrow/core/Option;Larrow/core/Option;Ljava/util/Map;Larrow/core/Option;Larrow/core/Option;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object; + public fun run (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun shouldBeConsumedInternal-dWUq8MI (Lkotlin/reflect/KClass;JLkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun shouldBeFailedInternal-dWUq8MI (Lkotlin/reflect/KClass;JLkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public final fun shouldBePublishedInternal-dWUq8MI (Lkotlin/reflect/KClass;JLkotlin/jvm/functions/Function1;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun then ()Lcom/trendyol/stove/testing/e2e/system/TestSystem; + public final fun unpause ()Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystem; +} + +public final class com/trendyol/stove/testing/e2e/kafka/KafkaSystem$Companion { + public final fun kafkaTemplate (Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystem;)Lorg/springframework/kafka/core/KafkaTemplate; +} + +public class com/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions : com/trendyol/stove/testing/e2e/database/migrations/SupportsMigrations, com/trendyol/stove/testing/e2e/system/abstractions/ConfiguresExposedConfiguration, com/trendyol/stove/testing/e2e/system/abstractions/SystemOptions { + public static final field Companion Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions$Companion; + public fun (Ljava/lang/String;Ljava/util/List;Lcom/trendyol/stove/testing/e2e/kafka/FallbackTemplateSerde;Lcom/trendyol/stove/testing/e2e/kafka/KafkaContainerOptions;Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)V + public synthetic fun (Ljava/lang/String;Ljava/util/List;Lcom/trendyol/stove/testing/e2e/kafka/FallbackTemplateSerde;Lcom/trendyol/stove/testing/e2e/kafka/KafkaContainerOptions;Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps;Lkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun getCleanup ()Lkotlin/jvm/functions/Function2; + public fun getConfigureExposedConfiguration ()Lkotlin/jvm/functions/Function1; + public fun getContainerOptions ()Lcom/trendyol/stove/testing/e2e/kafka/KafkaContainerOptions; + public fun getFallbackSerde ()Lcom/trendyol/stove/testing/e2e/kafka/FallbackTemplateSerde; + public fun getMigrationCollection ()Lcom/trendyol/stove/testing/e2e/database/migrations/MigrationCollection; + public fun getOps ()Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps; + public fun getPorts ()Ljava/util/List; + public fun getRegistry ()Ljava/lang/String; + public synthetic fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/testing/e2e/database/migrations/SupportsMigrations; + public fun migrations (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions; +} + +public final class com/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions$Companion { + public final fun getDEFAULT_KAFKA_PORTS ()Ljava/util/List; + public final fun provided (Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lcom/trendyol/stove/testing/e2e/kafka/FallbackTemplateSerde;Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/testing/e2e/kafka/ProvidedKafkaSystemOptions; + public static synthetic fun provided$default (Lcom/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions$Companion;Ljava/lang/String;Ljava/lang/String;Ljava/util/List;Lcom/trendyol/stove/testing/e2e/kafka/FallbackTemplateSerde;Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps;ZLkotlin/jvm/functions/Function2;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Lcom/trendyol/stove/testing/e2e/kafka/ProvidedKafkaSystemOptions; +} + +public abstract interface class com/trendyol/stove/testing/e2e/kafka/MessageProperties { + public abstract fun getKey ()Ljava/lang/String; + public abstract fun getMetadata ()Lcom/trendyol/stove/testing/e2e/messaging/MessageMetadata; + public abstract fun getPartition ()Ljava/lang/Integer; + public abstract fun getTimestamp ()Ljava/lang/Long; + public abstract fun getTopic ()Ljava/lang/String; + public abstract fun getValue ()[B + public abstract fun getValueAsString ()Ljava/lang/String; +} + +public final class com/trendyol/stove/testing/e2e/kafka/OptionsKt { + public static final fun kafka-E6EcY7A (Lcom/trendyol/stove/testing/e2e/system/TestSystem;Lkotlin/jvm/functions/Function2;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public static final fun kafka-PmNtuJU (Lcom/trendyol/stove/testing/e2e/system/TestSystem;Lkotlin/jvm/functions/Function0;)Lcom/trendyol/stove/testing/e2e/system/TestSystem; +} + +public final class com/trendyol/stove/testing/e2e/kafka/ProvidedKafkaSystemOptions : com/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions, com/trendyol/stove/testing/e2e/system/abstractions/ProvidedSystemOptions { + public fun (Lcom/trendyol/stove/testing/e2e/kafka/KafkaExposedConfiguration;Ljava/lang/String;Ljava/util/List;Lcom/trendyol/stove/testing/e2e/kafka/FallbackTemplateSerde;Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps;Lkotlin/jvm/functions/Function2;ZLkotlin/jvm/functions/Function1;)V + public synthetic fun (Lcom/trendyol/stove/testing/e2e/kafka/KafkaExposedConfiguration;Ljava/lang/String;Ljava/util/List;Lcom/trendyol/stove/testing/e2e/kafka/FallbackTemplateSerde;Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps;Lkotlin/jvm/functions/Function2;ZLkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun getConfig ()Lcom/trendyol/stove/testing/e2e/kafka/KafkaExposedConfiguration; + public fun getProvidedConfig ()Lcom/trendyol/stove/testing/e2e/kafka/KafkaExposedConfiguration; + public synthetic fun getProvidedConfig ()Lcom/trendyol/stove/testing/e2e/system/abstractions/ExposedConfiguration; + public final fun getRunMigrations ()Z + public fun getRunMigrationsForProvided ()Z +} + +public class com/trendyol/stove/testing/e2e/kafka/StoveKafkaContainer : org/testcontainers/kafka/ConfluentKafkaContainer, com/trendyol/stove/testing/e2e/containers/StoveContainer { + public fun (Lorg/testcontainers/utility/DockerImageName;)V + public fun execCommand ([Ljava/lang/String;J)Lcom/trendyol/stove/testing/e2e/containers/ExecResult; + public fun getContainerIdAccess ()Ljava/lang/String; + public fun getDockerClientAccess ()Lkotlin/Lazy; + public fun getImageNameAccess ()Lorg/testcontainers/utility/DockerImageName; + public fun inspect ()Lcom/trendyol/stove/testing/e2e/containers/StoveContainerInspectInformation; + public fun pause ()V + public fun unpause ()V +} + +public final class com/trendyol/stove/testing/e2e/kafka/TestSystemKafkaInterceptor : org/springframework/kafka/listener/CompositeRecordInterceptor, org/springframework/kafka/support/ProducerListener { + public fun (Lcom/trendyol/stove/testing/e2e/serialization/StoveSerde;)V + public fun failure (Lorg/apache/kafka/clients/consumer/ConsumerRecord;Ljava/lang/Exception;Lorg/apache/kafka/clients/consumer/Consumer;)V + public fun onError (Lorg/apache/kafka/clients/producer/ProducerRecord;Lorg/apache/kafka/clients/producer/RecordMetadata;Ljava/lang/Exception;)V + public fun onSuccess (Lorg/apache/kafka/clients/producer/ProducerRecord;Lorg/apache/kafka/clients/producer/RecordMetadata;)V + public fun success (Lorg/apache/kafka/clients/consumer/ConsumerRecord;Lorg/apache/kafka/clients/consumer/Consumer;)V +} + diff --git a/starters/spring/stove-spring-testing-e2e-kafka/api/stove-spring-testing-e2e-kafka.api b/starters/spring/stove-spring-testing-e2e-kafka/api/stove-spring-testing-e2e-kafka.api index 0d4104e07..a80435116 100644 --- a/starters/spring/stove-spring-testing-e2e-kafka/api/stove-spring-testing-e2e-kafka.api +++ b/starters/spring/stove-spring-testing-e2e-kafka/api/stove-spring-testing-e2e-kafka.api @@ -83,9 +83,7 @@ public final class com/trendyol/stove/testing/e2e/kafka/KafkaMigrationContext { } public final class com/trendyol/stove/testing/e2e/kafka/KafkaOps { - public fun ()V public fun (Lkotlin/jvm/functions/Function3;)V - public synthetic fun (Lkotlin/jvm/functions/Function3;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun component1 ()Lkotlin/jvm/functions/Function3; public final fun copy (Lkotlin/jvm/functions/Function3;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps; public static synthetic fun copy$default (Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps;Lkotlin/jvm/functions/Function3;ILjava/lang/Object;)Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps; @@ -146,6 +144,7 @@ public final class com/trendyol/stove/testing/e2e/kafka/KafkaSystemOptions$Compa } public final class com/trendyol/stove/testing/e2e/kafka/KafkaTemplateCompatibilityKt { + public static final fun defaultKafkaOps ()Lcom/trendyol/stove/testing/e2e/kafka/KafkaOps; public static final fun sendCompatible (Lorg/springframework/kafka/core/KafkaTemplate;Lorg/apache/kafka/clients/producer/ProducerRecord;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; } diff --git a/starters/spring/stove-spring-testing-e2e-kafka/build.gradle.kts b/starters/spring/stove-spring-testing-e2e-kafka/build.gradle.kts index c0f5a92d4..147b6f948 100644 --- a/starters/spring/stove-spring-testing-e2e-kafka/build.gradle.kts +++ b/starters/spring/stove-spring-testing-e2e-kafka/build.gradle.kts @@ -1,42 +1,11 @@ -import com.google.protobuf.gradle.id - -plugins { - alias(libs.plugins.protobuf) -} - dependencies { - api(projects.lib.stoveTestingE2e) - api(libs.testcontainers.kafka) - implementation(libs.spring.boot.kafka) - implementation(libs.caffeine) - implementation(libs.pprint) + api(projects.lib.stoveTestingE2e) + api(libs.testcontainers.kafka) + compileOnly(libs.spring.boot.kafka) + implementation(libs.caffeine) + implementation(libs.pprint) } dependencies { - testAnnotationProcessor(libs.spring.boot.annotationProcessor) - testImplementation(libs.spring.boot.autoconfigure) - testImplementation(projects.starters.spring.stoveSpringTestingE2e) - testImplementation(libs.logback.classic) - testImplementation(libs.google.protobuf.kotlin) - testImplementation(libs.kafka.streams.protobuf.serde) -} - -tasks.test.configure { - systemProperty("kotest.framework.config.fqn", "com.trendyol.stove.testing.e2e.kafka.Setup") + testImplementation(libs.kotest.runner.junit5) } - -protobuf { - protoc { - artifact = libs.protoc.get().toString() - } - - generateProtoTasks { - all().forEach { - it.descriptorSetOptions.includeSourceInfo = true - it.descriptorSetOptions.includeImports = true - it.builtins { id("kotlin") } - } - } -} - - diff --git a/starters/spring/stove-spring-testing-e2e-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/KafkaSystem.kt b/starters/spring/stove-spring-testing-e2e-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/KafkaSystem.kt index 7ae40fdd8..bdb36ddce 100644 --- a/starters/spring/stove-spring-testing-e2e-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/KafkaSystem.kt +++ b/starters/spring/stove-spring-testing-e2e-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/KafkaSystem.kt @@ -279,15 +279,15 @@ class KafkaSystem( throw AssertionError( "Kafka interceptor is not an instance of TestSystemKafkaInterceptor, " + "please make sure that you have configured the Stove Kafka interceptor in your Spring Application properly." + - "You can use a TestSystemInitializer to add the interceptor to your Spring Application: " + + "You can use stoveSpringRegistrar to add the interceptor to your Spring Application: " + """ - fun SpringApplication.addTestSystemDependencies() { - this.addInitializers(TestSystemInitializer()) - } - - class TestSystemInitializer : BaseApplicationContextInitializer({ - bean>(isPrimary = true) - }) + TestAppRunner.run(params) { + addInitializers( + stoveSpringRegistrar { + bean>(isPrimary = true) + } + ) + } """.trimIndent() ) } diff --git a/starters/spring/stove-spring-testing-e2e-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/KafkaTemplateCompatibility.kt b/starters/spring/stove-spring-testing-e2e-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/KafkaTemplateCompatibility.kt index 289fd5cf6..338e0b7df 100644 --- a/starters/spring/stove-spring-testing-e2e-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/KafkaTemplateCompatibility.kt +++ b/starters/spring/stove-spring-testing-e2e-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/KafkaTemplateCompatibility.kt @@ -3,19 +3,45 @@ package com.trendyol.stove.testing.e2e.kafka import kotlinx.coroutines.future.await import org.apache.kafka.clients.producer.ProducerRecord import org.springframework.kafka.core.KafkaTemplate -import org.springframework.util.concurrent.ListenableFuture import java.util.concurrent.CompletableFuture /** * Sends a [ProducerRecord] using the [KafkaTemplate] and waits for the result. * This method is used to send a record to Kafka in a compatible way with different versions of Spring Kafka. - * Supports Spring-Kafka 2.x and 3.x + * + * Supports: + * - Spring Kafka 2.x (ListenableFuture) + * - Spring Kafka 3.x (CompletableFuture with ListenableFuture backward compatibility) + * - Spring Kafka 4.x (CompletableFuture only) + * + * Uses reflection to avoid compile-time dependency on ListenableFuture which doesn't exist in Spring 4.x. */ suspend fun KafkaTemplate<*, *>.sendCompatible(record: ProducerRecord<*, *>) { val method = this::class.java.getDeclaredMethod("send", ProducerRecord::class.java).apply { isAccessible = true } - when (method.returnType.kotlin) { - CompletableFuture::class -> (method.invoke(this, record) as CompletableFuture<*>).await() - ListenableFuture::class -> (method.invoke(this, record) as ListenableFuture<*>).completable().await() - else -> error("Unsupported return type for KafkaTemplate.send method: ${method.returnType}") + val returnType = method.returnType + val result = method.invoke(this, record) + + when { + CompletableFuture::class.java.isAssignableFrom(returnType) -> { + (result as CompletableFuture<*>).await() + } + + returnType.name == "org.springframework.util.concurrent.ListenableFuture" -> { + // Use reflection to call completable() method for Spring Kafka 2.x/3.x ListenableFuture + val completableMethod = result.javaClass.getMethod("completable") + (completableMethod.invoke(result) as CompletableFuture<*>).await() + } + + else -> { + error("Unsupported return type for KafkaTemplate.send method: $returnType") + } } } + +/** + * Default KafkaOps that uses the compatible send method. + * Works with Spring Kafka 2.x, 3.x, and 4.x. + */ +fun defaultKafkaOps(): KafkaOps = KafkaOps( + send = { kafkaTemplate, record -> kafkaTemplate.sendCompatible(record) } +) diff --git a/starters/spring/stove-spring-testing-e2e-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/Options.kt b/starters/spring/stove-spring-testing-e2e-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/Options.kt index c01ac3610..503856f91 100644 --- a/starters/spring/stove-spring-testing-e2e-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/Options.kt +++ b/starters/spring/stove-spring-testing-e2e-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/Options.kt @@ -33,11 +33,15 @@ data class KafkaContainerOptions( override val containerFn: ContainerFn = { } ) : ContainerOptions +/** + * Operations for Kafka. It is used to customize the operations of Kafka. + * The reason why this exists is to provide a way to interact with lower versions of Spring-Kafka dependencies. + */ data class KafkaOps( val send: suspend ( KafkaTemplate<*, *>, ProducerRecord<*, *> - ) -> Unit = { kafkaTemplate, record -> kafkaTemplate.sendCompatible(record) } + ) -> Unit ) data class FallbackTemplateSerde( @@ -83,10 +87,11 @@ open class KafkaSystemOptions( open val containerOptions: KafkaContainerOptions = KafkaContainerOptions(), /** * Operations for Kafka. It is used to customize the operations of Kafka. - * The reason why this exists is to provide a way to interact with lower versions of Spring-Kafka dependencies. + * Defaults to [defaultKafkaOps] which works with Spring Kafka 2.x, 3.x, and 4.x. * @see KafkaOps + * @see defaultKafkaOps */ - open val ops: KafkaOps = KafkaOps(), + open val ops: KafkaOps = defaultKafkaOps(), /** * A suspend function to clean up data after tests complete. */ @@ -122,7 +127,7 @@ open class KafkaSystemOptions( registry: String = DEFAULT_REGISTRY, ports: List = DEFAULT_KAFKA_PORTS, fallbackSerde: FallbackTemplateSerde = FallbackTemplateSerde(), - ops: KafkaOps = KafkaOps(), + ops: KafkaOps = defaultKafkaOps(), runMigrations: Boolean = true, cleanup: suspend (Admin) -> Unit = {}, configureExposedConfiguration: (KafkaExposedConfiguration) -> List @@ -152,7 +157,7 @@ class ProvidedKafkaSystemOptions( registry: String = DEFAULT_REGISTRY, ports: List = DEFAULT_KAFKA_PORTS, fallbackSerde: FallbackTemplateSerde = FallbackTemplateSerde(), - ops: KafkaOps = KafkaOps(), + ops: KafkaOps = defaultKafkaOps(), cleanup: suspend (Admin) -> Unit = {}, /** * Whether to run migrations on the external instance. @@ -224,6 +229,7 @@ internal fun TestSystem.kafka(): KafkaSystem = getOrNone().getOrEls fun WithDsl.kafka( configure: () -> KafkaSystemOptions ): TestSystem { + SpringKafkaVersionCheck.ensureSpringKafkaAvailable() val options = configure() val runtime: SystemRuntime = if (options is ProvidedKafkaSystemOptions) { diff --git a/starters/spring/stove-spring-testing-e2e-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/SpringKafkaVersionCheck.kt b/starters/spring/stove-spring-testing-e2e-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/SpringKafkaVersionCheck.kt new file mode 100644 index 000000000..b2e13ac22 --- /dev/null +++ b/starters/spring/stove-spring-testing-e2e-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/SpringKafkaVersionCheck.kt @@ -0,0 +1,52 @@ +package com.trendyol.stove.testing.e2e.kafka + +/** + * Utility object to check Spring Kafka availability at runtime. + * Since Spring Kafka is a `compileOnly` dependency, users must bring their own version. + */ +internal object SpringKafkaVersionCheck { + private const val KAFKA_TEMPLATE_CLASS = "org.springframework.kafka.core.KafkaTemplate" + + /** + * Checks if Spring Kafka is available on the classpath. + * @throws IllegalStateException if Spring Kafka is not found + */ + fun ensureSpringKafkaAvailable() { + try { + Class.forName(KAFKA_TEMPLATE_CLASS) + } catch (e: ClassNotFoundException) { + throw IllegalStateException( + """ + | + |═══════════════════════════════════════════════════════════════════════════════ + | Spring Kafka Not Found on Classpath! + |═══════════════════════════════════════════════════════════════════════════════ + | + | stove-spring-testing-e2e-kafka requires Spring Kafka to be on your classpath. + | Spring Kafka is declared as a 'compileOnly' dependency, so you must add it + | to your project. + | + | Add one of the following to your build.gradle.kts: + | + | For Spring Boot 2.x: + | testImplementation("org.springframework.kafka:spring-kafka:2.9.x") + | // or use the starter: + | testImplementation("org.springframework.boot:spring-boot-starter-kafka:2.7.x") + | + | For Spring Boot 3.x: + | testImplementation("org.springframework.kafka:spring-kafka:3.x.x") + | // or use the starter: + | testImplementation("org.springframework.boot:spring-boot-starter-kafka:3.x.x") + | + | For Spring Boot 4.x: + | testImplementation("org.springframework.kafka:spring-kafka:4.x.x") + | // or use the starter: + | testImplementation("org.springframework.boot:spring-boot-starter-kafka:4.x.x") + | + |═══════════════════════════════════════════════════════════════════════════════ + """.trimMargin(), + e + ) + } + } +} diff --git a/starters/spring/stove-spring-testing-e2e-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/StoveMessage.kt b/starters/spring/stove-spring-testing-e2e-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/StoveMessage.kt index 26a61e3a6..40aa9de19 100644 --- a/starters/spring/stove-spring-testing-e2e-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/StoveMessage.kt +++ b/starters/spring/stove-spring-testing-e2e-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/StoveMessage.kt @@ -18,7 +18,7 @@ internal sealed class StoveMessage : MessageProperties { if (this === other) return true if (javaClass != other?.javaClass) return false - other as Consumed + other as StoveMessage if (topic != other.topic) return false if (!value.contentEquals(other.value)) return false diff --git a/starters/spring/stove-spring-testing-e2e-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/TestSystemInterceptor.kt b/starters/spring/stove-spring-testing-e2e-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/TestSystemInterceptor.kt index c6edc4d8c..5f54d49ea 100644 --- a/starters/spring/stove-spring-testing-e2e-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/TestSystemInterceptor.kt +++ b/starters/spring/stove-spring-testing-e2e-kafka/src/main/kotlin/com/trendyol/stove/testing/e2e/kafka/TestSystemInterceptor.kt @@ -22,7 +22,7 @@ import kotlin.time.Duration * For example, if the application uses Avro, then you should use Avro serde here. * Target of the serialization is ByteArray, so the serde should be able to serialize the message to ByteArray. */ -class TestSystemKafkaInterceptor( +class TestSystemKafkaInterceptor( private val serde: StoveSerde ) : CompositeRecordInterceptor(), ProducerListener { @@ -44,7 +44,7 @@ class TestSystemKafkaInterceptor( exception: Exception ) { val underlyingReason = extractCause(exception) - val message = record.toFailedStoveMessage(serde, underlyingReason) + val message = record.toFailedStoveMessage(serde, underlyingReason) store.record(Failure(ObservedMessage(message, record.toMetadata()), underlyingReason)) logger.error("Error while producing:\n{}", message, exception) } diff --git a/starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/CachingTests.kt b/starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/CachingTests.kt new file mode 100644 index 000000000..7e9bd5328 --- /dev/null +++ b/starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/CachingTests.kt @@ -0,0 +1,85 @@ +package com.trendyol.stove.testing.e2e.kafka + +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.nulls.shouldBeNull +import io.kotest.matchers.shouldBe + +class CachingTests : + FunSpec({ + + test("should create cache that stores and retrieves values") { + val cache = Caching.of() + + cache.put("key1", 100) + cache.put("key2", 200) + + cache.getIfPresent("key1") shouldBe 100 + cache.getIfPresent("key2") shouldBe 200 + } + + test("should return null for non-existent keys") { + val cache = Caching.of() + + cache.getIfPresent("non-existent").shouldBeNull() + } + + test("should overwrite existing values") { + val cache = Caching.of() + + cache.put("key", "original") + cache.put("key", "updated") + + cache.getIfPresent("key") shouldBe "updated" + } + + test("should support complex key types") { + data class ComplexKey( + val id: Int, + val name: String + ) + + val cache = Caching.of() + val key = ComplexKey(1, "test") + + cache.put(key, "value") + + cache.getIfPresent(key) shouldBe "value" + } + + test("should support any value types") { + data class ComplexValue( + val data: List, + val count: Int + ) + + val cache = Caching.of() + val value = ComplexValue(listOf("a", "b", "c"), 3) + + cache.put("key", value) + + cache.getIfPresent("key") shouldBe value + } + + test("asMap should return all cached entries") { + val cache = Caching.of() + + cache.put("one", 1) + cache.put("two", 2) + cache.put("three", 3) + + val map = cache.asMap() + map.size shouldBe 3 + map["one"] shouldBe 1 + map["two"] shouldBe 2 + map["three"] shouldBe 3 + } + + test("should handle invalidation") { + val cache = Caching.of() + + cache.put("key", "value") + cache.invalidate("key") + + cache.getIfPresent("key").shouldBeNull() + } + }) diff --git a/starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/ExtensionsTests.kt b/starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/ExtensionsTests.kt new file mode 100644 index 000000000..b4908d7f7 --- /dev/null +++ b/starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/ExtensionsTests.kt @@ -0,0 +1,68 @@ +package com.trendyol.stove.testing.e2e.kafka + +import arrow.core.* +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe + +class ExtensionsTests : + FunSpec({ + + test("addTestCase should add testCase to map when not present") { + val map = mutableMapOf("existingKey" to "existingValue") + + map.addTestCase("myTestCase".some()) + + map["testCase"] shouldBe "myTestCase" + map["existingKey"] shouldBe "existingValue" + } + + test("addTestCase should not overwrite existing testCase") { + val map = mutableMapOf("testCase" to "existingTestCase") + + map.addTestCase("newTestCase".some()) + + map["testCase"] shouldBe "existingTestCase" + } + + test("addTestCase should do nothing when Option is None") { + val map = mutableMapOf("key" to "value") + + map.addTestCase(none()) + + map.containsKey("testCase") shouldBe false + map["key"] shouldBe "value" + } + + test("addTestCase should return the same map") { + val map = mutableMapOf() + + val result = map.addTestCase("test".some()) + + result shouldBe map + } + + test("addTestCase with empty map and Some value") { + val map = mutableMapOf() + + map.addTestCase("firstTest".some()) + + map.size shouldBe 1 + map["testCase"] shouldBe "firstTest" + } + + test("addTestCase should preserve all existing entries") { + val map = mutableMapOf( + "header1" to "value1", + "header2" to "value2", + "header3" to "value3" + ) + + map.addTestCase("myTest".some()) + + map.size shouldBe 4 + map["header1"] shouldBe "value1" + map["header2"] shouldBe "value2" + map["header3"] shouldBe "value3" + map["testCase"] shouldBe "myTest" + } + }) diff --git a/starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/MessageStoreTests.kt b/starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/MessageStoreTests.kt new file mode 100644 index 000000000..dfb1d2487 --- /dev/null +++ b/starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/MessageStoreTests.kt @@ -0,0 +1,158 @@ +package com.trendyol.stove.testing.e2e.kafka + +import com.trendyol.stove.testing.e2e.messaging.* +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.* +import io.kotest.matchers.shouldBe +import io.kotest.matchers.string.shouldContain + +class MessageStoreTests : + FunSpec({ + + test("should record and retrieve consumed messages") { + val store = MessageStore() + val metadata = MessageMetadata("test-topic", "key", emptyMap()) + val message = StoveMessage.consumed( + topic = "test-topic", + value = "test-value".toByteArray(), + metadata = metadata, + offset = 1L + ) + + store.record(message) + + val records = store.consumedRecords() + records shouldHaveSize 1 + records.first() shouldBe message + } + + test("should record and retrieve multiple consumed messages") { + val store = MessageStore() + val metadata = MessageMetadata("topic", "key", emptyMap()) + + repeat(5) { i -> + store.record( + StoveMessage.consumed( + topic = "topic-$i", + value = "value-$i".toByteArray(), + metadata = metadata, + offset = i.toLong() + ) + ) + } + + store.consumedRecords() shouldHaveSize 5 + } + + test("should record and retrieve published messages") { + val store = MessageStore() + val metadata = MessageMetadata("pub-topic", "pub-key", emptyMap()) + val message = StoveMessage.published( + topic = "pub-topic", + value = "published-value".toByteArray(), + metadata = metadata + ) + + store.record(message) + + val records = store.producedRecords() + records shouldHaveSize 1 + records.first() shouldBe message + } + + test("should record and retrieve failed messages") { + val store = MessageStore() + val metadata = MessageMetadata("fail-topic", "fail-key", emptyMap()) + val failedMessage = StoveMessage.failed( + topic = "fail-topic", + value = "failed-value".toByteArray(), + metadata = metadata, + reason = RuntimeException("Test error") + ) + val failure = Failure( + message = ObservedMessage(failedMessage, metadata), + reason = failedMessage.reason + ) + + store.record(failure) + + val records = store.failedRecords() + records shouldHaveSize 1 + records.first().topic shouldBe "fail-topic" + records.first().reason.message shouldBe "Test error" + } + + test("should maintain separate stores for consumed, produced, and failed") { + val store = MessageStore() + val metadata = MessageMetadata("topic", "key", emptyMap()) + + store.record( + StoveMessage.consumed( + topic = "consumed-topic", + value = "consumed".toByteArray(), + metadata = metadata + ) + ) + store.record( + StoveMessage.published( + topic = "published-topic", + value = "published".toByteArray(), + metadata = metadata + ) + ) + + val failedMessage = StoveMessage.failed( + topic = "failed-topic", + value = "failed".toByteArray(), + metadata = metadata, + reason = RuntimeException("Error") + ) + store.record( + Failure( + message = ObservedMessage(failedMessage, metadata), + reason = failedMessage.reason + ) + ) + + store.consumedRecords() shouldHaveSize 1 + store.producedRecords() shouldHaveSize 1 + store.failedRecords() shouldHaveSize 1 + + store.consumedRecords().first().topic shouldBe "consumed-topic" + store.producedRecords().first().topic shouldBe "published-topic" + store.failedRecords().first().topic shouldBe "failed-topic" + } + + test("toString should include all message types") { + val store = MessageStore() + val metadata = MessageMetadata("topic", "key", emptyMap()) + + store.record( + StoveMessage.consumed( + topic = "consumed-topic", + value = "consumed".toByteArray(), + metadata = metadata + ) + ) + store.record( + StoveMessage.published( + topic = "published-topic", + value = "published".toByteArray(), + metadata = metadata + ) + ) + + val output = store.toString() + output shouldContain "Consumed" + output shouldContain "Published" + output shouldContain "Failed" + } + + test("should return empty lists when no messages recorded") { + val store = MessageStore() + + store.consumedRecords() shouldBe emptyList() + store.producedRecords() shouldBe emptyList() + store.failedRecords() shouldBe emptyList() + } + }) diff --git a/starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/StoveMessageTests.kt b/starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/StoveMessageTests.kt new file mode 100644 index 000000000..05e1849f2 --- /dev/null +++ b/starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/StoveMessageTests.kt @@ -0,0 +1,156 @@ +package com.trendyol.stove.testing.e2e.kafka + +import com.trendyol.stove.testing.e2e.messaging.MessageMetadata +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe + +class StoveMessageTests : + FunSpec({ + + test("consumed message should be created with factory method") { + val metadata = MessageMetadata("test-topic", "test-key", mapOf("header1" to "value1")) + val message = StoveMessage.consumed( + topic = "test-topic", + value = "test-value".toByteArray(), + metadata = metadata, + partition = 0, + key = "test-key", + timestamp = 1234567890L, + offset = 100L + ) + + message.topic shouldBe "test-topic" + message.valueAsString shouldBe "test-value" + message.metadata shouldBe metadata + message.partition shouldBe 0 + message.key shouldBe "test-key" + message.timestamp shouldBe 1234567890L + message.offset shouldBe 100L + } + + test("published message should be created with factory method") { + val metadata = MessageMetadata("test-topic", "test-key", emptyMap()) + val message = StoveMessage.published( + topic = "test-topic", + value = "published-value".toByteArray(), + metadata = metadata, + partition = 1, + key = "pub-key", + timestamp = 9876543210L + ) + + message.topic shouldBe "test-topic" + message.valueAsString shouldBe "published-value" + message.metadata shouldBe metadata + message.partition shouldBe 1 + message.key shouldBe "pub-key" + message.timestamp shouldBe 9876543210L + } + + test("failed message should be created with factory method") { + val metadata = MessageMetadata("error-topic", "error-key", mapOf("error" to "true")) + val exception = RuntimeException("Test failure") + val message = StoveMessage.failed( + topic = "error-topic", + value = "failed-value".toByteArray(), + metadata = metadata, + reason = exception, + partition = 2, + key = "error-key", + timestamp = 1111111111L + ) + + message.topic shouldBe "error-topic" + message.valueAsString shouldBe "failed-value" + message.metadata shouldBe metadata + message.partition shouldBe 2 + message.key shouldBe "error-key" + message.timestamp shouldBe 1111111111L + message.reason shouldBe exception + } + + test("consumed messages with same content should be equal") { + val metadata = MessageMetadata("topic", "key", emptyMap()) + val value = "same-value".toByteArray() + + val message1 = StoveMessage.consumed( + topic = "topic", + value = value, + metadata = metadata, + partition = 0, + key = "key", + timestamp = 123L, + offset = 1L + ) + + val message2 = StoveMessage.consumed( + topic = "topic", + value = value.copyOf(), + metadata = metadata, + partition = 0, + key = "key", + timestamp = 123L, + offset = 1L + ) + + message1 shouldBe message2 + message1.hashCode() shouldBe message2.hashCode() + } + + test("consumed messages with different offsets should not be equal") { + val metadata = MessageMetadata("topic", "key", emptyMap()) + val value = "same-value".toByteArray() + + val message1 = StoveMessage.consumed( + topic = "topic", + value = value, + metadata = metadata, + offset = 1L + ) + + val message2 = StoveMessage.consumed( + topic = "topic", + value = value.copyOf(), + metadata = metadata, + offset = 2L + ) + + message1 shouldNotBe message2 + } + + test("failed messages with different reasons should not be equal") { + val metadata = MessageMetadata("topic", "key", emptyMap()) + val value = "value".toByteArray() + + val message1 = StoveMessage.failed( + topic = "topic", + value = value, + metadata = metadata, + reason = RuntimeException("Error 1") + ) + + val message2 = StoveMessage.failed( + topic = "topic", + value = value.copyOf(), + metadata = metadata, + reason = RuntimeException("Error 2") + ) + + message1 shouldNotBe message2 + } + + test("message with null optional fields should be created successfully") { + val metadata = MessageMetadata("topic", "null", emptyMap()) + val message = StoveMessage.consumed( + topic = "topic", + value = "value".toByteArray(), + metadata = metadata + ) + + message.partition shouldBe null + message.key shouldBe null + message.timestamp shouldBe null + message.offset shouldBe null + } + }) diff --git a/starters/spring/stove-spring-testing-e2e/api/stove-spring-testing-e2e-common.api b/starters/spring/stove-spring-testing-e2e/api/stove-spring-testing-e2e-common.api new file mode 100644 index 000000000..0ce33e719 --- /dev/null +++ b/starters/spring/stove-spring-testing-e2e/api/stove-spring-testing-e2e-common.api @@ -0,0 +1,25 @@ +public final class com/trendyol/stove/testing/e2e/BridgeSystemKt { + public static final fun bridge-hQma78k (Lcom/trendyol/stove/testing/e2e/system/TestSystem;)Lcom/trendyol/stove/testing/e2e/system/TestSystem; +} + +public final class com/trendyol/stove/testing/e2e/SpringApplicationUnderTest : com/trendyol/stove/testing/e2e/system/abstractions/ApplicationUnderTest { + public static final field Companion Lcom/trendyol/stove/testing/e2e/SpringApplicationUnderTest$Companion; + public fun (Lcom/trendyol/stove/testing/e2e/system/TestSystem;Lkotlin/jvm/functions/Function1;Ljava/util/List;)V + public fun start (Ljava/util/List;Lkotlin/coroutines/Continuation;)Ljava/lang/Object; + public fun stop (Lkotlin/coroutines/Continuation;)Ljava/lang/Object; +} + +public final class com/trendyol/stove/testing/e2e/SpringApplicationUnderTest$Companion { +} + +public final class com/trendyol/stove/testing/e2e/SpringApplicationUnderTestKt { + public static final fun springBoot-FMzRXaI (Lcom/trendyol/stove/testing/e2e/system/TestSystem;Lkotlin/jvm/functions/Function1;Ljava/util/List;)Lcom/trendyol/stove/testing/e2e/system/abstractions/ReadyTestSystem; + public static synthetic fun springBoot-FMzRXaI$default (Lcom/trendyol/stove/testing/e2e/system/TestSystem;Lkotlin/jvm/functions/Function1;Ljava/util/List;ILjava/lang/Object;)Lcom/trendyol/stove/testing/e2e/system/abstractions/ReadyTestSystem; +} + +public final class com/trendyol/stove/testing/e2e/SpringBridgeSystem : com/trendyol/stove/testing/e2e/system/BridgeSystem, com/trendyol/stove/testing/e2e/system/abstractions/AfterRunAwareWithContext, com/trendyol/stove/testing/e2e/system/abstractions/PluggedSystem { + public fun (Lcom/trendyol/stove/testing/e2e/system/TestSystem;)V + public fun get (Lkotlin/reflect/KClass;)Ljava/lang/Object; + public fun getTestSystem ()Lcom/trendyol/stove/testing/e2e/system/TestSystem; +} + diff --git a/starters/spring/stove-spring-testing-e2e/api/stove-spring-testing-e2e.api b/starters/spring/stove-spring-testing-e2e/api/stove-spring-testing-e2e.api index cc080aa3f..c6bc0d67a 100644 --- a/starters/spring/stove-spring-testing-e2e/api/stove-spring-testing-e2e.api +++ b/starters/spring/stove-spring-testing-e2e/api/stove-spring-testing-e2e.api @@ -1,18 +1,14 @@ -public abstract class com/trendyol/stove/testing/e2e/BaseApplicationContextInitializer : org/springframework/context/ApplicationContextInitializer { - public fun ()V - public fun (Lkotlin/jvm/functions/Function1;)V - public synthetic fun (Lkotlin/jvm/functions/Function1;ILkotlin/jvm/internal/DefaultConstructorMarker;)V - protected fun applicationReady (Lorg/springframework/context/support/GenericApplicationContext;)V - public synthetic fun initialize (Lorg/springframework/context/ConfigurableApplicationContext;)V - public fun initialize (Lorg/springframework/context/support/GenericApplicationContext;)V - protected fun onEvent (Lorg/springframework/context/ApplicationEvent;)V - protected final fun register (Lkotlin/jvm/functions/Function1;)Lcom/trendyol/stove/testing/e2e/BaseApplicationContextInitializer; -} - public final class com/trendyol/stove/testing/e2e/BridgeSystemKt { public static final fun bridge-hQma78k (Lcom/trendyol/stove/testing/e2e/system/TestSystem;)Lcom/trendyol/stove/testing/e2e/system/TestSystem; } +public final class com/trendyol/stove/testing/e2e/RegistrarKt { + public static final fun addTestDependencies (Lorg/springframework/boot/SpringApplication;Lkotlin/jvm/functions/Function1;)V + public static final fun addTestDependencies4x (Lorg/springframework/boot/SpringApplication;Lkotlin/jvm/functions/Function1;)V + public static final fun stoveSpring4xRegistrar (Lkotlin/jvm/functions/Function1;)Lorg/springframework/context/ApplicationContextInitializer; + public static final fun stoveSpringRegistrar (Lkotlin/jvm/functions/Function1;)Lorg/springframework/context/ApplicationContextInitializer; +} + public final class com/trendyol/stove/testing/e2e/SpringApplicationUnderTest : com/trendyol/stove/testing/e2e/system/abstractions/ApplicationUnderTest { public static final field Companion Lcom/trendyol/stove/testing/e2e/SpringApplicationUnderTest$Companion; public fun (Lcom/trendyol/stove/testing/e2e/system/TestSystem;Lkotlin/jvm/functions/Function1;Ljava/util/List;)V diff --git a/starters/spring/stove-spring-testing-e2e/build.gradle.kts b/starters/spring/stove-spring-testing-e2e/build.gradle.kts index 2c24f6721..50a25e601 100644 --- a/starters/spring/stove-spring-testing-e2e/build.gradle.kts +++ b/starters/spring/stove-spring-testing-e2e/build.gradle.kts @@ -1,14 +1,12 @@ dependencies { api(projects.lib.stoveTestingE2e) - implementation(libs.spring.boot) + // Both Spring versions as compileOnly - users bring the actual version at runtime + compileOnly(libs.spring.boot) + compileOnly(libs.spring.boot.four) } dependencies { - testAnnotationProcessor(libs.spring.boot.three.annotationProcessor) - testImplementation(libs.spring.boot.three.autoconfigure) - testImplementation(libs.slf4j.simple) -} - -tasks.test.configure { - systemProperty("kotest.framework.config.fqn", "com.trendyol.stove.testing.e2e.Stove") + testImplementation(libs.kotest.runner.junit5) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.spring.boot) } diff --git a/starters/spring/stove-spring-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/BaseApplicationContextInitializer.kt b/starters/spring/stove-spring-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/BaseApplicationContextInitializer.kt deleted file mode 100644 index 19b456697..000000000 --- a/starters/spring/stove-spring-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/BaseApplicationContextInitializer.kt +++ /dev/null @@ -1,39 +0,0 @@ -package com.trendyol.stove.testing.e2e - -import com.trendyol.stove.testing.e2e.system.annotations.StoveDsl -import org.springframework.boot.context.event.ApplicationReadyEvent -import org.springframework.context.* -import org.springframework.context.support.* - -@StoveDsl -abstract class BaseApplicationContextInitializer( - registration: BeanDefinitionDsl.() -> Unit = {} -) : ApplicationContextInitializer { - private var registrations = mutableListOf<(BeanDefinitionDsl) -> Unit>() - private val beans = beans {} - - init { - registrations.add(registration) - } - - @StoveDsl - protected fun register(registration: BeanDefinitionDsl.() -> Unit): BaseApplicationContextInitializer { - registrations.add(registration) - return this - } - - override fun initialize(applicationContext: GenericApplicationContext) { - applicationContext.addApplicationListener { event -> - when (event) { - is ApplicationReadyEvent -> applicationReady(event.applicationContext as GenericApplicationContext) - else -> onEvent(event) - } - } - beans.initialize(applicationContext) - registrations.forEach { it(beans) } - } - - protected open fun applicationReady(applicationContext: GenericApplicationContext) {} - - protected open fun onEvent(event: ApplicationEvent) {} -} diff --git a/starters/spring/stove-spring-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/SpringApplicationUnderTest.kt b/starters/spring/stove-spring-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/SpringApplicationUnderTest.kt index 91e66bc33..4811fca5d 100644 --- a/starters/spring/stove-spring-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/SpringApplicationUnderTest.kt +++ b/starters/spring/stove-spring-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/SpringApplicationUnderTest.kt @@ -23,7 +23,10 @@ internal fun TestSystem.systemUnderTest( fun WithDsl.springBoot( runner: Runner, withParameters: List = listOf() -): ReadyTestSystem = this.testSystem.systemUnderTest(runner, withParameters) +): ReadyTestSystem { + SpringBootVersionCheck.ensureSpringBootAvailable() + return this.testSystem.systemUnderTest(runner, withParameters) +} @StoveDsl class SpringApplicationUnderTest( diff --git a/starters/spring/stove-spring-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/SpringBootVersionCheck.kt b/starters/spring/stove-spring-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/SpringBootVersionCheck.kt new file mode 100644 index 000000000..b01cc2775 --- /dev/null +++ b/starters/spring/stove-spring-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/SpringBootVersionCheck.kt @@ -0,0 +1,72 @@ +@file:Suppress("TooGenericExceptionCaught", "SwallowedException") + +package com.trendyol.stove.testing.e2e + +/** + * Utility object to check Spring Boot availability and version at runtime. + * Since Spring Boot is a `compileOnly` dependency, users must bring their own version. + */ +internal object SpringBootVersionCheck { + private const val SPRING_APPLICATION_CLASS = "org.springframework.boot.SpringApplication" + private const val SPRING_BOOT_VERSION_CLASS = "org.springframework.boot.SpringBootVersion" + + /** + * Checks if Spring Boot is available on the classpath. + * @throws IllegalStateException if Spring Boot is not found + */ + fun ensureSpringBootAvailable() { + try { + Class.forName(SPRING_APPLICATION_CLASS) + } catch (e: ClassNotFoundException) { + throw IllegalStateException( + """ + | + |═══════════════════════════════════════════════════════════════════════════════ + | Spring Boot Not Found on Classpath! + |═══════════════════════════════════════════════════════════════════════════════ + | + | stove-spring-testing-e2e requires Spring Boot to be on your classpath. + | Spring Boot is declared as a 'compileOnly' dependency, so you must add it + | to your project. + | + | Add one of the following to your build.gradle.kts: + | + | For Spring Boot 2.x: + | testImplementation("org.springframework.boot:spring-boot-starter:2.7.x") + | + | For Spring Boot 3.x: + | testImplementation("org.springframework.boot:spring-boot-starter:3.x.x") + | + | For Spring Boot 4.x: + | testImplementation("org.springframework.boot:spring-boot-starter:4.x.x") + | + |═══════════════════════════════════════════════════════════════════════════════ + """.trimMargin(), + e + ) + } + } + + /** + * Gets the Spring Boot version if available. + * @return the Spring Boot version string, or "unknown" if not determinable + */ + fun getSpringBootVersion(): String = try { + val versionClass = Class.forName(SPRING_BOOT_VERSION_CLASS) + val getVersionMethod = versionClass.getMethod("getVersion") + getVersionMethod.invoke(null) as? String ?: "unknown" + } catch (_: Exception) { + "unknown" + } + + /** + * Gets the major version of Spring Boot. + * @return the major version (2, 3, 4, etc.) or -1 if not determinable + */ + fun getSpringBootMajorVersion(): Int = try { + val version = getSpringBootVersion() + version.split(".").firstOrNull()?.toIntOrNull() ?: -1 + } catch (e: Exception) { + -1 + } +} diff --git a/starters/spring/stove-spring-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/registrar.kt b/starters/spring/stove-spring-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/registrar.kt new file mode 100644 index 000000000..47b20cbb1 --- /dev/null +++ b/starters/spring/stove-spring-testing-e2e/src/main/kotlin/com/trendyol/stove/testing/e2e/registrar.kt @@ -0,0 +1,116 @@ +@file:Suppress("DEPRECATION") + +package com.trendyol.stove.testing.e2e + +import com.trendyol.stove.testing.e2e.system.annotations.StoveDsl +import org.springframework.beans.factory.BeanRegistrarDsl +import org.springframework.boot.SpringApplication +import org.springframework.context.ApplicationContextInitializer +import org.springframework.context.support.* + +// ============================================================================= +// Spring Boot 3.x (uses BeanDefinitionDsl - deprecated but still works) +// ============================================================================= + +/** + * Creates an [ApplicationContextInitializer] that registers beans using the [BeanDefinitionDsl]. + * + * **For Spring Boot 3.x applications.** + * + * Example usage: + * ```kotlin + * TestAppRunner.run(params) { + * addInitializers( + * stoveSpringRegistrar { + * bean() + * bean { MyRepositoryImpl() } + * } + * ) + * } + * ``` + * + * @param registration A lambda with [BeanDefinitionDsl] receiver to define beans. + * @return An [ApplicationContextInitializer] that can be added to a [SpringApplication]. + */ +@StoveDsl +fun stoveSpringRegistrar( + registration: BeanDefinitionDsl.() -> Unit +): ApplicationContextInitializer = ApplicationContextInitializer { context -> + val beansDsl = beans(registration) + beansDsl.initialize(context) +} + +/** + * Extension function to easily add test dependencies to a [SpringApplication]. + * + * **For Spring Boot 3.x applications.** + * + * Example usage: + * ```kotlin + * TestAppRunner.run(params) { + * addTestDependencies { + * bean() + * bean { MyRepositoryImpl() } + * } + * } + * ``` + * + * @param registration A lambda with [BeanDefinitionDsl] receiver to define beans. + */ +@StoveDsl +fun SpringApplication.addTestDependencies( + registration: BeanDefinitionDsl.() -> Unit +): Unit = this.addInitializers(stoveSpringRegistrar(registration)) + +// ============================================================================= +// Spring Boot 4.x (uses BeanRegistrarDsl - the new recommended approach) +// ============================================================================= + +/** + * Creates an [ApplicationContextInitializer] that registers beans using the [BeanRegistrarDsl]. + * + * **For Spring Boot 4.x applications.** + * + * Example usage: + * ```kotlin + * TestAppRunner.run(params) { + * addInitializers( + * stoveSpring4xRegistrar { + * registerBean() + * registerBean { MyRepositoryImpl() } + * } + * ) + * } + * ``` + * + * @param registration A lambda with [BeanRegistrarDsl] receiver to define beans. + * @return An [ApplicationContextInitializer] that can be added to a [SpringApplication]. + */ +@StoveDsl +fun stoveSpring4xRegistrar( + registration: BeanRegistrarDsl.() -> Unit +): ApplicationContextInitializer<*> = ApplicationContextInitializer { context -> + context.register(BeanRegistrarDsl(registration)) +} + +/** + * Extension function to easily add test dependencies to a [SpringApplication]. + * + * **For Spring Boot 4.x applications.** + * + * Example usage: + * ```kotlin + * TestAppRunner.run(params) { + * addTestDependencies4x { + * registerBean() + * registerBean { MyRepositoryImpl() } + * } + * } + * ``` + * + * @param registration A lambda with [BeanRegistrarDsl] receiver to define beans. + */ +@StoveDsl +fun SpringApplication.addTestDependencies4x( + registration: BeanRegistrarDsl.() -> Unit +): Unit = this.addInitializers(stoveSpring4xRegistrar(registration)) diff --git a/starters/spring/stove-spring-testing-e2e/src/test/kotlin/com/trendyol/stove/testing/e2e/SpringApplicationUnderTestTests.kt b/starters/spring/stove-spring-testing-e2e/src/test/kotlin/com/trendyol/stove/testing/e2e/SpringApplicationUnderTestTests.kt new file mode 100644 index 000000000..b76088bac --- /dev/null +++ b/starters/spring/stove-spring-testing-e2e/src/test/kotlin/com/trendyol/stove/testing/e2e/SpringApplicationUnderTestTests.kt @@ -0,0 +1,126 @@ +package com.trendyol.stove.testing.e2e + +import com.trendyol.stove.testing.e2e.system.TestSystem +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.collections.shouldContain +import io.kotest.matchers.shouldBe +import org.mockito.kotlin.* +import org.springframework.context.ConfigurableApplicationContext + +class SpringApplicationUnderTestTests : + FunSpec({ + + test("should include default test-system configuration") { + val testSystem = TestSystem() + var capturedArgs: Array = emptyArray() + + val runner: (Array) -> ConfigurableApplicationContext = { args -> + capturedArgs = args + mock { + on { isRunning } doReturn true + on { isActive } doReturn true + } + } + + val applicationUnderTest = SpringApplicationUnderTest( + testSystem = testSystem, + runner = runner, + parameters = listOf() + ) + + applicationUnderTest.start(listOf()) + + capturedArgs.toList().shouldContain("--test-system=true") + } + + test("should include custom parameters") { + val testSystem = TestSystem() + var capturedArgs: Array = emptyArray() + + val runner: (Array) -> ConfigurableApplicationContext = { args -> + capturedArgs = args + mock { + on { isRunning } doReturn true + on { isActive } doReturn true + } + } + + val applicationUnderTest = SpringApplicationUnderTest( + testSystem = testSystem, + runner = runner, + parameters = listOf("custom.param=value") + ) + + applicationUnderTest.start(listOf()) + + capturedArgs.toList().shouldContain("--custom.param=value") + } + + test("should include provided configurations") { + val testSystem = TestSystem() + var capturedArgs: Array = emptyArray() + + val runner: (Array) -> ConfigurableApplicationContext = { args -> + capturedArgs = args + mock { + on { isRunning } doReturn true + on { isActive } doReturn true + } + } + + val applicationUnderTest = SpringApplicationUnderTest( + testSystem = testSystem, + runner = runner, + parameters = listOf() + ) + + applicationUnderTest.start(listOf("server.port=8080", "spring.profiles.active=test")) + + capturedArgs.toList().shouldContain("--server.port=8080") + capturedArgs.toList().shouldContain("--spring.profiles.active=test") + } + + test("should combine all configurations with -- prefix") { + val testSystem = TestSystem() + var capturedArgs: Array = emptyArray() + + val runner: (Array) -> ConfigurableApplicationContext = { args -> + capturedArgs = args + mock { + on { isRunning } doReturn true + on { isActive } doReturn true + } + } + + val applicationUnderTest = SpringApplicationUnderTest( + testSystem = testSystem, + runner = runner, + parameters = listOf("param1=val1") + ) + + applicationUnderTest.start(listOf("config1=val1")) + + capturedArgs.all { it.startsWith("--") } shouldBe true + } + + test("should stop application context") { + val mockContext = mock { + on { isRunning } doReturn true + on { isActive } doReturn true + } + val testSystem = TestSystem() + + val runner: (Array) -> ConfigurableApplicationContext = { mockContext } + + val applicationUnderTest = SpringApplicationUnderTest( + testSystem = testSystem, + runner = runner, + parameters = listOf() + ) + + applicationUnderTest.start(listOf()) + applicationUnderTest.stop() + + verify(mockContext).stop() + } + }) diff --git a/starters/spring/stove-spring-testing-e2e/src/test/kotlin/com/trendyol/stove/testing/e2e/SpringBridgeSystemTests.kt b/starters/spring/stove-spring-testing-e2e/src/test/kotlin/com/trendyol/stove/testing/e2e/SpringBridgeSystemTests.kt new file mode 100644 index 000000000..b058825c1 --- /dev/null +++ b/starters/spring/stove-spring-testing-e2e/src/test/kotlin/com/trendyol/stove/testing/e2e/SpringBridgeSystemTests.kt @@ -0,0 +1,49 @@ +package com.trendyol.stove.testing.e2e + +import com.trendyol.stove.testing.e2e.system.TestSystem +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.types.shouldBeInstanceOf +import org.mockito.kotlin.* +import org.springframework.context.ApplicationContext + +class SpringBridgeSystemTests : + FunSpec({ + + test("SpringBridgeSystem should return bean from application context") { + val testSystem = TestSystem() + val bridgeSystem = SpringBridgeSystem(testSystem) + val mockContext = mock() + + val testBean = TestBean("test-value") + whenever(mockContext.getBean(TestBean::class.java)).thenReturn(testBean) + + // Set the context via reflection since afterRun is protected + val ctxField = bridgeSystem.javaClass.superclass.getDeclaredField("ctx") + ctxField.isAccessible = true + ctxField.set(bridgeSystem, mockContext) + + val result = bridgeSystem.get(TestBean::class) + + result shouldBe testBean + verify(mockContext).getBean(TestBean::class.java) + } + + test("SpringBridgeSystem should be associated with test system") { + val testSystem = TestSystem() + val bridgeSystem = SpringBridgeSystem(testSystem) + + bridgeSystem.testSystem shouldBe testSystem + } + + test("SpringBridgeSystem should implement required interfaces") { + val testSystem = TestSystem() + val bridgeSystem = SpringBridgeSystem(testSystem) + + bridgeSystem.shouldBeInstanceOf() + } + }) + +data class TestBean( + val value: String +) diff --git a/starters/spring/tests/spring-2x-kafka-tests/api/spring-2x-kafka-tests.api b/starters/spring/tests/spring-2x-kafka-tests/api/spring-2x-kafka-tests.api new file mode 100644 index 000000000..e69de29bb diff --git a/starters/spring/tests/spring-2x-kafka-tests/build.gradle.kts b/starters/spring/tests/spring-2x-kafka-tests/build.gradle.kts new file mode 100644 index 000000000..883d36db2 --- /dev/null +++ b/starters/spring/tests/spring-2x-kafka-tests/build.gradle.kts @@ -0,0 +1,37 @@ +import com.google.protobuf.gradle.id + +plugins { + alias(libs.plugins.protobuf) +} + +dependencies { + api(projects.starters.spring.stoveSpringTestingE2eKafka) + implementation(libs.spring.boot.kafka) +} + +dependencies { + testAnnotationProcessor(libs.spring.boot.annotationProcessor) + testImplementation(libs.spring.boot.autoconfigure) + testImplementation(projects.starters.spring.tests.spring2xTests) + testImplementation(libs.logback.classic) + testImplementation(libs.google.protobuf.kotlin) + testImplementation(libs.kafka.streams.protobuf.serde) +} + +tasks.test.configure { + systemProperty("kotest.framework.config.fqn", "com.trendyol.stove.testing.e2e.kafka.Setup") +} + +protobuf { + protoc { + artifact = libs.protoc.get().toString() + } + + generateProtoTasks { + all().forEach { + it.descriptorSetOptions.includeSourceInfo = true + it.descriptorSetOptions.includeImports = true + it.builtins { id("kotlin") } + } + } +} diff --git a/starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/KotestExtensions.kt b/starters/spring/tests/spring-2x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/KotestExtensions.kt similarity index 100% rename from starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/KotestExtensions.kt rename to starters/spring/tests/spring-2x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/KotestExtensions.kt diff --git a/starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/protobufserde/ProtobufSerdeKafkaSystemTest.kt b/starters/spring/tests/spring-2x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/protobufserde/ProtobufSerdeKafkaSystemTest.kt similarity index 100% rename from starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/protobufserde/ProtobufSerdeKafkaSystemTest.kt rename to starters/spring/tests/spring-2x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/protobufserde/ProtobufSerdeKafkaSystemTest.kt diff --git a/starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/protobufserde/app.kt b/starters/spring/tests/spring-2x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/protobufserde/app.kt similarity index 100% rename from starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/protobufserde/app.kt rename to starters/spring/tests/spring-2x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/protobufserde/app.kt diff --git a/starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/shared.kt b/starters/spring/tests/spring-2x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/shared.kt similarity index 100% rename from starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/shared.kt rename to starters/spring/tests/spring-2x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/shared.kt diff --git a/starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/stringserde/StringSerdeKafkaSystemTest.kt b/starters/spring/tests/spring-2x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/stringserde/StringSerdeKafkaSystemTest.kt similarity index 100% rename from starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/stringserde/StringSerdeKafkaSystemTest.kt rename to starters/spring/tests/spring-2x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/stringserde/StringSerdeKafkaSystemTest.kt diff --git a/starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/stringserde/app.kt b/starters/spring/tests/spring-2x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/stringserde/app.kt similarity index 100% rename from starters/spring/stove-spring-testing-e2e-kafka/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/stringserde/app.kt rename to starters/spring/tests/spring-2x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/stringserde/app.kt diff --git a/starters/spring/stove-spring-testing-e2e-kafka/src/test/proto/example.proto b/starters/spring/tests/spring-2x-kafka-tests/src/test/proto/example.proto similarity index 100% rename from starters/spring/stove-spring-testing-e2e-kafka/src/test/proto/example.proto rename to starters/spring/tests/spring-2x-kafka-tests/src/test/proto/example.proto diff --git a/starters/spring/stove-spring-testing-e2e-kafka/src/test/resources/kotest.properties b/starters/spring/tests/spring-2x-kafka-tests/src/test/resources/kotest.properties similarity index 100% rename from starters/spring/stove-spring-testing-e2e-kafka/src/test/resources/kotest.properties rename to starters/spring/tests/spring-2x-kafka-tests/src/test/resources/kotest.properties diff --git a/starters/spring/tests/spring-2x-kafka-tests/src/test/resources/logback-test.xml b/starters/spring/tests/spring-2x-kafka-tests/src/test/resources/logback-test.xml new file mode 100644 index 000000000..a1e9ff6ea --- /dev/null +++ b/starters/spring/tests/spring-2x-kafka-tests/src/test/resources/logback-test.xml @@ -0,0 +1,20 @@ + + + + + + %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - + %yellow(%m) %n + + + + + + + + + + + + + diff --git a/starters/spring/tests/spring-2x-tests/api/spring-2x-tests.api b/starters/spring/tests/spring-2x-tests/api/spring-2x-tests.api new file mode 100644 index 000000000..e69de29bb diff --git a/starters/spring/tests/spring-2x-tests/build.gradle.kts b/starters/spring/tests/spring-2x-tests/build.gradle.kts new file mode 100644 index 000000000..18fe653d6 --- /dev/null +++ b/starters/spring/tests/spring-2x-tests/build.gradle.kts @@ -0,0 +1,14 @@ +dependencies { + api(projects.starters.spring.stoveSpringTestingE2e) + implementation(libs.spring.boot) +} + +dependencies { + testAnnotationProcessor(libs.spring.boot.three.annotationProcessor) + testImplementation(libs.spring.boot.three.autoconfigure) + testImplementation(libs.slf4j.simple) +} + +tasks.test.configure { + systemProperty("kotest.framework.config.fqn", "com.trendyol.stove.testing.e2e.Stove") +} diff --git a/starters/spring/tests/spring-2x-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/BridgeSystemTests.kt b/starters/spring/tests/spring-2x-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/BridgeSystemTests.kt new file mode 100644 index 000000000..e26c05014 --- /dev/null +++ b/starters/spring/tests/spring-2x-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/BridgeSystemTests.kt @@ -0,0 +1,161 @@ +package com.trendyol.stove.testing.e2e + +import com.fasterxml.jackson.databind.ObjectMapper +import com.trendyol.stove.testing.e2e.serialization.StoveSerde +import com.trendyol.stove.testing.e2e.system.* +import com.trendyol.stove.testing.e2e.system.TestSystem.Companion.validate +import io.kotest.core.config.AbstractProjectConfig +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.delay +import org.springframework.boot.* +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.ConfigurableApplicationContext +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component +import java.time.Instant +import kotlin.time.Duration.Companion.seconds + +object TestAppRunner { + fun run( + args: Array, + init: SpringApplication.() -> Unit = {} + ): ConfigurableApplicationContext = runApplication(args = args) { + init() + } +} + +@SpringBootApplication +open class TestSpringBootApp + +fun interface GetUtcNow { + companion object { + val frozenTime: Instant = Instant.parse("2021-01-01T00:00:00Z") + } + + operator fun invoke(): Instant +} + +class SystemTimeGetUtcNow : GetUtcNow { + override fun invoke(): Instant = GetUtcNow.frozenTime +} + +class TestAppInitializers { + var onEvent: Boolean = false + var appReady: Boolean = false + + @EventListener(ApplicationReadyEvent::class) + fun applicationReady() { + onEvent = true + appReady = true + } +} + +@Component +class ExampleService( + private val getUtcNow: GetUtcNow +) { + fun whatIsTheTime(): Instant = getUtcNow() +} + +class ParameterCollectorOfSpringBoot( + private val applicationArguments: ApplicationArguments +) { + val parameters: List + get() = applicationArguments.sourceArgs.toList() +} + +class Stove : AbstractProjectConfig() { + override suspend fun beforeProject(): Unit = + TestSystem() + .with { + bridge() + springBoot( + runner = { params -> + TestAppRunner.run(params) { + addInitializers( + stoveSpringRegistrar { + bean() + bean() + bean { StoveSerde.jackson.default } + bean { SystemTimeGetUtcNow() } + } + ) + } + }, + withParameters = listOf( + "context=SetupOfBridgeSystemTests" + ) + ) + }.run() + + override suspend fun afterProject(): Unit = TestSystem.stop() +} + +class BridgeSystemTests : + ShouldSpec({ + should("bridge to application") { + validate { + using { + whatIsTheTime() shouldBe GetUtcNow.frozenTime + } + + using { + parameters shouldBe listOf( + "--test-system=true", + "--context=SetupOfBridgeSystemTests" + ) + } + + delay(5.seconds) + using { + appReady shouldBe true + onEvent shouldBe true + } + } + } + + should("resolve multiple") { + validate { + using { getUtcNow: GetUtcNow, testAppInitializers: TestAppInitializers -> + getUtcNow() shouldBe GetUtcNow.frozenTime + testAppInitializers.appReady shouldBe true + testAppInitializers.onEvent shouldBe true + } + + using { getUtcNow, testAppInitializers, parameterCollectorOfSpringBoot -> + getUtcNow() shouldBe GetUtcNow.frozenTime + testAppInitializers.appReady shouldBe true + testAppInitializers.onEvent shouldBe true + parameterCollectorOfSpringBoot.parameters shouldBe listOf( + "--test-system=true", + "--context=SetupOfBridgeSystemTests" + ) + } + + using { getUtcNow, testAppInitializers, parameterCollectorOfSpringBoot, exampleService -> + getUtcNow() shouldBe GetUtcNow.frozenTime + testAppInitializers.appReady shouldBe true + testAppInitializers.onEvent shouldBe true + parameterCollectorOfSpringBoot.parameters shouldBe listOf( + "--test-system=true", + "--context=SetupOfBridgeSystemTests" + ) + exampleService.whatIsTheTime() shouldBe GetUtcNow.frozenTime + } + + using { getUtcNow, testAppInitializers, parameterCollectorOfSpringBoot, exampleService, objectMapper -> + getUtcNow() shouldBe GetUtcNow.frozenTime + testAppInitializers.appReady shouldBe true + testAppInitializers.onEvent shouldBe true + parameterCollectorOfSpringBoot.parameters shouldBe listOf( + "--test-system=true", + "--context=SetupOfBridgeSystemTests" + ) + exampleService.whatIsTheTime() shouldBe GetUtcNow.frozenTime + objectMapper.writeValueAsString(mapOf("a" to "b")) shouldBe """{"a":"b"}""" + } + } + } + }) diff --git a/starters/spring/tests/spring-3x-kafka-tests/api/spring-3x-kafka-tests.api b/starters/spring/tests/spring-3x-kafka-tests/api/spring-3x-kafka-tests.api new file mode 100644 index 000000000..e69de29bb diff --git a/starters/spring/tests/spring-3x-kafka-tests/build.gradle.kts b/starters/spring/tests/spring-3x-kafka-tests/build.gradle.kts new file mode 100644 index 000000000..0c5c404b9 --- /dev/null +++ b/starters/spring/tests/spring-3x-kafka-tests/build.gradle.kts @@ -0,0 +1,37 @@ +import com.google.protobuf.gradle.id + +plugins { + alias(libs.plugins.protobuf) +} + +dependencies { + api(projects.starters.spring.stoveSpringTestingE2eKafka) + implementation(libs.spring.boot.three.kafka) +} + +dependencies { + testAnnotationProcessor(libs.spring.boot.three.annotationProcessor) + testImplementation(libs.spring.boot.three.autoconfigure) + testImplementation(projects.starters.spring.tests.spring2xTests) + testImplementation(libs.logback.classic) + testImplementation(libs.google.protobuf.kotlin) + testImplementation(libs.kafka.streams.protobuf.serde) +} + +tasks.test.configure { + systemProperty("kotest.framework.config.fqn", "com.trendyol.stove.testing.e2e.kafka.Setup") +} + +protobuf { + protoc { + artifact = libs.protoc.get().toString() + } + + generateProtoTasks { + all().forEach { + it.descriptorSetOptions.includeSourceInfo = true + it.descriptorSetOptions.includeImports = true + it.builtins { id("kotlin") } + } + } +} diff --git a/starters/spring/tests/spring-3x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/KotestExtensions.kt b/starters/spring/tests/spring-3x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/KotestExtensions.kt new file mode 100644 index 000000000..9a375f501 --- /dev/null +++ b/starters/spring/tests/spring-3x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/KotestExtensions.kt @@ -0,0 +1,33 @@ +package com.trendyol.stove.testing.e2e.kafka + +import io.kotest.assertions.* +import io.kotest.assertions.print.Printed +import io.kotest.common.reflection.bestName +import io.kotest.matchers.errorCollector + +inline fun shouldThrowMaybe(block: () -> Any) { + val expectedExceptionClass = T::class + val thrownThrowable = try { + block() + null + } catch (thrown: Throwable) { + thrown + } + + when (thrownThrowable) { + null -> Unit + + is T -> Unit + + is AssertionError -> errorCollector.collectOrThrow(thrownThrowable) + + else -> errorCollector.collectOrThrow( + createAssertionError( + "Expected exception ${expectedExceptionClass.bestName()} but a ${thrownThrowable::class.simpleName} was thrown instead.", + cause = thrownThrowable, + expected = Expected(Printed(expectedExceptionClass.bestName())), + actual = Actual(Printed(thrownThrowable::class.simpleName ?: "null")) + ) + ) + } +} diff --git a/starters/spring/tests/spring-3x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/protobufserde/ProtobufSerdeKafkaSystemTest.kt b/starters/spring/tests/spring-3x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/protobufserde/ProtobufSerdeKafkaSystemTest.kt new file mode 100644 index 000000000..8b6bb852f --- /dev/null +++ b/starters/spring/tests/spring-3x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/protobufserde/ProtobufSerdeKafkaSystemTest.kt @@ -0,0 +1,119 @@ +package com.trendyol.stove.testing.e2e.kafka.protobufserde + +import com.google.protobuf.Message +import com.trendyol.stove.spring.testing.e2e.kafka.v1.* +import com.trendyol.stove.spring.testing.e2e.kafka.v1.Example.* +import com.trendyol.stove.testing.e2e.kafka.* +import com.trendyol.stove.testing.e2e.serialization.StoveSerde +import com.trendyol.stove.testing.e2e.springBoot +import com.trendyol.stove.testing.e2e.system.TestSystem +import com.trendyol.stove.testing.e2e.system.TestSystem.Companion.validate +import io.confluent.kafka.streams.serdes.protobuf.KafkaProtobufSerde +import io.kotest.core.spec.style.ShouldSpec +import org.springframework.context.support.beans +import kotlin.random.Random + +@Suppress("UNCHECKED_CAST") +class StoveProtobufSerde : StoveSerde { + private val parseFromMethod = "parseFrom" + private val protobufSerde: KafkaProtobufSerde = KafkaRegistry.createSerde() + + override fun serialize(value: Any): ByteArray = protobufSerde.serializer().serialize("any", value as Message) + + override fun deserialize(value: ByteArray, clazz: Class): T { + val incoming: Message = protobufSerde.deserializer().deserialize("any", value) + incoming.isAssignableFrom(clazz).also { isAssignableFrom -> + require(isAssignableFrom) { + "Expected '${clazz.simpleName}' but got '${incoming.descriptorForType.name}'. " + + "This could be transient ser/de problem since the message stream is constantly checked if the expected message is arrived, " + + "so you can ignore this error if you are sure that the message is the expected one." + } + } + + val parseFromMethod = clazz.getDeclaredMethod(parseFromMethod, ByteArray::class.java) + val parsed = parseFromMethod(incoming, incoming.toByteArray()) as T + return parsed + } +} + +private fun Message.isAssignableFrom(clazz: Class<*>): Boolean = this.descriptorForType.name == clazz.simpleName + +class ProtobufSerdeKafkaSystemTest : + ShouldSpec({ + beforeSpec { + TestSystem() + .with { + kafka { + KafkaSystemOptions( + configureExposedConfiguration = { + listOf( + "kafka.bootstrapServers=${it.bootstrapServers}", + "kafka.groupId=test-group", + "kafka.offset=earliest", + "kafka.schemaRegistryUrl=mock://mock-registry" + ) + }, + containerOptions = KafkaContainerOptions(tag = "7.8.1") + ) + } + springBoot( + runner = { params -> + KafkaTestSpringBotApplicationForProtobufSerde.run(params) { + addInitializers( + beans { + bean>() + bean { StoveProtobufSerde() } + } + ) + } + }, + withParameters = listOf( + "spring.lifecycle.timeout-per-shutdown-phase=0s" + ) + ) + }.run() + } + + afterSpec { + TestSystem.stop() + } + + should("publish and consume") { + validate { + kafka { + val userId = Random.nextInt().toString() + val productId = Random.nextInt().toString() + val product = product { + id = productId + name = "product-${Random.nextInt()}" + price = Random.nextDouble() + currency = "eur" + description = "description-${Random.nextInt()}" + } + val headers = mapOf("x-user-id" to userId) + publish("topic-protobuf", product, headers = headers) + shouldBePublished { + actual == product && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic-protobuf" + } + shouldBeConsumed { + actual == product && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic-protobuf" + } + + val orderId = Random.nextInt().toString() + val order = order { + id = orderId + customerId = userId + products += product + } + publish("topic-protobuf", order, headers = headers) + shouldBePublished { + actual == order && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic-protobuf" + } + + shouldBeConsumed { + actual == order && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic-protobuf" + } + } + } + } + }) diff --git a/starters/spring/tests/spring-3x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/protobufserde/app.kt b/starters/spring/tests/spring-3x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/protobufserde/app.kt new file mode 100644 index 000000000..8a8f0bcfb --- /dev/null +++ b/starters/spring/tests/spring-3x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/protobufserde/app.kt @@ -0,0 +1,161 @@ +package com.trendyol.stove.testing.e2e.kafka.protobufserde + +import com.google.protobuf.Message +import com.trendyol.stove.testing.e2e.kafka.StoveBusinessException +import io.confluent.kafka.schemaregistry.testutil.MockSchemaRegistry +import io.confluent.kafka.serializers.AbstractKafkaSchemaSerDeConfig.SCHEMA_REGISTRY_URL_CONFIG +import io.confluent.kafka.streams.serdes.protobuf.KafkaProtobufSerde +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.clients.producer.ProducerConfig +import org.apache.kafka.common.serialization.* +import org.slf4j.* +import org.springframework.boot.* +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.* +import org.springframework.context.ConfigurableApplicationContext +import org.springframework.context.annotation.Bean +import org.springframework.kafka.annotation.* +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.* +import org.springframework.kafka.listener.* +import org.springframework.util.backoff.FixedBackOff + +sealed class KafkaRegistry( + open val url: String +) { + object Mock : KafkaRegistry("mock://mock-registry") + + data class Defined( + override val url: String + ) : KafkaRegistry(url) + + companion object { + fun createSerde(registry: KafkaRegistry = Mock): KafkaProtobufSerde { + val schemaRegistryClient = when (registry) { + is Mock -> MockSchemaRegistry.getClientForScope("mock-registry") + is Defined -> MockSchemaRegistry.getClientForScope(registry.url) + } + val serde: KafkaProtobufSerde = KafkaProtobufSerde(schemaRegistryClient) + val serdeConfig: MutableMap = HashMap() + serdeConfig[SCHEMA_REGISTRY_URL_CONFIG] = registry.url + serde.configure(serdeConfig, false) + return serde + } + } +} + +class ProtobufValueSerializer : Serializer { + private val protobufSerde: KafkaProtobufSerde = KafkaRegistry.createSerde() + + override fun serialize( + topic: String, + data: T + ): ByteArray = when (data) { + is ByteArray -> data + else -> protobufSerde.serializer().serialize(topic, data as Message) + } +} + +class ProtobufValueDeserializer : Deserializer { + private val protobufSerde: KafkaProtobufSerde = KafkaRegistry.createSerde() + + override fun deserialize( + topic: String, + data: ByteArray + ): Message = protobufSerde.deserializer().deserialize(topic, data) +} + +@SpringBootApplication(scanBasePackages = ["com.trendyol.stove.testing.e2e.kafka.protobufserde"]) +@EnableKafka +@EnableConfigurationProperties(KafkaTestSpringBotApplicationForProtobufSerde.ProtobufSerdeKafkaConf::class) +open class KafkaTestSpringBotApplicationForProtobufSerde { + companion object { + fun run( + args: Array, + init: SpringApplication.() -> Unit = {} + ): ConfigurableApplicationContext { + System.setProperty("org.springframework.boot.logging.LoggingSystem", "none") + return runApplication(args = args) { + webApplicationType = WebApplicationType.NONE + init() + } + } + } + + private val logger: Logger = LoggerFactory.getLogger(javaClass) + + @ConfigurationProperties(prefix = "kafka") + data class ProtobufSerdeKafkaConf( + val bootstrapServers: String, + val groupId: String, + val offset: String, + val schemaRegistryUrl: String + ) + + @Bean + open fun createConfiguredSerdeForRecordValues(config: ProtobufSerdeKafkaConf): KafkaProtobufSerde { + val registry = when { + config.schemaRegistryUrl.contains("mock://") -> KafkaRegistry.Mock + else -> KafkaRegistry.Defined(config.schemaRegistryUrl) + } + return KafkaRegistry.createSerde(registry) + } + + @Bean + open fun kafkaListenerContainerFactory( + consumerFactory: ConsumerFactory, + interceptor: RecordInterceptor, + recoverer: DeadLetterPublishingRecoverer + ): ConcurrentKafkaListenerContainerFactory { + val factory = ConcurrentKafkaListenerContainerFactory() + factory.consumerFactory = consumerFactory + factory.setCommonErrorHandler( + DefaultErrorHandler( + recoverer, + FixedBackOff(20, 1) + ).also { it.addNotRetryableExceptions(StoveBusinessException::class.java) } + ) + factory.setRecordInterceptor(interceptor) + return factory + } + + @Bean + open fun recoverer( + kafkaTemplate: KafkaTemplate<*, *> + ): DeadLetterPublishingRecoverer = DeadLetterPublishingRecoverer(kafkaTemplate) + + @Bean + open fun consumerFactory( + config: ProtobufSerdeKafkaConf + ): ConsumerFactory = DefaultKafkaConsumerFactory( + mapOf( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, + ConsumerConfig.GROUP_ID_CONFIG to config.groupId, + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to config.offset, + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to Serdes.String().deserializer().javaClass, + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to ProtobufValueDeserializer().javaClass, + ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG to 2000, + ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG to 6000, + ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG to 6000 + ) + ) + + @Bean + open fun kafkaTemplate( + config: ProtobufSerdeKafkaConf + ): KafkaTemplate = KafkaTemplate( + DefaultKafkaProducerFactory( + mapOf( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to Serdes.String().serializer().javaClass, + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to ProtobufValueSerializer().javaClass, + ProducerConfig.ACKS_CONFIG to "1" + ) + ) + ) + + @KafkaListener(topics = ["topic-protobuf"], groupId = "group_id") + fun listen(message: Message) { + logger.info("Received Message in consumer: $message") + } +} diff --git a/starters/spring/tests/spring-3x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/shared.kt b/starters/spring/tests/spring-3x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/shared.kt new file mode 100644 index 000000000..2d91b537e --- /dev/null +++ b/starters/spring/tests/spring-3x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/shared.kt @@ -0,0 +1,12 @@ +package com.trendyol.stove.testing.e2e.kafka + +import io.kotest.core.config.AbstractProjectConfig +import io.kotest.engine.concurrency.SpecExecutionMode + +class Setup : AbstractProjectConfig() { + override val specExecutionMode: SpecExecutionMode = SpecExecutionMode.Sequential +} + +class StoveBusinessException( + message: String +) : Exception(message) diff --git a/starters/spring/tests/spring-3x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/stringserde/StringSerdeKafkaSystemTest.kt b/starters/spring/tests/spring-3x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/stringserde/StringSerdeKafkaSystemTest.kt new file mode 100644 index 000000000..8a7b7971d --- /dev/null +++ b/starters/spring/tests/spring-3x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/stringserde/StringSerdeKafkaSystemTest.kt @@ -0,0 +1,127 @@ +package com.trendyol.stove.testing.e2e.kafka.stringserde + +import arrow.core.some +import com.trendyol.stove.testing.e2e.kafka.* +import com.trendyol.stove.testing.e2e.serialization.StoveSerde +import com.trendyol.stove.testing.e2e.springBoot +import com.trendyol.stove.testing.e2e.system.TestSystem +import com.trendyol.stove.testing.e2e.system.TestSystem.Companion.validate +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import org.apache.kafka.clients.admin.NewTopic +import org.springframework.context.support.beans +import kotlin.random.Random + +class StringSerdeKafkaSystemTests : + ShouldSpec({ + beforeSpec { + TestSystem() + .with { + kafka { + KafkaSystemOptions( + configureExposedConfiguration = { + listOf( + "kafka.bootstrapServers=${it.bootstrapServers}", + "kafka.groupId=test-group", + "kafka.offset=earliest" + ) + }, + containerOptions = KafkaContainerOptions(tag = "7.8.1") + ) + } + springBoot( + runner = { params -> + KafkaTestSpringBotApplicationForStringSerde.run(params) { + addInitializers( + beans { + bean>() + bean { StoveSerde.jackson.anyByteArraySerde() } + } + ) + } + }, + withParameters = listOf( + "spring.lifecycle.timeout-per-shutdown-phase=0s" + ) + ) + }.run() + } + + afterSpec { + TestSystem.stop() + } + + should("publish and consume") { + validate { + kafka { + val userId = Random.nextInt().toString() + val message = + "this message is coming from ${testCase.descriptor.id.value} and testName is ${testCase.name.name}" + val headers = mapOf("x-user-id" to userId) + publish("topic", message, headers = headers) + shouldBePublished { + actual == message && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic" + } + shouldBeConsumed { + actual == message && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic" + } + } + } + } + + should("publish and consume with failed consumer") { + shouldThrowMaybe { + validate { + kafka { + val userId = Random.nextInt().toString() + val message = + "this message is coming from ${testCase.descriptor.id.value} and testName is ${testCase.name.name}" + val headers = mapOf("x-user-id" to userId) + publish("topic-failed", message, headers = headers) + shouldBePublished { + actual == message && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic-failed" + } + shouldBeFailed { + actual == message && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic-failed" && reason is StoveBusinessException + } + + shouldBePublished { + actual == message && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic-failed.DLT" + } + } + } + } + } + + should("admin operations") { + validate { + kafka { + adminOperations { + val topic = "topic" + createTopics(listOf(NewTopic(topic, 1, 1))) + listTopics().names().get().contains(topic) shouldBe true + deleteTopics(listOf(topic)) + listTopics().names().get().contains(topic) shouldBe false + } + } + } + } + + should("publish with ser/de") { + validate { + kafka { + val userId = Random.nextInt().toString() + val message = + "this message is coming from ${testCase.descriptor.id.value} and testName is ${testCase.name.name}" + val headers = mapOf("x-user-id" to userId) + publish("topic", message, serde = StoveSerde.jackson.anyJsonStringSerde().some(), headers = headers) + shouldBePublished { + actual == message && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic" + } + shouldBeConsumed { + actual == message && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic" + } + } + } + } + }) diff --git a/starters/spring/tests/spring-3x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/stringserde/app.kt b/starters/spring/tests/spring-3x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/stringserde/app.kt new file mode 100644 index 000000000..077dd400b --- /dev/null +++ b/starters/spring/tests/spring-3x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/stringserde/app.kt @@ -0,0 +1,113 @@ +package com.trendyol.stove.testing.e2e.kafka.stringserde + +import com.trendyol.stove.testing.e2e.kafka.StoveBusinessException +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.clients.producer.ProducerConfig +import org.apache.kafka.common.serialization.Serdes +import org.slf4j.* +import org.springframework.boot.* +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.* +import org.springframework.context.ConfigurableApplicationContext +import org.springframework.context.annotation.Bean +import org.springframework.kafka.annotation.* +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.* +import org.springframework.kafka.listener.* +import org.springframework.util.backoff.FixedBackOff + +@SpringBootApplication(scanBasePackages = ["com.trendyol.stove.testing.e2e.kafka.stringserde"]) +@EnableKafka +@EnableConfigurationProperties(KafkaTestSpringBotApplicationForStringSerde.StringSerdeKafkaConf::class) +open class KafkaTestSpringBotApplicationForStringSerde { + companion object { + fun run( + args: Array, + init: SpringApplication.() -> Unit = {} + ): ConfigurableApplicationContext { + System.setProperty("org.springframework.boot.logging.LoggingSystem", "none") + return runApplication(args = args) { + webApplicationType = WebApplicationType.NONE + init() + } + } + } + + private val logger: Logger = LoggerFactory.getLogger(javaClass) + + @ConfigurationProperties(prefix = "kafka") + data class StringSerdeKafkaConf( + val bootstrapServers: String, + val groupId: String, + val offset: String + ) + + @Bean + open fun kafkaListenerContainerFactory( + consumerFactory: ConsumerFactory, + interceptor: RecordInterceptor, + recoverer: DeadLetterPublishingRecoverer + ): ConcurrentKafkaListenerContainerFactory { + val factory = ConcurrentKafkaListenerContainerFactory() + factory.consumerFactory = consumerFactory + factory.setCommonErrorHandler( + DefaultErrorHandler( + recoverer, + FixedBackOff(20, 1) + ).also { it.addNotRetryableExceptions(StoveBusinessException::class.java) } + ) + factory.setRecordInterceptor(interceptor) + return factory + } + + @Bean + open fun recoverer( + kafkaTemplate: KafkaTemplate<*, *> + ): DeadLetterPublishingRecoverer = DeadLetterPublishingRecoverer(kafkaTemplate) + + @Bean + open fun consumerFactory( + config: StringSerdeKafkaConf + ): ConsumerFactory = DefaultKafkaConsumerFactory( + mapOf( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, + ConsumerConfig.GROUP_ID_CONFIG to config.groupId, + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to config.offset, + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to Serdes.String().deserializer().javaClass, + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to Serdes.String().deserializer().javaClass, + ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG to 2000, + ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG to 6000, + ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG to 6000 + ) + ) + + @Bean + open fun kafkaTemplate( + config: StringSerdeKafkaConf + ): KafkaTemplate = KafkaTemplate( + DefaultKafkaProducerFactory( + mapOf( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to Serdes.String().serializer().javaClass, + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to Serdes.String().serializer().javaClass, + ProducerConfig.ACKS_CONFIG to "1" + ) + ) + ) + + @KafkaListener(topics = ["topic"], groupId = "group_id") + fun listen(message: String) { + logger.info("Received Message in consumer: \n$message") + } + + @KafkaListener(topics = ["topic-failed"], groupId = "group_id") + fun listenFailed(message: String) { + logger.info("Received Message in failed consumer: \n$message") + throw StoveBusinessException("This exception is thrown intentionally for testing purposes.") + } + + @KafkaListener(topics = ["topic-failed.DLT"], groupId = "group_id") + fun listenDeadLetter(message: String) { + logger.info("Received Message in the lead letter, and allowing the fail by just logging: \n$message") + } +} diff --git a/starters/spring/tests/spring-3x-kafka-tests/src/test/proto/example.proto b/starters/spring/tests/spring-3x-kafka-tests/src/test/proto/example.proto new file mode 100644 index 000000000..9ce09ee5d --- /dev/null +++ b/starters/spring/tests/spring-3x-kafka-tests/src/test/proto/example.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +// buf:lint:ignore PACKAGE_DIRECTORY_MATCH +package com.trendyol.stove.spring.testing.e2e.kafka.v1; + +message Product { + string id = 1; + string name = 2; + string description = 3; + double price = 4; + string currency = 5; +} + +message Order { + string id = 1; + string customerId = 2; + repeated Product products = 3; +} diff --git a/starters/spring/tests/spring-3x-kafka-tests/src/test/resources/kotest.properties b/starters/spring/tests/spring-3x-kafka-tests/src/test/resources/kotest.properties new file mode 100644 index 000000000..599cf072f --- /dev/null +++ b/starters/spring/tests/spring-3x-kafka-tests/src/test/resources/kotest.properties @@ -0,0 +1 @@ +kotest.framework.config.fqn=com.trendyol.stove.testing.e2e.kafka.Setup diff --git a/starters/spring/tests/spring-3x-kafka-tests/src/test/resources/logback-test.xml b/starters/spring/tests/spring-3x-kafka-tests/src/test/resources/logback-test.xml new file mode 100644 index 000000000..a1e9ff6ea --- /dev/null +++ b/starters/spring/tests/spring-3x-kafka-tests/src/test/resources/logback-test.xml @@ -0,0 +1,20 @@ + + + + + + %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - + %yellow(%m) %n + + + + + + + + + + + + + diff --git a/starters/spring/tests/spring-3x-tests/api/spring-3x-tests.api b/starters/spring/tests/spring-3x-tests/api/spring-3x-tests.api new file mode 100644 index 000000000..e69de29bb diff --git a/starters/spring/tests/spring-3x-tests/build.gradle.kts b/starters/spring/tests/spring-3x-tests/build.gradle.kts new file mode 100644 index 000000000..360590d2e --- /dev/null +++ b/starters/spring/tests/spring-3x-tests/build.gradle.kts @@ -0,0 +1,14 @@ +dependencies { + api(projects.starters.spring.stoveSpringTestingE2e) + implementation(libs.spring.boot.three) +} + +dependencies { + testAnnotationProcessor(libs.spring.boot.three.annotationProcessor) + testImplementation(libs.spring.boot.three.autoconfigure) + testImplementation(libs.slf4j.simple) +} + +tasks.test.configure { + systemProperty("kotest.framework.config.fqn", "com.trendyol.stove.testing.e2e.Stove") +} diff --git a/starters/spring/tests/spring-3x-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/BridgeSystemTests.kt b/starters/spring/tests/spring-3x-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/BridgeSystemTests.kt new file mode 100644 index 000000000..e26c05014 --- /dev/null +++ b/starters/spring/tests/spring-3x-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/BridgeSystemTests.kt @@ -0,0 +1,161 @@ +package com.trendyol.stove.testing.e2e + +import com.fasterxml.jackson.databind.ObjectMapper +import com.trendyol.stove.testing.e2e.serialization.StoveSerde +import com.trendyol.stove.testing.e2e.system.* +import com.trendyol.stove.testing.e2e.system.TestSystem.Companion.validate +import io.kotest.core.config.AbstractProjectConfig +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import kotlinx.coroutines.delay +import org.springframework.boot.* +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.ConfigurableApplicationContext +import org.springframework.context.event.EventListener +import org.springframework.stereotype.Component +import java.time.Instant +import kotlin.time.Duration.Companion.seconds + +object TestAppRunner { + fun run( + args: Array, + init: SpringApplication.() -> Unit = {} + ): ConfigurableApplicationContext = runApplication(args = args) { + init() + } +} + +@SpringBootApplication +open class TestSpringBootApp + +fun interface GetUtcNow { + companion object { + val frozenTime: Instant = Instant.parse("2021-01-01T00:00:00Z") + } + + operator fun invoke(): Instant +} + +class SystemTimeGetUtcNow : GetUtcNow { + override fun invoke(): Instant = GetUtcNow.frozenTime +} + +class TestAppInitializers { + var onEvent: Boolean = false + var appReady: Boolean = false + + @EventListener(ApplicationReadyEvent::class) + fun applicationReady() { + onEvent = true + appReady = true + } +} + +@Component +class ExampleService( + private val getUtcNow: GetUtcNow +) { + fun whatIsTheTime(): Instant = getUtcNow() +} + +class ParameterCollectorOfSpringBoot( + private val applicationArguments: ApplicationArguments +) { + val parameters: List + get() = applicationArguments.sourceArgs.toList() +} + +class Stove : AbstractProjectConfig() { + override suspend fun beforeProject(): Unit = + TestSystem() + .with { + bridge() + springBoot( + runner = { params -> + TestAppRunner.run(params) { + addInitializers( + stoveSpringRegistrar { + bean() + bean() + bean { StoveSerde.jackson.default } + bean { SystemTimeGetUtcNow() } + } + ) + } + }, + withParameters = listOf( + "context=SetupOfBridgeSystemTests" + ) + ) + }.run() + + override suspend fun afterProject(): Unit = TestSystem.stop() +} + +class BridgeSystemTests : + ShouldSpec({ + should("bridge to application") { + validate { + using { + whatIsTheTime() shouldBe GetUtcNow.frozenTime + } + + using { + parameters shouldBe listOf( + "--test-system=true", + "--context=SetupOfBridgeSystemTests" + ) + } + + delay(5.seconds) + using { + appReady shouldBe true + onEvent shouldBe true + } + } + } + + should("resolve multiple") { + validate { + using { getUtcNow: GetUtcNow, testAppInitializers: TestAppInitializers -> + getUtcNow() shouldBe GetUtcNow.frozenTime + testAppInitializers.appReady shouldBe true + testAppInitializers.onEvent shouldBe true + } + + using { getUtcNow, testAppInitializers, parameterCollectorOfSpringBoot -> + getUtcNow() shouldBe GetUtcNow.frozenTime + testAppInitializers.appReady shouldBe true + testAppInitializers.onEvent shouldBe true + parameterCollectorOfSpringBoot.parameters shouldBe listOf( + "--test-system=true", + "--context=SetupOfBridgeSystemTests" + ) + } + + using { getUtcNow, testAppInitializers, parameterCollectorOfSpringBoot, exampleService -> + getUtcNow() shouldBe GetUtcNow.frozenTime + testAppInitializers.appReady shouldBe true + testAppInitializers.onEvent shouldBe true + parameterCollectorOfSpringBoot.parameters shouldBe listOf( + "--test-system=true", + "--context=SetupOfBridgeSystemTests" + ) + exampleService.whatIsTheTime() shouldBe GetUtcNow.frozenTime + } + + using { getUtcNow, testAppInitializers, parameterCollectorOfSpringBoot, exampleService, objectMapper -> + getUtcNow() shouldBe GetUtcNow.frozenTime + testAppInitializers.appReady shouldBe true + testAppInitializers.onEvent shouldBe true + parameterCollectorOfSpringBoot.parameters shouldBe listOf( + "--test-system=true", + "--context=SetupOfBridgeSystemTests" + ) + exampleService.whatIsTheTime() shouldBe GetUtcNow.frozenTime + objectMapper.writeValueAsString(mapOf("a" to "b")) shouldBe """{"a":"b"}""" + } + } + } + }) diff --git a/starters/spring/tests/spring-4x-kafka-tests/api/spring-4x-kafka-tests.api b/starters/spring/tests/spring-4x-kafka-tests/api/spring-4x-kafka-tests.api new file mode 100644 index 000000000..e69de29bb diff --git a/starters/spring/tests/spring-4x-kafka-tests/build.gradle.kts b/starters/spring/tests/spring-4x-kafka-tests/build.gradle.kts new file mode 100644 index 000000000..8fdd312f2 --- /dev/null +++ b/starters/spring/tests/spring-4x-kafka-tests/build.gradle.kts @@ -0,0 +1,37 @@ +import com.google.protobuf.gradle.id + +plugins { + alias(libs.plugins.protobuf) +} + +dependencies { + api(projects.starters.spring.stoveSpringTestingE2eKafka) + implementation(libs.spring.boot.four.kafka) +} + +dependencies { + testAnnotationProcessor(libs.spring.boot.four.annotationProcessor) + testImplementation(libs.spring.boot.four.autoconfigure) + testImplementation(projects.starters.spring.tests.spring4xTests) + testImplementation(libs.logback.classic) + testImplementation(libs.google.protobuf.kotlin) + testImplementation(libs.kafka.streams.protobuf.serde) +} + +tasks.test.configure { + systemProperty("kotest.framework.config.fqn", "com.trendyol.stove.testing.e2e.kafka.Setup") +} + +protobuf { + protoc { + artifact = libs.protoc.get().toString() + } + + generateProtoTasks { + all().forEach { + it.descriptorSetOptions.includeSourceInfo = true + it.descriptorSetOptions.includeImports = true + it.builtins { id("kotlin") } + } + } +} diff --git a/starters/spring/tests/spring-4x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/KotestExtensions.kt b/starters/spring/tests/spring-4x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/KotestExtensions.kt new file mode 100644 index 000000000..9a375f501 --- /dev/null +++ b/starters/spring/tests/spring-4x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/KotestExtensions.kt @@ -0,0 +1,33 @@ +package com.trendyol.stove.testing.e2e.kafka + +import io.kotest.assertions.* +import io.kotest.assertions.print.Printed +import io.kotest.common.reflection.bestName +import io.kotest.matchers.errorCollector + +inline fun shouldThrowMaybe(block: () -> Any) { + val expectedExceptionClass = T::class + val thrownThrowable = try { + block() + null + } catch (thrown: Throwable) { + thrown + } + + when (thrownThrowable) { + null -> Unit + + is T -> Unit + + is AssertionError -> errorCollector.collectOrThrow(thrownThrowable) + + else -> errorCollector.collectOrThrow( + createAssertionError( + "Expected exception ${expectedExceptionClass.bestName()} but a ${thrownThrowable::class.simpleName} was thrown instead.", + cause = thrownThrowable, + expected = Expected(Printed(expectedExceptionClass.bestName())), + actual = Actual(Printed(thrownThrowable::class.simpleName ?: "null")) + ) + ) + } +} diff --git a/starters/spring/tests/spring-4x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/protobufserde/ProtobufSerdeKafkaSystemTest.kt b/starters/spring/tests/spring-4x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/protobufserde/ProtobufSerdeKafkaSystemTest.kt new file mode 100644 index 000000000..cd01a9617 --- /dev/null +++ b/starters/spring/tests/spring-4x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/protobufserde/ProtobufSerdeKafkaSystemTest.kt @@ -0,0 +1,121 @@ +@file:Suppress("DEPRECATION") + +package com.trendyol.stove.testing.e2e.kafka.protobufserde + +import com.google.protobuf.Message +import com.trendyol.stove.spring.testing.e2e.kafka.v1.* +import com.trendyol.stove.spring.testing.e2e.kafka.v1.Example.* +import com.trendyol.stove.testing.e2e.kafka.* +import com.trendyol.stove.testing.e2e.serialization.StoveSerde +import com.trendyol.stove.testing.e2e.springBoot +import com.trendyol.stove.testing.e2e.system.TestSystem +import com.trendyol.stove.testing.e2e.system.TestSystem.Companion.validate +import io.confluent.kafka.streams.serdes.protobuf.KafkaProtobufSerde +import io.kotest.core.spec.style.ShouldSpec +import org.springframework.context.support.beans +import kotlin.random.Random + +@Suppress("UNCHECKED_CAST") +class StoveProtobufSerde : StoveSerde { + private val parseFromMethod = "parseFrom" + private val protobufSerde: KafkaProtobufSerde = KafkaRegistry.createSerde() + + override fun serialize(value: Any): ByteArray = protobufSerde.serializer().serialize("any", value as Message) + + override fun deserialize(value: ByteArray, clazz: Class): T { + val incoming: Message = protobufSerde.deserializer().deserialize("any", value) + incoming.isAssignableFrom(clazz).also { isAssignableFrom -> + require(isAssignableFrom) { + "Expected '${clazz.simpleName}' but got '${incoming.descriptorForType.name}'. " + + "This could be transient ser/de problem since the message stream is constantly checked if the expected message is arrived, " + + "so you can ignore this error if you are sure that the message is the expected one." + } + } + + val parseFromMethod = clazz.getDeclaredMethod(parseFromMethod, ByteArray::class.java) + val parsed = parseFromMethod(incoming, incoming.toByteArray()) as T + return parsed + } +} + +private fun Message.isAssignableFrom(clazz: Class<*>): Boolean = this.descriptorForType.name == clazz.simpleName + +class ProtobufSerdeKafkaSystemTest : + ShouldSpec({ + beforeSpec { + TestSystem() + .with { + kafka { + KafkaSystemOptions( + configureExposedConfiguration = { + listOf( + "kafka.bootstrapServers=${it.bootstrapServers}", + "kafka.groupId=test-group", + "kafka.offset=earliest", + "kafka.schemaRegistryUrl=mock://mock-registry" + ) + }, + containerOptions = KafkaContainerOptions(tag = "7.8.1") + ) + } + springBoot( + runner = { params -> + KafkaTestSpringBotApplicationForProtobufSerde.run(params) { + addInitializers( + beans { + bean>() + bean { StoveProtobufSerde() } + } + ) + } + }, + withParameters = listOf( + "spring.lifecycle.timeout-per-shutdown-phase=0s" + ) + ) + }.run() + } + + afterSpec { + TestSystem.stop() + } + + should("publish and consume") { + validate { + kafka { + val userId = Random.nextInt().toString() + val productId = Random.nextInt().toString() + val product = product { + id = productId + name = "product-${Random.nextInt()}" + price = Random.nextDouble() + currency = "eur" + description = "description-${Random.nextInt()}" + } + val headers = mapOf("x-user-id" to userId) + publish("topic-protobuf", product, headers = headers) + shouldBePublished { + actual == product && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic-protobuf" + } + shouldBeConsumed { + actual == product && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic-protobuf" + } + + val orderId = Random.nextInt().toString() + val order = order { + id = orderId + customerId = userId + products += product + } + publish("topic-protobuf", order, headers = headers) + shouldBePublished { + actual == order && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic-protobuf" + } + + shouldBeConsumed { + actual == order && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic-protobuf" + } + } + } + } + }) diff --git a/starters/spring/tests/spring-4x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/protobufserde/app.kt b/starters/spring/tests/spring-4x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/protobufserde/app.kt new file mode 100644 index 000000000..759ed8033 --- /dev/null +++ b/starters/spring/tests/spring-4x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/protobufserde/app.kt @@ -0,0 +1,161 @@ +package com.trendyol.stove.testing.e2e.kafka.protobufserde + +import com.google.protobuf.Message +import com.trendyol.stove.testing.e2e.kafka.StoveBusinessException +import io.confluent.kafka.schemaregistry.testutil.MockSchemaRegistry +import io.confluent.kafka.serializers.AbstractKafkaSchemaSerDeConfig.SCHEMA_REGISTRY_URL_CONFIG +import io.confluent.kafka.streams.serdes.protobuf.KafkaProtobufSerde +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.clients.producer.ProducerConfig +import org.apache.kafka.common.serialization.* +import org.slf4j.* +import org.springframework.boot.* +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.* +import org.springframework.context.ConfigurableApplicationContext +import org.springframework.context.annotation.Bean +import org.springframework.kafka.annotation.* +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.* +import org.springframework.kafka.listener.* +import org.springframework.util.backoff.FixedBackOff + +sealed class KafkaRegistry( + open val url: String +) { + object Mock : KafkaRegistry("mock://mock-registry") + + data class Defined( + override val url: String + ) : KafkaRegistry(url) + + companion object { + fun createSerde(registry: KafkaRegistry = Mock): KafkaProtobufSerde { + val schemaRegistryClient = when (registry) { + is Mock -> MockSchemaRegistry.getClientForScope("mock-registry") + is Defined -> MockSchemaRegistry.getClientForScope(registry.url) + } + val serde: KafkaProtobufSerde = KafkaProtobufSerde(schemaRegistryClient) + val serdeConfig: MutableMap = HashMap() + serdeConfig[SCHEMA_REGISTRY_URL_CONFIG] = registry.url + serde.configure(serdeConfig, false) + return serde + } + } +} + +class ProtobufValueSerializer : Serializer { + private val protobufSerde: KafkaProtobufSerde = KafkaRegistry.createSerde() + + override fun serialize( + topic: String, + data: T + ): ByteArray = when (data) { + is ByteArray -> data + else -> protobufSerde.serializer().serialize(topic, data as Message) + } +} + +class ProtobufValueDeserializer : Deserializer { + private val protobufSerde: KafkaProtobufSerde = KafkaRegistry.createSerde() + + override fun deserialize( + topic: String, + data: ByteArray + ): Message = protobufSerde.deserializer().deserialize(topic, data) +} + +@SpringBootApplication(scanBasePackages = ["com.trendyol.stove.testing.e2e.kafka.protobufserde"]) +@EnableKafka +@EnableConfigurationProperties(KafkaTestSpringBotApplicationForProtobufSerde.ProtobufSerdeKafkaConf::class) +open class KafkaTestSpringBotApplicationForProtobufSerde { + companion object { + fun run( + args: Array, + init: SpringApplication.() -> Unit = {} + ): ConfigurableApplicationContext { + System.setProperty("org.springframework.boot.logging.LoggingSystem", "none") + return runApplication(args = args) { + setWebApplicationType(WebApplicationType.NONE) + init() + } + } + } + + private val logger: Logger = LoggerFactory.getLogger(javaClass) + + @ConfigurationProperties(prefix = "kafka") + data class ProtobufSerdeKafkaConf( + val bootstrapServers: String, + val groupId: String, + val offset: String, + val schemaRegistryUrl: String + ) + + @Bean + open fun createConfiguredSerdeForRecordValues(config: ProtobufSerdeKafkaConf): KafkaProtobufSerde { + val registry = when { + config.schemaRegistryUrl.contains("mock://") -> KafkaRegistry.Mock + else -> KafkaRegistry.Defined(config.schemaRegistryUrl) + } + return KafkaRegistry.createSerde(registry) + } + + @Bean + open fun kafkaListenerContainerFactory( + consumerFactory: ConsumerFactory, + interceptor: RecordInterceptor, + recoverer: DeadLetterPublishingRecoverer + ): ConcurrentKafkaListenerContainerFactory { + val factory = ConcurrentKafkaListenerContainerFactory() + factory.setConsumerFactory(consumerFactory) + factory.setCommonErrorHandler( + DefaultErrorHandler( + recoverer, + FixedBackOff(20, 1) + ).also { it.addNotRetryableExceptions(StoveBusinessException::class.java) } + ) + factory.setRecordInterceptor(interceptor) + return factory + } + + @Bean + open fun recoverer( + kafkaTemplate: KafkaTemplate<*, *> + ): DeadLetterPublishingRecoverer = DeadLetterPublishingRecoverer(kafkaTemplate) + + @Bean + open fun consumerFactory( + config: ProtobufSerdeKafkaConf + ): ConsumerFactory = DefaultKafkaConsumerFactory( + mapOf( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, + ConsumerConfig.GROUP_ID_CONFIG to config.groupId, + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to config.offset, + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to Serdes.String().deserializer().javaClass, + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to ProtobufValueDeserializer().javaClass, + ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG to 2000, + ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG to 6000, + ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG to 6000 + ) + ) + + @Bean + open fun kafkaTemplate( + config: ProtobufSerdeKafkaConf + ): KafkaTemplate = KafkaTemplate( + DefaultKafkaProducerFactory( + mapOf( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to Serdes.String().serializer().javaClass, + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to ProtobufValueSerializer().javaClass, + ProducerConfig.ACKS_CONFIG to "1" + ) + ) + ) + + @KafkaListener(topics = ["topic-protobuf"], groupId = "group_id") + fun listen(message: Message) { + logger.info("Received Message in consumer: $message") + } +} diff --git a/starters/spring/tests/spring-4x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/shared.kt b/starters/spring/tests/spring-4x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/shared.kt new file mode 100644 index 000000000..2d91b537e --- /dev/null +++ b/starters/spring/tests/spring-4x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/shared.kt @@ -0,0 +1,12 @@ +package com.trendyol.stove.testing.e2e.kafka + +import io.kotest.core.config.AbstractProjectConfig +import io.kotest.engine.concurrency.SpecExecutionMode + +class Setup : AbstractProjectConfig() { + override val specExecutionMode: SpecExecutionMode = SpecExecutionMode.Sequential +} + +class StoveBusinessException( + message: String +) : Exception(message) diff --git a/starters/spring/tests/spring-4x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/stringserde/StringSerdeKafkaSystemTest.kt b/starters/spring/tests/spring-4x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/stringserde/StringSerdeKafkaSystemTest.kt new file mode 100644 index 000000000..14bd739f8 --- /dev/null +++ b/starters/spring/tests/spring-4x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/stringserde/StringSerdeKafkaSystemTest.kt @@ -0,0 +1,125 @@ +package com.trendyol.stove.testing.e2e.kafka.stringserde + +import arrow.core.some +import com.trendyol.stove.testing.e2e.* +import com.trendyol.stove.testing.e2e.kafka.* +import com.trendyol.stove.testing.e2e.serialization.StoveSerde +import com.trendyol.stove.testing.e2e.system.TestSystem +import com.trendyol.stove.testing.e2e.system.TestSystem.Companion.validate +import io.kotest.core.spec.style.ShouldSpec +import io.kotest.matchers.shouldBe +import org.apache.kafka.clients.admin.NewTopic +import kotlin.random.Random + +class StringSerdeKafkaSystemTests : + ShouldSpec({ + beforeSpec { + TestSystem() + .with { + kafka { + KafkaSystemOptions( + configureExposedConfiguration = { + listOf( + "kafka.bootstrapServers=${it.bootstrapServers}", + "kafka.groupId=test-group", + "kafka.offset=earliest" + ) + }, + containerOptions = KafkaContainerOptions(tag = "7.8.1") + ) + } + springBoot( + runner = { params -> + KafkaTestSpringBotApplicationForStringSerde.run(params) { + addInitializers( + stoveSpring4xRegistrar { + registerBean>(primary = true) + registerBean { StoveSerde.jackson.anyByteArraySerde() } + } + ) + } + }, + withParameters = listOf( + "spring.lifecycle.timeout-per-shutdown-phase=0s" + ) + ) + }.run() + } + + afterSpec { + TestSystem.stop() + } + + should("publish and consume") { + validate { + kafka { + val userId = Random.nextInt().toString() + val message = + "this message is coming from ${testCase.descriptor.id.value} and testName is ${testCase.name.name}" + val headers = mapOf("x-user-id" to userId) + publish("topic", message, headers = headers) + shouldBePublished { + actual == message && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic" + } + shouldBeConsumed { + actual == message && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic" + } + } + } + } + + should("publish and consume with failed consumer") { + shouldThrowMaybe { + validate { + kafka { + val userId = Random.nextInt().toString() + val message = "this message is coming from ${testCase.descriptor.id.value} and testName is ${testCase.name.name}" + val headers = mapOf("x-user-id" to userId) + publish("topic-failed", message, headers = headers) + shouldBePublished { + actual == message && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic-failed" + } + shouldBeFailed { + actual == message && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic-failed" && reason is StoveBusinessException + } + + shouldBePublished { + actual == message && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic-failed-dlt" + } + } + } + } + } + + should("admin operations") { + validate { + kafka { + adminOperations { + val topic = "topic" + createTopics(listOf(NewTopic(topic, 1, 1))) + listTopics().names().get().contains(topic) shouldBe true + deleteTopics(listOf(topic)) + listTopics().names().get().contains(topic) shouldBe false + } + } + } + } + + should("publish with ser/de") { + validate { + kafka { + val userId = Random.nextInt().toString() + val message = + "this message is coming from ${testCase.descriptor.id.value} and testName is ${testCase.name.name}" + val headers = mapOf("x-user-id" to userId) + publish("topic", message, serde = StoveSerde.jackson.anyJsonStringSerde().some(), headers = headers) + shouldBePublished { + actual == message && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic" + } + shouldBeConsumed { + actual == message && this.metadata.headers["x-user-id"] == userId && this.metadata.topic == "topic" + } + } + } + } + }) diff --git a/starters/spring/tests/spring-4x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/stringserde/app.kt b/starters/spring/tests/spring-4x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/stringserde/app.kt new file mode 100644 index 000000000..7491ee472 --- /dev/null +++ b/starters/spring/tests/spring-4x-kafka-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/kafka/stringserde/app.kt @@ -0,0 +1,113 @@ +package com.trendyol.stove.testing.e2e.kafka.stringserde + +import com.trendyol.stove.testing.e2e.kafka.StoveBusinessException +import org.apache.kafka.clients.consumer.ConsumerConfig +import org.apache.kafka.clients.producer.ProducerConfig +import org.apache.kafka.common.serialization.Serdes +import org.slf4j.* +import org.springframework.boot.* +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.context.properties.* +import org.springframework.context.ConfigurableApplicationContext +import org.springframework.context.annotation.Bean +import org.springframework.kafka.annotation.* +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory +import org.springframework.kafka.core.* +import org.springframework.kafka.listener.* +import org.springframework.util.backoff.FixedBackOff + +@SpringBootApplication(scanBasePackages = ["com.trendyol.stove.testing.e2e.kafka.stringserde"]) +@EnableKafka +@EnableConfigurationProperties(KafkaTestSpringBotApplicationForStringSerde.StringSerdeKafkaConf::class) +open class KafkaTestSpringBotApplicationForStringSerde { + companion object { + fun run( + args: Array, + init: SpringApplication.() -> Unit = {} + ): ConfigurableApplicationContext { + System.setProperty("org.springframework.boot.logging.LoggingSystem", "none") + return runApplication(args = args) { + setWebApplicationType(WebApplicationType.NONE) + init() + } + } + } + + private val logger: Logger = LoggerFactory.getLogger(javaClass) + + @ConfigurationProperties(prefix = "kafka") + data class StringSerdeKafkaConf( + val bootstrapServers: String, + val groupId: String, + val offset: String + ) + + @Bean + open fun kafkaListenerContainerFactory( + consumerFactory: ConsumerFactory, + interceptor: RecordInterceptor, + recoverer: DeadLetterPublishingRecoverer + ): ConcurrentKafkaListenerContainerFactory { + val factory = ConcurrentKafkaListenerContainerFactory() + factory.setConsumerFactory(consumerFactory) + factory.setCommonErrorHandler( + DefaultErrorHandler( + recoverer, + FixedBackOff(20, 1) + ).also { it.addNotRetryableExceptions(StoveBusinessException::class.java) } + ) + factory.setRecordInterceptor(interceptor) + return factory + } + + @Bean + open fun recoverer( + kafkaTemplate: KafkaTemplate<*, *> + ): DeadLetterPublishingRecoverer = DeadLetterPublishingRecoverer(kafkaTemplate) + + @Bean + open fun consumerFactory( + config: StringSerdeKafkaConf + ): ConsumerFactory = DefaultKafkaConsumerFactory( + mapOf( + ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, + ConsumerConfig.GROUP_ID_CONFIG to config.groupId, + ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to config.offset, + ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to Serdes.String().deserializer().javaClass, + ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to Serdes.String().deserializer().javaClass, + ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG to 2000, + ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG to 6000, + ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG to 6000 + ) + ) + + @Bean + open fun kafkaTemplate( + config: StringSerdeKafkaConf + ): KafkaTemplate = KafkaTemplate( + DefaultKafkaProducerFactory( + mapOf( + ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to config.bootstrapServers, + ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to Serdes.String().serializer().javaClass, + ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to Serdes.String().serializer().javaClass, + ProducerConfig.ACKS_CONFIG to "1" + ) + ) + ) + + @KafkaListener(topics = ["topic"], groupId = "group_id") + fun listen(message: String) { + logger.info("Received Message in consumer: \n$message") + } + + @KafkaListener(topics = ["topic-failed"], groupId = "group_id") + fun listenFailed(message: String) { + logger.info("Received Message in failed consumer: \n$message") + throw StoveBusinessException("This exception is thrown intentionally for testing purposes.") + } + + @KafkaListener(topics = ["topic-failed-dlt"], groupId = "group_id") + fun listenDeadLetter(message: String) { + logger.info("Received Message in the lead letter, and allowing the fail by just logging: \n$message") + } +} diff --git a/starters/spring/tests/spring-4x-kafka-tests/src/test/proto/example.proto b/starters/spring/tests/spring-4x-kafka-tests/src/test/proto/example.proto new file mode 100644 index 000000000..9ce09ee5d --- /dev/null +++ b/starters/spring/tests/spring-4x-kafka-tests/src/test/proto/example.proto @@ -0,0 +1,18 @@ +syntax = "proto3"; + +// buf:lint:ignore PACKAGE_DIRECTORY_MATCH +package com.trendyol.stove.spring.testing.e2e.kafka.v1; + +message Product { + string id = 1; + string name = 2; + string description = 3; + double price = 4; + string currency = 5; +} + +message Order { + string id = 1; + string customerId = 2; + repeated Product products = 3; +} diff --git a/starters/spring/tests/spring-4x-kafka-tests/src/test/resources/kotest.properties b/starters/spring/tests/spring-4x-kafka-tests/src/test/resources/kotest.properties new file mode 100644 index 000000000..599cf072f --- /dev/null +++ b/starters/spring/tests/spring-4x-kafka-tests/src/test/resources/kotest.properties @@ -0,0 +1 @@ +kotest.framework.config.fqn=com.trendyol.stove.testing.e2e.kafka.Setup diff --git a/starters/spring/tests/spring-4x-kafka-tests/src/test/resources/logback-test.xml b/starters/spring/tests/spring-4x-kafka-tests/src/test/resources/logback-test.xml new file mode 100644 index 000000000..a1e9ff6ea --- /dev/null +++ b/starters/spring/tests/spring-4x-kafka-tests/src/test/resources/logback-test.xml @@ -0,0 +1,20 @@ + + + + + + %white([%t]) %highlight(%-5level) %magenta(%c{1}) %cyan(trace.id:%X{traceId} version:%X{version}) - + %yellow(%m) %n + + + + + + + + + + + + + diff --git a/starters/spring/tests/spring-4x-tests/api/spring-4x-tests.api b/starters/spring/tests/spring-4x-tests/api/spring-4x-tests.api new file mode 100644 index 000000000..e69de29bb diff --git a/starters/spring/tests/spring-4x-tests/build.gradle.kts b/starters/spring/tests/spring-4x-tests/build.gradle.kts new file mode 100644 index 000000000..cb7d1fc96 --- /dev/null +++ b/starters/spring/tests/spring-4x-tests/build.gradle.kts @@ -0,0 +1,14 @@ +dependencies { + api(projects.starters.spring.stoveSpringTestingE2e) + implementation(libs.spring.boot.four) +} + +dependencies { + testAnnotationProcessor(libs.spring.boot.four.annotationProcessor) + testImplementation(libs.spring.boot.four.autoconfigure) + testImplementation(libs.slf4j.simple) +} + +tasks.test.configure { + systemProperty("kotest.framework.config.fqn", "com.trendyol.stove.testing.e2e.Stove") +} diff --git a/starters/spring/stove-spring-testing-e2e/src/test/kotlin/com/trendyol/stove/testing/e2e/BridgeSystemTests.kt b/starters/spring/tests/spring-4x-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/BridgeSystemTests.kt similarity index 87% rename from starters/spring/stove-spring-testing-e2e/src/test/kotlin/com/trendyol/stove/testing/e2e/BridgeSystemTests.kt rename to starters/spring/tests/spring-4x-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/BridgeSystemTests.kt index 583780f2f..193dc441c 100644 --- a/starters/spring/stove-spring-testing-e2e/src/test/kotlin/com/trendyol/stove/testing/e2e/BridgeSystemTests.kt +++ b/starters/spring/tests/spring-4x-tests/src/test/kotlin/com/trendyol/stove/testing/e2e/BridgeSystemTests.kt @@ -10,8 +10,9 @@ import io.kotest.matchers.shouldBe import kotlinx.coroutines.delay import org.springframework.boot.* import org.springframework.boot.autoconfigure.SpringBootApplication -import org.springframework.context.* -import org.springframework.context.support.GenericApplicationContext +import org.springframework.boot.context.event.ApplicationReadyEvent +import org.springframework.context.ConfigurableApplicationContext +import org.springframework.context.event.EventListener import org.springframework.stereotype.Component import java.time.Instant import kotlin.time.Duration.Companion.seconds @@ -40,28 +41,13 @@ class SystemTimeGetUtcNow : GetUtcNow { override fun invoke(): Instant = GetUtcNow.frozenTime } -class TestAppInitializers : - BaseApplicationContextInitializer({ - bean { StoveSerde.jackson.default } - bean { SystemTimeGetUtcNow() } - }) { +class TestAppInitializers { var onEvent: Boolean = false var appReady: Boolean = false - init { - register { - bean() - bean { this@TestAppInitializers } - } - } - - override fun onEvent(event: ApplicationEvent) { - super.onEvent(event) + @EventListener(ApplicationReadyEvent::class) + fun applicationReady() { onEvent = true - } - - override fun applicationReady(applicationContext: GenericApplicationContext) { - super.applicationReady(applicationContext) appReady = true } } @@ -89,7 +75,12 @@ class Stove : AbstractProjectConfig() { runner = { params -> TestAppRunner.run(params) { addInitializers( - TestAppInitializers() + stoveSpring4xRegistrar { + registerBean() + registerBean() + registerBean { StoveSerde.jackson.default } + registerBean { SystemTimeGetUtcNow() } + } ) } },