move and refactor city and product services to core

This commit is contained in:
Savosin Denis
2025-05-06 14:09:01 +07:00
parent b855aba506
commit f92ba7795d
18 changed files with 264 additions and 202 deletions

View File

@@ -2,6 +2,9 @@ group = "com.github.dannecron.demo"
version = "single-version"
dependencies {
implementation(project(":db"))
implementation(rootProject.libs.spring.boot.starter.jdbc)
implementation(rootProject.libs.spring.boot.starter.validation)
implementation(rootProject.libs.json.schema.validator)
}

View File

@@ -0,0 +1,15 @@
package com.github.dannecron.demo.core.dto
import java.time.OffsetDateTime
import java.util.UUID
data class City(
val id: Long,
val guid: UUID,
val name: String,
val createdAt: OffsetDateTime,
val updatedAt: OffsetDateTime?,
val deletedAt: OffsetDateTime?,
) {
fun isDeleted(): Boolean = deletedAt != null
}

View File

@@ -0,0 +1,11 @@
package com.github.dannecron.demo.core.dto
import java.time.OffsetDateTime
data class CityCreate(
val guid: String,
val name: String,
val createdAt: OffsetDateTime,
val updatedAt: OffsetDateTime?,
val deletedAt: OffsetDateTime?,
)

View File

@@ -0,0 +1,26 @@
package com.github.dannecron.demo.core.dto
import java.time.OffsetDateTime
import java.util.UUID
import kotlin.math.pow
import kotlin.math.roundToInt
data class Product(
val id: Long,
val guid: UUID,
val name: String,
val description: String?,
val price: Long,
val createdAt: OffsetDateTime,
val updatedAt: OffsetDateTime?,
val deletedAt: OffsetDateTime?,
) {
fun getPriceDouble(): Double = (price.toDouble() / 100).roundTo(2)
fun isDeleted(): Boolean = deletedAt != null
private fun Double.roundTo(numFractionDigits: Int): Double {
val factor = 10.0.pow(numFractionDigits.toDouble())
return (this * factor).roundToInt() / factor
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
package com.github.dannecron.demo.core.services.city
import com.github.dannecron.demo.core.dto.City
import com.github.dannecron.demo.core.dto.CityCreate
import com.github.dannecron.demo.core.exceptions.AlreadyDeletedException
import com.github.dannecron.demo.core.exceptions.CityNotFoundException
import org.springframework.stereotype.Service
import java.util.UUID
@Service
interface CityService {
fun findByGuid(guid: UUID): City?
fun create(name: String): City
fun create(cityCreate: CityCreate): City
@Throws(CityNotFoundException::class, AlreadyDeletedException::class)
fun delete(guid: UUID): City
}

View File

@@ -0,0 +1,71 @@
package com.github.dannecron.demo.core.services.city
import com.github.dannecron.demo.core.dto.City
import com.github.dannecron.demo.core.dto.CityCreate
import com.github.dannecron.demo.core.exceptions.AlreadyDeletedException
import com.github.dannecron.demo.core.exceptions.CityNotFoundException
import com.github.dannecron.demo.core.services.generation.CommonGenerator
import com.github.dannecron.demo.db.entity.CityEntity
import com.github.dannecron.demo.db.repository.CityRepository
import org.springframework.stereotype.Service
import java.time.OffsetDateTime
import java.util.UUID
@Service
class CityServiceImpl(
private val cityRepository: CityRepository,
private val commonGenerator: CommonGenerator,
): CityService {
override fun findByGuid(guid: UUID): City? = cityRepository.findByGuid(guid)?.toCore()
override fun create(name: String): City = CityEntity(
id = null,
guid = commonGenerator.generateUUID(),
name = name,
createdAt = commonGenerator.generateCurrentTime(),
updatedAt = null,
deletedAt = null,
).let(cityRepository::save).toCore()
override fun create(cityCreate: CityCreate): City = CityEntity(
id = null,
guid = UUID.fromString(cityCreate.guid),
name = cityCreate.name,
createdAt = cityCreate.createdAt,
updatedAt = cityCreate.updatedAt,
deletedAt = cityCreate.deletedAt,
).let(cityRepository::save).toCore()
@Throws(CityNotFoundException::class, AlreadyDeletedException::class)
override fun delete(guid: UUID): City {
val city = findByGuid(guid) ?: throw CityNotFoundException()
if (city.isDeleted()) {
throw AlreadyDeletedException()
}
return cityRepository.save(
city.copy(
deletedAt = OffsetDateTime.now(),
).toEntity()
).toCore()
}
private fun CityEntity.toCore() = City(
id = id!!,
guid = guid,
name = name,
createdAt = createdAt,
updatedAt = updatedAt,
deletedAt = deletedAt,
)
private fun City.toEntity() = CityEntity(
id = id,
guid = guid,
name = name,
createdAt = createdAt,
updatedAt = updatedAt,
deletedAt = deletedAt,
)
}

View File

@@ -0,0 +1,19 @@
package com.github.dannecron.demo.core.services.product
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 org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import java.util.UUID
interface ProductService {
fun findByGuid(guid: UUID): Product?
fun findAll(pageable: Pageable): Page<Product>
fun create(name: String, price: Long, description: String?): Product
@Throws(ProductNotFoundException::class, AlreadyDeletedException::class)
fun delete(guid: UUID): Product
}

View File

@@ -0,0 +1,82 @@
package com.github.dannecron.demo.core.services.product
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.generation.CommonGenerator
import com.github.dannecron.demo.core.utils.LoggerDelegate
import com.github.dannecron.demo.db.entity.ProductEntity
import com.github.dannecron.demo.db.repository.ProductRepository
import net.logstash.logback.marker.Markers
import org.springframework.data.domain.Page
import org.springframework.data.domain.Pageable
import org.springframework.stereotype.Service
import java.util.UUID
@Service
class ProductServiceImpl(
private val productRepository: ProductRepository,
private val commonGenerator: CommonGenerator,
): ProductService {
private val logger by LoggerDelegate()
override fun findByGuid(guid: UUID): Product? = productRepository.findByGuid(guid)?.toCore()
.also {
logger.debug(
Markers.appendEntries(mapOf("guid" to guid, "idResult" to it?.id)),
"find product by guid",
)
}
override fun findAll(pageable: Pageable): Page<Product> = productRepository.findAll(pageable)
.map { it.toCore() }
override fun create(name: String, price: Long, description: String?): Product =
ProductEntity(
id = null,
guid = commonGenerator.generateUUID(),
name = name,
description = description,
price = price,
createdAt = commonGenerator.generateCurrentTime(),
updatedAt = null,
deletedAt = null,
).let(productRepository::save).toCore()
@Throws(ProductNotFoundException::class, AlreadyDeletedException::class)
override fun delete(guid: UUID): Product {
val product = findByGuid(guid) ?: throw ProductNotFoundException()
if (product.isDeleted()) {
throw AlreadyDeletedException()
}
return product.copy(
deletedAt = commonGenerator.generateCurrentTime(),
).toEntity()
.let(productRepository::save)
.toCore()
}
private fun ProductEntity.toCore() = Product(
id = id!!,
guid = guid,
name = name,
description = description,
price = price,
createdAt = createdAt,
updatedAt = updatedAt,
deletedAt = deletedAt,
)
private fun Product.toEntity() = ProductEntity(
id = id,
guid = guid,
name = name,
description = description,
price = price,
createdAt = createdAt,
updatedAt = updatedAt,
deletedAt = deletedAt,
)
}

View File

@@ -0,0 +1,24 @@
package com.github.dannecron.demo.core.utils
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import kotlin.properties.ReadOnlyProperty
import kotlin.reflect.KProperty
import kotlin.reflect.full.companionObject
/**
* usage: `private val logger by LoggerDelegate()`
*/
class LoggerDelegate<in R : Any> : ReadOnlyProperty<R, Logger> {
override fun getValue(thisRef: R, property: KProperty<*>)
= getLogger(getClassForLogging(thisRef.javaClass))
private fun <T : Any> getClassForLogging(javaClass: Class<T>): Class<*> {
return javaClass.enclosingClass?.takeIf {
it.kotlin.companionObject?.java == javaClass
} ?: javaClass
}
}
fun getLogger(forClass: Class<*>): Logger = LoggerFactory.getLogger(forClass)

View File

@@ -0,0 +1,79 @@
package com.github.dannecron.demo.core.services.city
import com.github.dannecron.demo.core.dto.City
import com.github.dannecron.demo.core.dto.CityCreate
import com.github.dannecron.demo.core.services.generation.CommonGenerator
import com.github.dannecron.demo.db.entity.CityEntity
import com.github.dannecron.demo.db.repository.CityRepository
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 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 cityEntity = CityEntity(
id = 1000,
guid = mockGuid,
name = "name",
createdAt = mockCurrentTime,
updatedAt = null,
deletedAt = null,
)
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<CityEntity>())).thenReturn(cityEntity)
val result = cityServiceImpl.create("name")
assertEquals(city, result)
verify(cityRepository, times(1)).save(cityEntity.copy(id = null))
}
@Test
fun `create - by dto`() {
val cityGuid = UUID.randomUUID()
val createdAt = OffsetDateTime.now()
val cityCreate = CityCreate(
guid = cityGuid.toString(),
name = "name",
createdAt = createdAt,
updatedAt = null,
deletedAt = null,
)
val expectedCityEntity = cityEntity.copy(guid = cityGuid, createdAt = createdAt)
val expectedCity = city.copy(guid = cityGuid, createdAt = createdAt)
whenever(cityRepository.save(any<CityEntity>())).thenReturn(expectedCityEntity)
val result = cityServiceImpl.create(cityCreate)
assertEquals(expectedCity, result)
verify(cityRepository, times(1)).save(expectedCityEntity.copy(id = null))
}
}

View File

@@ -0,0 +1,130 @@
package com.github.dannecron.demo.core.services.product
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.generation.CommonGenerator
import com.github.dannecron.demo.db.entity.ProductEntity
import com.github.dannecron.demo.db.repository.ProductRepository
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.never
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.assertEquals
class ProductServiceImplTest {
private val mockGuid = UUID.randomUUID()
private val mockCurrentTime = OffsetDateTime.now()
private val productRepository: ProductRepository = mock()
private val commonGenerator: CommonGenerator = mock {
on { generateUUID() } doReturn mockGuid
on { generateCurrentTime() } doReturn mockCurrentTime
}
private val productService = ProductServiceImpl(
productRepository = productRepository,
commonGenerator = commonGenerator,
)
private val guid = UUID.randomUUID()
private val productEntity = ProductEntity(
id = 123,
guid = guid,
name = "name",
description = "description",
price = 10050,
createdAt = mockCurrentTime.minusDays(1),
updatedAt = mockCurrentTime.minusHours(2),
deletedAt = null,
)
private val product = Product(
id = 123,
guid = guid,
name = "name",
description = "description",
price = 10050,
createdAt = mockCurrentTime.minusDays(1),
updatedAt = mockCurrentTime.minusHours(2),
deletedAt = null,
)
@Test
fun create() {
val expectedProductForCreation = productEntity.copy(
id = null,
guid = mockGuid,
createdAt = mockCurrentTime,
updatedAt = null,
)
val expectedCreatedProductEntity = expectedProductForCreation.copy(id = 1)
val expectedProduct = product.copy(
id = 1,
guid = mockGuid,
createdAt = mockCurrentTime,
updatedAt = null,
)
whenever(productRepository.save<ProductEntity>(any())).thenReturn(expectedCreatedProductEntity)
val result = productService.create(
name = "name",
price = 10050,
description = "description",
)
assertEquals(expectedProduct, result)
verify(productRepository, times(1)).save(expectedProductForCreation)
}
@Test
fun `delete - success`() {
val deletedProductEntity = productEntity.copy(
deletedAt = mockCurrentTime,
)
val expectedProduct = product.copy(deletedAt = mockCurrentTime)
whenever(productRepository.findByGuid(any())).thenReturn(productEntity)
whenever(productRepository.save<ProductEntity>(any())).thenReturn(deletedProductEntity)
val result = productService.delete(guid)
assertEquals(expectedProduct, result)
verify(productRepository, times(1)).findByGuid(guid)
verify(productRepository, times(1)).save(deletedProductEntity)
}
@Test
fun `delete - fail - already deleted`() {
val deletedProduct = productEntity.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> {
productService.delete(guid)
}
verify(productRepository, times(1)).findByGuid(guid)
verify(productRepository, never()).save(any())
}
}