Merge branch 'features' into 'master'

Ajout de la vérification des étapes des recettes et des ingrédients des mélanges

See merge request color-recipes-explorer/backend!25
This commit is contained in:
William Nolin 2021-04-19 12:29:44 +00:00
commit 37b5a09479
11 changed files with 422 additions and 33 deletions

1
.gitignore vendored
View File

@ -11,5 +11,6 @@ logs/
data/
dokka/
dist/
out/
/src/main/resources/angular/static/*

View File

@ -1,3 +1,5 @@
import org.jetbrains.kotlin.gradle.tasks.KotlinCompile
group = "dev.fyloz.colorrecipesexplorer"
plugins {
@ -79,15 +81,17 @@ tasks.test {
}
}
tasks.withType<JavaCompile> {
tasks.withType<JavaCompile>() {
options.compilerArgs.addAll(arrayOf("--release", "11"))
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile> {
tasks.withType<KotlinCompile>().all {
kotlinOptions {
jvmTarget = "11"
jvmTarget = JavaVersion.VERSION_11.toString()
useIR = true
freeCompilerArgs = freeCompilerArgs + "-Xopt-in=kotlin.contracts.ExperimentalContracts"
freeCompilerArgs = listOf(
"-Xopt-in=kotlin.contracts.ExperimentalContracts",
"-Xinline-classes"
)
}
}

View File

@ -1,8 +1,12 @@
package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.exception.RestException
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.repository.MixMaterialRepository
import dev.fyloz.colorrecipesexplorer.service.utils.findDuplicated
import dev.fyloz.colorrecipesexplorer.service.utils.hasGaps
import org.springframework.context.annotation.Lazy
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
interface MixMaterialService : ModelService<MixMaterial, MixMaterialRepository> {
@ -17,6 +21,13 @@ interface MixMaterialService : ModelService<MixMaterial, MixMaterialRepository>
/** Updates the [quantity] of the given [mixMaterial]. */
fun updateQuantity(mixMaterial: MixMaterial, quantity: Float): MixMaterial
/**
* Validates if the given [mixMaterials]. To be valid, the position of each mix material must be greater or equals to 1 and unique in the set.
* There must also be no gap between the positions. Also, the quantity of the first mix material in the set must not be expressed in percentages.
* If any of those criteria are not met, an [InvalidGroupStepsPositionsException] will be thrown.
*/
fun validateMixMaterials(mixMaterials: Set<MixMaterial>)
}
@Service
@ -42,4 +53,90 @@ class MixMaterialServiceImpl(
update(mixMaterial.apply {
this.quantity = quantity
})
override fun validateMixMaterials(mixMaterials: Set<MixMaterial>) {
if (mixMaterials.isEmpty()) return
val sortedMixMaterials = mixMaterials.sortedBy { it.position }
val firstMixMaterial = sortedMixMaterials[0]
val errors = mutableSetOf<InvalidMixMaterialsPositionsError>()
// Check if the first mix material position is 1
fun isFirstMixMaterialPositionInvalid() =
sortedMixMaterials[0].position != 1
// Check if the first mix material is expressed in percents
fun isFirstMixMaterialPercentages() =
sortedMixMaterials[0].material.materialType!!.usePercentages
// Check if any positions is duplicated
fun getDuplicatedPositionsErrors() =
sortedMixMaterials
.findDuplicated { it.position }
.map { duplicatedMixMaterialsPositions(it) }
// Find all errors and throw if there is any
if (isFirstMixMaterialPositionInvalid()) errors += invalidFirstMixMaterialPosition(sortedMixMaterials[0])
errors += getDuplicatedPositionsErrors()
if (errors.isEmpty() && mixMaterials.hasGaps { it.position }) errors += gapBetweenStepsPositions()
if (errors.isNotEmpty()) {
throw InvalidMixMaterialsPositionsException(errors)
}
if (isFirstMixMaterialPercentages()) {
throw InvalidFirstMixMaterial(firstMixMaterial)
}
}
}
class InvalidMixMaterialsPositionsError(
val type: String,
val details: String
)
class InvalidMixMaterialsPositionsException(
val errors: Set<InvalidMixMaterialsPositionsError>
) : RestException(
"invalid-mixmaterial-position",
"Invalid mix materials positions",
HttpStatus.BAD_REQUEST,
"The position of mix materials are invalid",
mapOf(
"invalidMixMaterials" to errors
)
)
class InvalidFirstMixMaterial(
val mixMaterial: MixMaterial
) : RestException(
"invalid-mixmaterial-first",
"Invalid first mix material",
HttpStatus.BAD_REQUEST,
"The first mix material is invalid because its material must not be expressed in percents",
mapOf(
"mixMaterial" to mixMaterial
)
)
const val INVALID_FIRST_MIX_MATERIAL_POSITION_ERROR_CODE = "first"
const val DUPLICATED_MIX_MATERIALS_POSITIONS_ERROR_CODE = "duplicated"
const val GAP_BETWEEN_MIX_MATERIALS_POSITIONS_ERROR_CODE = "gap"
private fun invalidFirstMixMaterialPosition(mixMaterial: MixMaterial) =
InvalidMixMaterialsPositionsError(
INVALID_FIRST_MIX_MATERIAL_POSITION_ERROR_CODE,
"The position ${mixMaterial.position} is under the minimum of 1"
)
private fun duplicatedMixMaterialsPositions(position: Int) =
InvalidMixMaterialsPositionsError(
DUPLICATED_MIX_MATERIALS_POSITIONS_ERROR_CODE,
"The position $position is duplicated"
)
private fun gapBetweenStepsPositions() =
InvalidMixMaterialsPositionsError(
GAP_BETWEEN_MIX_MATERIALS_POSITIONS_ERROR_CODE,
"There is a gap between mix materials positions"
)

View File

@ -1,8 +1,8 @@
package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.repository.MixRepository
import dev.fyloz.colorrecipesexplorer.service.utils.setAll
import org.springframework.context.annotation.Lazy
import org.springframework.stereotype.Service
import javax.transaction.Transactional
@ -43,6 +43,8 @@ class MixServiceImpl(
val mixType = mixTypeService.getOrCreateForNameAndMaterialType(entity.name, materialType)
val mixMaterials = if (entity.mixMaterials != null) mixMaterialService.create(entity.mixMaterials) else setOf()
mixMaterialService.validateMixMaterials(mixMaterials)
var mix = mix(recipe = recipe, mixType = mixType, mixMaterials = mixMaterials.toMutableSet())
mix = save(mix)
@ -68,8 +70,7 @@ class MixServiceImpl(
}
}
if (entity.mixMaterials != null) {
mix.mixMaterials.clear()
mix.mixMaterials.addAll(mixMaterialService.create(entity.mixMaterials!!).toMutableSet())
mix.mixMaterials.setAll(mixMaterialService.create(entity.mixMaterials!!).toMutableSet())
}
return update(mix)
}

View File

@ -1,10 +1,10 @@
package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.model.validation.or
import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository
import dev.fyloz.colorrecipesexplorer.service.files.FileService
import dev.fyloz.colorrecipesexplorer.service.utils.setAll
import org.springframework.context.annotation.Lazy
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
@ -34,6 +34,7 @@ class RecipeServiceImpl(
recipeRepository: RecipeRepository,
val companyService: CompanyService,
val mixService: MixService,
val recipeStepService: RecipeStepService,
@Lazy val groupService: EmployeeGroupService
) :
AbstractExternalModelService<Recipe, RecipeSaveDto, RecipeUpdateDto, RecipeRepository>(recipeRepository),
@ -76,32 +77,34 @@ class RecipeServiceImpl(
remark = remark or persistedRecipe.remark,
company = persistedRecipe.company,
mixes = persistedRecipe.mixes,
groupsInformation = updateGroupsInformationSteps(persistedRecipe, entity.steps)
groupsInformation = updateGroupsInformation(persistedRecipe, entity)
)
})
}
private fun updateGroupsInformationSteps(recipe: Recipe, steps: Set<RecipeStepsDto>?): Set<RecipeGroupInformation> {
if (steps == null) return recipe.groupsInformation
private fun updateGroupsInformation(recipe: Recipe, updateDto: RecipeUpdateDto): Set<RecipeGroupInformation> {
val steps = updateDto.steps ?: return recipe.groupsInformation
val updatedGroupsInformation = mutableSetOf<RecipeGroupInformation>()
steps.forEach {
with(recipe.groupInformationForGroup(it.groupId)) {
updatedGroupsInformation.add(
this?.apply {
if (this.steps != null) {
this.steps!!.clear()
this.steps!!.addAll(it.steps)
} else {
this.steps = it.steps.toMutableSet()
}
} ?: recipeGroupInformation(
group = groupService.getById(it.groupId),
steps = it.steps.toMutableSet()
)
// Set steps for the existing RecipeGroupInformation or create a new one
val updatedGroupInformation = this?.apply {
if (this.steps != null) {
this.steps!!.setAll(it.steps)
} else {
this.steps = it.steps.toMutableSet()
}
} ?: recipeGroupInformation(
group = groupService.getById(it.groupId),
steps = it.steps.toMutableSet()
)
updatedGroupsInformation.add(updatedGroupInformation)
recipeStepService.validateGroupInformationSteps(updatedGroupInformation)
}
}
return updatedGroupsInformation
}

View File

@ -1,12 +1,24 @@
package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.model.RecipeStep
import dev.fyloz.colorrecipesexplorer.model.recipeStepIdAlreadyExistsException
import dev.fyloz.colorrecipesexplorer.model.recipeStepIdNotFoundException
import dev.fyloz.colorrecipesexplorer.exception.RestException
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.repository.RecipeStepRepository
import dev.fyloz.colorrecipesexplorer.service.utils.findDuplicated
import dev.fyloz.colorrecipesexplorer.service.utils.hasGaps
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
interface RecipeStepService : ModelService<RecipeStep, RecipeStepRepository>
interface RecipeStepService : ModelService<RecipeStep, RecipeStepRepository> {
/** Validates the steps of the given [groupInformation], according to the criteria of [validateSteps]. */
fun validateGroupInformationSteps(groupInformation: RecipeGroupInformation)
/**
* Validates if the given [steps]. To be valid, the position of each step must be greater or equals to 1 and unique in the set.
* There must also be no gap between the positions.
* If any of those criteria are not met, an [InvalidGroupStepsPositionsException] will be thrown.
*/
fun validateSteps(steps: Set<RecipeStep>)
}
@Service
class RecipeStepServiceImpl(recipeStepRepository: RecipeStepRepository) :
@ -14,4 +26,96 @@ class RecipeStepServiceImpl(recipeStepRepository: RecipeStepRepository) :
RecipeStepService {
override fun idNotFoundException(id: Long) = recipeStepIdNotFoundException(id)
override fun idAlreadyExistsException(id: Long) = recipeStepIdAlreadyExistsException(id)
override fun validateGroupInformationSteps(groupInformation: RecipeGroupInformation) {
if (groupInformation.steps == null) return
try {
validateSteps(groupInformation.steps!!)
} catch (validationException: InvalidStepsPositionsException) {
throw InvalidGroupStepsPositionsException(groupInformation.group, validationException)
}
}
override fun validateSteps(steps: Set<RecipeStep>) {
if (steps.isEmpty()) return
val sortedSteps = steps.sortedBy { it.position }
val errors = mutableSetOf<InvalidStepsPositionsError>()
// Check if the first step position is 1
fun isFirstStepPositionInvalid() =
sortedSteps[0].position != 1
// Check if any position is duplicated
fun getDuplicatedPositionsErrors() =
sortedSteps
.findDuplicated { it.position }
.map { duplicatedStepsPositions(it) }
// Find all errors and throw if there is any
if (isFirstStepPositionInvalid()) errors += invalidFirstStepPosition(sortedSteps[0])
errors += getDuplicatedPositionsErrors()
if (errors.isEmpty() && steps.hasGaps { it.position }) errors += gapBetweenStepsPositions()
if (errors.isNotEmpty()) {
throw InvalidStepsPositionsException(errors)
}
}
}
data class InvalidStepsPositionsError(
val type: String,
val details: String
)
class InvalidStepsPositionsException(
val errors: Set<InvalidStepsPositionsError>
) : RestException(
"invalid-recipestep-position",
"Invalid steps positions",
HttpStatus.BAD_REQUEST,
"The position of steps are invalid",
mapOf(
"invalidSteps" to errors
)
)
class InvalidGroupStepsPositionsException(
val group: EmployeeGroup,
val exception: InvalidStepsPositionsException
) : RestException(
"invalid-groupinformation-recipestep-position",
"Invalid steps positions",
HttpStatus.BAD_REQUEST,
"The position of steps for the group ${group.name} are invalid",
mapOf(
"group" to group.name,
"groupId" to group.id!!,
"invalidSteps" to exception.errors
)
) {
val errors: Set<InvalidStepsPositionsError>
get() = exception.errors
}
const val INVALID_FIRST_STEP_POSITION_ERROR_CODE = "first"
const val DUPLICATED_STEPS_POSITIONS_ERROR_CODE = "duplicated"
const val GAP_BETWEEN_STEPS_POSITIONS_ERROR_CODE = "gap"
private fun invalidFirstStepPosition(step: RecipeStep) =
InvalidStepsPositionsError(
INVALID_FIRST_STEP_POSITION_ERROR_CODE,
"The position ${step.position} is under the minimum of 1"
)
private fun duplicatedStepsPositions(position: Int) =
InvalidStepsPositionsError(
DUPLICATED_STEPS_POSITIONS_ERROR_CODE,
"The position $position is duplicated"
)
private fun gapBetweenStepsPositions() =
InvalidStepsPositionsError(
GAP_BETWEEN_STEPS_POSITIONS_ERROR_CODE,
"There is a gap between steps positions"
)

View File

@ -16,3 +16,23 @@ inline fun <T, R, reified E : Throwable> Iterable<T>.mapMayThrow(
}
}
}
/** Find duplicated in the given [Iterable] from keys obtained from the given [keySelector]. */
inline fun <T, K> Iterable<T>.findDuplicated(keySelector: (T) -> K) =
this.groupBy(keySelector)
.filter { it.value.count() > 1 }
.map { it.key }
/** Check if the given [Iterable] has gaps between each items, using keys obtained from the given [keySelector]. */
inline fun <T> Iterable<T>.hasGaps(keySelector: (T) -> Int) =
this.map(keySelector)
.toIntArray()
.sorted()
.filterIndexed { index, it -> it != index + 1 }
.isNotEmpty()
/** Clears and fills the given [MutableCollection] with the given [elements]. */
fun <T> MutableCollection<T>.setAll(elements: Collection<T>) {
this.clear()
this.addAll(elements)
}

View File

@ -306,6 +306,10 @@ fun RestException.assertErrorCode(type: String, identifierName: String) {
}
}
fun RestException.assertErrorCode(errorCode: String) {
assertEquals(errorCode, this.errorCode)
}
fun <E : Model, N : EntityDto<E>> withBaseSaveDtoTest(
entity: E,
entitySaveDto: N,

View File

@ -4,6 +4,7 @@ import com.nhaarman.mockitokotlin2.*
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.repository.MixMaterialRepository
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotEquals
@ -99,4 +100,70 @@ class MixMaterialServiceTest : AbstractModelServiceTest<MixMaterial, MixMaterial
assertEquals(found.quantity, quantity)
}
// validateMixMaterials()
@Test
fun `validateMixMaterials() throws InvalidMixMaterialsPositionsException when the position of the first mix material is not 1`() {
assertInvalidMixMaterialsPositionsException(
setOf(
mixMaterial(id = 0L, position = 0),
mixMaterial(id = 1L, position = 1),
mixMaterial(id = 2L, position = 2),
mixMaterial(id = 3L, position = 3)
),
INVALID_FIRST_MIX_MATERIAL_POSITION_ERROR_CODE
)
}
@Test
fun `validateMixMaterials() throws InvalidMixMaterialsPositionsException when positions are duplicated`() {
assertInvalidMixMaterialsPositionsException(
setOf(
mixMaterial(id = 0L, position = 1),
mixMaterial(id = 1L, position = 2),
mixMaterial(id = 2L, position = 2),
mixMaterial(id = 3L, position = 3)
),
DUPLICATED_MIX_MATERIALS_POSITIONS_ERROR_CODE
)
}
@Test
fun `validateMixMaterials() throws InvalidMixMaterialsPositionsException when there is a gap between positions`() {
assertInvalidMixMaterialsPositionsException(
setOf(
mixMaterial(id = 0L, position = 1),
mixMaterial(id = 1L, position = 2),
mixMaterial(id = 2L, position = 4),
mixMaterial(id = 3L, position = 5)
),
GAP_BETWEEN_MIX_MATERIALS_POSITIONS_ERROR_CODE
)
}
@Test
fun `validateMixMaterials() throws InvalidFirstMixMaterial when the first mix material's quantity is expressed in percents`() {
val normalMaterial = material(materialType = materialType(usePercentages = false))
val percentsMaterial = material(materialType = materialType(usePercentages = true))
val mixMaterials = setOf(
mixMaterial(id = 0L, position = 1, material = percentsMaterial),
mixMaterial(id = 1L, position = 2, material = normalMaterial),
mixMaterial(id = 2L, position = 3, material = normalMaterial),
mixMaterial(id = 3L, position = 4, material = normalMaterial)
)
assertThrows<InvalidFirstMixMaterial> {
service.validateMixMaterials(mixMaterials)
}
}
private fun assertInvalidMixMaterialsPositionsException(mixMaterials: Set<MixMaterial>, errorType: String) {
val exception = assertThrows<InvalidMixMaterialsPositionsException> {
service.validateMixMaterials(mixMaterials)
}
assertTrue { exception.errors.size == 1 }
assertTrue { exception.errors.first().type == errorType }
}
}

View File

@ -20,7 +20,8 @@ class RecipeServiceTest :
private val companyService: CompanyService = mock()
private val mixService: MixService = mock()
private val groupService: EmployeeGroupService = mock()
override val service: RecipeService = spy(RecipeServiceImpl(repository, companyService, mixService, groupService))
private val recipeStepService: RecipeStepService = mock()
override val service: RecipeService = spy(RecipeServiceImpl(repository, companyService, mixService, recipeStepService, groupService))
private val company: Company = company(id = 0L)
override val entity: Recipe = recipe(id = 0L, name = "recipe", company = company)

View File

@ -1,10 +1,11 @@
package dev.fyloz.colorrecipesexplorer.service
import com.nhaarman.mockitokotlin2.mock
import com.nhaarman.mockitokotlin2.spy
import dev.fyloz.colorrecipesexplorer.model.RecipeStep
import dev.fyloz.colorrecipesexplorer.model.recipeStep
import com.nhaarman.mockitokotlin2.*
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.repository.RecipeStepRepository
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import kotlin.test.assertTrue
class RecipeStepServiceTest :
AbstractModelServiceTest<RecipeStep, RecipeStepService, RecipeStepRepository>() {
@ -13,4 +14,90 @@ class RecipeStepServiceTest :
override val entity: RecipeStep = recipeStep(id = 0L, message = "message")
override val anotherEntity: RecipeStep = recipeStep(id = 1L, message = "another message")
// validateGroupInformationSteps()
@Test
fun `validateGroupInformationSteps() calls validateSteps() with the given RecipeGroupInformation steps`() {
withGroupInformation {
service.validateGroupInformationSteps(this)
verify(service).validateSteps(this.steps!!)
}
}
@Test
fun `validateGroupInformationSteps() throws InvalidGroupStepsPositionsException when validateSteps() throws an InvalidStepsPositionsException`() {
withGroupInformation {
doAnswer { throw InvalidStepsPositionsException(setOf()) }.whenever(service).validateSteps(this.steps!!)
assertThrows<InvalidGroupStepsPositionsException> {
service.validateGroupInformationSteps(this)
}
}
}
// validateSteps()
@Test
fun `validateSteps() throws an InvalidStepsPositionsException when the position of the first step of the given groupInformation is not 1`() {
assertInvalidStepsPositionsException(
mutableSetOf(
recipeStep(id = 0L, position = 0),
recipeStep(id = 1L, position = 1),
recipeStep(id = 2L, position = 2),
recipeStep(id = 3L, position = 3)
),
INVALID_FIRST_STEP_POSITION_ERROR_CODE
)
}
@Test
fun `validateSteps() throws an InvalidStepsPositionsException when steps positions are duplicated in the given groupInformation`() {
assertInvalidStepsPositionsException(
mutableSetOf(
recipeStep(id = 0L, position = 1),
recipeStep(id = 1L, position = 2),
recipeStep(id = 2L, position = 2),
recipeStep(id = 3L, position = 3)
),
DUPLICATED_STEPS_POSITIONS_ERROR_CODE
)
}
@Test
fun `validateSteps() throws an InvalidStepsPositionsException when there is a gap between steps positions in the given groupInformation`() {
assertInvalidStepsPositionsException(
mutableSetOf(
recipeStep(id = 0L, position = 1),
recipeStep(id = 1L, position = 2),
recipeStep(id = 2L, position = 4),
recipeStep(id = 3L, position = 5)
),
GAP_BETWEEN_STEPS_POSITIONS_ERROR_CODE
)
}
private fun withGroupInformation(steps: MutableSet<RecipeStep>? = null, test: RecipeGroupInformation.() -> Unit) {
recipeGroupInformation(
group = employeeGroup(id = 0L),
steps = steps ?: mutableSetOf(
recipeStep(id = 0L, position = 1),
recipeStep(id = 1L, position = 2),
recipeStep(id = 2L, position = 3),
recipeStep(id = 3L, position = 4)
)
) {
test()
}
}
private fun assertInvalidStepsPositionsException(steps: MutableSet<RecipeStep>, errorType: String) {
val exception = assertThrows<InvalidStepsPositionsException> {
service.validateSteps(steps)
}
assertTrue { exception.errors.size == 1 }
assertTrue { exception.errors.first().type == errorType }
}
}