From 361b1b2ba35b0f6eaa3bc7239f8db6ad16793550 Mon Sep 17 00:00:00 2001 From: FyloZ Date: Wed, 28 Apr 2021 13:24:41 -0400 Subject: [PATCH] Ajustement de RecipeImageService pour utiliser FileService --- .../colorrecipesexplorer/model/Recipe.kt | 355 ++++++++++-------- .../rest/RecipeController.kt | 114 +++--- .../service/MaterialService.kt | 1 + .../service/RecipeService.kt | 81 ++-- .../service/MaterialServiceTest.kt | 1 - .../service/MixServiceTest.kt | 2 +- .../service/RecipeServiceTest.kt | 173 ++++----- .../service/files/FileServiceTest.kt | 43 +-- 8 files changed, 389 insertions(+), 381 deletions(-) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Recipe.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Recipe.kt index 8d32a87..dd933ac 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Recipe.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Recipe.kt @@ -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, + @OneToMany(cascade = [CascadeType.ALL], mappedBy = "recipe") + val mixes: MutableList, - @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true) - @JoinColumn(name = "recipe_id") - val groupsInformation: Set + @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true) + @JoinColumn(name = "recipe_id") + val groupsInformation: Set ) : Model { /** The mix types contained in this recipe. */ val mixTypes: Collection @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 { 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? + val steps: Set? ) : EntityDto +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, + val groupsInformation: Set, + val imagesUrls: Set +) + @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? + @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true) + @JoinColumn(name = "recipe_group_information_id") + var steps: MutableSet? ) 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 + @field:NotNull(message = RECIPE_STEPS_DTO_MESSAGES_NULL_MESSAGE) + val steps: Set ) 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?, + val notes: Set?, - val mixesLocation: Set? + val mixesLocation: Set? ) 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 = mutableListOf(), - groupsInformation: Set = 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 = mutableListOf(), + groupsInformation: Set = 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? = 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? = setOf(), + op: RecipeUpdateDto.() -> Unit = {} ) = RecipeUpdateDto(id, name, description, color, gloss, sample, approbationDate, remark, steps).apply(op) +fun recipeOutputDto( + recipe: Recipe, + imagesUrls: Set, + 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? = mutableSetOf(), - op: RecipeGroupInformation.() -> Unit = {} + id: Long? = null, + group: EmployeeGroup = employeeGroup(), + note: String? = null, + steps: MutableSet? = mutableSetOf(), + op: RecipeGroupInformation.() -> Unit = {} ) = RecipeGroupInformation(id, group, note, steps).apply(op) fun recipePublicDataDto( - recipeId: Long = 0L, - notes: Set? = null, - mixesLocation: Set? = null, - op: RecipePublicDataDto.() -> Unit = {} + recipeId: Long = 0L, + notes: Set? = null, + mixesLocation: Set? = 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 + ) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RecipeController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RecipeController.kt index a4f39fc..d372c7a 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RecipeController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RecipeController.kt @@ -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_CONTROLLER_PATH) { - recipeService.save(recipe) - } + created(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 { - 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 { + 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) = + 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_CONTROLLER_PATH) { - mixService.save(mix) - } + created(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) + } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt index 6facd49..f40ae75 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt @@ -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) } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt index e1313bb..a7f7f68 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt @@ -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 { @@ -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 - /** Gets the identifier of every images associated to the recipe with the given [recipeId]. */ - fun getAllIdsForRecipe(recipeId: Long): Collection + /** 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 { - 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 { + 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" } diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialServiceTest.kt index 0025064..774c611 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialServiceTest.kt @@ -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()) doReturn(entity).whenever(materialUpdateDto).toEntity() diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MixServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MixServiceTest.kt index 22a41b0..00d1c2c 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MixServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MixServiceTest.kt @@ -113,7 +113,7 @@ class MixServiceTest : AbstractExternalModelServiceTest { + 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 { 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) + } + } } } diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileServiceTest.kt index 972a845..d66f05f 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileServiceTest.kt @@ -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()) } 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()) } just Runs + })) + val mockFile = mockk { + 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 {