Merge pull request 'feature/18-add-existing-files-cache' (#26) from feature/18-add-existing-files-cache into develop
continuous-integration/drone/push Build was killed Details

Reviewed-on: #26
This commit is contained in:
William Nolin 2022-02-12 15:21:38 -05:00
commit 5409bc8861
23 changed files with 1071 additions and 241 deletions

View File

@ -2,14 +2,14 @@
global-variables: global-variables:
release: &release ${DRONE_TAG} release: &release ${DRONE_TAG}
environment: &environment environment: &environment
JAVA_VERSION: 11 JAVA_VERSION: 17
GRADLE_VERSION: 7.1 GRADLE_VERSION: 7.3
CRE_VERSION: dev-${DRONE_BUILD_NUMBER} CRE_VERSION: dev-${DRONE_BUILD_NUMBER}
CRE_ARTIFACT_NAME: ColorRecipesExplorer CRE_ARTIFACT_NAME: ColorRecipesExplorer
CRE_REGISTRY_IMAGE: registry.fyloz.dev/colorrecipesexplorer/backend CRE_REGISTRY_IMAGE: registry.fyloz.dev/colorrecipesexplorer/backend
CRE_PORT: 9101 CRE_PORT: 9101
CRE_RELEASE: *release CRE_RELEASE: *release
gradle-image: &gradle-image gradle:7.1-jdk11 gradle-image: &gradle-image gradle:7.3-jdk17
alpine-image: &alpine-image alpine:latest alpine-image: &alpine-image alpine:latest
docker-registry: &docker-registry registry.fyloz.dev docker-registry: &docker-registry registry.fyloz.dev
docker-registry-repo: &docker-registry-repo registry.fyloz.dev/colorrecipesexplorer/backend docker-registry-repo: &docker-registry-repo registry.fyloz.dev/colorrecipesexplorer/backend

View File

@ -1,5 +1,5 @@
ARG GRADLE_VERSION=7.1 ARG GRADLE_VERSION=7.3
ARG JAVA_VERSION=11 ARG JAVA_VERSION=17
FROM gradle:$GRADLE_VERSION-jdk$JAVA_VERSION AS build FROM gradle:$GRADLE_VERSION-jdk$JAVA_VERSION AS build
WORKDIR /usr/src WORKDIR /usr/src

View File

@ -31,6 +31,7 @@ dependencies {
implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1") implementation("com.fasterxml.jackson.module:jackson-module-kotlin:2.13.1")
implementation("dev.fyloz.colorrecipesexplorer:database-manager:5.2.1") implementation("dev.fyloz.colorrecipesexplorer:database-manager:5.2.1")
implementation("dev.fyloz:memorycache:1.0")
implementation("io.github.microutils:kotlin-logging-jvm:2.1.21") implementation("io.github.microutils:kotlin-logging-jvm:2.1.21")
implementation("io.jsonwebtoken:jjwt-api:0.11.2") implementation("io.jsonwebtoken:jjwt-api:0.11.2")
implementation("io.jsonwebtoken:jjwt-impl:0.11.2") implementation("io.jsonwebtoken:jjwt-impl:0.11.2")
@ -50,7 +51,7 @@ dependencies {
implementation("org.springframework.boot:spring-boot-devtools:${springBootVersion}") implementation("org.springframework.boot:spring-boot-devtools:${springBootVersion}")
testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0") testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0")
testImplementation("io.mockk:mockk:1.12.0") testImplementation("io.mockk:mockk:1.12.1")
testImplementation("org.jetbrains.kotlin:kotlin-test:${kotlinVersion}") testImplementation("org.jetbrains.kotlin:kotlin-test:${kotlinVersion}")
testImplementation("org.mockito:mockito-inline:3.11.2") testImplementation("org.mockito:mockito-inline:3.11.2")
testImplementation("org.springframework:spring-test:5.3.13") testImplementation("org.springframework:spring-test:5.3.13")
@ -61,6 +62,8 @@ dependencies {
runtimeOnly("mysql:mysql-connector-java:8.0.22") runtimeOnly("mysql:mysql-connector-java:8.0.22")
runtimeOnly("org.postgresql:postgresql:42.2.16") runtimeOnly("org.postgresql:postgresql:42.2.16")
runtimeOnly("com.microsoft.sqlserver:mssql-jdbc:9.2.1.jre11") runtimeOnly("com.microsoft.sqlserver:mssql-jdbc:9.2.1.jre11")
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor:${springBootVersion}")
} }
springBoot { springBoot {
@ -68,8 +71,8 @@ springBoot {
} }
java { java {
sourceCompatibility = JavaVersion.VERSION_11 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_17
} }
sourceSets { sourceSets {
@ -83,23 +86,26 @@ sourceSets {
} }
tasks.test { tasks.test {
useJUnitPlatform()
jvmArgs("-XX:+ShowCodeDetailsInExceptionMessages")
testLogging {
events("skipped", "failed")
setExceptionFormat("full")
}
reports { reports {
junitXml.required.set(true) junitXml.required.set(true)
html.required.set(false) html.required.set(false)
} }
useJUnitPlatform()
testLogging {
events("skipped", "failed")
}
} }
tasks.withType<JavaCompile>() { tasks.withType<JavaCompile>() {
options.compilerArgs.addAll(arrayOf("--release", "11")) options.compilerArgs.addAll(arrayOf("--release", "17"))
} }
tasks.withType<KotlinCompile>().all { tasks.withType<KotlinCompile>().all {
kotlinOptions { kotlinOptions {
jvmTarget = JavaVersion.VERSION_11.toString() jvmTarget = JavaVersion.VERSION_17.toString()
freeCompilerArgs = listOf( freeCompilerArgs = listOf(
"-Xopt-in=kotlin.contracts.ExperimentalContracts", "-Xopt-in=kotlin.contracts.ExperimentalContracts",
"-Xinline-classes" "-Xinline-classes"

View File

@ -3,3 +3,5 @@ package dev.fyloz.colorrecipesexplorer
typealias SpringUser = org.springframework.security.core.userdetails.User typealias SpringUser = org.springframework.security.core.userdetails.User
typealias SpringUserDetails = org.springframework.security.core.userdetails.UserDetails typealias SpringUserDetails = org.springframework.security.core.userdetails.UserDetails
typealias SpringUserDetailsService = org.springframework.security.core.userdetails.UserDetailsService typealias SpringUserDetailsService = org.springframework.security.core.userdetails.UserDetailsService
typealias JavaFile = java.io.File

View File

@ -1,18 +1,18 @@
package dev.fyloz.colorrecipesexplorer.config package dev.fyloz.colorrecipesexplorer.config
import dev.fyloz.colorrecipesexplorer.ColorRecipesExplorerApplication
import dev.fyloz.colorrecipesexplorer.DatabaseUpdaterProperties
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
import dev.fyloz.colorrecipesexplorer.config.properties.MaterialTypeProperties import dev.fyloz.colorrecipesexplorer.config.properties.MaterialTypeProperties
import org.slf4j.Logger import dev.fyloz.colorrecipesexplorer.service.files.CachedFileSystemItem
import org.slf4j.LoggerFactory import dev.fyloz.memorycache.ExpiringMemoryCache
import dev.fyloz.memorycache.MemoryCache
import org.springframework.boot.context.properties.EnableConfigurationProperties import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration import org.springframework.context.annotation.Configuration
@Configuration @Configuration
@EnableConfigurationProperties(MaterialTypeProperties::class, CreProperties::class) @EnableConfigurationProperties(MaterialTypeProperties::class, CreProperties::class)
class SpringConfiguration { class CreConfiguration(private val creProperties: CreProperties) {
@Bean @Bean
fun logger(): Logger = LoggerFactory.getLogger(ColorRecipesExplorerApplication::class.java) fun fileCache(): MemoryCache<String, CachedFileSystemItem> =
ExpiringMemoryCache(maxAccessCount = creProperties.fileCacheMaxAccessCount)
} }

View File

@ -51,7 +51,7 @@ class MaterialTypeInitializer(
// Remove old system types // Remove old system types
oldSystemTypes.forEach { oldSystemTypes.forEach {
logger.info("Material type '${it.name}' is not a system type anymore") logger.info("Material type '${it.name}' is not a system type anymore")
materialTypeService.update(materialType(it, newSystemType = false)) materialTypeService.updateSystemType(it.copy(systemType = false))
} }
} }
} }

View File

@ -5,11 +5,13 @@ import kotlin.properties.Delegates.notNull
const val DEFAULT_DATA_DIRECTORY = "data" const val DEFAULT_DATA_DIRECTORY = "data"
const val DEFAULT_CONFIG_DIRECTORY = "config" const val DEFAULT_CONFIG_DIRECTORY = "config"
const val DEFAULT_FILE_CACHE_MAX_ACCESS_COUNT = 10_000L
@ConfigurationProperties(prefix = "cre.server") @ConfigurationProperties(prefix = "cre.server")
class CreProperties { class CreProperties {
var dataDirectory: String = DEFAULT_DATA_DIRECTORY var dataDirectory: String = DEFAULT_DATA_DIRECTORY
var configDirectory: String = DEFAULT_CONFIG_DIRECTORY var configDirectory: String = DEFAULT_CONFIG_DIRECTORY
var fileCacheMaxAccessCount: Long = DEFAULT_FILE_CACHE_MAX_ACCESS_COUNT
} }
@ConfigurationProperties(prefix = "cre.security") @ConfigurationProperties(prefix = "cre.security")

View File

@ -59,6 +59,17 @@ class AlreadyExistsException(
extensions = extensions.apply { this[identifierName] = identifierValue }.toMap() extensions = extensions.apply { this[identifierName] = identifierValue }.toMap()
) )
class CannotUpdateException(
errorCode: String,
title: String,
details: String
) : RestException(
errorCode = "cannotupdate-$errorCode",
title = title,
status = HttpStatus.BAD_REQUEST,
details = details
)
class CannotDeleteException( class CannotDeleteException(
errorCode: String, errorCode: String,
title: String, title: String,

View File

@ -2,6 +2,7 @@ package dev.fyloz.colorrecipesexplorer.model
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException
import dev.fyloz.colorrecipesexplorer.exception.CannotUpdateException
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import dev.fyloz.colorrecipesexplorer.model.validation.NullOrNotBlank import dev.fyloz.colorrecipesexplorer.model.validation.NullOrNotBlank
import dev.fyloz.colorrecipesexplorer.model.validation.NullOrSize import dev.fyloz.colorrecipesexplorer.model.validation.NullOrSize
@ -105,6 +106,7 @@ fun materialTypeUpdateDto(
private const val MATERIAL_TYPE_NOT_FOUND_EXCEPTION_TITLE = "Material type not found" private const val MATERIAL_TYPE_NOT_FOUND_EXCEPTION_TITLE = "Material type not found"
private const val MATERIAL_TYPE_ALREADY_EXISTS_EXCEPTION_TITLE = "Material type already exists" private const val MATERIAL_TYPE_ALREADY_EXISTS_EXCEPTION_TITLE = "Material type already exists"
private const val MATERIAL_TYPE_CANNOT_DELETE_EXCEPTION_TITLE = "Cannot delete material type" private const val MATERIAL_TYPE_CANNOT_DELETE_EXCEPTION_TITLE = "Cannot delete material type"
private const val MATERIAL_TYPE_CANNOT_UPDATE_EXCEPTION_TITLE = "Cannot update material type"
private const val MATERIAL_TYPE_EXCEPTION_ERROR_CODE = "materialtype" private const val MATERIAL_TYPE_EXCEPTION_ERROR_CODE = "materialtype"
fun materialTypeIdNotFoundException(id: Long) = fun materialTypeIdNotFoundException(id: Long) =
@ -150,9 +152,23 @@ fun materialTypePrefixAlreadyExistsException(prefix: String) =
"prefix" "prefix"
) )
fun cannotUpdateSystemMaterialTypeException(materialType: MaterialType) =
CannotUpdateException(
MATERIAL_TYPE_EXCEPTION_ERROR_CODE,
MATERIAL_TYPE_CANNOT_UPDATE_EXCEPTION_TITLE,
"Cannot update material type ${materialType.name} because it is a system material type"
)
fun cannotDeleteMaterialTypeException(materialType: MaterialType) = fun cannotDeleteMaterialTypeException(materialType: MaterialType) =
CannotDeleteException( CannotDeleteException(
MATERIAL_TYPE_EXCEPTION_ERROR_CODE, MATERIAL_TYPE_EXCEPTION_ERROR_CODE,
MATERIAL_TYPE_CANNOT_DELETE_EXCEPTION_TITLE, MATERIAL_TYPE_CANNOT_DELETE_EXCEPTION_TITLE,
"Cannot delete material type ${materialType.name} because one or more materials depends on it" "Cannot delete material type ${materialType.name} because one or more materials depends on it"
) )
fun cannotDeleteSystemMaterialTypeException(materialType: MaterialType) =
CannotDeleteException(
MATERIAL_TYPE_EXCEPTION_ERROR_CODE,
MATERIAL_TYPE_CANNOT_DELETE_EXCEPTION_TITLE,
"Cannot delete material type ${materialType.name} because it is a system material type"
)

View File

@ -9,6 +9,9 @@ interface MaterialTypeRepository : NamedJpaRepository<MaterialType> {
/** Checks if a material type exists with the given [prefix]. */ /** Checks if a material type exists with the given [prefix]. */
fun existsByPrefix(prefix: String): Boolean fun existsByPrefix(prefix: String): Boolean
/** Checks if a system material type with the given [id] exists. */
fun existsByIdAndSystemTypeIsTrue(id: Long): Boolean
/** Gets all material types which are not system types. */ /** Gets all material types which are not system types. */
fun findAllBySystemTypeIs(value: Boolean): Collection<MaterialType> fun findAllBySystemTypeIs(value: Boolean): Collection<MaterialType>

View File

@ -7,7 +7,7 @@ import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
interface MaterialTypeService : interface MaterialTypeService :
ExternalNamedModelService<MaterialType, MaterialTypeSaveDto, MaterialTypeUpdateDto, MaterialType, MaterialTypeRepository> { ExternalNamedModelService<MaterialType, MaterialTypeSaveDto, MaterialTypeUpdateDto, MaterialType, MaterialTypeRepository> {
/** Checks if a material type with the given [prefix] exists. */ /** Checks if a material type with the given [prefix] exists. */
fun existsByPrefix(prefix: String): Boolean fun existsByPrefix(prefix: String): Boolean
@ -19,14 +19,17 @@ interface MaterialTypeService :
/** Gets all material types who are not a system type. */ /** Gets all material types who are not a system type. */
fun getAllNonSystemType(): Collection<MaterialType> fun getAllNonSystemType(): Collection<MaterialType>
/** Allows to update the given system [materialType], should not be exposed to users. */
fun updateSystemType(materialType: MaterialType): MaterialType
} }
@Service @Service
@Profile("!emergency") @Profile("!emergency")
class MaterialTypeServiceImpl(repository: MaterialTypeRepository, private val materialService: MaterialService) : class MaterialTypeServiceImpl(repository: MaterialTypeRepository, private val materialService: MaterialService) :
AbstractExternalNamedModelService<MaterialType, MaterialTypeSaveDto, MaterialTypeUpdateDto, MaterialType, MaterialTypeRepository>( AbstractExternalNamedModelService<MaterialType, MaterialTypeSaveDto, MaterialTypeUpdateDto, MaterialType, MaterialTypeRepository>(
repository repository
), MaterialTypeService { ), MaterialTypeService {
override fun idNotFoundException(id: Long) = materialTypeIdNotFoundException(id) override fun idNotFoundException(id: Long) = materialTypeIdNotFoundException(id)
override fun idAlreadyExistsException(id: Long) = materialIdAlreadyExistsException(id) override fun idAlreadyExistsException(id: Long) = materialIdAlreadyExistsException(id)
override fun nameNotFoundException(name: String) = materialTypeNameNotFoundException(name) override fun nameNotFoundException(name: String) = materialTypeNameNotFoundException(name)
@ -36,7 +39,7 @@ class MaterialTypeServiceImpl(repository: MaterialTypeRepository, private val ma
override fun existsByPrefix(prefix: String): Boolean = repository.existsByPrefix(prefix) override fun existsByPrefix(prefix: String): Boolean = repository.existsByPrefix(prefix)
override fun isUsedByMaterial(materialType: MaterialType): Boolean = override fun isUsedByMaterial(materialType: MaterialType): Boolean =
materialService.existsByMaterialType(materialType) materialService.existsByMaterialType(materialType)
override fun getAllSystemTypes(): Collection<MaterialType> = repository.findAllBySystemTypeIs(true) override fun getAllSystemTypes(): Collection<MaterialType> = repository.findAllBySystemTypeIs(true)
override fun getAllNonSystemType(): Collection<MaterialType> = repository.findAllBySystemTypeIs(false) override fun getAllNonSystemType(): Collection<MaterialType> = repository.findAllBySystemTypeIs(false)
@ -52,15 +55,25 @@ class MaterialTypeServiceImpl(repository: MaterialTypeRepository, private val ma
return update(with(entity) { return update(with(entity) {
MaterialType( MaterialType(
id = id, id = id,
name = if (isNotNullAndNotBlank(name)) name else persistedMaterialType.name, name = if (isNotNullAndNotBlank(name)) name else persistedMaterialType.name,
prefix = if (isNotNullAndNotBlank(prefix)) prefix else persistedMaterialType.prefix, prefix = if (isNotNullAndNotBlank(prefix)) prefix else persistedMaterialType.prefix,
systemType = false systemType = false
) )
}) })
} }
override fun update(entity: MaterialType): MaterialType { override fun updateSystemType(materialType: MaterialType) =
update(materialType, true)
override fun update(entity: MaterialType) =
update(entity, false)
private fun update(entity: MaterialType, allowSystemTypes: Boolean): MaterialType {
if (!allowSystemTypes && repository.existsByIdAndSystemTypeIsTrue(entity.id!!)) {
throw cannotUpdateSystemMaterialTypeException(entity)
}
with(repository.findByPrefix(entity.prefix)) { with(repository.findByPrefix(entity.prefix)) {
if (this != null && id != entity.id) if (this != null && id != entity.id)
throw materialTypePrefixAlreadyExistsException(entity.prefix) throw materialTypePrefixAlreadyExistsException(entity.prefix)
@ -70,7 +83,11 @@ class MaterialTypeServiceImpl(repository: MaterialTypeRepository, private val ma
} }
override fun delete(entity: MaterialType) { override fun delete(entity: MaterialType) {
if (!repository.canBeDeleted(entity.id!!)) throw cannotDeleteMaterialTypeException(entity) if (repository.existsByIdAndSystemTypeIsTrue(entity.id!!)) {
throw cannotDeleteSystemMaterialTypeException(entity)
}
if (!repository.canBeDeleted(entity.id)) throw cannotDeleteMaterialTypeException(entity)
super.delete(entity) super.delete(entity)
} }
} }

View File

@ -1,5 +1,6 @@
package dev.fyloz.colorrecipesexplorer.service package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase
import dev.fyloz.colorrecipesexplorer.model.* import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.model.account.Group import dev.fyloz.colorrecipesexplorer.model.account.Group
import dev.fyloz.colorrecipesexplorer.model.validation.or import dev.fyloz.colorrecipesexplorer.model.validation.or
@ -9,10 +10,8 @@ import dev.fyloz.colorrecipesexplorer.service.files.WriteableFileService
import dev.fyloz.colorrecipesexplorer.service.users.GroupService import dev.fyloz.colorrecipesexplorer.service.users.GroupService
import dev.fyloz.colorrecipesexplorer.utils.setAll import dev.fyloz.colorrecipesexplorer.utils.setAll
import org.springframework.context.annotation.Lazy import org.springframework.context.annotation.Lazy
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import java.io.File
import java.time.LocalDate import java.time.LocalDate
import java.time.Period import java.time.Period
import javax.transaction.Transactional import javax.transaction.Transactional
@ -45,7 +44,7 @@ interface RecipeService :
} }
@Service @Service
@Profile("!emergency") @RequireDatabase
class RecipeServiceImpl( class RecipeServiceImpl(
recipeRepository: RecipeRepository, recipeRepository: RecipeRepository,
val companyService: CompanyService, val companyService: CompanyService,
@ -213,29 +212,20 @@ interface RecipeImageService {
/** Deletes the image with the given [name] for the given [recipe]. */ /** Deletes the image with the given [name] for the given [recipe]. */
fun delete(recipe: Recipe, name: String) fun delete(recipe: Recipe, name: String)
/** Gets the directory containing all images of the given [Recipe]. */
fun Recipe.getDirectory(): File
} }
const val RECIPE_IMAGE_ID_DELIMITER = "_" const val RECIPE_IMAGE_ID_DELIMITER = "_"
const val RECIPE_IMAGE_EXTENSION = ".jpg" const val RECIPE_IMAGE_EXTENSION = ".jpg"
@Service @Service
@Profile("!emergency") @RequireDatabase
class RecipeImageServiceImpl( class RecipeImageServiceImpl(
val fileService: WriteableFileService val fileService: WriteableFileService
) : RecipeImageService { ) : RecipeImageService {
override fun getAllImages(recipe: Recipe): Set<String> { override fun getAllImages(recipe: Recipe) =
val recipeDirectory = recipe.getDirectory() fileService.listDirectoryFiles(recipe.imagesDirectoryPath)
if (!recipeDirectory.exists() || !recipeDirectory.isDirectory) {
return setOf()
}
return recipeDirectory.listFiles()!! // Should never be null because we check if recipeDirectory exists and is a directory before
.filterNotNull()
.map { it.name } .map { it.name }
.toSet() .toSet()
}
override fun download(image: MultipartFile, recipe: Recipe): String { override fun download(image: MultipartFile, recipe: Recipe): String {
/** Gets the next id available for a new image for the given [recipe]. */ /** Gets the next id available for a new image for the given [recipe]. */
@ -252,17 +242,15 @@ class RecipeImageServiceImpl(
} + 1L } + 1L
} }
return getImageFileName(recipe, getNextAvailableId()).apply { return getImageFileName(recipe, getNextAvailableId()).also {
fileService.write(image, getImagePath(recipe, this), true) with(getImagePath(recipe, it)) {
fileService.writeToDirectory(image, this, recipe.imagesDirectoryPath, true)
}
} }
} }
override fun delete(recipe: Recipe, name: String) = override fun delete(recipe: Recipe, name: String) =
fileService.delete(getImagePath(recipe, name)) fileService.deleteFromDirectory(getImagePath(recipe, name), recipe.imagesDirectoryPath)
override fun Recipe.getDirectory(): File = File(with(fileService) {
this@getDirectory.imagesDirectoryPath.fullPath().path
})
private fun getImageFileName(recipe: Recipe, id: Long) = private fun getImageFileName(recipe: Recipe, id: Long) =
"${recipe.name}$RECIPE_IMAGE_ID_DELIMITER$id" "${recipe.name}$RECIPE_IMAGE_ID_DELIMITER$id"

View File

@ -1,5 +1,6 @@
package dev.fyloz.colorrecipesexplorer.service.config package dev.fyloz.colorrecipesexplorer.service.config
import dev.fyloz.colorrecipesexplorer.JavaFile
import dev.fyloz.colorrecipesexplorer.SUPPORTED_DATABASE_VERSION import dev.fyloz.colorrecipesexplorer.SUPPORTED_DATABASE_VERSION
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
import dev.fyloz.colorrecipesexplorer.emergencyMode import dev.fyloz.colorrecipesexplorer.emergencyMode
@ -8,14 +9,13 @@ import dev.fyloz.colorrecipesexplorer.model.Configuration
import dev.fyloz.colorrecipesexplorer.model.ConfigurationType import dev.fyloz.colorrecipesexplorer.model.ConfigurationType
import dev.fyloz.colorrecipesexplorer.model.configuration import dev.fyloz.colorrecipesexplorer.model.configuration
import dev.fyloz.colorrecipesexplorer.repository.ConfigurationRepository import dev.fyloz.colorrecipesexplorer.repository.ConfigurationRepository
import dev.fyloz.colorrecipesexplorer.service.files.create import dev.fyloz.colorrecipesexplorer.utils.create
import dev.fyloz.colorrecipesexplorer.utils.excludeAll import dev.fyloz.colorrecipesexplorer.utils.excludeAll
import org.slf4j.Logger import org.slf4j.Logger
import org.springframework.boot.info.BuildProperties import org.springframework.boot.info.BuildProperties
import org.springframework.context.annotation.Lazy import org.springframework.context.annotation.Lazy
import org.springframework.data.repository.findByIdOrNull import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Component import org.springframework.stereotype.Component
import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.io.FileOutputStream import java.io.FileOutputStream
import java.time.LocalDate import java.time.LocalDate
@ -96,7 +96,7 @@ private class FileConfigurationSource(
private val configFilePath: String private val configFilePath: String
) : ConfigurationSource { ) : ConfigurationSource {
private val properties = Properties().apply { private val properties = Properties().apply {
with(File(configFilePath)) { with(JavaFile(configFilePath)) {
if (!this.exists()) this.create() if (!this.exists()) this.create()
FileInputStream(this).use { FileInputStream(this).use {
this@apply.load(it) this@apply.load(it)

View File

@ -0,0 +1,184 @@
package dev.fyloz.colorrecipesexplorer.service.files
import dev.fyloz.colorrecipesexplorer.JavaFile
import dev.fyloz.colorrecipesexplorer.utils.File
import dev.fyloz.colorrecipesexplorer.utils.FilePath
import dev.fyloz.memorycache.MemoryCache
import mu.KotlinLogging
import org.springframework.stereotype.Component
interface FileCache {
/** Checks if the cache contains the given [path]. */
operator fun contains(path: FilePath): Boolean
/** Gets the cached file system item at the given [path]. */
operator fun get(path: FilePath): CachedFileSystemItem?
/** Gets the cached directory at the given [path]. */
fun getDirectory(path: FilePath): CachedDirectory?
/** Gets the cached file at the given [path]. */
fun getFile(path: FilePath): CachedFile?
/** Checks if the cached file system item at the given [path] exists. */
fun exists(path: FilePath): Boolean
/** Checks if the cached directory at the given [path] exists. */
fun directoryExists(path: FilePath): Boolean
/** Checks if the cached file at the given [path] exists. */
fun fileExists(path: FilePath): Boolean
/** Sets the file system item at the given [path] as existing or not. Loads the item in the cache if not already present. */
fun setExists(path: FilePath, exists: Boolean = true)
/** Loads the file system item at the given [path] into the cache. */
fun load(path: FilePath)
/** Adds the file system item at the given [itemPath] to the cached directory at the given [directoryPath]. */
fun addItemToDirectory(directoryPath: FilePath, itemPath: FilePath)
/** Removes the file system item at the given [itemPath] from the cached directory at the given [directoryPath]. */
fun removeItemFromDirectory(directoryPath: FilePath, itemPath: FilePath)
}
@Component
class DefaultFileCache(private val cache: MemoryCache<String, CachedFileSystemItem>) : FileCache {
private val logger = KotlinLogging.logger {}
override operator fun contains(path: FilePath) =
path.value in cache
override operator fun get(path: FilePath) =
cache[path.value]
private operator fun set(path: FilePath, item: CachedFileSystemItem) {
cache[path.value] = item
}
override fun getDirectory(path: FilePath) =
if (directoryExists(path)) {
this[path] as CachedDirectory
} else {
null
}
override fun getFile(path: FilePath) =
if (fileExists(path)) {
this[path] as CachedFile
} else {
null
}
override fun exists(path: FilePath) =
path in this && this[path]!!.exists
override fun directoryExists(path: FilePath) =
exists(path) && this[path] is CachedDirectory
override fun fileExists(path: FilePath) =
exists(path) && this[path] is CachedFile
override fun setExists(path: FilePath, exists: Boolean) {
if (path !in this) {
load(path)
}
this[path] = this[path]!!.clone(exists = exists)
logger.debug("Updated FileCache state: ${path.value} exists -> $exists")
}
override fun load(path: FilePath) =
with(JavaFile(path.value).toFileSystemItem()) {
this@DefaultFileCache[path] = this
logger.debug("Loaded file at ${path.value} into FileCache")
}
override fun addItemToDirectory(directoryPath: FilePath, itemPath: FilePath) {
val directory = prepareDirectory(directoryPath) ?: return
val updatedContent = setOf(
*directory.content.toTypedArray(),
JavaFile(itemPath.value).toFileSystemItem()
)
this[directoryPath] = directory.copy(content = updatedContent)
logger.debug("Added child ${itemPath.value} to ${directoryPath.value} in FileCache")
}
override fun removeItemFromDirectory(directoryPath: FilePath, itemPath: FilePath) {
val directory = prepareDirectory(directoryPath) ?: return
val updatedContent = directory.content
.filter { it.path.value != itemPath.value }
.toSet()
this[directoryPath] = directory.copy(content = updatedContent)
logger.debug("Removed child ${itemPath.value} from ${directoryPath.value} in FileCache")
}
private fun prepareDirectory(path: FilePath): CachedDirectory? {
if (!directoryExists(path)) {
logger.warn("Cannot add child to ${path.value} because it is not in the cache")
return null
}
val directory = getDirectory(path)
if (directory == null) {
logger.warn("Cannot add child to ${path.value} because it is not a directory")
return null
}
return directory
}
}
interface CachedFileSystemItem {
val name: String
val path: FilePath
val exists: Boolean
fun clone(exists: Boolean): CachedFileSystemItem
}
data class CachedFile(
override val name: String,
override val path: FilePath,
override val exists: Boolean
) : CachedFileSystemItem {
constructor(file: File) : this(file.name, file.toFilePath(), file.exists() && file.isFile)
override fun clone(exists: Boolean) =
this.copy(exists = exists)
}
data class CachedDirectory(
override val name: String,
override val path: FilePath,
override val exists: Boolean,
val content: Set<CachedFileSystemItem> = setOf()
) : CachedFileSystemItem {
constructor(file: File) : this(file.name, file.toFilePath(), file.exists() && file.isDirectory, file.fetchContent())
val contentFiles: Collection<CachedFile>
get() = content.filterIsInstance<CachedFile>()
override fun clone(exists: Boolean) =
this.copy(exists = exists)
companion object {
private fun File.fetchContent() =
(this.file.listFiles() ?: arrayOf<JavaFile>())
.filterNotNull()
.map { it.toFileSystemItem() }
.toSet()
}
}
fun JavaFile.toFileSystemItem() =
if (this.isDirectory) {
CachedDirectory(File(this))
} else {
CachedFile(File(this))
}

View File

@ -2,15 +2,17 @@ package dev.fyloz.colorrecipesexplorer.service.files
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
import dev.fyloz.colorrecipesexplorer.exception.RestException import dev.fyloz.colorrecipesexplorer.exception.RestException
import dev.fyloz.colorrecipesexplorer.utils.File
import dev.fyloz.colorrecipesexplorer.utils.FilePath
import dev.fyloz.colorrecipesexplorer.utils.withFileAt
import mu.KotlinLogging
import org.slf4j.Logger import org.slf4j.Logger
import org.springframework.core.io.ByteArrayResource import org.springframework.core.io.ByteArrayResource
import org.springframework.core.io.Resource import org.springframework.core.io.Resource
import org.springframework.http.HttpStatus import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import java.io.File
import java.io.IOException 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. */ /** Banned path shards. These are banned because they can allow access to files outside the data directory. */
val BANNED_FILE_PATH_SHARDS = setOf( val BANNED_FILE_PATH_SHARDS = setOf(
@ -26,8 +28,11 @@ interface FileService {
/** Reads the file at the given [path]. */ /** Reads the file at the given [path]. */
fun read(path: String): Resource fun read(path: String): Resource
/** List the files contained in the folder at the given [path]. Returns an empty collection if the directory does not exist. */
fun listDirectoryFiles(path: String): Collection<CachedFile>
/** Completes the path of the given [String] by adding the working directory. */ /** Completes the path of the given [String] by adding the working directory. */
fun String.fullPath(): FilePath fun fullPath(path: String): FilePath
} }
interface WriteableFileService : FileService { interface WriteableFileService : FileService {
@ -40,21 +45,38 @@ interface WriteableFileService : FileService {
/** Writes the given [data] to the given [path]. If the file at the path already exists, it will be overwritten if [overwrite] is enabled. */ /** Writes the given [data] to the given [path]. If the file at the path already exists, it will be overwritten if [overwrite] is enabled. */
fun write(data: ByteArrayResource, path: String, overwrite: Boolean) fun write(data: ByteArrayResource, path: String, overwrite: Boolean)
/** Writes the given [data] to the given [path], and specify the [parentPath]. If the file at the path already exists, it will be overwritten if [overwrite] is enabled. */
fun writeToDirectory(data: MultipartFile, path: String, parentPath: String, overwrite: Boolean)
/** Deletes the file at the given [path]. */ /** Deletes the file at the given [path]. */
fun delete(path: String) fun delete(path: String)
/** Deletes the file at the given [path], and specify the [parentPath]. */
fun deleteFromDirectory(path: String, parentPath: String)
} }
@Service @Service
class FileServiceImpl( class FileServiceImpl(
private val creProperties: CreProperties, private val fileCache: FileCache,
private val logger: Logger private val creProperties: CreProperties
) : WriteableFileService { ) : WriteableFileService {
override fun exists(path: String) = withFileAt(path.fullPath()) { private val logger = KotlinLogging.logger {}
this.exists() && this.isFile
override fun exists(path: String): Boolean {
val fullPath = fullPath(path)
return if (fullPath in fileCache) {
fileCache.exists(fullPath)
} else {
withFileAt(fullPath) {
(this.exists() && this.isFile).also {
fileCache.setExists(fullPath, it)
}
}
}
} }
override fun read(path: String) = ByteArrayResource( override fun read(path: String) = ByteArrayResource(
withFileAt(path.fullPath()) { withFileAt(fullPath(path)) {
if (!exists(path)) throw FileNotFoundException(path) if (!exists(path)) throw FileNotFoundException(path)
try { try {
readBytes() readBytes()
@ -64,12 +86,25 @@ class FileServiceImpl(
} }
) )
override fun listDirectoryFiles(path: String): Collection<CachedFile> =
with(fullPath(path)) {
if (this !in fileCache) {
fileCache.load(this)
}
(fileCache.getDirectory(this) ?: return setOf())
.contentFiles
}
override fun create(path: String) { override fun create(path: String) {
val fullPath = path.fullPath() val fullPath = fullPath(path)
if (!exists(path)) { if (!exists(path)) {
try { try {
withFileAt(fullPath) { withFileAt(fullPath) {
this.create() this.create()
fileCache.setExists(fullPath)
logger.info("Created file at '${fullPath.value}'")
} }
} catch (ex: IOException) { } catch (ex: IOException) {
FileCreateException(path).logAndThrow(ex, logger) FileCreateException(path).logAndThrow(ex, logger)
@ -79,35 +114,52 @@ class FileServiceImpl(
override fun write(file: MultipartFile, path: String, overwrite: Boolean) = override fun write(file: MultipartFile, path: String, overwrite: Boolean) =
prepareWrite(path, overwrite) { prepareWrite(path, overwrite) {
logWrittenDataSize(file.size)
file.transferTo(this.toPath()) file.transferTo(this.toPath())
} }
override fun write(data: ByteArrayResource, path: String, overwrite: Boolean) = override fun write(data: ByteArrayResource, path: String, overwrite: Boolean) =
prepareWrite(path, overwrite) { prepareWrite(path, overwrite) {
logWrittenDataSize(data.contentLength())
this.writeBytes(data.byteArray) this.writeBytes(data.byteArray)
} }
override fun writeToDirectory(data: MultipartFile, path: String, parentPath: String, overwrite: Boolean) {
fileCache.addItemToDirectory(fullPath(parentPath), fullPath(path))
write(data, path, overwrite)
}
override fun delete(path: String) { override fun delete(path: String) {
try { try {
withFileAt(path.fullPath()) { val fullPath = fullPath(path)
withFileAt(fullPath) {
if (!exists(path)) throw FileNotFoundException(path) if (!exists(path)) throw FileNotFoundException(path)
!this.delete()
this.delete()
fileCache.setExists(fullPath, false)
logger.info("Deleted file at '${fullPath.value}'")
} }
} catch (ex: IOException) { } catch (ex: IOException) {
FileDeleteException(path).logAndThrow(ex, logger) FileDeleteException(path).logAndThrow(ex, logger)
} }
} }
override fun String.fullPath(): FilePath { override fun deleteFromDirectory(path: String, parentPath: String) {
BANNED_FILE_PATH_SHARDS fileCache.removeItemFromDirectory(fullPath(parentPath), fullPath(path))
.firstOrNull { this.contains(it) } delete(path)
?.let { throw InvalidFilePathException(this, it) } }
return FilePath("${creProperties.dataDirectory}/$this") override fun fullPath(path: String): FilePath {
BANNED_FILE_PATH_SHARDS
.firstOrNull { path.contains(it) }
?.let { throw InvalidFilePathException(path, it) }
return FilePath("${creProperties.dataDirectory}/$path")
} }
private fun prepareWrite(path: String, overwrite: Boolean, op: File.() -> Unit) { private fun prepareWrite(path: String, overwrite: Boolean, op: File.() -> Unit) {
val fullPath = path.fullPath() val fullPath = fullPath(path)
if (exists(path)) { if (exists(path)) {
if (!overwrite) throw FileExistsException(path) if (!overwrite) throw FileExistsException(path)
@ -118,26 +170,17 @@ class FileServiceImpl(
try { try {
withFileAt(fullPath) { withFileAt(fullPath) {
this.op() this.op()
logger.info("Wrote data to file at '${fullPath.value}'")
} }
} catch (ex: IOException) { } catch (ex: IOException) {
FileWriteException(path).logAndThrow(ex, logger) FileWriteException(path).logAndThrow(ex, logger)
} }
} }
/** Runs the given [block] in the context of a file with the given [fullPath]. */ private fun logWrittenDataSize(size: Long) {
private fun <T> withFileAt(fullPath: FilePath, block: File.() -> T) = logger.debug("Writing $size bytes to file system...")
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" private const val FILE_IO_EXCEPTION_TITLE = "File IO error"

View File

@ -1,5 +1,6 @@
package dev.fyloz.colorrecipesexplorer.service.files package dev.fyloz.colorrecipesexplorer.service.files
import dev.fyloz.colorrecipesexplorer.utils.FilePath
import org.springframework.core.io.Resource import org.springframework.core.io.Resource
import org.springframework.core.io.ResourceLoader import org.springframework.core.io.ResourceLoader
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
@ -9,18 +10,27 @@ class ResourceFileService(
private val resourceLoader: ResourceLoader private val resourceLoader: ResourceLoader
) : FileService { ) : FileService {
override fun exists(path: String) = override fun exists(path: String) =
path.fullPath().resource.exists() fullPath(path).resource.exists()
override fun read(path: String): Resource = override fun read(path: String): Resource =
path.fullPath().resource.also { fullPath(path).resource.also {
if (!it.exists()) { if (!it.exists()) {
throw FileNotFoundException(path) throw FileNotFoundException(path)
} }
} }
override fun String.fullPath() = override fun listDirectoryFiles(path: String): Collection<CachedFile> {
FilePath("classpath:${this}") val content = fullPath(path).resource.file.listFiles() ?: return setOf()
return content
.filterNotNull()
.filter { it.isFile }
.map { it.toFileSystemItem() as CachedFile }
}
override fun fullPath(path: String) =
FilePath("classpath:${path}")
val FilePath.resource: Resource val FilePath.resource: Resource
get() = resourceLoader.getResource(this.path) get() = resourceLoader.getResource(this.value)
} }

View File

@ -0,0 +1,59 @@
package dev.fyloz.colorrecipesexplorer.utils
import dev.fyloz.colorrecipesexplorer.JavaFile
import java.nio.file.Files
import java.nio.file.Path
/** Mockable file wrapper, to prevent issues when mocking [java.io.File]. */
class File(val file: JavaFile) {
val name: String
get() = file.name
val isFile: Boolean
get() = file.isFile
val isDirectory: Boolean
get() = file.isDirectory
fun toPath(): Path =
file.toPath()
fun toFilePath(): FilePath =
FilePath(file.path)
fun exists() =
file.exists()
fun readBytes() =
file.readBytes()
fun writeBytes(array: ByteArray) =
file.writeBytes(array)
fun create() =
file.create()
fun delete(): Boolean =
file.delete()
companion object {
fun from(path: String) =
File(JavaFile(path))
fun from(path: FilePath) =
from(path.value)
}
}
// TODO: Move to value class when mocking them with mockk works
data class FilePath(val value: String)
/** Runs the given [block] in the context of a file with the given [fullPath]. */
fun <T> withFileAt(fullPath: FilePath, block: File.() -> T) =
File.from(fullPath).block()
/** Shortcut to create a file and its parent directories. */
fun JavaFile.create() {
Files.createDirectories(this.parentFile.toPath())
Files.createFile(this.toPath())
}

View File

@ -5,7 +5,6 @@ cre.server.data-directory=data
cre.server.config-directory=config cre.server.config-directory=config
cre.security.jwt-secret=CtnvGQjgZ44A1fh295gE78WWOgl8InrbwBgQsMy0 cre.security.jwt-secret=CtnvGQjgZ44A1fh295gE78WWOgl8InrbwBgQsMy0
cre.security.jwt-duration=18000000 cre.security.jwt-duration=18000000
cre.security.aes-secret=blabla
# Root user # Root user
cre.security.root.id=9999 cre.security.root.id=9999
cre.security.root.password=password cre.security.root.password=password

View File

@ -2,6 +2,8 @@ package dev.fyloz.colorrecipesexplorer.service
import com.nhaarman.mockitokotlin2.* import com.nhaarman.mockitokotlin2.*
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException
import dev.fyloz.colorrecipesexplorer.exception.CannotUpdateException
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import dev.fyloz.colorrecipesexplorer.model.* import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.repository.MaterialTypeRepository import dev.fyloz.colorrecipesexplorer.repository.MaterialTypeRepository
@ -164,8 +166,22 @@ class MaterialTypeServiceTest :
.assertErrorCode("prefix") .assertErrorCode("prefix")
} }
@Test
fun `update() throws CannotUpdateException when updating a system material type`() {
whenever(repository.existsByIdAndSystemTypeIsTrue(systemType.id!!)).doReturn(true)
assertThrows<CannotUpdateException> { service.update(systemType) }
}
// delete() // delete()
@Test
fun `delete() throws CannotDeleteException when deleting a system material type`() {
whenever(repository.existsByIdAndSystemTypeIsTrue(systemType.id!!)).doReturn(true)
assertThrows<CannotDeleteException> { service.delete(systemType) }
}
override fun `delete() deletes in the repository`() { override fun `delete() deletes in the repository`() {
whenCanBeDeleted { whenCanBeDeleted {
super.`delete() deletes in the repository`() super.`delete() deletes in the repository`()

View File

@ -6,8 +6,10 @@ import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.model.account.group import dev.fyloz.colorrecipesexplorer.model.account.group
import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository
import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService
import dev.fyloz.colorrecipesexplorer.service.files.CachedFile
import dev.fyloz.colorrecipesexplorer.service.files.WriteableFileService import dev.fyloz.colorrecipesexplorer.service.files.WriteableFileService
import dev.fyloz.colorrecipesexplorer.service.users.GroupService import dev.fyloz.colorrecipesexplorer.service.users.GroupService
import dev.fyloz.colorrecipesexplorer.utils.FilePath
import io.mockk.* import io.mockk.*
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@ -15,7 +17,6 @@ import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.assertThrows
import org.springframework.mock.web.MockMultipartFile import org.springframework.mock.web.MockMultipartFile
import org.springframework.web.multipart.MultipartFile import org.springframework.web.multipart.MultipartFile
import java.io.File
import java.time.LocalDate import java.time.LocalDate
import java.time.Period import java.time.Period
import kotlin.test.* import kotlin.test.*
@ -30,7 +31,17 @@ class RecipeServiceTest :
private val recipeStepService: RecipeStepService = mock() private val recipeStepService: RecipeStepService = mock()
private val configService: ConfigurationService = mock() private val configService: ConfigurationService = mock()
override val service: RecipeService = override val service: RecipeService =
spy(RecipeServiceImpl(repository, companyService, mixService, recipeStepService, groupService, mock(), configService)) spy(
RecipeServiceImpl(
repository,
companyService,
mixService,
recipeStepService,
groupService,
mock(),
configService
)
)
private val company: Company = company(id = 0L) private val company: Company = company(id = 0L)
override val entity: Recipe = recipe(id = 0L, name = "recipe", company = company) override val entity: Recipe = recipe(id = 0L, name = "recipe", company = company)
@ -273,18 +284,7 @@ private class RecipeImageServiceTestContext {
val recipe = spyk(recipe()) val recipe = spyk(recipe())
val recipeImagesIds = setOf(1L, 10L, 21L) val recipeImagesIds = setOf(1L, 10L, 21L)
val recipeImagesNames = recipeImagesIds.map { it.imageName }.toSet() val recipeImagesNames = recipeImagesIds.map { it.imageName }.toSet()
val recipeImagesFiles = recipeImagesNames.map { File(it) }.toTypedArray() val recipeImagesFiles = recipeImagesNames.map { CachedFile(it, FilePath(it), true) }
val recipeDirectory = mockk<File> {
every { exists() } returns true
every { isDirectory } returns true
every { listFiles() } returns recipeImagesFiles
}
init {
with(recipeImageService) {
every { recipe.getDirectory() } returns recipeDirectory
}
}
val Long.imageName val Long.imageName
get() = "${recipe.name}$RECIPE_IMAGE_ID_DELIMITER$this" get() = "${recipe.name}$RECIPE_IMAGE_ID_DELIMITER$this"
@ -308,6 +308,8 @@ class RecipeImageServiceTest {
@Test @Test
fun `getAllImages() returns a Set containing the name of every files in the recipe's directory`() { fun `getAllImages() returns a Set containing the name of every files in the recipe's directory`() {
test { test {
every { fileService.listDirectoryFiles(any()) } returns recipeImagesFiles
val foundImagesNames = recipeImageService.getAllImages(recipe) val foundImagesNames = recipeImageService.getAllImages(recipe)
assertEquals(recipeImagesNames, foundImagesNames) assertEquals(recipeImagesNames, foundImagesNames)
@ -317,7 +319,7 @@ class RecipeImageServiceTest {
@Test @Test
fun `getAllImages() returns an empty Set when the recipe's directory does not exists`() { fun `getAllImages() returns an empty Set when the recipe's directory does not exists`() {
test { test {
every { recipeDirectory.exists() } returns false every { fileService.listDirectoryFiles(any()) } returns emptySet()
assertTrue { assertTrue {
recipeImageService.getAllImages(recipe).isEmpty() recipeImageService.getAllImages(recipe).isEmpty()
@ -335,12 +337,15 @@ class RecipeImageServiceTest {
val expectedImageName = expectedImageId.imageName val expectedImageName = expectedImageId.imageName
val expectedImagePath = expectedImageName.imagePath val expectedImagePath = expectedImageName.imagePath
every { fileService.listDirectoryFiles(any()) } returns recipeImagesFiles
every { fileService.writeToDirectory(any(), any(), any(), any()) } just runs
val foundImageName = recipeImageService.download(mockImage, recipe) val foundImageName = recipeImageService.download(mockImage, recipe)
assertEquals(expectedImageName, foundImageName) assertEquals(expectedImageName, foundImageName)
verify { verify {
fileService.write(mockImage, expectedImagePath, true) fileService.writeToDirectory(mockImage, expectedImagePath, any(), true)
} }
} }
} }
@ -353,10 +358,12 @@ class RecipeImageServiceTest {
val imageName = recipeImagesIds.first().imageName val imageName = recipeImagesIds.first().imageName
val imagePath = imageName.imagePath val imagePath = imageName.imagePath
every { fileService.deleteFromDirectory(any(), any()) } just runs
recipeImageService.delete(recipe, imageName) recipeImageService.delete(recipe, imageName)
verify { verify {
fileService.delete(imagePath) fileService.deleteFromDirectory(imagePath, any())
} }
} }
} }

View File

@ -0,0 +1,426 @@
package dev.fyloz.colorrecipesexplorer.service.files
import dev.fyloz.colorrecipesexplorer.utils.FilePath
import dev.fyloz.memorycache.MemoryCache
import io.mockk.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
internal class DefaultFileCacheTest {
private val memoryCacheMock = mockk<MemoryCache<String, CachedFileSystemItem>>()
private val fileCache = spyk(DefaultFileCache(memoryCacheMock))
private val path = FilePath("unit_test_path")
private val cachedFile = CachedFile("unit_test_file", path, true)
private val cachedDirectory = CachedDirectory("unit_test_dictionary", path, true)
@AfterEach
internal fun afterEach() {
clearAllMocks()
}
private fun setup_memoryCacheMock_set() {
every { memoryCacheMock[any()] = any() } just runs
}
@Test
fun contains_normalBehavior_returnsTrue() {
// Arrange
every { any() in memoryCacheMock} returns true
// Act
val contains = path in fileCache
// Assert
assertTrue(contains)
}
@Test
fun contains_pathNotCached_returnsFalse() {
// Arrange
every { any() in memoryCacheMock} returns false
// Act
val contains = path in fileCache
// Assert
assertFalse(contains)
}
@Test
fun get_normalBehavior_returnsCachedItem() {
// Arrange
every { memoryCacheMock[any()] } returns cachedFile
// Act
val item = fileCache[path]
// Assert
assertEquals(cachedFile, item)
}
@Test
fun get_pathNotCached_returnsNull() {
// Arrange
every { memoryCacheMock[any()] } returns null
// Act
val item = fileCache[path]
// Assert
assertNull(item)
}
@Test
fun getDirectory_normalBehavior_returnsCachedDirectory() {
// Arrange
every { fileCache.directoryExists(any()) } returns true
every { fileCache[any()] } returns cachedDirectory
// Act
val directory = fileCache.getDirectory(path)
// Assert
assertEquals(cachedDirectory, directory)
}
@Test
fun getDirectory_directoryDoesNotExists_returnsNull() {
// Arrange
every { fileCache.directoryExists(any()) } returns false
every { fileCache[any()] } returns cachedDirectory
// Act
val directory = fileCache.getDirectory(path)
// Assert
assertNull(directory)
}
@Test
fun getFile_normalBehavior_returnsCachedFile() {
// Arrange
every { fileCache[any()] } returns cachedFile
every { fileCache.fileExists(any()) } returns true
// Act
val file = fileCache.getFile(path)
// Assert
assertEquals(cachedFile, file)
}
@Test
fun getFile_fileDoesNotExists_returnsNull() {
// Arrange
every { fileCache[any()] } returns cachedFile
every { fileCache.fileExists(any()) } returns false
// Act
val file = fileCache.getFile(path)
// Assert
assertNull(file)
}
@Test
fun exists_normalBehavior_returnsTrue() {
// Arrange
every { any() in fileCache } returns true
every { fileCache[any()] } returns cachedFile
// Act
val exists = fileCache.exists(path)
// Assert
assertTrue(exists)
}
@Test
fun exists_pathNotCached_returnsFalse() {
// Arrange
every { any() in fileCache } returns false
every { fileCache[any()] } returns cachedFile
// Act
val exists = fileCache.exists(path)
// Assert
assertFalse(exists)
}
@Test
fun exists_itemDoesNotExists_returnsFalse() {
// Arrange
val file = cachedFile.copy(exists = false)
every { any() in fileCache } returns true
every { fileCache[any()] } returns file
// Act
val exists = fileCache.exists(path)
// Assert
assertFalse(exists)
}
@Test
fun directoryExists_normalBehavior_returnsTrue() {
// Arrange
every { fileCache.exists(any()) } returns true
every { fileCache[any()] } returns cachedDirectory
// Act
val exists = fileCache.directoryExists(path)
// Assert
assertTrue(exists)
}
@Test
fun directoryExists_pathNotCached_returnsFalse() {
// Arrange
every { fileCache.exists(any()) } returns false
every { fileCache[any()] } returns cachedDirectory
// Act
val exists = fileCache.directoryExists(path)
// Assert
assertFalse(exists)
}
@Test
fun directoryExists_cachedItemIsNotDirectory_returnsFalse() {
// Arrange
every { fileCache.exists(any()) } returns true
every { fileCache[any()] } returns cachedFile
// Act
val exists = fileCache.directoryExists(path)
// Assert
assertFalse(exists)
}
@Test
fun fileExists_normalBehavior_returnsTrue() {
// Arrange
every { fileCache.exists(any()) } returns true
every { fileCache[any()] } returns cachedFile
// Act
val exists = fileCache.fileExists(path)
// Assert
assertTrue(exists)
}
@Test
fun fileExists_pathNotCached_returnsFalse() {
// Arrange
every { fileCache.exists(any()) } returns false
every { fileCache[any()] } returns cachedFile
// Act
val exists = fileCache.fileExists(path)
// Assert
assertFalse(exists)
}
@Test
fun fileExists_cachedItemIsNotFile_returnsFalse() {
// Arrange
every { fileCache.exists(any()) } returns true
every { fileCache[any()] } returns cachedDirectory
// Act
val exists = fileCache.fileExists(path)
// Assert
assertFalse(exists)
}
@Test
fun setExists_normalBehavior_callsSetInCache() {
// Arrange
every { any() in fileCache } returns true
every { fileCache[any()] } returns cachedFile
setup_memoryCacheMock_set()
val shouldExists = !cachedFile.exists
// Act
fileCache.setExists(path, exists = shouldExists)
// Assert
verify {
memoryCacheMock[path.value] = match { it.exists == shouldExists }
}
confirmVerified(memoryCacheMock)
}
@Test
fun setExists_pathNotCached_callsLoadPath() {
// Arrange
every { any() in fileCache } returns false
every { fileCache[any()] } returns cachedFile
setup_memoryCacheMock_set()
// Act
fileCache.setExists(path, exists = true)
// Assert
verify {
fileCache.load(path)
}
}
@Test
fun load_normalBehavior_callsSetInCache() {
// Arrange
setup_memoryCacheMock_set()
// Act
fileCache.load(path)
// Assert
verify {
memoryCacheMock[path.value] = match { it.path == path }
}
confirmVerified(memoryCacheMock)
}
@Test
fun addItemToDirectory_normalBehavior_addsItemToDirectoryContent() {
// Arrange
every { fileCache.directoryExists(path) } returns true
every { fileCache.getDirectory(path) } returns cachedDirectory
setup_memoryCacheMock_set()
val itemPath = FilePath("${path.value}/item")
// Act
fileCache.addItemToDirectory(path, itemPath)
// Assert
verify {
memoryCacheMock[path.value] = match<CachedDirectory> {
it.content.any { item -> item.path == itemPath }
}
}
confirmVerified(memoryCacheMock)
}
@Test
fun addItemToDirectory_directoryDoesNotExists_doesNothing() {
// Arrange
every { fileCache.directoryExists(path) } returns false
every { fileCache.getDirectory(path) } returns cachedDirectory
setup_memoryCacheMock_set()
val itemPath = FilePath("${path.value}/item")
// Act
fileCache.addItemToDirectory(path, itemPath)
// Assert
verify(exactly = 0) {
memoryCacheMock[path.value] = any()
}
confirmVerified(memoryCacheMock)
}
@Test
fun addItemToDirectory_notADirectory_doesNothing() {
// Arrange
every { fileCache.directoryExists(path) } returns true
every { fileCache.getDirectory(path) } returns null
setup_memoryCacheMock_set()
val itemPath = FilePath("${path.value}/item")
// Act
fileCache.addItemToDirectory(path, itemPath)
// Assert
verify(exactly = 0) {
memoryCacheMock[path.value] = any()
}
confirmVerified(memoryCacheMock)
}
@Test
fun removeItemFromDirectory_normalBehavior_removesItemFromDirectoryContent() {
// Arrange
val itemPath = FilePath("${path.value}/item")
val file = cachedFile.copy(path = itemPath)
val directory = cachedDirectory.copy(content = setOf(file))
every { fileCache.directoryExists(path) } returns true
every { fileCache.getDirectory(path) } returns directory
setup_memoryCacheMock_set()
// Act
fileCache.removeItemFromDirectory(path, itemPath)
// Assert
verify {
memoryCacheMock[path.value] = match<CachedDirectory> { it.content.isEmpty() }
}
confirmVerified(memoryCacheMock)
}
@Test
fun removeItemFromDirectory_directoryDoesNotExists_doesNothing() {
// Arrange
every { fileCache.directoryExists(path) } returns false
every { fileCache.getDirectory(path) } returns cachedDirectory
setup_memoryCacheMock_set()
val itemPath = FilePath("${path.value}/item")
// Act
fileCache.removeItemFromDirectory(path, itemPath)
// Assert
verify(exactly = 0) {
memoryCacheMock[path.value] = any()
}
confirmVerified(memoryCacheMock)
}
@Test
fun removeItemFromDirectory_notADirectory_doesNothing() {
// Arrange
every { fileCache.directoryExists(path) } returns true
every { fileCache.getDirectory(path) } returns null
setup_memoryCacheMock_set()
val itemPath = FilePath("${path.value}/item")
// Act
fileCache.removeItemFromDirectory(path, itemPath)
// Assert
verify(exactly = 0) {
memoryCacheMock[path.value] = any()
}
confirmVerified(memoryCacheMock)
}
}

View File

@ -1,12 +1,13 @@
package dev.fyloz.colorrecipesexplorer.service.files package dev.fyloz.colorrecipesexplorer.service.files
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
import dev.fyloz.colorrecipesexplorer.utils.File
import io.mockk.* import io.mockk.*
import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows import org.junit.jupiter.api.assertThrows
import org.springframework.mock.web.MockMultipartFile import org.springframework.mock.web.MockMultipartFile
import java.io.File
import java.io.IOException import java.io.IOException
import java.nio.file.Path import java.nio.file.Path
import kotlin.test.assertEquals import kotlin.test.assertEquals
@ -20,56 +21,121 @@ private const val mockFilePath = "existingFile"
private val mockFilePathPath = Path.of(mockFilePath) private val mockFilePathPath = Path.of(mockFilePath)
private val mockFileData = byteArrayOf(0x1, 0x8, 0xa, 0xf) private val mockFileData = byteArrayOf(0x1, 0x8, 0xa, 0xf)
private class FileServiceTestContext { class FileServiceTest {
val fileService = spyk(FileServiceImpl(creProperties, mockk { private val fileCacheMock = mockk<FileCache> {
every { error(any(), any<Exception>()) } just Runs every { setExists(any(), any()) } just runs
})) }
val mockFile = mockk<File> { private val fileService = spyk(FileServiceImpl(fileCacheMock, creProperties))
every { path } returns mockFilePath
private val mockFile = mockk<File> {
every { file } returns mockk()
every { exists() } returns true every { exists() } returns true
every { isFile } returns true every { isFile } returns true
every { toPath() } returns mockFilePathPath every { toPath() } returns mockFilePathPath
} }
val mockFileFullPath = spyk(FilePath("${creProperties.dataDirectory}/$mockFilePath")) { private val mockMultipartFile = spyk(MockMultipartFile(mockFilePath, mockFileData))
every { file } returns mockFile
with(fileService) { @BeforeEach
every { mockFilePath.fullPath() } returns this@spyk internal fun beforeEach() {
} mockkObject(File.Companion)
every { File.from(any<String>()) } returns mockFile
} }
val mockMultipartFile = spyk(MockMultipartFile(mockFilePath, mockFileData))
}
class FileServiceTest {
@AfterEach @AfterEach
internal fun afterEach() { internal fun afterEach() {
clearAllMocks() clearAllMocks()
} }
private fun whenFileCached(cached: Boolean = true, test: () -> Unit) {
every { fileCacheMock.contains(any()) } returns cached
test()
}
private fun whenFileNotCached(test: () -> Unit) {
whenFileCached(false, test)
}
private fun whenMockFilePathExists(exists: Boolean = true, test: () -> Unit) {
every { fileService.exists(mockFilePath) } returns exists
test()
}
// exists() // exists()
@Test @Test
fun `exists() returns true when the file at the given path exists and is a file`() { fun `exists() returns true when the file at the given path exists and is a file`() {
test { whenFileNotCached {
assertTrue { fileService.exists(mockFilePath) } assertTrue { fileService.exists(mockFilePath) }
verify {
mockFile.exists()
mockFile.isFile
}
confirmVerified(mockFile)
} }
} }
@Test @Test
fun `exists() returns false when the file at the given path does not exist`() { fun `exists() returns false when the file at the given path does not exist`() {
test { whenFileNotCached {
every { mockFile.exists() } returns false every { mockFile.exists() } returns false
assertFalse { fileService.exists(mockFilePath) } assertFalse { fileService.exists(mockFilePath) }
verify {
mockFile.exists()
}
confirmVerified(mockFile)
} }
} }
@Test @Test
fun `exists() returns false when the file at the given path is not a file`() { fun `exists() returns false when the file at the given path is not a file`() {
test { whenFileNotCached {
every { mockFile.isFile } returns false every { mockFile.isFile } returns false
assertFalse { fileService.exists(mockFilePath) } assertFalse { fileService.exists(mockFilePath) }
verify {
mockFile.exists()
mockFile.isFile
}
confirmVerified(mockFile)
}
}
@Test
fun `exists() returns true when the file at the given path is cached as existing`() {
whenFileCached {
every { fileCacheMock.exists(any()) } returns true
assertTrue { fileService.exists(mockFilePath) }
verify {
fileCacheMock.contains(any())
fileCacheMock.exists(any())
mockFile wasNot called
}
confirmVerified(fileCacheMock, mockFile)
}
}
@Test
fun `exists() returns false when the file at the given path is cached as not existing`() {
whenFileCached {
every { fileCacheMock.exists(any()) } returns false
assertFalse { fileService.exists(mockFilePath) }
verify {
fileCacheMock.contains(any())
fileCacheMock.exists(any())
mockFile wasNot called
}
confirmVerified(fileCacheMock, mockFile)
} }
} }
@ -77,39 +143,33 @@ class FileServiceTest {
@Test @Test
fun `read() returns a valid ByteArrayResource`() { fun `read() returns a valid ByteArrayResource`() {
test { whenMockFilePathExists {
whenMockFilePathExists { mockkStatic(File::readBytes)
mockkStatic(File::readBytes) every { mockFile.readBytes() } returns mockFileData
every { mockFile.readBytes() } returns mockFileData
val redResource = fileService.read(mockFilePath) val redResource = fileService.read(mockFilePath)
assertEquals(mockFileData, redResource.byteArray) assertEquals(mockFileData, redResource.byteArray)
}
} }
} }
@Test @Test
fun `read() throws FileNotFoundException when no file exists at the given path`() { fun `read() throws FileNotFoundException when no file exists at the given path`() {
test { whenMockFilePathExists(false) {
whenMockFilePathExists(false) { with(assertThrows<FileNotFoundException> { fileService.read(mockFilePath) }) {
with(assertThrows<FileNotFoundException> { fileService.read(mockFilePath) }) { assertEquals(mockFilePath, this.path)
assertEquals(mockFilePath, this.path)
}
} }
} }
} }
@Test @Test
fun `read() throws FileReadException when an IOException is thrown`() { fun `read() throws FileReadException when an IOException is thrown`() {
test { whenMockFilePathExists {
whenMockFilePathExists { mockkStatic(File::readBytes)
mockkStatic(File::readBytes) every { mockFile.readBytes() } throws IOException()
every { mockFile.readBytes() } throws IOException()
with(assertThrows<FileReadException> { fileService.read(mockFilePath) }) { with(assertThrows<FileReadException> { fileService.read(mockFilePath) }) {
assertEquals(mockFilePath, this.path) assertEquals(mockFilePath, this.path)
}
} }
} }
} }
@ -118,15 +178,19 @@ class FileServiceTest {
@Test @Test
fun `create() creates a file at the given path`() { fun `create() creates a file at the given path`() {
test { whenMockFilePathExists(false) {
whenMockFilePathExists(false) { whenFileNotCached {
mockkStatic(File::create) mockkStatic(File::create) {
every { mockFile.create() } just Runs every { mockFile.create() } just runs
fileService.create(mockFilePath) fileService.create(mockFilePath)
verify { verify {
mockFile.create() mockFile.create()
fileCacheMock.setExists(any())
}
confirmVerified(mockFile, fileCacheMock)
} }
} }
} }
@ -134,27 +198,23 @@ class FileServiceTest {
@Test @Test
fun `create() does nothing when a file already exists at the given path`() { fun `create() does nothing when a file already exists at the given path`() {
test { whenMockFilePathExists {
whenMockFilePathExists { fileService.create(mockFilePath)
fileService.create(mockFilePath)
verify(exactly = 0) { verify(exactly = 0) {
mockFile.create() mockFile.create()
}
} }
} }
} }
@Test @Test
fun `create() throws FileCreateException when the file creation throws an IOException`() { fun `create() throws FileCreateException when the file creation throws an IOException`() {
test { whenMockFilePathExists(false) {
whenMockFilePathExists(false) { mockkStatic(File::create)
mockkStatic(File::create) every { mockFile.create() } throws IOException()
every { mockFile.create() } throws IOException()
with(assertThrows<FileCreateException> { fileService.create(mockFilePath) }) { with(assertThrows<FileCreateException> { fileService.create(mockFilePath) }) {
assertEquals(mockFilePath, this.path) assertEquals(mockFilePath, this.path)
}
} }
} }
} }
@ -163,59 +223,51 @@ class FileServiceTest {
@Test @Test
fun `write() creates and writes the given MultipartFile to the file at the given path`() { fun `write() creates and writes the given MultipartFile to the file at the given path`() {
test { whenMockFilePathExists(false) {
whenMockFilePathExists(false) { every { fileService.create(mockFilePath) } just runs
every { fileService.create(mockFilePath) } just Runs every { mockMultipartFile.transferTo(mockFilePathPath) } just runs
every { mockMultipartFile.transferTo(mockFilePathPath) } just Runs
fileService.write(mockMultipartFile, mockFilePath, false) fileService.write(mockMultipartFile, mockFilePath, false)
verify { verify {
fileService.create(mockFilePath) fileService.create(mockFilePath)
mockMultipartFile.transferTo(mockFilePathPath) mockMultipartFile.transferTo(mockFilePathPath)
}
} }
} }
} }
@Test @Test
fun `write() throws FileExistsException when a file at the given path already exists and overwrite is disabled`() { fun `write() throws FileExistsException when a file at the given path already exists and overwrite is disabled`() {
test { whenMockFilePathExists {
whenMockFilePathExists { with(assertThrows<FileExistsException> { fileService.write(mockMultipartFile, mockFilePath, false) }) {
with(assertThrows<FileExistsException> { fileService.write(mockMultipartFile, mockFilePath, false) }) { assertEquals(mockFilePath, this.path)
assertEquals(mockFilePath, this.path)
}
} }
} }
} }
@Test @Test
fun `write() writes the given MultipartFile to an existing file when overwrite is enabled`() { fun `write() writes the given MultipartFile to an existing file when overwrite is enabled`() {
test { whenMockFilePathExists {
whenMockFilePathExists { every { mockMultipartFile.transferTo(mockFilePathPath) } just runs
every { mockMultipartFile.transferTo(mockFilePathPath) } just Runs
fileService.write(mockMultipartFile, mockFilePath, true) fileService.write(mockMultipartFile, mockFilePath, true)
verify { verify {
mockMultipartFile.transferTo(mockFilePathPath) mockMultipartFile.transferTo(mockFilePathPath)
}
} }
} }
} }
@Test @Test
fun `write() throws FileWriteException when writing the given file throws an IOException`() { fun `write() throws FileWriteException when writing the given file throws an IOException`() {
test { whenMockFilePathExists(false) {
whenMockFilePathExists(false) { every { fileService.create(mockFilePath) } just runs
every { fileService.create(mockFilePath) } just Runs every { mockMultipartFile.transferTo(mockFilePathPath) } throws IOException()
every { mockMultipartFile.transferTo(mockFilePathPath) } throws IOException()
with(assertThrows<FileWriteException> { with(assertThrows<FileWriteException> {
fileService.write(mockMultipartFile, mockFilePath, false) fileService.write(mockMultipartFile, mockFilePath, false)
}) { }) {
assertEquals(mockFilePath, this.path) assertEquals(mockFilePath, this.path)
}
} }
} }
} }
@ -224,35 +276,38 @@ class FileServiceTest {
@Test @Test
fun `delete() deletes the file at the given path`() { fun `delete() deletes the file at the given path`() {
test { whenMockFilePathExists {
whenMockFilePathExists { whenFileCached {
every { mockFile.delete() } returns true every { mockFile.delete() } returns true
fileService.delete(mockFilePath) fileService.delete(mockFilePath)
verify {
mockFile.delete()
fileCacheMock.setExists(any(), false)
}
confirmVerified(mockFile, fileCacheMock)
} }
} }
} }
@Test @Test
fun `delete() throws FileNotFoundException when no file exists at the given path`() { fun `delete() throws FileNotFoundException when no file exists at the given path`() {
test { whenMockFilePathExists(false) {
whenMockFilePathExists(false) { with(assertThrows<FileNotFoundException> { fileService.delete(mockFilePath) }) {
with(assertThrows<FileNotFoundException> { fileService.delete(mockFilePath) }) { assertEquals(mockFilePath, this.path)
assertEquals(mockFilePath, this.path)
}
} }
} }
} }
@Test @Test
fun `delete() throws FileDeleteException when deleting throw and IOException`() { fun `delete() throws FileDeleteException when deleting throw and IOException`() {
test { whenMockFilePathExists {
whenMockFilePathExists { every { mockFile.delete() } throws IOException()
every { mockFile.delete() } throws IOException()
with(assertThrows<FileDeleteException> { fileService.delete(mockFilePath) }) { with(assertThrows<FileDeleteException> { fileService.delete(mockFilePath) }) {
assertEquals(mockFilePath, this.path) assertEquals(mockFilePath, this.path)
}
} }
} }
} }
@ -261,37 +316,24 @@ class FileServiceTest {
@Test @Test
fun `fullPath() appends the given path to the given working directory`() { fun `fullPath() appends the given path to the given working directory`() {
test { with(fileService) {
with(fileService) { val fullFilePath = fullPath(mockFilePath)
val fullFilePath = mockFilePath.fullPath()
assertEquals("${creProperties.dataDirectory}/$mockFilePath", fullFilePath.path) assertEquals("${creProperties.dataDirectory}/$mockFilePath", fullFilePath.value)
}
} }
} }
@Test @Test
fun `fullPath() throws InvalidFilePathException when the given path contains invalid fragments`() { fun `fullPath() throws InvalidFilePathException when the given path contains invalid fragments`() {
test { with(fileService) {
with(fileService) { BANNED_FILE_PATH_SHARDS.forEach {
BANNED_FILE_PATH_SHARDS.forEach { val maliciousPath = "$it/$mockFilePath"
val maliciousPath = "$it/$mockFilePath"
with(assertThrows<InvalidFilePathException> { maliciousPath.fullPath() }) { with(assertThrows<InvalidFilePathException> { fullPath(maliciousPath) }) {
assertEquals(maliciousPath, this.path) assertEquals(maliciousPath, this.path)
assertEquals(it, this.fragment) assertEquals(it, this.fragment)
}
} }
} }
} }
} }
private fun test(test: FileServiceTestContext.() -> Unit) {
FileServiceTestContext().test()
}
private fun FileServiceTestContext.whenMockFilePathExists(exists: Boolean = true, test: () -> Unit) {
every { fileService.exists(mockFilePath) } returns exists
test()
}
} }

View File

@ -1,5 +1,6 @@
package dev.fyloz.colorrecipesexplorer.service.files package dev.fyloz.colorrecipesexplorer.service.files
import dev.fyloz.colorrecipesexplorer.utils.FilePath
import io.mockk.clearAllMocks import io.mockk.clearAllMocks
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
@ -26,7 +27,7 @@ class ResourceFileServiceTest {
private fun existsTest(shouldExists: Boolean, test: (String) -> Unit) { private fun existsTest(shouldExists: Boolean, test: (String) -> Unit) {
val path = "unit_test_resource" val path = "unit_test_resource"
with(service) { with(service) {
every { path.fullPath() } returns mockk { every { fullPath(path) } returns mockk {
every { resource } returns mockk { every { resource } returns mockk {
every { exists() } returns shouldExists every { exists() } returns shouldExists
} }
@ -60,7 +61,7 @@ class ResourceFileServiceTest {
} }
val path = "unit_test_path" val path = "unit_test_path"
with(service) { with(service) {
every { path.fullPath() } returns mockk { every { fullPath(path) } returns mockk {
every { resource } returns mockResource every { resource } returns mockResource
} }
@ -91,11 +92,9 @@ class ResourceFileServiceTest {
val path = "unit_test_path" val path = "unit_test_path"
val expectedPath = "classpath:$path" val expectedPath = "classpath:$path"
with(service) { val found = service.fullPath(path)
val found = path.fullPath()
assertEquals(expectedPath, found.path) assertEquals(expectedPath, found.value)
}
} }
@Test @Test
@ -103,7 +102,7 @@ class ResourceFileServiceTest {
val filePath = FilePath("classpath:unit_test_path") val filePath = FilePath("classpath:unit_test_path")
val resource = mockk<Resource>() val resource = mockk<Resource>()
every { resourceLoader.getResource(filePath.path) } returns resource every { resourceLoader.getResource(filePath.value) } returns resource
with(service) { with(service) {
val found = filePath.resource val found = filePath.resource