Ajout d'un API dédié aux fichiers.
Ajout de la bibliothèque MockK pour simplifier le mocking dans Kotlin.
This commit is contained in:
parent
37b5a09479
commit
c6b3367cfa
|
@ -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")
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -8,30 +8,34 @@ enum class EmployeePermission(
|
|||
val impliedPermissions: List<EmployeePermission> = 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,
|
||||
|
|
|
@ -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<ByteArrayResource> {
|
||||
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<Void> {
|
||||
fileService.write(file, path, overwrite)
|
||||
return created(path)
|
||||
}
|
||||
|
||||
@DeleteMapping
|
||||
@PreAuthorize("hasAnyAuthority('REMOVE_FILE')")
|
||||
fun delete(@RequestParam path: String): ResponseEntity<Void> {
|
||||
return noContent {
|
||||
fileService.delete(path)
|
||||
}
|
||||
}
|
||||
|
||||
private fun created(path: String): ResponseEntity<Void> =
|
||||
ResponseEntity
|
||||
.created(URI.create("${creProperties.deploymentUrl}$FILE_CONTROLLER_PATH?path=$path"))
|
||||
.build()
|
||||
}
|
|
@ -31,14 +31,14 @@ interface RecipeService : ExternalModelService<Recipe, RecipeSaveDto, RecipeUpda
|
|||
|
||||
@Service
|
||||
class RecipeServiceImpl(
|
||||
recipeRepository: RecipeRepository,
|
||||
val companyService: CompanyService,
|
||||
val mixService: MixService,
|
||||
val recipeStepService: RecipeStepService,
|
||||
@Lazy val groupService: EmployeeGroupService
|
||||
recipeRepository: RecipeRepository,
|
||||
val companyService: CompanyService,
|
||||
val mixService: MixService,
|
||||
val recipeStepService: RecipeStepService,
|
||||
@Lazy val groupService: EmployeeGroupService
|
||||
) :
|
||||
AbstractExternalModelService<Recipe, RecipeSaveDto, RecipeUpdateDto, RecipeRepository>(recipeRepository),
|
||||
RecipeService {
|
||||
AbstractExternalModelService<Recipe, RecipeSaveDto, RecipeUpdateDto, RecipeRepository>(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<Long> {
|
||||
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"
|
||||
}
|
||||
|
|
|
@ -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 <T> 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 <T> 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 : FileIOException> T.logAndThrow(baseException: Exception, logger: Logger): Nothing {
|
||||
logger.error(this.details, baseException)
|
||||
throw this
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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<Recipe, RecipeSaveDto, RecipeUpdateDto, RecipeService, RecipeRepository>() {
|
||||
AbstractExternalModelServiceTest<Recipe, RecipeSaveDto, RecipeUpdateDto, RecipeService, RecipeRepository>() {
|
||||
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<RecipeImageNotFoundException> { 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)
|
||||
|
|
|
@ -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<MultipartFile>()
|
||||
|
||||
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<MultipartFile>()
|
||||
|
||||
whenever(fileService.write(simdutMultipart, path)).doReturn(false)
|
||||
whenever(fileService.write(simdutMultipart, path, true)).doAnswer { throw FileCreateException(path) }
|
||||
|
||||
assertThrows<SimdutWriteException> { 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")
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue