move validation to separate core package, add generation service

some code cleanup
This commit is contained in:
Savosin Denis
2025-05-05 12:54:17 +07:00
parent d7c051746d
commit 7c12208883
42 changed files with 669 additions and 713 deletions

View File

@@ -69,13 +69,13 @@ subprojects {
dependencies { dependencies {
implementation(project(":db")) implementation(project(":db"))
implementation(project(":core"))
runtimeOnly(libs.micrometer.registry.prometheus) runtimeOnly(libs.micrometer.registry.prometheus)
implementation(libs.bundles.tracing) implementation(libs.bundles.tracing)
implementation(libs.jackson.datatype.jsr) implementation(libs.jackson.datatype.jsr)
implementation(libs.jackson.module.kotlin) implementation(libs.jackson.module.kotlin)
implementation(libs.json.schema.validator)
implementation(libs.ktor.client.cio) implementation(libs.ktor.client.cio)
implementation(libs.ktor.client.core) implementation(libs.ktor.client.core)
implementation(libs.logback.encoder) implementation(libs.logback.encoder)
@@ -85,13 +85,14 @@ dependencies {
implementation(libs.spring.boot.starter.mustache) implementation(libs.spring.boot.starter.mustache)
implementation(libs.spring.boot.starter.validation) implementation(libs.spring.boot.starter.validation)
implementation(libs.spring.boot.starter.web) implementation(libs.spring.boot.starter.web)
implementation(libs.spring.cloud.starter.streamKafka)
implementation(libs.spring.doc.openapi.starter) implementation(libs.spring.doc.openapi.starter)
implementation(libs.spring.kafka)
testImplementation(libs.spring.kafka.test) testImplementation(libs.ktor.client.mock)
testImplementation(libs.spring.boot.starter.actuatorAutoconfigure)
testImplementation(libs.spring.cloud.starter.streamTestBinder)
testImplementation(libs.testcontainers) testImplementation(libs.testcontainers)
testImplementation(libs.testcontainers.junit.jupiter) testImplementation(libs.testcontainers.junit.jupiter)
testImplementation(libs.ktor.client.mock)
developmentOnly(libs.spring.boot.devtools) developmentOnly(libs.spring.boot.devtools)
} }

7
core/build.gradle.kts Normal file
View File

@@ -0,0 +1,7 @@
group = "com.github.dannecron.demo"
version = "single-version"
dependencies {
implementation(rootProject.libs.spring.boot.starter.validation)
implementation(rootProject.libs.json.schema.validator)
}

View File

@@ -0,0 +1,22 @@
package com.github.dannecron.demo.core.config
import com.github.dannecron.demo.core.config.properties.ValidationProperties
import com.github.dannecron.demo.core.services.validation.SchemaValidator
import com.github.dannecron.demo.core.services.validation.SchemaValidatorImp
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.util.ResourceUtils
@Configuration
class SchemaValidationConfig(
private val validationProperties: ValidationProperties,
) {
@Bean
fun schemaValidator(): SchemaValidator = SchemaValidatorImp(
schemaMap = validationProperties.schema.mapValues {
schema -> ResourceUtils.getFile("classpath:json-schemas/${schema.value}")
.readText(Charsets.UTF_8)
}
)
}

View File

@@ -1,4 +1,4 @@
package com.github.dannecron.demo.config.properties package com.github.dannecron.demo.core.config.properties
import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.boot.context.properties.ConfigurationProperties

View File

@@ -0,0 +1,11 @@
package com.github.dannecron.demo.core.services.generation
import java.time.OffsetDateTime
import java.util.UUID
interface CommonGenerator {
fun generateUUID(): UUID
fun generateCurrentTime(): OffsetDateTime
}

View File

@@ -0,0 +1,16 @@
package com.github.dannecron.demo.core.services.generation
import org.springframework.stereotype.Component
import java.time.OffsetDateTime
import java.util.UUID
@Component
class CommonGeneratorImpl : CommonGenerator {
override fun generateUUID(): UUID {
return UUID.randomUUID()
}
override fun generateCurrentTime(): OffsetDateTime {
return OffsetDateTime.now()
}
}

View File

@@ -0,0 +1,11 @@
package com.github.dannecron.demo.core.services.validation
import com.github.dannecron.demo.core.services.validation.exceptions.ElementNotValidException
import com.github.dannecron.demo.core.services.validation.exceptions.SchemaNotFoundException
import kotlinx.serialization.json.JsonElement
interface SchemaValidator {
@Throws(ElementNotValidException::class, SchemaNotFoundException::class)
fun validate(schemaName: String, value: JsonElement)
}

View File

@@ -0,0 +1,32 @@
package com.github.dannecron.demo.core.services.validation
import com.github.dannecron.demo.core.services.validation.exceptions.ElementNotValidException
import com.github.dannecron.demo.core.services.validation.exceptions.SchemaNotFoundException
import io.github.optimumcode.json.schema.JsonSchema
import io.github.optimumcode.json.schema.ValidationError
import kotlinx.serialization.json.JsonElement
class SchemaValidatorImp(
private val schemaMap: Map<String, String>,
): SchemaValidator {
@Throws(
SchemaNotFoundException::class,
ElementNotValidException::class,
)
override fun validate(schemaName: String, value: JsonElement) {
JsonSchema.fromDefinition(
getSchema(schemaName),
).also {
val errors = mutableListOf<ValidationError>()
if (!it.validate(value, errors::add)) {
throw ElementNotValidException(errors)
}
}
}
@Throws(SchemaNotFoundException::class)
private fun getSchema(schemaName: String) = schemaMap[schemaName]
?: throw SchemaNotFoundException()
}

View File

@@ -1,4 +1,4 @@
package com.github.dannecron.demo.services.validation.exceptions package com.github.dannecron.demo.core.services.validation.exceptions
import io.github.optimumcode.json.schema.ValidationError import io.github.optimumcode.json.schema.ValidationError

View File

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

View File

@@ -1,25 +1,20 @@
package com.github.dannecron.demo.services.validation package com.github.dannecron.demo.core.services.validation
import com.github.dannecron.demo.BaseUnitTest import com.github.dannecron.demo.core.services.validation.exceptions.ElementNotValidException
import com.github.dannecron.demo.services.validation.SchemaValidator.Companion.SCHEMA_KAFKA_PRODUCT_SYNC import com.github.dannecron.demo.core.services.validation.exceptions.SchemaNotFoundException
import com.github.dannecron.demo.services.validation.exceptions.ElementNotValidException
import com.github.dannecron.demo.services.validation.exceptions.SchemaNotFoundException
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.ParameterizedTest
import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.Arguments
import org.junit.jupiter.params.provider.MethodSource import org.junit.jupiter.params.provider.MethodSource
import org.junit.runner.RunWith
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import org.springframework.test.context.junit4.SpringRunner
import kotlin.reflect.KClass import kotlin.reflect.KClass
import kotlin.test.assertFailsWith import kotlin.test.assertFailsWith
@RunWith(SpringRunner::class) class SchemaValidatorImpTest {
@SpringBootTest private val schemaValidatorImp = SchemaValidatorImp(
class SchemaValidatorImpTest( schemaMap = mapOf(
@Autowired val schemaValidatorImp: SchemaValidatorImp KAFKA_PRODUCT_SYNC_SCHEMA to getJsonSchema("json-schemas/kafka/product/sync.json")),
): BaseUnitTest() { )
@ParameterizedTest @ParameterizedTest
@MethodSource("validateDataProvider") @MethodSource("validateDataProvider")
fun validate(schemaName: String, inputRawJson: String, expectedException: KClass<out Throwable>?) { fun validate(schemaName: String, inputRawJson: String, expectedException: KClass<out Throwable>?) {
@@ -27,8 +22,6 @@ class SchemaValidatorImpTest(
if (expectedException == null) { if (expectedException == null) {
schemaValidatorImp.validate(schemaName = schemaName, value = element) schemaValidatorImp.validate(schemaName = schemaName, value = element)
// second time should use cache
schemaValidatorImp.validate(schemaName = schemaName, value = element)
return return
} }
@@ -39,10 +32,12 @@ class SchemaValidatorImpTest(
} }
companion object { companion object {
private const val KAFKA_PRODUCT_SYNC_SCHEMA = "kafkaProductSync"
@JvmStatic @JvmStatic
fun validateDataProvider() = listOf( fun validateDataProvider() = listOf(
Arguments.of( Arguments.of(
SCHEMA_KAFKA_PRODUCT_SYNC, KAFKA_PRODUCT_SYNC_SCHEMA,
""" """
{ {
"id": 123, "id": 123,
@@ -58,7 +53,7 @@ class SchemaValidatorImpTest(
null, null,
), ),
Arguments.of( // no id Arguments.of( // no id
SCHEMA_KAFKA_PRODUCT_SYNC, KAFKA_PRODUCT_SYNC_SCHEMA,
""" """
{ {
"guid": "3a27e322-b5b6-427f-b761-a02284c1cfa4", "guid": "3a27e322-b5b6-427f-b761-a02284c1cfa4",
@@ -73,7 +68,7 @@ class SchemaValidatorImpTest(
ElementNotValidException::class, ElementNotValidException::class,
), ),
Arguments.of( // wrong guid Arguments.of( // wrong guid
SCHEMA_KAFKA_PRODUCT_SYNC, KAFKA_PRODUCT_SYNC_SCHEMA,
""" """
{ {
"id": 213, "id": 213,
@@ -95,4 +90,9 @@ class SchemaValidatorImpTest(
) )
) )
} }
private fun getJsonSchema(resourcePath: String) = javaClass.classLoader
.getResourceAsStream(resourcePath)!!
.readAllBytes()
.toString(Charsets.UTF_8)
} }

View File

@@ -3,7 +3,7 @@ jackson = "2.15.4"
kotlin = "2.1.10" kotlin = "2.1.10"
ktor = "3.0.0" ktor = "3.0.0"
spring-boot = "3.2.10" spring-boot = "3.2.10"
spring-kafka = "3.1.3" spring-cloud = "3.2.10"
testcontainers = "1.19.7" testcontainers = "1.19.7"
[libraries] [libraries]
@@ -26,14 +26,15 @@ postgres = { module = "org.postgresql:postgresql", version = "42.6.2" }
spring-aspects = { module = "org.springframework:spring-aspects" } spring-aspects = { module = "org.springframework:spring-aspects" }
spring-boot-devtools = { module = "org.springframework.boot:spring-boot-devtools" } spring-boot-devtools = { module = "org.springframework.boot:spring-boot-devtools" }
spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "spring-boot" } spring-boot-starter-actuator = { module = "org.springframework.boot:spring-boot-starter-actuator", version.ref = "spring-boot" }
spring-boot-starter-actuatorAutoconfigure = { module = "org.springframework.boot:spring-boot-actuator-autoconfigure" }
spring-boot-starter-jdbc = { module = "org.springframework.boot:spring-boot-starter-data-jdbc", version.ref = "spring-boot"} spring-boot-starter-jdbc = { module = "org.springframework.boot:spring-boot-starter-data-jdbc", version.ref = "spring-boot"}
spring-boot-starter-mustache = { module = "org.springframework.boot:spring-boot-starter-mustache", version.ref = "spring-boot" } spring-boot-starter-mustache = { module = "org.springframework.boot:spring-boot-starter-mustache", version.ref = "spring-boot" }
spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "spring-boot" } spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-starter-test", version.ref = "spring-boot" }
spring-boot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation", version.ref = "spring-boot" } spring-boot-starter-validation = { module = "org.springframework.boot:spring-boot-starter-validation", version.ref = "spring-boot" }
spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" } spring-boot-starter-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring-boot" }
spring-cloud-starter-streamKafka = { module = "org.springframework.cloud:spring-cloud-starter-stream-kafka", version.ref = "spring-cloud"}
spring-cloud-starter-streamTestBinder = { module = "org.springframework.cloud:spring-cloud-stream-test-binder", version = "4.0.4"}
spring-doc-openapi-starter = "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0" spring-doc-openapi-starter = "org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0"
spring-kafka = { module = "org.springframework.kafka:spring-kafka", version.ref = "spring-kafka"}
spring-kafka-test = { module = "org.springframework.kafka:spring-kafka-test", version.ref = "spring-kafka"}
testcontainers = { module = "org.testcontainers:testcontainers", version.ref = "testcontainers"} testcontainers = { module = "org.testcontainers:testcontainers", version.ref = "testcontainers"}
testcontainers-junit-jupiter = { module = "org.testcontainers:junit-jupiter", version.ref = "testcontainers"} testcontainers-junit-jupiter = { module = "org.testcontainers:junit-jupiter", version.ref = "testcontainers"}
testcontainers-postgresql = { module = "org.testcontainers:postgresql", version.ref = "testcontainers"} testcontainers-postgresql = { module = "org.testcontainers:postgresql", version.ref = "testcontainers"}

View File

@@ -1,2 +1,6 @@
plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
}
rootProject.name = "demo" rootProject.name = "demo"
include("db") include("db")
include("core")

View File

@@ -1,31 +0,0 @@
package com.github.dannecron.demo.models
import com.github.dannecron.demo.models.serializables.OffsetDateTimeSerialization
import com.github.dannecron.demo.models.serializables.UuidSerialization
import kotlinx.serialization.Serializable
import org.springframework.data.annotation.Id
import org.springframework.data.relational.core.mapping.Column
import org.springframework.data.relational.core.mapping.Table
import java.time.OffsetDateTime
import java.util.*
@Table("city")
@Serializable
data class City(
@Id
val id: Long?,
@Serializable(with = UuidSerialization::class)
val guid: UUID,
val name: String,
@Serializable(with = OffsetDateTimeSerialization::class)
@Column(value = "created_at")
val createdAt: OffsetDateTime,
@Serializable(with = OffsetDateTimeSerialization::class)
@Column(value = "updated_at")
val updatedAt: OffsetDateTime?,
@Serializable(with = OffsetDateTimeSerialization::class)
@Column(value = "deleted_at")
val deletedAt: OffsetDateTime?,
) {
fun isDeleted(): Boolean = deletedAt != null
}

View File

@@ -1,27 +0,0 @@
package com.github.dannecron.demo.models
import com.github.dannecron.demo.models.serializables.OffsetDateTimeSerialization
import com.github.dannecron.demo.models.serializables.UuidSerialization
import kotlinx.serialization.Serializable
import org.springframework.data.annotation.Id
import org.springframework.data.relational.core.mapping.Column
import org.springframework.data.relational.core.mapping.Table
import java.time.OffsetDateTime
import java.util.*
@Table("customer")
@Serializable
data class Customer(
@Id
val id: Long?,
@Serializable(with = UuidSerialization::class)
val guid: UUID,
val name: String,
val cityId: Long?,
@Serializable(with = OffsetDateTimeSerialization::class)
@Column(value = "created_at")
val createdAt: OffsetDateTime,
@Serializable(with = OffsetDateTimeSerialization::class)
@Column(value = "updated_at")
val updatedAt: OffsetDateTime?,
)

View File

@@ -1,5 +1,7 @@
package com.github.dannecron.demo.models 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 import kotlinx.serialization.Serializable
@Serializable @Serializable

View File

@@ -1,6 +1,7 @@
package com.github.dannecron.demo.models.order package com.github.dannecron.demo.models
import com.github.dannecron.demo.models.Product import com.github.dannecron.demo.db.entity.Product
import com.github.dannecron.demo.db.entity.order.Order
data class OrderWithProducts( data class OrderWithProducts(
val order: Order, val order: Order,

View File

@@ -1,37 +0,0 @@
package com.github.dannecron.demo.models
import com.github.dannecron.demo.models.serializables.OffsetDateTimeSerialization
import com.github.dannecron.demo.models.serializables.UuidSerialization
import com.github.dannecron.demo.utils.roundTo
import kotlinx.serialization.Serializable
import org.springframework.data.annotation.Id
import org.springframework.data.relational.core.mapping.Column
import org.springframework.data.relational.core.mapping.Table
import java.time.OffsetDateTime
import java.util.*
@Table(value = "product")
@Serializable
data class Product(
@Id
val id: Long?,
@Serializable(with = UuidSerialization::class)
val guid: UUID,
val name: String,
val description: String?,
val price: Long,
@Serializable(with = OffsetDateTimeSerialization::class)
@Column(value = "created_at")
val createdAt: OffsetDateTime,
@Serializable(with = OffsetDateTimeSerialization::class)
@Column(value = "updated_at")
val updatedAt: OffsetDateTime?,
@Serializable(with = OffsetDateTimeSerialization::class)
@Column(value = "deleted_at")
val deletedAt: OffsetDateTime?,
) {
fun getPriceDouble(): Double = (price.toDouble() / 100).roundTo(2)
fun isDeleted(): Boolean = deletedAt != null
}

View File

@@ -1,31 +0,0 @@
package com.github.dannecron.demo.models.order
import com.github.dannecron.demo.models.serializables.OffsetDateTimeSerialization
import com.github.dannecron.demo.models.serializables.UuidSerialization
import kotlinx.serialization.Serializable
import org.springframework.data.annotation.Id
import org.springframework.data.relational.core.mapping.Column
import org.springframework.data.relational.core.mapping.Table
import java.time.OffsetDateTime
import java.util.*
@Table(value = "order")
@Serializable
data class Order(
@Id
val id: Long?,
@Serializable(with = UuidSerialization::class)
val guid: UUID,
val customerId: Long,
@Serializable(with = OffsetDateTimeSerialization::class)
@Column(value = "delivered_at")
val deliveredAt: OffsetDateTime?,
@Serializable(with = OffsetDateTimeSerialization::class)
@Column(value = "created_at")
val createdAt: OffsetDateTime,
@Serializable(with = OffsetDateTimeSerialization::class)
@Column(value = "updated_at")
val updatedAt: OffsetDateTime?
) {
fun isDelivered(): Boolean = deliveredAt != null
}

View File

@@ -1,41 +0,0 @@
package com.github.dannecron.demo.models.order
import com.github.dannecron.demo.models.serializables.OffsetDateTimeSerialization
import com.github.dannecron.demo.models.serializables.UuidSerialization
import kotlinx.serialization.Serializable
import org.springframework.data.annotation.Id
import org.springframework.data.annotation.Transient
import org.springframework.data.domain.Persistable
import org.springframework.data.relational.core.mapping.Column
import org.springframework.data.relational.core.mapping.Table
import java.time.OffsetDateTime
import java.util.*
@Table(value = "order_product")
@Serializable
data class OrderProduct(
@Id
@Serializable(with = UuidSerialization::class)
val guid: UUID,
@Column(value = "order_id")
val orderId: Long,
@Column(value = "product_id")
val productId: Long,
@Serializable(with = OffsetDateTimeSerialization::class)
@Column(value = "created_at")
val createdAt: OffsetDateTime,
@Serializable(with = OffsetDateTimeSerialization::class)
@Column(value = "updated_at")
val updatedAt: OffsetDateTime?,
): Persistable<UUID> {
@Transient
var isNewInstance: Boolean? = null
override fun getId(): UUID {
return guid
}
override fun isNew(): Boolean {
return isNewInstance ?: true
}
}

View File

@@ -1,19 +0,0 @@
package com.github.dannecron.demo.models.serializables
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
object OffsetDateTimeSerialization: KSerializer<OffsetDateTime> {
override val descriptor = PrimitiveSerialDescriptor("Time", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): OffsetDateTime = OffsetDateTime.parse(decoder.decodeString())
override fun serialize(encoder: Encoder, value: OffsetDateTime) {
encoder.encodeString(value.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME))
}
}

View File

@@ -1,18 +0,0 @@
package com.github.dannecron.demo.models.serializables
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import java.util.*
object UuidSerialization: KSerializer<UUID> {
override val descriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
override fun deserialize(decoder: Decoder): UUID = UUID.fromString(decoder.decodeString())
override fun serialize(encoder: Encoder, value: UUID) {
encoder.encodeString(value.toString())
}
}

View File

@@ -1,6 +1,6 @@
package com.github.dannecron.demo.services.database.city package com.github.dannecron.demo.services.database.city
import com.github.dannecron.demo.models.City import com.github.dannecron.demo.db.entity.City
import com.github.dannecron.demo.services.database.exceptions.CityNotFoundException import com.github.dannecron.demo.services.database.exceptions.CityNotFoundException
import com.github.dannecron.demo.services.database.exceptions.AlreadyDeletedException import com.github.dannecron.demo.services.database.exceptions.AlreadyDeletedException
import com.github.dannecron.demo.services.kafka.dto.CityCreateDto import com.github.dannecron.demo.services.kafka.dto.CityCreateDto

View File

@@ -1,24 +1,28 @@
package com.github.dannecron.demo.services.database.city package com.github.dannecron.demo.services.database.city
import com.github.dannecron.demo.models.City import com.github.dannecron.demo.core.services.generation.CommonGenerator
import com.github.dannecron.demo.providers.CityRepository import com.github.dannecron.demo.db.entity.City
import com.github.dannecron.demo.db.repository.CityRepository
import com.github.dannecron.demo.services.database.exceptions.AlreadyDeletedException import com.github.dannecron.demo.services.database.exceptions.AlreadyDeletedException
import com.github.dannecron.demo.services.database.exceptions.CityNotFoundException import com.github.dannecron.demo.services.database.exceptions.CityNotFoundException
import com.github.dannecron.demo.services.kafka.dto.CityCreateDto import com.github.dannecron.demo.services.kafka.dto.CityCreateDto
import org.springframework.stereotype.Service
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.* import java.util.UUID
@Service
class CityServiceImpl( class CityServiceImpl(
private val cityRepository: CityRepository private val cityRepository: CityRepository,
private val commonGenerator: CommonGenerator,
): CityService { ): CityService {
override fun findByGuid(guid: UUID): City? = cityRepository.findByGuid(guid) override fun findByGuid(guid: UUID): City? = cityRepository.findByGuid(guid)
override fun create(name: String): City = City( override fun create(name: String): City = City(
id = null, id = null,
guid = UUID.randomUUID(), guid = commonGenerator.generateUUID(),
name = name, name = name,
createdAt = OffsetDateTime.now(), createdAt = commonGenerator.generateCurrentTime(),
updatedAt = null, updatedAt = null,
deletedAt = null, deletedAt = null,
).let { ).let {

View File

@@ -1,6 +1,6 @@
package com.github.dannecron.demo.services.database.customer package com.github.dannecron.demo.services.database.customer
import com.github.dannecron.demo.models.Customer import com.github.dannecron.demo.db.entity.Customer
import com.github.dannecron.demo.models.CustomerExtended import com.github.dannecron.demo.models.CustomerExtended
import com.github.dannecron.demo.services.database.exceptions.CityNotFoundException import com.github.dannecron.demo.services.database.exceptions.CityNotFoundException
import java.util.* import java.util.*

View File

@@ -1,16 +1,19 @@
package com.github.dannecron.demo.services.database.customer package com.github.dannecron.demo.services.database.customer
import com.github.dannecron.demo.models.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.models.CustomerExtended
import com.github.dannecron.demo.providers.CityRepository
import com.github.dannecron.demo.providers.CustomerRepository
import com.github.dannecron.demo.services.database.exceptions.CityNotFoundException import com.github.dannecron.demo.services.database.exceptions.CityNotFoundException
import java.time.OffsetDateTime import org.springframework.stereotype.Service
import java.util.* import java.util.UUID
@Service
class CustomerServiceImpl( class CustomerServiceImpl(
private val customerRepository: CustomerRepository, private val customerRepository: CustomerRepository,
private val cityRepository: CityRepository private val cityRepository: CityRepository,
private val commonGenerator: CommonGenerator,
): CustomerService { ): CustomerService {
override fun findByGuid(guid: UUID): CustomerExtended? = customerRepository.findByGuid(guid) override fun findByGuid(guid: UUID): CustomerExtended? = customerRepository.findByGuid(guid)
?.let { ?.let {
@@ -22,12 +25,12 @@ class CustomerServiceImpl(
override fun create(name: String, cityGuid: UUID?): Customer = Customer( override fun create(name: String, cityGuid: UUID?): Customer = Customer(
id = null, id = null,
guid = UUID.randomUUID(), guid = commonGenerator.generateUUID(),
name = name, name = name,
cityId = cityGuid?.let { cityId = cityGuid?.let {
cityRepository.findByGuid(it)?.id ?: throw CityNotFoundException() cityRepository.findByGuid(it)?.id ?: throw CityNotFoundException()
}, },
createdAt = OffsetDateTime.now(), createdAt = commonGenerator.generateCurrentTime(),
updatedAt = null, updatedAt = null,
).let { ).let {
customerRepository.save(it) customerRepository.save(it)

View File

@@ -1,55 +1,65 @@
package com.github.dannecron.demo.services.database.order package com.github.dannecron.demo.services.database.order
import com.github.dannecron.demo.models.Customer import com.github.dannecron.demo.core.services.generation.CommonGenerator
import com.github.dannecron.demo.models.Product import com.github.dannecron.demo.db.entity.Customer
import com.github.dannecron.demo.models.order.Order import com.github.dannecron.demo.db.entity.Product
import com.github.dannecron.demo.models.order.OrderProduct import com.github.dannecron.demo.db.entity.order.Order
import com.github.dannecron.demo.models.order.OrderWithProducts import com.github.dannecron.demo.db.entity.order.OrderProduct
import com.github.dannecron.demo.providers.OrderProductRepository import com.github.dannecron.demo.db.repository.OrderProductRepository
import com.github.dannecron.demo.providers.OrderRepository import com.github.dannecron.demo.db.repository.OrderRepository
import com.github.dannecron.demo.providers.ProductRepository import com.github.dannecron.demo.db.repository.ProductRepository
import org.springframework.beans.factory.annotation.Autowired import com.github.dannecron.demo.models.OrderWithProducts
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional import org.springframework.transaction.annotation.Transactional
import java.time.OffsetDateTime
import java.util.*
@Service @Service
class OrderServiceImpl( class OrderServiceImpl(
@Autowired private val orderRepository: OrderRepository, private val orderRepository: OrderRepository,
@Autowired private val orderProductRepository: OrderProductRepository, private val orderProductRepository: OrderProductRepository,
@Autowired private val productRepository: ProductRepository, private val productRepository: ProductRepository,
private val commonGenerator: CommonGenerator,
) { ) {
fun getByCustomerId(customerId: Long): List<OrderWithProducts> = orderRepository.findByCustomerId(customerId) fun findByCustomerId(customerId: Long): List<OrderWithProducts> = orderRepository.findByCustomerId(customerId)
.let { orders -> orders.map { order -> OrderWithProducts( .let { orders -> orders.map { order -> OrderWithProducts(
order = order, order = order,
products = orderProductRepository.findByOrderId(orderId = order.id!!) products = findProductsByOrderId(order.id!!),
.map { orderProduct -> orderProduct.productId }
.let { productIds -> productRepository.findAllById(productIds).toList() }
) } } ) } }
@Transactional @Transactional
fun createOrder(customer: Customer, products: Set<Product>): Order { fun createOrder(customer: Customer, products: Set<Product>): Order {
val order = Order( val order = Order(
id = null, id = null,
guid = UUID.randomUUID(), guid = commonGenerator.generateUUID(),
customerId = customer.id!!, customerId = customer.id!!,
deliveredAt = null, deliveredAt = null,
createdAt = OffsetDateTime.now(), createdAt = commonGenerator.generateCurrentTime(),
updatedAt = null, updatedAt = null,
) )
return orderRepository.save(order) return orderRepository.save(order)
.also { .also { saveProductsForNewOrder(it, products.toList()) }
savedOrder -> products.toList() }
.map { product -> OrderProduct(
guid = UUID.randomUUID(), private fun findProductsByOrderId(orderId: Long): List<Product> =
orderId = savedOrder.id!!, orderProductRepository.findByOrderId(orderId = orderId)
productId = product.id!!, .map { it.productId }
createdAt = OffsetDateTime.now(), .let {
updatedAt = null if (it.isEmpty()) {
) } emptyList()
.also { orderProductRepository.saveAll(it) } } 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

@@ -1,13 +1,13 @@
package com.github.dannecron.demo.services.database.product package com.github.dannecron.demo.services.database.product
import com.github.dannecron.demo.models.Product import com.github.dannecron.demo.db.entity.Product
import com.github.dannecron.demo.services.database.exceptions.AlreadyDeletedException 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.exceptions.ProductNotFoundException
import com.github.dannecron.demo.services.kafka.exceptions.InvalidArgumentException import com.github.dannecron.demo.services.kafka.exceptions.InvalidArgumentException
import org.springframework.data.domain.Page import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.util.* import java.util.UUID
@Service @Service
interface ProductService { interface ProductService {

View File

@@ -1,21 +1,26 @@
package com.github.dannecron.demo.services.database.product package com.github.dannecron.demo.services.database.product
import com.github.dannecron.demo.models.Product import com.github.dannecron.demo.core.services.generation.CommonGenerator
import com.github.dannecron.demo.providers.ProductRepository import com.github.dannecron.demo.db.entity.Product
import com.github.dannecron.demo.db.repository.ProductRepository
import com.github.dannecron.demo.services.database.exceptions.AlreadyDeletedException 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.exceptions.ProductNotFoundException
import com.github.dannecron.demo.services.kafka.Producer 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 com.github.dannecron.demo.utils.LoggerDelegate import com.github.dannecron.demo.utils.LoggerDelegate
import net.logstash.logback.marker.Markers import net.logstash.logback.marker.Markers
import org.springframework.data.domain.Page import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable import org.springframework.data.domain.Pageable
import java.time.OffsetDateTime import org.springframework.stereotype.Service
import java.util.* import java.time.format.DateTimeFormatter
import java.util.UUID
@Service
class ProductServiceImpl( class ProductServiceImpl(
private val defaultSyncTopic: String,
private val productRepository: ProductRepository, private val productRepository: ProductRepository,
private val producer: Producer, private val producer: Producer,
private val commonGenerator: CommonGenerator,
): ProductService { ): ProductService {
private val logger by LoggerDelegate() private val logger by LoggerDelegate()
@@ -32,11 +37,11 @@ class ProductServiceImpl(
override fun create(name: String, price: Long, description: String?): Product { override fun create(name: String, price: Long, description: String?): Product {
val product = Product( val product = Product(
id = null, id = null,
guid = UUID.randomUUID(), guid = commonGenerator.generateUUID(),
name = name, name = name,
description = description, description = description,
price = price, price = price,
createdAt = OffsetDateTime.now(), createdAt = commonGenerator.generateCurrentTime(),
updatedAt = null, updatedAt = null,
deletedAt = null, deletedAt = null,
) )
@@ -52,7 +57,7 @@ class ProductServiceImpl(
} }
val deletedProduct = product.copy( val deletedProduct = product.copy(
deletedAt = OffsetDateTime.now(), deletedAt = commonGenerator.generateCurrentTime(),
) )
return productRepository.save(deletedProduct) return productRepository.save(deletedProduct)
@@ -61,7 +66,17 @@ class ProductServiceImpl(
override fun syncToKafka(guid: UUID, topic: String?) { override fun syncToKafka(guid: UUID, topic: String?) {
val product = findByGuid(guid) ?: throw ProductNotFoundException() val product = findByGuid(guid) ?: throw ProductNotFoundException()
producer.produceProductInfo(topic ?: defaultSyncTopic, product) producer.produceProductSync(product.toKafkaDto())
} }
private fun Product.toKafkaDto() = ProductDto(
id = id ?: throw InvalidArgumentException("product.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,14 +0,0 @@
package com.github.dannecron.demo.services.validation
import com.github.dannecron.demo.services.validation.exceptions.ElementNotValidException
import com.github.dannecron.demo.services.validation.exceptions.SchemaNotFoundException
import kotlinx.serialization.json.JsonElement
interface SchemaValidator {
companion object {
const val SCHEMA_KAFKA_PRODUCT_SYNC = "kafka-product-sync"
}
@Throws(ElementNotValidException::class, SchemaNotFoundException::class)
fun validate(schemaName: String, value: JsonElement)
}

View File

@@ -1,42 +0,0 @@
package com.github.dannecron.demo.services.validation
import com.github.dannecron.demo.services.validation.exceptions.ElementNotValidException
import com.github.dannecron.demo.services.validation.exceptions.SchemaNotFoundException
import io.github.optimumcode.json.schema.JsonSchema
import io.github.optimumcode.json.schema.ValidationError
import kotlinx.serialization.json.JsonElement
import org.springframework.util.ResourceUtils
class SchemaValidatorImp(
private val schemaMap: Map<String, String>,
): SchemaValidator {
private val loadedSchema: MutableMap<String, String> = mutableMapOf()
override fun validate(schemaName: String, value: JsonElement) {
JsonSchema.fromDefinition(
getSchema(schemaName),
).also {
val errors = mutableListOf<ValidationError>()
if (!it.validate(value, errors::add)) {
throw ElementNotValidException(errors)
}
}
}
private fun getSchema(schemaName: String): String {
val loaded = loadedSchema[schemaName]
if (loaded != null) {
return loaded
}
val schemaFile = schemaMap[schemaName]
?: throw SchemaNotFoundException()
val schema = ResourceUtils.getFile("classpath:json-schemas/$schemaFile")
.readText(Charsets.UTF_8)
loadedSchema[schemaName] = schema
return schema
}
}

View File

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

View File

@@ -1,14 +0,0 @@
package com.github.dannecron.demo.utils
import kotlin.math.pow
import kotlin.math.roundToInt
fun Double.roundTo(numFractionDigits: Int): Double {
val factor = 10.0.pow(numFractionDigits.toDouble())
return (this * factor).roundToInt() / factor
}
fun String.snakeToCamelCase(): String {
val pattern = "_[a-z]".toRegex()
return replace(pattern) { it.value.last().uppercase() }
}

View File

@@ -1,19 +0,0 @@
package com.github.dannecron.demo
import com.github.dannecron.demo.services.kafka.Producer
import org.springframework.boot.test.autoconfigure.data.jdbc.DataJdbcTest
import org.springframework.boot.test.autoconfigure.jdbc.AutoConfigureTestDatabase
import org.springframework.boot.test.mock.mockito.MockBean
import org.springframework.data.jdbc.repository.config.EnableJdbcRepositories
import org.springframework.test.context.ActiveProfiles
import org.testcontainers.junit.jupiter.Testcontainers
@ActiveProfiles("db")
@DataJdbcTest
@Testcontainers(disabledWithoutDocker = false)
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@EnableJdbcRepositories
class BaseDbTest {
@MockBean
lateinit var producer: Producer
}

View File

@@ -1,56 +0,0 @@
package com.github.dannecron.demo.services.database.city
import com.github.dannecron.demo.BaseDbTest
import com.github.dannecron.demo.models.City
import com.github.dannecron.demo.providers.CityRepository
import com.github.dannecron.demo.services.database.exceptions.AlreadyDeletedException
import com.github.dannecron.demo.services.database.exceptions.CityNotFoundException
import org.junit.jupiter.api.assertThrows
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.test.context.ContextConfiguration
import java.util.*
import kotlin.test.*
@ContextConfiguration(classes = [CityRepository::class, CityServiceImpl::class])
class CityServiceImplDbTest: BaseDbTest() {
@Autowired
private lateinit var cityRepository: CityRepository
@Autowired
private lateinit var cityServiceImpl: CityServiceImpl
@Test
fun createFindDelete_success() {
val name = "Some city"
var city: City? = null
try {
city = cityServiceImpl.create(name = name)
assertNotNull(city.id)
assertEquals(name, city.name)
val dbCity = cityServiceImpl.findByGuid(city.guid)
assertNotNull(dbCity)
assertEquals(city.id, dbCity.id)
assertFalse(dbCity.isDeleted())
val deletedCity = cityServiceImpl.delete(city.guid)
assertEquals(city.id, deletedCity.id)
assertNotNull(deletedCity.deletedAt)
assertTrue(deletedCity.isDeleted())
assertThrows<AlreadyDeletedException> {
cityServiceImpl.delete(city.guid)
}
assertThrows<CityNotFoundException> {
cityServiceImpl.delete(UUID.randomUUID())
}
} finally {
val id = city?.id
if (id != null) {
cityRepository.deleteById(id)
}
}
}
}

View File

@@ -0,0 +1,70 @@
package com.github.dannecron.demo.services.database.city
import com.github.dannecron.demo.core.services.generation.CommonGenerator
import com.github.dannecron.demo.db.entity.City
import com.github.dannecron.demo.db.repository.CityRepository
import com.github.dannecron.demo.services.kafka.dto.CityCreateDto
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.time.format.DateTimeFormatter
import java.util.UUID
import kotlin.test.Test
import kotlin.test.assertEquals
class CityServiceImplTest {
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 cityRepository: CityRepository = mock()
private val cityServiceImpl = CityServiceImpl(cityRepository, commonGenerator)
private val city = City(
id = 1000,
guid = mockGuid,
name = "name",
createdAt = mockCurrentTime,
updatedAt = null,
deletedAt = null,
)
@Test
fun `create - by name`() {
whenever(cityRepository.save(any<City>())).thenReturn(city)
val result = cityServiceImpl.create("name")
assertEquals(city, result)
verify(cityRepository, times(1)).save(city.copy(id = null))
}
@Test
fun `create - by dto`() {
val cityGuid = UUID.randomUUID()
val createdAt = OffsetDateTime.now()
val cityCreate = CityCreateDto(
guid = cityGuid.toString(),
name = "name",
createdAt = createdAt.format(DateTimeFormatter.ISO_OFFSET_DATE_TIME),
updatedAt = null,
deletedAt = null,
)
val expectedCity = city.copy(guid = cityGuid, createdAt = createdAt)
whenever(cityRepository.save(any<City>())).thenReturn(expectedCity)
val result = cityServiceImpl.create(cityCreate)
assertEquals(expectedCity, result)
verify(cityRepository, times(1)).save(expectedCity.copy(id = null))
}
}

View File

@@ -1,74 +0,0 @@
package com.github.dannecron.demo.services.database.customer
import com.github.dannecron.demo.BaseDbTest
import com.github.dannecron.demo.models.City
import com.github.dannecron.demo.providers.CityRepository
import com.github.dannecron.demo.providers.CustomerRepository
import com.github.dannecron.demo.services.database.exceptions.CityNotFoundException
import org.junit.jupiter.api.assertThrows
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.test.context.ContextConfiguration
import java.time.OffsetDateTime
import java.util.*
import kotlin.test.*
@ContextConfiguration(classes = [CustomerRepository::class, CityRepository::class, CustomerServiceImpl::class])
class CustomerServiceImplDbTest: BaseDbTest() {
@Autowired
private lateinit var customerRepository: CustomerRepository
@Autowired
private lateinit var cityRepository: CityRepository
@Autowired
private lateinit var customerServiceImpl: CustomerServiceImpl
@Test
fun createFind_success() {
val nameOne = "Some Dude-One"
val nameTwo = "Some Dude-Two"
val nameThree = "Some Dude-Three"
var city = City(
id = null,
guid = UUID.randomUUID(),
name = "some city name",
createdAt = OffsetDateTime.now(),
updatedAt = null,
deletedAt = null,
)
var customerIds = longArrayOf()
try {
city = cityRepository.save(city)
customerServiceImpl.create(nameTwo, null).let {
customerIds += it.id ?: fail("customerWithNoCity id is null")
assertNull(it.cityId)
assertNotNull(it.createdAt)
assertNull(it.updatedAt)
}
val customerWithCity = customerServiceImpl.create(nameOne, city.guid)
customerIds += customerWithCity.id ?: fail("customerWithCity id is null")
assertEquals(city.id, customerWithCity.cityId)
assertNotNull(customerWithCity.createdAt)
assertNull(customerWithCity.updatedAt)
val existedCustomer = customerServiceImpl.findByGuid(customerWithCity.guid)
assertNotNull(existedCustomer)
assertEquals(customerWithCity.id, existedCustomer.customer.id)
assertEquals(city.id, existedCustomer.city?.id)
assertThrows<CityNotFoundException> {
customerServiceImpl.create(nameThree, UUID.randomUUID())
}
} finally {
val cityId = city.id
if (cityId != null) {
cityRepository.deleteById(cityId)
}
customerIds.onEach { customerRepository.deleteById(it) }
}
}
}

View File

@@ -0,0 +1,124 @@
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,83 +1,147 @@
package com.github.dannecron.demo.services.database.order package com.github.dannecron.demo.services.database.order
import com.github.dannecron.demo.BaseDbTest import com.github.dannecron.demo.core.services.generation.CommonGenerator
import com.github.dannecron.demo.models.Customer import com.github.dannecron.demo.db.entity.Customer
import com.github.dannecron.demo.models.Product import com.github.dannecron.demo.db.entity.Product
import com.github.dannecron.demo.models.order.Order import com.github.dannecron.demo.db.entity.order.Order
import com.github.dannecron.demo.providers.CustomerRepository import com.github.dannecron.demo.db.entity.order.OrderProduct
import com.github.dannecron.demo.providers.OrderRepository import com.github.dannecron.demo.db.repository.OrderProductRepository
import com.github.dannecron.demo.providers.ProductRepository import com.github.dannecron.demo.db.repository.OrderRepository
import org.springframework.beans.factory.annotation.Autowired import com.github.dannecron.demo.db.repository.ProductRepository
import org.springframework.test.context.ContextConfiguration 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.time.OffsetDateTime
import java.util.* import java.util.UUID
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertNotNull
@ContextConfiguration(classes = [OrderServiceImpl::class]) class OrderServiceImplTest {
class OrderServiceImplTest: BaseDbTest() { private val mockGuid = UUID.randomUUID()
@Autowired private val mockCurrentTime = OffsetDateTime.now()
private lateinit var orderRepository: OrderRepository
@Autowired
private lateinit var productRepository: ProductRepository
@Autowired
private lateinit var customerRepository: CustomerRepository
@Autowired
private lateinit var orderServiceImpl: OrderServiceImpl
@Test private val commonGenerator: CommonGenerator = mock {
fun createAndFind_success() { on { generateUUID() } doReturn mockGuid
var productOneId: Long? = null on { generateCurrentTime() } doReturn mockCurrentTime
var productTwoId: Long? = null
var customerId: Long? = null
var order: Order? = null
try {
val productOne = makeProduct().let { productRepository.save(it) }.also { productOneId = it.id!! }
val productTwo = makeProduct(price = 20000L)
.let { productRepository.save(it) }
.also { productTwoId = it.id!! }
val customer = makeCustomer().let { customerRepository.save(it) }.also { customerId = it.id!! }
order = orderServiceImpl.createOrder(customer, setOf(productOne, productTwo))
assertNotNull(order.id)
val orderWithProducts = orderServiceImpl.getByCustomerId(customerId = customerId!!)
assertEquals(1, orderWithProducts.count())
orderWithProducts.first().also {
assertEquals(order.id, it.order.id)
assertEquals(2, it.products.count())
assertEquals(productTwo.id, it.getMostExpensiveOrderedProduct()?.id)
assertEquals(300.00, it.getTotalOrderPrice())
}
} finally {
order?.id?.also { orderRepository.deleteById(it) }
customerId?.also { customerRepository.deleteById(it) }
productOneId?.also { productRepository.deleteById(it) }
productTwoId?.also { productRepository.deleteById(it) }
}
} }
private fun makeProduct(price: Long = 10000L): Product = Product( private val orderRepository: OrderRepository = mock()
id = null, 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(), guid = UUID.randomUUID(),
name = "name" + 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, description = null,
price = price, price = 10000L,
createdAt = OffsetDateTime.now(), createdAt = now.minusMonths(1),
updatedAt = null, updatedAt = null,
deletedAt = null, deletedAt = null,
) )
private fun makeCustomer(): Customer = Customer( private val orderProduct = OrderProduct(
id = null,
guid = UUID.randomUUID(), guid = UUID.randomUUID(),
name = "client", orderId = orderOneId,
cityId = null, productId = productId,
createdAt = OffsetDateTime.now(), createdAt = now.minusDays(1),
updatedAt = null, 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,69 +0,0 @@
package com.github.dannecron.demo.services.database.product
import com.github.dannecron.demo.BaseDbTest
import com.github.dannecron.demo.models.Product
import com.github.dannecron.demo.providers.ProductRepository
import com.github.dannecron.demo.services.database.exceptions.AlreadyDeletedException
import com.github.dannecron.demo.services.database.exceptions.ProductNotFoundException
import org.junit.jupiter.api.assertThrows
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.test.context.ContextConfiguration
import java.util.*
import kotlin.test.*
@ContextConfiguration(classes = [ProductRepository::class])
class ProductServiceImplDbTest: BaseDbTest() {
private lateinit var productService: ProductServiceImpl
@Autowired
private lateinit var productRepository: ProductRepository
@BeforeTest
fun setUp() {
productService = ProductServiceImpl(
defaultSyncTopic = "some-default-topic",
productRepository = productRepository,
producer = producer
)
}
@Test
fun createFindDelete_success() {
val name = "new-product-name"
val price = 33333L
val description = "some-description"
var product: Product? = null
try {
product = productService.create(name = name, price = price, description = description)
assertNotNull(product.id)
assertEquals(name, product.name)
assertEquals(price, product.price)
assertEquals(333.33, product.getPriceDouble())
val dbProduct = productService.findByGuid(product.guid)
assertNotNull(dbProduct)
assertEquals(product.id, dbProduct.id)
assertFalse(dbProduct.isDeleted())
val deletedProduct = productService.delete(product.guid)
assertNotNull(deletedProduct)
assertEquals(product.id, deletedProduct.id)
assertNotNull(deletedProduct.deletedAt)
assertTrue(deletedProduct.isDeleted())
// try to delete already deleted product
assertThrows<AlreadyDeletedException> {
productService.delete(product.guid)
}
assertThrows<ProductNotFoundException> {
productService.delete(UUID.randomUUID())
}
} finally {
val id = product?.id
if (id != null) {
productRepository.deleteById(id)
}
}
}
}

View File

@@ -1,99 +1,149 @@
package com.github.dannecron.demo.services.database.product package com.github.dannecron.demo.services.database.product
import com.github.dannecron.demo.BaseUnitTest import com.github.dannecron.demo.core.services.generation.CommonGenerator
import com.github.dannecron.demo.models.Product import com.github.dannecron.demo.db.entity.Product
import com.github.dannecron.demo.providers.ProductRepository import com.github.dannecron.demo.db.repository.ProductRepository
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.exceptions.ProductNotFoundException
import com.github.dannecron.demo.services.kafka.Producer import com.github.dannecron.demo.services.kafka.Producer
import com.github.dannecron.demo.services.kafka.exceptions.InvalidArgumentException import com.github.dannecron.demo.services.kafka.dto.ProductDto
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.assertThrows
import org.junit.runner.RunWith import org.mockito.kotlin.any
import org.mockito.kotlin.* import org.mockito.kotlin.doReturn
import org.springframework.beans.factory.annotation.Qualifier import org.mockito.kotlin.mock
import org.springframework.boot.test.context.SpringBootTest import org.mockito.kotlin.never
import org.springframework.boot.test.mock.mockito.MockBean import org.mockito.kotlin.times
import org.springframework.test.context.junit4.SpringRunner import org.mockito.kotlin.verify
import org.mockito.kotlin.verifyNoInteractions
import org.mockito.kotlin.whenever
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.util.* import java.time.format.DateTimeFormatter
import kotlin.test.BeforeTest import java.util.UUID
import kotlin.test.Test import kotlin.test.assertEquals
@RunWith(SpringRunner::class) class ProductServiceImplTest {
@SpringBootTest private val mockGuid = UUID.randomUUID()
class ProductServiceImplTest: BaseUnitTest() { private val mockCurrentTime = OffsetDateTime.now()
private val defaultTopic = "some-default-topic"
private lateinit var productService: ProductServiceImpl
@MockBean private val productRepository: ProductRepository = mock()
@Qualifier("producer") private val producer: Producer = mock()
private lateinit var producer: Producer private val commonGenerator: CommonGenerator = mock {
@MockBean on { generateUUID() } doReturn mockGuid
private lateinit var productRepository: ProductRepository on { generateCurrentTime() } doReturn mockCurrentTime
@BeforeTest
fun setUp() {
productService = ProductServiceImpl(
defaultSyncTopic = defaultTopic,
productRepository = productRepository,
producer = producer,
)
} }
private val productService = ProductServiceImpl(
productRepository = productRepository,
producer = producer,
commonGenerator = commonGenerator,
)
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 @Test
fun syncToKafka_success() { fun create() {
val guid = UUID.randomUUID() val expectedProductForCreation = product.copy(
val product = Product( id = null,
id = 123, guid = mockGuid,
guid = guid, createdAt = mockCurrentTime,
updatedAt = null,
)
val expectedCreatedProduct = expectedProductForCreation.copy(id = 1)
whenever(productRepository.save<Product>(any())).thenReturn(expectedCreatedProduct)
val result = productService.create(
name = "name", name = "name",
description = "description",
price = 10050, price = 10050,
createdAt = OffsetDateTime.now().minusDays(1), description = "description",
updatedAt = OffsetDateTime.now().minusHours(2),
deletedAt = OffsetDateTime.now(),
) )
assertEquals(expectedCreatedProduct, result)
whenever(productRepository.findByGuid(eq(guid))) doReturn product verify(productRepository, times(1)).save(expectedProductForCreation)
whenever(producer.produceProductInfo(defaultTopic, product)) doAnswer {}
productService.syncToKafka(guid, null)
} }
@Test @Test
fun syncToKafka_notFound() { fun `delete - success`() {
val specificTopic = "specificNotice" val deletedProduct = product.copy(
val guid = UUID.randomUUID() deletedAt = mockCurrentTime,
)
whenever(productRepository.findByGuid(any())).thenReturn(product)
whenever(productRepository.save<Product>(any())).thenReturn(deletedProduct)
whenever(productRepository.findByGuid(eq(guid))) doReturn null val result = productService.delete(guid)
assertEquals(deletedProduct, result)
verify(productRepository, times(1)).findByGuid(guid)
verify(productRepository, times(1)).save(deletedProduct)
}
@Test
fun `delete - fail - already deleted`() {
val deletedProduct = product.copy(
deletedAt = mockCurrentTime,
)
whenever(productRepository.findByGuid(any())).thenReturn(deletedProduct)
assertThrows<AlreadyDeletedException> {
productService.delete(guid)
}
verify(productRepository, times(1)).findByGuid(guid)
verify(productRepository, never()).save(any())
}
@Test
fun `delete - fail - not found`() {
whenever(productRepository.findByGuid(any())).thenReturn(null)
assertThrows<ProductNotFoundException> { assertThrows<ProductNotFoundException> {
productService.syncToKafka(guid, specificTopic) productService.delete(guid)
} }
verifyNoInteractions(producer) verify(productRepository, times(1)).findByGuid(guid)
verify(productRepository, never()).save(any())
} }
@Test @Test
fun syncToKafka_invalidArgumentException() { fun `syncToKafka - success`() {
val specificTopic = "specificNotice" whenever(productRepository.findByGuid(any())) doReturn product
val guid = UUID.randomUUID()
val product = Product( productService.syncToKafka(guid, null)
id = 123,
guid = guid,
name = "name",
description = "description",
price = 10050,
createdAt = OffsetDateTime.now().minusDays(1),
updatedAt = OffsetDateTime.now().minusHours(2),
deletedAt = OffsetDateTime.now(),
)
whenever(productRepository.findByGuid(eq(guid))) doReturn product verify(productRepository, times(1)).findByGuid(guid)
whenever(producer.produceProductInfo(specificTopic, product)) doThrow InvalidArgumentException("some error") verify(producer, times(1)).produceProductSync(kafkaProductDto)
}
assertThrows< InvalidArgumentException> { @Test
productService.syncToKafka(guid, specificTopic) fun `syncToKafka - not found`() {
whenever(productRepository.findByGuid(any())) doReturn null
assertThrows<ProductNotFoundException> {
productService.syncToKafka(guid, null)
} }
verify(productRepository, times(1)).findByGuid(guid)
verifyNoInteractions(producer)
} }
} }