Ajustement de RecipeImageService pour utiliser FileService

This commit is contained in:
FyloZ 2021-04-28 13:24:41 -04:00
parent 0f649f983c
commit 361b1b2ba3
8 changed files with 389 additions and 381 deletions

View File

@ -25,230 +25,271 @@ private const val RECIPE_STEPS_DTO_MESSAGES_NULL_MESSAGE = "Des messages sont re
private const val NOTE_GROUP_ID_NULL_MESSAGE = "Un identifiant de groupe est requis"
const val RECIPE_IMAGES_DIRECTORY = "images/recipes"
@Entity
@Table(name = "recipe")
data class Recipe(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long?,
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long?,
/** The name of the recipe. It is not unique in the entire system, but is unique in the scope of a [Company]. */
val name: String,
/** The name of the recipe. It is not unique in the entire system, but is unique in the scope of a [Company]. */
val name: String,
val description: String,
val description: String,
/** The color produced by the recipe. The string should be formatted as a hexadecimal color without the sharp (#). */
val color: String,
/** The color produced by the recipe. The string should be formatted as a hexadecimal color without the sharp (#). */
val color: String,
/** The gloss of the color in percents. (0-100) */
val gloss: Byte,
/** The gloss of the color in percents. (0-100) */
val gloss: Byte,
val sample: Int?,
val sample: Int?,
@Column(name = "approbation_date")
val approbationDate: LocalDate?,
@Column(name = "approbation_date")
val approbationDate: LocalDate?,
/** A remark given by the creator of the recipe. */
val remark: String,
/** A remark given by the creator of the recipe. */
val remark: String,
@ManyToOne
@JoinColumn(name = "company_id")
val company: Company,
@ManyToOne
@JoinColumn(name = "company_id")
val company: Company,
@OneToMany(cascade = [CascadeType.ALL], mappedBy = "recipe")
val mixes: MutableList<Mix>,
@OneToMany(cascade = [CascadeType.ALL], mappedBy = "recipe")
val mixes: MutableList<Mix>,
@OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true)
@JoinColumn(name = "recipe_id")
val groupsInformation: Set<RecipeGroupInformation>
@OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true)
@JoinColumn(name = "recipe_id")
val groupsInformation: Set<RecipeGroupInformation>
) : Model {
/** The mix types contained in this recipe. */
val mixTypes: Collection<MixType>
@JsonIgnore
get() = mixes.map { it.mixType }
val imagesDirectoryPath
@JsonIgnore
@Transient
get() = "$RECIPE_IMAGES_DIRECTORY/$id"
fun groupInformationForGroup(groupId: Long) =
groupsInformation.firstOrNull { it.group.id == groupId }
groupsInformation.firstOrNull { it.group.id == groupId }
}
open class RecipeSaveDto(
@field:NotBlank(message = RECIPE_NAME_NULL_MESSAGE)
val name: String,
@field:NotBlank(message = RECIPE_NAME_NULL_MESSAGE)
val name: String,
@field:NotBlank(message = RECIPE_DESCRIPTION_NULL_MESSAGE)
val description: String,
@field:NotBlank(message = RECIPE_DESCRIPTION_NULL_MESSAGE)
val description: String,
@field:NotBlank(message = RECIPE_COLOR_NULL_MESSAGE)
@field:Pattern(regexp = "^#([0-9a-f]{6})$")
val color: String,
@field:NotBlank(message = RECIPE_COLOR_NULL_MESSAGE)
@field:Pattern(regexp = "^#([0-9a-f]{6})$")
val color: String,
@field:NotNull(message = RECIPE_GLOSS_NULL_MESSAGE)
@field:Min(value = 0, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE)
@field:Max(value = 100, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE)
val gloss: Byte,
@field:NotNull(message = RECIPE_GLOSS_NULL_MESSAGE)
@field:Min(value = 0, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE)
@field:Max(value = 100, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE)
val gloss: Byte,
@field:Min(value = 0, message = RECIPE_SAMPLE_TOO_SMALL_MESSAGE)
val sample: Int?,
@field:Min(value = 0, message = RECIPE_SAMPLE_TOO_SMALL_MESSAGE)
val sample: Int?,
val approbationDate: LocalDate?,
val approbationDate: LocalDate?,
val remark: String?,
val remark: String?,
@field:Min(value = 0, message = RECIPE_COMPANY_NULL_MESSAGE)
val companyId: Long = -1L,
@field:Min(value = 0, message = RECIPE_COMPANY_NULL_MESSAGE)
val companyId: Long = -1L,
) : EntityDto<Recipe> {
override fun toEntity(): Recipe = recipe(
name = name,
description = description,
sample = sample,
approbationDate = approbationDate,
remark = remark ?: "",
company = company(id = companyId)
name = name,
description = description,
sample = sample,
approbationDate = approbationDate,
remark = remark ?: "",
company = company(id = companyId)
)
}
open class RecipeUpdateDto(
@field:NotNull(message = RECIPE_ID_NULL_MESSAGE)
val id: Long,
@field:NotNull(message = RECIPE_ID_NULL_MESSAGE)
val id: Long,
@field:NullOrNotBlank(message = RECIPE_NAME_NULL_MESSAGE)
val name: String?,
@field:NullOrNotBlank(message = RECIPE_NAME_NULL_MESSAGE)
val name: String?,
@field:NullOrNotBlank(message = RECIPE_DESCRIPTION_NULL_MESSAGE)
val description: String?,
@field:NullOrNotBlank(message = RECIPE_DESCRIPTION_NULL_MESSAGE)
val description: String?,
@field:NullOrNotBlank(message = RECIPE_COLOR_NULL_MESSAGE)
@field:Pattern(regexp = "^#([0-9a-f]{6})$")
val color: String?,
@field:NullOrNotBlank(message = RECIPE_COLOR_NULL_MESSAGE)
@field:Pattern(regexp = "^#([0-9a-f]{6})$")
val color: String?,
@field:Min(value = 0, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE)
@field:Max(value = 100, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE)
val gloss: Byte?,
@field:Min(value = 0, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE)
@field:Max(value = 100, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE)
val gloss: Byte?,
@field:NullOrSize(min = 0, message = RECIPE_SAMPLE_TOO_SMALL_MESSAGE)
val sample: Int?,
@field:NullOrSize(min = 0, message = RECIPE_SAMPLE_TOO_SMALL_MESSAGE)
val sample: Int?,
val approbationDate: LocalDate?,
val approbationDate: LocalDate?,
val remark: String?,
val remark: String?,
val steps: Set<RecipeStepsDto>?
val steps: Set<RecipeStepsDto>?
) : EntityDto<Recipe>
data class RecipeOutputDto(
val id: Long,
val name: String,
val description: String,
val color: String,
val gloss: Byte,
val sample: Int?,
val approbationDate: LocalDate?,
val remark: String?,
val company: Company,
val mixes: Set<Mix>,
val groupsInformation: Set<RecipeGroupInformation>,
val imagesUrls: Set<String>
)
@Entity
@Table(name = "recipe_group_information")
data class RecipeGroupInformation(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long?,
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long?,
@ManyToOne
@JoinColumn(name = "group_id")
val group: EmployeeGroup,
@ManyToOne
@JoinColumn(name = "group_id")
val group: EmployeeGroup,
var note: String?,
var note: String?,
@OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true)
@JoinColumn(name = "recipe_group_information_id")
var steps: MutableSet<RecipeStep>?
@OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true)
@JoinColumn(name = "recipe_group_information_id")
var steps: MutableSet<RecipeStep>?
)
data class RecipeStepsDto(
@field:NotNull(message = RECIPE_STEPS_DTO_GROUP_ID_NULL_MESSAGE)
val groupId: Long,
@field:NotNull(message = RECIPE_STEPS_DTO_GROUP_ID_NULL_MESSAGE)
val groupId: Long,
@field:NotNull(message = RECIPE_STEPS_DTO_MESSAGES_NULL_MESSAGE)
val steps: Set<RecipeStep>
@field:NotNull(message = RECIPE_STEPS_DTO_MESSAGES_NULL_MESSAGE)
val steps: Set<RecipeStep>
)
data class RecipePublicDataDto(
@field:NotNull(message = RECIPE_ID_NULL_MESSAGE)
val recipeId: Long,
@field:NotNull(message = RECIPE_ID_NULL_MESSAGE)
val recipeId: Long,
val notes: Set<NoteDto>?,
val notes: Set<NoteDto>?,
val mixesLocation: Set<MixLocationDto>?
val mixesLocation: Set<MixLocationDto>?
)
data class NoteDto(
@field:NotNull(message = NOTE_GROUP_ID_NULL_MESSAGE)
val groupId: Long,
@field:NotNull(message = NOTE_GROUP_ID_NULL_MESSAGE)
val groupId: Long,
val content: String?
val content: String?
)
// ==== DSL ====
fun recipe(
id: Long? = null,
name: String = "name",
description: String = "description",
color: String = "ffffff",
gloss: Byte = 0,
sample: Int? = -1,
approbationDate: LocalDate? = LocalDate.MIN,
remark: String = "remark",
company: Company = company(),
mixes: MutableList<Mix> = mutableListOf(),
groupsInformation: Set<RecipeGroupInformation> = setOf(),
op: Recipe.() -> Unit = {}
id: Long? = null,
name: String = "name",
description: String = "description",
color: String = "ffffff",
gloss: Byte = 0,
sample: Int? = -1,
approbationDate: LocalDate? = LocalDate.MIN,
remark: String = "remark",
company: Company = company(),
mixes: MutableList<Mix> = mutableListOf(),
groupsInformation: Set<RecipeGroupInformation> = setOf(),
op: Recipe.() -> Unit = {}
) = Recipe(
id,
name,
description,
color,
gloss,
sample,
approbationDate,
remark,
company,
mixes,
groupsInformation
id,
name,
description,
color,
gloss,
sample,
approbationDate,
remark,
company,
mixes,
groupsInformation
).apply(op)
fun recipeSaveDto(
name: String = "name",
description: String = "description",
color: String = "ffffff",
gloss: Byte = 0,
sample: Int? = -1,
approbationDate: LocalDate? = LocalDate.MIN,
remark: String = "remark",
companyId: Long = 0L,
op: RecipeSaveDto.() -> Unit = {}
name: String = "name",
description: String = "description",
color: String = "ffffff",
gloss: Byte = 0,
sample: Int? = -1,
approbationDate: LocalDate? = LocalDate.MIN,
remark: String = "remark",
companyId: Long = 0L,
op: RecipeSaveDto.() -> Unit = {}
) = RecipeSaveDto(name, description, color, gloss, sample, approbationDate, remark, companyId).apply(op)
fun recipeUpdateDto(
id: Long = 0L,
name: String? = "name",
description: String? = "description",
color: String? = "ffffff",
gloss: Byte? = 0,
sample: Int? = -1,
approbationDate: LocalDate? = LocalDate.MIN,
remark: String? = "remark",
steps: Set<RecipeStepsDto>? = setOf(),
op: RecipeUpdateDto.() -> Unit = {}
id: Long = 0L,
name: String? = "name",
description: String? = "description",
color: String? = "ffffff",
gloss: Byte? = 0,
sample: Int? = -1,
approbationDate: LocalDate? = LocalDate.MIN,
remark: String? = "remark",
steps: Set<RecipeStepsDto>? = setOf(),
op: RecipeUpdateDto.() -> Unit = {}
) = RecipeUpdateDto(id, name, description, color, gloss, sample, approbationDate, remark, steps).apply(op)
fun recipeOutputDto(
recipe: Recipe,
imagesUrls: Set<String>,
op: RecipeOutputDto.() -> Unit = {}
) = RecipeOutputDto(
recipe.id!!,
recipe.name,
recipe.description,
recipe.color,
recipe.gloss,
recipe.sample,
recipe.approbationDate,
recipe.remark,
recipe.company,
recipe.mixes.toSet(),
recipe.groupsInformation,
imagesUrls
).apply(op)
fun recipeGroupInformation(
id: Long? = null,
group: EmployeeGroup = employeeGroup(),
note: String? = null,
steps: MutableSet<RecipeStep>? = mutableSetOf(),
op: RecipeGroupInformation.() -> Unit = {}
id: Long? = null,
group: EmployeeGroup = employeeGroup(),
note: String? = null,
steps: MutableSet<RecipeStep>? = mutableSetOf(),
op: RecipeGroupInformation.() -> Unit = {}
) = RecipeGroupInformation(id, group, note, steps).apply(op)
fun recipePublicDataDto(
recipeId: Long = 0L,
notes: Set<NoteDto>? = null,
mixesLocation: Set<MixLocationDto>? = null,
op: RecipePublicDataDto.() -> Unit = {}
recipeId: Long = 0L,
notes: Set<NoteDto>? = null,
mixesLocation: Set<MixLocationDto>? = null,
op: RecipePublicDataDto.() -> Unit = {}
) = RecipePublicDataDto(recipeId, notes, mixesLocation).apply(op)
fun noteDto(
groupId: Long = 0L,
content: String? = "note",
op: NoteDto.() -> Unit = {}
groupId: Long = 0L,
content: String? = "note",
op: NoteDto.() -> Unit = {}
) = NoteDto(groupId, content).apply(op)
// ==== Exceptions ====
@ -256,30 +297,18 @@ private const val RECIPE_NOT_FOUND_EXCEPTION_TITLE = "Recipe not found"
private const val RECIPE_ALREADY_EXISTS_EXCEPTION_TITLE = "Recipe already exists"
private const val RECIPE_EXCEPTION_ERROR_CODE = "recipe"
class RecipeImageNotFoundException(id: Long, recipe: Recipe) :
RestException(
"notfound-recipeimage-id",
"Recipe image not found",
HttpStatus.NOT_FOUND,
"A recipe image with the id $id could no be found for the recipe ${recipe.name}",
mapOf(
"id" to id,
"recipe" to recipe.name
)
)
fun recipeIdNotFoundException(id: Long) =
NotFoundException(
RECIPE_EXCEPTION_ERROR_CODE,
RECIPE_NOT_FOUND_EXCEPTION_TITLE,
"A recipe with the id $id could not be found",
id
)
NotFoundException(
RECIPE_EXCEPTION_ERROR_CODE,
RECIPE_NOT_FOUND_EXCEPTION_TITLE,
"A recipe with the id $id could not be found",
id
)
fun recipeIdAlreadyExistsException(id: Long) =
AlreadyExistsException(
RECIPE_EXCEPTION_ERROR_CODE,
RECIPE_ALREADY_EXISTS_EXCEPTION_TITLE,
"A recipe with the id $id already exists",
id
)
AlreadyExistsException(
RECIPE_EXCEPTION_ERROR_CODE,
RECIPE_ALREADY_EXISTS_EXCEPTION_TITLE,
"A recipe with the id $id already exists",
id
)

View File

@ -3,7 +3,9 @@ package dev.fyloz.colorrecipesexplorer.rest
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeEditRecipes
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeRemoveRecipes
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewRecipes
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.rest.files.FILE_CONTROLLER_PATH
import dev.fyloz.colorrecipesexplorer.service.MixService
import dev.fyloz.colorrecipesexplorer.service.RecipeImageService
import dev.fyloz.colorrecipesexplorer.service.RecipeService
@ -12,7 +14,8 @@ import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import java.net.URI
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import javax.validation.Valid
@ -22,69 +25,82 @@ private const val MIX_CONTROLLER_PATH = "api/recipe/mix"
@RestController
@RequestMapping(RECIPE_CONTROLLER_PATH)
@PreAuthorizeViewRecipes
class RecipeController(private val recipeService: RecipeService) {
class RecipeController(
private val recipeService: RecipeService,
private val recipeImageService: RecipeImageService,
private val creProperties: CreProperties
) {
@GetMapping
fun getAll() =
ok(recipeService.getAll())
ok(recipeService.getAll())
@GetMapping("{id}")
fun getById(@PathVariable id: Long) =
ok(recipeService.getById(id))
ok(recipeService.getById(id))
@PostMapping
@PreAuthorizeEditRecipes
fun save(@Valid @RequestBody recipe: RecipeSaveDto) =
created<Recipe>(RECIPE_CONTROLLER_PATH) {
recipeService.save(recipe)
}
created<Recipe>(RECIPE_CONTROLLER_PATH) {
recipeService.save(recipe)
}
@PutMapping
@PreAuthorizeEditRecipes
fun update(@Valid @RequestBody recipe: RecipeUpdateDto) =
noContent {
recipeService.update(recipe)
}
noContent {
recipeService.update(recipe)
}
@PutMapping("public")
@PreAuthorize("hasAuthority('EDIT_RECIPES_PUBLIC_DATA')")
fun updatePublicData(@Valid @RequestBody publicDataDto: RecipePublicDataDto) =
noContent {
recipeService.updatePublicData(publicDataDto)
}
noContent {
recipeService.updatePublicData(publicDataDto)
}
@DeleteMapping("{id}")
@PreAuthorizeRemoveRecipes
fun deleteById(@PathVariable id: Long) =
noContent {
recipeService.deleteById(id)
}
}
noContent {
recipeService.deleteById(id)
}
@RestController
@RequestMapping(RECIPE_CONTROLLER_PATH)
@PreAuthorizeViewRecipes
class RecipeImageController(val recipeImageService: RecipeImageService) {
@GetMapping("{recipeId}/image")
fun getAllIdsForRecipe(@PathVariable recipeId: Long) =
ok(recipeImageService.getAllIdsForRecipe(recipeId))
@GetMapping("{recipeId}/image/{id}", produces = [MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE])
fun getById(@PathVariable recipeId: Long, @PathVariable id: Long) =
ok(recipeImageService.getByIdForRecipe(id, recipeId))
@PostMapping("{recipeId}/image", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
@PutMapping("{recipeId}/image", consumes = [MediaType.APPLICATION_OCTET_STREAM_VALUE])
@PreAuthorizeEditRecipes
fun save(@PathVariable recipeId: Long, image: MultipartFile): ResponseEntity<Void> {
val id = recipeImageService.save(image, recipeId)
return ResponseEntity.created(URI.create("/$RECIPE_CONTROLLER_PATH/$recipeId/image/$id")).build()
fun downloadImage(@PathVariable recipeId: Long, image: MultipartFile): ResponseEntity<RecipeOutputDto> {
recipeImageService.download(image, recipeService.getById(recipeId))
return getById(recipeId)
}
@DeleteMapping("{recipeId}/image/{id}")
@PreAuthorizeRemoveRecipes
fun delete(@PathVariable recipeId: Long, @PathVariable id: Long) =
noContent {
recipeImageService.delete(id, recipeId)
}
@DeleteMapping("{recipeId}/image/{name}")
@PreAuthorizeEditRecipes
fun deleteImage(@PathVariable recipeId: Long, @PathVariable name: String) =
noContent {
recipeImageService.delete(recipeService.getById(recipeId), name)
}
private fun ok(recipe: Recipe) =
ok(recipe.toOutput())
private fun ok(recipes: Collection<Recipe>) =
ok(recipes.map { it.toOutput() })
private fun Recipe.toOutput() =
recipeOutputDto(
this,
recipeImageService.getAllImages(this)
.map { this.imageUrl(it) }
.toSet()
)
private fun Recipe.imageUrl(name: String) =
"${creProperties.deploymentUrl}$FILE_CONTROLLER_PATH?path=${
URLEncoder.encode(
"${this.imagesDirectoryPath}/$name",
StandardCharsets.UTF_8
)
}"
}
@RestController
@ -93,26 +109,26 @@ class RecipeImageController(val recipeImageService: RecipeImageService) {
class MixController(private val mixService: MixService) {
@GetMapping("{id}")
fun getById(@PathVariable id: Long) =
ok(mixService.getById(id))
ok(mixService.getById(id))
@PostMapping
@PreAuthorizeEditRecipes
fun save(@Valid @RequestBody mix: MixSaveDto) =
created<Mix>(MIX_CONTROLLER_PATH) {
mixService.save(mix)
}
created<Mix>(MIX_CONTROLLER_PATH) {
mixService.save(mix)
}
@PutMapping
@PreAuthorizeEditRecipes
fun update(@Valid @RequestBody mix: MixUpdateDto) =
noContent {
mixService.update(mix)
}
noContent {
mixService.update(mix)
}
@DeleteMapping("{id}")
@PreAuthorizeRemoveRecipes
fun deleteById(@PathVariable id: Long) =
noContent {
mixService.deleteById(id)
}
noContent {
mixService.deleteById(id)
}
}

View File

@ -115,6 +115,7 @@ class MaterialServiceImpl(
override fun delete(entity: Material) {
if (!repository.canBeDeleted(entity.id!!)) throw cannotDeleteMaterialException(entity)
fileService.delete(entity.simdutFilePath)
super.delete(entity)
}
}

View File

@ -9,7 +9,6 @@ import org.springframework.context.annotation.Lazy
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
import java.io.File
import java.nio.file.NoSuchFileException
import javax.transaction.Transactional
interface RecipeService : ExternalModelService<Recipe, RecipeSaveDto, RecipeUpdateDto, RecipeRepository> {
@ -139,63 +138,69 @@ class RecipeServiceImpl(
update(mix.recipe.apply { mixes.remove(mix) })
}
const val RECIPE_IMAGES_DIRECTORY = "images/recipe"
interface RecipeImageService {
// TOOD change return type to ByteArrayResource
fun getByIdForRecipe(id: Long, recipeId: Long): ByteArray
/** Gets the name of every images associated to the recipe with the given [recipe]. */
fun getAllImages(recipe: Recipe): Set<String>
/** Gets the identifier of every images associated to the recipe with the given [recipeId]. */
fun getAllIdsForRecipe(recipeId: Long): Collection<Long>
/** Saves the given [image] and associate it to the recipe with the given [recipe]. Returns the name of the saved image. */
fun download(image: MultipartFile, recipe: Recipe): String
/** Saves the given [image] and associate it to the recipe with the given [recipeId]. Returns the identifier of the saved image. */
fun save(image: MultipartFile, recipeId: Long): Long
/** Deletes the image with the given [name] for the given [recipe]. */
fun delete(recipe: Recipe, name: String)
/** Deletes the image with the given [recipeId] and [id]. */
fun delete(id: Long, recipeId: Long)
/** Gets the directory containing all images of the given [Recipe]. */
fun Recipe.getDirectory(): File
}
@Service
class RecipeImageServiceImpl(val recipeService: RecipeService, val fileService: FileService) : RecipeImageService {
override fun getByIdForRecipe(id: Long, recipeId: Long): ByteArray =
try {
fileService.read(getPath(id, recipeId)).byteArray
} catch (ex: NoSuchFileException) {
throw RecipeImageNotFoundException(id, recipeService.getById(recipeId))
}
const val RECIPE_IMAGE_ID_DELIMITER = "_"
const val RECIPE_IMAGE_EXTENSION = ".jpg"
override fun getAllIdsForRecipe(recipeId: Long): Collection<Long> {
val recipe = recipeService.getById(recipeId)
val recipeDirectory = getRecipeDirectory(recipe.id!!)
@Service
class RecipeImageServiceImpl(
val recipeService: RecipeService,
val fileService: FileService
) : RecipeImageService {
override fun getAllImages(recipe: Recipe): Set<String> {
val recipeDirectory = recipe.getDirectory()
if (!recipeDirectory.exists() || !recipeDirectory.isDirectory) {
return listOf()
return setOf()
}
return recipeDirectory.listFiles()!! // Should never be null because we check if recipeDirectory is a directory and exists before
return recipeDirectory.listFiles()!! // Should never be null because we check if recipeDirectory exists and is a directory before
.filterNotNull()
.map { it.name.toLong() }
.map { it.name }
.toSet()
}
override fun save(image: MultipartFile, recipeId: Long): Long {
/** Gets the next id available for a new image for the recipe with the given [recipeId]. */
override fun download(image: MultipartFile, recipe: Recipe): String {
/** Gets the next id available for a new image for the given [recipe]. */
fun getNextAvailableId(): Long =
with(getAllIdsForRecipe(recipeId)) {
with(getAllImages(recipe)) {
if (isEmpty())
0
else
maxOrNull()!! + 1L // maxOrNull() cannot return null because existingIds cannot be empty at this point
maxOf {
it.split(RECIPE_IMAGE_ID_DELIMITER)
.last()
.replace(RECIPE_IMAGE_EXTENSION, "")
.toLong()
} + 1L
}
val nextAvailableId = getNextAvailableId()
fileService.write(image, getPath(nextAvailableId, recipeId), true)
return nextAvailableId
return getImageFileName(recipe, getNextAvailableId()).apply {
fileService.write(image, getImagePath(recipe, this), true)
}
}
override fun delete(id: Long, recipeId: Long) =
fileService.delete(getPath(id, recipeId))
override fun delete(recipe: Recipe, name: String) =
fileService.delete(getImagePath(recipe, name))
/** Gets the images directory of the recipe with the given [recipeId]. */
fun getRecipeDirectory(recipeId: Long) = File("$RECIPE_IMAGES_DIRECTORY/$recipeId")
override fun Recipe.getDirectory(): File = File(with(fileService) {
this@getDirectory.imagesDirectoryPath.fullPath().path
})
/** Gets the file of the image with the given [recipeId] and [id]. */
fun getPath(id: Long, recipeId: Long): String = "$RECIPE_IMAGES_DIRECTORY/$recipeId/$id"
fun getImageFileName(recipe: Recipe, id: Long) =
"${recipe.name}$RECIPE_IMAGE_ID_DELIMITER$id"
fun getImagePath(recipe: Recipe, name: String) =
"${recipe.imagesDirectoryPath}/$name$RECIPE_IMAGE_EXTENSION"
}

View File

@ -141,7 +141,6 @@ class MaterialServiceTest :
val mockSimdutFile = MockMultipartFile("simdut", byteArrayOf(1, 2, 3, 4, 5))
val materialUpdateDto = spy(materialUpdateDto(id = 0L, simdutFile = mockSimdutFile))
// doReturn(entity).whenever(service).getById(materialUpdateDto.id)
doReturn(entity).whenever(service).getById(any())
doReturn(entity).whenever(service).update(any<Material>())
doReturn(entity).whenever(materialUpdateDto).toEntity()

View File

@ -113,7 +113,7 @@ class MixServiceTest : AbstractExternalModelServiceTest<Mix, MixSaveDto, MixUpda
override fun `update(dto) calls and returns update() with the created entity`() {
val mixUpdateDto = spy(mixUpdateDto(id = 0L, name = null, materialTypeId = null))
doReturn(entity).whenever(service).getById(mixUpdateDto.id)
doReturn(entity).whenever(service).getById(any())
doReturn(entity).whenever(service).update(entity)
val found = service.update(mixUpdateDto)

View File

@ -4,13 +4,11 @@ import com.nhaarman.mockitokotlin2.*
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository
import dev.fyloz.colorrecipesexplorer.service.files.FileService
import io.mockk.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.springframework.core.io.ByteArrayResource
import org.springframework.mock.web.MockMultipartFile
import java.io.File
import java.nio.file.NoSuchFileException
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@ -165,129 +163,100 @@ class RecipeServiceTest :
}
}
private class RecipeImageServiceTestContext {
val fileService = mockk<FileService> {
every { write(any(), any(), any()) } just Runs
every { delete(any()) } just Runs
}
val recipeImageService = spyk(RecipeImageServiceImpl(mockk(), fileService))
val recipe = spyk(recipe())
val recipeImagesIds = setOf(1L, 10L, 21L)
val recipeImagesNames = recipeImagesIds.map { it.imageName }.toSet()
val recipeImagesFiles = recipeImagesNames.map { File(it) }.toTypedArray()
val recipeDirectory = spyk(File(recipe.imagesDirectoryPath)) {
every { exists() } returns true
every { isDirectory } returns true
every { listFiles() } returns recipeImagesFiles
}
init {
with(recipeImageService) {
every { recipe.getDirectory() } returns recipeDirectory
}
}
val Long.imageName
get() = "${recipe.name}$RECIPE_IMAGE_ID_DELIMITER$this"
val String.imagePath
get() = "${recipe.imagesDirectoryPath}/$this$RECIPE_IMAGE_EXTENSION"
}
class RecipeImageServiceTest {
private val recipeService: RecipeService = mock()
private val fileService: FileService = mock()
private val service = spy(RecipeImageServiceImpl(recipeService, fileService))
private val recipeId = 1L
private val imageId = 5L
private val imagePath = "$RECIPE_IMAGES_DIRECTORY/$recipeId/$imageId"
private val recipe = recipe(id = recipeId)
private val recipeDirectory: File = mock()
private val imagesIds = listOf(1L, 3L, 10L, 21L)
private val imageData = byteArrayOf(64, 32, 16, 8, 4, 2, 1)
private val image = MockMultipartFile("$imageId", imageData)
@AfterEach
internal fun tearDown() {
reset(recipeService, fileService, service, recipeDirectory)
internal fun afterEach() {
clearAllMocks()
}
// getByIdForRecipe()
private fun test(test: RecipeImageServiceTestContext.() -> Unit) {
RecipeImageServiceTestContext().test()
}
// getAllImages()
@Test
fun `getByIdForRecipe() returns data for the given recipe and image id red by the file service`() {
whenever(fileService.read(imagePath)).doReturn(ByteArrayResource(imageData))
fun `getAllImages() returns a Set containing the name of every files in the recipe's directory`() {
test {
val foundImagesNames = recipeImageService.getAllImages(recipe)
val found = service.getByIdForRecipe(imageId, recipeId)
assertEquals(imageData, found)
assertEquals(recipeImagesNames, foundImagesNames)
}
}
@Test
fun `getByIdForRecipe() throws RecipeImageNotFoundException when no image with the given recipe and image id exists`() {
doReturn(imagePath).whenever(service).getPath(imageId, recipeId)
whenever(recipeService.getById(recipeId)).doReturn(recipe)
whenever(fileService.read(imagePath)).doAnswer { throw NoSuchFileException(imagePath) }
fun `getAllImages() returns an empty Set when the recipe's directory does not exists`() {
test {
every { recipeDirectory.exists() } returns false
assertThrows<RecipeImageNotFoundException> { service.getByIdForRecipe(imageId, recipeId) }
assertTrue {
recipeImageService.getAllImages(recipe).isEmpty()
}
}
}
// getAllIdsForRecipe()
// download()
@Test
fun `getAllIdsForRecipe() returns a list containing all image's identifier of the images of the given recipe`() {
val expectedFiles = imagesIds.map { File(it.toString()) }.toTypedArray()
fun `download() writes the given image to the FileService and returns its name`() {
test {
val mockImage = MockMultipartFile("image.jpg", byteArrayOf(*"Random data".encodeToByteArray()))
val expectedImageId = recipeImagesIds.maxOrNull()!! + 1L
val expectedImageName = expectedImageId.imageName
val expectedImagePath = expectedImageName.imagePath
whenever(recipeService.getById(recipeId)).doReturn(recipe)
whenever(recipeDirectory.exists()).doReturn(true)
whenever(recipeDirectory.isDirectory).doReturn(true)
whenever(recipeDirectory.listFiles()).doReturn(expectedFiles)
doReturn(recipeDirectory).whenever(service).getRecipeDirectory(recipeId)
val foundImageName = recipeImageService.download(mockImage, recipe)
val found = service.getAllIdsForRecipe(recipeId)
assertEquals(expectedImageName, foundImageName)
assertEquals(imagesIds, found)
}
@Test
fun `getAllIdsForRecipe() returns an empty list when the given recipe's directory does not exists`() {
whenever(recipeService.getById(recipeId)).doReturn(recipe)
whenever(recipeDirectory.exists()).doReturn(false)
whenever(recipeDirectory.isDirectory).doReturn(true)
doReturn(recipeDirectory).whenever(service).getRecipeDirectory(recipeId)
val found = service.getAllIdsForRecipe(recipeId)
assertTrue(found.isEmpty())
}
@Test
fun `getAllIdsForRecipe() returns an empty list when the given recipe's directory is not a directory`() {
whenever(recipeService.getById(recipeId)).doReturn(recipe)
whenever(recipeDirectory.exists()).doReturn(true)
whenever(recipeDirectory.isDirectory).doReturn(false)
doReturn(recipeDirectory).whenever(service).getRecipeDirectory(recipeId)
val found = service.getAllIdsForRecipe(recipeId)
assertTrue(found.isEmpty())
}
// save()
@Test
fun `save() writes the given image to the file service with the expected path`() {
val expectedNextAvailableId = imagesIds.maxOrNull()!! + 1
val imagePath = "$RECIPE_IMAGES_DIRECTORY/$recipeId/$expectedNextAvailableId"
doReturn(imagesIds).whenever(service).getAllIdsForRecipe(recipeId)
doReturn(imagePath).whenever(service).getPath(expectedNextAvailableId, recipeId)
service.save(image, recipeId)
verify(fileService).write(image, imagePath, true)
verify {
fileService.write(mockImage, expectedImagePath, true)
}
}
}
// delete()
@Test
fun `delete() deletes the image with the given recipe and image id from the file service`() {
doReturn(imagePath).whenever(service).getPath(imageId, recipeId)
fun `delete() deletes the image with the given name in the FileService`() {
test {
val imageName = recipeImagesIds.first().imageName
val imagePath = imageName.imagePath
service.delete(imageId, recipeId)
recipeImageService.delete(recipe, imageName)
verify(fileService).delete(imagePath)
}
// getRecipeDirectory()
@Test
fun `getRecipeDirectory() returns a file with the expected path`() {
val recipeDirectoryPath = "$RECIPE_IMAGES_DIRECTORY/$recipeId"
val found = service.getRecipeDirectory(recipeId)
assertEquals(recipeDirectoryPath, found.path)
}
// getPath()
@Test
fun `getPath() returns the expected path`() {
val found = service.getPath(imageId, recipeId)
assertEquals(imagePath, found)
verify {
fileService.delete(imagePath)
}
}
}
}

View File

@ -6,7 +6,6 @@ import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.springframework.mock.web.MockMultipartFile
import org.springframework.web.multipart.MultipartFile
import java.io.File
import java.io.IOException
import java.nio.file.Path
@ -23,33 +22,23 @@ private val mockFilePathPath = Path.of(mockFilePath)
private val mockFileData = byteArrayOf(0x1, 0x8, 0xa, 0xf)
private class FileServiceTestContext {
val fileService: FileService
val mockFile: File
val mockFileFullPath: FilePath
val mockMultipartFile: MultipartFile
init {
fileService = spyk(FileServiceImpl(creProperties, mockk {
every { error(any(), any<Exception>()) } just Runs
}))
mockFile = mockk {
every { path } returns mockFilePath
every { exists() } returns true
every { isFile } returns true
every { toPath() } returns mockFilePathPath
}
mockFileFullPath = spyk(FilePath("${creProperties.workingDirectory}/$mockFilePath")) {
every { file } returns mockFile
with(fileService) {
every { mockFilePath.fullPath() } returns this@spyk
}
}
mockMultipartFile = spyk(MockMultipartFile(mockFilePath, mockFileData))
val fileService = spyk(FileServiceImpl(creProperties, mockk {
every { error(any(), any<Exception>()) } just Runs
}))
val mockFile = mockk<File> {
every { path } returns mockFilePath
every { exists() } returns true
every { isFile } returns true
every { toPath() } returns mockFilePathPath
}
val mockFileFullPath = spyk(FilePath("${creProperties.workingDirectory}/$mockFilePath")) {
every { file } returns mockFile
with(fileService) {
every { mockFilePath.fullPath() } returns this@spyk
}
}
val mockMultipartFile = spyk(MockMultipartFile(mockFilePath, mockFileData))
}
class FileServiceTest {