diff --git a/build.gradle.kts b/build.gradle.kts index 3b1bfaf..230d484 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") @@ -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 { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/SpringConfiguration.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/CreConfiguration.kt similarity index 56% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/SpringConfiguration.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/CreConfiguration.kt index 6deafc6..13f6ffb 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/SpringConfiguration.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/CreConfiguration.kt @@ -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 = + ExpiringMemoryCache(maxAccessCount = creProperties.fileCacheMaxAccessCount) } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/properties/CreProperties.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/properties/CreProperties.kt index aad3e76..b0ddfff 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/properties/CreProperties.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/properties/CreProperties.kt @@ -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") diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileCache.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileCache.kt index 6bd2ea9..1961138 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileCache.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileCache.kt @@ -3,6 +3,7 @@ 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 @@ -18,33 +19,32 @@ interface FileCache { /** 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. */ + + /** 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]. */ + + /** 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 : FileCache { +class DefaultFileCache(private val cache: MemoryCache) : FileCache { private val logger = KotlinLogging.logger {} - private val cache = hashMapOf() override operator fun contains(path: FilePath) = path.value in cache @@ -71,7 +71,7 @@ class DefaultFileCache : FileCache { } override fun exists(path: FilePath) = - path in this && cache[path.value]!!.exists + path in this && this[path]!!.exists override fun directoryExists(path: FilePath) = exists(path) && this[path] is CachedDirectory @@ -84,13 +84,13 @@ class DefaultFileCache : FileCache { load(path) } - this[path] = this[path]!!.clone(exists) + 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()) { - cache[path.value] = this + this@DefaultFileCache[path] = this logger.debug("Loaded file at ${path.value} into FileCache") } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Files.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Files.kt index eea31dd..33c0edd 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Files.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Files.kt @@ -46,7 +46,7 @@ class File(val file: JavaFile) { } // TODO: Move to value class when mocking them with mockk works -class FilePath(val value: String) +data class FilePath(val value: String) /** Runs the given [block] in the context of a file with the given [fullPath]. */ fun withFileAt(fullPath: FilePath, block: File.() -> T) = diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 1aa848b..18c7f3f 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -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 diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/DefaultFileCacheTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/DefaultFileCacheTest.kt new file mode 100644 index 0000000..c35cf96 --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/DefaultFileCacheTest.kt @@ -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>() + + 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 { + 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 { 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) + } +} \ No newline at end of file