main app refactoring

This commit is contained in:
Savosin Denis
2025-05-12 15:20:37 +07:00
parent 5af11485b6
commit 0494b253f0
45 changed files with 491 additions and 984 deletions

View File

@@ -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"))
}

View File

@@ -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,

View File

@@ -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<String, String> = 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<String, String>().apply {
consumerFactory = consumerFactory()
}
}

View File

@@ -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<String, Any> = 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<String, Any> = KafkaTemplate(
producerFactory(),
)
@Bean
fun producer(
@Autowired kafkaTemplate: KafkaTemplate<String, Any>,
@Autowired schemaValidator: SchemaValidator,
): Producer = ProducerImpl(
kafkaTemplate,
schemaValidator,
)
}

View File

@@ -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<CityCreateDto> =
Consumer(cityCreateConsumer::process)
}

View File

@@ -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,
)
}

View File

@@ -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

View File

@@ -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<Any> {
try {
productService.syncToKafka(guid, topic)
productSyncService.syncToKafka(guid, topic)
} catch (_: InvalidArgumentException) {
throw UnprocessableException("cannot sync product to kafka")
}

View File

@@ -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?,
)

View File

@@ -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<Product>,
) {
fun getMostExpensiveOrderedProduct(): Product? = products.maxByOrNull { pr -> pr.price }
fun getTotalOrderPrice(): Double = products.sumOf { pr -> pr.getPriceDouble() }
}

View File

@@ -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<City, Long> {
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?
}

View File

@@ -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<Customer, Long> {
fun findByGuid(guid: UUID): Customer?
}

View File

@@ -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<OrderProduct, UUID> {
fun findByOrderId(orderId: Long): List<OrderProduct>
}

View File

@@ -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<Order, Long> {
fun findByCustomerId(customerId: Long): List<Order>
}

View File

@@ -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<Product, Long>, PagingAndSortingRepository<Product, Long> {
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?
}

View File

@@ -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)

View File

@@ -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()) {

View File

@@ -1,8 +1,8 @@
package com.github.dannecron.demo.providers.html
open class Tag(val name: String) {
val children: MutableList<Tag> = ArrayList()
val attributes: MutableList<Attribute> = ArrayList()
val children: MutableList<Tag> = mutableListOf()
val attributes: MutableList<Attribute> = mutableListOf()
override fun toString(): String {
return "<$name" +
@@ -25,4 +25,4 @@ fun <T: Tag> 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())) {}

View File

@@ -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?)
}

View File

@@ -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),
)
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -1,3 +0,0 @@
package com.github.dannecron.demo.services.database.exceptions
class AlreadyDeletedException: RuntimeException()

View File

@@ -1,3 +0,0 @@
package com.github.dannecron.demo.services.database.exceptions
class CityNotFoundException: ModelNotFoundException("city")

View File

@@ -1,3 +0,0 @@
package com.github.dannecron.demo.services.database.exceptions
open class ModelNotFoundException(entityName: String): RuntimeException("$entityName not found")

View File

@@ -1,3 +0,0 @@
package com.github.dannecron.demo.services.database.exceptions
class ProductNotFoundException: ModelNotFoundException("product")

View File

@@ -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<OrderWithProducts> = orderRepository.findByCustomerId(customerId)
.let { orders -> orders.map { order -> OrderWithProducts(
order = order,
products = findProductsByOrderId(order.id!!),
) } }
@Transactional
fun createOrder(customer: Customer, products: Set<Product>): 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<Product> =
orderProductRepository.findByOrderId(orderId = orderId)
.map { it.productId }
.let {
if (it.isEmpty()) {
emptyList()
} else {
productRepository.findAllById(it).toList()
}
}
private fun saveProductsForNewOrder(savedOrder: Order, products: List<Product>) {
products.map {
OrderProduct(
guid = commonGenerator.generateUUID(),
orderId = savedOrder.id!!,
productId = it.id!!,
createdAt = commonGenerator.generateCurrentTime(),
updatedAt = null
)
}.also { orderProductRepository.saveAll(it) }
}
}

View File

@@ -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)
}

View File

@@ -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)
}
)
}

View File

@@ -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<CityCreateDto>(message)
.also {
val counter = Counter.builder("kafka_consumer_city_create")
.description("consumed created city event")
.register(metricRegistry)
counter.increment()
}
cityService.create(cityCreateDto)
}
}

View File

@@ -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)
}

View File

@@ -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<String, Any>,
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,
)
}
}
}

View File

@@ -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),
)
}
)

View File

@@ -0,0 +1,5 @@
package com.github.dannecron.demo.services.metrics
interface MetricsSender {
fun incrementConsumerCityCreate()
}

View File

@@ -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()
}
}

View File

@@ -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:

View File

@@ -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"),
)
}
}

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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)
}
}

View File

@@ -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<ProductNotFoundException> {
productSyncService.syncToKafka(guid, null)
}
verify(productService, times(1)).findByGuid(guid)
verifyNoInteractions(producer)
}
}

View File

@@ -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<Customer>())).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<Customer>())).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<Customer>())).thenReturn(customer)
whenever(cityRepository.findByGuid(cityGuid)).thenReturn(null)
assertThrows<CityNotFoundException> {
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)
}
}

View File

@@ -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<Order>())).thenReturn(newOrder)
whenever(orderProductRepository.saveAll(any<List<OrderProduct>>())).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))
}
}

View File

@@ -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<String, Any>
@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<CityCreateDto>())) doReturn City(
id = 123,
guid = cityGuid,
name = cityName,
createdAt = createdAt,
updatedAt = null,
deletedAt = null,
)
val message: Message<String> = 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())
}
}

View File

@@ -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<String, Any>
@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<Message<String>>()
whenever(kafkaTemplate.send(captor.capture())) doReturn CompletableFuture<SendResult<String, Any>>()
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<ProductDto>(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))
}
}