diff --git a/build.gradle.kts b/build.gradle.kts index 47bd99c..e4689ab 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -44,18 +44,19 @@ allprojects { dependencies { implementation(rootProject.libs.kotlin.reflect) implementation(rootProject.libs.kotlinx.serialization.json) + implementation(rootProject.libs.logback.encoder) implementation(rootProject.libs.spring.aspects) testImplementation(rootProject.libs.kotlin.test.junit) testImplementation(rootProject.libs.mockito.kotlin) testImplementation(rootProject.libs.spring.boot.starter.test) - - kover(project(":db")) } tasks.test { useJUnitPlatform() finalizedBy("koverXmlReport") + + jvmArgs("--add-opens=java.base/java.lang=ALL-UNNAMED", "--add-opens=java.base/java.util=ALL-UNNAMED") } } @@ -78,7 +79,6 @@ dependencies { implementation(libs.jackson.module.kotlin) implementation(libs.ktor.client.cio) implementation(libs.ktor.client.core) - implementation(libs.logback.encoder) implementation(libs.postgres) implementation(libs.spring.boot.starter.actuator) implementation(libs.spring.boot.starter.jdbc) @@ -95,4 +95,7 @@ dependencies { testImplementation(libs.testcontainers.junit.jupiter) developmentOnly(libs.spring.boot.devtools) + + kover(project(":core")) + kover(project(":db")) } diff --git a/src/main/kotlin/com/github/dannecron/demo/config/AppConfig.kt b/src/main/kotlin/com/github/dannecron/demo/config/AppConfig.kt index be02ab6..d55834d 100644 --- a/src/main/kotlin/com/github/dannecron/demo/config/AppConfig.kt +++ b/src/main/kotlin/com/github/dannecron/demo/config/AppConfig.kt @@ -3,26 +3,12 @@ package com.github.dannecron.demo.config import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule -import com.github.dannecron.demo.config.properties.KafkaProperties -import com.github.dannecron.demo.config.properties.ValidationProperties -import com.github.dannecron.demo.providers.CityRepository -import com.github.dannecron.demo.providers.CustomerRepository -import com.github.dannecron.demo.providers.ProductRepository -import com.github.dannecron.demo.services.database.city.CityService -import com.github.dannecron.demo.services.database.city.CityServiceImpl -import com.github.dannecron.demo.services.database.customer.CustomerService -import com.github.dannecron.demo.services.database.customer.CustomerServiceImpl -import com.github.dannecron.demo.services.database.product.ProductService -import com.github.dannecron.demo.services.database.product.ProductServiceImpl -import com.github.dannecron.demo.services.kafka.Producer -import com.github.dannecron.demo.services.validation.SchemaValidator -import com.github.dannecron.demo.services.validation.SchemaValidatorImp -import io.ktor.client.engine.* -import io.ktor.client.engine.cio.* +import com.github.dannecron.demo.core.config.properties.ValidationProperties +import io.ktor.client.engine.HttpClientEngine +import io.ktor.client.engine.cio.CIO import io.micrometer.observation.ObservationRegistry import io.micrometer.observation.aop.ObservedAspect import io.opentelemetry.exporter.otlp.http.trace.OtlpHttpSpanExporter -import org.springframework.beans.factory.annotation.Autowired import org.springframework.beans.factory.annotation.Value import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.context.annotation.Bean @@ -31,10 +17,8 @@ import com.github.dannecron.demo.services.neko.Client as NekoClient import com.github.dannecron.demo.services.neko.ClientImpl as NekoClientImpl @Configuration -@EnableConfigurationProperties(KafkaProperties::class, ValidationProperties::class) -class AppConfig( - @Autowired private val kafkaProperties: KafkaProperties, -) { +@EnableConfigurationProperties(ValidationProperties::class) +class AppConfig { @Bean fun objectMapper(): ObjectMapper = ObjectMapper().apply { registerModules(JavaTimeModule()) @@ -42,43 +26,20 @@ class AppConfig( } @Bean - fun productService( - @Autowired productRepository: ProductRepository, - @Autowired producer: Producer, - ): ProductService = ProductServiceImpl( - kafkaProperties.producer.product.defaultSyncTopic, - productRepository, - producer, - ) + fun otlpHttpSpanExporter(@Value("\${tracing.url}") url: String): OtlpHttpSpanExporter = + OtlpHttpSpanExporter.builder() + .setEndpoint(url) + .build() @Bean - fun cityService(@Autowired cityRepository: CityRepository): CityService = CityServiceImpl(cityRepository) - - @Bean - fun customerService( - @Autowired customerRepository: CustomerRepository, - @Autowired cityRepository: CityRepository, - ): CustomerService = CustomerServiceImpl(customerRepository, cityRepository) - - @Bean - fun schemaValidator( - @Autowired validationProperties: ValidationProperties, - ): SchemaValidator = SchemaValidatorImp(validationProperties.schema) - - @Bean - fun otlpHttpSpanExporter(@Value("\${tracing.url}") url: String) = OtlpHttpSpanExporter.builder() - .setEndpoint(url) - .build() - - @Bean - fun observedAspect(@Autowired observationRegistry: ObservationRegistry) = ObservedAspect(observationRegistry) + fun observedAspect(observationRegistry: ObservationRegistry) = ObservedAspect(observationRegistry) @Bean fun httpClientEngine(): HttpClientEngine = CIO.create() @Bean fun nekoClient( - @Autowired httpClientEngine: HttpClientEngine, + httpClientEngine: HttpClientEngine, @Value("\${neko.baseUrl}") baseUrl: String, ): NekoClient = NekoClientImpl( engine = httpClientEngine, diff --git a/src/main/kotlin/com/github/dannecron/demo/config/KafkaConsumerConfig.kt b/src/main/kotlin/com/github/dannecron/demo/config/KafkaConsumerConfig.kt deleted file mode 100644 index a416da5..0000000 --- a/src/main/kotlin/com/github/dannecron/demo/config/KafkaConsumerConfig.kt +++ /dev/null @@ -1,42 +0,0 @@ -package com.github.dannecron.demo.config - -import com.github.dannecron.demo.config.properties.KafkaProperties -import com.github.dannecron.demo.services.database.city.CityService -import com.github.dannecron.demo.services.kafka.Consumer -import io.micrometer.core.instrument.MeterRegistry -import org.apache.kafka.clients.consumer.ConsumerConfig -import org.apache.kafka.common.serialization.StringDeserializer -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory -import org.springframework.kafka.core.ConsumerFactory -import org.springframework.kafka.core.DefaultKafkaConsumerFactory - -@Configuration -class KafkaConsumerConfig( - @Autowired val kafkaProperties: KafkaProperties -) { - @Bean - fun consumer( - @Autowired cityService: CityService, - @Autowired metricRegistry: MeterRegistry - ): Consumer = Consumer( - cityService = cityService, - metricRegistry = metricRegistry, - ) - - @Bean - fun consumerFactory(): ConsumerFactory = DefaultKafkaConsumerFactory(mapOf( - ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG to kafkaProperties.bootstrapServers, - ConsumerConfig.GROUP_ID_CONFIG to kafkaProperties.consumer.groupId, - ConsumerConfig.AUTO_OFFSET_RESET_CONFIG to kafkaProperties.consumer.autoOffsetReset, - ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java, - ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG to StringDeserializer::class.java - )) - - @Bean - fun kafkaListenerContainerFactory() = ConcurrentKafkaListenerContainerFactory().apply { - consumerFactory = consumerFactory() - } -} diff --git a/src/main/kotlin/com/github/dannecron/demo/config/KafkaProducerConfig.kt b/src/main/kotlin/com/github/dannecron/demo/config/KafkaProducerConfig.kt deleted file mode 100644 index b5ef628..0000000 --- a/src/main/kotlin/com/github/dannecron/demo/config/KafkaProducerConfig.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.github.dannecron.demo.config - -import com.github.dannecron.demo.config.properties.KafkaProperties -import com.github.dannecron.demo.services.kafka.Producer -import com.github.dannecron.demo.services.kafka.ProducerImpl -import com.github.dannecron.demo.services.validation.SchemaValidator -import org.apache.kafka.clients.producer.ProducerConfig -import org.apache.kafka.common.serialization.StringSerializer -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.context.annotation.Bean -import org.springframework.context.annotation.Configuration -import org.springframework.kafka.core.DefaultKafkaProducerFactory -import org.springframework.kafka.core.KafkaTemplate -import org.springframework.kafka.core.ProducerFactory - -@Configuration -class KafkaProducerConfig( - @Autowired val kafkaProperties: KafkaProperties -) { - @Bean - fun producerFactory(): ProducerFactory = DefaultKafkaProducerFactory(mapOf( - ProducerConfig.BOOTSTRAP_SERVERS_CONFIG to kafkaProperties.bootstrapServers, - ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, - ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG to StringSerializer::class.java, - )) - - @Bean - fun kafkaTemplate(): KafkaTemplate = KafkaTemplate( - producerFactory(), - ) - - @Bean - fun producer( - @Autowired kafkaTemplate: KafkaTemplate, - @Autowired schemaValidator: SchemaValidator, - ): Producer = ProducerImpl( - kafkaTemplate, - schemaValidator, - ) -} diff --git a/src/main/kotlin/com/github/dannecron/demo/config/kafka/CityConsumerConfig.kt b/src/main/kotlin/com/github/dannecron/demo/config/kafka/CityConsumerConfig.kt new file mode 100644 index 0000000..902df7c --- /dev/null +++ b/src/main/kotlin/com/github/dannecron/demo/config/kafka/CityConsumerConfig.kt @@ -0,0 +1,15 @@ +package com.github.dannecron.demo.config.kafka + +import com.github.dannecron.demo.services.kafka.CityCreateConsumer +import com.github.dannecron.demo.services.kafka.dto.CityCreateDto +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import java.util.function.Consumer + +@Configuration +class CityConsumerConfig { + + @Bean + fun citySyncConsumer(cityCreateConsumer: CityCreateConsumer): Consumer = + Consumer(cityCreateConsumer::process) +} diff --git a/src/main/kotlin/com/github/dannecron/demo/config/properties/KafkaProperties.kt b/src/main/kotlin/com/github/dannecron/demo/config/properties/KafkaProperties.kt deleted file mode 100644 index 1096c92..0000000 --- a/src/main/kotlin/com/github/dannecron/demo/config/properties/KafkaProperties.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.github.dannecron.demo.config.properties - -import org.springframework.boot.context.properties.ConfigurationProperties -import org.springframework.boot.context.properties.bind.ConstructorBinding - -@ConfigurationProperties("kafka") -data class KafkaProperties @ConstructorBinding constructor( - val bootstrapServers: String, - val producer: Producer, - val consumer: Consumer, -) { - data class Producer( - val product: Product, - ) { - data class Product( - val defaultSyncTopic: String - ) - } - - data class Consumer( - val groupId: String, - val topics: String, - val autoStartup: Boolean, - val autoOffsetReset: String, - ) -} diff --git a/src/main/kotlin/com/github/dannecron/demo/http/controllers/CustomerController.kt b/src/main/kotlin/com/github/dannecron/demo/http/controllers/CustomerController.kt index 0e4d033..a372987 100644 --- a/src/main/kotlin/com/github/dannecron/demo/http/controllers/CustomerController.kt +++ b/src/main/kotlin/com/github/dannecron/demo/http/controllers/CustomerController.kt @@ -1,7 +1,7 @@ package com.github.dannecron.demo.http.controllers +import com.github.dannecron.demo.core.services.customer.CustomerService import com.github.dannecron.demo.http.exceptions.NotFoundException -import com.github.dannecron.demo.services.database.customer.CustomerService import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.HttpStatus import org.springframework.http.MediaType diff --git a/src/main/kotlin/com/github/dannecron/demo/http/controllers/ProductController.kt b/src/main/kotlin/com/github/dannecron/demo/http/controllers/ProductController.kt index f73d73f..c48cf41 100644 --- a/src/main/kotlin/com/github/dannecron/demo/http/controllers/ProductController.kt +++ b/src/main/kotlin/com/github/dannecron/demo/http/controllers/ProductController.kt @@ -1,15 +1,16 @@ package com.github.dannecron.demo.http.controllers +import com.github.dannecron.demo.core.dto.Product +import com.github.dannecron.demo.core.exceptions.AlreadyDeletedException +import com.github.dannecron.demo.core.exceptions.ProductNotFoundException +import com.github.dannecron.demo.core.services.product.ProductService import com.github.dannecron.demo.http.exceptions.NotFoundException import com.github.dannecron.demo.http.exceptions.UnprocessableException import com.github.dannecron.demo.http.requests.CreateProductRequest import com.github.dannecron.demo.http.responses.NotFoundResponse import com.github.dannecron.demo.http.responses.makeOkResponse import com.github.dannecron.demo.http.responses.page.PageResponse -import com.github.dannecron.demo.models.Product -import com.github.dannecron.demo.services.database.exceptions.AlreadyDeletedException -import com.github.dannecron.demo.services.database.exceptions.ProductNotFoundException -import com.github.dannecron.demo.services.database.product.ProductService +import com.github.dannecron.demo.services.ProductSyncService import com.github.dannecron.demo.services.kafka.exceptions.InvalidArgumentException import io.swagger.v3.oas.annotations.media.Content import io.swagger.v3.oas.annotations.media.Schema @@ -27,7 +28,8 @@ import java.util.* @RestController @RequestMapping(value = ["/api/product"], produces = [MediaType.APPLICATION_JSON_VALUE]) class ProductController( - val productService: ProductService, + private val productService: ProductService, + private val productSyncService: ProductSyncService, ) { @GetMapping("/{guid}") @Throws(NotFoundException::class) @@ -71,7 +73,7 @@ class ProductController( @RequestParam(required = false) topic: String? ): ResponseEntity { try { - productService.syncToKafka(guid, topic) + productSyncService.syncToKafka(guid, topic) } catch (_: InvalidArgumentException) { throw UnprocessableException("cannot sync product to kafka") } diff --git a/src/main/kotlin/com/github/dannecron/demo/models/CustomerExtended.kt b/src/main/kotlin/com/github/dannecron/demo/models/CustomerExtended.kt deleted file mode 100644 index c11bdb5..0000000 --- a/src/main/kotlin/com/github/dannecron/demo/models/CustomerExtended.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.github.dannecron.demo.models - -import com.github.dannecron.demo.db.entity.City -import com.github.dannecron.demo.db.entity.Customer -import kotlinx.serialization.Serializable - -@Serializable -data class CustomerExtended( - val customer: Customer, - val city: City?, -) diff --git a/src/main/kotlin/com/github/dannecron/demo/models/OrderWithProducts.kt b/src/main/kotlin/com/github/dannecron/demo/models/OrderWithProducts.kt deleted file mode 100644 index 86796b7..0000000 --- a/src/main/kotlin/com/github/dannecron/demo/models/OrderWithProducts.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.github.dannecron.demo.models - -import com.github.dannecron.demo.db.entity.Product -import com.github.dannecron.demo.db.entity.order.Order - -data class OrderWithProducts( - val order: Order, - val products: List, -) { - fun getMostExpensiveOrderedProduct(): Product? = products.maxByOrNull { pr -> pr.price } - - fun getTotalOrderPrice(): Double = products.sumOf { pr -> pr.getPriceDouble() } -} diff --git a/src/main/kotlin/com/github/dannecron/demo/providers/CityRepository.kt b/src/main/kotlin/com/github/dannecron/demo/providers/CityRepository.kt deleted file mode 100644 index 145d311..0000000 --- a/src/main/kotlin/com/github/dannecron/demo/providers/CityRepository.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.github.dannecron.demo.providers - -import com.github.dannecron.demo.models.City -import org.springframework.data.jdbc.repository.query.Query -import org.springframework.data.repository.CrudRepository -import org.springframework.data.repository.query.Param -import org.springframework.stereotype.Repository -import java.time.OffsetDateTime -import java.util.* - -@Repository -interface CityRepository: CrudRepository { - fun findByGuid(guid: UUID): City? - - @Query(value = "UPDATE City SET deleted_at = :deletedAt WHERE guid = :guid RETURNING *") - fun softDelete(@Param("guid") guid: UUID, @Param("deletedAt") deletedAt: OffsetDateTime): City? -} diff --git a/src/main/kotlin/com/github/dannecron/demo/providers/CustomerRepository.kt b/src/main/kotlin/com/github/dannecron/demo/providers/CustomerRepository.kt deleted file mode 100644 index 541ecdb..0000000 --- a/src/main/kotlin/com/github/dannecron/demo/providers/CustomerRepository.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.github.dannecron.demo.providers - -import com.github.dannecron.demo.models.Customer -import org.springframework.data.repository.CrudRepository -import org.springframework.stereotype.Repository -import java.util.* - -@Repository -interface CustomerRepository: CrudRepository { - fun findByGuid(guid: UUID): Customer? -} diff --git a/src/main/kotlin/com/github/dannecron/demo/providers/OrderProductRepository.kt b/src/main/kotlin/com/github/dannecron/demo/providers/OrderProductRepository.kt deleted file mode 100644 index 7a8994e..0000000 --- a/src/main/kotlin/com/github/dannecron/demo/providers/OrderProductRepository.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.github.dannecron.demo.providers - -import com.github.dannecron.demo.models.order.OrderProduct -import org.springframework.data.repository.CrudRepository -import java.util.* - -interface OrderProductRepository: CrudRepository { - fun findByOrderId(orderId: Long): List -} diff --git a/src/main/kotlin/com/github/dannecron/demo/providers/OrderRepository.kt b/src/main/kotlin/com/github/dannecron/demo/providers/OrderRepository.kt deleted file mode 100644 index b99d1b5..0000000 --- a/src/main/kotlin/com/github/dannecron/demo/providers/OrderRepository.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.github.dannecron.demo.providers - -import com.github.dannecron.demo.models.order.Order -import org.springframework.data.repository.CrudRepository -import org.springframework.stereotype.Repository - -@Repository -interface OrderRepository: CrudRepository { - fun findByCustomerId(customerId: Long): List -} diff --git a/src/main/kotlin/com/github/dannecron/demo/providers/ProductRepository.kt b/src/main/kotlin/com/github/dannecron/demo/providers/ProductRepository.kt deleted file mode 100644 index 83c0d74..0000000 --- a/src/main/kotlin/com/github/dannecron/demo/providers/ProductRepository.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.github.dannecron.demo.providers - -import com.github.dannecron.demo.models.Product -import org.springframework.data.jdbc.repository.query.Query -import org.springframework.data.repository.CrudRepository -import org.springframework.data.repository.PagingAndSortingRepository -import org.springframework.data.repository.query.Param -import org.springframework.stereotype.Repository -import java.time.OffsetDateTime -import java.util.* - -@Repository -interface ProductRepository: CrudRepository, PagingAndSortingRepository { - fun findByGuid(guid: UUID): Product? - - @Query(value = "UPDATE Product SET deleted_at = :deletedAt WHERE guid = :guid RETURNING *") - fun softDelete(@Param("guid") guid: UUID, @Param("deletedAt") deletedAt: OffsetDateTime): Product? -} diff --git a/src/main/kotlin/com/github/dannecron/demo/providers/html/Html.kt b/src/main/kotlin/com/github/dannecron/demo/providers/html/Html.kt index 5f6a8e8..92520c1 100644 --- a/src/main/kotlin/com/github/dannecron/demo/providers/html/Html.kt +++ b/src/main/kotlin/com/github/dannecron/demo/providers/html/Html.kt @@ -1,14 +1,14 @@ package com.github.dannecron.demo.providers.html -class Html: com.github.dannecron.demo.providers.html.Tag("html") +class Html: Tag("html") -fun html(init: com.github.dannecron.demo.providers.html.Html.() -> Unit): com.github.dannecron.demo.providers.html.Html { - val tag = com.github.dannecron.demo.providers.html.Html() +fun html(init: Html.() -> Unit): Html { + val tag = Html() tag.init() return tag } -fun com.github.dannecron.demo.providers.html.Html.table(init : com.github.dannecron.demo.providers.html.Table.() -> Unit) = doInit( - com.github.dannecron.demo.providers.html.Table(), init) -fun com.github.dannecron.demo.providers.html.Html.center(init : com.github.dannecron.demo.providers.html.Center.() -> Unit) = doInit( - com.github.dannecron.demo.providers.html.Center(), init) +fun Html.table(init : Table.() -> Unit) = doInit( + Table(), init) +fun Html.center(init : Center.() -> Unit) = doInit( + Center(), init) diff --git a/src/main/kotlin/com/github/dannecron/demo/providers/html/HtmlProvider.kt b/src/main/kotlin/com/github/dannecron/demo/providers/html/HtmlProvider.kt index 272190d..d9d339b 100644 --- a/src/main/kotlin/com/github/dannecron/demo/providers/html/HtmlProvider.kt +++ b/src/main/kotlin/com/github/dannecron/demo/providers/html/HtmlProvider.kt @@ -4,7 +4,7 @@ fun getTitleColor() = "#b9c9fe" fun getCellColor(index: Int, row: Int) = if ((index + row) %2 == 0) "#dce4ff" else "#eff2ff" fun renderProductTable(): String { - return com.github.dannecron.demo.providers.html.html { + return html { table { tr(color = getTitleColor()) { diff --git a/src/main/kotlin/com/github/dannecron/demo/providers/html/Tag.kt b/src/main/kotlin/com/github/dannecron/demo/providers/html/Tag.kt index 9f1d676..3129b3b 100644 --- a/src/main/kotlin/com/github/dannecron/demo/providers/html/Tag.kt +++ b/src/main/kotlin/com/github/dannecron/demo/providers/html/Tag.kt @@ -1,8 +1,8 @@ package com.github.dannecron.demo.providers.html open class Tag(val name: String) { - val children: MutableList = ArrayList() - val attributes: MutableList = ArrayList() + val children: MutableList = mutableListOf() + val attributes: MutableList = mutableListOf() override fun toString(): String { return "<$name" + @@ -25,4 +25,4 @@ fun Tag.doInit(tag: T, init: T.() -> Unit): T { return tag } -fun Tag.text(s : Any?) = doInit(Text(s.toString()), {}) +fun Tag.text(s : Any?) = doInit(Text(s.toString())) {} diff --git a/src/main/kotlin/com/github/dannecron/demo/services/ProductSyncService.kt b/src/main/kotlin/com/github/dannecron/demo/services/ProductSyncService.kt new file mode 100644 index 0000000..95bbcca --- /dev/null +++ b/src/main/kotlin/com/github/dannecron/demo/services/ProductSyncService.kt @@ -0,0 +1,12 @@ +package com.github.dannecron.demo.services + + +import com.github.dannecron.demo.core.exceptions.ProductNotFoundException +import com.github.dannecron.demo.services.kafka.exceptions.InvalidArgumentException +import java.util.UUID + +interface ProductSyncService { + + @Throws(ProductNotFoundException::class, InvalidArgumentException::class) + fun syncToKafka(guid: UUID, topic: String?) +} diff --git a/src/main/kotlin/com/github/dannecron/demo/services/ProductSyncServiceImpl.kt b/src/main/kotlin/com/github/dannecron/demo/services/ProductSyncServiceImpl.kt new file mode 100644 index 0000000..1959373 --- /dev/null +++ b/src/main/kotlin/com/github/dannecron/demo/services/ProductSyncServiceImpl.kt @@ -0,0 +1,36 @@ +package com.github.dannecron.demo.services + +import com.github.dannecron.demo.core.dto.Product +import com.github.dannecron.demo.core.exceptions.ProductNotFoundException +import com.github.dannecron.demo.core.services.product.ProductService +import com.github.dannecron.demo.services.kafka.Producer +import com.github.dannecron.demo.services.kafka.dto.ProductDto +import com.github.dannecron.demo.services.kafka.exceptions.InvalidArgumentException +import org.springframework.stereotype.Service +import java.time.format.DateTimeFormatter +import java.util.UUID + +@Service +class ProductSyncServiceImpl( + private val productService: ProductService, + private val producer: Producer, +) : ProductSyncService { + + @Throws(ProductNotFoundException::class, InvalidArgumentException::class) + override fun syncToKafka(guid: UUID, topic: String?) { + val product = productService.findByGuid(guid) ?: throw ProductNotFoundException() + + producer.produceProductSync(product.toKafkaDto()) + } + + private fun Product.toKafkaDto() = ProductDto( + id = id, + guid = guid.toString(), + name = name, + description = description, + price = price, + createdAt = createdAt.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), + updatedAt = updatedAt?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), + deletedAt = deletedAt?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), + ) +} diff --git a/src/main/kotlin/com/github/dannecron/demo/services/database/customer/CustomerService.kt b/src/main/kotlin/com/github/dannecron/demo/services/database/customer/CustomerService.kt deleted file mode 100644 index 60c9f9b..0000000 --- a/src/main/kotlin/com/github/dannecron/demo/services/database/customer/CustomerService.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.github.dannecron.demo.services.database.customer - -import com.github.dannecron.demo.db.entity.Customer -import com.github.dannecron.demo.models.CustomerExtended -import com.github.dannecron.demo.services.database.exceptions.CityNotFoundException -import java.util.* - -interface CustomerService { - fun findByGuid(guid: UUID): CustomerExtended? - - @Throws(CityNotFoundException::class) - fun create(name: String, cityGuid: UUID?): Customer -} diff --git a/src/main/kotlin/com/github/dannecron/demo/services/database/customer/CustomerServiceImpl.kt b/src/main/kotlin/com/github/dannecron/demo/services/database/customer/CustomerServiceImpl.kt deleted file mode 100644 index 5be5c22..0000000 --- a/src/main/kotlin/com/github/dannecron/demo/services/database/customer/CustomerServiceImpl.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.github.dannecron.demo.services.database.customer - -import com.github.dannecron.demo.core.services.generation.CommonGenerator -import com.github.dannecron.demo.db.entity.Customer -import com.github.dannecron.demo.db.repository.CityRepository -import com.github.dannecron.demo.db.repository.CustomerRepository -import com.github.dannecron.demo.models.CustomerExtended -import com.github.dannecron.demo.services.database.exceptions.CityNotFoundException -import org.springframework.stereotype.Service -import java.util.UUID - -@Service -class CustomerServiceImpl( - private val customerRepository: CustomerRepository, - private val cityRepository: CityRepository, - private val commonGenerator: CommonGenerator, -): CustomerService { - override fun findByGuid(guid: UUID): CustomerExtended? = customerRepository.findByGuid(guid) - ?.let { - customer -> CustomerExtended( - customer = customer, - city = customer.cityId?.let { cityId -> cityRepository.findById(cityId).orElse(null) } - ) - } - - override fun create(name: String, cityGuid: UUID?): Customer = Customer( - id = null, - guid = commonGenerator.generateUUID(), - name = name, - cityId = cityGuid?.let { - cityRepository.findByGuid(it)?.id ?: throw CityNotFoundException() - }, - createdAt = commonGenerator.generateCurrentTime(), - updatedAt = null, - ).let { - customerRepository.save(it) - } -} diff --git a/src/main/kotlin/com/github/dannecron/demo/services/database/exceptions/AlreadyDeletedException.kt b/src/main/kotlin/com/github/dannecron/demo/services/database/exceptions/AlreadyDeletedException.kt deleted file mode 100644 index 0854a98..0000000 --- a/src/main/kotlin/com/github/dannecron/demo/services/database/exceptions/AlreadyDeletedException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.github.dannecron.demo.services.database.exceptions - -class AlreadyDeletedException: RuntimeException() diff --git a/src/main/kotlin/com/github/dannecron/demo/services/database/exceptions/CityNotFoundException.kt b/src/main/kotlin/com/github/dannecron/demo/services/database/exceptions/CityNotFoundException.kt deleted file mode 100644 index 8205c75..0000000 --- a/src/main/kotlin/com/github/dannecron/demo/services/database/exceptions/CityNotFoundException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.github.dannecron.demo.services.database.exceptions - -class CityNotFoundException: ModelNotFoundException("city") diff --git a/src/main/kotlin/com/github/dannecron/demo/services/database/exceptions/ModelNotFoundException.kt b/src/main/kotlin/com/github/dannecron/demo/services/database/exceptions/ModelNotFoundException.kt deleted file mode 100644 index 1d7f241..0000000 --- a/src/main/kotlin/com/github/dannecron/demo/services/database/exceptions/ModelNotFoundException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.github.dannecron.demo.services.database.exceptions - -open class ModelNotFoundException(entityName: String): RuntimeException("$entityName not found") diff --git a/src/main/kotlin/com/github/dannecron/demo/services/database/exceptions/ProductNotFoundException.kt b/src/main/kotlin/com/github/dannecron/demo/services/database/exceptions/ProductNotFoundException.kt deleted file mode 100644 index 3d68a6c..0000000 --- a/src/main/kotlin/com/github/dannecron/demo/services/database/exceptions/ProductNotFoundException.kt +++ /dev/null @@ -1,3 +0,0 @@ -package com.github.dannecron.demo.services.database.exceptions - -class ProductNotFoundException: ModelNotFoundException("product") diff --git a/src/main/kotlin/com/github/dannecron/demo/services/database/order/OrderServiceImpl.kt b/src/main/kotlin/com/github/dannecron/demo/services/database/order/OrderServiceImpl.kt deleted file mode 100644 index 7f8c049..0000000 --- a/src/main/kotlin/com/github/dannecron/demo/services/database/order/OrderServiceImpl.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.github.dannecron.demo.services.database.order - -import com.github.dannecron.demo.core.services.generation.CommonGenerator -import com.github.dannecron.demo.db.entity.Customer -import com.github.dannecron.demo.db.entity.Product -import com.github.dannecron.demo.db.entity.order.Order -import com.github.dannecron.demo.db.entity.order.OrderProduct -import com.github.dannecron.demo.db.repository.OrderProductRepository -import com.github.dannecron.demo.db.repository.OrderRepository -import com.github.dannecron.demo.db.repository.ProductRepository -import com.github.dannecron.demo.models.OrderWithProducts -import org.springframework.stereotype.Service -import org.springframework.transaction.annotation.Transactional - -@Service -class OrderServiceImpl( - private val orderRepository: OrderRepository, - private val orderProductRepository: OrderProductRepository, - private val productRepository: ProductRepository, - private val commonGenerator: CommonGenerator, -) { - fun findByCustomerId(customerId: Long): List = orderRepository.findByCustomerId(customerId) - .let { orders -> orders.map { order -> OrderWithProducts( - order = order, - products = findProductsByOrderId(order.id!!), - ) } } - - @Transactional - fun createOrder(customer: Customer, products: Set): Order { - val order = Order( - id = null, - guid = commonGenerator.generateUUID(), - customerId = customer.id!!, - deliveredAt = null, - createdAt = commonGenerator.generateCurrentTime(), - updatedAt = null, - ) - - return orderRepository.save(order) - .also { saveProductsForNewOrder(it, products.toList()) } - } - - private fun findProductsByOrderId(orderId: Long): List = - orderProductRepository.findByOrderId(orderId = orderId) - .map { it.productId } - .let { - if (it.isEmpty()) { - emptyList() - } else { - productRepository.findAllById(it).toList() - } - } - - private fun saveProductsForNewOrder(savedOrder: Order, products: List) { - products.map { - OrderProduct( - guid = commonGenerator.generateUUID(), - orderId = savedOrder.id!!, - productId = it.id!!, - createdAt = commonGenerator.generateCurrentTime(), - updatedAt = null - ) - }.also { orderProductRepository.saveAll(it) } - } -} diff --git a/src/main/kotlin/com/github/dannecron/demo/services/kafka/CityCreateConsumer.kt b/src/main/kotlin/com/github/dannecron/demo/services/kafka/CityCreateConsumer.kt new file mode 100644 index 0000000..291f4dc --- /dev/null +++ b/src/main/kotlin/com/github/dannecron/demo/services/kafka/CityCreateConsumer.kt @@ -0,0 +1,7 @@ +package com.github.dannecron.demo.services.kafka + +import com.github.dannecron.demo.services.kafka.dto.CityCreateDto + +interface CityCreateConsumer { + fun process(cityCreateDto: CityCreateDto) +} diff --git a/src/main/kotlin/com/github/dannecron/demo/services/kafka/CityCreateConsumerImpl.kt b/src/main/kotlin/com/github/dannecron/demo/services/kafka/CityCreateConsumerImpl.kt new file mode 100644 index 0000000..87a3538 --- /dev/null +++ b/src/main/kotlin/com/github/dannecron/demo/services/kafka/CityCreateConsumerImpl.kt @@ -0,0 +1,34 @@ +package com.github.dannecron.demo.services.kafka + +import com.github.dannecron.demo.core.dto.CityCreate +import com.github.dannecron.demo.core.services.city.CityService +import com.github.dannecron.demo.services.kafka.dto.CityCreateDto +import com.github.dannecron.demo.services.metrics.MetricsSender +import org.springframework.stereotype.Component +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter + +@Component +class CityCreateConsumerImpl( + private val cityService: CityService, + private val metricsSender: MetricsSender, +) : CityCreateConsumer { + + override fun process(cityCreateDto: CityCreateDto) { + cityService.create(cityCreateDto.toCore()).also { + metricsSender.incrementConsumerCityCreate() + } + } + + private fun CityCreateDto.toCore() = CityCreate( + guid = guid, + name = name, + createdAt = OffsetDateTime.parse(createdAt, DateTimeFormatter.ISO_OFFSET_DATE_TIME), + updatedAt = updatedAt?.let { + OffsetDateTime.parse(it, DateTimeFormatter.ISO_OFFSET_DATE_TIME) + }, + deletedAt = deletedAt?.let { + OffsetDateTime.parse(it, DateTimeFormatter.ISO_OFFSET_DATE_TIME) + } + ) +} diff --git a/src/main/kotlin/com/github/dannecron/demo/services/kafka/Consumer.kt b/src/main/kotlin/com/github/dannecron/demo/services/kafka/Consumer.kt deleted file mode 100644 index b4e93bd..0000000 --- a/src/main/kotlin/com/github/dannecron/demo/services/kafka/Consumer.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.github.dannecron.demo.services.kafka - -import com.github.dannecron.demo.services.database.city.CityService -import com.github.dannecron.demo.services.kafka.dto.CityCreateDto -import io.micrometer.core.instrument.Counter -import io.micrometer.core.instrument.MeterRegistry -import kotlinx.serialization.json.Json -import org.springframework.kafka.annotation.KafkaListener -import org.springframework.messaging.handler.annotation.Payload -import org.springframework.stereotype.Component - -@Component -class Consumer( - private val cityService: CityService, - private val metricRegistry: MeterRegistry, -) { - @KafkaListener( - topics = ["#{'\${kafka.consumer.topics}'.split(',')}"], - autoStartup = "\${kafka.consumer.auto-startup:false}", - ) - fun handleCityCreate(@Payload message: String) { - val cityCreateDto = Json.decodeFromString(message) - .also { - val counter = Counter.builder("kafka_consumer_city_create") - .description("consumed created city event") - .register(metricRegistry) - counter.increment() - } - - cityService.create(cityCreateDto) - } -} diff --git a/src/main/kotlin/com/github/dannecron/demo/services/kafka/Producer.kt b/src/main/kotlin/com/github/dannecron/demo/services/kafka/Producer.kt index 282d429..0b38fb1 100644 --- a/src/main/kotlin/com/github/dannecron/demo/services/kafka/Producer.kt +++ b/src/main/kotlin/com/github/dannecron/demo/services/kafka/Producer.kt @@ -1,9 +1,9 @@ package com.github.dannecron.demo.services.kafka -import com.github.dannecron.demo.models.Product +import com.github.dannecron.demo.services.kafka.dto.ProductDto import com.github.dannecron.demo.services.kafka.exceptions.InvalidArgumentException interface Producer { @Throws(InvalidArgumentException::class) - fun produceProductInfo(topicName: String, product: Product) + fun produceProductSync(product: ProductDto) } diff --git a/src/main/kotlin/com/github/dannecron/demo/services/kafka/ProducerImpl.kt b/src/main/kotlin/com/github/dannecron/demo/services/kafka/ProducerImpl.kt index a9c9561..1779baf 100644 --- a/src/main/kotlin/com/github/dannecron/demo/services/kafka/ProducerImpl.kt +++ b/src/main/kotlin/com/github/dannecron/demo/services/kafka/ProducerImpl.kt @@ -1,32 +1,36 @@ package com.github.dannecron.demo.services.kafka -import com.github.dannecron.demo.models.Product +import com.github.dannecron.demo.core.services.validation.SchemaValidator import com.github.dannecron.demo.services.kafka.dto.ProductDto -import com.github.dannecron.demo.services.validation.SchemaValidator -import com.github.dannecron.demo.services.validation.SchemaValidator.Companion.SCHEMA_KAFKA_PRODUCT_SYNC import kotlinx.serialization.json.Json import kotlinx.serialization.json.encodeToJsonElement -import org.springframework.kafka.core.KafkaTemplate -import org.springframework.kafka.support.KafkaHeaders +import org.springframework.cloud.stream.function.StreamBridge import org.springframework.messaging.support.MessageBuilder import org.springframework.stereotype.Service @Service class ProducerImpl( - private val kafkaTemplate: KafkaTemplate, + private val streamBridge: StreamBridge, private val schemaValidator: SchemaValidator, ): Producer { - override fun produceProductInfo(topicName: String, product: Product) { - Json.encodeToJsonElement(ProductDto(product)).let { - schemaValidator.validate(SCHEMA_KAFKA_PRODUCT_SYNC, it) + private companion object { + private const val BINDING_NAME_PRODUCT_SYNC = "productSyncProducer" + private const val SCHEMA_KAFKA_PRODUCT_SYNC = "kafka-product-sync" + } - MessageBuilder.withPayload(it.toString()) - .setHeader(KafkaHeaders.TOPIC, topicName) - .setHeader("X-Custom-Header", "some-custom-header") - .build() - } + override fun produceProductSync(product: ProductDto) { + Json.encodeToJsonElement((product)) + .also { schemaValidator.validate(SCHEMA_KAFKA_PRODUCT_SYNC, it) } .let { - msg -> kafkaTemplate.send(msg) + MessageBuilder.withPayload(it.toString()) + .setHeader("X-Custom-Header", "some-custom-header") + .build() + } + .let { + streamBridge.send( + BINDING_NAME_PRODUCT_SYNC, + it, + ) } } } diff --git a/src/main/kotlin/com/github/dannecron/demo/services/kafka/dto/ProductDto.kt b/src/main/kotlin/com/github/dannecron/demo/services/kafka/dto/ProductDto.kt index 12c1cd4..2fd4ff7 100644 --- a/src/main/kotlin/com/github/dannecron/demo/services/kafka/dto/ProductDto.kt +++ b/src/main/kotlin/com/github/dannecron/demo/services/kafka/dto/ProductDto.kt @@ -1,9 +1,6 @@ package com.github.dannecron.demo.services.kafka.dto -import com.github.dannecron.demo.models.Product -import com.github.dannecron.demo.services.kafka.exceptions.InvalidArgumentException import kotlinx.serialization.Serializable -import java.time.format.DateTimeFormatter @Serializable data class ProductDto( @@ -15,16 +12,4 @@ data class ProductDto( val createdAt: String, val updatedAt: String?, val deletedAt: String?, -) { - @Throws(InvalidArgumentException::class) - constructor(product: Product) : this( - id = product.id ?: throw InvalidArgumentException("product.id"), - guid = product.guid.toString(), - name = product.name, - description = product.description, - price = product.price, - createdAt = product.createdAt.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), - updatedAt = product.updatedAt?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), - deletedAt = product.deletedAt?.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), - ) -} +) diff --git a/src/main/kotlin/com/github/dannecron/demo/services/metrics/MetricsSender.kt b/src/main/kotlin/com/github/dannecron/demo/services/metrics/MetricsSender.kt new file mode 100644 index 0000000..c26531e --- /dev/null +++ b/src/main/kotlin/com/github/dannecron/demo/services/metrics/MetricsSender.kt @@ -0,0 +1,5 @@ +package com.github.dannecron.demo.services.metrics + +interface MetricsSender { + fun incrementConsumerCityCreate() +} diff --git a/src/main/kotlin/com/github/dannecron/demo/services/metrics/MetricsSenderImpl.kt b/src/main/kotlin/com/github/dannecron/demo/services/metrics/MetricsSenderImpl.kt new file mode 100644 index 0000000..ce43b05 --- /dev/null +++ b/src/main/kotlin/com/github/dannecron/demo/services/metrics/MetricsSenderImpl.kt @@ -0,0 +1,18 @@ +package com.github.dannecron.demo.services.metrics + +import io.micrometer.core.instrument.Counter +import io.micrometer.core.instrument.MeterRegistry +import org.springframework.stereotype.Service + +@Service +class MetricsSenderImpl( + metricRegistry: MeterRegistry, +) : MetricsSender { + private val consumerCityCreateCounter = Counter.builder("kafka_consumer_city_create") + .description("consumed created city event") + .register(metricRegistry) + + override fun incrementConsumerCityCreate() { + consumerCityCreateCounter.increment() + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 63c2aea..7f46a76 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,6 +16,50 @@ spring: default-schema: ${DB_SCHEMA:public} profiles: active: ${SPRING_ACTIVE_PROFILE:default} + kafka: + bootstrap-servers: ${KAFKA_SERVERS} + security: + protocol: PLAINTEXT + cloud: + discovery: + client: + composite-indicator: + enabled: false + function: + definition: > + citySyncConsumer + stream: + defaultBinder: kafka + kafka: + default: + producer: + sync: true + binder: + enable-observation: true + requiredAcks: all + producerProperties: + retries: 3 + key: + serializer: org.apache.kafka.common.serialization.StringSerializer + consumerProperties: + key: + deserializer: org.apache.kafka.common.serialization.StringDeserializer + bindings: + citySyncConsumer-in-0: + consumer: + enable-dlq: false + bindings: + # input + citySyncConsumer-in-0: + group: demo-group + destination: demo-city-sync + binder: kafka + consumer: + retry-template-name: DEFAULT + # output + productSyncProducer: + destination: demo-product-sync + binder: kafka logging: level: @@ -26,11 +70,6 @@ kafka: producer: product: default-sync-topic: demo-product-sync - consumer: - group-id: demo-consumer - topics: demo-city-sync - auto-offset-reset: latest - auto-startup: true validation: schema: diff --git a/src/test/kotlin/com/github/dannecron/demo/BaseUnitTest.kt b/src/test/kotlin/com/github/dannecron/demo/BaseUnitTest.kt index e4be7e2..82ad12c 100644 --- a/src/test/kotlin/com/github/dannecron/demo/BaseUnitTest.kt +++ b/src/test/kotlin/com/github/dannecron/demo/BaseUnitTest.kt @@ -1,38 +1,15 @@ package com.github.dannecron.demo -import com.github.dannecron.demo.config.properties.KafkaProperties -import com.github.dannecron.demo.config.properties.ValidationProperties -import com.github.dannecron.demo.services.kafka.Consumer -import com.github.dannecron.demo.services.validation.SchemaValidator.Companion.SCHEMA_KAFKA_PRODUCT_SYNC +import com.github.dannecron.demo.core.config.properties.ValidationProperties import org.springframework.boot.test.context.TestConfiguration -import org.springframework.boot.test.mock.mockito.MockBean import org.springframework.context.annotation.Bean open class BaseUnitTest { - @MockBean - lateinit var consumer: Consumer - @TestConfiguration class TestConfig { - @Bean - fun kafkaProperties(): KafkaProperties = KafkaProperties( - bootstrapServers = "localhost:1111", - producer = KafkaProperties.Producer( - product = KafkaProperties.Producer.Product( - defaultSyncTopic = "some-default", - ), - ), - consumer = KafkaProperties.Consumer( - groupId = "group", - topics = "topic", - autoStartup = false, - autoOffsetReset = "none", - ), - ) - @Bean fun validationProperties(): ValidationProperties = ValidationProperties( - schema = mapOf(SCHEMA_KAFKA_PRODUCT_SYNC to "kafka/product/sync.json"), + schema = mapOf("kafka-product-sync" to "kafka/product/sync.json"), ) } } diff --git a/src/test/kotlin/com/github/dannecron/demo/config/kafka/CityEntityCreateConsumerImplConfigTest.kt b/src/test/kotlin/com/github/dannecron/demo/config/kafka/CityEntityCreateConsumerImplConfigTest.kt new file mode 100644 index 0000000..79df813 --- /dev/null +++ b/src/test/kotlin/com/github/dannecron/demo/config/kafka/CityEntityCreateConsumerImplConfigTest.kt @@ -0,0 +1,76 @@ +package com.github.dannecron.demo.config.kafka + +import com.github.dannecron.demo.services.kafka.CityCreateConsumer +import com.github.dannecron.demo.services.kafka.dto.CityCreateDto +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.jupiter.api.Test +import org.mockito.kotlin.after +import org.mockito.kotlin.verify +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration +import org.springframework.boot.autoconfigure.EnableAutoConfiguration +import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration +import org.springframework.boot.autoconfigure.jdbc.DataSourceTransactionManagerAutoConfiguration +import org.springframework.boot.autoconfigure.orm.jpa.HibernateJpaAutoConfiguration +import org.springframework.boot.autoconfigure.security.servlet.SecurityAutoConfiguration +import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration +import org.springframework.boot.test.mock.mockito.MockBean +import org.springframework.cloud.stream.binder.test.InputDestination +import org.springframework.cloud.stream.binder.test.TestChannelBinderConfiguration +import org.springframework.messaging.support.MessageBuilder +import org.springframework.test.context.TestPropertySource +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter +import java.util.UUID + +@SpringJUnitConfig( + classes = [CityConsumerConfig::class, TestChannelBinderConfiguration::class], +) +@TestPropertySource( + properties = [ + "spring.jmx.enabled=false", + "spring.cloud.stream.default-binder=kafka", + "spring.cloud.function.definition=citySyncConsumer", + "spring.cloud.stream.bindings.citySyncConsumer-in-0.destination=demo-city-sync" + ], +) +@EnableAutoConfiguration( + exclude = [ + WebMvcAutoConfiguration::class, + DataSourceAutoConfiguration::class, + DataSourceTransactionManagerAutoConfiguration::class, + HibernateJpaAutoConfiguration::class, + SecurityAutoConfiguration::class, + EndpointAutoConfiguration::class, + ] +) +class CityEntityCreateConsumerImplConfigTest { + + @Autowired + private lateinit var inputDestination: InputDestination + + @MockBean + private lateinit var cityCreateConsumer: CityCreateConsumer + + @Test + fun `citySyncConsumer - success`() { + val cityGuid = UUID.randomUUID() + val cityName = "new-city" + val createdAt = OffsetDateTime.now().minusDays(1) + val cityCreateDto = CityCreateDto( + guid = cityGuid.toString(), + name = cityName, + createdAt = createdAt.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), + updatedAt = null, + deletedAt = null, + ) + + val rawEvent = Json.encodeToString(cityCreateDto) + val msg = MessageBuilder.withPayload(rawEvent).build() + inputDestination.send(msg, "demo-city-sync") + + verify(cityCreateConsumer, after(1000).times(1)).process(cityCreateDto) + } +} diff --git a/src/test/kotlin/com/github/dannecron/demo/http/controllers/CustomerControllerTest.kt b/src/test/kotlin/com/github/dannecron/demo/http/controllers/CustomerControllerTest.kt index 83eb0a6..b9cd24f 100644 --- a/src/test/kotlin/com/github/dannecron/demo/http/controllers/CustomerControllerTest.kt +++ b/src/test/kotlin/com/github/dannecron/demo/http/controllers/CustomerControllerTest.kt @@ -1,11 +1,11 @@ package com.github.dannecron.demo.http.controllers import com.github.dannecron.demo.BaseUnitTest +import com.github.dannecron.demo.core.dto.City +import com.github.dannecron.demo.core.dto.Customer +import com.github.dannecron.demo.core.dto.view.CustomerExtended +import com.github.dannecron.demo.core.services.customer.CustomerService import com.github.dannecron.demo.http.responses.ResponseStatus -import com.github.dannecron.demo.models.City -import com.github.dannecron.demo.models.Customer -import com.github.dannecron.demo.models.CustomerExtended -import com.github.dannecron.demo.services.database.customer.CustomerService import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.whenever @@ -16,7 +16,7 @@ import org.springframework.http.MediaType import org.springframework.test.web.servlet.MockMvc import org.springframework.test.web.servlet.get import java.time.OffsetDateTime -import java.util.* +import java.util.UUID import kotlin.test.Test @WebMvcTest(CustomerController::class) diff --git a/src/test/kotlin/com/github/dannecron/demo/http/controllers/ProductControllerTest.kt b/src/test/kotlin/com/github/dannecron/demo/http/controllers/ProductControllerTest.kt index 1d9065b..7ded05b 100644 --- a/src/test/kotlin/com/github/dannecron/demo/http/controllers/ProductControllerTest.kt +++ b/src/test/kotlin/com/github/dannecron/demo/http/controllers/ProductControllerTest.kt @@ -1,14 +1,17 @@ package com.github.dannecron.demo.http.controllers import com.github.dannecron.demo.BaseUnitTest +import com.github.dannecron.demo.core.dto.Product +import com.github.dannecron.demo.core.services.product.ProductService import com.github.dannecron.demo.http.responses.ResponseStatus -import com.github.dannecron.demo.models.Product -import com.github.dannecron.demo.services.database.product.ProductService +import com.github.dannecron.demo.services.ProductSyncService import org.hamcrest.Matchers.contains import org.hamcrest.Matchers.nullValue import org.junit.jupiter.api.Test -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.eq +import org.mockito.kotlin.any +import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.times +import org.mockito.kotlin.verify import org.mockito.kotlin.verifyNoInteractions import org.mockito.kotlin.whenever import org.springframework.beans.factory.annotation.Autowired @@ -25,73 +28,72 @@ import org.springframework.test.web.servlet.post import org.springframework.web.bind.MethodArgumentNotValidException import java.time.OffsetDateTime import java.time.format.DateTimeFormatter -import java.util.* +import java.util.UUID @WebMvcTest(ProductController::class) -class ProductControllerTest(@Autowired val mockMvc: MockMvc): BaseUnitTest() { +class ProductControllerTest: BaseUnitTest() { + + @Autowired + private lateinit var mockMvc: MockMvc + @MockBean private lateinit var productService: ProductService - @Test - fun getProduct_success() { - val guid = UUID.randomUUID() - val now = OffsetDateTime.now() - val product = Product( - id = 12, - guid = guid, - name = "some", - description = null, - price = 11130, - createdAt = now, - updatedAt = null, - deletedAt = null, - ) + @MockBean + private lateinit var productSyncService: ProductSyncService - whenever(productService.findByGuid( - eq(guid), - )) doReturn product + private val guid = UUID.randomUUID() + private val now = OffsetDateTime.now() + private val productId = 12L + private val productName = "new-product" + private val productPrice = 20123L + private val product = Product( + id = productId, + guid = guid, + name = productName, + description = null, + price = productPrice, + createdAt = now, + updatedAt = null, + deletedAt = null, + ) + + @Test + fun `getProduct - 200`() { + whenever(productService.findByGuid(any())).thenReturn(product) mockMvc.get("/api/product/$guid") .andExpect { status { isOk() } } .andExpect { content { contentType(MediaType.APPLICATION_JSON) } } .andExpect { jsonPath("\$.id") { value(product.id.toString()) } } .andExpect { jsonPath("\$.guid") { value(guid.toString()) } } - .andExpect { jsonPath("\$.name") { value("some") } } + .andExpect { jsonPath("\$.name") { value(productName) } } .andExpect { jsonPath("\$.createdAt") { value(now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)) } } .andExpect { jsonPath("\$.updatedAt") { value(nullValue()) } } + + verify(productService, times(1)).findByGuid(guid) + verifyNoInteractions(productSyncService) } @Test - fun getProduct_notFound() { - val guid = UUID.randomUUID() - - whenever(productService.findByGuid( - eq(guid), - )) doReturn null + fun `getProduct - 404`() { + whenever(productService.findByGuid(any())).thenReturn(null) mockMvc.get("/api/product/$guid") .andExpect { status { isNotFound() } } .andExpect { content { contentType(MediaType.APPLICATION_JSON) } } .andExpect { jsonPath("\$.status") { value(ResponseStatus.NOT_FOUND.status) } } + + verify(productService, times(1)).findByGuid(guid) + verifyNoInteractions(productSyncService) } @Test - fun getProducts_success() { - val now = OffsetDateTime.now() + fun `getProducts - 200`() { val pageRequest = PageRequest.of(1, 2, Sort.by(Sort.Direction.DESC, "createdAt")) - whenever(productService.findAll( - pageRequest, - )) doReturn PageImpl(listOf(Product( - id = 12, - guid = UUID.randomUUID(), - name = "some", - description = null, - price = 11130, - createdAt = now, - updatedAt = null, - deletedAt = null, - ))) + whenever(productService.findAll(any())) + .thenReturn(PageImpl(listOf(product))) mockMvc.get("/api/product?page=1&size=2&sort=createdAt,desc") .andExpect { status { isOk() } } @@ -99,37 +101,22 @@ class ProductControllerTest(@Autowired val mockMvc: MockMvc): BaseUnitTest() { .andExpect { jsonPath("\$.meta.total") { value(1) } } .andExpect { jsonPath("\$.meta.pages") { value(1) } } .andExpect { jsonPath("\$.data") { isArray() } } - .andExpect { jsonPath("\$.data[0].id") { value(12) } } - .andExpect { jsonPath("\$.data[0].name") { value("some") } } + .andExpect { jsonPath("\$.data[0].id") { value(productId) } } + .andExpect { jsonPath("\$.data[0].name") { value(productName) } } .andExpect { jsonPath("\$.data[0].description") { value(null) } } .andExpect { jsonPath("\$.data[0].createdAt") { value(now.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME)) } } - .andExpect { jsonPath("\$.data[0].priceDouble") { value(111.30) } } + .andExpect { jsonPath("\$.data[0].priceDouble") { value(201.23) } } .andExpect { jsonPath("\$.data[0].isDeleted") { value(false) } } + + verify(productService, times(1)).findAll(pageRequest) + verifyNoInteractions(productSyncService) } @Test - fun createProduct_success() { - val productId = 13L - val name = "new-product" - val description = null - val price = 20000L + fun `createProduct - 200`() { + val reqBody = """{"name":"$productName","description":null,"price":$productPrice}""" - val reqBody = """{"name":"$name","description":null,"price":$price}""" - - whenever(productService.create( - eq(name), - eq(price), - eq(description) - )) doReturn Product( - id = productId, - guid = UUID.randomUUID(), - name = name, - description = description, - price = price, - createdAt = OffsetDateTime.now(), - updatedAt = null, - deletedAt = null, - ) + whenever(productService.create(any(), any(), anyOrNull())).thenReturn(product) mockMvc.post("/api/product") { contentType = MediaType.APPLICATION_JSON @@ -138,13 +125,14 @@ class ProductControllerTest(@Autowired val mockMvc: MockMvc): BaseUnitTest() { .andExpect { status { isCreated() } } .andExpect { content { contentType(MediaType.APPLICATION_JSON) } } .andExpect { jsonPath("\$.id") { value(productId) } } + + verify(productService, times(1)).create(productName, productPrice, null) + verifyNoInteractions(productSyncService) } @Test - fun createProduct_badRequest_noNameParam() { - val price = 20000L - - val reqBody = """{"description":null,"price":$price}""" + fun `createProduct - 400 - no name param`() { + val reqBody = """{"description":null,"price":$productPrice}""" mockMvc.post("/api/product") { contentType = MediaType.APPLICATION_JSON @@ -156,13 +144,12 @@ class ProductControllerTest(@Autowired val mockMvc: MockMvc): BaseUnitTest() { .andExpect { jsonPath("\$.cause") { contains("name") } } verifyNoInteractions(productService) + verifyNoInteractions(productSyncService) } @Test - fun createProduct_badRequest_emptyName() { - val price = 20000L - - val reqBody = """{"name":"","description":null,"price":$price}""" + fun `createProduct - 400 - empty name param`() { + val reqBody = """{"name":"","description":null,"price":$productPrice}""" mockMvc.post("/api/product") { contentType = MediaType.APPLICATION_JSON @@ -174,28 +161,21 @@ class ProductControllerTest(@Autowired val mockMvc: MockMvc): BaseUnitTest() { .andExpect { jsonPath("\$.cause") { value(MethodArgumentNotValidException::class.qualifiedName) } } verifyNoInteractions(productService) + verifyNoInteractions(productSyncService) } @Test - fun deleteProduct_success() { - val guid = UUID.randomUUID() + fun `deleteProduct - 200`() { + val deletedProduct = product.copy(deletedAt = now) - whenever(productService.delete( - eq(guid), - )) doReturn Product( - id = 2133, - guid = guid, - name = "name", - description = "description", - price = 210202, - createdAt = OffsetDateTime.now(), - updatedAt = null, - deletedAt = OffsetDateTime.now(), - ) + whenever(productService.delete(any())).thenReturn(deletedProduct) mockMvc.delete("/api/product/${guid}") .andExpect { status { isOk() } } .andExpect { content { contentType(MediaType.APPLICATION_JSON) } } .andExpect { jsonPath("\$.status") { value(ResponseStatus.OK.status) } } + + verify(productService, times(1)).delete(guid) + verifyNoInteractions(productSyncService) } } diff --git a/src/test/kotlin/com/github/dannecron/demo/services/ProductSyncServiceImplTest.kt b/src/test/kotlin/com/github/dannecron/demo/services/ProductSyncServiceImplTest.kt new file mode 100644 index 0000000..c81ee2d --- /dev/null +++ b/src/test/kotlin/com/github/dannecron/demo/services/ProductSyncServiceImplTest.kt @@ -0,0 +1,74 @@ +package com.github.dannecron.demo.services + +import com.github.dannecron.demo.core.dto.Product +import com.github.dannecron.demo.core.exceptions.ProductNotFoundException +import com.github.dannecron.demo.core.services.product.ProductService +import com.github.dannecron.demo.services.kafka.Producer +import com.github.dannecron.demo.services.kafka.dto.ProductDto +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.mockito.kotlin.any +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoInteractions +import org.mockito.kotlin.whenever +import java.time.OffsetDateTime +import java.time.format.DateTimeFormatter +import java.util.UUID + +class ProductSyncServiceImplTest { + private val productService: ProductService = mock() + private val producer: Producer = mock() + + private val productSyncService = ProductSyncServiceImpl( + productService = productService, + producer = producer + ) + + private val guid = UUID.randomUUID() + private val product = Product( + id = 123, + guid = guid, + name = "name", + description = "description", + price = 10050, + createdAt = OffsetDateTime.now().minusDays(1), + updatedAt = OffsetDateTime.now().minusHours(2), + deletedAt = null, + ) + + private val kafkaProductDto = ProductDto( + id = 123, + guid = guid.toString(), + name = "name", + description = "description", + price = 10050, + createdAt = product.createdAt.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), + updatedAt = product.updatedAt!!.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), + deletedAt = null, + ) + + @Test + fun `syncToKafka - success`() { + whenever(productService.findByGuid(any())).thenReturn(product) + + productSyncService.syncToKafka(guid, null) + + verify(productService, times(1)).findByGuid(guid) + verify(producer, times(1)).produceProductSync(kafkaProductDto) + } + + @Test + fun `syncToKafka - not found`() { + whenever(productService.findByGuid(any())) doReturn null + + assertThrows { + productSyncService.syncToKafka(guid, null) + } + + verify(productService, times(1)).findByGuid(guid) + verifyNoInteractions(producer) + } +} diff --git a/src/test/kotlin/com/github/dannecron/demo/services/database/customer/CustomerServiceImplTest.kt b/src/test/kotlin/com/github/dannecron/demo/services/database/customer/CustomerServiceImplTest.kt deleted file mode 100644 index 90f6bb6..0000000 --- a/src/test/kotlin/com/github/dannecron/demo/services/database/customer/CustomerServiceImplTest.kt +++ /dev/null @@ -1,124 +0,0 @@ -package com.github.dannecron.demo.services.database.customer - -import com.github.dannecron.demo.core.services.generation.CommonGenerator -import com.github.dannecron.demo.db.entity.City -import com.github.dannecron.demo.db.entity.Customer -import com.github.dannecron.demo.db.repository.CityRepository -import com.github.dannecron.demo.db.repository.CustomerRepository -import com.github.dannecron.demo.models.CustomerExtended -import com.github.dannecron.demo.services.database.exceptions.CityNotFoundException -import org.junit.jupiter.api.assertThrows -import org.mockito.kotlin.any -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.mock -import org.mockito.kotlin.never -import org.mockito.kotlin.times -import org.mockito.kotlin.verify -import org.mockito.kotlin.verifyNoInteractions -import org.mockito.kotlin.whenever -import java.time.OffsetDateTime -import java.util.Optional -import java.util.UUID -import kotlin.test.Test -import kotlin.test.assertEquals - -class CustomerServiceImplTest { - private val mockGuid = UUID.randomUUID() - private val mockCurrentTime = OffsetDateTime.now() - - private val commonGenerator: CommonGenerator = mock { - on { generateUUID() } doReturn mockGuid - on { generateCurrentTime() } doReturn mockCurrentTime - } - - private val customerRepository: CustomerRepository = mock() - private val cityRepository: CityRepository = mock() - private val customerServiceImpl = CustomerServiceImpl( - customerRepository = customerRepository, - cityRepository = cityRepository, - commonGenerator = commonGenerator, - ) - - private val cityId = 123L - private val cityGuid = UUID.randomUUID() - private val customer = Customer( - id = 1, - guid = mockGuid, - name = "name", - cityId = cityId, - createdAt = mockCurrentTime, - updatedAt = null, - ) - private val city = City( - id = cityId, - guid = cityGuid, - name = "city", - createdAt = OffsetDateTime.now(), - updatedAt = null, - deletedAt = null, - ) - - @Test - fun `create - success - with city`() { - whenever(customerRepository.save(any())).thenReturn(customer) - whenever(cityRepository.findByGuid(cityGuid)).thenReturn(city) - - val result = customerServiceImpl.create("name", cityGuid) - assertEquals(customer, result) - - verify(customerRepository, times(1)).save(customer.copy(id = null)) - verify(cityRepository, times(1)).findByGuid(cityGuid) - } - - @Test - fun `create - success - no city`() { - val customerNoCity = customer.copy(cityId = null) - - whenever(customerRepository.save(any())).thenReturn(customerNoCity) - - val result = customerServiceImpl.create("name", null) - assertEquals(customerNoCity, result) - - verify(customerRepository, times(1)).save(customerNoCity.copy(id = null)) - verifyNoInteractions(cityRepository) - } - - @Test - fun `create - fail - with city`() { - whenever(customerRepository.save(any())).thenReturn(customer) - whenever(cityRepository.findByGuid(cityGuid)).thenReturn(null) - - assertThrows { - customerServiceImpl.create("name", cityGuid) - } - - verify(customerRepository, never()).save(customer.copy(id = null)) - verify(cityRepository, times(1)).findByGuid(cityGuid) - } - - @Test - fun `findByGuid - with city`() { - val customerGuid = mockGuid - whenever(customerRepository.findByGuid(any())).thenReturn(customer) - whenever(cityRepository.findById(any())).thenReturn(Optional.of(city)) - - val result = customerServiceImpl.findByGuid(customerGuid) - assertEquals(CustomerExtended(customer, city), result) - - verify(customerRepository, times(1)).findByGuid(customerGuid) - verify(cityRepository, times(1)).findById(cityId) - } - - @Test - fun `findByGuid - no city`() { - val customerGuid = mockGuid - whenever(customerRepository.findByGuid(any())).thenReturn(customer) - whenever(cityRepository.findById(any())).thenReturn(Optional.empty()) - - val result = customerServiceImpl.findByGuid(customerGuid) - assertEquals(CustomerExtended(customer, null), result) - - verify(customerRepository, times(1)).findByGuid(customerGuid) - verify(cityRepository, times(1)).findById(cityId) - } -} diff --git a/src/test/kotlin/com/github/dannecron/demo/services/database/order/OrderServiceImplTest.kt b/src/test/kotlin/com/github/dannecron/demo/services/database/order/OrderServiceImplTest.kt deleted file mode 100644 index 028f196..0000000 --- a/src/test/kotlin/com/github/dannecron/demo/services/database/order/OrderServiceImplTest.kt +++ /dev/null @@ -1,147 +0,0 @@ -package com.github.dannecron.demo.services.database.order - -import com.github.dannecron.demo.core.services.generation.CommonGenerator -import com.github.dannecron.demo.db.entity.Customer -import com.github.dannecron.demo.db.entity.Product -import com.github.dannecron.demo.db.entity.order.Order -import com.github.dannecron.demo.db.entity.order.OrderProduct -import com.github.dannecron.demo.db.repository.OrderProductRepository -import com.github.dannecron.demo.db.repository.OrderRepository -import com.github.dannecron.demo.db.repository.ProductRepository -import com.github.dannecron.demo.models.OrderWithProducts -import org.mockito.kotlin.any -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.mock -import org.mockito.kotlin.times -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import java.time.OffsetDateTime -import java.util.UUID -import kotlin.test.Test -import kotlin.test.assertEquals - -class OrderServiceImplTest { - private val mockGuid = UUID.randomUUID() - private val mockCurrentTime = OffsetDateTime.now() - - private val commonGenerator: CommonGenerator = mock { - on { generateUUID() } doReturn mockGuid - on { generateCurrentTime() } doReturn mockCurrentTime - } - - private val orderRepository: OrderRepository = mock() - private val productRepository: ProductRepository = mock() - private val orderProductRepository: OrderProductRepository = mock() - - private val orderServiceImpl = OrderServiceImpl( - orderRepository = orderRepository, - orderProductRepository = orderProductRepository, - productRepository = productRepository, - commonGenerator = commonGenerator, - ) - - private val now = OffsetDateTime.now() - - private val customerId = 123L - private val customer = Customer( - id = customerId, - guid = UUID.randomUUID(), - name = "customer", - cityId = null, - createdAt = now, - updatedAt = null, - ) - - private val orderOneId = 1001L - private val orderTwoId = 1002L - private val orderOne = Order( - id = orderOneId, - guid = UUID.randomUUID(), - customerId = customerId, - deliveredAt = now.minusHours(1), - createdAt = now.minusDays(1), - updatedAt = now.minusHours(1), - ) - private val orderTwo = Order( - id = orderTwoId, - guid = UUID.randomUUID(), - customerId = customerId, - deliveredAt = null, - createdAt = now, - updatedAt = null, - ) - - private val productId = 100L - private val product = Product( - id = productId, - guid = UUID.randomUUID(), - name = "product", - description = null, - price = 10000L, - createdAt = now.minusMonths(1), - updatedAt = null, - deletedAt = null, - ) - - private val orderProduct = OrderProduct( - guid = UUID.randomUUID(), - orderId = orderOneId, - productId = productId, - createdAt = now.minusDays(1), - updatedAt = null, - ) - - @Test - fun findByCustomerId() { - whenever(orderRepository.findByCustomerId(any())).thenReturn(listOf(orderOne, orderTwo)) - whenever(orderProductRepository.findByOrderId(any())) - .thenReturn(listOf(orderProduct)) - .thenReturn(emptyList()) - whenever(productRepository.findAllById(any())).thenReturn(listOf(product)) - - val expectedResult = listOf( - OrderWithProducts( - order = orderOne, - products = listOf(product), - ), - OrderWithProducts( - order = orderTwo, - products = emptyList(), - ), - ) - - val result = orderServiceImpl.findByCustomerId(customerId) - assertEquals(expectedResult, result) - - verify(orderRepository, times(1)).findByCustomerId(customerId) - verify(orderProductRepository, times(1)).findByOrderId(orderOneId) - verify(orderProductRepository, times(1)).findByOrderId(orderTwoId) - verify(productRepository, times(1)).findAllById(listOf(productId)) - } - - @Test - fun create() { - val newOrder = orderTwo.copy( - guid = mockGuid, - createdAt = mockCurrentTime, - ) - val newOrderProduct = orderProduct.copy( - guid = mockGuid, - createdAt = mockCurrentTime, - orderId = orderTwoId, - ) - - whenever(orderRepository.save(any())).thenReturn(newOrder) - whenever(orderProductRepository.saveAll(any>())).thenReturn(listOf(newOrderProduct)) - - val result = orderServiceImpl.createOrder( - customer = customer, - products = setOf(product), - ) - - assertEquals(newOrder, result) - - verify(orderRepository, times(1)).save(newOrder.copy(id = null)) - verify(orderProductRepository, times(1)).saveAll(listOf(newOrderProduct)) - } -} diff --git a/src/test/kotlin/com/github/dannecron/demo/services/kafka/ConsumerKfkTest.kt b/src/test/kotlin/com/github/dannecron/demo/services/kafka/ConsumerKfkTest.kt deleted file mode 100644 index fa42291..0000000 --- a/src/test/kotlin/com/github/dannecron/demo/services/kafka/ConsumerKfkTest.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.github.dannecron.demo.services.kafka - -import com.github.dannecron.demo.models.City -import com.github.dannecron.demo.services.database.city.CityService -import com.github.dannecron.demo.services.kafka.dto.CityCreateDto -import io.micrometer.core.instrument.MeterRegistry -import kotlinx.serialization.encodeToString -import kotlinx.serialization.json.Json -import org.mockito.kotlin.after -import org.mockito.kotlin.any -import org.mockito.kotlin.doReturn -import org.mockito.kotlin.verify -import org.mockito.kotlin.whenever -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.mock.mockito.MockBean -import org.springframework.kafka.core.KafkaTemplate -import org.springframework.kafka.support.KafkaHeaders -import org.springframework.kafka.test.context.EmbeddedKafka -import org.springframework.messaging.Message -import org.springframework.messaging.support.MessageBuilder -import org.springframework.test.annotation.DirtiesContext -import org.springframework.test.context.ActiveProfiles -import java.time.OffsetDateTime -import java.time.format.DateTimeFormatter -import java.util.UUID -import kotlin.test.Test -import kotlin.test.assertEquals - -@ActiveProfiles("kafka") -@SpringBootTest -@EmbeddedKafka( - brokerProperties = ["listeners=PLAINTEXT://localhost:3392", "port=3392"], - topics = ["demo-city-sync"], - partitions = 1, -) -@DirtiesContext -class ConsumerKfkTest { - - @Autowired - private lateinit var kafkaTemplate: KafkaTemplate - - @Autowired - private lateinit var metricRegistry: MeterRegistry - - @MockBean - private lateinit var cityService: CityService - - @Test - fun consumer_handleCityCreate() { - val cityGuid = UUID.randomUUID() - val cityName = "new-city" - val createdAt = OffsetDateTime.now().minusDays(1) - val cityCreateDto = CityCreateDto( - guid = cityGuid.toString(), - name = cityName, - createdAt = createdAt.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), - updatedAt = null, - deletedAt = null, - ) - - whenever(cityService.create(any())) doReturn City( - id = 123, - guid = cityGuid, - name = cityName, - createdAt = createdAt, - updatedAt = null, - deletedAt = null, - ) - - val message: Message = MessageBuilder - .withPayload( - Json.encodeToString(cityCreateDto) - ) - .setHeader(KafkaHeaders.TOPIC, "demo-city-sync") - .build() - - kafkaTemplate.send(message) - - verify(cityService, after(1000).times(1)).create(cityCreateDto) - - assertEquals(1.0, metricRegistry.get("kafka_consumer_city_create").counter().count()) - } -} diff --git a/src/test/kotlin/com/github/dannecron/demo/services/kafka/ProducerImplTest.kt b/src/test/kotlin/com/github/dannecron/demo/services/kafka/ProducerImplTest.kt index 8c7bbd0..a808171 100644 --- a/src/test/kotlin/com/github/dannecron/demo/services/kafka/ProducerImplTest.kt +++ b/src/test/kotlin/com/github/dannecron/demo/services/kafka/ProducerImplTest.kt @@ -1,71 +1,62 @@ package com.github.dannecron.demo.services.kafka -import com.github.dannecron.demo.BaseUnitTest -import com.github.dannecron.demo.models.Product +import com.github.dannecron.demo.core.services.validation.SchemaValidator import com.github.dannecron.demo.services.kafka.dto.ProductDto -import com.github.dannecron.demo.services.validation.SchemaValidator import kotlinx.serialization.json.Json import kotlinx.serialization.json.encodeToJsonElement -import org.junit.runner.RunWith -import org.mockito.kotlin.* -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.boot.test.mock.mockito.MockBean -import org.springframework.kafka.core.KafkaTemplate -import org.springframework.kafka.support.KafkaHeaders -import org.springframework.kafka.support.SendResult +import org.junit.jupiter.api.Assertions.assertEquals +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.springframework.cloud.stream.function.StreamBridge import org.springframework.messaging.Message -import org.springframework.test.context.junit4.SpringRunner import java.time.OffsetDateTime -import java.util.* -import java.util.concurrent.CompletableFuture +import java.time.format.DateTimeFormatter +import java.util.UUID import kotlin.test.Test -import kotlin.test.assertEquals -@RunWith(SpringRunner::class) -@SpringBootTest -class ProducerImplTest: BaseUnitTest() { - @Autowired - private lateinit var producerImpl: ProducerImpl - - @MockBean - private lateinit var kafkaTemplate: KafkaTemplate - - @MockBean - private lateinit var schemaValidator: SchemaValidator +class ProducerImplTest { + private val streamBridge: StreamBridge = mock() + private val schemaValidator: SchemaValidator = mock() + private val producerImpl = ProducerImpl( + streamBridge = streamBridge, + schemaValidator = schemaValidator, + ) @Test - fun produceProductInfo_success() { - val topic = "some-topic" - val product = Product( + fun produceProductSync_success() { + val guid = UUID.randomUUID() + val createdAt = OffsetDateTime.now().minusDays(2) + val updatedAt = OffsetDateTime.now().minusHours(1) + val productDto = ProductDto( id = 123, - guid = UUID.randomUUID(), + guid = guid.toString(), name = "name", description = null, price = 10050, - createdAt = OffsetDateTime.now().minusDays(2), - updatedAt = OffsetDateTime.now().minusHours(1), - deletedAt = OffsetDateTime.now(), + createdAt = createdAt.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), + updatedAt = updatedAt.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME), + deletedAt = null, ) val captor = argumentCaptor>() - whenever(kafkaTemplate.send(captor.capture())) doReturn CompletableFuture>() + whenever(streamBridge.send(any(), captor.capture())).thenReturn(true) - whenever(schemaValidator.validate( - eq("product-sync"), - eq(Json.encodeToJsonElement(product)) - )) doAnswer { } - - producerImpl.produceProductInfo(topic, product) + producerImpl.produceProductSync(productDto) assertEquals(1, captor.allValues.count()) val actualArgument = captor.firstValue val actualProductDto = Json.decodeFromString(actualArgument.payload) - assertEquals(product.id, actualProductDto.id) - assertEquals(product.guid.toString(), actualProductDto.guid) - assertEquals(topic, actualArgument.headers[KafkaHeaders.TOPIC]) + assertEquals(productDto, actualProductDto) assertEquals("some-custom-header", actualArgument.headers["X-Custom-Header"]) + + verify(streamBridge, times(1)).send(eq("productSyncProducer"), any()) + verify(schemaValidator, times(1)).validate("kafka-product-sync", Json.encodeToJsonElement(productDto)) } }