feature/18-add-existing-files-cache #26

Merged
william merged 11 commits from feature/18-add-existing-files-cache into develop 2022-02-12 15:21:39 -05:00
23 changed files with 1071 additions and 241 deletions

View File

@ -2,14 +2,14 @@
global-variables:
release: &release ${DRONE_TAG}
environment: &environment
JAVA_VERSION: 11
GRADLE_VERSION: 7.1
JAVA_VERSION: 17
GRADLE_VERSION: 7.3
CRE_VERSION: dev-${DRONE_BUILD_NUMBER}
CRE_ARTIFACT_NAME: ColorRecipesExplorer
CRE_REGISTRY_IMAGE: registry.fyloz.dev/colorrecipesexplorer/backend
CRE_PORT: 9101
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
docker-registry: &docker-registry registry.fyloz.dev
docker-registry-repo: &docker-registry-repo registry.fyloz.dev/colorrecipesexplorer/backend

View File

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

View File

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

View File

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

View File

@ -1,18 +1,18 @@
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.MaterialTypeProperties
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import dev.fyloz.colorrecipesexplorer.service.files.CachedFileSystemItem
import dev.fyloz.memorycache.ExpiringMemoryCache
import dev.fyloz.memorycache.MemoryCache
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
@Configuration
@EnableConfigurationProperties(MaterialTypeProperties::class, CreProperties::class)
class SpringConfiguration {
class CreConfiguration(private val creProperties: CreProperties) {
@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
oldSystemTypes.forEach {
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_CONFIG_DIRECTORY = "config"
const val DEFAULT_FILE_CACHE_MAX_ACCESS_COUNT = 10_000L
@ConfigurationProperties(prefix = "cre.server")
class CreProperties {
var dataDirectory: String = DEFAULT_DATA_DIRECTORY
var configDirectory: String = DEFAULT_CONFIG_DIRECTORY
var fileCacheMaxAccessCount: Long = DEFAULT_FILE_CACHE_MAX_ACCESS_COUNT
}
@ConfigurationProperties(prefix = "cre.security")

View File

@ -59,6 +59,17 @@ class AlreadyExistsException(
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(
errorCode: 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.CannotDeleteException
import dev.fyloz.colorrecipesexplorer.exception.CannotUpdateException
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import dev.fyloz.colorrecipesexplorer.model.validation.NullOrNotBlank
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_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_UPDATE_EXCEPTION_TITLE = "Cannot update material type"
private const val MATERIAL_TYPE_EXCEPTION_ERROR_CODE = "materialtype"
fun materialTypeIdNotFoundException(id: Long) =
@ -150,9 +152,23 @@ fun materialTypePrefixAlreadyExistsException(prefix: String) =
"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) =
CannotDeleteException(
MATERIAL_TYPE_EXCEPTION_ERROR_CODE,
MATERIAL_TYPE_CANNOT_DELETE_EXCEPTION_TITLE,
"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]. */
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. */
fun findAllBySystemTypeIs(value: Boolean): Collection<MaterialType>

View File

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

View File

@ -1,5 +1,6 @@
package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.model.account.Group
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.utils.setAll
import org.springframework.context.annotation.Lazy
import org.springframework.context.annotation.Profile
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
import java.io.File
import java.time.LocalDate
import java.time.Period
import javax.transaction.Transactional
@ -45,7 +44,7 @@ interface RecipeService :
}
@Service
@Profile("!emergency")
@RequireDatabase
class RecipeServiceImpl(
recipeRepository: RecipeRepository,
val companyService: CompanyService,
@ -213,29 +212,20 @@ interface RecipeImageService {
/** Deletes the image with the given [name] for the given [recipe]. */
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_EXTENSION = ".jpg"
@Service
@Profile("!emergency")
@RequireDatabase
class RecipeImageServiceImpl(
val fileService: WriteableFileService
) : RecipeImageService {
override fun getAllImages(recipe: Recipe): Set<String> {
val recipeDirectory = recipe.getDirectory()
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()
override fun getAllImages(recipe: Recipe) =
fileService.listDirectoryFiles(recipe.imagesDirectoryPath)
.map { it.name }
.toSet()
}
override fun download(image: MultipartFile, recipe: Recipe): String {
/** Gets the next id available for a new image for the given [recipe]. */
@ -252,17 +242,15 @@ class RecipeImageServiceImpl(
} + 1L
}
return getImageFileName(recipe, getNextAvailableId()).apply {
fileService.write(image, getImagePath(recipe, this), true)
return getImageFileName(recipe, getNextAvailableId()).also {
with(getImagePath(recipe, it)) {
fileService.writeToDirectory(image, this, recipe.imagesDirectoryPath, true)
}
}
}
override fun delete(recipe: Recipe, name: String) =
fileService.delete(getImagePath(recipe, name))
override fun Recipe.getDirectory(): File = File(with(fileService) {
this@getDirectory.imagesDirectoryPath.fullPath().path
})
fileService.deleteFromDirectory(getImagePath(recipe, name), recipe.imagesDirectoryPath)
private fun getImageFileName(recipe: Recipe, id: Long) =
"${recipe.name}$RECIPE_IMAGE_ID_DELIMITER$id"

View File

@ -1,5 +1,6 @@
package dev.fyloz.colorrecipesexplorer.service.config
import dev.fyloz.colorrecipesexplorer.JavaFile
import dev.fyloz.colorrecipesexplorer.SUPPORTED_DATABASE_VERSION
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
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.configuration
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 org.slf4j.Logger
import org.springframework.boot.info.BuildProperties
import org.springframework.context.annotation.Lazy
import org.springframework.data.repository.findByIdOrNull
import org.springframework.stereotype.Component
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.time.LocalDate
@ -96,7 +96,7 @@ private class FileConfigurationSource(
private val configFilePath: String
) : ConfigurationSource {
private val properties = Properties().apply {
with(File(configFilePath)) {
with(JavaFile(configFilePath)) {
if (!this.exists()) this.create()
FileInputStream(this).use {
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.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.springframework.core.io.ByteArrayResource
import org.springframework.core.io.Resource
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.nio.file.Files
/** Banned path shards. These are banned because they can allow access to files outside the data directory. */
val BANNED_FILE_PATH_SHARDS = setOf(
@ -26,8 +28,11 @@ interface FileService {
/** Reads the file at the given [path]. */
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. */
fun String.fullPath(): FilePath
fun fullPath(path: String): FilePath
}
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. */
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]. */
fun delete(path: String)
/** Deletes the file at the given [path], and specify the [parentPath]. */
fun deleteFromDirectory(path: String, parentPath: String)
}
@Service
class FileServiceImpl(
private val creProperties: CreProperties,
private val logger: Logger
private val fileCache: FileCache,
private val creProperties: CreProperties
) : WriteableFileService {
override fun exists(path: String) = withFileAt(path.fullPath()) {
this.exists() && this.isFile
private val logger = KotlinLogging.logger {}
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(
withFileAt(path.fullPath()) {
withFileAt(fullPath(path)) {
if (!exists(path)) throw FileNotFoundException(path)
try {
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) {
val fullPath = path.fullPath()
val fullPath = fullPath(path)
if (!exists(path)) {
try {
withFileAt(fullPath) {
this.create()
fileCache.setExists(fullPath)
logger.info("Created file at '${fullPath.value}'")
}
} catch (ex: IOException) {
FileCreateException(path).logAndThrow(ex, logger)
@ -79,35 +114,52 @@ class FileServiceImpl(
override fun write(file: MultipartFile, path: String, overwrite: Boolean) =
prepareWrite(path, overwrite) {
logWrittenDataSize(file.size)
file.transferTo(this.toPath())
}
override fun write(data: ByteArrayResource, path: String, overwrite: Boolean) =
prepareWrite(path, overwrite) {
logWrittenDataSize(data.contentLength())
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) {
try {
withFileAt(path.fullPath()) {
val fullPath = fullPath(path)
withFileAt(fullPath) {
if (!exists(path)) throw FileNotFoundException(path)
!this.delete()
this.delete()
fileCache.setExists(fullPath, false)
logger.info("Deleted file at '${fullPath.value}'")
}
} catch (ex: IOException) {
FileDeleteException(path).logAndThrow(ex, logger)
}
}
override fun String.fullPath(): FilePath {
BANNED_FILE_PATH_SHARDS
.firstOrNull { this.contains(it) }
?.let { throw InvalidFilePathException(this, it) }
override fun deleteFromDirectory(path: String, parentPath: String) {
fileCache.removeItemFromDirectory(fullPath(parentPath), fullPath(path))
delete(path)
}
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) {
val fullPath = path.fullPath()
val fullPath = fullPath(path)
if (exists(path)) {
if (!overwrite) throw FileExistsException(path)
@ -118,26 +170,17 @@ class FileServiceImpl(
try {
withFileAt(fullPath) {
this.op()
logger.info("Wrote data to file at '${fullPath.value}'")
}
} catch (ex: IOException) {
FileWriteException(path).logAndThrow(ex, logger)
}
}
/** 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 fun logWrittenDataSize(size: Long) {
logger.debug("Writing $size bytes to file system...")
}
}
private const val FILE_IO_EXCEPTION_TITLE = "File IO error"

View File

@ -1,5 +1,6 @@
package dev.fyloz.colorrecipesexplorer.service.files
import dev.fyloz.colorrecipesexplorer.utils.FilePath
import org.springframework.core.io.Resource
import org.springframework.core.io.ResourceLoader
import org.springframework.stereotype.Service
@ -9,18 +10,27 @@ class ResourceFileService(
private val resourceLoader: ResourceLoader
) : FileService {
override fun exists(path: String) =
path.fullPath().resource.exists()
fullPath(path).resource.exists()
override fun read(path: String): Resource =
path.fullPath().resource.also {
fullPath(path).resource.also {
if (!it.exists()) {
throw FileNotFoundException(path)
}
}
override fun String.fullPath() =
FilePath("classpath:${this}")
override fun listDirectoryFiles(path: String): Collection<CachedFile> {
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
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.security.jwt-secret=CtnvGQjgZ44A1fh295gE78WWOgl8InrbwBgQsMy0
cre.security.jwt-duration=18000000
cre.security.aes-secret=blabla
# Root user
cre.security.root.id=9999
cre.security.root.password=password

View File

@ -2,6 +2,8 @@ package dev.fyloz.colorrecipesexplorer.service
import com.nhaarman.mockitokotlin2.*
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.model.*
import dev.fyloz.colorrecipesexplorer.repository.MaterialTypeRepository
@ -164,8 +166,22 @@ class MaterialTypeServiceTest :
.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()
@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`() {
whenCanBeDeleted {
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.repository.RecipeRepository
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.users.GroupService
import dev.fyloz.colorrecipesexplorer.utils.FilePath
import io.mockk.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
@ -15,7 +17,6 @@ import org.junit.jupiter.api.TestInstance
import org.junit.jupiter.api.assertThrows
import org.springframework.mock.web.MockMultipartFile
import org.springframework.web.multipart.MultipartFile
import java.io.File
import java.time.LocalDate
import java.time.Period
import kotlin.test.*
@ -30,7 +31,17 @@ class RecipeServiceTest :
private val recipeStepService: RecipeStepService = mock()
private val configService: ConfigurationService = mock()
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)
override val entity: Recipe = recipe(id = 0L, name = "recipe", company = company)
@ -273,18 +284,7 @@ private class RecipeImageServiceTestContext {
val recipe = spyk(recipe())
val recipeImagesIds = setOf(1L, 10L, 21L)
val recipeImagesNames = recipeImagesIds.map { it.imageName }.toSet()
val recipeImagesFiles = recipeImagesNames.map { File(it) }.toTypedArray()
val recipeDirectory = mockk<File> {
every { exists() } returns true
every { isDirectory } returns true
every { listFiles() } returns recipeImagesFiles
}
init {
with(recipeImageService) {
every { recipe.getDirectory() } returns recipeDirectory
}
}
val recipeImagesFiles = recipeImagesNames.map { CachedFile(it, FilePath(it), true) }
val Long.imageName
get() = "${recipe.name}$RECIPE_IMAGE_ID_DELIMITER$this"
@ -308,6 +308,8 @@ class RecipeImageServiceTest {
@Test
fun `getAllImages() returns a Set containing the name of every files in the recipe's directory`() {
test {
every { fileService.listDirectoryFiles(any()) } returns recipeImagesFiles
val foundImagesNames = recipeImageService.getAllImages(recipe)
assertEquals(recipeImagesNames, foundImagesNames)
@ -317,7 +319,7 @@ class RecipeImageServiceTest {
@Test
fun `getAllImages() returns an empty Set when the recipe's directory does not exists`() {
test {
every { recipeDirectory.exists() } returns false
every { fileService.listDirectoryFiles(any()) } returns emptySet()
assertTrue {
recipeImageService.getAllImages(recipe).isEmpty()
@ -335,12 +337,15 @@ class RecipeImageServiceTest {
val expectedImageName = expectedImageId.imageName
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)
assertEquals(expectedImageName, foundImageName)
verify {
fileService.write(mockImage, expectedImagePath, true)
fileService.writeToDirectory(mockImage, expectedImagePath, any(), true)
}
}
}
@ -353,10 +358,12 @@ class RecipeImageServiceTest {
val imageName = recipeImagesIds.first().imageName
val imagePath = imageName.imagePath
every { fileService.deleteFromDirectory(any(), any()) } just runs
recipeImageService.delete(recipe, imageName)
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
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
import dev.fyloz.colorrecipesexplorer.utils.File
import io.mockk.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.springframework.mock.web.MockMultipartFile
import java.io.File
import java.io.IOException
import java.nio.file.Path
import kotlin.test.assertEquals
@ -20,56 +21,121 @@ private const val mockFilePath = "existingFile"
private val mockFilePathPath = Path.of(mockFilePath)
private val mockFileData = byteArrayOf(0x1, 0x8, 0xa, 0xf)
private class FileServiceTestContext {
val fileService = spyk(FileServiceImpl(creProperties, mockk {
every { error(any(), any<Exception>()) } just Runs
}))
val mockFile = mockk<File> {
every { path } returns mockFilePath
class FileServiceTest {
private val fileCacheMock = mockk<FileCache> {
every { setExists(any(), any()) } just runs
}
private val fileService = spyk(FileServiceImpl(fileCacheMock, creProperties))
private val mockFile = mockk<File> {
every { file } returns mockk()
every { exists() } returns true
every { isFile } returns true
every { toPath() } returns mockFilePathPath
}
val mockFileFullPath = spyk(FilePath("${creProperties.dataDirectory}/$mockFilePath")) {
every { file } returns mockFile
private val mockMultipartFile = spyk(MockMultipartFile(mockFilePath, mockFileData))
with(fileService) {
every { mockFilePath.fullPath() } returns this@spyk
}
@BeforeEach
internal fun beforeEach() {
mockkObject(File.Companion)
every { File.from(any<String>()) } returns mockFile
}
val mockMultipartFile = spyk(MockMultipartFile(mockFilePath, mockFileData))
}
class FileServiceTest {
@AfterEach
internal fun afterEach() {
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()
@Test
fun `exists() returns true when the file at the given path exists and is a file`() {
test {
whenFileNotCached {
assertTrue { fileService.exists(mockFilePath) }
verify {
mockFile.exists()
mockFile.isFile
}
confirmVerified(mockFile)
}
}
@Test
fun `exists() returns false when the file at the given path does not exist`() {
test {
whenFileNotCached {
every { mockFile.exists() } returns false
assertFalse { fileService.exists(mockFilePath) }
verify {
mockFile.exists()
}
confirmVerified(mockFile)
}
}
@Test
fun `exists() returns false when the file at the given path is not a file`() {
test {
whenFileNotCached {
every { mockFile.isFile } returns false
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
fun `read() returns a valid ByteArrayResource`() {
test {
whenMockFilePathExists {
mockkStatic(File::readBytes)
every { mockFile.readBytes() } returns mockFileData
whenMockFilePathExists {
mockkStatic(File::readBytes)
every { mockFile.readBytes() } returns mockFileData
val redResource = fileService.read(mockFilePath)
val redResource = fileService.read(mockFilePath)
assertEquals(mockFileData, redResource.byteArray)
}
assertEquals(mockFileData, redResource.byteArray)
}
}
@Test
fun `read() throws FileNotFoundException when no file exists at the given path`() {
test {
whenMockFilePathExists(false) {
with(assertThrows<FileNotFoundException> { fileService.read(mockFilePath) }) {
assertEquals(mockFilePath, this.path)
}
whenMockFilePathExists(false) {
with(assertThrows<FileNotFoundException> { fileService.read(mockFilePath) }) {
assertEquals(mockFilePath, this.path)
}
}
}
@Test
fun `read() throws FileReadException when an IOException is thrown`() {
test {
whenMockFilePathExists {
mockkStatic(File::readBytes)
every { mockFile.readBytes() } throws IOException()
whenMockFilePathExists {
mockkStatic(File::readBytes)
every { mockFile.readBytes() } throws IOException()
with(assertThrows<FileReadException> { fileService.read(mockFilePath) }) {
assertEquals(mockFilePath, this.path)
}
with(assertThrows<FileReadException> { fileService.read(mockFilePath) }) {
assertEquals(mockFilePath, this.path)
}
}
}
@ -118,15 +178,19 @@ class FileServiceTest {
@Test
fun `create() creates a file at the given path`() {
test {
whenMockFilePathExists(false) {
mockkStatic(File::create)
every { mockFile.create() } just Runs
whenMockFilePathExists(false) {
whenFileNotCached {
mockkStatic(File::create) {
every { mockFile.create() } just runs
fileService.create(mockFilePath)
fileService.create(mockFilePath)
verify {
mockFile.create()
verify {
mockFile.create()
fileCacheMock.setExists(any())
}
confirmVerified(mockFile, fileCacheMock)
}
}
}
@ -134,27 +198,23 @@ class FileServiceTest {
@Test
fun `create() does nothing when a file already exists at the given path`() {
test {
whenMockFilePathExists {
fileService.create(mockFilePath)
whenMockFilePathExists {
fileService.create(mockFilePath)
verify(exactly = 0) {
mockFile.create()
}
verify(exactly = 0) {
mockFile.create()
}
}
}
@Test
fun `create() throws FileCreateException when the file creation throws an IOException`() {
test {
whenMockFilePathExists(false) {
mockkStatic(File::create)
every { mockFile.create() } throws IOException()
whenMockFilePathExists(false) {
mockkStatic(File::create)
every { mockFile.create() } throws IOException()
with(assertThrows<FileCreateException> { fileService.create(mockFilePath) }) {
assertEquals(mockFilePath, this.path)
}
with(assertThrows<FileCreateException> { fileService.create(mockFilePath) }) {
assertEquals(mockFilePath, this.path)
}
}
}
@ -163,59 +223,51 @@ class FileServiceTest {
@Test
fun `write() creates and writes the given MultipartFile to the file at the given path`() {
test {
whenMockFilePathExists(false) {
every { fileService.create(mockFilePath) } just Runs
every { mockMultipartFile.transferTo(mockFilePathPath) } just Runs
whenMockFilePathExists(false) {
every { fileService.create(mockFilePath) } just runs
every { mockMultipartFile.transferTo(mockFilePathPath) } just runs
fileService.write(mockMultipartFile, mockFilePath, false)
fileService.write(mockMultipartFile, mockFilePath, false)
verify {
fileService.create(mockFilePath)
mockMultipartFile.transferTo(mockFilePathPath)
}
verify {
fileService.create(mockFilePath)
mockMultipartFile.transferTo(mockFilePathPath)
}
}
}
@Test
fun `write() throws FileExistsException when a file at the given path already exists and overwrite is disabled`() {
test {
whenMockFilePathExists {
with(assertThrows<FileExistsException> { fileService.write(mockMultipartFile, mockFilePath, false) }) {
assertEquals(mockFilePath, this.path)
}
whenMockFilePathExists {
with(assertThrows<FileExistsException> { fileService.write(mockMultipartFile, mockFilePath, false) }) {
assertEquals(mockFilePath, this.path)
}
}
}
@Test
fun `write() writes the given MultipartFile to an existing file when overwrite is enabled`() {
test {
whenMockFilePathExists {
every { mockMultipartFile.transferTo(mockFilePathPath) } just Runs
whenMockFilePathExists {
every { mockMultipartFile.transferTo(mockFilePathPath) } just runs
fileService.write(mockMultipartFile, mockFilePath, true)
fileService.write(mockMultipartFile, mockFilePath, true)
verify {
mockMultipartFile.transferTo(mockFilePathPath)
}
verify {
mockMultipartFile.transferTo(mockFilePathPath)
}
}
}
@Test
fun `write() throws FileWriteException when writing the given file throws an IOException`() {
test {
whenMockFilePathExists(false) {
every { fileService.create(mockFilePath) } just Runs
every { mockMultipartFile.transferTo(mockFilePathPath) } throws IOException()
whenMockFilePathExists(false) {
every { fileService.create(mockFilePath) } just runs
every { mockMultipartFile.transferTo(mockFilePathPath) } throws IOException()
with(assertThrows<FileWriteException> {
fileService.write(mockMultipartFile, mockFilePath, false)
}) {
assertEquals(mockFilePath, this.path)
}
with(assertThrows<FileWriteException> {
fileService.write(mockMultipartFile, mockFilePath, false)
}) {
assertEquals(mockFilePath, this.path)
}
}
}
@ -224,35 +276,38 @@ class FileServiceTest {
@Test
fun `delete() deletes the file at the given path`() {
test {
whenMockFilePathExists {
whenMockFilePathExists {
whenFileCached {
every { mockFile.delete() } returns true
fileService.delete(mockFilePath)
verify {
mockFile.delete()
fileCacheMock.setExists(any(), false)
}
confirmVerified(mockFile, fileCacheMock)
}
}
}
@Test
fun `delete() throws FileNotFoundException when no file exists at the given path`() {
test {
whenMockFilePathExists(false) {
with(assertThrows<FileNotFoundException> { fileService.delete(mockFilePath) }) {
assertEquals(mockFilePath, this.path)
}
whenMockFilePathExists(false) {
with(assertThrows<FileNotFoundException> { fileService.delete(mockFilePath) }) {
assertEquals(mockFilePath, this.path)
}
}
}
@Test
fun `delete() throws FileDeleteException when deleting throw and IOException`() {
test {
whenMockFilePathExists {
every { mockFile.delete() } throws IOException()
whenMockFilePathExists {
every { mockFile.delete() } throws IOException()
with(assertThrows<FileDeleteException> { fileService.delete(mockFilePath) }) {
assertEquals(mockFilePath, this.path)
}
with(assertThrows<FileDeleteException> { fileService.delete(mockFilePath) }) {
assertEquals(mockFilePath, this.path)
}
}
}
@ -261,37 +316,24 @@ class FileServiceTest {
@Test
fun `fullPath() appends the given path to the given working directory`() {
test {
with(fileService) {
val fullFilePath = mockFilePath.fullPath()
with(fileService) {
val fullFilePath = fullPath(mockFilePath)
assertEquals("${creProperties.dataDirectory}/$mockFilePath", fullFilePath.path)
}
assertEquals("${creProperties.dataDirectory}/$mockFilePath", fullFilePath.value)
}
}
@Test
fun `fullPath() throws InvalidFilePathException when the given path contains invalid fragments`() {
test {
with(fileService) {
BANNED_FILE_PATH_SHARDS.forEach {
val maliciousPath = "$it/$mockFilePath"
with(fileService) {
BANNED_FILE_PATH_SHARDS.forEach {
val maliciousPath = "$it/$mockFilePath"
with(assertThrows<InvalidFilePathException> { maliciousPath.fullPath() }) {
assertEquals(maliciousPath, this.path)
assertEquals(it, this.fragment)
}
with(assertThrows<InvalidFilePathException> { fullPath(maliciousPath) }) {
assertEquals(maliciousPath, this.path)
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
import dev.fyloz.colorrecipesexplorer.utils.FilePath
import io.mockk.clearAllMocks
import io.mockk.every
import io.mockk.mockk
@ -26,7 +27,7 @@ class ResourceFileServiceTest {
private fun existsTest(shouldExists: Boolean, test: (String) -> Unit) {
val path = "unit_test_resource"
with(service) {
every { path.fullPath() } returns mockk {
every { fullPath(path) } returns mockk {
every { resource } returns mockk {
every { exists() } returns shouldExists
}
@ -60,7 +61,7 @@ class ResourceFileServiceTest {
}
val path = "unit_test_path"
with(service) {
every { path.fullPath() } returns mockk {
every { fullPath(path) } returns mockk {
every { resource } returns mockResource
}
@ -91,11 +92,9 @@ class ResourceFileServiceTest {
val path = "unit_test_path"
val expectedPath = "classpath:$path"
with(service) {
val found = path.fullPath()
val found = service.fullPath(path)
assertEquals(expectedPath, found.path)
}
assertEquals(expectedPath, found.value)
}
@Test
@ -103,7 +102,7 @@ class ResourceFileServiceTest {
val filePath = FilePath("classpath:unit_test_path")
val resource = mockk<Resource>()
every { resourceLoader.getResource(filePath.path) } returns resource
every { resourceLoader.getResource(filePath.value) } returns resource
with(service) {
val found = filePath.resource