mirror of
https://github.com/Dannecron/spring-boot-demo.git
synced 2025-12-25 16:22:35 +03:00
main app refactoring
This commit is contained in:
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@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()
|
||||
fun otlpHttpSpanExporter(@Value("\${tracing.url}") url: String): OtlpHttpSpanExporter =
|
||||
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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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?,
|
||||
)
|
||||
@@ -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() }
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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?
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
|
||||
@@ -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())) {}
|
||||
|
||||
@@ -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?)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package com.github.dannecron.demo.services.database.exceptions
|
||||
|
||||
class AlreadyDeletedException: RuntimeException()
|
||||
@@ -1,3 +0,0 @@
|
||||
package com.github.dannecron.demo.services.database.exceptions
|
||||
|
||||
class CityNotFoundException: ModelNotFoundException("city")
|
||||
@@ -1,3 +0,0 @@
|
||||
package com.github.dannecron.demo.services.database.exceptions
|
||||
|
||||
open class ModelNotFoundException(entityName: String): RuntimeException("$entityName not found")
|
||||
@@ -1,3 +0,0 @@
|
||||
package com.github.dannecron.demo.services.database.exceptions
|
||||
|
||||
class ProductNotFoundException: ModelNotFoundException("product")
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
override fun produceProductSync(product: ProductDto) {
|
||||
Json.encodeToJsonElement((product))
|
||||
.also { schemaValidator.validate(SCHEMA_KAFKA_PRODUCT_SYNC, it) }
|
||||
.let {
|
||||
MessageBuilder.withPayload(it.toString())
|
||||
.setHeader(KafkaHeaders.TOPIC, topicName)
|
||||
.setHeader("X-Custom-Header", "some-custom-header")
|
||||
.build()
|
||||
}
|
||||
.let {
|
||||
msg -> kafkaTemplate.send(msg)
|
||||
streamBridge.send(
|
||||
BINDING_NAME_PRODUCT_SYNC,
|
||||
it,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.github.dannecron.demo.services.metrics
|
||||
|
||||
interface MetricsSender {
|
||||
fun incrementConsumerCityCreate()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
@MockBean
|
||||
private lateinit var productSyncService: ProductSyncService
|
||||
|
||||
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 = "some",
|
||||
name = productName,
|
||||
description = null,
|
||||
price = 11130,
|
||||
price = productPrice,
|
||||
createdAt = now,
|
||||
updatedAt = null,
|
||||
deletedAt = null,
|
||||
)
|
||||
|
||||
whenever(productService.findByGuid(
|
||||
eq(guid),
|
||||
)) doReturn product
|
||||
@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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user