#25 Migrate recipe steps to new logic
continuous-integration/drone/push Build is passing Details

This commit is contained in:
FyloZ 2022-02-27 20:46:56 -05:00
parent b598652594
commit 0e97fef70e
Signed by: william
GPG Key ID: 835378AE9AF4AE97
7 changed files with 373 additions and 212 deletions

View File

@ -0,0 +1,15 @@
package dev.fyloz.colorrecipesexplorer.dtos
data class RecipeStepDto(
override val id: Long = 0L,
val position: Int,
val message: String
) : EntityDto {
companion object {
const val VALIDATION_ERROR_CODE_INVALID_FIRST_STEP = "first"
const val VALIDATION_ERROR_CODE_DUPLICATED_STEPS_POSITION = "duplicated"
const val VALIDATION_ERROR_CODE_GAP_BETWEEN_STEPS_POSITIONS = "gap"
}
}

View File

@ -61,7 +61,7 @@ abstract class BaseLogic<D : EntityDto, S : Service<D, *, *>>(
override fun deleteById(id: Long) =
service.deleteById(id)
protected fun notFoundException(identifierName: String = idIdentifierName, value: Any) =
protected fun notFoundException(identifierName: String = ID_IDENTIFIER_NAME, value: Any) =
NotFoundException(
typeNameLowerCase,
"$typeName not found",
@ -70,7 +70,7 @@ abstract class BaseLogic<D : EntityDto, S : Service<D, *, *>>(
identifierName
)
protected fun alreadyExistsException(identifierName: String = nameIdentifierName, value: Any) =
protected fun alreadyExistsException(identifierName: String = NAME_IDENTIFIER_NAME, value: Any) =
AlreadyExistsException(
typeNameLowerCase,
"$typeName already exists",
@ -87,7 +87,7 @@ abstract class BaseLogic<D : EntityDto, S : Service<D, *, *>>(
)
companion object {
const val idIdentifierName = "id"
const val nameIdentifierName = "name"
const val ID_IDENTIFIER_NAME = "id"
const val NAME_IDENTIFIER_NAME = "name"
}
}

View File

@ -1,19 +1,18 @@
package dev.fyloz.colorrecipesexplorer.logic
import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase
import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent
import dev.fyloz.colorrecipesexplorer.dtos.RecipeStepDto
import dev.fyloz.colorrecipesexplorer.exception.RestException
import dev.fyloz.colorrecipesexplorer.model.RecipeGroupInformation
import dev.fyloz.colorrecipesexplorer.model.RecipeStep
import dev.fyloz.colorrecipesexplorer.model.account.Group
import dev.fyloz.colorrecipesexplorer.model.recipeStepIdAlreadyExistsException
import dev.fyloz.colorrecipesexplorer.model.recipeStepIdNotFoundException
import dev.fyloz.colorrecipesexplorer.repository.RecipeStepRepository
import dev.fyloz.colorrecipesexplorer.model.recipeStepDto
import dev.fyloz.colorrecipesexplorer.service.RecipeStepService
import dev.fyloz.colorrecipesexplorer.utils.findDuplicated
import dev.fyloz.colorrecipesexplorer.utils.hasGaps
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
interface RecipeStepLogic : ModelService<RecipeStep, RecipeStepRepository> {
interface RecipeStepLogic : Logic<RecipeStepDto, RecipeStepService> {
/** Validates the steps of the given [groupInformation], according to the criteria of [validateSteps]. */
fun validateGroupInformationSteps(groupInformation: RecipeGroupInformation)
@ -22,106 +21,112 @@ interface RecipeStepLogic : ModelService<RecipeStep, RecipeStepRepository> {
* 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>)
fun validateSteps(steps: Set<RecipeStepDto>)
}
@Service
@RequireDatabase
class DefaultRecipeStepLogic(recipeStepRepository: RecipeStepRepository) :
AbstractModelService<RecipeStep, RecipeStepRepository>(recipeStepRepository),
RecipeStepLogic {
override fun idNotFoundException(id: Long) = recipeStepIdNotFoundException(id)
override fun idAlreadyExistsException(id: Long) = recipeStepIdAlreadyExistsException(id)
@LogicComponent
class DefaultRecipeStepLogic(recipeStepService: RecipeStepService) :
BaseLogic<RecipeStepDto, RecipeStepService>(recipeStepService, RecipeStep::class.simpleName!!), RecipeStepLogic {
override fun validateGroupInformationSteps(groupInformation: RecipeGroupInformation) {
if (groupInformation.steps == null) return
try {
validateSteps(groupInformation.steps!!)
validateSteps(groupInformation.steps!!.map { recipeStepDto(it) }.toSet())
} catch (validationException: InvalidStepsPositionsException) {
throw InvalidGroupStepsPositionsException(groupInformation.group, validationException)
}
}
override fun validateSteps(steps: Set<RecipeStep>) {
override fun validateSteps(steps: Set<RecipeStepDto>) {
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
validateFirstStepPosition(sortedSteps, errors)
// Check if any position is duplicated
fun getDuplicatedPositionsErrors() =
sortedSteps
.findDuplicated { it.position }
.map { duplicatedStepsPositions(it) }
validateDuplicatedStepsPositions(sortedSteps, errors)
// Check for gaps between positions
validateGapsInStepsPositions(sortedSteps, errors)
// 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)
}
}
private fun validateFirstStepPosition(
steps: List<RecipeStepDto>,
errors: MutableSet<InvalidStepsPositionsError>
) {
if (steps[0].position != 1) {
errors += InvalidStepsPositionsError(
RecipeStepDto.VALIDATION_ERROR_CODE_INVALID_FIRST_STEP,
"The first step must be at position 1"
)
}
}
private fun validateDuplicatedStepsPositions(
steps: List<RecipeStepDto>,
errors: MutableSet<InvalidStepsPositionsError>
) {
errors += steps
.findDuplicated { it.position }
.map {
InvalidStepsPositionsError(
RecipeStepDto.VALIDATION_ERROR_CODE_DUPLICATED_STEPS_POSITION,
"The position $it is duplicated"
)
}
}
private fun validateGapsInStepsPositions(
steps: List<RecipeStepDto>,
errors: MutableSet<InvalidStepsPositionsError>
) {
if (errors.isEmpty() && steps.hasGaps { it.position }) {
errors += InvalidStepsPositionsError(
RecipeStepDto.VALIDATION_ERROR_CODE_GAP_BETWEEN_STEPS_POSITIONS,
"There is a gap between steps positions"
)
}
}
}
data class InvalidStepsPositionsError(
val type: String,
val details: String
val type: String,
val details: String
)
class InvalidStepsPositionsException(
val errors: Set<InvalidStepsPositionsError>
val errors: Set<InvalidStepsPositionsError>
) : RestException(
"invalid-recipestep-position",
"Invalid steps positions",
HttpStatus.BAD_REQUEST,
"The position of steps are invalid",
mapOf(
"invalidSteps" to errors
)
"invalid-recipestep-position",
"Invalid steps positions",
HttpStatus.BAD_REQUEST,
"The position of steps are invalid",
mapOf(
"invalidSteps" to errors
)
)
class InvalidGroupStepsPositionsException(
val group: Group,
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
)
"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

@ -1,7 +1,6 @@
package dev.fyloz.colorrecipesexplorer.model
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import dev.fyloz.colorrecipesexplorer.dtos.RecipeStepDto
import javax.persistence.*
@Entity
@ -16,31 +15,7 @@ data class RecipeStep(
val message: String
) : ModelEntity
// ==== DSL ====
fun recipeStep(
id: Long? = null,
position: Int = 0,
message: String = "message",
op: RecipeStep.() -> Unit = {}
) = RecipeStep(id, position, message).apply(op)
// ==== Exceptions ====
private const val RECIPE_STEP_NOT_FOUND_EXCEPTION_TITLE = "Recipe step not found"
private const val RECIPE_STEP_ALREADY_EXISTS_EXCEPTION_TITLE = "Recipe step already exists"
private const val RECIPE_STEP_EXCEPTION_ERROR_CODE = "recipestep"
fun recipeStepIdNotFoundException(id: Long) =
NotFoundException(
RECIPE_STEP_EXCEPTION_ERROR_CODE,
RECIPE_STEP_NOT_FOUND_EXCEPTION_TITLE,
"A recipe step with the id $id could not be found",
id
)
fun recipeStepIdAlreadyExistsException(id: Long) =
AlreadyExistsException(
RECIPE_STEP_EXCEPTION_ERROR_CODE,
RECIPE_STEP_ALREADY_EXISTS_EXCEPTION_TITLE,
"A recipe step with the id $id already exists",
id
)
@Deprecated("Temporary DSL for transition")
fun recipeStepDto(
entity: RecipeStep
) = RecipeStepDto(entity.id!!, entity.position, entity.message)

View File

@ -0,0 +1,18 @@
package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent
import dev.fyloz.colorrecipesexplorer.dtos.RecipeStepDto
import dev.fyloz.colorrecipesexplorer.model.RecipeStep
import dev.fyloz.colorrecipesexplorer.repository.RecipeStepRepository
interface RecipeStepService : Service<RecipeStepDto, RecipeStep, RecipeStepRepository>
@ServiceComponent
class DefaultRecipeStepService(repository: RecipeStepRepository) :
BaseService<RecipeStepDto, RecipeStep, RecipeStepRepository>(repository), RecipeStepService {
override fun toDto(entity: RecipeStep) =
RecipeStepDto(entity.id!!, entity.position, entity.message)
override fun toEntity(dto: RecipeStepDto) =
RecipeStep(dto.id, dto.position, dto.message)
}

View File

@ -0,0 +1,257 @@
package dev.fyloz.colorrecipesexplorer.logic
import dev.fyloz.colorrecipesexplorer.dtos.RecipeStepDto
import dev.fyloz.colorrecipesexplorer.model.RecipeGroupInformation
import dev.fyloz.colorrecipesexplorer.model.RecipeStep
import dev.fyloz.colorrecipesexplorer.model.account.Group
import dev.fyloz.colorrecipesexplorer.service.RecipeStepService
import io.mockk.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertDoesNotThrow
import org.junit.jupiter.api.assertThrows
import kotlin.test.assertTrue
class DefaultRecipeStepLogicTest {
private val recipeStepServiceMock = mockk<RecipeStepService>()
private val recipeStepLogic = spyk(DefaultRecipeStepLogic(recipeStepServiceMock))
@AfterEach
internal fun afterEach() {
clearAllMocks()
}
@Test
fun validateGroupInformationSteps_normalBehavior_callsValidateSteps() {
// Arrange
every { recipeStepLogic.validateSteps(any()) } just runs
val group = Group(1L, "Unit test group")
val steps = mutableSetOf(RecipeStep(1L, 1, "A message"))
val groupInfo = RecipeGroupInformation(1L, group, "A note", steps)
// Act
recipeStepLogic.validateGroupInformationSteps(groupInfo)
// Assert
verify {
recipeStepLogic.validateSteps(any()) // TODO replace with actual steps dtos when RecipeGroupInformation updated
}
}
@Test
fun validateGroupInformationSteps_stepSetIsNull_doesNothing() {
// Arrange
every { recipeStepLogic.validateSteps(any()) } just runs
val group = Group(1L, "Unit test group")
val groupInfo = RecipeGroupInformation(1L, group, "A note", null)
// Act
recipeStepLogic.validateGroupInformationSteps(groupInfo)
// Assert
verify(exactly = 0) {
recipeStepLogic.validateSteps(any()) // TODO replace with actual steps dtos when RecipeGroupInformation updated
}
}
@Test
fun validateGroupInformationSteps_invalidSteps_throwsInvalidGroupStepsPositionsException() {
// Arrange
val errors = setOf(InvalidStepsPositionsError("error", "An unit test error"))
every { recipeStepLogic.validateSteps(any()) } throws InvalidStepsPositionsException(errors)
val group = Group(1L, "Unit test group")
val steps = mutableSetOf(RecipeStep(1L, 1, "A message"))
val groupInfo = RecipeGroupInformation(1L, group, "A note", steps)
// Act
// Assert
assertThrows<InvalidGroupStepsPositionsException> { recipeStepLogic.validateGroupInformationSteps(groupInfo) }
}
@Test
fun validateSteps_normalBehavior_doesNothing() {
// Arrange
val recipeSteps = setOf(
RecipeStepDto(1L, 1, "A message"),
RecipeStepDto(2L, 2, "Another message")
)
// Act
// Assert
assertDoesNotThrow { recipeStepLogic.validateSteps(recipeSteps) }
}
@Test
fun validateSteps_emptyStepSet_doesNothing() {
// Arrange
val recipeSteps = setOf<RecipeStepDto>()
// Act
// Assert
assertDoesNotThrow { recipeStepLogic.validateSteps(recipeSteps) }
}
@Test
fun validateSteps_hasInvalidPositions_throwsInvalidStepsPositionsException() {
// Arrange
val recipeSteps = setOf(
RecipeStepDto(1L, 2, "A message"),
RecipeStepDto(2L, 3, "Another message")
)
// Act
// Assert
assertThrows<InvalidStepsPositionsException> { recipeStepLogic.validateSteps(recipeSteps) }
}
@Test
fun validateSteps_firstStepPositionInvalid_returnsInvalidStepValidationError() {
// Arrange
val recipeSteps = setOf(
RecipeStepDto(1L, 2, "A message"),
RecipeStepDto(2L, 3, "Another message")
)
// Act
val exception = assertThrows<InvalidStepsPositionsException> { recipeStepLogic.validateSteps(recipeSteps) }
// Assert
assertTrue {
exception.errors.any { it.type == RecipeStepDto.VALIDATION_ERROR_CODE_INVALID_FIRST_STEP }
}
}
@Test
fun validateSteps_duplicatedPositions_returnsInvalidStepValidationError() {
// Arrange
val recipeSteps = setOf(
RecipeStepDto(1L, 1, "A message"),
RecipeStepDto(2L, 1, "Another message")
)
// Act
val exception = assertThrows<InvalidStepsPositionsException> { recipeStepLogic.validateSteps(recipeSteps) }
// Assert
assertTrue {
exception.errors.any { it.type == RecipeStepDto.VALIDATION_ERROR_CODE_DUPLICATED_STEPS_POSITION }
}
}
@Test
fun validateSteps_gapsInPositions_returnsInvalidStepValidationError() {
// Arrange
val recipeSteps = setOf(
RecipeStepDto(1L, 1, "A message"),
RecipeStepDto(2L, 3, "Another message")
)
// Act
val exception = assertThrows<InvalidStepsPositionsException> { recipeStepLogic.validateSteps(recipeSteps) }
// Assert
assertTrue {
exception.errors.any { it.type == RecipeStepDto.VALIDATION_ERROR_CODE_GAP_BETWEEN_STEPS_POSITIONS }
}
}
}
//@TestInstance(TestInstance.Lifecycle.PER_CLASS)
//class RecipeStepLogicTest :
// AbstractModelServiceTest<RecipeStep, RecipeStepLogic, RecipeStepRepository>() {
// override val repository: RecipeStepRepository = mock()
// override val logic: RecipeStepLogic = spy(DefaultRecipeStepLogic(repository))
//
// 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 {
// logic.validateGroupInformationSteps(this)
//
// verify(logic).validateSteps(this.steps!!)
// }
// }
//
// @Test
// fun `validateGroupInformationSteps() throws InvalidGroupStepsPositionsException when validateSteps() throws an InvalidStepsPositionsException`() {
// withGroupInformation {
// doAnswer { throw InvalidStepsPositionsException(setOf()) }.whenever(logic).validateSteps(this.steps!!)
//
// assertThrows<InvalidGroupStepsPositionsException> {
// logic.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 = group(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> {
// logic.validateSteps(steps)
// }
//
// assertTrue { exception.errors.size == 1 }
// assertTrue { exception.errors.first().type == errorType }
// }
//}

View File

@ -1,109 +0,0 @@
package dev.fyloz.colorrecipesexplorer.logic
import com.nhaarman.mockitokotlin2.*
import dev.fyloz.colorrecipesexplorer.model.RecipeGroupInformation
import dev.fyloz.colorrecipesexplorer.model.RecipeStep
import dev.fyloz.colorrecipesexplorer.model.account.group
import dev.fyloz.colorrecipesexplorer.model.recipeGroupInformation
import dev.fyloz.colorrecipesexplorer.model.recipeStep
import dev.fyloz.colorrecipesexplorer.repository.RecipeStepRepository
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.assertThrows
import kotlin.test.assertTrue
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class RecipeStepLogicTest :
AbstractModelServiceTest<RecipeStep, RecipeStepLogic, RecipeStepRepository>() {
override val repository: RecipeStepRepository = mock()
override val logic: RecipeStepLogic = spy(DefaultRecipeStepLogic(repository))
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 {
logic.validateGroupInformationSteps(this)
verify(logic).validateSteps(this.steps!!)
}
}
@Test
fun `validateGroupInformationSteps() throws InvalidGroupStepsPositionsException when validateSteps() throws an InvalidStepsPositionsException`() {
withGroupInformation {
doAnswer { throw InvalidStepsPositionsException(setOf()) }.whenever(logic).validateSteps(this.steps!!)
assertThrows<InvalidGroupStepsPositionsException> {
logic.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 = group(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> {
logic.validateSteps(steps)
}
assertTrue { exception.errors.size == 1 }
assertTrue { exception.errors.first().type == errorType }
}
}