From c6b3367cfa6f34c59d413e0f5afe63ce072dfe0f Mon Sep 17 00:00:00 2001 From: FyloZ Date: Mon, 26 Apr 2021 23:30:46 -0400 Subject: [PATCH 1/6] =?UTF-8?q?Ajout=20d'un=20API=20d=C3=A9di=C3=A9=20aux?= =?UTF-8?q?=20fichiers.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajout de la bibliothèque MockK pour simplifier le mocking dans Kotlin. --- build.gradle.kts | 3 +- .../config/properties/CreProperties.kt | 1 + .../model/EmployeePermission.kt | 22 +- .../rest/files/FileController.kt | 55 +++++ .../service/RecipeService.kt | 97 ++++---- .../service/files/FileService.kt | 219 +++++++++++++----- .../service/files/SimdutService.kt | 32 +-- src/main/resources/application.properties | 1 + .../service/RecipeServiceTest.kt | 40 ++-- .../service/files/SimdutServiceTest.kt | 31 +-- 10 files changed, 321 insertions(+), 180 deletions(-) create mode 100644 src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/files/FileController.kt diff --git a/build.gradle.kts b/build.gradle.kts index cfa2953..5432c74 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -40,9 +40,10 @@ dependencies { implementation("org.springframework.boot:spring-boot-devtools:2.3.4.RELEASE") testImplementation("org.springframework:spring-test:5.1.6.RELEASE") - testImplementation("org.mockito:mockito-core:3.6.0") + testImplementation("org.mockito:mockito-inline:3.6.0") testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0") testImplementation("org.junit.jupiter:junit-jupiter-api:5.3.2") + testImplementation("io.mockk:mockk:1.10.6") testImplementation("org.springframework.boot:spring-boot-starter-test:2.3.4.RELEASE") testImplementation("org.springframework.boot:spring-boot-test-autoconfigure:2.3.4.RELEASE") testImplementation("org.jetbrains.kotlin:kotlin-test:1.4.10") diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/properties/CreProperties.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/properties/CreProperties.kt index 4a14568..ee2d3c7 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/properties/CreProperties.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/properties/CreProperties.kt @@ -5,4 +5,5 @@ import org.springframework.boot.context.properties.ConfigurationProperties @ConfigurationProperties(prefix = "cre.server") class CreProperties { var workingDirectory: String = "data" + var deploymentUrl: String = "http://localhost" } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/EmployeePermission.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/EmployeePermission.kt index e6ef1b7..7f1aecc 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/EmployeePermission.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/EmployeePermission.kt @@ -8,30 +8,34 @@ enum class EmployeePermission( val impliedPermissions: List = listOf(), val deprecated: Boolean = false ) { - VIEW_RECIPES, - VIEW_CATALOG, + READ_FILE, + WRITE_FILE(listOf(READ_FILE)), + REMOVE_FILE(listOf(WRITE_FILE)), + + VIEW_RECIPES(listOf(READ_FILE)), + VIEW_CATALOG(listOf(READ_FILE)), VIEW_USERS, PRINT_MIXES(listOf(VIEW_RECIPES)), EDIT_RECIPES_PUBLIC_DATA(listOf(VIEW_RECIPES)), - EDIT_RECIPES(listOf(EDIT_RECIPES_PUBLIC_DATA)), - EDIT_MATERIALS(listOf(VIEW_CATALOG)), + EDIT_RECIPES(listOf(EDIT_RECIPES_PUBLIC_DATA, WRITE_FILE)), + EDIT_MATERIALS(listOf(VIEW_CATALOG, WRITE_FILE)), EDIT_MATERIAL_TYPES(listOf(VIEW_CATALOG)), EDIT_COMPANIES(listOf(VIEW_CATALOG)), EDIT_USERS(listOf(VIEW_USERS)), EDIT_CATALOG(listOf(EDIT_MATERIALS, EDIT_MATERIAL_TYPES, EDIT_COMPANIES)), - ADD_TO_INVENTORY(listOf(VIEW_CATALOG)), - DEDUCT_FROM_INVENTORY(listOf(VIEW_RECIPES)), - - REMOVE_RECIPES(listOf(EDIT_RECIPES)), - REMOVE_MATERIALS(listOf(EDIT_MATERIALS)), + REMOVE_RECIPES(listOf(EDIT_RECIPES, REMOVE_FILE)), + REMOVE_MATERIALS(listOf(EDIT_MATERIALS, REMOVE_FILE)), REMOVE_MATERIAL_TYPES(listOf(EDIT_MATERIAL_TYPES)), REMOVE_COMPANIES(listOf(EDIT_COMPANIES)), REMOVE_USERS(listOf(EDIT_USERS)), REMOVE_CATALOG(listOf(REMOVE_MATERIALS, REMOVE_MATERIAL_TYPES, REMOVE_COMPANIES)), + ADD_TO_INVENTORY(listOf(VIEW_CATALOG)), + DEDUCT_FROM_INVENTORY(listOf(VIEW_RECIPES)), + ADMIN( listOf( EDIT_CATALOG, diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/files/FileController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/files/FileController.kt new file mode 100644 index 0000000..2735583 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/files/FileController.kt @@ -0,0 +1,55 @@ +package dev.fyloz.colorrecipesexplorer.rest.files + +import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties +import dev.fyloz.colorrecipesexplorer.rest.noContent +import dev.fyloz.colorrecipesexplorer.service.files.FileService +import org.springframework.core.io.ByteArrayResource +import org.springframework.http.MediaType +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 + +const val FILE_CONTROLLER_PATH = "/api/file" + +@RestController +@RequestMapping(FILE_CONTROLLER_PATH) +class FileController( + private val fileService: FileService, + private val creProperties: CreProperties +) { + @GetMapping(produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE]) + @PreAuthorize("hasAnyAuthority('READ_FILE')") + fun upload(@RequestParam path: String): ResponseEntity { + val file = fileService.read(path) + return ResponseEntity.ok() + .contentLength(file.contentLength()) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .body(file) + } + + @PutMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) + @PreAuthorize("hasAnyAuthority('WRITE_FILE')") + fun download( + file: MultipartFile, + @RequestParam path: String, + @RequestParam(required = false) overwrite: Boolean = false + ): ResponseEntity { + fileService.write(file, path, overwrite) + return created(path) + } + + @DeleteMapping + @PreAuthorize("hasAnyAuthority('REMOVE_FILE')") + fun delete(@RequestParam path: String): ResponseEntity { + return noContent { + fileService.delete(path) + } + } + + private fun created(path: String): ResponseEntity = + ResponseEntity + .created(URI.create("${creProperties.deploymentUrl}$FILE_CONTROLLER_PATH?path=$path")) + .build() +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt index 7df7296..e1313bb 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt @@ -31,14 +31,14 @@ interface RecipeService : ExternalModelService(recipeRepository), - RecipeService { + AbstractExternalModelService(recipeRepository), + RecipeService { override fun idNotFoundException(id: Long) = recipeIdNotFoundException(id) override fun idAlreadyExistsException(id: Long) = recipeIdAlreadyExistsException(id) @@ -49,14 +49,14 @@ class RecipeServiceImpl( // TODO checks if name is unique in the scope of the [company] return save(with(entity) { recipe( - name = name, - description = description, - color = color, - gloss = gloss, - sample = sample, - approbationDate = approbationDate, - remark = remark ?: "", - company = companyService.getById(companyId) + name = name, + description = description, + color = color, + gloss = gloss, + sample = sample, + approbationDate = approbationDate, + remark = remark ?: "", + company = companyService.getById(companyId) ) }) } @@ -67,17 +67,17 @@ class RecipeServiceImpl( return update(with(entity) { recipe( - id = id, - name = name or persistedRecipe.name, - description = description or persistedRecipe.description, - color = color or persistedRecipe.color, - gloss = gloss ?: persistedRecipe.gloss, - sample = sample ?: persistedRecipe.sample, - approbationDate = approbationDate ?: persistedRecipe.approbationDate, - remark = remark or persistedRecipe.remark, - company = persistedRecipe.company, - mixes = persistedRecipe.mixes, - groupsInformation = updateGroupsInformation(persistedRecipe, entity) + id = id, + name = name or persistedRecipe.name, + description = description or persistedRecipe.description, + color = color or persistedRecipe.color, + gloss = gloss ?: persistedRecipe.gloss, + sample = sample ?: persistedRecipe.sample, + approbationDate = approbationDate ?: persistedRecipe.approbationDate, + remark = remark or persistedRecipe.remark, + company = persistedRecipe.company, + mixes = persistedRecipe.mixes, + groupsInformation = updateGroupsInformation(persistedRecipe, entity) ) }) } @@ -96,8 +96,8 @@ class RecipeServiceImpl( this.steps = it.steps.toMutableSet() } } ?: recipeGroupInformation( - group = groupService.getById(it.groupId), - steps = it.steps.toMutableSet() + group = groupService.getById(it.groupId), + steps = it.steps.toMutableSet() ) updatedGroupsInformation.add(updatedGroupInformation) @@ -114,7 +114,7 @@ class RecipeServiceImpl( val recipe = getById(publicDataDto.recipeId) fun noteForGroup(group: EmployeeGroup) = - publicDataDto.notes.firstOrNull { it.groupId == group.id }?.content + publicDataDto.notes.firstOrNull { it.groupId == group.id }?.content // Notes recipe.groupsInformation.map { @@ -133,15 +133,16 @@ class RecipeServiceImpl( } override fun addMix(recipe: Recipe, mix: Mix) = - update(recipe.apply { mixes.add(mix) }) + update(recipe.apply { mixes.add(mix) }) override fun removeMix(mix: Mix): Recipe = - update(mix.recipe.apply { mixes.remove(mix) }) + 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 identifier of every images associated to the recipe with the given [recipeId]. */ @@ -157,11 +158,11 @@ interface RecipeImageService { @Service class RecipeImageServiceImpl(val recipeService: RecipeService, val fileService: FileService) : RecipeImageService { override fun getByIdForRecipe(id: Long, recipeId: Long): ByteArray = - try { - fileService.readAsBytes(getPath(id, recipeId)) - } catch (ex: NoSuchFileException) { - throw RecipeImageNotFoundException(id, recipeService.getById(recipeId)) - } + try { + fileService.read(getPath(id, recipeId)).byteArray + } catch (ex: NoSuchFileException) { + throw RecipeImageNotFoundException(id, recipeService.getById(recipeId)) + } override fun getAllIdsForRecipe(recipeId: Long): Collection { val recipe = recipeService.getById(recipeId) @@ -170,31 +171,31 @@ class RecipeImageServiceImpl(val recipeService: RecipeService, val fileService: return listOf() } return recipeDirectory.listFiles()!! // Should never be null because we check if recipeDirectory is a directory and exists before - .filterNotNull() - .map { it.name.toLong() } + .filterNotNull() + .map { it.name.toLong() } } override fun save(image: MultipartFile, recipeId: Long): Long { /** Gets the next id available for a new image for the recipe with the given [recipeId]. */ fun getNextAvailableId(): Long = - with(getAllIdsForRecipe(recipeId)) { - if (isEmpty()) - 0 - else - maxOrNull()!! + 1L // maxOrNull() cannot return null because existingIds cannot be empty at this point - } + with(getAllIdsForRecipe(recipeId)) { + if (isEmpty()) + 0 + else + maxOrNull()!! + 1L // maxOrNull() cannot return null because existingIds cannot be empty at this point + } val nextAvailableId = getNextAvailableId() - fileService.write(image, getPath(nextAvailableId, recipeId)) + fileService.write(image, getPath(nextAvailableId, recipeId), true) return nextAvailableId } override fun delete(id: Long, recipeId: Long) = - fileService.delete(getPath(id, recipeId)) + fileService.delete(getPath(id, recipeId)) /** Gets the images directory of the recipe with the given [recipeId]. */ - fun getRecipeDirectory(recipeId: Long) = File(fileService.getPath("$RECIPE_IMAGES_DIRECTORY/$recipeId")) + fun getRecipeDirectory(recipeId: Long) = File("$RECIPE_IMAGES_DIRECTORY/$recipeId") /** Gets the file of the image with the given [recipeId] and [id]. */ - fun getPath(id: Long, recipeId: Long): String = fileService.getPath("$RECIPE_IMAGES_DIRECTORY/$recipeId/$id") + fun getPath(id: Long, recipeId: Long): String = "$RECIPE_IMAGES_DIRECTORY/$recipeId/$id" } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileService.kt index c6c11fd..8fa6d3d 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileService.kt @@ -1,83 +1,178 @@ package dev.fyloz.colorrecipesexplorer.service.files import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties +import dev.fyloz.colorrecipesexplorer.exception.RestException import org.slf4j.Logger -import org.springframework.core.io.ResourceLoader +import org.springframework.core.io.ByteArrayResource +import org.springframework.http.HttpStatus import org.springframework.stereotype.Service import org.springframework.web.multipart.MultipartFile import java.io.File import java.io.IOException -import java.io.InputStream -import java.nio.charset.StandardCharsets import java.nio.file.Files -@Service -class FileService( - private val resourcesLoader: ResourceLoader, - private val creProperties: CreProperties, - private val logger: Logger -) { - /** Reads the resource at the given [path] as a [String]. */ - fun readResource(path: String): String = try { - resourcesLoader.getResource("classpath:$path").inputStream.use { - readInputStreamAsString(it) - } - } catch (ex: IOException) { - logger.error("Could not read resource", ex) - "" - } +interface FileService { + /** Checks if the file at the given [path] exists. */ + fun exists(path: String): Boolean - /** Reads the given [stream] as a [String]. */ - fun readInputStreamAsString(stream: InputStream) = with(stream.readAllBytes()) { - String(this, StandardCharsets.UTF_8) - } + /** Reads the file at the given [path]. */ + fun read(path: String): ByteArrayResource - /** Reads the file at the given [path] as a [ByteArray]. */ - fun readAsBytes(path: String) = - withFileAt(path) { this.readBytes() } + /** Creates a file at the given [path]. */ + fun create(path: String) - /** Writes the given [multipartFile] to the file at the given [path]. */ - fun write(multipartFile: MultipartFile, path: String): Boolean = - if (multipartFile.size <= 0) true - else try { - multipartFile.transferTo(create(path).toPath()) - true - } catch (ex: IOException) { - logger.error("Unable to write multipart file", ex) - false - } - - /** Creates a new file at the given [path]. If the file already exists, nothing will be done. */ - fun create(path: String) = withFileAt(path) { - if (!exists(path)) { - try { - Files.createDirectories(this.parentFile.toPath()) - Files.createFile(this.toPath()) - } catch (ex: IOException) { - logger.error("Unable to create file", ex) - } - } - this - } + /** Writes the given [file] at the given [path]. If the file already exists, it will be overwritten if [overwrite] is true. */ + fun write(file: MultipartFile, path: String, overwrite: Boolean) /** Deletes the file at the given [path]. */ - fun delete(path: String) = withFileAt(path) { - try { - if (exists(path)) Files.delete(this.toPath()) - } catch (ex: IOException) { - logger.error("Unable to delete file", ex) - } - } + fun delete(path: String) - /** Checks if a file with the given [path] exists on the disk. */ - fun exists(path: String): Boolean = withFileAt(path) { + /** Completes the path of the given [String] by adding the working directory. */ + fun String.fullPath(): FilePath +} + +@Service +class FileServiceImpl( + private val creProperties: CreProperties, + private val logger: Logger +) : FileService { + override fun exists(path: String) = withFileAt(path.fullPath()) { this.exists() && this.isFile } - /** Runs the given [block] in the context of a file with the given [path]. */ - fun withFileAt(path: String, block: File.() -> T) = - File(path).block() + override fun read(path: String) = ByteArrayResource( + withFileAt(path.fullPath()) { + if (!exists(path)) throw FileNotFoundException(path) + try { + readBytes() + } catch (ex: IOException) { + FileReadException(path).logAndThrow(ex, logger) + } + } + ) - fun getPath(fileName: String): String = - "${creProperties.workingDirectory}/$fileName" + override fun create(path: String) { + val fullPath = path.fullPath() + if (!exists(path)) { + try { + withFileAt(fullPath) { + this.create() + } + } catch (ex: IOException) { + FileCreateException(path).logAndThrow(ex, logger) + } + } + } + + override fun write(file: MultipartFile, path: String, overwrite: Boolean) { + val fullPath = path.fullPath() + + if (exists(path)) { + if (!overwrite) throw FileExistsException(path) + } else { + create(path) + } + + try { + withFileAt(fullPath) { + file.transferTo(this.toPath()) + } + } catch (ex: IOException) { + FileWriteException(path).logAndThrow(ex, logger) + } + } + + override fun delete(path: String) { + try { + withFileAt(path.fullPath()) { + if (!exists(path)) throw FileNotFoundException(path) + !this.delete() + } + } catch (ex: IOException) { + FileDeleteException(path).logAndThrow(ex, logger) + } + } + + override fun String.fullPath() = + FilePath("${creProperties.workingDirectory}/$this") + + /** Runs the given [block] in the context of a file with the given [fullPath]. */ + private fun withFileAt(fullPath: FilePath, block: File.() -> T) = + fullPath.file.block() +} + +data class FilePath(val path: String) { + val file: File + get() = File(path) +} + +/** Shortcut to create a file and its parent directories. */ +fun File.create() { + Files.createDirectories(this.parentFile.toPath()) + Files.createFile(this.toPath()) +} + +private const val FILE_IO_EXCEPTION_TITLE = "File IO error" + +class FileExistsException(val path: String) : + RestException( + "io-exists", + FILE_IO_EXCEPTION_TITLE, + HttpStatus.BAD_REQUEST, + "Could not write file to '$path' because it already exists. To overwrite the file set the overwrite parameter to true", + pathMap(path) + ) + +class FileNotFoundException(val path: String) : + RestException( + "io-notfound", + FILE_IO_EXCEPTION_TITLE, + HttpStatus.NOT_FOUND, + "Could not access file at '$path' because it does not exists", + pathMap(path) + ) + +sealed class FileIOException(type: String, details: String, val path: String) : + RestException( + "io-$type", + FILE_IO_EXCEPTION_TITLE, + HttpStatus.INTERNAL_SERVER_ERROR, + details, + pathMap(path) + ) + +class FileReadException(path: String) : + FileIOException( + "read", + "Could not read file at '$path'", + path + ) + +class FileWriteException(path: String) : + FileIOException( + "write", + "Could not write file to '$path'", + path + ) + +class FileCreateException(path: String) : + FileIOException( + "create", + "Could not create file at '$path'", + path + ) + +class FileDeleteException(path: String) : + FileIOException( + "delete", + "Could not delete file at '$path'", + path + ) + +private fun pathMap(path: String) = + mapOf("path" to path) + +private fun T.logAndThrow(baseException: Exception, logger: Logger): Nothing { + logger.error(this.details, baseException) + throw this } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/SimdutService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/SimdutService.kt index e13c501..c781859 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/SimdutService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/SimdutService.kt @@ -12,20 +12,21 @@ const val SIMDUT_DIRECTORY = "simdut" @Service class SimdutService( - private val fileService: FileService, - private val logger: Logger + private val fileService: FileService, + private val logger: Logger ) { /** Checks if the given [material] has a SIMDUT file. */ fun exists(material: Material) = - fileService.exists(getPath(material)) + fileService.exists(getPath(material)) /** Reads the SIMDUT file of the given [material]. */ + // TODO change return type to ByteArrayResource fun read(material: Material): ByteArray { val path = getPath(material) if (!fileService.exists(path)) return ByteArray(0) return try { - fileService.readAsBytes(path) + fileService.read(path).byteArray } catch (ex: IOException) { logger.error("Could not read SIMDUT file", ex) ByteArray(0) @@ -34,8 +35,11 @@ class SimdutService( /** Writes the given [simdut] file for the given [material] to the disk. */ fun write(material: Material, simdut: MultipartFile) { - if (!fileService.write(simdut, getPath(material))) + try { + fileService.write(simdut, getPath(material), true) + } catch (ex: FileWriteException) { throw SimdutWriteException(material) + } } /** Updates the SIMDUT file of the given [material] with the given [simdut]. */ @@ -46,21 +50,21 @@ class SimdutService( /** Deletes the SIMDUT file of the given [material]. */ fun delete(material: Material) = - fileService.delete(getPath(material)) + fileService.delete(getPath(material)) /** Gets the path of the SIMDUT file of the given [material]. */ fun getPath(material: Material) = - fileService.getPath("$SIMDUT_DIRECTORY/${getSimdutFileName(material)}") + "$SIMDUT_DIRECTORY/${getSimdutFileName(material)}" /** Gets the name of the SIMDUT file of the given [material]. */ fun getSimdutFileName(material: Material) = - material.id.toString() + material.id.toString() } class SimdutWriteException(material: Material) : - RestException( - "simdut-write", - "Could not write SIMDUT file", - HttpStatus.INTERNAL_SERVER_ERROR, - "Could not write the SIMDUT file for the material ${material.name} to the disk" - ) + RestException( + "simdut-write", + "Could not write SIMDUT file", + HttpStatus.INTERNAL_SERVER_ERROR, + "Could not write the SIMDUT file for the material ${material.name} to the disk" + ) diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 492010d..3250a34 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -2,6 +2,7 @@ server.port=9090 # CRE cre.server.working-directory=data +cre.server.deployment-url=http://localhost:9090 cre.security.jwt-secret=CtnvGQjgZ44A1fh295gE cre.security.jwt-duration=18000000 # Root user diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeServiceTest.kt index e42820e..776275e 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeServiceTest.kt @@ -7,6 +7,7 @@ import dev.fyloz.colorrecipesexplorer.service.files.FileService 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 @@ -15,13 +16,14 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class RecipeServiceTest : - AbstractExternalModelServiceTest() { + AbstractExternalModelServiceTest() { override val repository: RecipeRepository = mock() private val companyService: CompanyService = mock() private val mixService: MixService = mock() private val groupService: EmployeeGroupService = mock() private val recipeStepService: RecipeStepService = mock() - override val service: RecipeService = spy(RecipeServiceImpl(repository, companyService, mixService, recipeStepService, groupService)) + 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) @@ -79,22 +81,22 @@ class RecipeServiceTest : @Test override fun `update(dto) calls and returns update() with the created entity`() = - withBaseUpdateDtoTest(entity, entityUpdateDto, service, { any() }) + withBaseUpdateDtoTest(entity, entityUpdateDto, service, { any() }) // updatePublicData() @Test fun `updatePublicData() updates the notes of a recipe groups information according to the RecipePublicDataDto`() { val recipe = recipe( - id = 0L, groupsInformation = setOf( - recipeGroupInformation(id = 0L, group = employeeGroup(id = 1L), note = "Old note"), - recipeGroupInformation(id = 1L, group = employeeGroup(id = 2L), note = "Another note"), - recipeGroupInformation(id = 2L, group = employeeGroup(id = 3L), note = "Up to date note") - ) + id = 0L, groupsInformation = setOf( + recipeGroupInformation(id = 0L, group = employeeGroup(id = 1L), note = "Old note"), + recipeGroupInformation(id = 1L, group = employeeGroup(id = 2L), note = "Another note"), + recipeGroupInformation(id = 2L, group = employeeGroup(id = 3L), note = "Up to date note") + ) ) val notes = setOf( - noteDto(groupId = 1, content = "Note 1"), - noteDto(groupId = 2, content = null) + noteDto(groupId = 1, content = "Note 1"), + noteDto(groupId = 2, content = null) ) val publicData = recipePublicDataDto(recipeId = recipe.id!!, notes = notes) @@ -115,10 +117,10 @@ class RecipeServiceTest : @Test fun `updatePublicData() update the location of a recipe mixes in the mix service according to the RecipePublicDataDto`() { val publicData = recipePublicDataDto( - mixesLocation = setOf( - mixLocationDto(mixId = 0L, location = "Loc 1"), - mixLocationDto(mixId = 1L, location = "Loc 2") - ) + mixesLocation = setOf( + mixLocationDto(mixId = 0L, location = "Loc 1"), + mixLocationDto(mixId = 1L, location = "Loc 2") + ) ) service.updatePublicData(publicData) @@ -186,8 +188,7 @@ class RecipeImageServiceTest { @Test fun `getByIdForRecipe() returns data for the given recipe and image id red by the file service`() { - whenever(fileService.getPath(imagePath)).doReturn(imagePath) - whenever(fileService.readAsBytes(imagePath)).doReturn(imageData) + whenever(fileService.read(imagePath)).doReturn(ByteArrayResource(imageData)) val found = service.getByIdForRecipe(imageId, recipeId) @@ -198,7 +199,7 @@ class RecipeImageServiceTest { 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.readAsBytes(imagePath)).doAnswer { throw NoSuchFileException(imagePath) } + whenever(fileService.read(imagePath)).doAnswer { throw NoSuchFileException(imagePath) } assertThrows { service.getByIdForRecipe(imageId, recipeId) } } @@ -256,7 +257,7 @@ class RecipeImageServiceTest { service.save(image, recipeId) - verify(fileService).write(image, imagePath) + verify(fileService).write(image, imagePath, true) } // delete() @@ -275,7 +276,6 @@ class RecipeImageServiceTest { @Test fun `getRecipeDirectory() returns a file with the expected path`() { val recipeDirectoryPath = "$RECIPE_IMAGES_DIRECTORY/$recipeId" - whenever(fileService.getPath(recipeDirectoryPath)).doReturn(recipeDirectoryPath) val found = service.getRecipeDirectory(recipeId) @@ -286,8 +286,6 @@ class RecipeImageServiceTest { @Test fun `getPath() returns the expected path`() { - whenever(fileService.getPath(any())).doAnswer { it.arguments[0] as String } - val found = service.getPath(imageId, recipeId) assertEquals(imagePath, found) diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/SimdutServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/SimdutServiceTest.kt index c80391b..22ecfc0 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/SimdutServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/SimdutServiceTest.kt @@ -6,6 +6,7 @@ import dev.fyloz.colorrecipesexplorer.model.material 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.web.multipart.MultipartFile import java.io.IOException import kotlin.test.assertEquals @@ -25,7 +26,7 @@ class SimdutServiceTest { @JvmName("withNullableMaterialPath") private inline fun withMaterialPath(material: Material? = null, exists: Boolean = true, test: (String) -> Unit) = - withMaterialPath(material ?: this.material, exists, test) + withMaterialPath(material ?: this.material, exists, test) private inline fun withMaterialPath(material: Material, exists: Boolean = true, test: (String) -> Unit) { val path = "data/simdut/${material.id}" @@ -58,7 +59,7 @@ class SimdutServiceTest { withMaterialPath { path -> val simdutContent = byteArrayOf(0xf) - whenever(fileService.readAsBytes(path)).doReturn(simdutContent) + whenever(fileService.read(path)).doReturn(ByteArrayResource(simdutContent)) val found = service.read(material) @@ -78,7 +79,7 @@ class SimdutServiceTest { @Test fun `read() returns a empty ByteArray when reading the SIMDUT throws an IOException`() { withMaterialPath { path -> - whenever(fileService.readAsBytes(path)).doAnswer { throw IOException() } + whenever(fileService.read(path)).doAnswer { throw IOException() } val found = service.read(material) @@ -93,11 +94,9 @@ class SimdutServiceTest { withMaterialPath { path -> val simdutMultipart = mock() - whenever(fileService.write(simdutMultipart, path)).doReturn(true) - service.write(material, simdutMultipart) - verify(fileService).write(simdutMultipart, path) + verify(fileService).write(simdutMultipart, path, true) } } @@ -106,7 +105,7 @@ class SimdutServiceTest { withMaterialPath { path -> val simdutMultipart = mock() - whenever(fileService.write(simdutMultipart, path)).doReturn(false) + whenever(fileService.write(simdutMultipart, path, true)).doAnswer { throw FileCreateException(path) } assertThrows { service.write(material, simdutMultipart) } } @@ -138,22 +137,4 @@ class SimdutServiceTest { verify(fileService).delete(path) } } - - // getPath() - - @Test - fun `getPath() returns the appropriate path for the given material`() { - val simdutFileName = material.id.toString() - val workingDirectory = "data" - val expectedPath = "$workingDirectory/$SIMDUT_DIRECTORY/$simdutFileName" - - whenever(fileService.getPath(any())).doAnswer { "$workingDirectory/${it.arguments[0]}" } - doAnswer { simdutFileName }.whenever(service).getSimdutFileName(material) - - val found = service.getPath(material) - - assertEquals(expectedPath, found) - - verify(fileService).getPath("$SIMDUT_DIRECTORY/$simdutFileName") - } } From ee4385ccb4befff69d16da5d89310331969a4a92 Mon Sep 17 00:00:00 2001 From: FyloZ Date: Mon, 26 Apr 2021 23:31:58 -0400 Subject: [PATCH 2/6] Bon test du service de fichiers --- .../service/files/FileServiceTest.kt | 321 ++++++++++++++---- 1 file changed, 248 insertions(+), 73 deletions(-) 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 756d7de..972a845 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileServiceTest.kt @@ -1,118 +1,293 @@ package dev.fyloz.colorrecipesexplorer.service.files -import com.nhaarman.mockitokotlin2.* import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties +import io.mockk.* import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test -import org.slf4j.Logger -import org.springframework.core.io.Resource -import org.springframework.core.io.ResourceLoader +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.io.InputStream -import java.nio.file.Paths +import java.nio.file.Path import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue +private val creProperties = CreProperties().apply { + workingDirectory = "data" + deploymentUrl = "http://localhost" +} +private const val mockFilePath = "existingFile" +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)) + } +} + class FileServiceTest { - private val resourcesLoader = mock() - private val logger = mock() - private val properties = CreProperties() - - private val service = spy(FileService(resourcesLoader, properties, logger)) - - private val path = "/var/cre/file" - @AfterEach - fun afterEach() { - reset(resourcesLoader, logger, service) + internal fun afterEach() { + clearAllMocks() } - // readResource() + // exists() @Test - fun `readResource() returns content of the resource at the given path`() { - val resource = mock() - val resourceStream = mock() - val resourceContent = """ - Line 1 - Line 2 - Line 3 - """.trimIndent() - - whenever(resource.inputStream).doReturn(resourceStream) - whenever(resourcesLoader.getResource("classpath:$path")).doReturn(resource) - doReturn(resourceContent).whenever(service).readInputStreamAsString(resourceStream) - - val found = service.readResource(path) - - assertEquals(resourceContent, found) + fun `exists() returns true when the file at the given path exists and is a file`() { + fileServiceTest { + assertTrue { fileService.exists(mockFilePath) } + } } - // readInputStreamAsString() + @Test + fun `exists() returns false when the file at the given path does not exist`() { + fileServiceTest { + every { mockFile.exists() } returns false + + assertFalse { fileService.exists(mockFilePath) } + } + } @Test - fun `readInputStreamAsString() returns a String matching the given input stream's content`() { - val stream = mock() - val streamContent = """ - Line 1 - Line 2 - Line 3 - """.trimIndent() + fun `exists() returns false when the file at the given path is not a file`() { + fileServiceTest { + every { mockFile.isFile } returns false - whenever(stream.readAllBytes()).doAnswer { streamContent.toByteArray() } + assertFalse { fileService.exists(mockFilePath) } + } + } - val found = service.readInputStreamAsString(stream) + // read() - assertEquals(streamContent, found) + @Test + fun `read() returns a valid ByteArrayResource`() { + fileServiceTest { + whenMockFilePathExists { + mockkStatic(File::readBytes) + every { mockFile.readBytes() } returns mockFileData + + val redResource = fileService.read(mockFilePath) + + assertEquals(mockFileData, redResource.byteArray) + } + } + } + + @Test + fun `read() throws FileNotFoundException when no file exists at the given path`() { + fileServiceTest { + whenMockFilePathExists(false) { + with(assertThrows { fileService.read(mockFilePath) }) { + assertEquals(mockFilePath, this.path) + } + } + } + } + + @Test + fun `read() throws FileReadException when an IOException is thrown`() { + fileServiceTest { + whenMockFilePathExists { + mockkStatic(File::readBytes) + every { mockFile.readBytes() } throws IOException() + + with(assertThrows { fileService.read(mockFilePath) }) { + assertEquals(mockFilePath, this.path) + } + } + } + } + + // create() + + @Test + fun `create() creates a file at the given path`() { + fileServiceTest { + whenMockFilePathExists(false) { + mockkStatic(File::create) + every { mockFile.create() } just Runs + + fileService.create(mockFilePath) + + verify { + mockFile.create() + } + } + } + } + + @Test + fun `create() does nothing when a file already exists at the given path`() { + fileServiceTest { + whenMockFilePathExists { + fileService.create(mockFilePath) + + verify(exactly = 0) { + mockFile.create() + } + } + } + } + + @Test + fun `create() throws FileCreateException when the file creation throws an IOException`() { + fileServiceTest { + whenMockFilePathExists(false) { + mockkStatic(File::create) + every { mockFile.create() } throws IOException() + + with(assertThrows { fileService.create(mockFilePath) }) { + assertEquals(mockFilePath, this.path) + } + } + } } // write() - private inline fun withMultipartFile(size: Long = 1000L, test: (MultipartFile) -> Unit) { - val multipartFile = mock() - whenever(multipartFile.size).doReturn(size) - - test(multipartFile) - } - @Test - fun `write() transfers data from the given MultipartFile to the file at the given path and returns true`() { - withMultipartFile { multipartFile -> - val file = mock() - val filePath = Paths.get(path) + fun `write() creates and writes the given MultipartFile to the file at the given path`() { + fileServiceTest { + whenMockFilePathExists(false) { + every { fileService.create(mockFilePath) } just Runs + every { mockMultipartFile.transferTo(mockFilePathPath) } just Runs - whenever(file.toPath()).doReturn(filePath) - doAnswer { file }.whenever(service).create(path) + fileService.write(mockMultipartFile, mockFilePath, false) - assertTrue { service.write(multipartFile, path) } - - verify(multipartFile).transferTo(filePath) + verify { + fileService.create(mockFilePath) + mockMultipartFile.transferTo(mockFilePathPath) + } + } } } @Test - fun `write() returns true when given MultipartFile is empty`() { - withMultipartFile(size = 0L) { multipartFile -> - assertTrue { service.write(multipartFile, path) } - - verify(multipartFile, never()).transferTo(any()) + fun `write() throws FileExistsException when a file at the given path already exists and overwrite is disabled`() { + fileServiceTest { + whenMockFilePathExists { + with(assertThrows { fileService.write(mockMultipartFile, mockFilePath, false) }) { + assertEquals(mockFilePath, this.path) + } + } } } @Test - fun `write() returns false when the data transfer throw an IOException`() { - withMultipartFile { multipartFile -> - val file = mock() - val filePath = Paths.get(path) + fun `write() writes the given MultipartFile to an existing file when overwrite is enabled`() { + fileServiceTest { + whenMockFilePathExists { + every { mockMultipartFile.transferTo(mockFilePathPath) } just Runs - whenever(file.toPath()).doReturn(filePath) - whenever(multipartFile.transferTo(filePath)).doThrow(IOException()) - doAnswer { file }.whenever(service).create(path) + fileService.write(mockMultipartFile, mockFilePath, true) - assertFalse { service.write(multipartFile, path) } + verify { + mockMultipartFile.transferTo(mockFilePathPath) + } + } } } + + @Test + fun `write() throws FileWriteException when writing the given file throws an IOException`() { + fileServiceTest { + whenMockFilePathExists(false) { + every { fileService.create(mockFilePath) } just Runs + every { mockMultipartFile.transferTo(mockFilePathPath) } throws IOException() + + with(assertThrows { + fileService.write(mockMultipartFile, mockFilePath, false) + }) { + assertEquals(mockFilePath, this.path) + } + } + } + } + + // delete() + + @Test + fun `delete() deletes the file at the given path`() { + fileServiceTest { + whenMockFilePathExists { + every { mockFile.delete() } returns true + + fileService.delete(mockFilePath) + } + } + } + + @Test + fun `delete() throws FileNotFoundException when no file exists at the given path`() { + fileServiceTest { + whenMockFilePathExists(false) { + with(assertThrows { fileService.delete(mockFilePath) }) { + assertEquals(mockFilePath, this.path) + } + } + } + } + + @Test + fun `delete() throws FileDeleteException when deleting throw and IOException`() { + fileServiceTest { + whenMockFilePathExists { + every { mockFile.delete() } throws IOException() + + with(assertThrows { fileService.delete(mockFilePath) }) { + assertEquals(mockFilePath, this.path) + } + } + } + } + + // String.fullPath() + + @Test + fun `fullPath() appends the given path to the given working directory`() { + fileServiceTest { + with(fileService) { + val fullFilePath = mockFilePath.fullPath() + + assertEquals("${creProperties.workingDirectory}/$mockFilePath", fullFilePath.path) + } + } + } + + private fun fileServiceTest(test: FileServiceTestContext.() -> Unit) { + FileServiceTestContext().test() + } + + private fun FileServiceTestContext.whenMockFilePathExists(exists: Boolean = true, test: () -> Unit) { + every { fileService.exists(mockFilePath) } returns exists + test() + } } From 0f649f983ce821f77761a5862d5512d7853bdf7e Mon Sep 17 00:00:00 2001 From: FyloZ Date: Tue, 27 Apr 2021 10:58:42 -0400 Subject: [PATCH 3/6] Ajustement de MaterialService pour utiliser FileService --- .../colorrecipesexplorer/model/Material.kt | 213 ++++++++++-------- .../rest/MaterialController.kt | 104 +++++---- .../colorrecipesexplorer/rest/RestUtils.kt | 22 +- .../rest/files/FileController.kt | 4 + .../service/MaterialService.kt | 89 ++++---- .../service/files/SimdutService.kt | 70 ------ .../service/MaterialServiceTest.kt | 76 ++----- .../service/files/SimdutServiceTest.kt | 140 ------------ 8 files changed, 265 insertions(+), 453 deletions(-) delete mode 100644 src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/SimdutService.kt delete mode 100644 src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/SimdutServiceTest.kt diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Material.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Material.kt index 3b060a1..b337627 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Material.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Material.kt @@ -1,11 +1,13 @@ package dev.fyloz.colorrecipesexplorer.model +import com.fasterxml.jackson.annotation.JsonIgnore import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException import dev.fyloz.colorrecipesexplorer.exception.NotFoundException import dev.fyloz.colorrecipesexplorer.model.validation.NullOrNotBlank import dev.fyloz.colorrecipesexplorer.model.validation.NullOrSize import org.springframework.web.multipart.MultipartFile +import java.net.URI import javax.persistence.* import javax.validation.constraints.Min import javax.validation.constraints.NotBlank @@ -21,106 +23,135 @@ private const val MATERIAL_QUANTITY_MATERIAL_NULL_MESSAGE = "Un produit est requ private const val MATERIAL_QUANTITY_QUANTITY_NULL_MESSAGE = "Une quantité est requises" private const val MATERIAL_QUANTITY_QUANTITY_NEGATIVE_MESSAGE = "La quantité doit être supérieure ou égale à 0" +const val SIMDUT_FILES_PATH = "pdf/simdut" + @Entity @Table(name = "material") data class Material( - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - override val id: Long?, + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + override val id: Long?, - @Column(unique = true) - override var name: String, + @Column(unique = true) + override var name: String, - @Column(name = "inventory_quantity") - var inventoryQuantity: Float, + @Column(name = "inventory_quantity") + var inventoryQuantity: Float, - @Column(name = "mix_type") - val isMixType: Boolean, + @Column(name = "mix_type") + val isMixType: Boolean, - @ManyToOne - @JoinColumn(name = "material_type_id") - var materialType: MaterialType? -) : NamedModel + @ManyToOne + @JoinColumn(name = "material_type_id") + var materialType: MaterialType? +) : NamedModel { + val simdutFilePath + @JsonIgnore + @Transient + get() = "$SIMDUT_FILES_PATH/$name.pdf" +} open class MaterialSaveDto( - @field:NotBlank(message = MATERIAL_NAME_NULL_MESSAGE) - val name: String, + @field:NotBlank(message = MATERIAL_NAME_NULL_MESSAGE) + val name: String, - @field:NotNull(message = MATERIAL_INVENTORY_QUANTITY_NULL_MESSAGE) - @field:Min(value = 0, message = MATERIAL_INVENTORY_QUANTITY_NEGATIVE_MESSAGE) - val inventoryQuantity: Float, + @field:NotNull(message = MATERIAL_INVENTORY_QUANTITY_NULL_MESSAGE) + @field:Min(value = 0, message = MATERIAL_INVENTORY_QUANTITY_NEGATIVE_MESSAGE) + val inventoryQuantity: Float, - @field:NotNull(message = MATERIAL_TYPE_NULL_MESSAGE) - val materialTypeId: Long, + @field:NotNull(message = MATERIAL_TYPE_NULL_MESSAGE) + val materialTypeId: Long, - val simdutFile: MultipartFile? = null + val simdutFile: MultipartFile? = null ) : EntityDto open class MaterialUpdateDto( - @field:NotNull(message = MATERIAL_ID_NULL_MESSAGE) - val id: Long, + @field:NotNull(message = MATERIAL_ID_NULL_MESSAGE) + val id: Long, - @field:NullOrNotBlank(message = MATERIAL_NAME_NULL_MESSAGE) - val name: String?, + @field:NullOrNotBlank(message = MATERIAL_NAME_NULL_MESSAGE) + val name: String?, - @field:NullOrSize(min = 0, message = MATERIAL_INVENTORY_QUANTITY_NEGATIVE_MESSAGE) - val inventoryQuantity: Float?, + @field:NullOrSize(min = 0, message = MATERIAL_INVENTORY_QUANTITY_NEGATIVE_MESSAGE) + val inventoryQuantity: Float?, - val materialTypeId: Long?, + val materialTypeId: Long?, - val simdutFile: MultipartFile? = null + val simdutFile: MultipartFile? = null ) : EntityDto -data class MaterialQuantityDto( - @field:NotNull(message = MATERIAL_QUANTITY_MATERIAL_NULL_MESSAGE) - val material: Long, +data class MaterialOutputDto( + val id: Long, + val name: String, + val inventoryQuantity: Float, + val isMixType: Boolean, + val materialType: MaterialType, + val simdutUrl: String? +) - @field:NotNull(message = MATERIAL_QUANTITY_QUANTITY_NULL_MESSAGE) - @field:Min(value = 0, message = MATERIAL_QUANTITY_QUANTITY_NEGATIVE_MESSAGE) - val quantity: Float +data class MaterialQuantityDto( + @field:NotNull(message = MATERIAL_QUANTITY_MATERIAL_NULL_MESSAGE) + val material: Long, + + @field:NotNull(message = MATERIAL_QUANTITY_QUANTITY_NULL_MESSAGE) + @field:Min(value = 0, message = MATERIAL_QUANTITY_QUANTITY_NEGATIVE_MESSAGE) + val quantity: Float ) // === DSL === fun material( - id: Long? = null, - name: String = "name", - inventoryQuantity: Float = 0f, - isMixType: Boolean = false, - materialType: MaterialType? = materialType(), - op: Material.() -> Unit = {} + id: Long? = null, + name: String = "name", + inventoryQuantity: Float = 0f, + isMixType: Boolean = false, + materialType: MaterialType? = materialType(), + op: Material.() -> Unit = {} ) = Material(id, name, inventoryQuantity, isMixType, materialType).apply(op) fun material( - material: Material, - id: Long? = null, - name: String? = null, + material: Material, + id: Long? = null, + name: String? = null, ) = Material( - id ?: material.id, name - ?: material.name, material.inventoryQuantity, material.isMixType, material.materialType + id ?: material.id, name + ?: material.name, material.inventoryQuantity, material.isMixType, material.materialType ) fun materialSaveDto( - name: String = "name", - inventoryQuantity: Float = 0f, - materialTypeId: Long = 0L, - simdutFile: MultipartFile? = null, - op: MaterialSaveDto.() -> Unit = {} + name: String = "name", + inventoryQuantity: Float = 0f, + materialTypeId: Long = 0L, + simdutFile: MultipartFile? = null, + op: MaterialSaveDto.() -> Unit = {} ) = MaterialSaveDto(name, inventoryQuantity, materialTypeId, simdutFile).apply(op) fun materialUpdateDto( - id: Long = 0L, - name: String? = "name", - inventoryQuantity: Float? = 0f, - materialTypeId: Long? = 0L, - simdutFile: MultipartFile? = null, - op: MaterialUpdateDto.() -> Unit = {} + id: Long = 0L, + name: String? = "name", + inventoryQuantity: Float? = 0f, + materialTypeId: Long? = 0L, + simdutFile: MultipartFile? = null, + op: MaterialUpdateDto.() -> Unit = {} ) = MaterialUpdateDto(id, name, inventoryQuantity, materialTypeId, simdutFile).apply(op) +fun materialOutputDto( + material: Material, + simdutUrl: String?, + op: MaterialOutputDto.() -> Unit = {} +) = MaterialOutputDto( + id = material.id!!, + name = material.name, + inventoryQuantity = material.inventoryQuantity, + isMixType = material.isMixType, + materialType = material.materialType!!, + simdutUrl = simdutUrl +).apply(op) + fun materialQuantityDto( - materialId: Long, - quantity: Float, - op: MaterialQuantityDto.() -> Unit = {} + materialId: Long, + quantity: Float, + op: MaterialQuantityDto.() -> Unit = {} ) = MaterialQuantityDto(materialId, quantity).apply(op) // ==== Exceptions ==== @@ -130,42 +161,42 @@ private const val MATERIAL_CANNOT_DELETE_EXCEPTION_TITLE = "Cannot delete materi private const val MATERIAL_EXCEPTION_ERROR_CODE = "material" fun materialIdNotFoundException(id: Long) = - NotFoundException( - MATERIAL_EXCEPTION_ERROR_CODE, - MATERIAL_NOT_FOUND_EXCEPTION_TITLE, - "A material with the id $id could not be found", - id - ) + NotFoundException( + MATERIAL_EXCEPTION_ERROR_CODE, + MATERIAL_NOT_FOUND_EXCEPTION_TITLE, + "A material with the id $id could not be found", + id + ) fun materialNameNotFoundException(name: String) = - NotFoundException( - MATERIAL_EXCEPTION_ERROR_CODE, - MATERIAL_NOT_FOUND_EXCEPTION_TITLE, - "A material with the name $name could not be found", - name, - "name" - ) + NotFoundException( + MATERIAL_EXCEPTION_ERROR_CODE, + MATERIAL_NOT_FOUND_EXCEPTION_TITLE, + "A material with the name $name could not be found", + name, + "name" + ) fun materialIdAlreadyExistsException(id: Long) = - AlreadyExistsException( - MATERIAL_EXCEPTION_ERROR_CODE, - MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE, - "A material with the id $id already exists", - id - ) + AlreadyExistsException( + MATERIAL_EXCEPTION_ERROR_CODE, + MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE, + "A material with the id $id already exists", + id + ) fun materialNameAlreadyExistsException(name: String) = - AlreadyExistsException( - MATERIAL_EXCEPTION_ERROR_CODE, - MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE, - "A material with the name $name already exists", - name, - "name" - ) + AlreadyExistsException( + MATERIAL_EXCEPTION_ERROR_CODE, + MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE, + "A material with the name $name already exists", + name, + "name" + ) fun cannotDeleteMaterialException(material: Material) = - CannotDeleteException( - MATERIAL_EXCEPTION_ERROR_CODE, - MATERIAL_CANNOT_DELETE_EXCEPTION_TITLE, - "Cannot delete the material ${material.name} because one or more recipes depends on it" - ) + CannotDeleteException( + MATERIAL_EXCEPTION_ERROR_CODE, + MATERIAL_CANNOT_DELETE_EXCEPTION_TITLE, + "Cannot delete the material ${material.name} because one or more recipes depends on it" + ) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialController.kt index a44d493..e9801f4 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialController.kt @@ -1,13 +1,18 @@ package dev.fyloz.colorrecipesexplorer.rest import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewCatalog +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.MaterialService import org.springframework.http.MediaType 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 private const val MATERIAL_CONTROLLER_PATH = "api/material" @@ -15,78 +20,89 @@ private const val MATERIAL_CONTROLLER_PATH = "api/material" @RestController @RequestMapping(MATERIAL_CONTROLLER_PATH) @PreAuthorizeViewCatalog -class MaterialController(private val materialService: MaterialService) { +class MaterialController( + private val materialService: MaterialService, + private val creProperties: CreProperties +) { @GetMapping fun getAll() = - ok(materialService.getAll()) + ok(materialService.getAll()) @GetMapping("notmixtype") fun getAllNotMixType() = - ok(materialService.getAllNotMixType()) + ok(materialService.getAllNotMixType()) @GetMapping("{id}") fun getById(@PathVariable id: Long) = - ok(materialService.getById(id)) + ok(materialService.getById(id)) @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) @PreAuthorize("hasAuthority('EDIT_MATERIALS')") fun save(@Valid material: MaterialSaveDto, simdutFile: MultipartFile?) = - created(MATERIAL_CONTROLLER_PATH) { - materialService.save( - materialSaveDto( - name = material.name, - inventoryQuantity = material.inventoryQuantity, - materialTypeId = material.materialTypeId, - simdutFile = simdutFile + created { + materialService.save( + materialSaveDto( + name = material.name, + inventoryQuantity = material.inventoryQuantity, + materialTypeId = material.materialTypeId, + simdutFile = simdutFile + ) ) - ) - } + } @PutMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) @PreAuthorize("hasAuthority('EDIT_MATERIALS')") fun update(@Valid material: MaterialUpdateDto, simdutFile: MultipartFile?) = - noContent { - materialService.update( - materialUpdateDto( - id = material.id, - name = material.name, - inventoryQuantity = material.inventoryQuantity, - materialTypeId = material.materialTypeId, - simdutFile = simdutFile + noContent { + materialService.update( + materialUpdateDto( + id = material.id, + name = material.name, + inventoryQuantity = material.inventoryQuantity, + materialTypeId = material.materialTypeId, + simdutFile = simdutFile + ) ) - ) - } + } @DeleteMapping("{id}") @PreAuthorize("hasAuthority('REMOVE_MATERIALS')") fun deleteById(@PathVariable id: Long) = - noContent { - materialService.deleteById(id) - } - - @GetMapping("{id}/simdut/exists") - fun hasSimdut(@PathVariable id: Long) = - ok(materialService.hasSimdut(id)) - - @GetMapping("{id}/simdut", produces = [MediaType.APPLICATION_PDF_VALUE]) - fun getSimdut(@PathVariable id: Long): ResponseEntity = with(materialService.getSimdut(id)) { - if (this.isEmpty()) { - notFound() - } else { - ok(this, httpHeaders(contentType = MediaType.APPLICATION_PDF)) + noContent { + materialService.deleteById(id) } - } - - @GetMapping("/simdut") - fun getAllIdsWithSimdut() = - ok(materialService.getAllIdsWithSimdut()) @GetMapping("mix/create/{recipeId}") fun getAllForMixCreation(@PathVariable recipeId: Long) = - ok(materialService.getAllForMixCreation(recipeId)) + ok(materialService.getAllForMixCreation(recipeId)) @GetMapping("mix/update/{mixId}") fun getAllForMixUpdate(@PathVariable mixId: Long) = - ok(materialService.getAllForMixUpdate(mixId)) + ok(materialService.getAllForMixUpdate(mixId)) + + private fun ok(material: Material) = + ok(material.toOutput()) + + private fun ok(materials: Collection) = + ok(materials.map { it.toOutput() }) + + private fun created(producer: () -> Material): ResponseEntity = with(producer().toOutput()) { + ResponseEntity + .created(URI.create("$MATERIAL_CONTROLLER_PATH/${this.id}")) + .body(this) + } + + private fun Material.toOutput() = materialOutputDto( + this, + if (materialService.hasSimdut(this)) this.simdutUrl() else null + ) + + private fun Material.simdutUrl() = + "${creProperties.deploymentUrl}$FILE_CONTROLLER_PATH?path=${ + URLEncoder.encode( + this.simdutFilePath, + StandardCharsets.UTF_8 + ) + }" } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RestUtils.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RestUtils.kt index 46004d1..2f8fa7c 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RestUtils.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RestUtils.kt @@ -9,11 +9,11 @@ import java.net.URI /** Creates a HTTP OK [ResponseEntity] from the given [body]. */ fun ok(body: T): ResponseEntity = - ResponseEntity.ok(body) + ResponseEntity.ok(body) /** Creates a HTTP OK [ResponseEntity] from the given [body] and [headers]. */ fun ok(body: T, headers: HttpHeaders): ResponseEntity = - ResponseEntity(body, headers, HttpStatus.OK) + ResponseEntity(body, headers, HttpStatus.OK) /** Executes the given [action] then returns an HTTP OK [ResponseEntity] form the given [body]. */ fun ok(action: () -> Unit): ResponseEntity { @@ -23,19 +23,23 @@ fun ok(action: () -> Unit): ResponseEntity { /** Creates a HTTP CREATED [ResponseEntity] from the given [body] with the location set to [controllerPath]/id. */ fun created(controllerPath: String, body: T): ResponseEntity = - ResponseEntity.created(URI.create("$controllerPath/${body.id}")).body(body) + created(controllerPath, body, body.id!!) /** Creates a HTTP CREATED [ResponseEntity] with the result of the given [producer] as its body. */ fun created(controllerPath: String, producer: () -> T): ResponseEntity = - created(controllerPath, producer()) + created(controllerPath, producer()) + +/** Creates a HTTP CREATED [ResponseEntity] from the given [body] with the location set to [controllerPath]/id. */ +fun created(controllerPath: String, body: T, id: Any): ResponseEntity = + ResponseEntity.created(URI.create("$controllerPath/$id")).body(body) /** Creates a HTTP NOT FOUND [ResponseEntity]. */ fun notFound(): ResponseEntity = - ResponseEntity.notFound().build() + ResponseEntity.notFound().build() /** Creates a HTTP NO CONTENT [ResponseEntity]. */ fun noContent(): ResponseEntity = - ResponseEntity.noContent().build() + ResponseEntity.noContent().build() /** Executes the given [action] then returns an HTTP NO CONTENT [ResponseEntity]. */ fun noContent(action: () -> Unit): ResponseEntity { @@ -45,12 +49,12 @@ fun noContent(action: () -> Unit): ResponseEntity { /** Creates a HTTP FORBIDDEN [ResponseEntity]. */ fun forbidden(): ResponseEntity = - ResponseEntity.status(HttpStatus.FORBIDDEN).build() + ResponseEntity.status(HttpStatus.FORBIDDEN).build() /** Creates an [HttpHeaders] instance from the given options. */ fun httpHeaders( - contentType: MediaType = MediaType.APPLICATION_JSON, - op: HttpHeaders.() -> Unit = {} + contentType: MediaType = MediaType.APPLICATION_JSON, + op: HttpHeaders.() -> Unit = {} ) = HttpHeaders().apply { this.contentType = contentType diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/files/FileController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/files/FileController.kt index 2735583..3dff79b 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/files/FileController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/files/FileController.kt @@ -24,6 +24,7 @@ class FileController( fun upload(@RequestParam path: String): ResponseEntity { val file = fileService.read(path) return ResponseEntity.ok() + .header("Content-Disposition", "attachment; filename=${getFileNameFromPath(path)}") .contentLength(file.contentLength()) .contentType(MediaType.APPLICATION_OCTET_STREAM) .body(file) @@ -52,4 +53,7 @@ class FileController( ResponseEntity .created(URI.create("${creProperties.deploymentUrl}$FILE_CONTROLLER_PATH?path=$path")) .build() + + private fun getFileNameFromPath(path: String) = + path.split("/").last() } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt index 0b9831b..6facd49 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt @@ -1,23 +1,19 @@ package dev.fyloz.colorrecipesexplorer.service -import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException import dev.fyloz.colorrecipesexplorer.model.* import dev.fyloz.colorrecipesexplorer.repository.MaterialRepository -import dev.fyloz.colorrecipesexplorer.service.files.SimdutService +import dev.fyloz.colorrecipesexplorer.service.files.FileService import io.jsonwebtoken.lang.Assert import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Service interface MaterialService : - ExternalNamedModelService { + ExternalNamedModelService { /** Checks if a material with the given [materialType] exists. */ fun existsByMaterialType(materialType: MaterialType): Boolean - /** Checks if the material with the given [id] has a SIMDUT file. */ - fun hasSimdut(id: Long): Boolean - - /** Gets the SIMDUT file of the material with the given [id]. */ - fun getSimdut(id: Long): ByteArray + /** Checks if the given [material] has a SIMDUT file. */ + fun hasSimdut(material: Material): Boolean /** Gets all materials that are not a mix type. */ fun getAllNotMixType(): Collection @@ -28,53 +24,48 @@ interface MaterialService : /** Gets all materials available for updating the mix with the given [mixId], including normal materials and materials from [MixType]s included in the mix recipe, excluding the material of the [MixType] of the said mix. */ fun getAllForMixUpdate(mixId: Long): Collection - /** Gets the identifier of materials for which a SIMDUT exists. */ - fun getAllIdsWithSimdut(): Collection - /** Updates the quantity of the given [material] with the given [factor] and returns the updated quantity. */ fun updateQuantity(material: Material, factor: Float): Float } @Service class MaterialServiceImpl( - materialRepository: MaterialRepository, - val simdutService: SimdutService, - val recipeService: RecipeService, - val mixService: MixService, - @Lazy val materialTypeService: MaterialTypeService + materialRepository: MaterialRepository, + val recipeService: RecipeService, + val mixService: MixService, + @Lazy val materialTypeService: MaterialTypeService, + val fileService: FileService ) : - AbstractExternalNamedModelService( - materialRepository - ), - MaterialService { + AbstractExternalNamedModelService( + materialRepository + ), + MaterialService { override fun idNotFoundException(id: Long) = materialIdNotFoundException(id) override fun idAlreadyExistsException(id: Long) = materialIdAlreadyExistsException(id) override fun nameNotFoundException(name: String) = materialNameNotFoundException(name) override fun nameAlreadyExistsException(name: String) = materialNameAlreadyExistsException(name) override fun existsByMaterialType(materialType: MaterialType): Boolean = - repository.existsByMaterialType(materialType) + repository.existsByMaterialType(materialType) - override fun hasSimdut(id: Long): Boolean = simdutService.exists(getById(id)) - override fun getSimdut(id: Long): ByteArray = simdutService.read(getById(id)) + override fun hasSimdut(material: Material): Boolean = fileService.exists(material.simdutFilePath) override fun getAllNotMixType(): Collection = getAll().filter { !it.isMixType } - override fun getAllIdsWithSimdut(): Collection = - getAllNotMixType() - .filter { simdutService.exists(it) } - .map { it.id!! } - override fun save(entity: MaterialSaveDto): Material = - save(with(entity) { - material( - name = entity.name, - inventoryQuantity = entity.inventoryQuantity, - materialType = materialTypeService.getById(materialTypeId), - isMixType = false - ) - }).apply { - if (entity.simdutFile != null && !entity.simdutFile.isEmpty) simdutService.write(this, entity.simdutFile) - } + save(with(entity) { + material( + name = entity.name, + inventoryQuantity = entity.inventoryQuantity, + materialType = materialTypeService.getById(materialTypeId), + isMixType = false + ) + }).apply { + if (entity.simdutFile != null && !entity.simdutFile.isEmpty) fileService.write( + entity.simdutFile, + this.simdutFilePath, + false + ) + } override fun update(entity: MaterialUpdateDto): Material { val persistedMaterial by lazy { @@ -83,14 +74,18 @@ class MaterialServiceImpl( return update(with(entity) { material( - id = id, - name = if (name != null && name.isNotBlank()) name else persistedMaterial.name, - inventoryQuantity = if (inventoryQuantity != null && inventoryQuantity != Float.MIN_VALUE) inventoryQuantity else persistedMaterial.inventoryQuantity, - isMixType = persistedMaterial.isMixType, - materialType = if (materialTypeId != null) materialTypeService.getById(materialTypeId) else persistedMaterial.materialType + id = id, + name = if (name != null && name.isNotBlank()) name else persistedMaterial.name, + inventoryQuantity = if (inventoryQuantity != null && inventoryQuantity != Float.MIN_VALUE) inventoryQuantity else persistedMaterial.inventoryQuantity, + isMixType = persistedMaterial.isMixType, + materialType = if (materialTypeId != null) materialTypeService.getById(materialTypeId) else persistedMaterial.materialType ) }).apply { - if (entity.simdutFile != null && !entity.simdutFile.isEmpty) simdutService.update(entity.simdutFile, this) + if (entity.simdutFile != null && !entity.simdutFile.isEmpty) fileService.write( + entity.simdutFile, + this.simdutFilePath, + true + ) } } @@ -103,15 +98,15 @@ class MaterialServiceImpl( override fun getAllForMixCreation(recipeId: Long): Collection { val recipesMixTypes = recipeService.getById(recipeId).mixTypes return getAll() - .filter { !it.isMixType || recipesMixTypes.any { mixType -> mixType.material.id == it.id } } + .filter { !it.isMixType || recipesMixTypes.any { mixType -> mixType.material.id == it.id } } } override fun getAllForMixUpdate(mixId: Long): Collection { val mix = mixService.getById(mixId) val recipesMixTypes = mix.recipe.mixTypes return getAll() - .filter { !it.isMixType || recipesMixTypes.any { mixType -> mixType.material.id == it.id } } - .filter { it.id != mix.mixType.material.id } + .filter { !it.isMixType || recipesMixTypes.any { mixType -> mixType.material.id == it.id } } + .filter { it.id != mix.mixType.material.id } } private fun assertPersistedMaterial(material: Material) { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/SimdutService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/SimdutService.kt deleted file mode 100644 index c781859..0000000 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/SimdutService.kt +++ /dev/null @@ -1,70 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.service.files - -import dev.fyloz.colorrecipesexplorer.exception.RestException -import dev.fyloz.colorrecipesexplorer.model.Material -import org.slf4j.Logger -import org.springframework.http.HttpStatus -import org.springframework.stereotype.Service -import org.springframework.web.multipart.MultipartFile -import java.io.IOException - -const val SIMDUT_DIRECTORY = "simdut" - -@Service -class SimdutService( - private val fileService: FileService, - private val logger: Logger -) { - /** Checks if the given [material] has a SIMDUT file. */ - fun exists(material: Material) = - fileService.exists(getPath(material)) - - /** Reads the SIMDUT file of the given [material]. */ - // TODO change return type to ByteArrayResource - fun read(material: Material): ByteArray { - val path = getPath(material) - if (!fileService.exists(path)) return ByteArray(0) - - return try { - fileService.read(path).byteArray - } catch (ex: IOException) { - logger.error("Could not read SIMDUT file", ex) - ByteArray(0) - } - } - - /** Writes the given [simdut] file for the given [material] to the disk. */ - fun write(material: Material, simdut: MultipartFile) { - try { - fileService.write(simdut, getPath(material), true) - } catch (ex: FileWriteException) { - throw SimdutWriteException(material) - } - } - - /** Updates the SIMDUT file of the given [material] with the given [simdut]. */ - fun update(simdut: MultipartFile, material: Material) { - delete(material) - write(material, simdut) - } - - /** Deletes the SIMDUT file of the given [material]. */ - fun delete(material: Material) = - fileService.delete(getPath(material)) - - /** Gets the path of the SIMDUT file of the given [material]. */ - fun getPath(material: Material) = - "$SIMDUT_DIRECTORY/${getSimdutFileName(material)}" - - /** Gets the name of the SIMDUT file of the given [material]. */ - fun getSimdutFileName(material: Material) = - material.id.toString() -} - -class SimdutWriteException(material: Material) : - RestException( - "simdut-write", - "Could not write SIMDUT file", - HttpStatus.INTERNAL_SERVER_ERROR, - "Could not write the SIMDUT file for the material ${material.name} to the disk" - ) diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialServiceTest.kt index 9def652..0025064 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialServiceTest.kt @@ -4,7 +4,7 @@ import com.nhaarman.mockitokotlin2.* import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException import dev.fyloz.colorrecipesexplorer.model.* import dev.fyloz.colorrecipesexplorer.repository.MaterialRepository -import dev.fyloz.colorrecipesexplorer.service.files.SimdutService +import dev.fyloz.colorrecipesexplorer.service.files.FileService import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows @@ -16,12 +16,12 @@ import kotlin.test.assertTrue class MaterialServiceTest : AbstractExternalNamedModelServiceTest() { override val repository: MaterialRepository = mock() - private val simdutService: SimdutService = mock() private val recipeService: RecipeService = mock() private val mixService: MixService = mock() private val materialTypeService: MaterialTypeService = mock() + private val fileService: FileService = mock() override val service: MaterialService = - spy(MaterialServiceImpl(repository, simdutService, recipeService, mixService, materialTypeService)) + spy(MaterialServiceImpl(repository, recipeService, mixService, materialTypeService, fileService)) override val entity: Material = material(id = 0L, name = "material") override val anotherEntity: Material = material(id = 1L, name = "another material") @@ -33,7 +33,7 @@ class MaterialServiceTest : @AfterEach override fun afterEach() { - reset(simdutService) + reset(recipeService, mixService, materialTypeService, fileService) super.afterEach() } @@ -61,20 +61,20 @@ class MaterialServiceTest : @Test fun `hasSimdut() returns false when simdutService_exists() returns false`() { - whenever(simdutService.exists(entity)).doReturn(false) + whenever(fileService.exists(any())).doReturn(false) doReturn(entity).whenever(service).getById(entity.id!!) - val found = service.hasSimdut(entity.id!!) + val found = service.hasSimdut(entity) assertFalse(found) } @Test fun `hasSimdut() returns true when simdutService_exists() returns true`() { - whenever(simdutService.exists(entity)).doReturn(true) + whenever(fileService.exists(any())).doReturn(true) doReturn(entity).whenever(service).getById(entity.id!!) - val found = service.hasSimdut(entity.id!!) + val found = service.hasSimdut(entity) assertTrue(found) } @@ -94,33 +94,6 @@ class MaterialServiceTest : assertFalse(found.contains(mixTypeMaterial)) } - // getAllIdsWithSimdut() - - @Test - fun `getAllIdsWithSimdut() returns a list containing the identifier of every material with a SIMDUT file`() { - val materials = listOf( - material(id = 0L), - material(id = 1L), - material(id = 2L), - material(id = 3L) - ) - val hasSimdut = mapOf( - *materials - .map { it.id!! to it.evenId } - .toTypedArray() - ) - val expectedIds = hasSimdut - .filter { it.value } - .map { it.key } - - whenever(simdutService.exists(any())).doAnswer { hasSimdut[(it.arguments[0] as Material).id] } - doReturn(materials).whenever(service).getAllNotMixType() - - val found = service.getAllIdsWithSimdut() - - assertEquals(expectedIds, found) - } - // save() @Test @@ -146,7 +119,7 @@ class MaterialServiceTest : service.save(materialSaveDto) - verify(simdutService).write(entity, mockMultipartFile) + verify(fileService).write(mockMultipartFile, entity.simdutFilePath, false) } // update() @@ -163,6 +136,21 @@ class MaterialServiceTest : .assertErrorCode("name") } + @Test + override fun `update(dto) calls and returns update() with the created entity`() { + 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() + + service.update(materialUpdateDto) + + verify(fileService).write(mockSimdutFile, entity.simdutFilePath, true) + } + // updateQuantity() @Test @@ -220,22 +208,6 @@ class MaterialServiceTest : assertFalse(anotherMixTypeMaterial in found) } - // update() - - @Test - override fun `update(dto) calls and returns update() with the created entity`() { - 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).update(any()) - doReturn(entity).whenever(materialUpdateDto).toEntity() - - service.update(materialUpdateDto) - - verify(simdutService).update(eq(mockSimdutFile), any()) - } - // delete() diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/SimdutServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/SimdutServiceTest.kt deleted file mode 100644 index 22ecfc0..0000000 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/SimdutServiceTest.kt +++ /dev/null @@ -1,140 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.service.files - -import com.nhaarman.mockitokotlin2.* -import dev.fyloz.colorrecipesexplorer.model.Material -import dev.fyloz.colorrecipesexplorer.model.material -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.web.multipart.MultipartFile -import java.io.IOException -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class SimdutServiceTest { - private val fileService = mock() - private val service = spy(SimdutService(fileService, mock())) - - private val material = material(id = 0L) - - @AfterEach - fun afterEach() { - reset(fileService, service) - } - - @JvmName("withNullableMaterialPath") - private inline fun withMaterialPath(material: Material? = null, exists: Boolean = true, test: (String) -> Unit) = - withMaterialPath(material ?: this.material, exists, test) - - private inline fun withMaterialPath(material: Material, exists: Boolean = true, test: (String) -> Unit) { - val path = "data/simdut/${material.id}" - doReturn(path).whenever(service).getPath(material) - whenever(fileService.exists(path)).doReturn(exists) - - test(path) - } - - // exists() - - @Test - fun `exists() returns true when a SIMDUT file exists for the given material`() { - withMaterialPath { - assertTrue { service.exists(material) } - } - } - - @Test - fun `exists() returns false when no SIMDUT file exists for the given material`() { - withMaterialPath(exists = false) { - assertFalse { service.exists(material) } - } - } - - // read() - - @Test - fun `read() returns a filled ByteArray when a SIMDUT exists for the given material`() { - withMaterialPath { path -> - val simdutContent = byteArrayOf(0xf) - - whenever(fileService.read(path)).doReturn(ByteArrayResource(simdutContent)) - - val found = service.read(material) - - assertEquals(simdutContent, found) - } - } - - @Test - fun `read() returns a empty ByteArray when no SIMDUT exists for the given material`() { - withMaterialPath(exists = false) { - val found = service.read(material) - - assertTrue { found.isEmpty() } - } - } - - @Test - fun `read() returns a empty ByteArray when reading the SIMDUT throws an IOException`() { - withMaterialPath { path -> - whenever(fileService.read(path)).doAnswer { throw IOException() } - - val found = service.read(material) - - assertTrue { found.isEmpty() } - } - } - - // write() - - @Test - fun `write() writes the given MultipartFile to the disk for the given material`() { - withMaterialPath { path -> - val simdutMultipart = mock() - - service.write(material, simdutMultipart) - - verify(fileService).write(simdutMultipart, path, true) - } - } - - @Test - fun `write() throws a SimdutWriteException when writing the given MultipartFile to the disk fails`() { - withMaterialPath { path -> - val simdutMultipart = mock() - - whenever(fileService.write(simdutMultipart, path, true)).doAnswer { throw FileCreateException(path) } - - assertThrows { service.write(material, simdutMultipart) } - } - } - - // update() - - @Test - fun `update() deletes and write the SIMDUT for the given material`() { - val simdutMultipart = mock() - - // Prevents calling the actual implementation - doAnswer { }.whenever(service).delete(material) - doAnswer { }.whenever(service).write(material, simdutMultipart) - - service.update(simdutMultipart, material) - - verify(service).delete(material) - verify(service).write(material, simdutMultipart) - } - - // delete() - - @Test - fun `delete() deletes the SIMDUT of the given material from the disk`() { - withMaterialPath { path -> - service.delete(material) - - verify(fileService).delete(path) - } - } -} From 361b1b2ba35b0f6eaa3bc7239f8db6ad16793550 Mon Sep 17 00:00:00 2001 From: FyloZ Date: Wed, 28 Apr 2021 13:24:41 -0400 Subject: [PATCH 4/6] 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 { From 64829f74cb0411269594d0926a4ddd680037c7ea Mon Sep 17 00:00:00 2001 From: FyloZ Date: Wed, 28 Apr 2021 14:27:29 -0400 Subject: [PATCH 5/6] =?UTF-8?q?Ajout=20des=20certaines=20chaines=20de=20ca?= =?UTF-8?q?ract=C3=A8res=20bannies=20dans=20le=20chemin=20des=20fichiers?= =?UTF-8?q?=20dans=20FileService.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rest/files/FileController.kt | 10 ++-- .../service/files/FileService.kt | 28 +++++++++- .../service/files/FileServiceTest.kt | 52 ++++++++++++------- 3 files changed, 67 insertions(+), 23 deletions(-) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/files/FileController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/files/FileController.kt index 3dff79b..9e5125b 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/files/FileController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/files/FileController.kt @@ -12,6 +12,7 @@ import org.springframework.web.multipart.MultipartFile import java.net.URI const val FILE_CONTROLLER_PATH = "/api/file" +private const val DEFAULT_MEDIA_TYPE = MediaType.APPLICATION_OCTET_STREAM_VALUE @RestController @RequestMapping(FILE_CONTROLLER_PATH) @@ -21,12 +22,15 @@ class FileController( ) { @GetMapping(produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE]) @PreAuthorize("hasAnyAuthority('READ_FILE')") - fun upload(@RequestParam path: String): ResponseEntity { + fun upload( + @RequestParam path: String, + @RequestParam(required = false) mediaType: String? + ): ResponseEntity { val file = fileService.read(path) return ResponseEntity.ok() - .header("Content-Disposition", "attachment; filename=${getFileNameFromPath(path)}") + .header("Content-Disposition", "filename=${getFileNameFromPath(path)}") .contentLength(file.contentLength()) - .contentType(MediaType.APPLICATION_OCTET_STREAM) + .contentType(MediaType.parseMediaType(mediaType ?: DEFAULT_MEDIA_TYPE)) .body(file) } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileService.kt index 8fa6d3d..4600a8a 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileService.kt @@ -11,6 +11,13 @@ import java.io.File import java.io.IOException import java.nio.file.Files +/** Banned path shards. These are banned because they can allow access to files outside the data directory. */ +val BANNED_FILE_PATH_SHARDS = setOf( + "~", + "..", + "//" +) + interface FileService { /** Checks if the file at the given [path] exists. */ fun exists(path: String): Boolean @@ -93,8 +100,13 @@ class FileServiceImpl( } } - override fun String.fullPath() = - FilePath("${creProperties.workingDirectory}/$this") + override fun String.fullPath(): FilePath { + BANNED_FILE_PATH_SHARDS + .firstOrNull { this.contains(it) } + ?.let { throw InvalidFilePathException(this, it) } + + return FilePath("${creProperties.workingDirectory}/$this") + } /** Runs the given [block] in the context of a file with the given [fullPath]. */ private fun withFileAt(fullPath: FilePath, block: File.() -> T) = @@ -114,6 +126,18 @@ fun File.create() { private const val FILE_IO_EXCEPTION_TITLE = "File IO error" +class InvalidFilePathException(val path: String, val fragment: String) : + RestException( + "invalid-filepath", + "Invalid file path", + HttpStatus.BAD_REQUEST, + "The given path is invalid because it contains a potentially malicious String '$fragment'", + mapOf( + "path" to path, + "invalidString" to fragment + ) + ) + class FileExistsException(val path: String) : RestException( "io-exists", 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 d66f05f..5316d9b 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileServiceTest.kt @@ -51,14 +51,14 @@ class FileServiceTest { @Test fun `exists() returns true when the file at the given path exists and is a file`() { - fileServiceTest { + test { assertTrue { fileService.exists(mockFilePath) } } } @Test fun `exists() returns false when the file at the given path does not exist`() { - fileServiceTest { + test { every { mockFile.exists() } returns false assertFalse { fileService.exists(mockFilePath) } @@ -67,7 +67,7 @@ class FileServiceTest { @Test fun `exists() returns false when the file at the given path is not a file`() { - fileServiceTest { + test { every { mockFile.isFile } returns false assertFalse { fileService.exists(mockFilePath) } @@ -78,7 +78,7 @@ class FileServiceTest { @Test fun `read() returns a valid ByteArrayResource`() { - fileServiceTest { + test { whenMockFilePathExists { mockkStatic(File::readBytes) every { mockFile.readBytes() } returns mockFileData @@ -92,7 +92,7 @@ class FileServiceTest { @Test fun `read() throws FileNotFoundException when no file exists at the given path`() { - fileServiceTest { + test { whenMockFilePathExists(false) { with(assertThrows { fileService.read(mockFilePath) }) { assertEquals(mockFilePath, this.path) @@ -103,7 +103,7 @@ class FileServiceTest { @Test fun `read() throws FileReadException when an IOException is thrown`() { - fileServiceTest { + test { whenMockFilePathExists { mockkStatic(File::readBytes) every { mockFile.readBytes() } throws IOException() @@ -119,7 +119,7 @@ class FileServiceTest { @Test fun `create() creates a file at the given path`() { - fileServiceTest { + test { whenMockFilePathExists(false) { mockkStatic(File::create) every { mockFile.create() } just Runs @@ -135,7 +135,7 @@ class FileServiceTest { @Test fun `create() does nothing when a file already exists at the given path`() { - fileServiceTest { + test { whenMockFilePathExists { fileService.create(mockFilePath) @@ -148,7 +148,7 @@ class FileServiceTest { @Test fun `create() throws FileCreateException when the file creation throws an IOException`() { - fileServiceTest { + test { whenMockFilePathExists(false) { mockkStatic(File::create) every { mockFile.create() } throws IOException() @@ -164,7 +164,7 @@ class FileServiceTest { @Test fun `write() creates and writes the given MultipartFile to the file at the given path`() { - fileServiceTest { + test { whenMockFilePathExists(false) { every { fileService.create(mockFilePath) } just Runs every { mockMultipartFile.transferTo(mockFilePathPath) } just Runs @@ -181,7 +181,7 @@ class FileServiceTest { @Test fun `write() throws FileExistsException when a file at the given path already exists and overwrite is disabled`() { - fileServiceTest { + test { whenMockFilePathExists { with(assertThrows { fileService.write(mockMultipartFile, mockFilePath, false) }) { assertEquals(mockFilePath, this.path) @@ -192,7 +192,7 @@ class FileServiceTest { @Test fun `write() writes the given MultipartFile to an existing file when overwrite is enabled`() { - fileServiceTest { + test { whenMockFilePathExists { every { mockMultipartFile.transferTo(mockFilePathPath) } just Runs @@ -207,7 +207,7 @@ class FileServiceTest { @Test fun `write() throws FileWriteException when writing the given file throws an IOException`() { - fileServiceTest { + test { whenMockFilePathExists(false) { every { fileService.create(mockFilePath) } just Runs every { mockMultipartFile.transferTo(mockFilePathPath) } throws IOException() @@ -225,7 +225,7 @@ class FileServiceTest { @Test fun `delete() deletes the file at the given path`() { - fileServiceTest { + test { whenMockFilePathExists { every { mockFile.delete() } returns true @@ -236,7 +236,7 @@ class FileServiceTest { @Test fun `delete() throws FileNotFoundException when no file exists at the given path`() { - fileServiceTest { + test { whenMockFilePathExists(false) { with(assertThrows { fileService.delete(mockFilePath) }) { assertEquals(mockFilePath, this.path) @@ -247,7 +247,7 @@ class FileServiceTest { @Test fun `delete() throws FileDeleteException when deleting throw and IOException`() { - fileServiceTest { + test { whenMockFilePathExists { every { mockFile.delete() } throws IOException() @@ -262,7 +262,7 @@ class FileServiceTest { @Test fun `fullPath() appends the given path to the given working directory`() { - fileServiceTest { + test { with(fileService) { val fullFilePath = mockFilePath.fullPath() @@ -271,7 +271,23 @@ class FileServiceTest { } } - private fun fileServiceTest(test: FileServiceTestContext.() -> Unit) { + @Test + fun `fullPath() throws InvalidFilePathException when the given path contains invalid fragments`() { + test { + with(fileService) { + BANNED_FILE_PATH_SHARDS.forEach { + val maliciousPath = "$it/$mockFilePath" + + with(assertThrows { maliciousPath.fullPath() }) { + assertEquals(maliciousPath, this.path) + assertEquals(it, this.fragment) + } + } + } + } + } + + private fun test(test: FileServiceTestContext.() -> Unit) { FileServiceTestContext().test() } From ef27e57f47ef36e049881a815ad2875959dd3904 Mon Sep 17 00:00:00 2001 From: FyloZ Date: Wed, 28 Apr 2021 17:50:05 -0400 Subject: [PATCH 6/6] Tout fonctionne! --- ...aLoader.kt => ApplicationReadyListener.kt} | 15 +- .../colorrecipesexplorer/model/Employee.kt | 224 +++++++++--------- .../model/EmployeeGroup.kt | 9 +- .../colorrecipesexplorer/model/Material.kt | 27 +-- .../fyloz/colorrecipesexplorer/model/Mix.kt | 159 +++++++------ .../colorrecipesexplorer/model/MixMaterial.kt | 77 +++--- .../colorrecipesexplorer/model/Recipe.kt | 41 ++-- .../rest/AccountControllers.kt | 106 +++++---- .../rest/CompanyController.kt | 4 +- .../rest/InventoryController.kt | 11 +- .../rest/MaterialController.kt | 55 ++--- .../rest/MaterialTypeController.kt | 4 +- .../rest/RecipeController.kt | 39 +-- .../colorrecipesexplorer/rest/RestUtils.kt | 3 + .../service/AccountService.kt | 184 +++++++------- .../service/CompanyService.kt | 19 +- .../service/MaterialService.kt | 41 +++- .../service/MaterialTypeService.kt | 6 +- .../service/MixMaterialService.kt | 101 ++++---- .../service/MixService.kt | 27 ++- .../service/RecipeService.kt | 32 ++- .../colorrecipesexplorer/service/Service.kt | 58 +++-- .../service/AbstractServiceTest.kt | 52 ++-- .../service/MaterialServiceTest.kt | 39 +-- .../service/RecipeServiceTest.kt | 4 +- 25 files changed, 722 insertions(+), 615 deletions(-) rename src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/{InitialDataLoader.kt => ApplicationReadyListener.kt} (58%) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/InitialDataLoader.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ApplicationReadyListener.kt similarity index 58% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/InitialDataLoader.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ApplicationReadyListener.kt index 0e1ca4b..f135661 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/InitialDataLoader.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ApplicationReadyListener.kt @@ -1,6 +1,8 @@ package dev.fyloz.colorrecipesexplorer.config +import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties import dev.fyloz.colorrecipesexplorer.config.properties.MaterialTypeProperties +import dev.fyloz.colorrecipesexplorer.rest.CRE_PROPERTIES import dev.fyloz.colorrecipesexplorer.service.MaterialTypeService import org.springframework.boot.context.event.ApplicationReadyEvent import org.springframework.context.ApplicationListener @@ -10,10 +12,13 @@ import org.springframework.core.annotation.Order @Configuration @Order(Ordered.HIGHEST_PRECEDENCE) -class InitialDataLoader( - private val materialTypeService: MaterialTypeService, - private val materialTypeProperties: MaterialTypeProperties +class ApplicationReadyListener( + private val materialTypeService: MaterialTypeService, + private val materialTypeProperties: MaterialTypeProperties, + private val creProperties: CreProperties ) : ApplicationListener { - override fun onApplicationEvent(event: ApplicationReadyEvent) = - materialTypeService.saveSystemTypes(materialTypeProperties.systemTypes) + override fun onApplicationEvent(event: ApplicationReadyEvent) { + materialTypeService.saveSystemTypes(materialTypeProperties.systemTypes) + CRE_PROPERTIES = creProperties + } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Employee.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Employee.kt index fa564eb..1b98d41 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Employee.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Employee.kt @@ -1,7 +1,5 @@ package dev.fyloz.colorrecipesexplorer.model -import com.fasterxml.jackson.annotation.JsonIgnore -import com.fasterxml.jackson.annotation.JsonProperty import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException import dev.fyloz.colorrecipesexplorer.exception.NotFoundException import dev.fyloz.colorrecipesexplorer.model.validation.NullOrNotBlank @@ -25,139 +23,143 @@ private const val EMPLOYEE_PASSWORD_TOO_SHORT_MESSAGE = "Le mot de passe doit co @Entity @Table(name = "employee") data class Employee( - @Id - override val id: Long, + @Id + override val id: Long, - @Column(name = "first_name") - val firstName: String = "", + @Column(name = "first_name") + val firstName: String = "", - @Column(name = "last_name") - val lastName: String = "", + @Column(name = "last_name") + val lastName: String = "", - @JsonIgnore - val password: String = "", + val password: String = "", - @JsonIgnore - @Column(name = "default_group_user") - val isDefaultGroupUser: Boolean = false, + @Column(name = "default_group_user") + val isDefaultGroupUser: Boolean = false, - @JsonIgnore - @Column(name = "system_user") - val isSystemUser: Boolean = false, + @Column(name = "system_user") + val isSystemUser: Boolean = false, - @ManyToOne - @JoinColumn(name = "group_id") - @Fetch(FetchMode.SELECT) - var group: EmployeeGroup? = null, + @ManyToOne + @JoinColumn(name = "group_id") + @Fetch(FetchMode.SELECT) + var group: EmployeeGroup? = null, - @Enumerated(EnumType.STRING) - @ElementCollection(fetch = FetchType.EAGER) - @CollectionTable(name = "employee_permission", joinColumns = [JoinColumn(name = "employee_id")]) - @Column(name = "permission") - @Fetch(FetchMode.SUBSELECT) - @get:JsonProperty("explicitPermissions") - val permissions: MutableSet = mutableSetOf(), + @Enumerated(EnumType.STRING) + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "employee_permission", joinColumns = [JoinColumn(name = "employee_id")]) + @Column(name = "permission") + @Fetch(FetchMode.SUBSELECT) + val permissions: MutableSet = mutableSetOf(), - @Column(name = "last_login_time") - var lastLoginTime: LocalDateTime? = null + @Column(name = "last_login_time") + var lastLoginTime: LocalDateTime? = null ) : Model { - @get:JsonProperty("permissions") val flatPermissions: Set get() = permissions - .flatMap { it.flat() } - .filter { !it.deprecated } - .toMutableSet() - .apply { - if (group != null) this.addAll(group!!.flatPermissions) - } + .flatMap { it.flat() } + .filter { !it.deprecated } + .toMutableSet() + .apply { + if (group != null) this.addAll(group!!.flatPermissions) + } - @get:JsonIgnore val authorities: Set get() = flatPermissions.map { it.toAuthority() }.toMutableSet() } /** DTO for creating employees. Allows a [password] a [groupId]. */ open class EmployeeSaveDto( - @field:NotNull(message = EMPLOYEE_ID_NULL_MESSAGE) - val id: Long, + @field:NotNull(message = EMPLOYEE_ID_NULL_MESSAGE) + val id: Long, - @field:NotBlank(message = EMPLOYEE_FIRST_NAME_EMPTY_MESSAGE) - val firstName: String, + @field:NotBlank(message = EMPLOYEE_FIRST_NAME_EMPTY_MESSAGE) + val firstName: String, - @field:NotBlank(message = EMPLOYEE_LAST_NAME_EMPTY_MESSAGE) - val lastName: String, + @field:NotBlank(message = EMPLOYEE_LAST_NAME_EMPTY_MESSAGE) + val lastName: String, - @field:NotBlank(message = EMPLOYEE_PASSWORD_EMPTY_MESSAGE) - @field:Size(min = 8, message = EMPLOYEE_PASSWORD_TOO_SHORT_MESSAGE) - val password: String, + @field:NotBlank(message = EMPLOYEE_PASSWORD_EMPTY_MESSAGE) + @field:Size(min = 8, message = EMPLOYEE_PASSWORD_TOO_SHORT_MESSAGE) + val password: String, - val groupId: Long?, + val groupId: Long?, - @Enumerated(EnumType.STRING) - val permissions: MutableSet = mutableSetOf() + @Enumerated(EnumType.STRING) + val permissions: MutableSet = mutableSetOf() ) : EntityDto open class EmployeeUpdateDto( - @field:NotNull(message = EMPLOYEE_ID_NULL_MESSAGE) - val id: Long, + @field:NotNull(message = EMPLOYEE_ID_NULL_MESSAGE) + val id: Long, - @field:NullOrNotBlank(message = EMPLOYEE_FIRST_NAME_EMPTY_MESSAGE) - val firstName: String?, + @field:NullOrNotBlank(message = EMPLOYEE_FIRST_NAME_EMPTY_MESSAGE) + val firstName: String?, - @field:NullOrNotBlank(message = EMPLOYEE_LAST_NAME_EMPTY_MESSAGE) - val lastName: String?, + @field:NullOrNotBlank(message = EMPLOYEE_LAST_NAME_EMPTY_MESSAGE) + val lastName: String?, - val groupId: Long?, + val groupId: Long?, - @Enumerated(EnumType.STRING) - val permissions: Set? + @Enumerated(EnumType.STRING) + val permissions: Set? ) : EntityDto +data class EmployeeOutputDto( + override val id: Long, + val firstName: String, + val lastName: String, + val group: EmployeeGroup?, + val permissions: Set, + val explicitPermissions: Set, + val lastLoginTime: LocalDateTime? +) : Model + data class EmployeeLoginRequest(val id: Long, val password: String) // ==== DSL ==== fun employee( - passwordEncoder: PasswordEncoder = BCryptPasswordEncoder(), - id: Long = 0L, - firstName: String = "firstName", - lastName: String = "lastName", - password: String = passwordEncoder.encode("password"), - isDefaultGroupUser: Boolean = false, - isSystemUser: Boolean = false, - group: EmployeeGroup? = null, - permissions: MutableSet = mutableSetOf(), - lastLoginTime: LocalDateTime? = null, - op: Employee.() -> Unit = {} + passwordEncoder: PasswordEncoder = BCryptPasswordEncoder(), + id: Long = 0L, + firstName: String = "firstName", + lastName: String = "lastName", + password: String = passwordEncoder.encode("password"), + isDefaultGroupUser: Boolean = false, + isSystemUser: Boolean = false, + group: EmployeeGroup? = null, + permissions: MutableSet = mutableSetOf(), + lastLoginTime: LocalDateTime? = null, + op: Employee.() -> Unit = {} ) = Employee( - id, - firstName, - lastName, - password, - isDefaultGroupUser, - isSystemUser, - group, - permissions, - lastLoginTime + id, + firstName, + lastName, + password, + isDefaultGroupUser, + isSystemUser, + group, + permissions, + lastLoginTime ).apply(op) fun employeeSaveDto( - passwordEncoder: PasswordEncoder = BCryptPasswordEncoder(), - id: Long = 0L, - firstName: String = "firstName", - lastName: String = "lastName", - password: String = passwordEncoder.encode("password"), - groupId: Long? = null, - permissions: MutableSet = mutableSetOf(), - op: EmployeeSaveDto.() -> Unit = {} + passwordEncoder: PasswordEncoder = BCryptPasswordEncoder(), + id: Long = 0L, + firstName: String = "firstName", + lastName: String = "lastName", + password: String = passwordEncoder.encode("password"), + groupId: Long? = null, + permissions: MutableSet = mutableSetOf(), + op: EmployeeSaveDto.() -> Unit = {} ) = EmployeeSaveDto(id, firstName, lastName, password, groupId, permissions).apply(op) fun employeeUpdateDto( - id: Long = 0L, - firstName: String = "firstName", - lastName: String = "lastName", - groupId: Long? = null, - permissions: MutableSet = mutableSetOf(), - op: EmployeeUpdateDto.() -> Unit = {} + id: Long = 0L, + firstName: String = "firstName", + lastName: String = "lastName", + groupId: Long? = null, + permissions: MutableSet = mutableSetOf(), + op: EmployeeUpdateDto.() -> Unit = {} ) = EmployeeUpdateDto(id, firstName, lastName, groupId, permissions).apply(op) // ==== Exceptions ==== @@ -166,26 +168,26 @@ private const val EMPLOYEE_ALREADY_EXISTS_EXCEPTION_TITLE = "Employee already ex private const val EMPLOYEE_EXCEPTION_ERROR_CODE = "employee" fun employeeIdNotFoundException(id: Long) = - NotFoundException( - EMPLOYEE_EXCEPTION_ERROR_CODE, - EMPLOYEE_NOT_FOUND_EXCEPTION_TITLE, - "An employee with the id $id could not be found", - id - ) + NotFoundException( + EMPLOYEE_EXCEPTION_ERROR_CODE, + EMPLOYEE_NOT_FOUND_EXCEPTION_TITLE, + "An employee with the id $id could not be found", + id + ) fun employeeIdAlreadyExistsException(id: Long) = - AlreadyExistsException( - EMPLOYEE_EXCEPTION_ERROR_CODE, - EMPLOYEE_ALREADY_EXISTS_EXCEPTION_TITLE, - "An employee with the id $id already exists", - id - ) + AlreadyExistsException( + EMPLOYEE_EXCEPTION_ERROR_CODE, + EMPLOYEE_ALREADY_EXISTS_EXCEPTION_TITLE, + "An employee with the id $id already exists", + id + ) fun employeeFullNameAlreadyExistsException(firstName: String, lastName: String) = - AlreadyExistsException( - EMPLOYEE_EXCEPTION_ERROR_CODE, - EMPLOYEE_ALREADY_EXISTS_EXCEPTION_TITLE, - "An employee with the name '$firstName $lastName' already exists", - "$firstName $lastName", - "fullName" - ) + AlreadyExistsException( + EMPLOYEE_EXCEPTION_ERROR_CODE, + EMPLOYEE_ALREADY_EXISTS_EXCEPTION_TITLE, + "An employee with the name '$firstName $lastName' already exists", + "$firstName $lastName", + "fullName" + ) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/EmployeeGroup.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/EmployeeGroup.kt index a0d467e..a88e0e4 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/EmployeeGroup.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/EmployeeGroup.kt @@ -31,10 +31,8 @@ data class EmployeeGroup( @CollectionTable(name = "group_permission", joinColumns = [JoinColumn(name = "group_id")]) @Column(name = "permission") @Fetch(FetchMode.SUBSELECT) - @get:JsonProperty("explicitPermissions") val permissions: MutableSet = mutableSetOf(), ) : NamedModel { - @get:JsonProperty("permissions") val flatPermissions: Set get() = this.permissions .flatMap { it.flat() } @@ -69,6 +67,13 @@ open class EmployeeGroupUpdateDto( EmployeeGroup(id, name, permissions) } +data class EmployeeGroupOutputDto( + override val id: Long, + val name: String, + val permissions: Set, + val explicitPermissions: Set +): Model + fun employeeGroup( id: Long? = null, name: String = "name", diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Material.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Material.kt index b337627..5dc53de 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Material.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Material.kt @@ -6,8 +6,11 @@ import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException import dev.fyloz.colorrecipesexplorer.exception.NotFoundException import dev.fyloz.colorrecipesexplorer.model.validation.NullOrNotBlank import dev.fyloz.colorrecipesexplorer.model.validation.NullOrSize +import dev.fyloz.colorrecipesexplorer.rest.CRE_PROPERTIES +import dev.fyloz.colorrecipesexplorer.rest.files.FILE_CONTROLLER_PATH import org.springframework.web.multipart.MultipartFile -import java.net.URI +import java.net.URLEncoder +import java.nio.charset.StandardCharsets import javax.persistence.* import javax.validation.constraints.Min import javax.validation.constraints.NotBlank @@ -49,6 +52,8 @@ data class Material( @JsonIgnore @Transient get() = "$SIMDUT_FILES_PATH/$name.pdf" + + } open class MaterialSaveDto( @@ -81,13 +86,13 @@ open class MaterialUpdateDto( ) : EntityDto data class MaterialOutputDto( - val id: Long, + override val id: Long, val name: String, val inventoryQuantity: Float, val isMixType: Boolean, val materialType: MaterialType, val simdutUrl: String? -) +) : Model data class MaterialQuantityDto( @field:NotNull(message = MATERIAL_QUANTITY_MATERIAL_NULL_MESSAGE) @@ -135,19 +140,6 @@ fun materialUpdateDto( op: MaterialUpdateDto.() -> Unit = {} ) = MaterialUpdateDto(id, name, inventoryQuantity, materialTypeId, simdutFile).apply(op) -fun materialOutputDto( - material: Material, - simdutUrl: String?, - op: MaterialOutputDto.() -> Unit = {} -) = MaterialOutputDto( - id = material.id!!, - name = material.name, - inventoryQuantity = material.inventoryQuantity, - isMixType = material.isMixType, - materialType = material.materialType!!, - simdutUrl = simdutUrl -).apply(op) - fun materialQuantityDto( materialId: Long, quantity: Float, @@ -155,7 +147,8 @@ fun materialQuantityDto( ) = MaterialQuantityDto(materialId, quantity).apply(op) // ==== Exceptions ==== -private const val MATERIAL_NOT_FOUND_EXCEPTION_TITLE = "Material not found" + private const +val MATERIAL_NOT_FOUND_EXCEPTION_TITLE = "Material not found" private const val MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE = "Material already exists" private const val MATERIAL_CANNOT_DELETE_EXCEPTION_TITLE = "Cannot delete material" private const val MATERIAL_EXCEPTION_ERROR_CODE = "material" diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Mix.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Mix.kt index 4dcb08f..e332b36 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Mix.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Mix.kt @@ -22,107 +22,116 @@ private const val MIX_DEDUCT_RATION_NEGATIVE_MESSAGE = "Le ratio doit être éga @Entity @Table(name = "mix") data class Mix( - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - override val id: Long?, + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + override val id: Long?, - var location: String?, + var location: String?, - @JsonIgnore - @ManyToOne - @JoinColumn(name = "recipe_id") - val recipe: Recipe, + @JsonIgnore + @ManyToOne + @JoinColumn(name = "recipe_id") + val recipe: Recipe, - @ManyToOne - @JoinColumn(name = "mix_type_id") - var mixType: MixType, + @ManyToOne + @JoinColumn(name = "mix_type_id") + var mixType: MixType, - @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true) - @JoinColumn(name = "mix_id") - var mixMaterials: MutableSet, + @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true) + @JoinColumn(name = "mix_id") + var mixMaterials: MutableSet, ) : Model open class MixSaveDto( - @field:NotBlank(message = MIX_NAME_NULL_MESSAGE) - val name: String, + @field:NotBlank(message = MIX_NAME_NULL_MESSAGE) + val name: String, - @field:NotNull(message = MIX_RECIPE_NULL_MESSAGE) - val recipeId: Long, + @field:NotNull(message = MIX_RECIPE_NULL_MESSAGE) + val recipeId: Long, - @field:NotNull(message = MIX_MATERIAL_TYPE_NULL_MESSAGE) - val materialTypeId: Long, + @field:NotNull(message = MIX_MATERIAL_TYPE_NULL_MESSAGE) + val materialTypeId: Long, - val mixMaterials: Set? + val mixMaterials: Set? ) : EntityDto { override fun toEntity(): Mix = throw UnsupportedOperationException() } open class MixUpdateDto( - @field:NotNull(message = MIX_ID_NULL_MESSAGE) - val id: Long, + @field:NotNull(message = MIX_ID_NULL_MESSAGE) + val id: Long, - @field:NullOrNotBlank(message = MIX_NAME_NULL_MESSAGE) - val name: String?, + @field:NullOrNotBlank(message = MIX_NAME_NULL_MESSAGE) + val name: String?, - val materialTypeId: Long?, + val materialTypeId: Long?, - var mixMaterials: Set? + var mixMaterials: Set? ) : EntityDto { override fun toEntity(): Mix = throw UnsupportedOperationException() } -data class MixDeductDto( - @field:NotNull(message = MIX_DEDUCT_MIX_ID_NULL_MESSAGE) - val id: Long, +data class MixOutputDto( + val id: Long, + val location: String?, + val mixType: MixType, + val mixMaterials: Set +) - @field:NotNull(message = MIX_DEDUCT_RATIO_NULL_MESSAGE) - @field:Min(value = 0, message = MIX_DEDUCT_RATION_NEGATIVE_MESSAGE) - val ratio: Float +data class MixDeductDto( + @field:NotNull(message = MIX_DEDUCT_MIX_ID_NULL_MESSAGE) + val id: Long, + + @field:NotNull(message = MIX_DEDUCT_RATIO_NULL_MESSAGE) + @field:Min(value = 0, message = MIX_DEDUCT_RATION_NEGATIVE_MESSAGE) + val ratio: Float ) data class MixLocationDto( - @field:NotNull(message = MIX_DEDUCT_MIX_ID_NULL_MESSAGE) - val mixId: Long, + @field:NotNull(message = MIX_DEDUCT_MIX_ID_NULL_MESSAGE) + val mixId: Long, - val location: String? + val location: String? ) +//fun Mix.toOutput() = + // ==== DSL ==== fun mix( - id: Long? = null, - location: String? = "location", - recipe: Recipe = recipe(), - mixType: MixType = mixType(), - mixMaterials: MutableSet = mutableSetOf(), - op: Mix.() -> Unit = {} + id: Long? = null, + location: String? = "location", + recipe: Recipe = recipe(), + mixType: MixType = mixType(), + mixMaterials: MutableSet = mutableSetOf(), + op: Mix.() -> Unit = {} ) = Mix(id, location, recipe, mixType, mixMaterials).apply(op) fun mixSaveDto( - name: String = "name", - recipeId: Long = 0L, - materialTypeId: Long = 0L, - mixMaterials: Set? = setOf(), - op: MixSaveDto.() -> Unit = {} + name: String = "name", + recipeId: Long = 0L, + materialTypeId: Long = 0L, + mixMaterials: Set? = setOf(), + op: MixSaveDto.() -> Unit = {} ) = MixSaveDto(name, recipeId, materialTypeId, mixMaterials).apply(op) fun mixUpdateDto( - id: Long = 0L, - name: String? = "name", - materialTypeId: Long? = 0L, - mixMaterials: Set? = setOf(), - op: MixUpdateDto.() -> Unit = {} + id: Long = 0L, + name: String? = "name", + materialTypeId: Long? = 0L, + mixMaterials: Set? = setOf(), + op: MixUpdateDto.() -> Unit = {} ) = MixUpdateDto(id, name, materialTypeId, mixMaterials).apply(op) fun mixRatio( - id: Long = 0L, - ratio: Float = 1f, - op: MixDeductDto.() -> Unit = {} + id: Long = 0L, + ratio: Float = 1f, + op: MixDeductDto.() -> Unit = {} ) = MixDeductDto(id, ratio).apply(op) fun mixLocationDto( - mixId: Long = 0L, - location: String? = "location", - op: MixLocationDto.() -> Unit = {} + mixId: Long = 0L, + location: String? = "location", + op: MixLocationDto.() -> Unit = {} ) = MixLocationDto(mixId, location).apply(op) // ==== Exceptions ==== @@ -132,24 +141,24 @@ private const val MIX_CANNOT_DELETE_EXCEPTION_TITLE = "Cannot delete mix" private const val MIX_EXCEPTION_ERROR_CODE = "mix" fun mixIdNotFoundException(id: Long) = - NotFoundException( - MIX_EXCEPTION_ERROR_CODE, - MIX_NOT_FOUND_EXCEPTION_TITLE, - "A mix with the id $id could not be found", - id - ) + NotFoundException( + MIX_EXCEPTION_ERROR_CODE, + MIX_NOT_FOUND_EXCEPTION_TITLE, + "A mix with the id $id could not be found", + id + ) fun mixIdAlreadyExistsException(id: Long) = - AlreadyExistsException( - MIX_EXCEPTION_ERROR_CODE, - MIX_ALREADY_EXISTS_EXCEPTION_TITLE, - "A mix with the id $id already exists", - id - ) + AlreadyExistsException( + MIX_EXCEPTION_ERROR_CODE, + MIX_ALREADY_EXISTS_EXCEPTION_TITLE, + "A mix with the id $id already exists", + id + ) fun cannotDeleteMixException(mix: Mix) = - CannotDeleteException( - MIX_EXCEPTION_ERROR_CODE, - MIX_CANNOT_DELETE_EXCEPTION_TITLE, - "Cannot delete the mix ${mix.mixType.name} because one or more mixes depends on it" - ) + CannotDeleteException( + MIX_EXCEPTION_ERROR_CODE, + MIX_CANNOT_DELETE_EXCEPTION_TITLE, + "Cannot delete the mix ${mix.mixType.name} because one or more mixes depends on it" + ) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/MixMaterial.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/MixMaterial.kt index 090a9bd..97f751c 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/MixMaterial.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/MixMaterial.kt @@ -13,44 +13,51 @@ private const val MIX_MATERIAL_DTO_QUANTITY_NEGATIVE_MESSAGE = "La quantité ne @Entity @Table(name = "mix_material") data class MixMaterial( - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - override val id: Long?, + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + override val id: Long?, - @ManyToOne - @JoinColumn(name = "material_id") - val material: Material, + @ManyToOne + @JoinColumn(name = "material_id") + val material: Material, - var quantity: Float, + var quantity: Float, - var position: Int + var position: Int ) : Model +data class MixMaterialOutputDto( + val id: Long, + val material: MaterialOutputDto, + val quantity: Float, + val position: Int +) + data class MixMaterialDto( - @field:NotNull(message = MIX_MATERIAL_DTO_MATERIAL_ID_NULL_MESSAGE) - val materialId: Long, + @field:NotNull(message = MIX_MATERIAL_DTO_MATERIAL_ID_NULL_MESSAGE) + val materialId: Long, - @field:NotNull(message = MIX_MATERIAL_DTO_QUANTITY_NULL_MESSAGE) - @field:Min(value = 0, message = MIX_MATERIAL_DTO_QUANTITY_NEGATIVE_MESSAGE) - val quantity: Float, + @field:NotNull(message = MIX_MATERIAL_DTO_QUANTITY_NULL_MESSAGE) + @field:Min(value = 0, message = MIX_MATERIAL_DTO_QUANTITY_NEGATIVE_MESSAGE) + val quantity: Float, - val position: Int + val position: Int ) // ==== DSL ==== fun mixMaterial( - id: Long? = null, - material: Material = material(), - quantity: Float = 0f, - position: Int = 0, - op: MixMaterial.() -> Unit = {} + id: Long? = null, + material: Material = material(), + quantity: Float = 0f, + position: Int = 0, + op: MixMaterial.() -> Unit = {} ) = MixMaterial(id, material, quantity, position).apply(op) fun mixMaterialDto( - materialId: Long = 0L, - quantity: Float = 0f, - position: Int = 0, - op: MixMaterialDto.() -> Unit = {} + materialId: Long = 0L, + quantity: Float = 0f, + position: Int = 0, + op: MixMaterialDto.() -> Unit = {} ) = MixMaterialDto(materialId, quantity, position).apply(op) // ==== Exceptions ==== @@ -59,17 +66,17 @@ private const val MIX_MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE = "Mix material al private const val MIX_MATERIAL_EXCEPTION_ERROR_CODE = "mixmaterial" fun mixMaterialIdNotFoundException(id: Long) = - NotFoundException( - MIX_MATERIAL_EXCEPTION_ERROR_CODE, - MIX_MATERIAL_NOT_FOUND_EXCEPTION_TITLE, - "A mix material with the id $id could not be found", - id - ) + NotFoundException( + MIX_MATERIAL_EXCEPTION_ERROR_CODE, + MIX_MATERIAL_NOT_FOUND_EXCEPTION_TITLE, + "A mix material with the id $id could not be found", + id + ) fun mixMaterialIdAlreadyExistsException(id: Long) = - AlreadyExistsException( - MIX_MATERIAL_EXCEPTION_ERROR_CODE, - MIX_MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE, - "A mix material with the id $id already exists", - id - ) + AlreadyExistsException( + MIX_MATERIAL_EXCEPTION_ERROR_CODE, + MIX_MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE, + "A mix material with the id $id already exists", + id + ) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Recipe.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Recipe.kt index dd933ac..367375a 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Recipe.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Recipe.kt @@ -3,10 +3,12 @@ package dev.fyloz.colorrecipesexplorer.model import com.fasterxml.jackson.annotation.JsonIgnore import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException import dev.fyloz.colorrecipesexplorer.exception.NotFoundException -import dev.fyloz.colorrecipesexplorer.exception.RestException import dev.fyloz.colorrecipesexplorer.model.validation.NullOrNotBlank import dev.fyloz.colorrecipesexplorer.model.validation.NullOrSize -import org.springframework.http.HttpStatus +import dev.fyloz.colorrecipesexplorer.rest.CRE_PROPERTIES +import dev.fyloz.colorrecipesexplorer.rest.files.FILE_CONTROLLER_PATH +import java.net.URLEncoder +import java.nio.charset.StandardCharsets import java.time.LocalDate import javax.persistence.* import javax.validation.constraints.* @@ -76,6 +78,14 @@ data class Recipe( fun groupInformationForGroup(groupId: Long) = groupsInformation.firstOrNull { it.group.id == groupId } + + fun imageUrl(name: String) = + "${CRE_PROPERTIES.deploymentUrl}$FILE_CONTROLLER_PATH?path=${ + URLEncoder.encode( + "${this.imagesDirectoryPath}/$name", + StandardCharsets.UTF_8 + ) + }" } open class RecipeSaveDto( @@ -143,7 +153,7 @@ open class RecipeUpdateDto( ) : EntityDto data class RecipeOutputDto( - val id: Long, + override val id: Long, val name: String, val description: String, val color: String, @@ -152,10 +162,10 @@ data class RecipeOutputDto( val approbationDate: LocalDate?, val remark: String?, val company: Company, - val mixes: Set, + val mixes: Set, val groupsInformation: Set, - val imagesUrls: Set -) + var imagesUrls: Set +) : Model @Entity @Table(name = "recipe_group_information") @@ -252,25 +262,6 @@ fun recipeUpdateDto( 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(), diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/AccountControllers.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/AccountControllers.kt index 0776aa5..ea9efde 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/AccountControllers.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/AccountControllers.kt @@ -4,7 +4,7 @@ import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeEditUsers import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeRemoveUsers import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewUsers import dev.fyloz.colorrecipesexplorer.model.* -import dev.fyloz.colorrecipesexplorer.service.EmployeeGroupServiceImpl +import dev.fyloz.colorrecipesexplorer.service.EmployeeGroupService import dev.fyloz.colorrecipesexplorer.service.EmployeeService import org.springframework.http.MediaType import org.springframework.security.access.prepost.PreAuthorize @@ -23,52 +23,56 @@ class EmployeeController(private val employeeService: EmployeeService) { @GetMapping @PreAuthorizeViewUsers fun getAll() = - ok(employeeService.getAll()) + ok(employeeService.getAllForOutput()) @GetMapping("{id}") @PreAuthorizeViewUsers fun getById(@PathVariable id: Long) = - ok(employeeService.getById(id)) + ok(employeeService.getByIdForOutput(id)) @GetMapping("current") fun getCurrent(loggedInEmployee: Principal?) = - if (loggedInEmployee != null) - ok( - employeeService.getById( - loggedInEmployee.name.toLong(), - ignoreDefaultGroupUsers = false, - ignoreSystemUsers = false + if (loggedInEmployee != null) + ok( + with(employeeService) { + getById( + loggedInEmployee.name.toLong(), + ignoreDefaultGroupUsers = false, + ignoreSystemUsers = false + ).toOutput() + } ) - ) - else - forbidden() + else + forbidden() @PostMapping @PreAuthorizeEditUsers fun save(@Valid @RequestBody employee: EmployeeSaveDto) = - created(EMPLOYEE_CONTROLLER_PATH) { - employeeService.save(employee) - } + created(EMPLOYEE_CONTROLLER_PATH) { + with(employeeService) { + save(employee).toOutput() + } + } @PutMapping @PreAuthorizeEditUsers fun update(@Valid @RequestBody employee: EmployeeUpdateDto) = - noContent { - employeeService.update(employee) - } + noContent { + employeeService.update(employee) + } @PutMapping("{id}/password", consumes = [MediaType.TEXT_PLAIN_VALUE]) @PreAuthorizeEditUsers fun updatePassword(@PathVariable id: Long, @RequestBody password: String) = - noContent { - employeeService.updatePassword(id, password) - } + noContent { + employeeService.updatePassword(id, password) + } @PutMapping("{employeeId}/permissions/{permission}") @PreAuthorizeEditUsers fun addPermission( - @PathVariable employeeId: Long, - @PathVariable permission: EmployeePermission + @PathVariable employeeId: Long, + @PathVariable permission: EmployeePermission ) = noContent { employeeService.addPermission(employeeId, permission) } @@ -76,8 +80,8 @@ class EmployeeController(private val employeeService: EmployeeService) { @DeleteMapping("{employeeId}/permissions/{permission}") @PreAuthorizeEditUsers fun removePermission( - @PathVariable employeeId: Long, - @PathVariable permission: EmployeePermission + @PathVariable employeeId: Long, + @PathVariable permission: EmployeePermission ) = noContent { employeeService.removePermission(employeeId, permission) } @@ -85,59 +89,69 @@ class EmployeeController(private val employeeService: EmployeeService) { @DeleteMapping("{id}") @PreAuthorizeRemoveUsers fun deleteById(@PathVariable id: Long) = - employeeService.deleteById(id) + employeeService.deleteById(id) } @RestController @RequestMapping(EMPLOYEE_GROUP_CONTROLLER_PATH) -class GroupsController(private val groupService: EmployeeGroupServiceImpl) { +class GroupsController( + private val groupService: EmployeeGroupService, + private val employeeService: EmployeeService +) { @GetMapping @PreAuthorize("hasAnyAuthority('VIEW_RECIPES', 'VIEW_USERS')") fun getAll() = - ok(groupService.getAll()) + ok(groupService.getAllForOutput()) @GetMapping("{id}") @PreAuthorizeViewUsers fun getById(@PathVariable id: Long) = - ok(groupService.getById(id)) + ok(groupService.getByIdForOutput(id)) @GetMapping("{id}/employees") @PreAuthorizeViewUsers fun getEmployeesForGroup(@PathVariable id: Long) = - ok(groupService.getEmployeesForGroup(id)) + ok(with(employeeService) { + groupService.getEmployeesForGroup(id) + .map { it.toOutput() } + }) @PostMapping("default/{groupId}") @PreAuthorizeViewUsers fun setDefaultGroup(@PathVariable groupId: Long, response: HttpServletResponse) = - noContent { - groupService.setResponseDefaultGroup(groupId, response) - } + noContent { + groupService.setResponseDefaultGroup(groupId, response) + } @GetMapping("default") @PreAuthorizeViewUsers fun getRequestDefaultGroup(request: HttpServletRequest) = - ok(groupService.getRequestDefaultGroup(request)) + ok(with(groupService) { + getRequestDefaultGroup(request).toOutput() + }) @PostMapping @PreAuthorizeEditUsers fun save(@Valid @RequestBody group: EmployeeGroupSaveDto) = - created(EMPLOYEE_GROUP_CONTROLLER_PATH) { - groupService.save(group) - } + created(EMPLOYEE_GROUP_CONTROLLER_PATH) { + with(groupService) { + save(group).toOutput() + } + } @PutMapping @PreAuthorizeEditUsers fun update(@Valid @RequestBody group: EmployeeGroupUpdateDto) = - noContent { - groupService.update(group) - } + noContent { + groupService.update(group) + } @DeleteMapping("{id}") @PreAuthorizeRemoveUsers fun deleteById(@PathVariable id: Long) = - noContent { - groupService.deleteById(id) - } + noContent { + groupService.deleteById(id) + } } @RestController @@ -145,7 +159,7 @@ class GroupsController(private val groupService: EmployeeGroupServiceImpl) { class LogoutController(private val employeeService: EmployeeService) { @GetMapping("logout") fun logout(request: HttpServletRequest) = - ok { - employeeService.logout(request) - } + ok { + employeeService.logout(request) + } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/CompanyController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/CompanyController.kt index ef59303..e1acc00 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/CompanyController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/CompanyController.kt @@ -17,11 +17,11 @@ private const val COMPANY_CONTROLLER_PATH = "api/company" class CompanyController(private val companyService: CompanyService) { @GetMapping fun getAll() = - ok(companyService.getAll()) + ok(companyService.getAllForOutput()) @GetMapping("{id}") fun getById(@PathVariable id: Long) = - ok(companyService.getById(id)) + ok(companyService.getByIdForOutput(id)) @PostMapping @PreAuthorize("hasAuthority('EDIT_COMPANIES')") diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/InventoryController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/InventoryController.kt index 832e14d..636c3af 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/InventoryController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/InventoryController.kt @@ -15,21 +15,20 @@ private const val INVENTORY_CONTROLLER_PATH = "api/inventory" @RestController @RequestMapping(INVENTORY_CONTROLLER_PATH) class InventoryController( - private val inventoryService: InventoryService + private val inventoryService: InventoryService ) { @PutMapping("add") @PreAuthorize("hasAuthority('ADD_TO_INVENTORY')") - fun add(@RequestBody quantities: Collection): ResponseEntity> { - return ResponseEntity.ok(inventoryService.add(quantities)) - } + fun add(@RequestBody quantities: Collection) = + ok(inventoryService.add(quantities)) @PutMapping("deduct") @PreAuthorize("hasAuthority('DEDUCT_FROM_INVENTORY')") fun deduct(@RequestBody quantities: Collection) = - ok(inventoryService.deduct(quantities)) + ok(inventoryService.deduct(quantities)) @PutMapping("deduct/mix") @PreAuthorize("hasAuthority('DEDUCT_FROM_INVENTORY')") fun deduct(@RequestBody mixRatio: MixDeductDto) = - ok(inventoryService.deductMix(mixRatio)) + ok(inventoryService.deductMix(mixRatio)) } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialController.kt index e9801f4..8e99b03 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialController.kt @@ -3,7 +3,6 @@ package dev.fyloz.colorrecipesexplorer.rest import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewCatalog 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.MaterialService import org.springframework.http.MediaType import org.springframework.http.ResponseEntity @@ -11,8 +10,6 @@ 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 private const val MATERIAL_CONTROLLER_PATH = "api/material" @@ -21,12 +18,11 @@ private const val MATERIAL_CONTROLLER_PATH = "api/material" @RequestMapping(MATERIAL_CONTROLLER_PATH) @PreAuthorizeViewCatalog class MaterialController( - private val materialService: MaterialService, - private val creProperties: CreProperties + private val materialService: MaterialService ) { @GetMapping fun getAll() = - ok(materialService.getAll()) + ok(materialService.getAllForOutput()) @GetMapping("notmixtype") fun getAllNotMixType() = @@ -34,20 +30,22 @@ class MaterialController( @GetMapping("{id}") fun getById(@PathVariable id: Long) = - ok(materialService.getById(id)) + ok(materialService.getByIdForOutput(id)) @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) @PreAuthorize("hasAuthority('EDIT_MATERIALS')") fun save(@Valid material: MaterialSaveDto, simdutFile: MultipartFile?) = - created { - materialService.save( - materialSaveDto( - name = material.name, - inventoryQuantity = material.inventoryQuantity, - materialTypeId = material.materialTypeId, - simdutFile = simdutFile - ) - ) + created(MATERIAL_CONTROLLER_PATH) { + with(materialService) { + save( + materialSaveDto( + name = material.name, + inventoryQuantity = material.inventoryQuantity, + materialTypeId = material.materialTypeId, + simdutFile = simdutFile + ) + ).toOutput() + } } @PutMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) @@ -79,30 +77,5 @@ class MaterialController( @GetMapping("mix/update/{mixId}") fun getAllForMixUpdate(@PathVariable mixId: Long) = ok(materialService.getAllForMixUpdate(mixId)) - - private fun ok(material: Material) = - ok(material.toOutput()) - - private fun ok(materials: Collection) = - ok(materials.map { it.toOutput() }) - - private fun created(producer: () -> Material): ResponseEntity = with(producer().toOutput()) { - ResponseEntity - .created(URI.create("$MATERIAL_CONTROLLER_PATH/${this.id}")) - .body(this) - } - - private fun Material.toOutput() = materialOutputDto( - this, - if (materialService.hasSimdut(this)) this.simdutUrl() else null - ) - - private fun Material.simdutUrl() = - "${creProperties.deploymentUrl}$FILE_CONTROLLER_PATH?path=${ - URLEncoder.encode( - this.simdutFilePath, - StandardCharsets.UTF_8 - ) - }" } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialTypeController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialTypeController.kt index 3991873..50e26f0 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialTypeController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialTypeController.kt @@ -17,11 +17,11 @@ private const val MATERIAL_TYPE_CONTROLLER_PATH = "api/materialtype" class MaterialTypeController(private val materialTypeService: MaterialTypeService) { @GetMapping fun getAll() = - ok(materialTypeService.getAll()) + ok(materialTypeService.getAllForOutput()) @GetMapping("{id}") fun getById(@PathVariable id: Long) = - ok(materialTypeService.getById(id)) + ok(materialTypeService.getByIdForOutput(id)) @PostMapping @PreAuthorize("hasAuthority('EDIT_MATERIAL_TYPES')") diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RecipeController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RecipeController.kt index d372c7a..8bf447e 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RecipeController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RecipeController.kt @@ -27,22 +27,23 @@ private const val MIX_CONTROLLER_PATH = "api/recipe/mix" @PreAuthorizeViewRecipes class RecipeController( private val recipeService: RecipeService, - private val recipeImageService: RecipeImageService, - private val creProperties: CreProperties + private val recipeImageService: RecipeImageService ) { @GetMapping fun getAll() = - ok(recipeService.getAll()) + ok(recipeService.getAllForOutput()) @GetMapping("{id}") fun getById(@PathVariable id: Long) = - ok(recipeService.getById(id)) + ok(recipeService.getByIdForOutput(id)) @PostMapping @PreAuthorizeEditRecipes fun save(@Valid @RequestBody recipe: RecipeSaveDto) = - created(RECIPE_CONTROLLER_PATH) { - recipeService.save(recipe) + created(RECIPE_CONTROLLER_PATH) { + with(recipeService) { + save(recipe).toOutput() + } } @PutMapping @@ -66,7 +67,7 @@ class RecipeController( recipeService.deleteById(id) } - @PutMapping("{recipeId}/image", consumes = [MediaType.APPLICATION_OCTET_STREAM_VALUE]) + @PutMapping("{recipeId}/image", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) @PreAuthorizeEditRecipes fun downloadImage(@PathVariable recipeId: Long, image: MultipartFile): ResponseEntity { recipeImageService.download(image, recipeService.getById(recipeId)) @@ -79,28 +80,6 @@ class RecipeController( 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 @@ -109,7 +88,7 @@ class RecipeController( class MixController(private val mixService: MixService) { @GetMapping("{id}") fun getById(@PathVariable id: Long) = - ok(mixService.getById(id)) + ok(mixService.getByIdForOutput(id)) @PostMapping @PreAuthorizeEditRecipes diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RestUtils.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RestUtils.kt index 2f8fa7c..c7a82fc 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RestUtils.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RestUtils.kt @@ -1,5 +1,6 @@ package dev.fyloz.colorrecipesexplorer.rest +import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties import dev.fyloz.colorrecipesexplorer.model.Model import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus @@ -7,6 +8,8 @@ import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import java.net.URI +lateinit var CRE_PROPERTIES: CreProperties + /** Creates a HTTP OK [ResponseEntity] from the given [body]. */ fun ok(body: T): ResponseEntity = ResponseEntity.ok(body) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountService.kt index d3b81ba..8f08ed4 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountService.kt @@ -20,7 +20,8 @@ import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse import javax.transaction.Transactional -interface EmployeeService : ExternalModelService { +interface EmployeeService : + ExternalModelService { /** Check if an [Employee] with the given [firstName] and [lastName] exists. */ fun existsByFirstNameAndLastName(firstName: String, lastName: String): Boolean @@ -56,7 +57,7 @@ interface EmployeeService : ExternalModelService { + ExternalNamedModelService { /** Gets all the employees of the group with the given [id]. */ fun getEmployeesForGroup(id: Long): Collection @@ -74,50 +75,62 @@ interface EmployeeUserDetailsService : UserDetailsService { @Service class EmployeeServiceImpl( - employeeRepository: EmployeeRepository, - @Lazy val groupService: EmployeeGroupService, - @Lazy val passwordEncoder: PasswordEncoder, -) : AbstractExternalModelService(employeeRepository), - EmployeeService { + employeeRepository: EmployeeRepository, + @Lazy val groupService: EmployeeGroupService, + @Lazy val passwordEncoder: PasswordEncoder, +) : AbstractExternalModelService( + employeeRepository +), + EmployeeService { override fun idNotFoundException(id: Long) = employeeIdNotFoundException(id) override fun idAlreadyExistsException(id: Long) = employeeIdAlreadyExistsException(id) + override fun Employee.toOutput() = EmployeeOutputDto( + this.id, + this.firstName, + this.lastName, + this.group, + this.flatPermissions, + this.permissions, + this.lastLoginTime + ) + override fun existsByFirstNameAndLastName(firstName: String, lastName: String): Boolean = - repository.existsByFirstNameAndLastName(firstName, lastName) + repository.existsByFirstNameAndLastName(firstName, lastName) override fun getAll(): Collection = - super.getAll().filter { !it.isSystemUser && !it.isDefaultGroupUser } + super.getAll().filter { !it.isSystemUser && !it.isDefaultGroupUser } override fun getById(id: Long): Employee = - getById(id, ignoreDefaultGroupUsers = true, ignoreSystemUsers = true) + getById(id, ignoreDefaultGroupUsers = true, ignoreSystemUsers = true) override fun getById(id: Long, ignoreDefaultGroupUsers: Boolean, ignoreSystemUsers: Boolean): Employee = - super.getById(id).apply { - if (ignoreSystemUsers && isSystemUser || ignoreDefaultGroupUsers && isDefaultGroupUser) - throw idNotFoundException(id) - } + super.getById(id).apply { + if (ignoreSystemUsers && isSystemUser || ignoreDefaultGroupUsers && isDefaultGroupUser) + throw idNotFoundException(id) + } override fun getByGroup(group: EmployeeGroup): Collection = - repository.findAllByGroup(group).filter { - !it.isSystemUser && !it.isDefaultGroupUser - } + repository.findAllByGroup(group).filter { + !it.isSystemUser && !it.isDefaultGroupUser + } override fun getDefaultGroupEmployee(group: EmployeeGroup): Employee = - repository.findByIsDefaultGroupUserIsTrueAndGroupIs(group) + repository.findByIsDefaultGroupUserIsTrueAndGroupIs(group) override fun save(entity: EmployeeSaveDto): Employee = - save(with(entity) { - Employee( - id, - firstName, - lastName, - passwordEncoder.encode(password), - isDefaultGroupUser = false, - isSystemUser = false, - group = if (groupId != null) groupService.getById(groupId) else null, - permissions = permissions - ) - }) + save(with(entity) { + Employee( + id, + firstName, + lastName, + passwordEncoder.encode(password), + isDefaultGroupUser = false, + isSystemUser = false, + group = if (groupId != null) groupService.getById(groupId) else null, + permissions = permissions + ) + }) override fun save(entity: Employee): Employee { if (existsById(entity.id)) @@ -129,14 +142,14 @@ class EmployeeServiceImpl( override fun saveDefaultGroupEmployee(group: EmployeeGroup) { save( - employee( - id = 1000000L + group.id!!, - firstName = group.name, - lastName = "EmployeeModel", - password = passwordEncoder.encode(group.name), - group = group, - isDefaultGroupUser = true - ) + employee( + id = 1000000L + group.id!!, + firstName = group.name, + lastName = "EmployeeModel", + password = passwordEncoder.encode(group.name), + group = group, + isDefaultGroupUser = true + ) ) } @@ -144,9 +157,9 @@ class EmployeeServiceImpl( val employee = getById(employeeId, ignoreDefaultGroupUsers = true, ignoreSystemUsers = false) employee.lastLoginTime = time return update( - employee, - ignoreDefaultGroupUsers = true, - ignoreSystemUsers = false + employee, + ignoreDefaultGroupUsers = true, + ignoreSystemUsers = false ) } @@ -154,21 +167,21 @@ class EmployeeServiceImpl( val persistedEmployee by lazy { getById(entity.id) } return update(with(entity) { Employee( - id = id, - firstName = firstName or persistedEmployee.firstName, - lastName = lastName or persistedEmployee.lastName, - password = persistedEmployee.password, - isDefaultGroupUser = false, - isSystemUser = false, - group = if (entity.groupId != null) groupService.getById(entity.groupId) else persistedEmployee.group, - permissions = permissions?.toMutableSet() ?: persistedEmployee.permissions, - lastLoginTime = persistedEmployee.lastLoginTime + id = id, + firstName = firstName or persistedEmployee.firstName, + lastName = lastName or persistedEmployee.lastName, + password = persistedEmployee.password, + isDefaultGroupUser = false, + isSystemUser = false, + group = if (entity.groupId != null) groupService.getById(entity.groupId) else persistedEmployee.group, + permissions = permissions?.toMutableSet() ?: persistedEmployee.permissions, + lastLoginTime = persistedEmployee.lastLoginTime ) }) } override fun update(entity: Employee): Employee = - update(entity, ignoreDefaultGroupUsers = true, ignoreSystemUsers = true) + update(entity, ignoreDefaultGroupUsers = true, ignoreSystemUsers = true) override fun update(entity: Employee, ignoreDefaultGroupUsers: Boolean, ignoreSystemUsers: Boolean): Employee { with(repository.findByFirstNameAndLastName(entity.firstName, entity.lastName)) { @@ -183,24 +196,24 @@ class EmployeeServiceImpl( val persistedEmployee = getById(id, ignoreDefaultGroupUsers = true, ignoreSystemUsers = true) return super.update(with(persistedEmployee) { Employee( - id, - firstName, - lastName, - passwordEncoder.encode(password), - isDefaultGroupUser, - isSystemUser, - group, - permissions, - lastLoginTime + id, + firstName, + lastName, + passwordEncoder.encode(password), + isDefaultGroupUser, + isSystemUser, + group, + permissions, + lastLoginTime ) }) } override fun addPermission(employeeId: Long, permission: EmployeePermission): Employee = - super.update(getById(employeeId).apply { permissions += permission }) + super.update(getById(employeeId).apply { permissions += permission }) override fun removePermission(employeeId: Long, permission: EmployeePermission): Employee = - super.update(getById(employeeId).apply { permissions -= permission }) + super.update(getById(employeeId).apply { permissions -= permission }) override fun logout(request: HttpServletRequest) { val authorizationCookie = WebUtils.getCookie(request, "Authorization") @@ -217,20 +230,27 @@ const val defaultGroupCookieMaxAge = 10 * 365 * 24 * 60 * 60 // 10 ans @Service class EmployeeGroupServiceImpl( - private val employeeService: EmployeeService, - employeeGroupRepository: EmployeeGroupRepository -) : AbstractExternalNamedModelService( - employeeGroupRepository + private val employeeService: EmployeeService, + employeeGroupRepository: EmployeeGroupRepository +) : AbstractExternalNamedModelService( + employeeGroupRepository ), - EmployeeGroupService { + EmployeeGroupService { override fun idNotFoundException(id: Long) = employeeGroupIdNotFoundException(id) override fun idAlreadyExistsException(id: Long) = employeeGroupIdAlreadyExistsException(id) override fun nameNotFoundException(name: String) = employeeGroupNameNotFoundException(name) override fun nameAlreadyExistsException(name: String) = employeeGroupNameAlreadyExistsException(name) + override fun EmployeeGroup.toOutput() = EmployeeGroupOutputDto( + this.id!!, + this.name, + this.permissions, + this.flatPermissions + ) + override fun existsByName(name: String): Boolean = repository.existsByName(name) override fun getEmployeesForGroup(id: Long): Collection = - employeeService.getByGroup(getById(id)) + employeeService.getByGroup(getById(id)) @Transactional override fun save(entity: EmployeeGroup): EmployeeGroup { @@ -243,9 +263,9 @@ class EmployeeGroupServiceImpl( val persistedGroup by lazy { getById(entity.id) } return update(with(entity) { EmployeeGroup( - entity.id, - if (name.isNotBlank()) entity.name else persistedGroup.name, - if (permissions.isNotEmpty()) entity.permissions else persistedGroup.permissions + entity.id, + if (name.isNotBlank()) entity.name else persistedGroup.name, + if (permissions.isNotEmpty()) entity.permissions else persistedGroup.permissions ) }) } @@ -258,11 +278,11 @@ class EmployeeGroupServiceImpl( override fun getRequestDefaultGroup(request: HttpServletRequest): EmployeeGroup { val defaultGroupCookie = WebUtils.getCookie(request, defaultGroupCookieName) - ?: throw NoDefaultGroupException() + ?: throw NoDefaultGroupException() val defaultGroupUser = employeeService.getById( - defaultGroupCookie.value.toLong(), - ignoreDefaultGroupUsers = false, - ignoreSystemUsers = true + defaultGroupCookie.value.toLong(), + ignoreDefaultGroupUsers = false, + ignoreSystemUsers = true ) return defaultGroupUser.group!! } @@ -271,17 +291,17 @@ class EmployeeGroupServiceImpl( val group = getById(groupId) val defaultGroupUser = employeeService.getDefaultGroupEmployee(group) response.addHeader( - "Set-Cookie", - "$defaultGroupCookieName=${defaultGroupUser.id}; Max-Age=${defaultGroupCookieMaxAge}; Path=/api; HttpOnly; Secure; SameSite=strict" + "Set-Cookie", + "$defaultGroupCookieName=${defaultGroupUser.id}; Max-Age=${defaultGroupCookieMaxAge}; Path=/api; HttpOnly; Secure; SameSite=strict" ) } } @Service class EmployeeUserDetailsServiceImpl( - private val employeeService: EmployeeService + private val employeeService: EmployeeService ) : - EmployeeUserDetailsService { + EmployeeUserDetailsService { override fun loadUserByUsername(username: String): UserDetails { try { return loadUserByEmployeeId(username.toLong(), true) @@ -294,9 +314,9 @@ class EmployeeUserDetailsServiceImpl( override fun loadUserByEmployeeId(employeeId: Long, ignoreDefaultGroupUsers: Boolean): UserDetails { val employee = employeeService.getById( - employeeId, - ignoreDefaultGroupUsers = ignoreDefaultGroupUsers, - ignoreSystemUsers = false + employeeId, + ignoreDefaultGroupUsers = ignoreDefaultGroupUsers, + ignoreSystemUsers = false ) return User(employee.id.toString(), employee.password, employee.authorities) } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/CompanyService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/CompanyService.kt index 5e2d495..72a2f47 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/CompanyService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/CompanyService.kt @@ -5,23 +5,28 @@ import dev.fyloz.colorrecipesexplorer.repository.CompanyRepository import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Service -interface CompanyService : ExternalNamedModelService { +interface CompanyService : + ExternalNamedModelService { /** Checks if the given [company] is used by one or more recipes. */ fun isLinkedToRecipes(company: Company): Boolean } @Service class CompanyServiceImpl( - companyRepository: CompanyRepository, - @Lazy val recipeService: RecipeService + companyRepository: CompanyRepository, + @Lazy val recipeService: RecipeService ) : - AbstractExternalNamedModelService(companyRepository), - CompanyService { + AbstractExternalNamedModelService( + companyRepository + ), + CompanyService { override fun idNotFoundException(id: Long) = companyIdNotFoundException(id) override fun idAlreadyExistsException(id: Long) = companyIdAlreadyExistsException(id) override fun nameNotFoundException(name: String) = companyNameNotFoundException(name) override fun nameAlreadyExistsException(name: String) = companyNameAlreadyExistsException(name) + override fun Company.toOutput() = this + override fun isLinkedToRecipes(company: Company): Boolean = recipeService.existsByCompany(company) override fun update(entity: CompanyUpdateDto): Company { @@ -30,8 +35,8 @@ class CompanyServiceImpl( return update(with(entity) { company( - id = id, - name = if (name != null && name.isNotBlank()) name else persistedCompany.name + id = id, + name = if (name != null && name.isNotBlank()) name else persistedCompany.name ) }) } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt index f40ae75..02b843d 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt @@ -2,13 +2,17 @@ package dev.fyloz.colorrecipesexplorer.service import dev.fyloz.colorrecipesexplorer.model.* import dev.fyloz.colorrecipesexplorer.repository.MaterialRepository +import dev.fyloz.colorrecipesexplorer.rest.CRE_PROPERTIES +import dev.fyloz.colorrecipesexplorer.rest.files.FILE_CONTROLLER_PATH import dev.fyloz.colorrecipesexplorer.service.files.FileService import io.jsonwebtoken.lang.Assert import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Service +import java.net.URLEncoder +import java.nio.charset.StandardCharsets interface MaterialService : - ExternalNamedModelService { + ExternalNamedModelService { /** Checks if a material with the given [materialType] exists. */ fun existsByMaterialType(materialType: MaterialType): Boolean @@ -16,13 +20,13 @@ interface MaterialService : fun hasSimdut(material: Material): Boolean /** Gets all materials that are not a mix type. */ - fun getAllNotMixType(): Collection + fun getAllNotMixType(): Collection /** Gets all materials available for the creation of a mix for the recipe with the given [recipeId], including normal materials and materials from [MixType]s included in the said recipe. */ - fun getAllForMixCreation(recipeId: Long): Collection + fun getAllForMixCreation(recipeId: Long): Collection /** Gets all materials available for updating the mix with the given [mixId], including normal materials and materials from [MixType]s included in the mix recipe, excluding the material of the [MixType] of the said mix. */ - fun getAllForMixUpdate(mixId: Long): Collection + fun getAllForMixUpdate(mixId: Long): Collection /** Updates the quantity of the given [material] with the given [factor] and returns the updated quantity. */ fun updateQuantity(material: Material, factor: Float): Float @@ -36,7 +40,7 @@ class MaterialServiceImpl( @Lazy val materialTypeService: MaterialTypeService, val fileService: FileService ) : - AbstractExternalNamedModelService( + AbstractExternalNamedModelService( materialRepository ), MaterialService { @@ -45,11 +49,28 @@ class MaterialServiceImpl( override fun nameNotFoundException(name: String) = materialNameNotFoundException(name) override fun nameAlreadyExistsException(name: String) = materialNameAlreadyExistsException(name) + override fun Material.toOutput(): MaterialOutputDto = + MaterialOutputDto( + id = this.id!!, + name = this.name, + inventoryQuantity = this.inventoryQuantity, + isMixType = this.isMixType, + materialType = this.materialType!!, + simdutUrl = if (fileService.exists(this.simdutFilePath)) + "${CRE_PROPERTIES.deploymentUrl}$FILE_CONTROLLER_PATH?path=${ + URLEncoder.encode( + this.simdutFilePath, + StandardCharsets.UTF_8 + ) + }" + else null + ) + override fun existsByMaterialType(materialType: MaterialType): Boolean = repository.existsByMaterialType(materialType) override fun hasSimdut(material: Material): Boolean = fileService.exists(material.simdutFilePath) - override fun getAllNotMixType(): Collection = getAll().filter { !it.isMixType } + override fun getAllNotMixType(): Collection = getAllForOutput().filter { !it.isMixType } override fun save(entity: MaterialSaveDto): Material = save(with(entity) { @@ -95,16 +116,16 @@ class MaterialServiceImpl( updatedQuantity } - override fun getAllForMixCreation(recipeId: Long): Collection { + override fun getAllForMixCreation(recipeId: Long): Collection { val recipesMixTypes = recipeService.getById(recipeId).mixTypes - return getAll() + return getAllForOutput() .filter { !it.isMixType || recipesMixTypes.any { mixType -> mixType.material.id == it.id } } } - override fun getAllForMixUpdate(mixId: Long): Collection { + override fun getAllForMixUpdate(mixId: Long): Collection { val mix = mixService.getById(mixId) val recipesMixTypes = mix.recipe.mixTypes - return getAll() + return getAllForOutput() .filter { !it.isMixType || recipesMixTypes.any { mixType -> mixType.material.id == it.id } } .filter { it.id != mix.mixType.material.id } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialTypeService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialTypeService.kt index 3a9f108..e088ee4 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialTypeService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialTypeService.kt @@ -8,7 +8,7 @@ import dev.fyloz.colorrecipesexplorer.repository.MaterialTypeRepository import org.springframework.stereotype.Service interface MaterialTypeService : - ExternalNamedModelService { + ExternalNamedModelService { /** Checks if a material type with the given [prefix] exists. */ fun existsByPrefix(prefix: String): Boolean @@ -27,7 +27,7 @@ interface MaterialTypeService : @Service class MaterialTypeServiceImpl(repository: MaterialTypeRepository, private val materialService: MaterialService) : - AbstractExternalNamedModelService( + AbstractExternalNamedModelService( repository ), MaterialTypeService { override fun idNotFoundException(id: Long) = materialTypeIdNotFoundException(id) @@ -35,6 +35,8 @@ class MaterialTypeServiceImpl(repository: MaterialTypeRepository, private val ma override fun nameNotFoundException(name: String) = materialTypeNameNotFoundException(name) override fun nameAlreadyExistsException(name: String) = materialTypeNameAlreadyExistsException(name) + override fun MaterialType.toOutput() = this + override fun existsByPrefix(prefix: String): Boolean = repository.existsByPrefix(prefix) override fun isUsedByMaterial(materialType: MaterialType): Boolean = materialService.existsByMaterialType(materialType) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixMaterialService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixMaterialService.kt index 636196d..b64cc12 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixMaterialService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixMaterialService.kt @@ -28,31 +28,40 @@ interface MixMaterialService : ModelService * If any of those criteria are not met, an [InvalidGroupStepsPositionsException] will be thrown. */ fun validateMixMaterials(mixMaterials: Set) + + fun MixMaterial.toOutput(): MixMaterialOutputDto } @Service class MixMaterialServiceImpl( - mixMaterialRepository: MixMaterialRepository, - @Lazy val materialService: MaterialService + mixMaterialRepository: MixMaterialRepository, + @Lazy val materialService: MaterialService ) : AbstractModelService(mixMaterialRepository), MixMaterialService { override fun idNotFoundException(id: Long) = mixMaterialIdNotFoundException(id) override fun idAlreadyExistsException(id: Long) = mixMaterialIdAlreadyExistsException(id) + override fun MixMaterial.toOutput() = MixMaterialOutputDto( + this.id!!, + with(materialService) { this@toOutput.material.toOutput() }, + this.quantity, + this.position + ) + override fun existsByMaterial(material: Material): Boolean = repository.existsByMaterial(material) override fun create(mixMaterials: Set): Set = - mixMaterials.map(::create).toSet() + mixMaterials.map(::create).toSet() override fun create(mixMaterial: MixMaterialDto): MixMaterial = - mixMaterial( - material = materialService.getById(mixMaterial.materialId), - quantity = mixMaterial.quantity, - position = mixMaterial.position - ) + mixMaterial( + material = materialService.getById(mixMaterial.materialId), + quantity = mixMaterial.quantity, + position = mixMaterial.position + ) override fun updateQuantity(mixMaterial: MixMaterial, quantity: Float) = - update(mixMaterial.apply { - this.quantity = quantity - }) + update(mixMaterial.apply { + this.quantity = quantity + }) override fun validateMixMaterials(mixMaterials: Set) { if (mixMaterials.isEmpty()) return @@ -63,17 +72,17 @@ class MixMaterialServiceImpl( // Check if the first mix material position is 1 fun isFirstMixMaterialPositionInvalid() = - sortedMixMaterials[0].position != 1 + sortedMixMaterials[0].position != 1 // Check if the first mix material is expressed in percents fun isFirstMixMaterialPercentages() = - sortedMixMaterials[0].material.materialType!!.usePercentages + sortedMixMaterials[0].material.materialType!!.usePercentages // Check if any positions is duplicated fun getDuplicatedPositionsErrors() = - sortedMixMaterials - .findDuplicated { it.position } - .map { duplicatedMixMaterialsPositions(it) } + sortedMixMaterials + .findDuplicated { it.position } + .map { duplicatedMixMaterialsPositions(it) } // Find all errors and throw if there is any if (isFirstMixMaterialPositionInvalid()) errors += invalidFirstMixMaterialPosition(sortedMixMaterials[0]) @@ -90,32 +99,32 @@ class MixMaterialServiceImpl( } class InvalidMixMaterialsPositionsError( - val type: String, - val details: String + val type: String, + val details: String ) class InvalidMixMaterialsPositionsException( - val errors: Set + val errors: Set ) : RestException( - "invalid-mixmaterial-position", - "Invalid mix materials positions", - HttpStatus.BAD_REQUEST, - "The position of mix materials are invalid", - mapOf( - "invalidMixMaterials" to errors - ) + "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 + 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 - ) + "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" @@ -123,20 +132,20 @@ 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" - ) + 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" - ) + 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" - ) + InvalidMixMaterialsPositionsError( + GAP_BETWEEN_MIX_MATERIALS_POSITIONS_ERROR_CODE, + "There is a gap between mix materials positions" + ) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixService.kt index 8416417..85411d7 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixService.kt @@ -7,7 +7,7 @@ import org.springframework.context.annotation.Lazy import org.springframework.stereotype.Service import javax.transaction.Transactional -interface MixService : ExternalModelService { +interface MixService : ExternalModelService { /** Gets all mixes with the given [mixType]. */ fun getAllByMixType(mixType: MixType): Collection @@ -23,19 +23,30 @@ interface MixService : ExternalModelService(mixRepository), - MixService { + mixRepository: MixRepository, + @Lazy val recipeService: RecipeService, + @Lazy val materialTypeService: MaterialTypeService, + val mixMaterialService: MixMaterialService, + val mixTypeService: MixTypeService +) : AbstractExternalModelService(mixRepository), + MixService { override fun idNotFoundException(id: Long) = mixIdNotFoundException(id) override fun idAlreadyExistsException(id: Long) = mixIdAlreadyExistsException(id) override fun getAllByMixType(mixType: MixType): Collection = repository.findAllByMixType(mixType) override fun mixTypeIsShared(mixType: MixType): Boolean = getAllByMixType(mixType).count() > 1 + override fun Mix.toOutput() = MixOutputDto( + this.id!!, + this.location, + this.mixType, + this.mixMaterials.map { + with(mixMaterialService) { + return@with it.toOutput() + } + }.toSet() + ) + @Transactional override fun save(entity: MixSaveDto): Mix { val recipe = recipeService.getById(entity.recipeId) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt index a7f7f68..709ac4d 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt @@ -11,7 +11,8 @@ import org.springframework.web.multipart.MultipartFile import java.io.File import javax.transaction.Transactional -interface RecipeService : ExternalModelService { +interface RecipeService : + ExternalModelService { /** Checks if one or more recipes have the given [company]. */ fun existsByCompany(company: Company): Boolean @@ -34,13 +35,37 @@ class RecipeServiceImpl( val companyService: CompanyService, val mixService: MixService, val recipeStepService: RecipeStepService, - @Lazy val groupService: EmployeeGroupService + @Lazy val groupService: EmployeeGroupService, + val recipeImageService: RecipeImageService ) : - AbstractExternalModelService(recipeRepository), + AbstractExternalModelService( + recipeRepository + ), RecipeService { override fun idNotFoundException(id: Long) = recipeIdNotFoundException(id) override fun idAlreadyExistsException(id: Long) = recipeIdAlreadyExistsException(id) + override fun Recipe.toOutput() = RecipeOutputDto( + this.id!!, + this.name, + this.description, + this.color, + this.gloss, + this.sample, + this.approbationDate, + this.remark, + this.company, + this.mixes.map { + with(mixService) { + it.toOutput() + } + }.toSet(), + this.groupsInformation, + recipeImageService.getAllImages(this) + .map { this.imageUrl(it) } + .toSet() + ) + override fun existsByCompany(company: Company): Boolean = repository.existsByCompany(company) override fun getAllByCompany(company: Company): Collection = repository.findAllByCompany(company) @@ -157,7 +182,6 @@ const val RECIPE_IMAGE_EXTENSION = ".jpg" @Service class RecipeImageServiceImpl( - val recipeService: RecipeService, val fileService: FileService ) : RecipeImageService { override fun getAllImages(recipe: Recipe): Set { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/Service.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/Service.kt index ef45f8e..902a32c 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/Service.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/Service.kt @@ -62,7 +62,7 @@ abstract class AbstractService>(override val reposito } abstract class AbstractModelService>(repository: R) : - AbstractService(repository), ModelService { + AbstractService(repository), ModelService { protected abstract fun idNotFoundException(id: Long): NotFoundException protected abstract fun idAlreadyExistsException(id: Long): AlreadyExistsException @@ -83,7 +83,7 @@ abstract class AbstractModelService>(repos } override fun deleteById(id: Long) = - delete(getById(id)) // Use delete(entity) to prevent code duplication and to ease testing + delete(getById(id)) // Use delete(entity) to prevent code duplication and to ease testing protected fun assertId(id: Long?) { Assert.notNull(id, "${javaClass.simpleName}.update() was called with a null identifier") @@ -91,7 +91,7 @@ abstract class AbstractModelService>(repos } abstract class AbstractNamedModelService>(repository: R) : - AbstractModelService(repository), NamedModelService { + AbstractModelService(repository), NamedModelService { protected abstract fun nameNotFoundException(name: String): NotFoundException protected abstract fun nameAlreadyExistsException(name: String): AlreadyExistsException @@ -126,33 +126,57 @@ abstract class AbstractNamedModelService, U : EntityDto, R : JpaRepository> : Service { +interface ExternalService, U : EntityDto, O, R : JpaRepository> : Service { + /** Gets all entities mapped to their output model. */ + fun getAllForOutput(): Collection + /** Saves a given [entity]. */ fun save(entity: S): E = save(entity.toEntity()) /** Updates a given [entity]. */ - fun update(entity: U): E = update(entity.toEntity()) + fun update(entity: U): E + + /** Convert the given entity to its output model. */ + fun E.toOutput(): O } /** An [ExternalService] for entities implementing the [Model] interface. */ -interface ExternalModelService, U : EntityDto, R : JpaRepository> : - ModelService, ExternalService +interface ExternalModelService, U : EntityDto, O, R : JpaRepository> : + ModelService, ExternalService { + /** Gets the entity with the given [id] mapped to its output model. */ + fun getByIdForOutput(id: Long): O +} /** An [ExternalService] for entities implementing the [NamedModel] interface. */ -interface ExternalNamedModelService, U : EntityDto, R : JpaRepository> : - NamedModelService, ExternalModelService +interface ExternalNamedModelService, U : EntityDto, O, R : JpaRepository> : + NamedModelService, ExternalModelService /** An [AbstractService] with the functionalities of a [ExternalService]. */ @Suppress("unused") -abstract class AbstractExternalService, U : EntityDto, R : JpaRepository>(repository: R) : - AbstractService(repository), ExternalService +abstract class AbstractExternalService, U : EntityDto, O, R : JpaRepository>(repository: R) : + AbstractService(repository), ExternalService { + override fun getAllForOutput() = + getAll().map { it.toOutput() } +} /** An [AbstractModelService] with the functionalities of a [ExternalService]. */ -abstract class AbstractExternalModelService, U : EntityDto, R : JpaRepository>( - repository: R -) : AbstractModelService(repository), ExternalModelService +abstract class AbstractExternalModelService, U : EntityDto, O, R : JpaRepository>( + repository: R +) : AbstractModelService(repository), ExternalModelService { + override fun getAllForOutput() = + getAll().map { it.toOutput() } + + override fun getByIdForOutput(id: Long) = + getById(id).toOutput() +} /** An [AbstractNamedModelService] with the functionalities of a [ExternalService]. */ -abstract class AbstractExternalNamedModelService, U : EntityDto, R : NamedJpaRepository>( - repository: R -) : AbstractNamedModelService(repository), ExternalNamedModelService +abstract class AbstractExternalNamedModelService, U : EntityDto, O, R : NamedJpaRepository>( + repository: R +) : AbstractNamedModelService(repository), ExternalNamedModelService { + override fun getAllForOutput() = + getAll().map { it.toOutput() } + + override fun getByIdForOutput(id: Long) = + getById(id).toOutput() +} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/AbstractServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/AbstractServiceTest.kt index 0574fee..8d7f95c 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/AbstractServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/AbstractServiceTest.kt @@ -27,8 +27,8 @@ abstract class AbstractServiceTest, R : JpaRepository protected val entityList: List get() = listOf( - entity, - anotherEntity + entity, + anotherEntity ) @AfterEach @@ -91,7 +91,7 @@ abstract class AbstractServiceTest, R : JpaRepository } abstract class AbstractModelServiceTest, R : JpaRepository> : - AbstractServiceTest1() { + AbstractServiceTest1() { // existsById() @@ -129,7 +129,7 @@ abstract class AbstractModelServiceTest, R : J whenever(repository.findById(entity.id!!)).doReturn(Optional.empty()) assertThrows { service.getById(entity.id!!) } - .assertErrorCode() + .assertErrorCode() } // save() @@ -139,7 +139,7 @@ abstract class AbstractModelServiceTest, R : J doReturn(true).whenever(service).existsById(entity.id!!) assertThrows { service.save(entity) } - .assertErrorCode() + .assertErrorCode() } // update() @@ -161,7 +161,7 @@ abstract class AbstractModelServiceTest, R : J doReturn(false).whenever(service).existsById(entity.id!!) assertThrows { service.update(entity) } - .assertErrorCode() + .assertErrorCode() } // deleteById() @@ -177,7 +177,7 @@ abstract class AbstractModelServiceTest, R : J } abstract class AbstractNamedModelServiceTest, R : NamedJpaRepository> : - AbstractModelServiceTest() { + AbstractModelServiceTest() { protected abstract val entityWithEntityName: E // existsByName() @@ -216,7 +216,7 @@ abstract class AbstractNamedModelServiceTest { service.getByName(entity.name) } - .assertErrorCode("name") + .assertErrorCode("name") } // save() @@ -226,7 +226,7 @@ abstract class AbstractNamedModelServiceTest { service.save(entity) } - .assertErrorCode("name") + .assertErrorCode("name") } // update() @@ -258,7 +258,7 @@ abstract class AbstractNamedModelServiceTest { service.update(entity) } - .assertErrorCode("name") + .assertErrorCode("name") } } @@ -269,8 +269,8 @@ interface ExternalModelServiceTest { // ==== IMPLEMENTATIONS FOR EXTERNAL SERVICES ==== // Lots of code duplication but I don't have a better solution for now -abstract class AbstractExternalModelServiceTest, U : EntityDto, S : ExternalModelService, R : JpaRepository> : - AbstractModelServiceTest(), ExternalModelServiceTest { +abstract class AbstractExternalModelServiceTest, U : EntityDto, S : ExternalModelService, R : JpaRepository> : + AbstractModelServiceTest(), ExternalModelServiceTest { protected abstract val entitySaveDto: N protected abstract val entityUpdateDto: U @@ -281,8 +281,8 @@ abstract class AbstractExternalModelServiceTest, U : } } -abstract class AbstractExternalNamedModelServiceTest, U : EntityDto, S : ExternalNamedModelService, R : NamedJpaRepository> : - AbstractNamedModelServiceTest(), ExternalModelServiceTest { +abstract class AbstractExternalNamedModelServiceTest, U : EntityDto, S : ExternalNamedModelService, R : NamedJpaRepository> : + AbstractNamedModelServiceTest(), ExternalModelServiceTest { protected abstract val entitySaveDto: N protected abstract val entityUpdateDto: U @@ -294,10 +294,10 @@ abstract class AbstractExternalNamedModelServiceTest> withBaseSaveDtoTest( - entity: E, - entitySaveDto: N, - service: ExternalService, - saveMockMatcher: () -> E = { entity }, - op: () -> Unit = {} + entity: E, + entitySaveDto: N, + service: ExternalService, + saveMockMatcher: () -> E = { entity }, + op: () -> Unit = {} ) { doReturn(entity).whenever(service).save(saveMockMatcher()) doReturn(entity).whenever(entitySaveDto).toEntity() @@ -329,11 +329,11 @@ fun > withBaseSaveDtoTest( } fun > withBaseUpdateDtoTest( - entity: E, - entityUpdateDto: U, - service: ExternalModelService, - updateMockMatcher: () -> E, - op: E.() -> Unit = {} + entity: E, + entityUpdateDto: U, + service: ExternalModelService, + updateMockMatcher: () -> E, + op: E.() -> Unit = {} ) { doAnswer { it.arguments[0] }.whenever(service).update(updateMockMatcher()) doReturn(entity).whenever(entityUpdateDto).toEntity() diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialServiceTest.kt index 774c611..dc13ca3 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialServiceTest.kt @@ -14,16 +14,17 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class MaterialServiceTest : - AbstractExternalNamedModelServiceTest() { + AbstractExternalNamedModelServiceTest() { override val repository: MaterialRepository = mock() private val recipeService: RecipeService = mock() private val mixService: MixService = mock() private val materialTypeService: MaterialTypeService = mock() private val fileService: FileService = mock() override val service: MaterialService = - spy(MaterialServiceImpl(repository, recipeService, mixService, materialTypeService, fileService)) + spy(MaterialServiceImpl(repository, recipeService, mixService, materialTypeService, fileService)) override val entity: Material = material(id = 0L, name = "material") + private val entityOutput = materialOutputDto(entity) override val anotherEntity: Material = material(id = 1L, name = "another material") override val entityWithEntityName: Material = material(id = 2L, name = "material") override val entitySaveDto: MaterialSaveDto = spy(materialSaveDto()) @@ -83,15 +84,16 @@ class MaterialServiceTest : @Test fun `getAllNotMixType() returns a list containing every material that are not a mix type`() { - val mixTypeMaterial = material(name = "mix type material", isMixType = true) + val mixTypeMaterial = material(id = 1L, name = "mix type material", isMixType = true) + val mixTypeMaterialOutput = materialOutputDto(mixTypeMaterial) val materialList = listOf(entity, mixTypeMaterial) doReturn(materialList).whenever(service).getAll() val found = service.getAllNotMixType() - assertTrue(found.contains(entity)) - assertFalse(found.contains(mixTypeMaterial)) + assertTrue(found.contains(entityOutput)) + assertFalse(found.contains(mixTypeMaterialOutput)) } // save() @@ -101,7 +103,7 @@ class MaterialServiceTest : doReturn(true).whenever(service).existsByName(entity.name) assertThrows { service.save(entity) } - .assertErrorCode("name") + .assertErrorCode("name") } @Test @@ -133,7 +135,7 @@ class MaterialServiceTest : doReturn(entity).whenever(service).getById(material.id!!) assertThrows { service.update(material) } - .assertErrorCode("name") + .assertErrorCode("name") } @Test @@ -173,16 +175,16 @@ class MaterialServiceTest : val anotherMixTypeMaterial = material(id = 2L, isMixType = true) val materials = listOf(normalMaterial, mixTypeMaterial, anotherMixTypeMaterial) val recipe = - recipe(id = 0L, mixes = mutableListOf(mix(mixType = mixType(id = 0L, material = mixTypeMaterial)))) + recipe(id = 0L, mixes = mutableListOf(mix(mixType = mixType(id = 0L, material = mixTypeMaterial)))) whenever(recipeService.getById(recipe.id!!)).doReturn(recipe) doReturn(materials).whenever(service).getAll() val found = service.getAllForMixCreation(recipe.id!!) - assertTrue(normalMaterial in found) - assertTrue(mixTypeMaterial in found) - assertFalse(anotherMixTypeMaterial in found) + assertTrue(materialOutputDto(normalMaterial) in found) + assertTrue(materialOutputDto(mixTypeMaterial) in found) + assertFalse(materialOutputDto(anotherMixTypeMaterial) in found) } // getAllForMixUpdate() @@ -202,9 +204,9 @@ class MaterialServiceTest : val found = service.getAllForMixUpdate(mix.id!!) - assertTrue(normalMaterial in found) - assertTrue(mixTypeMaterial in found) - assertFalse(anotherMixTypeMaterial in found) + assertTrue(materialOutputDto(normalMaterial) in found) + assertTrue(materialOutputDto(mixTypeMaterial) in found) + assertFalse(materialOutputDto(anotherMixTypeMaterial) in found) } @@ -233,4 +235,13 @@ class MaterialServiceTest : test() } + + private fun materialOutputDto(material: Material) = MaterialOutputDto( + id = material.id!!, + name = material.name, + inventoryQuantity = material.inventoryQuantity, + isMixType = material.isMixType, + materialType = material.materialType!!, + simdutUrl = null + ) } diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeServiceTest.kt index 584b62f..509d490 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeServiceTest.kt @@ -21,7 +21,7 @@ class RecipeServiceTest : private val groupService: EmployeeGroupService = mock() private val recipeStepService: RecipeStepService = mock() override val service: RecipeService = - spy(RecipeServiceImpl(repository, companyService, mixService, recipeStepService, groupService)) + spy(RecipeServiceImpl(repository, companyService, mixService, recipeStepService, groupService, mock())) private val company: Company = company(id = 0L) override val entity: Recipe = recipe(id = 0L, name = "recipe", company = company) @@ -168,7 +168,7 @@ private class RecipeImageServiceTestContext { every { write(any(), any(), any()) } just Runs every { delete(any()) } just Runs } - val recipeImageService = spyk(RecipeImageServiceImpl(mockk(), fileService)) + val recipeImageService = spyk(RecipeImageServiceImpl(fileService)) val recipe = spyk(recipe()) val recipeImagesIds = setOf(1L, 10L, 21L) val recipeImagesNames = recipeImagesIds.map { it.imageName }.toSet()