diff --git a/.drone.yml b/.drone.yml index f9aa83f..3de46ec 100644 --- a/.drone.yml +++ b/.drone.yml @@ -2,16 +2,17 @@ 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:5443/colorrecipesexplorer/backend + 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-repo: &docker-registry-repo registry.fyloz.dev:5443/colorrecipesexplorer/backend + docker-registry: &docker-registry registry.fyloz.dev + docker-registry-repo: &docker-registry-repo registry.fyloz.dev/colorrecipesexplorer/backend kind: pipeline name: default @@ -56,7 +57,12 @@ steps: - GRADLE_VERSION - JAVA_VERSION - CRE_VERSION + registry: *docker-registry repo: *docker-registry-repo + username: + from_secret: docker_username + password: + from_secret: docker_password when: branch: develop event: @@ -71,8 +77,14 @@ steps: build_args_from_env: - GRADLE_VERSION - JAVA_VERSION - - CRE_VERSION + build_args: + - CRE_VERSION=${DRONE_TAG} + registry: *docker-registry repo: *docker-registry-repo + username: + from_secret: docker_username + password: + from_secret: docker_password when: event: - tag @@ -94,6 +106,7 @@ steps: DEPLOY_SPRING_PROFILES: mysql,rest DEPLOY_DATA_VOLUME: /var/cre/data DEPLOY_CONFIG_VOLUME: /var/cre/config + DEPLOY_LOGS_VOLUME: /var/cre/logs commands: - apk update - apk add --no-cache openssh-client @@ -106,7 +119,7 @@ steps: - '[[ -f /.dockerenv ]] && echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config' - ssh -p $DEPLOY_SERVER_SSH_PORT $DEPLOY_SERVER_USERNAME@$DEPLOY_SERVER "docker stop $DEPLOY_CONTAINER_NAME || true && docker rm $DEPLOY_CONTAINER_NAME || true" - ssh -p $DEPLOY_SERVER_SSH_PORT $DEPLOY_SERVER_USERNAME@$DEPLOY_SERVER "docker pull $CRE_REGISTRY_IMAGE:$CRE_RELEASE" - - ssh -p $DEPLOY_SERVER_SSH_PORT $DEPLOY_SERVER_USERNAME@$DEPLOY_SERVER "docker run -d -p $CRE_PORT:9090 --name=$DEPLOY_CONTAINER_NAME -v $DEPLOY_DATA_VOLUME:/usr/bin/data -v $DEPLOY_CONFIG_VOLUME:/usr/bin/config -e spring_profiles_active=$DEPLOY_SPRING_PROFILES $CRE_REGISTRY_IMAGE:$CRE_RELEASE" + - ssh -p $DEPLOY_SERVER_SSH_PORT $DEPLOY_SERVER_USERNAME@$DEPLOY_SERVER "docker run -d -p $CRE_PORT:9090 --name=$DEPLOY_CONTAINER_NAME -v $DEPLOY_DATA_VOLUME:/usr/bin/data -v $DEPLOY_CONFIG_VOLUME:/usr/bin/config -v $DEPLOY_LOGS_VOLUME:/usr/bin/logs -e spring_profiles_active=$DEPLOY_SPRING_PROFILES $CRE_REGISTRY_IMAGE:$CRE_RELEASE" when: event: - tag \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index adf4abc..f16b892 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 @@ -28,5 +28,6 @@ ENV spring_datasource_password=pass VOLUME /usr/bin/data VOLUME /usr/bin/config +VOLUME /usr/bin/logs ENTRYPOINT ["java", "-jar", "ColorRecipesExplorer.jar"] diff --git a/build.gradle.kts b/build.gradle.kts index 22e7087..1325653 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,17 +2,17 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile group = "dev.fyloz.colorrecipesexplorer" -val kotlinVersion = "1.6.0" +val kotlinVersion = "1.6.20" val springBootVersion = "2.6.1" plugins { // Outer scope variables can't be accessed in the plugins section, so we have to redefine them here - val kotlinVersion = "1.6.0" + val kotlinVersion = "1.6.20" val springBootVersion = "2.6.1" id("java") id("org.jetbrains.kotlin.jvm") version kotlinVersion - id("org.jetbrains.dokka") version "1.4.32" + id("org.jetbrains.dokka") version "1.6.10" id("org.springframework.boot") version springBootVersion id("org.jetbrains.kotlin.plugin.spring") version kotlinVersion id("org.jetbrains.kotlin.plugin.jpa") version kotlinVersion @@ -30,7 +30,8 @@ dependencies { implementation(platform("org.jetbrains.kotlin:kotlin-bom:${kotlinVersion}")) 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:6.2") + 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() { - options.compilerArgs.addAll(arrayOf("--release", "11")) + options.compilerArgs.addAll(arrayOf("--release", "17")) } tasks.withType().all { kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() + jvmTarget = JavaVersion.VERSION_17.toString() freeCompilerArgs = listOf( "-Xopt-in=kotlin.contracts.ExperimentalContracts", "-Xinline-classes" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ffed3a2..aa991fc 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/main/java/dev/fyloz/colorrecipesexplorer/service/files/XlsService.java b/src/main/java/dev/fyloz/colorrecipesexplorer/logic/files/XlsService.java similarity index 78% rename from src/main/java/dev/fyloz/colorrecipesexplorer/service/files/XlsService.java rename to src/main/java/dev/fyloz/colorrecipesexplorer/logic/files/XlsService.java index 812bd0a..845f459 100644 --- a/src/main/java/dev/fyloz/colorrecipesexplorer/service/files/XlsService.java +++ b/src/main/java/dev/fyloz/colorrecipesexplorer/logic/files/XlsService.java @@ -1,8 +1,9 @@ -package dev.fyloz.colorrecipesexplorer.service.files; +package dev.fyloz.colorrecipesexplorer.logic.files; -import dev.fyloz.colorrecipesexplorer.model.Recipe; -import dev.fyloz.colorrecipesexplorer.service.RecipeService; +import dev.fyloz.colorrecipesexplorer.dtos.RecipeDto; +import dev.fyloz.colorrecipesexplorer.logic.RecipeLogic; import dev.fyloz.colorrecipesexplorer.xlsx.XlsxExporter; +import mu.KotlinLogging; import org.slf4j.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Profile; @@ -17,14 +18,12 @@ import java.util.zip.ZipOutputStream; @Service @Profile("!emergency") public class XlsService { - - private final RecipeService recipeService; - private final Logger logger; + private final RecipeLogic recipeService; + private final Logger logger = KotlinLogging.INSTANCE.logger("XlsService"); @Autowired - public XlsService(RecipeService recipeService, Logger logger) { - this.recipeService = recipeService; - this.logger = logger; + public XlsService(RecipeLogic recipeLogic) { + this.recipeService = recipeLogic; } /** @@ -33,7 +32,7 @@ public class XlsService { * @param recipe La recette * @return Le fichier XLS de la recette */ - public byte[] generate(Recipe recipe) { + public byte[] generate(RecipeDto recipe) { return new XlsxExporter(logger).generate(recipe); } @@ -56,10 +55,10 @@ public class XlsService { logger.info("Exportation de toutes les couleurs en XLS"); byte[] zipContent; - Collection recipes = recipeService.getAll(); + Collection recipes = recipeService.getAll(); try (ByteArrayOutputStream byteOutput = new ByteArrayOutputStream(); ZipOutputStream zipOutput = new ZipOutputStream(byteOutput)) { - for (Recipe recipe : recipes) { + for (RecipeDto recipe : recipes) { byte[] recipeXLS = generate(recipe); zipOutput.putNextEntry(new ZipEntry(String.format("%s_%s.xlsx", recipe.getCompany().getName(), recipe.getName()))); zipOutput.write(recipeXLS, 0, recipeXLS.length); diff --git a/src/main/java/dev/fyloz/colorrecipesexplorer/xlsx/XlsxExporter.java b/src/main/java/dev/fyloz/colorrecipesexplorer/xlsx/XlsxExporter.java index 79b54e4..9955c37 100644 --- a/src/main/java/dev/fyloz/colorrecipesexplorer/xlsx/XlsxExporter.java +++ b/src/main/java/dev/fyloz/colorrecipesexplorer/xlsx/XlsxExporter.java @@ -1,8 +1,8 @@ package dev.fyloz.colorrecipesexplorer.xlsx; -import dev.fyloz.colorrecipesexplorer.model.Mix; -import dev.fyloz.colorrecipesexplorer.model.MixMaterial; -import dev.fyloz.colorrecipesexplorer.model.Recipe; +import dev.fyloz.colorrecipesexplorer.dtos.MixDto; +import dev.fyloz.colorrecipesexplorer.dtos.MixQuantityOutputDto; +import dev.fyloz.colorrecipesexplorer.dtos.RecipeDto; import dev.fyloz.colorrecipesexplorer.xlsx.component.Document; import dev.fyloz.colorrecipesexplorer.xlsx.component.Sheet; import dev.fyloz.colorrecipesexplorer.xlsx.component.Table; @@ -23,7 +23,7 @@ public class XlsxExporter { this.logger = logger; } - public byte[] generate(Recipe recipe) { + public byte[] generate(RecipeDto recipe) { logger.info(String.format("Génération du XLS de la couleur %s (%s)", recipe.getName(), recipe.getId())); Document document = new Document(recipe.getName(), logger); @@ -44,7 +44,7 @@ public class XlsxExporter { return output; } - private void registerCells(Recipe recipe, Sheet sheet) { + private void registerCells(RecipeDto recipe, Sheet sheet) { // Header sheet.registerCell(new TitleCell(recipe.getName())); sheet.registerCell(new DescriptionCell(DescriptionCell.DescriptionCellType.NAME, "Bannière")); @@ -59,20 +59,20 @@ public class XlsxExporter { sheet.registerCell(new DescriptionCell(DescriptionCell.DescriptionCellType.VALUE_STR, recipe.getRemark())); // Mélanges - Collection recipeMixes = recipe.getMixes(); + Collection recipeMixes = recipe.getMixes(); if (recipeMixes.size() > 0) { sheet.registerCell(new SectionTitleCell("Recette")); - for (Mix mix : recipeMixes) { - Table mixTable = new Table(4, mix.getMixMaterials().size() + 1, mix.getMixType().getName()); + for (MixDto mix : recipeMixes) { + Table mixTable = new Table(4, mix.getMixQuantities().getAll().size() + 1, mix.getMixType().getName()); mixTable.setColumnName(0, "Quantité"); mixTable.setColumnName(2, "Unités"); int row = 0; - for (MixMaterial mixMaterial : mix.getMixMaterials()) { - mixTable.setRowName(row, mixMaterial.getMaterial().getName()); - mixTable.setContent(new Position(1, row + 1), mixMaterial.getQuantity()); - mixTable.setContent(new Position(3, row + 1), mixMaterial.getMaterial().getMaterialType().getUsePercentages() ? "%" : "mL"); + for (MixQuantityOutputDto mixQuantity : mix.getMixQuantitiesOutput()) { + mixTable.setRowName(row, mixQuantity.getMaterial().getName()); + mixTable.setContent(new Position(1, row + 1), mixQuantity.getQuantity()); + mixTable.setContent(new Position(3, row + 1), mixQuantity.getMaterial().getMaterialType().getUsePercentages() ? "%" : "mL"); row++; } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt new file mode 100644 index 0000000..d7853b7 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/Constants.kt @@ -0,0 +1,50 @@ +package dev.fyloz.colorrecipesexplorer + +object Constants { + object ControllerPaths { + const val COMPANY = "/api/company" + const val FILE = "/api/file" + const val GROUP = "/api/user/group" + const val INVENTORY = "/api/inventory" + const val MATERIAL = "/api/material" + const val MATERIAL_TYPE = "/api/materialtype" + const val MIX = "/api/recipe/mix" + const val RECIPE = "/api/recipe" + const val TOUCH_UP_KIT = "/api/touchupkit" + const val USER = "/api/user" + } + + object FilePaths { + private const val PDF = "pdf" + private const val IMAGES = "images" + + const val SIMDUT = "$PDF/simdut" + const val TOUCH_UP_KITS = "$PDF/touchupkits" + const val RECIPE_IMAGES = "$IMAGES/recipes" + } + + object ModelNames { + const val COMPANY = "Company" + const val GROUP = "Group" + const val MATERIAL = "Material" + const val MATERIAL_TYPE = "MaterialType" + const val MIX = "Mix" + const val MIX_MATERIAL = "MixMaterial" + const val MIX_TYPE = "MixType" + const val RECIPE = "Recipe" + const val RECIPE_STEP = "RecipeStep" + const val TOUCH_UP_KIT = "TouchUpKit" + const val USER = "User" + } + + object ValidationMessages { + const val SIZE_GREATER_OR_EQUALS_ZERO = "Must be greater or equals to 0" + const val SIZE_GREATER_OR_EQUALS_ONE = "Must be greater or equals to 1" + const val RANGE_OUTSIDE_PERCENTS = "Must be between 0 and 100" + const val PASSWORD_TOO_SMALL = "Must contains at least 8 characters" + } + + object ValidationRegexes { + const val VALIDATION_COLOR_PATTERN = "^#([0-9a-f]{6})$" + } +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/DatabaseVersioning.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/DatabaseVersioning.kt index 5f020fb..6ca8e8f 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/DatabaseVersioning.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/DatabaseVersioning.kt @@ -3,9 +3,9 @@ package dev.fyloz.colorrecipesexplorer import dev.fyloz.colorrecipesexplorer.databasemanager.CreDatabase import dev.fyloz.colorrecipesexplorer.databasemanager.databaseContext import dev.fyloz.colorrecipesexplorer.databasemanager.databaseUpdaterProperties -import dev.fyloz.colorrecipesexplorer.model.Configuration +import dev.fyloz.colorrecipesexplorer.logic.config.ConfigurationLogic import dev.fyloz.colorrecipesexplorer.model.ConfigurationType -import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService +import mu.KotlinLogging import org.slf4j.Logger import org.springframework.boot.jdbc.DataSourceBuilder import org.springframework.context.annotation.Bean @@ -15,7 +15,7 @@ import org.springframework.core.env.ConfigurableEnvironment import javax.sql.DataSource import org.springframework.context.annotation.Configuration as SpringConfiguration -const val SUPPORTED_DATABASE_VERSION = 5 +const val SUPPORTED_DATABASE_VERSION = 6 const val ENV_VAR_ENABLE_DATABASE_UPDATE_NAME = "CRE_ENABLE_DB_UPDATE" val DATABASE_NAME_REGEX = Regex("(\\w+)$") @@ -23,11 +23,12 @@ val DATABASE_NAME_REGEX = Regex("(\\w+)$") @SpringConfiguration @DependsOn("configurationsInitializer", "configurationService") class DataSourceConfiguration { + private val logger = KotlinLogging.logger {} + @Bean(name = ["dataSource"]) fun customDataSource( - logger: Logger, environment: ConfigurableEnvironment, - configurationService: ConfigurationService + configurationService: ConfigurationLogic ): DataSource { fun getConfiguration(type: ConfigurationType) = if (type.secure) configurationService.getSecure(type) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/TypeAliases.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/TypeAliases.kt index b56a00d..1433775 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/TypeAliases.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/TypeAliases.kt @@ -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 diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ApplicationListeners.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ApplicationListeners.kt index 2f92bf5..f4bff95 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ApplicationListeners.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/ApplicationListeners.kt @@ -4,10 +4,10 @@ import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase import dev.fyloz.colorrecipesexplorer.config.initializers.AbstractInitializer import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties import dev.fyloz.colorrecipesexplorer.emergencyMode +import dev.fyloz.colorrecipesexplorer.logic.config.ConfigurationLogic import dev.fyloz.colorrecipesexplorer.rest.CRE_PROPERTIES import dev.fyloz.colorrecipesexplorer.restartApplication -import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService -import org.slf4j.Logger +import mu.KotlinLogging import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent import org.springframework.context.ApplicationListener import org.springframework.context.annotation.Configuration @@ -20,10 +20,11 @@ import kotlin.concurrent.thread @Order(Ordered.HIGHEST_PRECEDENCE) @RequireDatabase class ApplicationReadyListener( - private val configurationService: ConfigurationService, - private val creProperties: CreProperties, - private val logger: Logger + private val configurationLogic: ConfigurationLogic, + private val creProperties: CreProperties ) : AbstractInitializer() { + private val logger = KotlinLogging.logger {} + override fun initialize() { if (emergencyMode) { logger.error("Emergency mode is enabled, default material types will not be created") @@ -40,17 +41,17 @@ class ApplicationReadyListener( } private fun initDatabaseConfigurations() { - configurationService.initializeProperties { !it.file } + configurationLogic.initializeProperties { !it.file } } } @Configuration("configurationsInitializer") class ConfigurationsInitializer( - private val configurationService: ConfigurationService + private val configurationLogic: ConfigurationLogic ) { @PostConstruct fun initializeFileConfigurations() { - configurationService.initializeProperties { it.file } + configurationLogic.initializeProperties { it.file } } } 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..20a08d9 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.logic.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 fileMemoryCache(): MemoryCache = + ExpiringMemoryCache(maxAccessCount = creProperties.fileCacheMaxAccessCount) } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/annotations/Components.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/annotations/Components.kt new file mode 100644 index 0000000..c7d0927 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/annotations/Components.kt @@ -0,0 +1,15 @@ +package dev.fyloz.colorrecipesexplorer.config.annotations + +import org.springframework.stereotype.Service + +@Service +@RequireDatabase +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class ServiceComponent + +@Service +@RequireDatabase +@Target(AnnotationTarget.CLASS) +@Retention(AnnotationRetention.RUNTIME) +annotation class LogicComponent \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/initializers/MaterialTypeInitializer.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/initializers/MaterialTypeInitializer.kt index 1bb99d2..8431c25 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/initializers/MaterialTypeInitializer.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/initializers/MaterialTypeInitializer.kt @@ -2,16 +2,15 @@ package dev.fyloz.colorrecipesexplorer.config.initializers import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase import dev.fyloz.colorrecipesexplorer.config.properties.MaterialTypeProperties -import dev.fyloz.colorrecipesexplorer.model.MaterialType -import dev.fyloz.colorrecipesexplorer.model.materialType -import dev.fyloz.colorrecipesexplorer.service.MaterialTypeService +import dev.fyloz.colorrecipesexplorer.dtos.MaterialTypeDto +import dev.fyloz.colorrecipesexplorer.logic.MaterialTypeLogic import mu.KotlinLogging import org.springframework.context.annotation.Configuration @Configuration @RequireDatabase class MaterialTypeInitializer( - private val materialTypeService: MaterialTypeService, + private val materialTypeLogic: MaterialTypeLogic, private val materialTypeProperties: MaterialTypeProperties ) : AbstractInitializer() { private val logger = KotlinLogging.logger {} @@ -24,21 +23,20 @@ class MaterialTypeInitializer( private fun ensureSystemMaterialTypesExists() { val systemTypes = materialTypeProperties.systemTypes.map { it.toMaterialType() } - val oldSystemTypes = materialTypeService.getAllSystemTypes().toMutableSet() + val oldSystemTypes = materialTypeLogic.getAll(true).toMutableSet() - fun saveOrUpdateSystemType(type: MaterialType) { - if (materialTypeService.existsByName(type.name)) { - with(materialTypeService.getByName(type.name)) { - if (!this.systemType) { - logger.info("Material type '${type.name}' already exists and will be flagged as a system type") - materialTypeService.update(this.copy(systemType = true)) - } else { - logger.debug("System material type '${type.name}' already exists") - } + fun saveOrUpdateSystemType(type: MaterialTypeDto) { + val storedMaterialType = materialTypeLogic.getByName(type.name) + if (storedMaterialType != null) { + if (!storedMaterialType.systemType) { + logger.info("Material type '${type.name}' already exists and will be flagged as a system type") + materialTypeLogic.update(storedMaterialType.copy(systemType = true)) + } else { + logger.debug("System material type '${type.name}' already exists") } } else { logger.info("System material type '${type.name}' will be created") - materialTypeService.save(type) + materialTypeLogic.save(type) } } @@ -51,7 +49,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)) + materialTypeLogic.updateNonSystemType(it.copy(systemType = false)) } } } \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/initializers/MixInitializer.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/initializers/MixInitializer.kt index 0f0921f..8b7a8d0 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/initializers/MixInitializer.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/initializers/MixInitializer.kt @@ -1,9 +1,8 @@ package dev.fyloz.colorrecipesexplorer.config.initializers import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase -import dev.fyloz.colorrecipesexplorer.model.Mix -import dev.fyloz.colorrecipesexplorer.model.MixMaterial -import dev.fyloz.colorrecipesexplorer.service.MixService +import dev.fyloz.colorrecipesexplorer.dtos.* +import dev.fyloz.colorrecipesexplorer.logic.MixLogic import dev.fyloz.colorrecipesexplorer.utils.merge import mu.KotlinLogging import org.springframework.context.annotation.Configuration @@ -12,7 +11,7 @@ import java.util.* @Configuration @RequireDatabase class MixInitializer( - private val mixService: MixService + private val mixLogic: MixLogic ) : AbstractInitializer() { private val logger = KotlinLogging.logger {} @@ -24,19 +23,20 @@ class MixInitializer( private fun fixAllPositions() { logger.debug("Validating mix materials positions...") - mixService.getAll() - .filter { mix -> mix.mixMaterials.any { it.position == 0 } } + mixLogic.getAll() + .filter { it.mixQuantities.all.any { mq -> mq.position == 0 } } .forEach(this::fixMixPositions) logger.debug("Mix materials positions are valid!") } - private fun fixMixPositions(mix: Mix) { - val maxPosition = mix.mixMaterials.maxOf { it.position } + private fun fixMixPositions(mix: MixDto) { + val mixQuantities = mix.mixQuantitiesOutput + val maxPosition = mixQuantities.maxOf { it.position } - logger.warn("Mix ${mix.id} (${mix.mixType.name}, ${mix.recipe.name}) has invalid positions:") + logger.warn("Mix ${mix.id} (mix name: ${mix.mixType.name}, recipe id: ${mix.recipeId}) has invalid positions:") - val invalidMixMaterials: Collection = with(mix.mixMaterials.filter { it.position == 0 }) { + val invalidMixQuantities: Collection = with(mixQuantities.filter { it.position == 0 }) { if (maxPosition == 0 && this.size > 1) { orderMixMaterials(this) } else { @@ -44,28 +44,37 @@ class MixInitializer( } } - val fixedMixMaterials = increaseMixMaterialsPosition(invalidMixMaterials, maxPosition + 1) - val updatedMixMaterials = mix.mixMaterials.merge(fixedMixMaterials) + val fixedMixQuantities = increaseMixMaterialsPosition(invalidMixQuantities, maxPosition + 1) + val updatedMixQuantities = + mixQuantities.map { MixQuantitySaveDto(it.id, it.material.id, it.quantity, it.position, it.isMixType) } + .merge(fixedMixQuantities) - with(mix.copy(mixMaterials = updatedMixMaterials.toMutableSet())) { - mixService.update(this) - } + val updatedMix = MixSaveDto(mix.id, mix.mixType.name, mix.recipeId, mix.mixType.materialType.id, updatedMixQuantities) + mixLogic.update(updatedMix) } - private fun increaseMixMaterialsPosition(mixMaterials: Iterable, firstPosition: Int) = - mixMaterials - .mapIndexed { index, mixMaterial -> mixMaterial.copy(position = firstPosition + index) } + private fun increaseMixMaterialsPosition(mixQuantities: Iterable, firstPosition: Int) = + mixQuantities + .mapIndexed { index, mixQuantity -> + MixQuantitySaveDto( + mixQuantity.id, + mixQuantity.material.id, + mixQuantity.quantity, + firstPosition + index, + mixQuantity.isMixType + ) + } .onEach { - logger.info("\tPosition of material ${it.material.id} (${it.material.name}) has been set to ${it.position}") + logger.info("\tPosition of material ${it.id} (mixType: ${it.isMixType}) has been set to ${it.position}") } - private fun orderMixMaterials(mixMaterials: Collection) = - LinkedList(mixMaterials).apply { - while (this.peek().material.materialType?.usePercentages == true) { + private fun orderMixMaterials(mixQuantities: Collection) = + LinkedList(mixQuantities).apply { + while (this.peek().material.materialType.usePercentages) { // The first mix material can't use percents, so move it to the end of the queue val pop = this.pop() this.add(pop) - logger.debug("\tMaterial ${pop.material.id} (${pop.material.name}) uses percents, moving to the end of the queue") + logger.debug("\tMaterial ${pop.id} (mixType: ${pop.isMixType}) uses percents, moving to the end of the queue") } } } \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/initializers/RecipeInitializer.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/initializers/RecipeInitializer.kt index 4d49ab5..5347e20 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/initializers/RecipeInitializer.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/initializers/RecipeInitializer.kt @@ -1,10 +1,10 @@ package dev.fyloz.colorrecipesexplorer.config.initializers import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase -import dev.fyloz.colorrecipesexplorer.model.Recipe -import dev.fyloz.colorrecipesexplorer.model.RecipeGroupInformation -import dev.fyloz.colorrecipesexplorer.model.RecipeStep -import dev.fyloz.colorrecipesexplorer.service.RecipeService +import dev.fyloz.colorrecipesexplorer.dtos.RecipeDto +import dev.fyloz.colorrecipesexplorer.dtos.RecipeGroupInformationDto +import dev.fyloz.colorrecipesexplorer.dtos.RecipeStepDto +import dev.fyloz.colorrecipesexplorer.logic.RecipeLogic import dev.fyloz.colorrecipesexplorer.utils.merge import mu.KotlinLogging import org.springframework.context.annotation.Configuration @@ -12,7 +12,7 @@ import org.springframework.context.annotation.Configuration @Configuration @RequireDatabase class RecipeInitializer( - private val recipeService: RecipeService + private val recipeLogic: RecipeLogic ) : AbstractInitializer() { private val logger = KotlinLogging.logger {} @@ -24,44 +24,43 @@ class RecipeInitializer( private fun fixAllPositions() { logger.debug("Validating recipes steps positions...") - recipeService.getAll() + recipeLogic.getAllWithMixesAndGroupsInformation() .forEach(this::fixRecipePositions) logger.debug("Recipes steps positions are valid!") } - private fun fixRecipePositions(recipe: Recipe) { + private fun fixRecipePositions(recipe: RecipeDto) { val fixedGroupInformation = recipe.groupsInformation - .filter { it.steps != null } - .filter { groupInfo -> groupInfo.steps!!.any { it.position == 0 } } + .filter { groupInfo -> groupInfo.steps.any { it.position == 0 } } .map { fixGroupInformationPositions(recipe, it) } - val updatedGroupInformation = recipe.groupsInformation.merge(fixedGroupInformation) + val updatedGroupInformation = recipe.groupsInformation.merge(fixedGroupInformation) { it.id } - with(recipe.copy(groupsInformation = updatedGroupInformation.toMutableSet())) { - recipeService.update(this) + with(recipe.copy(groupsInformation = updatedGroupInformation)) { + recipeLogic.update(this) } } private fun fixGroupInformationPositions( - recipe: Recipe, - groupInformation: RecipeGroupInformation - ): RecipeGroupInformation { - val steps = groupInformation.steps!! + recipe: RecipeDto, + groupInformation: RecipeGroupInformationDto + ): RecipeGroupInformationDto { + val steps = groupInformation.steps val maxPosition = steps.maxOf { it.position } logger.warn("Recipe ${recipe.id} (${recipe.name}) has invalid positions:") val invalidRecipeSteps = steps.filter { it.position == 0 } val fixedRecipeSteps = increaseRecipeStepsPosition(groupInformation, invalidRecipeSteps, maxPosition + 1) - val updatedRecipeSteps = steps.merge(fixedRecipeSteps) + val updatedRecipeSteps = steps.merge(fixedRecipeSteps) { it.id } - return groupInformation.copy(steps = updatedRecipeSteps.toMutableSet()) + return groupInformation.copy(steps = updatedRecipeSteps) } private fun increaseRecipeStepsPosition( - groupInformation: RecipeGroupInformation, - recipeSteps: Iterable, + groupInformation: RecipeGroupInformationDto, + recipeSteps: Iterable, firstPosition: Int ) = recipeSteps 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/config/properties/MaterialTypeProperties.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/properties/MaterialTypeProperties.kt index 226e9d3..343935a 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/properties/MaterialTypeProperties.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/properties/MaterialTypeProperties.kt @@ -1,7 +1,6 @@ package dev.fyloz.colorrecipesexplorer.config.properties -import dev.fyloz.colorrecipesexplorer.model.MaterialType -import dev.fyloz.colorrecipesexplorer.model.materialType +import dev.fyloz.colorrecipesexplorer.dtos.MaterialTypeDto import org.springframework.boot.context.properties.ConfigurationProperties import org.springframework.stereotype.Component import org.springframework.util.Assert @@ -16,9 +15,9 @@ class MaterialTypeProperties { var prefix: String = "", var usePercentages: Boolean = false ) { - fun toMaterialType(): MaterialType { + fun toMaterialType(): MaterialTypeDto { Assert.hasText(name, "A system material type has an empty name") - return materialType(name = name, prefix = prefix, usePercentages = usePercentages, systemType = true) + return MaterialTypeDto(name = name, prefix = prefix, usePercentages = usePercentages, systemType = true) } } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt index b7c27a8..47f997f 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/JwtFilters.kt @@ -2,13 +2,12 @@ package dev.fyloz.colorrecipesexplorer.config.security import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties +import dev.fyloz.colorrecipesexplorer.dtos.UserDetails +import dev.fyloz.colorrecipesexplorer.dtos.UserDto +import dev.fyloz.colorrecipesexplorer.dtos.UserLoginRequestDto import dev.fyloz.colorrecipesexplorer.exception.NotFoundException -import dev.fyloz.colorrecipesexplorer.model.account.UserDetails -import dev.fyloz.colorrecipesexplorer.model.account.UserLoginRequest -import dev.fyloz.colorrecipesexplorer.model.account.UserOutputDto -import dev.fyloz.colorrecipesexplorer.model.account.toAuthorities -import dev.fyloz.colorrecipesexplorer.service.users.JwtService -import dev.fyloz.colorrecipesexplorer.service.users.UserDetailsService +import dev.fyloz.colorrecipesexplorer.logic.users.JwtLogic +import dev.fyloz.colorrecipesexplorer.logic.users.UserDetailsLogic import dev.fyloz.colorrecipesexplorer.utils.addCookie import io.jsonwebtoken.ExpiredJwtException import org.springframework.security.authentication.AuthenticationManager @@ -28,7 +27,7 @@ val blacklistedJwtTokens = mutableListOf() // Not working, move to a ca class JwtAuthenticationFilter( private val authManager: AuthenticationManager, - private val jwtService: JwtService, + private val jwtLogic: JwtLogic, private val securityProperties: CreSecurityProperties, private val updateUserLoginTime: (Long) -> Unit ) : UsernamePasswordAuthenticationFilter() { @@ -40,7 +39,7 @@ class JwtAuthenticationFilter( } override fun attemptAuthentication(request: HttpServletRequest, response: HttpServletResponse): Authentication { - val loginRequest = jacksonObjectMapper().readValue(request.inputStream, UserLoginRequest::class.java) + val loginRequest = jacksonObjectMapper().readValue(request.inputStream, UserLoginRequestDto::class.java) logger.debug("Login attempt for user ${loginRequest.id}...") return authManager.authenticate(UsernamePasswordAuthenticationToken(loginRequest.id, loginRequest.password)) } @@ -52,7 +51,7 @@ class JwtAuthenticationFilter( auth: Authentication ) { val userDetails = auth.principal as UserDetails - val token = jwtService.buildJwt(userDetails) + val token = jwtLogic.buildJwt(userDetails) with(userDetails.user) { logger.info("User ${this.id} (${this.firstName} ${this.lastName}) has logged in successfully") @@ -72,9 +71,9 @@ class JwtAuthenticationFilter( } class JwtAuthorizationFilter( - private val jwtService: JwtService, + private val jwtLogic: JwtLogic, authenticationManager: AuthenticationManager, - private val userDetailsService: UserDetailsService + private val userDetailsLogic: UserDetailsLogic ) : BasicAuthenticationFilter(authenticationManager) { override fun doFilterInternal(request: HttpServletRequest, response: HttpServletResponse, chain: FilterChain) { fun tryLoginFromBearer(): Boolean { @@ -109,18 +108,18 @@ class JwtAuthorizationFilter( private fun getAuthentication(token: String): UsernamePasswordAuthenticationToken? { return try { - val user = jwtService.parseJwt(token.replace("Bearer", "")) + val user = jwtLogic.parseJwt(token.replace("Bearer", "")) getAuthenticationToken(user) } catch (_: ExpiredJwtException) { null } } - private fun getAuthenticationToken(user: UserOutputDto) = - UsernamePasswordAuthenticationToken(user.id, null, user.permissions.toAuthorities()) + private fun getAuthenticationToken(user: UserDto) = + UsernamePasswordAuthenticationToken(user.id, null, user.authorities) private fun getAuthenticationToken(userId: Long): UsernamePasswordAuthenticationToken? = try { - val userDetails = userDetailsService.loadUserById(userId) + val userDetails = userDetailsLogic.loadUserById(userId) UsernamePasswordAuthenticationToken(userDetails.username, null, userDetails.authorities) } catch (_: NotFoundException) { null diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt index f2a70e7..c17ee93 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/config/security/SecurityConfig.kt @@ -1,13 +1,12 @@ package dev.fyloz.colorrecipesexplorer.config.security import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties +import dev.fyloz.colorrecipesexplorer.dtos.UserDto import dev.fyloz.colorrecipesexplorer.emergencyMode +import dev.fyloz.colorrecipesexplorer.logic.users.JwtLogic +import dev.fyloz.colorrecipesexplorer.logic.users.UserDetailsLogic +import dev.fyloz.colorrecipesexplorer.logic.users.UserLogic import dev.fyloz.colorrecipesexplorer.model.account.Permission -import dev.fyloz.colorrecipesexplorer.model.account.User -import dev.fyloz.colorrecipesexplorer.service.users.JwtService -import dev.fyloz.colorrecipesexplorer.service.users.UserDetailsService -import dev.fyloz.colorrecipesexplorer.service.users.UserService -import mu.KLogger import mu.KotlinLogging import org.slf4j.Logger import org.springframework.boot.context.properties.EnableConfigurationProperties @@ -38,8 +37,8 @@ private const val rootUserFirstName = "Root" private const val rootUserLastName = "User" abstract class BaseSecurityConfig( - private val userDetailsService: UserDetailsService, - private val jwtService: JwtService, + private val userDetailsLogic: UserDetailsLogic, + private val jwtLogic: JwtLogic, private val environment: Environment, protected val securityProperties: CreSecurityProperties ) : WebSecurityConfigurerAdapter() { @@ -70,7 +69,7 @@ abstract class BaseSecurityConfig( } override fun configure(authBuilder: AuthenticationManagerBuilder) { - authBuilder.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder) + authBuilder.userDetailsService(userDetailsLogic).passwordEncoder(passwordEncoder) } override fun configure(http: HttpSecurity) { @@ -81,13 +80,13 @@ abstract class BaseSecurityConfig( .addFilter( JwtAuthenticationFilter( authenticationManager(), - jwtService, + jwtLogic, securityProperties, this::updateUserLoginTime ) ) .addFilter( - JwtAuthorizationFilter(jwtService, authenticationManager(), userDetailsService) + JwtAuthorizationFilter(jwtLogic, authenticationManager(), userDetailsLogic) ) .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() @@ -118,12 +117,12 @@ abstract class BaseSecurityConfig( @EnableGlobalMethodSecurity(prePostEnabled = true) @EnableConfigurationProperties(CreSecurityProperties::class) class SecurityConfig( - @Lazy userDetailsService: UserDetailsService, - @Lazy private val userService: UserService, - jwtService: JwtService, + @Lazy userDetailsLogic: UserDetailsLogic, + @Lazy private val userLogic: UserLogic, + jwtLogic: JwtLogic, environment: Environment, securityProperties: CreSecurityProperties -) : BaseSecurityConfig(userDetailsService, jwtService, environment, securityProperties) { +) : BaseSecurityConfig(userDetailsLogic, jwtLogic, environment, securityProperties) { override val logger = KotlinLogging.logger {} @PostConstruct @@ -137,7 +136,7 @@ class SecurityConfig( } override fun updateUserLoginTime(userId: Long) { - userService.updateLastLoginTime(userId) + userLogic.updateLastLoginTime(userId) } private fun createRootUser() { @@ -146,15 +145,16 @@ class SecurityConfig( } with(securityProperties.root!!) { - if (!userService.existsById(this.id)) { - userService.save( - User( + if (!userLogic.existsById(this.id)) { + userLogic.save( + UserDto( id = this.id, firstName = rootUserFirstName, lastName = rootUserLastName, + group = null, password = passwordEncoder.encode(this.password), - isSystemUser = true, - permissions = mutableSetOf(Permission.ADMIN) + permissions = listOf(Permission.ADMIN), + isSystemUser = true ) ) } @@ -166,11 +166,11 @@ class SecurityConfig( @Profile("emergency") @EnableConfigurationProperties(CreSecurityProperties::class) class EmergencySecurityConfig( - userDetailsService: UserDetailsService, - jwtService: JwtService, + userDetailsLogic: UserDetailsLogic, + jwtLogic: JwtLogic, environment: Environment, securityProperties: CreSecurityProperties -) : BaseSecurityConfig(userDetailsService, jwtService, environment, securityProperties) { +) : BaseSecurityConfig(userDetailsLogic, jwtLogic, environment, securityProperties) { override val logger = KotlinLogging.logger {} init { diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/CompanyDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/CompanyDto.kt new file mode 100644 index 0000000..520f738 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/CompanyDto.kt @@ -0,0 +1,10 @@ +package dev.fyloz.colorrecipesexplorer.dtos + +import javax.validation.constraints.NotBlank + +data class CompanyDto( + override val id: Long = 0L, + + @field:NotBlank + val name: String +) : EntityDto \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/EntityDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/EntityDto.kt new file mode 100644 index 0000000..43fbb19 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/EntityDto.kt @@ -0,0 +1,5 @@ +package dev.fyloz.colorrecipesexplorer.dtos + +interface EntityDto { + val id: Long +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/GroupDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/GroupDto.kt new file mode 100644 index 0000000..78ab227 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/GroupDto.kt @@ -0,0 +1,25 @@ +package dev.fyloz.colorrecipesexplorer.dtos + +import com.fasterxml.jackson.annotation.JsonIgnore +import dev.fyloz.colorrecipesexplorer.model.account.Permission +import javax.validation.constraints.NotBlank +import javax.validation.constraints.NotEmpty + +data class GroupDto( + override val id: Long = 0L, + + @field:NotBlank + val name: String, + + @field:NotEmpty + val permissions: List, + + val explicitPermissions: List = listOf() +) : EntityDto { + @get:JsonIgnore + val defaultGroupUserId = getDefaultGroupUserId(id) + + companion object { + fun getDefaultGroupUserId(id: Long) = 1000000 + id + } +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/MaterialDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/MaterialDto.kt new file mode 100644 index 0000000..f9ceba7 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/MaterialDto.kt @@ -0,0 +1,41 @@ +package dev.fyloz.colorrecipesexplorer.dtos + +import dev.fyloz.colorrecipesexplorer.Constants +import org.springframework.web.multipart.MultipartFile +import javax.validation.constraints.Min +import javax.validation.constraints.NotBlank + +data class MaterialDto( + override val id: Long = 0L, + + val name: String, + + val inventoryQuantity: Float, + + val isMixType: Boolean, + + val materialType: MaterialTypeDto, + + val hasSimdut: Boolean = false +) : EntityDto + +data class MaterialSaveDto( + override val id: Long = 0L, + + @field:NotBlank + val name: String, + + @field:Min(0, message = Constants.ValidationMessages.SIZE_GREATER_OR_EQUALS_ZERO) + val inventoryQuantity: Float, + + val materialTypeId: Long, + + val simdutFile: MultipartFile? +) : EntityDto + +data class MaterialQuantityDto( + val materialId: Long, + + @field:Min(0, message = Constants.ValidationMessages.SIZE_GREATER_OR_EQUALS_ZERO) + val quantity: Float +) \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/MaterialTypeDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/MaterialTypeDto.kt new file mode 100644 index 0000000..eb2b197 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/MaterialTypeDto.kt @@ -0,0 +1,13 @@ +package dev.fyloz.colorrecipesexplorer.dtos + +data class MaterialTypeDto( + override val id: Long = 0L, + + val name: String, + + val prefix: String, + + val usePercentages: Boolean, + + val systemType: Boolean = false +) : EntityDto \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/MixDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/MixDto.kt new file mode 100644 index 0000000..186380a --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/MixDto.kt @@ -0,0 +1,77 @@ +package dev.fyloz.colorrecipesexplorer.dtos + +import com.fasterxml.jackson.annotation.JsonIgnore +import com.fasterxml.jackson.annotation.JsonProperty +import dev.fyloz.colorrecipesexplorer.Constants +import javax.validation.constraints.Min +import javax.validation.constraints.NotBlank + +data class MixDto( + override val id: Long = 0L, + + val location: String? = null, + + @JsonIgnore + val recipeId: Long, + + val mixType: MixTypeDto, + + @JsonIgnore + val mixQuantities: MixQuantitiesDto, +) : EntityDto { + @Suppress("unused") + @get:JsonProperty("mixQuantities") + val mixQuantitiesOutput by lazy { + mixQuantities.materials.map { + MixQuantityOutputDto(it.id, it.material, it.quantity, it.position, false) + } + mixQuantities.mixTypes.map { + MixQuantityOutputDto(it.id, it.mixType.asMaterial(), it.quantity, it.position, true) + } + } +} + +data class MixQuantitiesDto( + val materials: List = listOf(), + + val mixTypes: List = listOf() +) { + val all get() = materials + mixTypes +} + +data class MixQuantityOutputDto( + val id: Long, + + val material: MaterialDto, + + val quantity: Float, + + val position: Int, + + val isMixType: Boolean +) + +data class MixSaveDto( + val id: Long = 0L, + + @field:NotBlank + val name: String, + + val recipeId: Long = 0L, + + val materialTypeId: Long, + + val mixQuantities: List +) + +data class MixDeductDto( + val id: Long, + + @field:Min(0, message = Constants.ValidationMessages.SIZE_GREATER_OR_EQUALS_ZERO) + val ratio: Float +) + +data class MixLocationDto( + val mixId: Long, + + val location: String? +) \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/MixMaterialDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/MixMaterialDto.kt new file mode 100644 index 0000000..a2ce9c4 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/MixMaterialDto.kt @@ -0,0 +1,57 @@ +package dev.fyloz.colorrecipesexplorer.dtos + +import dev.fyloz.colorrecipesexplorer.Constants +import javax.validation.constraints.Min + +sealed interface MixQuantityDto : EntityDto { + val quantity: Float + val position: Int + + val materialType: MaterialTypeDto + val name: String +} + +data class MixMaterialDto( + override val id: Long = 0L, + + val material: MaterialDto, + + override val quantity: Float, + + override val position: Int +) : MixQuantityDto { + override val materialType: MaterialTypeDto + get() = material.materialType + + override val name: String + get() = material.name +} + +data class MixMixTypeDto( + override val id: Long, + + val mixType: MixTypeDto, + + override val quantity: Float, + + override val position: Int +) : MixQuantityDto { + override val materialType: MaterialTypeDto + get() = mixType.materialType + + override val name: String + get() = mixType.name +} + +data class MixQuantitySaveDto( + override val id: Long = 0L, + + val materialId: Long, + + @field:Min(0, message = Constants.ValidationMessages.SIZE_GREATER_OR_EQUALS_ZERO) + val quantity: Float, + + val position: Int, + + val isMixType: Boolean +) : EntityDto \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/MixTypeDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/MixTypeDto.kt new file mode 100644 index 0000000..29022d6 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/MixTypeDto.kt @@ -0,0 +1,14 @@ +package dev.fyloz.colorrecipesexplorer.dtos + +data class MixTypeDto( + override val id: Long = 0L, + + val name: String, + + val materialType: MaterialTypeDto, + + val material: MaterialDto? = null +) : EntityDto { + fun asMaterial() = + MaterialDto(id, name, 0f, true, materialType) +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/RecipeDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/RecipeDto.kt new file mode 100644 index 0000000..0a76be4 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/RecipeDto.kt @@ -0,0 +1,121 @@ +package dev.fyloz.colorrecipesexplorer.dtos + +import com.fasterxml.jackson.annotation.JsonIgnore +import dev.fyloz.colorrecipesexplorer.Constants +import java.time.LocalDate +import javax.validation.constraints.Max +import javax.validation.constraints.Min +import javax.validation.constraints.NotBlank +import javax.validation.constraints.Pattern + +data class RecipeDto( + override val id: Long = 0L, + + val name: String, + + val description: String, + + val color: String, + + val gloss: Byte, + + val sample: Int?, + + val approbationDate: LocalDate?, + + val approbationExpired: Boolean, + + val remark: String, + + val company: CompanyDto, + + val mixes: List, + + val groupsInformation: List +) : EntityDto { + val mixTypes: Collection + @JsonIgnore + get() = mixes.map { it.mixType } +} + +data class RecipeSaveDto( + @field:NotBlank + val name: String, + + @field:NotBlank + val description: String, + + @field:NotBlank + @field:Pattern(regexp = Constants.ValidationRegexes.VALIDATION_COLOR_PATTERN) + val color: String, + + @field:Min(0, message = Constants.ValidationMessages.RANGE_OUTSIDE_PERCENTS) + @field:Max(100, message = Constants.ValidationMessages.RANGE_OUTSIDE_PERCENTS) + val gloss: Byte, + + @field:Min(0, message = Constants.ValidationMessages.SIZE_GREATER_OR_EQUALS_ZERO) + val sample: Int?, + + val approbationDate: LocalDate?, + + val remark: String?, + + val companyId: Long +) + +data class RecipeUpdateDto( + val id: Long, + + @field:NotBlank + val name: String, + + @field:NotBlank + val description: String, + + @field:NotBlank + @field:Pattern(regexp = Constants.ValidationRegexes.VALIDATION_COLOR_PATTERN) + val color: String, + + @field:Min(0, message = Constants.ValidationMessages.RANGE_OUTSIDE_PERCENTS) + @field:Max(100, message = Constants.ValidationMessages.RANGE_OUTSIDE_PERCENTS) + val gloss: Byte, + + @field:Min(0, message = Constants.ValidationMessages.SIZE_GREATER_OR_EQUALS_ZERO) + val sample: Int?, + + val approbationDate: LocalDate?, + + val remark: String?, + + val steps: List +) + +data class RecipeGroupInformationDto( + override val id: Long = 0L, + + val group: GroupDto, + + val note: String? = null, + + val steps: List = listOf() +) : EntityDto + +data class RecipeGroupStepsDto( + val groupId: Long, + + val steps: List +) + +data class RecipeGroupNoteDto( + val groupId: Long, + + val content: String? +) + +data class RecipePublicDataDto( + val recipeId: Long, + + val notes: List, + + val mixesLocation: List +) \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/RecipeStepDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/RecipeStepDto.kt new file mode 100644 index 0000000..e597927 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/RecipeStepDto.kt @@ -0,0 +1,9 @@ +package dev.fyloz.colorrecipesexplorer.dtos + +data class RecipeStepDto( + override val id: Long = 0L, + + val position: Int, + + val message: String +) : EntityDto \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/TouchUpKitDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/TouchUpKitDto.kt new file mode 100644 index 0000000..b597c91 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/TouchUpKitDto.kt @@ -0,0 +1,52 @@ +package dev.fyloz.colorrecipesexplorer.dtos + +import dev.fyloz.colorrecipesexplorer.Constants +import java.time.LocalDate +import javax.validation.constraints.Min +import javax.validation.constraints.NotBlank +import javax.validation.constraints.NotEmpty + +data class TouchUpKitDto( + override val id: Long = 0L, + + @field:NotBlank + val project: String, + + @field:NotBlank + val buggy: String, + + @field:NotBlank + val company: String, + + @field:Min(1, message = Constants.ValidationMessages.SIZE_GREATER_OR_EQUALS_ONE) + val quantity: Int, + + val shippingDate: LocalDate, + + val completionDate: LocalDate?, + + val completed: Boolean = false, + + val expired: Boolean = false, + + @field:NotEmpty + val finish: List, + + @field:NotEmpty + val material: List, + + @field:NotEmpty + val content: List +) : EntityDto + +data class TouchUpKitProductDto( + override val id: Long = 0L, + + val name: String, + + val description: String?, + + val quantity: Float, + + val ready: Boolean +) : EntityDto \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/UserDto.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/UserDto.kt new file mode 100644 index 0000000..edfaef4 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/dtos/UserDto.kt @@ -0,0 +1,94 @@ +package dev.fyloz.colorrecipesexplorer.dtos + +import com.fasterxml.jackson.annotation.JsonIgnore +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.SpringUserDetails +import dev.fyloz.colorrecipesexplorer.model.account.Permission +import dev.fyloz.colorrecipesexplorer.model.account.toAuthority +import java.time.LocalDateTime +import javax.validation.constraints.NotBlank +import javax.validation.constraints.Size + +data class UserDto( + override val id: Long = 0L, + + val firstName: String, + + val lastName: String, + + @field:JsonIgnore + val password: String = "", + + val group: GroupDto?, + + val permissions: List, + + val explicitPermissions: List = listOf(), + + val lastLoginTime: LocalDateTime? = null, + + @field:JsonIgnore + val isDefaultGroupUser: Boolean = false, + + @field:JsonIgnore + val isSystemUser: Boolean = false +) : EntityDto { + @get:JsonIgnore + val authorities + get() = permissions + .map { it.toAuthority() } + .toMutableSet() +} + +data class UserSaveDto( + val id: Long = 0L, + + @field:NotBlank + val firstName: String, + + @field:NotBlank + val lastName: String, + + @field:NotBlank + @field:Size(min = 8, message = Constants.ValidationMessages.PASSWORD_TOO_SMALL) + val password: String, + + val groupId: Long?, + + val permissions: List, + + // TODO WN: Test if working + // @JsonProperty(access = JsonProperty.Access.READ_ONLY) + @field:JsonIgnore + val isSystemUser: Boolean = false, + + @field:JsonIgnore + val isDefaultGroupUser: Boolean = false +) + +data class UserUpdateDto( + val id: Long = 0L, + + @field:NotBlank + val firstName: String, + + @field:NotBlank + val lastName: String, + + val groupId: Long?, + + val permissions: List +) + +data class UserLoginRequestDto(val id: Long, val password: String) + +class UserDetails(val user: UserDto) : SpringUserDetails { + override fun getPassword() = user.password + override fun getUsername() = user.id.toString() + override fun getAuthorities() = user.authorities + + override fun isAccountNonExpired() = true + override fun isAccountNonLocked() = true + override fun isCredentialsNonExpired() = true + override fun isEnabled() = true +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/exception/InvalidPositionsException.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/exception/InvalidPositionsException.kt new file mode 100644 index 0000000..c53c0aa --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/exception/InvalidPositionsException.kt @@ -0,0 +1,15 @@ +package dev.fyloz.colorrecipesexplorer.exception + +import org.springframework.http.HttpStatus + +class InvalidPositionsException(val errors: Set) : RestException( + "invalid-positions", + "Invalid positions", + HttpStatus.BAD_REQUEST, + "The positions are invalid", + mapOf( + "errors" to errors + ) +) + +data class InvalidPositionError(val type: String, val details: String) \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/exception/NoDefaultGroupException.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/exception/NoDefaultGroupException.kt new file mode 100644 index 0000000..7a0025c --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/exception/NoDefaultGroupException.kt @@ -0,0 +1,10 @@ +package dev.fyloz.colorrecipesexplorer.exception + +import org.springframework.http.HttpStatus + +class NoDefaultGroupException : RestException( + "nodefaultgroup", + "No default group", + HttpStatus.NOT_FOUND, + "No default group cookie is defined in the current request" +) \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/exception/RestException.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/exception/RestException.kt index 1bc62b7..7e64182 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/exception/RestException.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/exception/RestException.kt @@ -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, diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/CompanyLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/CompanyLogic.kt new file mode 100644 index 0000000..2b7539a --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/CompanyLogic.kt @@ -0,0 +1,38 @@ +package dev.fyloz.colorrecipesexplorer.logic + +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent +import dev.fyloz.colorrecipesexplorer.dtos.CompanyDto +import dev.fyloz.colorrecipesexplorer.service.CompanyService + +interface CompanyLogic : Logic + +@LogicComponent +class DefaultCompanyLogic(service: CompanyService) : + BaseLogic(service, Constants.ModelNames.COMPANY), CompanyLogic { + override fun save(dto: CompanyDto): CompanyDto { + throwIfNameAlreadyExists(dto.name) + + return super.save(dto) + } + + override fun update(dto: CompanyDto): CompanyDto { + throwIfNameAlreadyExists(dto.name, dto.id) + + return super.update(dto) + } + + override fun deleteById(id: Long) { + if (service.isUsedByRecipe(id)) { + throw cannotDeleteException("Cannot delete the company with the id '$id' because one or more recipes depends on it") + } + + super.deleteById(id) + } + + private fun throwIfNameAlreadyExists(name: String, id: Long? = null) { + if (service.existsByName(name, id)) { + throw alreadyExistsException(value = name) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/InventoryLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/InventoryLogic.kt new file mode 100644 index 0000000..d4db8a1 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/InventoryLogic.kt @@ -0,0 +1,125 @@ +package dev.fyloz.colorrecipesexplorer.logic + +import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase +import dev.fyloz.colorrecipesexplorer.dtos.MaterialDto +import dev.fyloz.colorrecipesexplorer.dtos.MaterialQuantityDto +import dev.fyloz.colorrecipesexplorer.dtos.MixDeductDto +import dev.fyloz.colorrecipesexplorer.dtos.MixMaterialDto +import dev.fyloz.colorrecipesexplorer.exception.RestException +import dev.fyloz.colorrecipesexplorer.model.Material +import dev.fyloz.colorrecipesexplorer.utils.mapMayThrow +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import javax.transaction.Transactional + +interface InventoryLogic { + /** Adds each given [MaterialQuantityDto] to the inventory and returns the updated quantities. */ + fun add(materialQuantities: Collection): Collection + + /** Adds a given quantity to the given [Material]'s inventory quantity according to the given [materialQuantity] and returns the updated quantity. */ + fun add(materialQuantity: MaterialQuantityDto): Float + + /** Deducts the inventory quantity of each [Material]s in the mix according to the ratio defined in the given [mixRatio] and returns the updated quantities. */ + fun deductMix(mixRatio: MixDeductDto): Collection + + /** Deducts the inventory quantity of each given [MaterialQuantityDto] and returns the updated quantities. */ + fun deduct(materialQuantities: Collection): Collection + + /** Deducts the inventory quantity of a given [Material] by a given quantity according to the given [materialQuantity] and returns the updated quantity. */ + fun deduct(materialQuantity: MaterialQuantityDto): Float +} + +@Service +@RequireDatabase +class DefaultInventoryLogic( + private val materialLogic: MaterialLogic, + private val mixLogic: MixLogic +) : InventoryLogic { + @Transactional + override fun add(materialQuantities: Collection) = + materialQuantities.map { MaterialQuantityDto(it.materialId, add(it)) } + + override fun add(materialQuantity: MaterialQuantityDto) = + materialLogic.updateQuantity( + materialLogic.getById(materialQuantity.materialId), + materialQuantity.quantity + ) + + @Transactional + override fun deductMix(mixRatio: MixDeductDto): Collection { + val mix = mixLogic.getById(mixRatio.id) + val mixMaterials = mix.mixQuantities.materials + + if (mixMaterials.isEmpty()) return listOf() + return deduct(getMaterialsWithAdjustedQuantities(mixMaterials, mixRatio)) + } + + @Transactional + override fun deduct(materialQuantities: Collection): Collection { + val thrown = mutableListOf() + + val updatedQuantities = + materialQuantities.mapMayThrow( + { thrown.add(it) } + ) { + MaterialQuantityDto(it.materialId, deduct(it)) + } + + if (thrown.isNotEmpty()) { + throw MultiplesNotEnoughInventoryException(thrown) + } + + return updatedQuantities + } + + override fun deduct(materialQuantity: MaterialQuantityDto): Float = + with(materialLogic.getById(materialQuantity.materialId)) { + if (this.inventoryQuantity >= materialQuantity.quantity) { + materialLogic.updateQuantity(this, -materialQuantity.quantity) + } else { + throw NotEnoughInventoryException(materialQuantity.quantity, this) + } + } + + private fun getMaterialsWithAdjustedQuantities( + mixMaterials: Collection, + mixRatio: MixDeductDto + ): Collection { + val adjustedFirstMaterialQuantity = mixMaterials.first().quantity * mixRatio.ratio + + fun getAdjustedQuantity(material: MaterialDto, quantity: Float) = + if (!material.materialType.usePercentages) + quantity * mixRatio.ratio // Simply multiply the quantity by the ratio + else + (quantity * adjustedFirstMaterialQuantity) / 100f // Percents quantities are a ratio of the first material + + return mixMaterials.associate { it.material to it.quantity } + .mapValues { getAdjustedQuantity(it.key, it.value) } + .map { MaterialQuantityDto(it.key.id, it.value) } + } +} + +class NotEnoughInventoryException(quantity: Float, material: MaterialDto) : + RestException( + "notenoughinventory", + "Not enough inventory", + HttpStatus.BAD_REQUEST, + "Cannot deduct ${quantity}mL of ${material.name} because there is only ${material.inventoryQuantity}mL in inventory", + mapOf( + "material" to material.name, + "materialId" to material.id.toString(), + "requestQuantity" to quantity, + "availableQuantity" to material.inventoryQuantity + ) + ) + +class MultiplesNotEnoughInventoryException(exceptions: List) : + RestException( + "notenoughinventory-multiple", + "Not enough inventory", + HttpStatus.BAD_REQUEST, + "Cannot deduct requested quantities because there is no enough of them in inventory", + mapOf( + "lowQuantities" to exceptions.map { it.extensions } + ) + ) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/Logic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/Logic.kt new file mode 100644 index 0000000..8fef428 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/Logic.kt @@ -0,0 +1,101 @@ +package dev.fyloz.colorrecipesexplorer.logic + +import dev.fyloz.colorrecipesexplorer.dtos.EntityDto +import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException +import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException +import dev.fyloz.colorrecipesexplorer.exception.NotFoundException +import dev.fyloz.colorrecipesexplorer.service.Service +import dev.fyloz.colorrecipesexplorer.utils.collections.LazyMapList +import org.springframework.transaction.annotation.Transactional + +/** + * Represents the logic for a DTO type. + * + * @param D The type of the DTO. + * @param S The service for the DTO. + */ +interface Logic> { + /** Checks if a DTO with the given [id] exists. */ + fun existsById(id: Long): Boolean + + /** Get all DTOs. */ + fun getAll(): Collection + + /** Get the DTO for the given [id]. Throws if no DTO were found. */ + fun getById(id: Long): D + + /** Saves the given [dto]. */ + fun save(dto: D): D + + /** Saves all the given [dtos]. */ + fun saveAll(dtos: Collection): Collection + + /** Updates the given [dto]. Throws if no DTO with the same id exists. */ + fun update(dto: D): D + + /** Deletes the dto with the given [id]. */ + fun deleteById(id: Long) +} + +abstract class BaseLogic>( + protected val service: S, + protected val typeName: String +) : Logic { + protected val typeNameLowerCase = typeName.lowercase() + + override fun existsById(id: Long) = + service.existsById(id) + + override fun getAll() = + service.getAll() + + override fun getById(id: Long) = + service.getById(id) ?: throw notFoundException(value = id) + + override fun save(dto: D) = + service.save(dto) + + override fun saveAll(dtos: Collection) = + dtos.map(::save) + + override fun update(dto: D): D { + if (!existsById(dto.id)) { + throw notFoundException(value = dto.id) + } + + return service.save(dto) + } + + override fun deleteById(id: Long) = + service.deleteById(id) + + protected fun notFoundException(identifierName: String = ID_IDENTIFIER_NAME, value: Any) = + NotFoundException( + typeNameLowerCase, + "$typeName not found", + "A $typeNameLowerCase with the $identifierName '$value' could not be found", + value, + identifierName + ) + + protected fun alreadyExistsException(identifierName: String = NAME_IDENTIFIER_NAME, value: Any) = + AlreadyExistsException( + typeNameLowerCase, + "$typeName already exists", + "A $typeNameLowerCase with the $identifierName '$value' already exists", + value, + identifierName + ) + + protected fun cannotDeleteException(details: String) = + CannotDeleteException( + typeNameLowerCase, + "Cannot delete $typeNameLowerCase", + details + ) + + companion object { + const val ID_IDENTIFIER_NAME = "id" + const val NAME_IDENTIFIER_NAME = "name" + } +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/MaterialLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/MaterialLogic.kt new file mode 100644 index 0000000..926af5c --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/MaterialLogic.kt @@ -0,0 +1,121 @@ +package dev.fyloz.colorrecipesexplorer.logic + +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent +import dev.fyloz.colorrecipesexplorer.dtos.MaterialDto +import dev.fyloz.colorrecipesexplorer.dtos.MaterialSaveDto +import dev.fyloz.colorrecipesexplorer.dtos.MixTypeDto +import dev.fyloz.colorrecipesexplorer.logic.files.WriteableFileLogic +import dev.fyloz.colorrecipesexplorer.model.Material +import dev.fyloz.colorrecipesexplorer.service.MaterialService + +interface MaterialLogic : Logic { + /** Checks if a material with the given [name] exists. */ + fun existsByName(name: String): Boolean + + /** + * Returns every material available in the context of the recipe with the given [recipeId]. + * The materials included contains every non mix type material, and the materials generated for the recipe mix types. + */ + fun getAllForRecipe(recipeId: Long): Collection + + /** + * Returns every material available in the context of the mix with the given [mixId]. + * The materials included contains every non mix type material, and the materials generated for + * the mix's recipe mix types, excluding the mix's mix type. + */ + fun getAllForMix(mixId: Long): Collection + + /** Saves the given [dto]. */ + fun save(dto: MaterialSaveDto): MaterialDto + + /** Updates the given [dto]. */ + fun update(dto: MaterialSaveDto): MaterialDto + + /** Updates the quantity of the given [material] with the given [factor] and returns the updated quantity. */ + fun updateQuantity(material: MaterialDto, factor: Float): Float +} + +@LogicComponent +class DefaultMaterialLogic( + service: MaterialService, + val recipeLogic: RecipeLogic, + val mixLogic: MixLogic, + val materialTypeLogic: MaterialTypeLogic, + val fileLogic: WriteableFileLogic +) : BaseLogic(service, Constants.ModelNames.MATERIAL), MaterialLogic { + override fun existsByName(name: String) = service.existsByName(name, null) + + override fun getAllForRecipe(recipeId: Long): Collection { + val recipe = recipeLogic.getById(recipeId) + + return getAllWithMixTypesMaterials(recipe.mixTypes) + } + + override fun getAllForMix(mixId: Long): Collection { + val mix = mixLogic.getById(mixId) + val recipe = recipeLogic.getById(mix.recipeId) + + val availableMixTypes = recipe.mixTypes.filter { it != mix.mixType } + return getAllWithMixTypesMaterials(availableMixTypes) + } + + private fun getAllWithMixTypesMaterials(mixTypes: Collection) = + getAll() + mixTypes.map { it.asMaterial() } + + override fun save(dto: MaterialSaveDto) = save(saveDtoToDto(dto, false)).also { saveSimdutFile(dto, false) } + override fun save(dto: MaterialDto): MaterialDto { + throwIfNameAlreadyExists(dto.name) + + return super.save(dto) + } + + override fun update(dto: MaterialSaveDto) = update(saveDtoToDto(dto, true)).also { saveSimdutFile(dto, true) } + override fun update(dto: MaterialDto): MaterialDto { + throwIfNameAlreadyExists(dto.name, dto.id) + + return super.update(dto) + } + + override fun updateQuantity(material: MaterialDto, factor: Float): Float { + val updatedQuantity = material.inventoryQuantity + factor + service.updateInventoryQuantityById(material.id, updatedQuantity) + + return updatedQuantity + } + + override fun deleteById(id: Long) { + if (service.isUsedByMixMaterialOrMixType(id)) { + throw cannotDeleteException("Cannot delete the material with the id '$id' because mix types and/or recipes depends on it") + } + + val material = getById(id) + val simdutPath = Material.getSimdutFilePath(material.name) + if (fileLogic.exists(simdutPath)) { + fileLogic.delete(simdutPath) + } + + super.deleteById(id) + } + + private fun saveDtoToDto(saveDto: MaterialSaveDto, updating: Boolean): MaterialDto { + val isMixType = !updating || getById(saveDto.id).isMixType + val materialType = materialTypeLogic.getById(saveDto.materialTypeId) + + return MaterialDto(saveDto.id, saveDto.name, saveDto.inventoryQuantity, isMixType, materialType) + } + + private fun saveSimdutFile(dto: MaterialSaveDto, updating: Boolean) { + val file = dto.simdutFile + + if (file != null && !file.isEmpty) { + fileLogic.write(file, Material.getSimdutFilePath(dto.name), updating) + } + } + + private fun throwIfNameAlreadyExists(name: String, id: Long? = null) { + if (service.existsByName(name, id)) { + throw alreadyExistsException(value = name) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/MaterialTypeLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/MaterialTypeLogic.kt new file mode 100644 index 0000000..49b202f --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/MaterialTypeLogic.kt @@ -0,0 +1,75 @@ +package dev.fyloz.colorrecipesexplorer.logic + +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent +import dev.fyloz.colorrecipesexplorer.dtos.MaterialTypeDto +import dev.fyloz.colorrecipesexplorer.exception.CannotUpdateException +import dev.fyloz.colorrecipesexplorer.service.MaterialTypeService + +interface MaterialTypeLogic : Logic { + /** Gets all material types which are or not [systemType]s. */ + fun getAll(systemType: Boolean): Collection + + /** Gets the material type with the given [name]. */ + fun getByName(name: String): MaterialTypeDto? + + /** Updates the given [dto], and throws if it is a system types. */ + fun updateNonSystemType(dto: MaterialTypeDto) +} + +@LogicComponent +class DefaultMaterialTypeLogic(service: MaterialTypeService) : + BaseLogic(service, Constants.ModelNames.MATERIAL_TYPE), MaterialTypeLogic { + override fun getAll(systemType: Boolean) = service.getAll(systemType) + override fun getByName(name: String) = service.getByName(name) + + override fun updateNonSystemType(dto: MaterialTypeDto) { + if (service.existsById(dto.id, true)) { + throw CannotUpdateException( + typeNameLowerCase, + "Cannot update $typeNameLowerCase", + "Cannot update material type '${dto.name}' because it is a system material type" + ) + } + + update(dto) + } + + override fun save(dto: MaterialTypeDto): MaterialTypeDto { + throwIfNameAlreadyExists(dto.name) + throwIfPrefixAlreadyExists(dto.prefix) + + return super.save(dto) + } + + override fun update(dto: MaterialTypeDto): MaterialTypeDto { + throwIfNameAlreadyExists(dto.name, dto.id) + throwIfPrefixAlreadyExists(dto.prefix, dto.id) + + return super.update(dto) + } + + override fun deleteById(id: Long) { + if (service.isUsedByMaterial(id)) { + throw cannotDeleteException("Cannot delete material type with the id '$id' because one or more materials depends on it") + } + + super.deleteById(id) + } + + private fun throwIfNameAlreadyExists(name: String, id: Long? = null) { + if (service.existsByName(name, id)) { + throw alreadyExistsException(value = name) + } + } + + private fun throwIfPrefixAlreadyExists(prefix: String, id: Long? = null) { + if (service.existsByPrefix(prefix, id)) { + throw alreadyExistsException(PREFIX_IDENTIFIER_NAME, prefix) + } + } + + companion object { + const val PREFIX_IDENTIFIER_NAME = "prefix" + } +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/MixLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/MixLogic.kt new file mode 100644 index 0000000..64da2e3 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/MixLogic.kt @@ -0,0 +1,73 @@ +package dev.fyloz.colorrecipesexplorer.logic + +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent +import dev.fyloz.colorrecipesexplorer.dtos.MixDto +import dev.fyloz.colorrecipesexplorer.dtos.MixLocationDto +import dev.fyloz.colorrecipesexplorer.dtos.MixSaveDto +import dev.fyloz.colorrecipesexplorer.service.MixService +import org.springframework.context.annotation.Lazy +import org.springframework.transaction.annotation.Transactional + +interface MixLogic : Logic { + /** Saves the given [dto]. */ + fun save(dto: MixSaveDto): MixDto + + /** Updates the given [dto]. */ + fun update(dto: MixSaveDto): MixDto + + /** Updates the location of each mix in the given [updatedLocations]. */ + fun updateLocations(updatedLocations: Collection) +} + +@LogicComponent +class DefaultMixLogic( + service: MixService, + @Lazy private val recipeLogic: RecipeLogic, + @Lazy private val materialTypeLogic: MaterialTypeLogic, + private val mixTypeLogic: MixTypeLogic, + private val mixQuantityLogic: MixQuantityLogic +) : BaseLogic(service, Constants.ModelNames.MIX), MixLogic { + @Transactional + override fun save(dto: MixSaveDto): MixDto { + val recipe = recipeLogic.getById(dto.recipeId) + val materialType = materialTypeLogic.getById(dto.materialTypeId) + + val mix = MixDto( + recipeId = recipe.id, + mixType = mixTypeLogic.getOrCreateForNameAndMaterialType(dto.name, materialType), + mixQuantities = mixQuantityLogic.validateAndPrepareForMix(dto.mixQuantities) + ) + + return save(mix) + } + + @Transactional + override fun update(dto: MixSaveDto): MixDto { + val materialType = materialTypeLogic.getById(dto.materialTypeId) + val mix = getById(dto.id) + + // Update the mix type if it has been changed + val mixType = if (mix.mixType.name != dto.name || mix.mixType.materialType.id != dto.materialTypeId) { + mixTypeLogic.updateOrCreateForNameAndMaterialType(mix.mixType, dto.name, materialType) + } else { + mix.mixType + } + + return update( + MixDto( + id = dto.id, + recipeId = mix.recipeId, + mixType = mixType, + mixQuantities = mixQuantityLogic.validateAndPrepareForMix(dto.mixQuantities) + ) + ) + } + + override fun updateLocations(updatedLocations: Collection) = + updatedLocations.forEach(::updateLocation) + + private fun updateLocation(updatedLocation: MixLocationDto) { + service.updateLocationById(updatedLocation.mixId, updatedLocation.location) + } +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/MixQuantityLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/MixQuantityLogic.kt new file mode 100644 index 0000000..a6ddae8 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/MixQuantityLogic.kt @@ -0,0 +1,95 @@ +package dev.fyloz.colorrecipesexplorer.logic + +import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent +import dev.fyloz.colorrecipesexplorer.dtos.* +import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionError +import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionsException +import dev.fyloz.colorrecipesexplorer.exception.RestException +import dev.fyloz.colorrecipesexplorer.utils.PositionUtils +import org.springframework.context.annotation.Lazy +import org.springframework.http.HttpStatus + +interface MixQuantityLogic { + /** + * Validates if the given [mixMaterials]. To be valid, the position of each mix material must be greater or equals to 1 and unique in the set. + * There must also be no gap between the positions. Also, the quantity of the first mix material in the set must not be expressed in percentages. + * If any of those criteria are not met, an [InvalidGroupStepsPositionsException] will be thrown. + */ + fun validateMixQuantities(mixMaterials: List) + + /** Validates the given mix quantities [dtos] and put them in [MixQuantitiesDto] to be consumed by a mix. */ + fun validateAndPrepareForMix(dtos: List): MixQuantitiesDto +} + +@LogicComponent +class DefaultMixQuantityLogic( + @Lazy private val materialLogic: MaterialLogic, + private val mixTypeLogic: MixTypeLogic +) : MixQuantityLogic { + override fun validateMixQuantities(mixMaterials: List) { + if (mixMaterials.isEmpty()) return + + val sortedMixMaterials = mixMaterials.sortedBy { it.position } + + try { + PositionUtils.validate(sortedMixMaterials.map { it.position }) + } catch (ex: InvalidPositionsException) { + throw InvalidMixMaterialsPositionsException(ex.errors) + } + + val firstMixMaterial = sortedMixMaterials[0] + if (firstMixMaterial is MixMaterialDto) { + if (firstMixMaterial.material.materialType.usePercentages) { + throw InvalidFirstMixMaterialException(sortedMixMaterials[0]) + } + } + } + + override fun validateAndPrepareForMix(dtos: List): MixQuantitiesDto { + val mixMixTypes = dtos.filter { it.isMixType }.map { + MixMixTypeDto( + id = it.id, + mixType = mixTypeLogic.getById(it.materialId), + quantity = it.quantity, + position = it.position + ) + } + + val mixMaterials = dtos.filter { !it.isMixType }.map { + MixMaterialDto( + id = it.id, + material = materialLogic.getById(it.materialId), + quantity = it.quantity, + position = it.position + ) + } + + validateMixQuantities(mixMixTypes + mixMaterials) + return MixQuantitiesDto(mixMaterials, mixMixTypes) + } +} + +// TODO check if required +class InvalidMixMaterialsPositionsException( + val errors: Set +) : RestException( + "invalid-mixmaterial-position", + "Invalid mix materials positions", + HttpStatus.BAD_REQUEST, + "The position of mix materials are invalid", + mapOf( + "invalidMixMaterials" to errors + ) +) + +class InvalidFirstMixMaterialException( + val mixMaterial: MixQuantityDto +) : RestException( + "invalid-mixmaterial-first", + "Invalid first mix material", + HttpStatus.BAD_REQUEST, + "The first mix material is invalid because its material must not be expressed in percents", + mapOf( + "mixMaterial" to mixMaterial + ) +) \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/MixTypeLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/MixTypeLogic.kt new file mode 100644 index 0000000..5d49677 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/MixTypeLogic.kt @@ -0,0 +1,59 @@ +package dev.fyloz.colorrecipesexplorer.logic + +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent +import dev.fyloz.colorrecipesexplorer.dtos.MaterialTypeDto +import dev.fyloz.colorrecipesexplorer.dtos.MixTypeDto +import dev.fyloz.colorrecipesexplorer.service.MixTypeService +import org.springframework.transaction.annotation.Transactional + +interface MixTypeLogic : Logic { + /** Returns a mix type for the given [name] and [materialType]. If this mix type does not already exist, it will be created. */ + fun getOrCreateForNameAndMaterialType(name: String, materialType: MaterialTypeDto): MixTypeDto + + /** Updates the [mixType] with the given [name] and [materialType], or create a new one if it is shared with other mixes. */ + fun updateOrCreateForNameAndMaterialType( + mixType: MixTypeDto, + name: String, + materialType: MaterialTypeDto + ): MixTypeDto +} + +@LogicComponent +class DefaultMixTypeLogic(service: MixTypeService) : BaseLogic(service, Constants.ModelNames.MIX_TYPE), MixTypeLogic { + @Transactional + override fun getOrCreateForNameAndMaterialType(name: String, materialType: MaterialTypeDto) = + service.getByNameAndMaterialType(name, materialType.id) ?: saveForNameAndMaterialType(name, materialType) + + override fun updateOrCreateForNameAndMaterialType( + mixType: MixTypeDto, + name: String, + materialType: MaterialTypeDto + ) = if (service.existsByNameAndMaterialType(name, materialType.id, mixType.id)) { + service.getByNameAndMaterialType(name, materialType.id)!! + } else if (service.isShared(mixType.id)) { + saveForNameAndMaterialType(name, materialType) + } else { + updateForNameAndMaterialType(mixType, name, materialType) + } + + override fun deleteById(id: Long) { + if (service.isUsedByMixes(id)) { + throw cannotDeleteException("Cannot delete the mix type with the id '$id' because one or more mixes depends on it") + } + + super.deleteById(id) + } + + private fun saveForNameAndMaterialType(name: String, materialType: MaterialTypeDto): MixTypeDto { + return save(MixTypeDto(name = name, materialType = materialType)) + } + + private fun updateForNameAndMaterialType( + mixType: MixTypeDto, + name: String, + materialType: MaterialTypeDto + ): MixTypeDto { + return update(mixType.copy(name = name, materialType = materialType, material = mixType.material)) + } +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/RecipeLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/RecipeLogic.kt new file mode 100644 index 0000000..2687249 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/RecipeLogic.kt @@ -0,0 +1,190 @@ +package dev.fyloz.colorrecipesexplorer.logic + +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent +import dev.fyloz.colorrecipesexplorer.dtos.* +import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException +import dev.fyloz.colorrecipesexplorer.logic.files.WriteableFileLogic +import dev.fyloz.colorrecipesexplorer.logic.users.GroupLogic +import dev.fyloz.colorrecipesexplorer.service.RecipeService +import dev.fyloz.colorrecipesexplorer.utils.collections.LazyMapList +import dev.fyloz.colorrecipesexplorer.utils.merge +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.multipart.MultipartFile + +interface RecipeLogic : Logic { + /** Gets all recipes and load their mixes and groupsInformation, to prevent LazyInitializationExceptions */ + fun getAllWithMixesAndGroupsInformation(): Collection + + /** Gets all recipes with the given [name]. */ + fun getAllByName(name: String): Collection + + /** Saves the given [dto]. */ + fun save(dto: RecipeSaveDto): RecipeDto + + /** Updates the given [dto]. */ + fun update(dto: RecipeUpdateDto): RecipeDto + + /** Updates the public data of a recipe with the given [publicDataDto]. */ + fun updatePublicData(publicDataDto: RecipePublicDataDto) +} + +@LogicComponent +class DefaultRecipeLogic( + service: RecipeService, + private val companyLogic: CompanyLogic, + private val recipeStepLogic: RecipeStepLogic, + private val mixLogic: MixLogic, + private val groupLogic: GroupLogic +) : BaseLogic(service, Constants.ModelNames.RECIPE), RecipeLogic { + @Transactional + override fun getAllWithMixesAndGroupsInformation() = + getAll().onEach { (it.mixes as LazyMapList<*, *>).initialize() } + .onEach { (it.groupsInformation as LazyMapList<*, *>).initialize() } + + override fun getAllByName(name: String) = service.getAllByName(name) + + override fun save(dto: RecipeSaveDto) = save( + RecipeDto( + name = dto.name, + description = dto.description, + color = dto.color, + gloss = dto.gloss, + sample = dto.sample, + approbationDate = dto.approbationDate, + approbationExpired = false, + remark = dto.remark ?: "", + company = companyLogic.getById(dto.companyId), + mixes = listOf(), + groupsInformation = listOf() + ) + ) + + override fun save(dto: RecipeDto): RecipeDto { + throwIfNameAndCompanyAlreadyExists(dto.name, dto.company.id) + + return super.save(dto) + } + + override fun update(dto: RecipeUpdateDto): RecipeDto { + val recipe = getById(dto.id) + + return update( + RecipeDto( + id = dto.id, + name = dto.name, + description = dto.description, + color = dto.color, + gloss = dto.gloss, + sample = dto.sample, + approbationDate = dto.approbationDate, + approbationExpired = false, + remark = dto.remark ?: "", + company = recipe.company, + mixes = recipe.mixes, + groupsInformation = updateGroupsInformationSteps(recipe, dto) + ) + ) + } + + override fun update(dto: RecipeDto): RecipeDto { + throwIfNameAndCompanyAlreadyExists(dto.name, dto.company.id, dto.id) + + return super.update(dto) + } + + @Transactional + override fun updatePublicData(publicDataDto: RecipePublicDataDto) { + // Update notes + if (publicDataDto.notes.isNotEmpty()) { + val recipe = getById(publicDataDto.recipeId) + update(recipe.copy(groupsInformation = updateGroupsInformationNotes(recipe, publicDataDto.notes))) + } + + // Update mixes locations + if (publicDataDto.mixesLocation.isNotEmpty()) { + mixLogic.updateLocations(publicDataDto.mixesLocation) + } + } + + private fun updateGroupsInformationSteps(recipe: RecipeDto, dto: RecipeUpdateDto): List { + val updatedGroupsInformation = dto.steps.map { updateGroupInformationSteps(recipe, it) } + return recipe.groupsInformation.merge(updatedGroupsInformation) + } + + private fun updateGroupInformationSteps(recipe: RecipeDto, groupSteps: RecipeGroupStepsDto) = + getOrCreateGroupInformation(recipe, groupSteps.groupId).copy(steps = groupSteps.steps).also { + recipeStepLogic.validateGroupInformationSteps(it) + } + + private fun updateGroupsInformationNotes( + recipe: RecipeDto, notes: List + ): List { + val updatedGroupsInformation = notes.map { updateGroupInformationNote(recipe, it) } + return recipe.groupsInformation.merge(updatedGroupsInformation) + } + + private fun updateGroupInformationNote(recipe: RecipeDto, groupNote: RecipeGroupNoteDto) = + getOrCreateGroupInformation(recipe, groupNote.groupId).copy(note = groupNote.content) + + private fun getOrCreateGroupInformation(recipe: RecipeDto, groupId: Long) = + recipe.groupsInformation.firstOrNull { it.group.id == groupId } + ?: RecipeGroupInformationDto(group = groupLogic.getById(groupId)) + + private fun throwIfNameAndCompanyAlreadyExists(name: String, companyId: Long, id: Long? = null) { + if (service.existsByNameAndCompany(name, companyId, id)) { + throw AlreadyExistsException( + "$typeNameLowerCase-company", + "$typeName already exists", + "A recipe with the name '$name' already exists for the company with the id '$companyId'", + name, + NAME_IDENTIFIER_NAME, + mutableMapOf( + "companyId" to companyId + ) + ) + } + } +} + +interface RecipeImageLogic { + /** Gets the id of every image associated to the recipe with the given [recipeId]. */ + fun getAllImages(recipeId: Long): List + + /** Saves the given [image] and associate it to the recipe with the given [recipeId]. Returns the id of the saved image. */ + fun download(image: MultipartFile, recipeId: Long): String + + /** Deletes the image with the given [id] for the given [recipeId]. */ + fun delete(recipeId: Long, id: String) +} + +@LogicComponent +class DefaultRecipeImageLogic(val fileLogic: WriteableFileLogic) : RecipeImageLogic { + override fun getAllImages(recipeId: Long) = + fileLogic.listDirectoryFiles(getRecipeImagesDirectory(recipeId)).map { it.name } + + override fun download(image: MultipartFile, recipeId: Long): String { + /** Gets the next id available for a new image for the given [recipeId]. */ + fun getNextAvailableId(): String = with(getAllImages(recipeId)) { + val currentIds = mapNotNull { it.toLongOrNull() } + if (currentIds.isEmpty()) { + return 0.toString() + } + + val nextId = currentIds.maxOf { it } + 1L + return nextId.toString() + } + + return getNextAvailableId().also { + val imagePath = getImagePath(recipeId, it) + fileLogic.writeToDirectory(image, imagePath, getRecipeImagesDirectory(recipeId), true) + } + } + + override fun delete(recipeId: Long, id: String) = + fileLogic.deleteFromDirectory(getImagePath(recipeId, id), getRecipeImagesDirectory(recipeId)) + + private fun getImagePath(recipeId: Long, id: String) = "${getRecipeImagesDirectory(recipeId)}/$id" + + private fun getRecipeImagesDirectory(recipeId: Long) = "${Constants.FilePaths.RECIPE_IMAGES}/$recipeId" +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/RecipeStepLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/RecipeStepLogic.kt new file mode 100644 index 0000000..4aaf79f --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/RecipeStepLogic.kt @@ -0,0 +1,49 @@ +package dev.fyloz.colorrecipesexplorer.logic + +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent +import dev.fyloz.colorrecipesexplorer.dtos.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.RecipeGroupInformationDto +import dev.fyloz.colorrecipesexplorer.dtos.RecipeStepDto +import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionError +import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionsException +import dev.fyloz.colorrecipesexplorer.exception.RestException +import dev.fyloz.colorrecipesexplorer.model.account.Group +import dev.fyloz.colorrecipesexplorer.service.RecipeStepService +import dev.fyloz.colorrecipesexplorer.utils.PositionUtils +import org.springframework.http.HttpStatus + +interface RecipeStepLogic : Logic { + /** Validates the steps of the given [groupInformation], according to the criteria of [PositionUtils.validate]. */ + fun validateGroupInformationSteps(groupInformation: RecipeGroupInformationDto) +} + +@LogicComponent +class DefaultRecipeStepLogic(recipeStepService: RecipeStepService) : + BaseLogic(recipeStepService, Constants.ModelNames.RECIPE_STEP), RecipeStepLogic { + override fun validateGroupInformationSteps(groupInformation: RecipeGroupInformationDto) { + try { + PositionUtils.validate(groupInformation.steps.map { it.position }.toList()) + } catch (ex: InvalidPositionsException) { + throw InvalidGroupStepsPositionsException(groupInformation.group, ex) + } + } +} + +class InvalidGroupStepsPositionsException( + val group: GroupDto, + val exception: InvalidPositionsException +) : RestException( + "invalid-groupinformation-recipestep-position", + "Invalid steps positions", + HttpStatus.BAD_REQUEST, + "The position of steps for the group ${group.name} are invalid", + mapOf( + "group" to group.name, + "groupId" to group.id, + "invalidSteps" to exception.errors + ) +) { + val errors: Set + get() = exception.errors +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/TouchUpKitLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/TouchUpKitLogic.kt new file mode 100644 index 0000000..2b4fed7 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/TouchUpKitLogic.kt @@ -0,0 +1,95 @@ +package dev.fyloz.colorrecipesexplorer.logic + +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent +import dev.fyloz.colorrecipesexplorer.dtos.TouchUpKitDto +import dev.fyloz.colorrecipesexplorer.logic.config.ConfigurationLogic +import dev.fyloz.colorrecipesexplorer.logic.files.WriteableFileLogic +import dev.fyloz.colorrecipesexplorer.model.ConfigurationType +import dev.fyloz.colorrecipesexplorer.service.TouchUpKitService +import dev.fyloz.colorrecipesexplorer.utils.* +import org.springframework.core.io.ByteArrayResource +import org.springframework.core.io.Resource +import java.time.LocalDate + +interface TouchUpKitLogic : Logic { + /** Sets the touch up kit with the given [id] as complete. */ + fun complete(id: Long) + + /** + * Generates and returns a [PdfDocument] for the given [job] as a [ByteArrayResource]. + * + * If TOUCH_UP_KIT_CACHE_PDF is enabled and a file exists for the job, its content will be returned. + * If caching is enabled but no file exists for the job, the generated ByteArrayResource will be cached on the disk. + */ + fun generateJobPdfResource(job: String): Resource + + /** Generates and returns a [PdfDocument] for the given [job]. */ + fun generateJobPdf(job: String): PdfDocument + + /** Writes the given [pdf] to the disk if TOUCH_UP_KIT_CACHE_PDF is enabled. */ + fun cacheJobPdf(job: String, pdf: PdfDocument) +} + +@LogicComponent +class DefaultTouchUpKitLogic( + service: TouchUpKitService, + private val fileLogic: WriteableFileLogic, + private val configLogic: ConfigurationLogic +) : BaseLogic(service, Constants.ModelNames.TOUCH_UP_KIT), TouchUpKitLogic { + private val cacheGeneratedFiles by lazy { + configLogic.getContent(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF) == true.toString() + } + + override fun complete(id: Long) = service.updateCompletionDateById(id, LocalDate.now()) + + override fun generateJobPdfResource(job: String): Resource { + if (cacheGeneratedFiles) { + val pdfPath = jobPdfPath(job) + if (fileLogic.exists(pdfPath)) { + return fileLogic.read(pdfPath) + } + } + + val pdf = generateJobPdf(job) + cacheJobPdf(job, pdf) + + return pdf.toByteArrayResource() + } + + override fun generateJobPdf(job: String) = pdf { + container { + centeredVertically = true + drawContainerBottom = true + text(TOUCH_UP_TEXT_FR) { + bold = true + fontSize = PDF_DEFAULT_FONT_SIZE + 12 + } + text(TOUCH_UP_TEXT_EN) { + bold = true + fontSize = PDF_DEFAULT_FONT_SIZE + 12 + } + text(job) { + marginTop = 10f + } + } + + container(containers[0]) { + drawContainerBottom = false + } + } + + override fun cacheJobPdf(job: String, pdf: PdfDocument) { + if (!cacheGeneratedFiles) return + + fileLogic.write(pdf.toByteArrayResource(), jobPdfPath(job), true) + } + + private fun jobPdfPath(job: String) = + "${Constants.FilePaths.TOUCH_UP_KITS}/$job.pdf" + + companion object { + const val TOUCH_UP_TEXT_FR = "KIT DE RETOUCHE" + const val TOUCH_UP_TEXT_EN = "TOUCH UP KIT" + } +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/config/ConfigurationService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/config/ConfigurationLogic.kt similarity index 94% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/config/ConfigurationService.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/config/ConfigurationLogic.kt index f6db17e..14d3de2 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/config/ConfigurationService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/config/ConfigurationLogic.kt @@ -1,19 +1,19 @@ -package dev.fyloz.colorrecipesexplorer.service.config +package dev.fyloz.colorrecipesexplorer.logic.config import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties +import dev.fyloz.colorrecipesexplorer.logic.files.ResourceFileLogic +import dev.fyloz.colorrecipesexplorer.logic.files.WriteableFileLogic import dev.fyloz.colorrecipesexplorer.model.* -import dev.fyloz.colorrecipesexplorer.service.files.ResourceFileService -import dev.fyloz.colorrecipesexplorer.service.files.WriteableFileService import dev.fyloz.colorrecipesexplorer.utils.decrypt import dev.fyloz.colorrecipesexplorer.utils.encrypt -import org.slf4j.Logger +import mu.KotlinLogging import org.springframework.context.annotation.Lazy import org.springframework.core.io.Resource import org.springframework.security.crypto.keygen.KeyGenerators import org.springframework.stereotype.Service import org.springframework.web.multipart.MultipartFile -interface ConfigurationService { +interface ConfigurationLogic { /** Gets all set configurations. */ fun getAll(): List @@ -73,13 +73,13 @@ const val CONFIGURATION_ICON_FILE_PATH = "images/icon" const val CONFIGURATION_FORMATTED_LIST_DELIMITER = ';' @Service("configurationService") -class ConfigurationServiceImpl( - @Lazy private val fileService: WriteableFileService, - private val resourceFileService: ResourceFileService, +class DefaultConfigurationLogic( + @Lazy private val fileService: WriteableFileLogic, + private val resourceFileService: ResourceFileLogic, private val configurationSource: ConfigurationSource, - private val securityProperties: CreSecurityProperties, - private val logger: Logger -) : ConfigurationService { + private val securityProperties: CreSecurityProperties +) : ConfigurationLogic { + private val logger = KotlinLogging.logger { } private val saltConfigurationType = ConfigurationType.GENERATED_ENCRYPTION_SALT private val encryptionSalt by lazy { securityProperties.configSalt ?: getGeneratedSalt() diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/config/ConfigurationSource.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/config/ConfigurationSource.kt similarity index 95% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/config/ConfigurationSource.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/config/ConfigurationSource.kt index 6971f9e..0fbe70a 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/config/ConfigurationSource.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/config/ConfigurationSource.kt @@ -1,5 +1,6 @@ -package dev.fyloz.colorrecipesexplorer.service.config +package dev.fyloz.colorrecipesexplorer.logic.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 mu.KotlinLogging 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 @@ -36,9 +36,9 @@ interface ConfigurationSource { class CompositeConfigurationSource( @Lazy private val configurationRepository: ConfigurationRepository, private val properties: CreProperties, - private val buildInfo: BuildProperties, - private val logger: Logger + private val buildInfo: BuildProperties ) : ConfigurationSource { + private val logger = KotlinLogging.logger {} private val repository by lazy { RepositoryConfigurationSource(configurationRepository) } private val file by lazy { FileConfigurationSource("${properties.configDirectory}/$CONFIGURATION_FILE_PATH") @@ -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) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/files/FileCache.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/files/FileCache.kt new file mode 100644 index 0000000..c666abe --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/files/FileCache.kt @@ -0,0 +1,184 @@ +package dev.fyloz.colorrecipesexplorer.logic.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) : 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 = setOf() +) : CachedFileSystemItem { + constructor(file: File) : this(file.name, file.toFilePath(), file.exists() && file.isDirectory, file.fetchContent()) + + val contentFiles: Collection + get() = content.filterIsInstance() + + override fun clone(exists: Boolean) = + this.copy(exists = exists) + + companion object { + private fun File.fetchContent() = + (this.file.listFiles() ?: arrayOf()) + .filterNotNull() + .map { it.toFileSystemItem() } + .toSet() + } +} + +fun JavaFile.toFileSystemItem() = + if (this.isDirectory) { + CachedDirectory(File(this)) + } else { + CachedFile(File(this)) + } \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/files/FileLogic.kt similarity index 62% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileService.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/files/FileLogic.kt index 4136ebe..060d651 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/files/FileLogic.kt @@ -1,16 +1,18 @@ -package dev.fyloz.colorrecipesexplorer.service.files +package dev.fyloz.colorrecipesexplorer.logic.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( @@ -19,18 +21,21 @@ val BANNED_FILE_PATH_SHARDS = setOf( "//" ) -interface FileService { +interface FileLogic { /** Checks if the file at the given [path] exists. */ fun exists(path: String): Boolean /** 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 + /** 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 WriteableFileLogic : FileLogic { /** Creates a file at the given [path]. */ fun create(path: String) @@ -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 -) : WriteableFileService { - override fun exists(path: String) = withFileAt(path.fullPath()) { - this.exists() && this.isFile +class DefaultFileLogic( + private val fileCache: FileCache, + private val creProperties: CreProperties +) : WriteableFileLogic { + 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 = + 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 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" diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/files/ResourceFileLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/files/ResourceFileLogic.kt new file mode 100644 index 0000000..765070b --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/files/ResourceFileLogic.kt @@ -0,0 +1,36 @@ +package dev.fyloz.colorrecipesexplorer.logic.files + +import dev.fyloz.colorrecipesexplorer.utils.FilePath +import org.springframework.core.io.Resource +import org.springframework.core.io.ResourceLoader +import org.springframework.stereotype.Service + +@Service +class ResourceFileLogic( + private val resourceLoader: ResourceLoader +) : FileLogic { + override fun exists(path: String) = + fullPath(path).resource.exists() + + override fun read(path: String): Resource = + fullPath(path).resource.also { + if (!it.exists()) { + throw FileNotFoundException(path) + } + } + + override fun listDirectoryFiles(path: String): Collection { + 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.value) +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/jobs/TouchUpKitRemover.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/jobs/TouchUpKitRemover.kt similarity index 71% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/jobs/TouchUpKitRemover.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/jobs/TouchUpKitRemover.kt index 5cde5bf..df75b16 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/jobs/TouchUpKitRemover.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/jobs/TouchUpKitRemover.kt @@ -1,6 +1,6 @@ -package dev.fyloz.colorrecipesexplorer.service.jobs +package dev.fyloz.colorrecipesexplorer.logic.jobs -import dev.fyloz.colorrecipesexplorer.service.TouchUpKitService +import dev.fyloz.colorrecipesexplorer.logic.TouchUpKitLogic import mu.KotlinLogging import org.springframework.context.annotation.Profile import org.springframework.scheduling.annotation.Scheduled @@ -9,7 +9,7 @@ import org.springframework.stereotype.Component @Component @Profile("!emergency") class TouchUpKitRemover( - private val touchUpKitService: TouchUpKitService + private val touchUpKitLogic: TouchUpKitLogic ) { private val logger = KotlinLogging.logger {} @@ -20,10 +20,10 @@ class TouchUpKitRemover( } private fun removeExpiredKits() { - with(touchUpKitService.getAll().filter(touchUpKitService::isExpired)) { + with(touchUpKitLogic.getAll().filter { it.expired }) { this.forEach { logger.debug("Removed expired touch up kit ${it.id} (${it.project} ${it.buggy})") - touchUpKitService.delete(it) + touchUpKitLogic.deleteById(it.id) } logger.info("Removed ${this.size} expired touch up kits") } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/GroupLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/GroupLogic.kt new file mode 100644 index 0000000..b421223 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/GroupLogic.kt @@ -0,0 +1,80 @@ +package dev.fyloz.colorrecipesexplorer.logic.users + +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent +import dev.fyloz.colorrecipesexplorer.config.security.defaultGroupCookieName +import dev.fyloz.colorrecipesexplorer.dtos.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.UserDto +import dev.fyloz.colorrecipesexplorer.exception.NoDefaultGroupException +import dev.fyloz.colorrecipesexplorer.logic.BaseLogic +import dev.fyloz.colorrecipesexplorer.logic.Logic +import dev.fyloz.colorrecipesexplorer.service.GroupService +import org.springframework.transaction.annotation.Transactional +import org.springframework.web.util.WebUtils +import javax.servlet.http.HttpServletRequest +import javax.servlet.http.HttpServletResponse + +const val defaultGroupCookieMaxAge = 10 * 365 * 24 * 60 * 60 // 10 ans + +interface GroupLogic : Logic { + /** Gets all the users of the group with the given [id]. */ + fun getUsersForGroup(id: Long): Collection + + /** Gets the default group from a cookie in the given HTTP [request]. */ + fun getRequestDefaultGroup(request: HttpServletRequest): GroupDto + + /** Sets the default group cookie for the given HTTP [response]. */ + fun setResponseDefaultGroup(id: Long, response: HttpServletResponse) +} + +@LogicComponent +class DefaultGroupLogic(service: GroupService, private val userLogic: UserLogic) : + BaseLogic(service, Constants.ModelNames.GROUP), + GroupLogic { + override fun getUsersForGroup(id: Long) = userLogic.getAllByGroup(getById(id)) + + override fun getRequestDefaultGroup(request: HttpServletRequest): GroupDto { + val defaultGroupCookie = WebUtils.getCookie(request, defaultGroupCookieName) + ?: throw NoDefaultGroupException() + val defaultGroupUser = userLogic.getById( + defaultGroupCookie.value.toLong(), + isSystemUser = false, + isDefaultGroupUser = true + ) + return defaultGroupUser.group!! + } + + override fun setResponseDefaultGroup(id: Long, response: HttpServletResponse) { + val defaultGroupUser = userLogic.getDefaultGroupUser(getById(id)) + response.addHeader( + "Set-Cookie", + "$defaultGroupCookieName=${defaultGroupUser.id}; Max-Age=$defaultGroupCookieMaxAge; Path=/api; HttpOnly; Secure; SameSite=strict" + ) + } + + @Transactional + override fun save(dto: GroupDto): GroupDto { + throwIfNameAlreadyExists(dto.name) + + return super.save(dto).also { + userLogic.saveDefaultGroupUser(it) + } + } + + override fun update(dto: GroupDto): GroupDto { + throwIfNameAlreadyExists(dto.name, dto.id) + + return super.update(dto) + } + + override fun deleteById(id: Long) { + userLogic.deleteById(GroupDto.getDefaultGroupUserId(id)) + super.deleteById(id) + } + + private fun throwIfNameAlreadyExists(name: String, id: Long? = null) { + if (service.existsByName(name, id)) { + throw alreadyExistsException(value = name) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/users/JwtService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/JwtLogic.kt similarity index 75% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/users/JwtService.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/JwtLogic.kt index 282d6ba..47469bf 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/users/JwtService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/JwtLogic.kt @@ -1,12 +1,10 @@ -package dev.fyloz.colorrecipesexplorer.service.users +package dev.fyloz.colorrecipesexplorer.logic.users import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties -import dev.fyloz.colorrecipesexplorer.model.account.User -import dev.fyloz.colorrecipesexplorer.model.account.UserDetails -import dev.fyloz.colorrecipesexplorer.model.account.UserOutputDto -import dev.fyloz.colorrecipesexplorer.model.account.toOutputDto +import dev.fyloz.colorrecipesexplorer.dtos.UserDetails +import dev.fyloz.colorrecipesexplorer.dtos.UserDto import dev.fyloz.colorrecipesexplorer.utils.base64encode import dev.fyloz.colorrecipesexplorer.utils.toDate import io.jsonwebtoken.Jwts @@ -18,22 +16,22 @@ import java.util.* const val jwtClaimUser = "user" -interface JwtService { +interface JwtLogic { /** Build a JWT token for the given [userDetails]. */ fun buildJwt(userDetails: UserDetails): String /** Build a JWT token for the given [user]. */ - fun buildJwt(user: User): String + fun buildJwt(user: UserDto): String /** Parses a user from the given [jwt] token. */ - fun parseJwt(jwt: String): UserOutputDto + fun parseJwt(jwt: String): UserDto } @Service -class JwtServiceImpl( +class DefaultJwtLogic( val objectMapper: ObjectMapper, val securityProperties: CreSecurityProperties -) : JwtService { +) : JwtLogic { private val secretKey by lazy { securityProperties.jwtSecret.base64encode() } @@ -54,14 +52,14 @@ class JwtServiceImpl( override fun buildJwt(userDetails: UserDetails) = buildJwt(userDetails.user) - override fun buildJwt(user: User): String = + override fun buildJwt(user: UserDto): String = jwtBuilder .setSubject(user.id.toString()) .setExpiration(getCurrentExpirationDate()) .claim(jwtClaimUser, user.serialize()) .compact() - override fun parseJwt(jwt: String): UserOutputDto = + override fun parseJwt(jwt: String): UserDto = with( jwtParser.parseClaimsJws(jwt) .body.get(jwtClaimUser, String::class.java) @@ -74,6 +72,6 @@ class JwtServiceImpl( .plusSeconds(securityProperties.jwtDuration) .toDate() - private fun User.serialize(): String = - objectMapper.writeValueAsString(this.toOutputDto()) + private fun UserDto.serialize(): String = + objectMapper.writeValueAsString(this) } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/users/UserDetailsService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/UserDetailsLogic.kt similarity index 55% rename from src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/users/UserDetailsService.kt rename to src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/UserDetailsLogic.kt index 923abed..25b8369 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/users/UserDetailsService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/UserDetailsLogic.kt @@ -1,40 +1,41 @@ -package dev.fyloz.colorrecipesexplorer.service.users +package dev.fyloz.colorrecipesexplorer.logic.users import dev.fyloz.colorrecipesexplorer.SpringUserDetails import dev.fyloz.colorrecipesexplorer.SpringUserDetailsService +import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties +import dev.fyloz.colorrecipesexplorer.dtos.UserDetails +import dev.fyloz.colorrecipesexplorer.dtos.UserDto import dev.fyloz.colorrecipesexplorer.exception.NotFoundException import dev.fyloz.colorrecipesexplorer.model.account.Permission import dev.fyloz.colorrecipesexplorer.model.account.User -import dev.fyloz.colorrecipesexplorer.model.account.UserDetails -import dev.fyloz.colorrecipesexplorer.model.account.user import org.springframework.context.annotation.Profile import org.springframework.security.core.userdetails.UsernameNotFoundException import org.springframework.stereotype.Service -interface UserDetailsService : SpringUserDetailsService { +interface UserDetailsLogic : SpringUserDetailsService { /** Loads an [User] for the given [id]. */ - fun loadUserById(id: Long, ignoreDefaultGroupUsers: Boolean = false): UserDetails + fun loadUserById(id: Long, isDefaultGroupUser: Boolean = true): UserDetails } @Service -@Profile("!emergency") -class UserDetailsServiceImpl( - private val userService: UserService -) : UserDetailsService { +@RequireDatabase +class DefaultUserDetailsLogic( + private val userLogic: UserLogic +) : UserDetailsLogic { override fun loadUserByUsername(username: String): UserDetails { try { - return loadUserById(username.toLong(), true) + return loadUserById(username.toLong(), false) } catch (ex: NotFoundException) { throw UsernameNotFoundException(username) } } - override fun loadUserById(id: Long, ignoreDefaultGroupUsers: Boolean): UserDetails { - val user = userService.getById( + override fun loadUserById(id: Long, isDefaultGroupUser: Boolean): UserDetails { + val user = userLogic.getById( id, - ignoreDefaultGroupUsers = ignoreDefaultGroupUsers, - ignoreSystemUsers = false + isSystemUser = true, + isDefaultGroupUser = isDefaultGroupUser ) return UserDetails(user) } @@ -42,10 +43,10 @@ class UserDetailsServiceImpl( @Service @Profile("emergency") -class EmergencyUserDetailsServiceImpl( +class EmergencyUserDetailsLogic( securityProperties: CreSecurityProperties -) : UserDetailsService { - private val users: Set +) : UserDetailsLogic { + private val users: Set init { if (securityProperties.root == null) { @@ -55,20 +56,23 @@ class EmergencyUserDetailsServiceImpl( users = setOf( // Add root user with(securityProperties.root!!) { - user( + UserDto( id = this.id, - plainPassword = this.password, - permissions = mutableSetOf(Permission.ADMIN) + firstName = "Root", + lastName = "User", + group = null, + password = this.password, + permissions = listOf(Permission.ADMIN) ) } ) } override fun loadUserByUsername(username: String): SpringUserDetails { - return loadUserById(username.toLong(), true) + return loadUserById(username.toLong(), false) } - override fun loadUserById(id: Long, ignoreDefaultGroupUsers: Boolean): UserDetails { + override fun loadUserById(id: Long, isDefaultGroupUser: Boolean): UserDetails { val user = users.firstOrNull { it.id == id } ?: throw UsernameNotFoundException(id.toString()) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/UserLogic.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/UserLogic.kt new file mode 100644 index 0000000..bd17f04 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/logic/users/UserLogic.kt @@ -0,0 +1,169 @@ +package dev.fyloz.colorrecipesexplorer.logic.users + +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent +import dev.fyloz.colorrecipesexplorer.config.security.authorizationCookieName +import dev.fyloz.colorrecipesexplorer.config.security.blacklistedJwtTokens +import dev.fyloz.colorrecipesexplorer.dtos.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.UserDto +import dev.fyloz.colorrecipesexplorer.dtos.UserSaveDto +import dev.fyloz.colorrecipesexplorer.dtos.UserUpdateDto +import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException +import dev.fyloz.colorrecipesexplorer.logic.BaseLogic +import dev.fyloz.colorrecipesexplorer.logic.Logic +import dev.fyloz.colorrecipesexplorer.model.account.Permission +import dev.fyloz.colorrecipesexplorer.service.UserService +import org.springframework.context.annotation.Lazy +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder +import org.springframework.security.crypto.password.PasswordEncoder +import org.springframework.web.util.WebUtils +import java.time.LocalDateTime +import javax.servlet.http.HttpServletRequest + +interface UserLogic : Logic { + /** Gets all users which have the given [group]. */ + fun getAllByGroup(group: GroupDto): Collection + + /** Gets the user with the given [id]. */ + fun getById(id: Long, isSystemUser: Boolean, isDefaultGroupUser: Boolean): UserDto + + /** Gets the default user of the given [group]. */ + fun getDefaultGroupUser(group: GroupDto): UserDto + + /** Save a default group user for the given [group]. */ + fun saveDefaultGroupUser(group: GroupDto) + + /** Saves the given [dto]. */ + fun save(dto: UserSaveDto): UserDto + + /** Updates the given [dto]. */ + fun update(dto: UserUpdateDto): UserDto + + /** Updates the last login time of the user with the given [id]. */ + fun updateLastLoginTime(id: Long, time: LocalDateTime = LocalDateTime.now()): UserDto + + /** Updates the password of the user with the given [id]. */ + fun updatePassword(id: Long, password: String): UserDto + + /** Adds the given [permission] to the user with the given [id]. */ + fun addPermission(id: Long, permission: Permission): UserDto + + /** Removes the given [permission] from the user with the given [id]. */ + fun removePermission(id: Long, permission: Permission): UserDto + + /** Logout a user. Add the authorization token of the given [request] to the blacklisted tokens. */ + fun logout(request: HttpServletRequest) +} + +@LogicComponent +class DefaultUserLogic( + service: UserService, @Lazy private val groupLogic: GroupLogic, @Lazy private val passwordEncoder: PasswordEncoder +) : BaseLogic(service, Constants.ModelNames.USER), UserLogic { + override fun getAll() = service.getAll(isSystemUser = false, isDefaultGroupUser = false) + + override fun getAllByGroup(group: GroupDto) = service.getAllByGroup(group) + + override fun getById(id: Long) = getById(id, isSystemUser = false, isDefaultGroupUser = false) + override fun getById(id: Long, isSystemUser: Boolean, isDefaultGroupUser: Boolean) = + service.getById(id, !isDefaultGroupUser, !isSystemUser) ?: throw notFoundException(value = id) + + override fun getDefaultGroupUser(group: GroupDto) = + service.getDefaultGroupUser(group) ?: throw notFoundException(identifierName = "groupId", value = group.id) + + override fun saveDefaultGroupUser(group: GroupDto) { + save( + UserSaveDto( + id = group.defaultGroupUserId, + firstName = group.name, + lastName = "User", + password = group.name, + groupId = group.id, + permissions = listOf(), + isDefaultGroupUser = true + ) + ) + } + + override fun save(dto: UserSaveDto) = save( + UserDto( + id = dto.id, + firstName = dto.firstName, + lastName = dto.lastName, + password = passwordEncoder.encode(dto.password), + group = if (dto.groupId != null) groupLogic.getById(dto.groupId) else null, + permissions = dto.permissions, + isSystemUser = dto.isSystemUser, + isDefaultGroupUser = dto.isDefaultGroupUser + ) + ) + + override fun save(dto: UserDto): UserDto { + throwIfIdAlreadyExists(dto.id) + throwIfFirstNameAndLastNameAlreadyExists(dto.firstName, dto.lastName) + + return super.save(dto) + } + + override fun update(dto: UserUpdateDto): UserDto { + val user = getById(dto.id, isSystemUser = false, isDefaultGroupUser = false) + + return update( + user.copy( + firstName = dto.firstName, + lastName = dto.lastName, + group = if (dto.groupId != null) groupLogic.getById(dto.groupId) else null, + permissions = dto.permissions + ) + ) + } + + override fun update(dto: UserDto): UserDto { + throwIfFirstNameAndLastNameAlreadyExists(dto.firstName, dto.lastName, dto.id) + + return super.update(dto) + } + + override fun updateLastLoginTime(id: Long, time: LocalDateTime) = with(getById(id)) { + update(this.copy(lastLoginTime = time)) + } + + override fun updatePassword(id: Long, password: String) = with(getById(id)) { + update(this.copy(password = passwordEncoder.encode(password))) + } + + override fun addPermission(id: Long, permission: Permission) = with(getById(id)) { + update(this.copy(permissions = this.permissions + permission)) + } + + override fun removePermission(id: Long, permission: Permission) = with(getById(id)) { + update(this.copy(permissions = this.permissions - permission)) + } + + override fun logout(request: HttpServletRequest) { + val authorizationCookie = WebUtils.getCookie(request, authorizationCookieName) + if (authorizationCookie != null) { + val authorizationToken = authorizationCookie.value + if (authorizationToken != null && authorizationToken.startsWith("Bearer")) { + blacklistedJwtTokens.add(authorizationToken) + } + } + } + + private fun throwIfIdAlreadyExists(id: Long) { + if (service.existsById(id)) { + throw alreadyExistsException(identifierName = ID_IDENTIFIER_NAME, value = id) + } + } + + private fun throwIfFirstNameAndLastNameAlreadyExists(firstName: String, lastName: String, id: Long? = null) { + if (service.existsByFirstNameAndLastName(firstName, lastName, id)) { + throw AlreadyExistsException( + typeNameLowerCase, + "$typeName already exists", + "A $typeNameLowerCase with the name '$firstName $lastName' already exists", + "$firstName $lastName", + "fullName" + ) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Company.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Company.kt index d2bcba7..e05a6ef 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Company.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Company.kt @@ -1,107 +1,14 @@ package dev.fyloz.colorrecipesexplorer.model -import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException -import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException -import dev.fyloz.colorrecipesexplorer.exception.NotFoundException -import dev.fyloz.colorrecipesexplorer.model.validation.NullOrNotBlank import javax.persistence.* -import javax.validation.constraints.NotBlank -import javax.validation.constraints.NotNull @Entity @Table(name = "company") data class Company( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - override val id: Long?, + override val id: Long, @Column(unique = true) - override val name: String -) : NamedModel { - override fun toString(): String { - return name - } -} - - -open class CompanySaveDto( - @field:NotBlank val name: String -) : EntityDto { - override fun toEntity(): Company = Company(null, name) -} - - -open class CompanyUpdateDto( - val id: Long, - - @field:NotBlank - val name: String? -) : EntityDto { - override fun toEntity(): Company = Company(id, name ?: "") -} - -// ==== DSL ==== -fun company( - id: Long? = null, - name: String = "name", - op: Company.() -> Unit = {} -) = Company(id, name).apply(op) - -fun companySaveDto( - name: String = "name", - op: CompanySaveDto.() -> Unit = {} -) = CompanySaveDto(name).apply(op) - -fun companyUpdateDto( - id: Long = 0L, - name: String? = "name", - op: CompanyUpdateDto.() -> Unit = {} -) = CompanyUpdateDto(id, name).apply(op) - -// ==== Exceptions ==== -private const val COMPANY_NOT_FOUND_EXCEPTION_TITLE = "Company not found" -private const val COMPANY_ALREADY_EXISTS_EXCEPTION_TITLE = "Company already exists" -private const val COMPANY_CANNOT_DELETE_EXCEPTION_TITLE = "Cannot delete company" -private const val COMPANY_EXCEPTION_ERROR_CODE = "company" - -fun companyIdNotFoundException(id: Long) = - NotFoundException( - COMPANY_EXCEPTION_ERROR_CODE, - COMPANY_NOT_FOUND_EXCEPTION_TITLE, - "A company with the id $id could not be found", - id - ) - -fun companyNameNotFoundException(name: String) = - NotFoundException( - COMPANY_EXCEPTION_ERROR_CODE, - COMPANY_NOT_FOUND_EXCEPTION_TITLE, - "A company with the name $name could not be found", - name, - "name" - ) - -fun companyIdAlreadyExistsException(id: Long) = - AlreadyExistsException( - COMPANY_EXCEPTION_ERROR_CODE, - COMPANY_ALREADY_EXISTS_EXCEPTION_TITLE, - "A company with the id $id already exists", - id - ) - -fun companyNameAlreadyExistsException(name: String) = - AlreadyExistsException( - COMPANY_EXCEPTION_ERROR_CODE, - COMPANY_ALREADY_EXISTS_EXCEPTION_TITLE, - "A company with the name $name already exists", - name, - "name" - ) - -fun cannotDeleteCompany(company: Company) = - CannotDeleteException( - COMPANY_EXCEPTION_ERROR_CODE, - COMPANY_CANNOT_DELETE_EXCEPTION_TITLE, - "Cannot delete the company ${company.name} because one or more recipes depends on it" - ) +) : ModelEntity \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Material.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Material.kt index 76f505a..05e04a6 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Material.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Material.kt @@ -1,172 +1,30 @@ package dev.fyloz.colorrecipesexplorer.model -import com.fasterxml.jackson.annotation.JsonIgnore -import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException -import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException -import dev.fyloz.colorrecipesexplorer.exception.NotFoundException -import org.springframework.web.multipart.MultipartFile +import dev.fyloz.colorrecipesexplorer.Constants import javax.persistence.* -import javax.validation.constraints.Min -import javax.validation.constraints.NotBlank -import javax.validation.constraints.Size - -const val SIMDUT_FILES_PATH = "pdf/simdut" @Entity @Table(name = "material") data class Material( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - override val id: Long?, + override val id: Long, @Column(unique = true) - override var name: String, + val name: String, @Column(name = "inventory_quantity") - var inventoryQuantity: Float, + val inventoryQuantity: Float, @Column(name = "mix_type") val isMixType: Boolean, @ManyToOne @JoinColumn(name = "material_type_id") - var materialType: MaterialType? -) : NamedModel { - val simdutFilePath - @JsonIgnore - @Transient - get() = "$SIMDUT_FILES_PATH/$name.pdf" -} - -open class MaterialSaveDto( - @field:NotBlank - val name: String, - - @field:Min(0, message = VALIDATION_SIZE_GE_ZERO) - val inventoryQuantity: Float, - - val materialTypeId: Long, - - val simdutFile: MultipartFile? = null -) : EntityDto - -open class MaterialUpdateDto( - val id: Long, - - @field:NotBlank - val name: String?, - - @field:Min(0, message = VALIDATION_SIZE_GE_ZERO) - val inventoryQuantity: Float?, - - val materialTypeId: Long?, - - val simdutFile: MultipartFile? = null -) : EntityDto - -data class MaterialOutputDto( - override val id: Long, - val name: String, - val inventoryQuantity: Float, - val isMixType: Boolean, - val materialType: MaterialType, - val simdutUrl: String? -) : Model - -data class MaterialQuantityDto( - val material: Long, - - @field:Min(0, message = VALIDATION_SIZE_GE_ZERO) - val quantity: Float -) - -// === DSL === - -fun material( - id: Long? = null, - name: String = "name", - inventoryQuantity: Float = 0f, - isMixType: Boolean = false, - materialType: MaterialType? = materialType(), - op: Material.() -> Unit = {} -) = Material(id, name, inventoryQuantity, isMixType, materialType).apply(op) - -fun material( - material: Material, - id: Long? = null, - name: String? = null, -) = Material( - id ?: material.id, name - ?: material.name, material.inventoryQuantity, material.isMixType, material.materialType -) - -fun materialSaveDto( - name: String = "name", - inventoryQuantity: Float = 0f, - materialTypeId: Long = 0L, - simdutFile: MultipartFile? = null, - op: MaterialSaveDto.() -> Unit = {} -) = MaterialSaveDto(name, inventoryQuantity, materialTypeId, simdutFile).apply(op) - -fun materialUpdateDto( - id: Long = 0L, - name: String? = "name", - inventoryQuantity: Float? = 0f, - materialTypeId: Long? = 0L, - simdutFile: MultipartFile? = null, - op: MaterialUpdateDto.() -> Unit = {} -) = MaterialUpdateDto(id, name, inventoryQuantity, materialTypeId, simdutFile).apply(op) - -fun materialQuantityDto( - materialId: Long, - quantity: Float, - op: MaterialQuantityDto.() -> Unit = {} -) = MaterialQuantityDto(materialId, quantity).apply(op) - -// ==== Exceptions ==== -private const -val MATERIAL_NOT_FOUND_EXCEPTION_TITLE = "Material not found" -private const val MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE = "Material already exists" -private const val MATERIAL_CANNOT_DELETE_EXCEPTION_TITLE = "Cannot delete material" -private const val MATERIAL_EXCEPTION_ERROR_CODE = "material" - -fun materialIdNotFoundException(id: Long) = - NotFoundException( - MATERIAL_EXCEPTION_ERROR_CODE, - MATERIAL_NOT_FOUND_EXCEPTION_TITLE, - "A material with the id $id could not be found", - id - ) - -fun materialNameNotFoundException(name: String) = - NotFoundException( - MATERIAL_EXCEPTION_ERROR_CODE, - MATERIAL_NOT_FOUND_EXCEPTION_TITLE, - "A material with the name $name could not be found", - name, - "name" - ) - -fun materialIdAlreadyExistsException(id: Long) = - AlreadyExistsException( - MATERIAL_EXCEPTION_ERROR_CODE, - MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE, - "A material with the id $id already exists", - id - ) - -fun materialNameAlreadyExistsException(name: String) = - AlreadyExistsException( - MATERIAL_EXCEPTION_ERROR_CODE, - MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE, - "A material with the name $name already exists", - name, - "name" - ) - -fun cannotDeleteMaterialException(material: Material) = - CannotDeleteException( - MATERIAL_EXCEPTION_ERROR_CODE, - MATERIAL_CANNOT_DELETE_EXCEPTION_TITLE, - "Cannot delete the material ${material.name} because one or more recipes depends on it" - ) + val materialType: MaterialType? +) : ModelEntity { + companion object { + fun getSimdutFilePath(name: String) = + "${Constants.FilePaths.SIMDUT}/$name.pdf" + } +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/MaterialType.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/MaterialType.kt index d19ac34..597fc8b 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/MaterialType.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/MaterialType.kt @@ -1,158 +1,26 @@ package dev.fyloz.colorrecipesexplorer.model -import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException -import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException -import dev.fyloz.colorrecipesexplorer.exception.NotFoundException -import dev.fyloz.colorrecipesexplorer.model.validation.NullOrNotBlank -import dev.fyloz.colorrecipesexplorer.model.validation.NullOrSize import org.hibernate.annotations.ColumnDefault import javax.persistence.* -import javax.validation.constraints.NotBlank -import javax.validation.constraints.NotNull -import javax.validation.constraints.Size - -private const val VALIDATION_PREFIX_SIZE = "Must contains exactly 3 characters" @Entity @Table(name = "material_type") data class MaterialType( - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - override val id: Long? = null, + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + override val id: Long, - @Column(unique = true) - override val name: String = "", + @Column(unique = true) + val name: String = "", - @Column(unique = true) - val prefix: String = "", + @Column(unique = true) + val prefix: String = "", - @Column(name = "use_percentages") - @ColumnDefault("false") - val usePercentages: Boolean = false, + @Column(name = "use_percentages") + @ColumnDefault("false") + val usePercentages: Boolean = false, - @Column(name = "system_type") - @ColumnDefault("false") - val systemType: Boolean = false -) : NamedModel - -open class MaterialTypeSaveDto( - @field:NotBlank - val name: String, - - @field:NotBlank - @field:Size(min = 3, max = 3, message = VALIDATION_PREFIX_SIZE) - val prefix: String, - - val usePercentages: Boolean = false -) : EntityDto { - override fun toEntity(): MaterialType = - MaterialType(null, name, prefix, usePercentages) -} - -open class MaterialTypeUpdateDto( - val id: Long, - - @field:NotBlank - val name: String?, - - @field:Size(min = 3, max = 3, message = VALIDATION_PREFIX_SIZE) - val prefix: String? -) : EntityDto { - override fun toEntity(): MaterialType = - MaterialType(id, name ?: "", prefix ?: "") -} - -// ==== DSL ==== -fun materialType( - id: Long? = null, - name: String = "name", - prefix: String = "PRE", - usePercentages: Boolean = false, - systemType: Boolean = false, - op: MaterialType.() -> Unit = {} -) = MaterialType(id, name, prefix, usePercentages, systemType).apply(op) - -fun materialType( - materialType: MaterialType, - newId: Long? = null, - newName: String? = null, - newSystemType: Boolean? = null -) = with(materialType) { - MaterialType( - newId ?: id, - newName ?: name, - prefix, - usePercentages, - newSystemType ?: systemType - ) -} - -fun materialTypeSaveDto( - name: String = "name", - prefix: String = "PRE", - usePercentages: Boolean = false, - op: MaterialTypeSaveDto.() -> Unit = {} -) = MaterialTypeSaveDto(name, prefix, usePercentages).apply(op) - -fun materialTypeUpdateDto( - id: Long = 0L, - name: String? = null, - prefix: String? = null, - op: MaterialTypeUpdateDto.() -> Unit = {} -) = MaterialTypeUpdateDto(id, name, prefix).apply(op) - -// ==== Exceptions ==== -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_EXCEPTION_ERROR_CODE = "materialtype" - -fun materialTypeIdNotFoundException(id: Long) = - NotFoundException( - MATERIAL_TYPE_EXCEPTION_ERROR_CODE, - MATERIAL_TYPE_NOT_FOUND_EXCEPTION_TITLE, - "A material type with the id $id could not be found", - id - ) - -fun materialTypeNameNotFoundException(name: String) = - NotFoundException( - MATERIAL_TYPE_EXCEPTION_ERROR_CODE, - MATERIAL_TYPE_NOT_FOUND_EXCEPTION_TITLE, - "A material type with the name $name could not be found", - name, - "name" - ) - -fun materialTypeIdAlreadyExistsException(id: Long) = - AlreadyExistsException( - MATERIAL_TYPE_EXCEPTION_ERROR_CODE, - MATERIAL_TYPE_ALREADY_EXISTS_EXCEPTION_TITLE, - "A material type with the id $id already exists", - id - ) - -fun materialTypeNameAlreadyExistsException(name: String) = - AlreadyExistsException( - MATERIAL_TYPE_EXCEPTION_ERROR_CODE, - MATERIAL_TYPE_ALREADY_EXISTS_EXCEPTION_TITLE, - "A material type with the name $name already exists", - name, - "name" - ) - -fun materialTypePrefixAlreadyExistsException(prefix: String) = - AlreadyExistsException( - MATERIAL_TYPE_EXCEPTION_ERROR_CODE, - MATERIAL_TYPE_ALREADY_EXISTS_EXCEPTION_TITLE, - "A material type with the prefix $prefix already exists", - prefix, - "prefix" - ) - -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" - ) + @Column(name = "system_type") + @ColumnDefault("false") + val systemType: Boolean = false +) : ModelEntity \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Mix.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Mix.kt index 3622343..86ecad7 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Mix.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Mix.kt @@ -1,144 +1,24 @@ package dev.fyloz.colorrecipesexplorer.model -import com.fasterxml.jackson.annotation.JsonIgnore -import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException -import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException -import dev.fyloz.colorrecipesexplorer.exception.NotFoundException import javax.persistence.* -import javax.validation.constraints.Min -import javax.validation.constraints.NotBlank - @Entity @Table(name = "mix") data class Mix( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - override val id: Long?, + override val id: Long, - var location: String?, + val location: String?, - @JsonIgnore - @ManyToOne - @JoinColumn(name = "recipe_id") - val recipe: Recipe, + @Column(name = "recipe_id") + val recipeId: Long, @ManyToOne @JoinColumn(name = "mix_type_id") - var mixType: MixType, + val mixType: MixType, @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true) @JoinColumn(name = "mix_id") - var mixMaterials: MutableSet, -) : Model - -open class MixSaveDto( - @field:NotBlank - val name: String, - - val recipeId: Long, - - val materialTypeId: Long, - - val mixMaterials: Set? -) : EntityDto - -open class MixUpdateDto( - val id: Long, - - @field:NotBlank - val name: String?, - - val materialTypeId: Long?, - - var mixMaterials: Set? -) : EntityDto - -data class MixOutputDto( - val id: Long, - val location: String?, - val mixType: MixType, - val mixMaterials: Set -) - -data class MixDeductDto( - val id: Long, - - @field:Min(0, message = VALIDATION_SIZE_GE_ZERO) - val ratio: Float -) - -data class MixLocationDto( - val mixId: Long, - - val location: String? -) - -//fun Mix.toOutput() = - -// ==== DSL ==== -fun mix( - id: Long? = null, - location: String? = "location", - recipe: Recipe = recipe(), - mixType: MixType = mixType(), - mixMaterials: MutableSet = mutableSetOf(), - op: Mix.() -> Unit = {} -) = Mix(id, location, recipe, mixType, mixMaterials).apply(op) - -fun mixSaveDto( - name: String = "name", - recipeId: Long = 0L, - materialTypeId: Long = 0L, - mixMaterials: Set? = setOf(), - op: MixSaveDto.() -> Unit = {} -) = MixSaveDto(name, recipeId, materialTypeId, mixMaterials).apply(op) - -fun mixUpdateDto( - id: Long = 0L, - name: String? = "name", - materialTypeId: Long? = 0L, - mixMaterials: Set? = setOf(), - op: MixUpdateDto.() -> Unit = {} -) = MixUpdateDto(id, name, materialTypeId, mixMaterials).apply(op) - -fun mixRatio( - id: Long = 0L, - ratio: Float = 1f, - op: MixDeductDto.() -> Unit = {} -) = MixDeductDto(id, ratio).apply(op) - -fun mixLocationDto( - mixId: Long = 0L, - location: String? = "location", - op: MixLocationDto.() -> Unit = {} -) = MixLocationDto(mixId, location).apply(op) - -// ==== Exceptions ==== -private const val MIX_NOT_FOUND_EXCEPTION_TITLE = "Mix not found" -private const val MIX_ALREADY_EXISTS_EXCEPTION_TITLE = "Mix already exists" -private const val MIX_CANNOT_DELETE_EXCEPTION_TITLE = "Cannot delete mix" -private const val MIX_EXCEPTION_ERROR_CODE = "mix" - -fun mixIdNotFoundException(id: Long) = - NotFoundException( - MIX_EXCEPTION_ERROR_CODE, - MIX_NOT_FOUND_EXCEPTION_TITLE, - "A mix with the id $id could not be found", - id - ) - -fun mixIdAlreadyExistsException(id: Long) = - AlreadyExistsException( - MIX_EXCEPTION_ERROR_CODE, - MIX_ALREADY_EXISTS_EXCEPTION_TITLE, - "A mix with the id $id already exists", - id - ) - -fun cannotDeleteMixException(mix: Mix) = - CannotDeleteException( - MIX_EXCEPTION_ERROR_CODE, - MIX_CANNOT_DELETE_EXCEPTION_TITLE, - "Cannot delete the mix ${mix.mixType.name} because one or more mixes depends on it" - ) + val mixMaterials: List +) : ModelEntity \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/MixMaterial.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/MixMaterial.kt index c48316b..f3f8f0f 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/MixMaterial.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/MixMaterial.kt @@ -1,76 +1,19 @@ package dev.fyloz.colorrecipesexplorer.model -import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException -import dev.fyloz.colorrecipesexplorer.exception.NotFoundException import javax.persistence.* -import javax.validation.constraints.Min -import javax.validation.constraints.NotNull @Entity @Table(name = "mix_material") data class MixMaterial( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - override val id: Long?, + override val id: Long, @ManyToOne @JoinColumn(name = "material_id") val material: Material, - var quantity: Float, - - var position: Int -) : Model - -data class MixMaterialDto( - val materialId: Long, - - @field:Min(0, message = VALIDATION_SIZE_GE_ZERO) val quantity: Float, val position: Int -) - -data class MixMaterialOutputDto( - val id: Long, - val material: MaterialOutputDto, - val quantity: Float, - val position: Int -) - -// ==== DSL ==== -fun mixMaterial( - id: Long? = null, - material: Material = material(), - quantity: Float = 0f, - position: Int = 0, - op: MixMaterial.() -> Unit = {} -) = MixMaterial(id, material, quantity, position).apply(op) - -fun mixMaterialDto( - materialId: Long = 0L, - quantity: Float = 0f, - position: Int = 0, - op: MixMaterialDto.() -> Unit = {} -) = MixMaterialDto(materialId, quantity, position).apply(op) - -// ==== Exceptions ==== -private const val MIX_MATERIAL_NOT_FOUND_EXCEPTION_TITLE = "Mix material not found" -private const val MIX_MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE = "Mix material already exists" -private const val MIX_MATERIAL_EXCEPTION_ERROR_CODE = "mixmaterial" - -fun mixMaterialIdNotFoundException(id: Long) = - NotFoundException( - MIX_MATERIAL_EXCEPTION_ERROR_CODE, - MIX_MATERIAL_NOT_FOUND_EXCEPTION_TITLE, - "A mix material with the id $id could not be found", - id - ) - -fun mixMaterialIdAlreadyExistsException(id: Long) = - AlreadyExistsException( - MIX_MATERIAL_EXCEPTION_ERROR_CODE, - MIX_MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE, - "A mix material with the id $id already exists", - id - ) +) : ModelEntity \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/MixMixType.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/MixMixType.kt new file mode 100644 index 0000000..e39a27c --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/MixMixType.kt @@ -0,0 +1,19 @@ +package dev.fyloz.colorrecipesexplorer.model + +import javax.persistence.* + +@Entity +@Table(name = "mix_mix_type") +data class MixMixType( + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + override val id: Long, + + @ManyToOne + @JoinColumn(name = "mix_type_id") + val mixType: MixType, + + val quantity: Float, + + val position: Int +) : ModelEntity \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/MixType.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/MixType.kt index 6281099..dec9c12 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/MixType.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/MixType.kt @@ -1,100 +1,21 @@ package dev.fyloz.colorrecipesexplorer.model -import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException -import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException -import dev.fyloz.colorrecipesexplorer.exception.NotFoundException -import dev.fyloz.colorrecipesexplorer.exception.RestException -import org.springframework.http.HttpStatus import javax.persistence.* @Entity @Table(name = "mix_type") data class MixType( - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - override val id: Long?, + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + override val id: Long, - @Column(unique = true) - override var name: String, + val name: String, - @OneToOne(cascade = [CascadeType.ALL]) - @JoinColumn(name = "material_id") - var material: Material -) : NamedModel + @ManyToOne + @JoinColumn(name = "material_type_id") + val materialType: MaterialType, -// ==== DSL ==== -fun mixType( - id: Long? = null, - name: String = "name", - material: Material = material(), - op: MixType.() -> Unit = {} -) = MixType(id, name, material).apply(op) - -fun mixType( - name: String = "name", - materialType: MaterialType = materialType(), - op: MixType.() -> Unit = {} -) = mixType( - id = null, - name, - material = material(name = name, inventoryQuantity = 0f, isMixType = true, materialType = materialType) -).apply(op) - -// ==== Exceptions ==== -private const val MIX_TYPE_NOT_FOUND_EXCEPTION_TITLE = "Mix type not found" -private const val MIX_TYPE_ALREADY_EXISTS_EXCEPTION_TITLE = "Mix type already exists" -private const val MIX_TYPE_CANNOT_DELETE_EXCEPTION_TITLE = "Cannot delete mix type" -private const val MIX_TYPE_EXCEPTION_ERROR_CODE = "mixtype" - -class MixTypeNameAndMaterialTypeNotFoundException(name: String, materialType: MaterialType) : - RestException( - "notfound-mixtype-namematerialtype", - MIX_TYPE_NOT_FOUND_EXCEPTION_TITLE, - HttpStatus.NOT_FOUND, - "A mix type with the name $name and material type ${materialType.name} could not be found", - mapOf( - "name" to name, - "materialType" to materialType.name - ) - ) - -fun mixTypeIdNotFoundException(id: Long) = - NotFoundException( - MIX_TYPE_EXCEPTION_ERROR_CODE, - MIX_TYPE_NOT_FOUND_EXCEPTION_TITLE, - "A mix type with the id $id could not be found", - id - ) - -fun mixTypeIdAlreadyExistsException(id: Long) = - AlreadyExistsException( - MIX_TYPE_EXCEPTION_ERROR_CODE, - MIX_TYPE_ALREADY_EXISTS_EXCEPTION_TITLE, - "A mix type with the id $id already exists", - id - ) - -fun mixTypeNameNotFoundException(name: String) = - NotFoundException( - MIX_TYPE_EXCEPTION_ERROR_CODE, - MIX_TYPE_NOT_FOUND_EXCEPTION_TITLE, - "A mix type with the name $name could not be found", - name, - "name" - ) - -fun mixTypeNameAlreadyExistsException(name: String) = - AlreadyExistsException( - MIX_TYPE_EXCEPTION_ERROR_CODE, - MIX_TYPE_ALREADY_EXISTS_EXCEPTION_TITLE, - "A mix type with the name $name already exists", - name, - "name" - ) - -fun cannotDeleteMixTypeException(mixType: MixType) = - CannotDeleteException( - MIX_TYPE_EXCEPTION_ERROR_CODE, - MIX_TYPE_CANNOT_DELETE_EXCEPTION_TITLE, - "Cannot delete the mix type ${mixType.name} because one or more mixes depends on it" - ) + @OneToOne(cascade = [CascadeType.ALL]) + @JoinColumn(name = "material_id") + val material: Material? +) : ModelEntity \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Model.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Model.kt deleted file mode 100644 index 147285f..0000000 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Model.kt +++ /dev/null @@ -1,22 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.model - -/** The model of a stored entity. Each model should implements its own equals and hashCode methods to keep compatibility with the legacy Java and Thymeleaf code. */ -interface Model { - val id: Long? -} - -interface NamedModel : Model { - val name: String -} - -interface EntityDto { - /** Converts the dto to an actual entity. */ - fun toEntity(): E { - throw UnsupportedOperationException() - } -} - -// GENERAL VALIDATION MESSAGES -const val VALIDATION_SIZE_GE_ZERO = "Must be greater or equals to 0" -const val VALIDATION_SIZE_GE_ONE = "Must be greater or equals to 1" -const val VALIDATION_RANGE_PERCENTS = "Must be between 0 and 100" diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/ModelEntity.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/ModelEntity.kt new file mode 100644 index 0000000..00465ef --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/ModelEntity.kt @@ -0,0 +1,6 @@ +package dev.fyloz.colorrecipesexplorer.model + +/** Represents an entity with an id, named differently to prevent conflicts with the JPA annotation. */ +interface ModelEntity { + val id: Long +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Recipe.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Recipe.kt index a78ba28..7392285 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Recipe.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/Recipe.kt @@ -1,27 +1,15 @@ package dev.fyloz.colorrecipesexplorer.model -import com.fasterxml.jackson.annotation.JsonIgnore -import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException -import dev.fyloz.colorrecipesexplorer.exception.NotFoundException import dev.fyloz.colorrecipesexplorer.model.account.Group -import dev.fyloz.colorrecipesexplorer.model.account.group -import dev.fyloz.colorrecipesexplorer.rest.FILE_CONTROLLER_PATH -import java.net.URLEncoder -import java.nio.charset.StandardCharsets import java.time.LocalDate import javax.persistence.* -import javax.validation.constraints.* - -private const val VALIDATION_COLOR_PATTERN = "^#([0-9a-f]{6})$" - -const val RECIPE_IMAGES_DIRECTORY = "images/recipes" @Entity @Table(name = "recipe") data class Recipe( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - override val id: Long?, + override val id: Long, /** The name of the recipe. It is not unique in the entire system, but is unique in the scope of a [Company]. */ val name: String, @@ -46,254 +34,28 @@ data class Recipe( @JoinColumn(name = "company_id") val company: Company, - @OneToMany(cascade = [CascadeType.ALL], mappedBy = "recipe") - val mixes: MutableList, + @OneToMany(cascade = [CascadeType.ALL], mappedBy = "recipeId") + val mixes: List, - @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true) + @OneToMany(cascade = [CascadeType.ALL], orphanRemoval = true) @JoinColumn(name = "recipe_id") - val groupsInformation: Set -) : Model { - /** The mix types contained in this recipe. */ - val mixTypes: Collection - @JsonIgnore - get() = mixes.map { it.mixType } - - val imagesDirectoryPath - @JsonIgnore - @Transient - get() = "$RECIPE_IMAGES_DIRECTORY/$id" - - fun groupInformationForGroup(groupId: Long) = - groupsInformation.firstOrNull { it.group.id == groupId } - - fun imageUrl(deploymentUrl: String, name: String) = - "$deploymentUrl$FILE_CONTROLLER_PATH?path=${ - URLEncoder.encode( - "${this.imagesDirectoryPath}/$name", - StandardCharsets.UTF_8 - ) - }" -} - -open class RecipeSaveDto( - @field:NotBlank - val name: String, - - @field:NotBlank - val description: String, - - @field:NotBlank - @field:Pattern(regexp = VALIDATION_COLOR_PATTERN) - val color: String, - - @field:Min(0, message = VALIDATION_RANGE_PERCENTS) - @field:Max(100, message = VALIDATION_RANGE_PERCENTS) - val gloss: Byte, - - @field:Min(0, message = VALIDATION_SIZE_GE_ZERO) - val sample: Int?, - - val approbationDate: LocalDate?, - - val remark: String?, - - val companyId: Long -) : EntityDto { - override fun toEntity(): Recipe = recipe( - name = name, - description = description, - sample = sample, - approbationDate = approbationDate, - remark = remark ?: "", - company = company(id = companyId) - ) -} - -open class RecipeUpdateDto( - val id: Long, - - @field:NotBlank - val name: String?, - - @field:NotBlank - val description: String?, - - @field:NotBlank - @field:Pattern(regexp = VALIDATION_COLOR_PATTERN) - val color: String?, - - @field:Min(0, message = VALIDATION_RANGE_PERCENTS) - @field:Max(100, message = VALIDATION_RANGE_PERCENTS) - val gloss: Byte?, - - @field:Min(0, message = VALIDATION_SIZE_GE_ZERO) - val sample: Int?, - - val approbationDate: LocalDate?, - - val remark: String?, - - val steps: Set? -) : EntityDto - -data class RecipeOutputDto( - override val id: Long, - val name: String, - val description: String, - val color: String, - val gloss: Byte, - val sample: Int?, - val approbationDate: LocalDate?, - val approbationExpired: Boolean?, - val remark: String?, - val company: Company, - val mixes: Set, - val groupsInformation: Set, - var imagesUrls: Set -) : Model + val groupsInformation: List +) : ModelEntity @Entity @Table(name = "recipe_group_information") data class RecipeGroupInformation( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - override val id: Long?, + override val id: Long, @ManyToOne @JoinColumn(name = "group_id") val group: Group, - var note: String?, + val note: String?, @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true) @JoinColumn(name = "recipe_group_information_id") - var steps: MutableSet? -) : Model - -data class RecipeStepsDto( - val groupId: Long, - - val steps: Set -) - -data class RecipePublicDataDto( - val recipeId: Long, - - val notes: Set?, - - val mixesLocation: Set? -) - -data class NoteDto( - val groupId: Long, - - val content: String? -) - -// ==== DSL ==== -fun recipe( - id: Long? = null, - name: String = "name", - description: String = "description", - color: String = "ffffff", - gloss: Byte = 0, - sample: Int? = -1, - approbationDate: LocalDate? = LocalDate.MIN, - remark: String = "remark", - company: Company = company(), - mixes: MutableList = mutableListOf(), - groupsInformation: Set = setOf(), - op: Recipe.() -> Unit = {} -) = Recipe( - id, - name, - description, - color, - gloss, - sample, - approbationDate, - remark, - company, - mixes, - groupsInformation -).apply(op) - -fun recipeSaveDto( - name: String = "name", - description: String = "description", - color: String = "ffffff", - gloss: Byte = 0, - sample: Int? = -1, - approbationDate: LocalDate? = LocalDate.MIN, - remark: String = "remark", - companyId: Long = 0L, - op: RecipeSaveDto.() -> Unit = {} -) = RecipeSaveDto(name, description, color, gloss, sample, approbationDate, remark, companyId).apply(op) - -fun recipeUpdateDto( - id: Long = 0L, - name: String? = "name", - description: String? = "description", - color: String? = "ffffff", - gloss: Byte? = 0, - sample: Int? = -1, - approbationDate: LocalDate? = LocalDate.MIN, - remark: String? = "remark", - steps: Set? = setOf(), - op: RecipeUpdateDto.() -> Unit = {} -) = RecipeUpdateDto(id, name, description, color, gloss, sample, approbationDate, remark, steps).apply(op) - -fun recipeGroupInformation( - id: Long? = null, - group: Group = group(), - note: String? = null, - steps: MutableSet? = mutableSetOf(), - op: RecipeGroupInformation.() -> Unit = {} -) = RecipeGroupInformation(id, group, note, steps).apply(op) - -fun recipePublicDataDto( - recipeId: Long = 0L, - notes: Set? = null, - mixesLocation: Set? = null, - op: RecipePublicDataDto.() -> Unit = {} -) = RecipePublicDataDto(recipeId, notes, mixesLocation).apply(op) - -fun noteDto( - groupId: Long = 0L, - content: String? = "note", - op: NoteDto.() -> Unit = {} -) = NoteDto(groupId, content).apply(op) - -// ==== Exceptions ==== -private const val RECIPE_NOT_FOUND_EXCEPTION_TITLE = "Recipe not found" -private const val RECIPE_ALREADY_EXISTS_EXCEPTION_TITLE = "Recipe already exists" -private const val RECIPE_EXCEPTION_ERROR_CODE = "recipe" - -fun recipeIdNotFoundException(id: Long) = - NotFoundException( - RECIPE_EXCEPTION_ERROR_CODE, - RECIPE_NOT_FOUND_EXCEPTION_TITLE, - "A recipe with the id $id could not be found", - id - ) - -fun recipeIdAlreadyExistsException(id: Long) = - AlreadyExistsException( - RECIPE_EXCEPTION_ERROR_CODE, - RECIPE_ALREADY_EXISTS_EXCEPTION_TITLE, - "A recipe with the id $id already exists", - id - ) - -fun recipeNameAlreadyExistsForCompanyException(name: String, company: Company) = - AlreadyExistsException( - "${RECIPE_EXCEPTION_ERROR_CODE}-company", - RECIPE_ALREADY_EXISTS_EXCEPTION_TITLE, - "A recipe with the name $name already exists for the company ${company.name}", - name, - "name", - mutableMapOf( - "company" to company.name, - "companyId" to company.id!! - ) - ) + val steps: List? +) : ModelEntity \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/RecipeStep.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/RecipeStep.kt index 1f5a3a7..14ea885 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/RecipeStep.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/RecipeStep.kt @@ -1,7 +1,5 @@ package dev.fyloz.colorrecipesexplorer.model -import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException -import dev.fyloz.colorrecipesexplorer.exception.NotFoundException import javax.persistence.* @Entity @@ -9,38 +7,9 @@ import javax.persistence.* data class RecipeStep( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - override val id: Long?, + override val id: Long, val position: Int, val message: String -) : Model - -// ==== DSL ==== -fun recipeStep( - id: Long? = null, - position: Int = 0, - message: String = "message", - op: RecipeStep.() -> Unit = {} -) = RecipeStep(id, position, message).apply(op) - -// ==== Exceptions ==== -private const val RECIPE_STEP_NOT_FOUND_EXCEPTION_TITLE = "Recipe step not found" -private const val RECIPE_STEP_ALREADY_EXISTS_EXCEPTION_TITLE = "Recipe step already exists" -private const val RECIPE_STEP_EXCEPTION_ERROR_CODE = "recipestep" - -fun recipeStepIdNotFoundException(id: Long) = - NotFoundException( - RECIPE_STEP_EXCEPTION_ERROR_CODE, - RECIPE_STEP_NOT_FOUND_EXCEPTION_TITLE, - "A recipe step with the id $id could not be found", - id - ) - -fun recipeStepIdAlreadyExistsException(id: Long) = - AlreadyExistsException( - RECIPE_STEP_EXCEPTION_ERROR_CODE, - RECIPE_STEP_ALREADY_EXISTS_EXCEPTION_TITLE, - "A recipe step with the id $id already exists", - id - ) +) : ModelEntity \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/Group.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/Group.kt index 1169c5e..ebf84b6 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/Group.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/Group.kt @@ -1,135 +1,24 @@ package dev.fyloz.colorrecipesexplorer.model.account -import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException -import dev.fyloz.colorrecipesexplorer.exception.NotFoundException -import dev.fyloz.colorrecipesexplorer.exception.RestException -import dev.fyloz.colorrecipesexplorer.model.* +import dev.fyloz.colorrecipesexplorer.model.ModelEntity import org.hibernate.annotations.Fetch import org.hibernate.annotations.FetchMode -import org.springframework.http.HttpStatus import javax.persistence.* -import javax.validation.constraints.NotBlank -import javax.validation.constraints.NotEmpty -import javax.validation.constraints.NotNull -import javax.validation.constraints.Size @Entity @Table(name = "user_group") data class Group( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - override var id: Long? = null, + override val id: Long, @Column(unique = true) - override val name: String = "", + val name: String, @Enumerated(EnumType.STRING) @ElementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "group_permission", joinColumns = [JoinColumn(name = "group_id")]) @Column(name = "permission") @Fetch(FetchMode.SUBSELECT) - val permissions: MutableSet = mutableSetOf(), -) : NamedModel { - val flatPermissions: Set - get() = this.permissions - .flatMap { it.flat() } - .filter { !it.deprecated } - .toSet() -} - -open class GroupSaveDto( - @field:NotBlank - val name: String, - - @field:NotEmpty - val permissions: MutableSet -) : EntityDto { - override fun toEntity(): Group = - Group(null, name, permissions) -} - -open class GroupUpdateDto( - val id: Long, - - @field:NotBlank - val name: String, - - @field:NotEmpty - val permissions: MutableSet -) : EntityDto { - override fun toEntity(): Group = - Group(id, name, permissions) -} - -data class GroupOutputDto( - override val id: Long, - val name: String, - val permissions: Set, - val explicitPermissions: Set -): Model - -fun group( - id: Long? = null, - name: String = "name", - permissions: MutableSet = mutableSetOf(), - op: Group.() -> Unit = {} -) = Group(id, name, permissions).apply(op) - -fun groupSaveDto( - name: String = "name", - permissions: MutableSet = mutableSetOf(), - op: GroupSaveDto.() -> Unit = {} -) = GroupSaveDto(name, permissions).apply(op) - -fun groupUpdateDto( - id: Long = 0L, - name: String = "name", - permissions: MutableSet = mutableSetOf(), - op: GroupUpdateDto.() -> Unit = {} -) = GroupUpdateDto(id, name, permissions).apply(op) - -// ==== Exceptions ==== -private const val GROUP_NOT_FOUND_EXCEPTION_TITLE = "Group not found" -private const val GROUP_ALREADY_EXISTS_EXCEPTION_TITLE = "Group already exists" -private const val GROUP_EXCEPTION_ERROR_CODE = "group" - -class NoDefaultGroupException : RestException( - "nodefaultgroup", - "No default group", - HttpStatus.NOT_FOUND, - "No default group cookie is defined in the current request" -) - -fun groupIdNotFoundException(id: Long) = - NotFoundException( - GROUP_EXCEPTION_ERROR_CODE, - GROUP_NOT_FOUND_EXCEPTION_TITLE, - "A group with the id $id could not be found", - id - ) - -fun groupNameNotFoundException(name: String) = - NotFoundException( - GROUP_EXCEPTION_ERROR_CODE, - GROUP_NOT_FOUND_EXCEPTION_TITLE, - "A group with the name $name could not be found", - name, - "name" - ) - -fun groupIdAlreadyExistsException(id: Long) = - AlreadyExistsException( - GROUP_EXCEPTION_ERROR_CODE, - GROUP_ALREADY_EXISTS_EXCEPTION_TITLE, - "A group with the id $id already exists", - id, - ) - -fun groupNameAlreadyExistsException(name: String) = - AlreadyExistsException( - GROUP_EXCEPTION_ERROR_CODE, - GROUP_ALREADY_EXISTS_EXCEPTION_TITLE, - "A group with the name $name already exists", - name, - "name" - ) + val permissions: List, +) : ModelEntity \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/User.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/User.kt index ac6f5d6..7d8ce3a 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/User.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/account/User.kt @@ -1,20 +1,10 @@ package dev.fyloz.colorrecipesexplorer.model.account -import dev.fyloz.colorrecipesexplorer.SpringUserDetails -import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException -import dev.fyloz.colorrecipesexplorer.exception.NotFoundException -import dev.fyloz.colorrecipesexplorer.model.EntityDto -import dev.fyloz.colorrecipesexplorer.model.Model +import dev.fyloz.colorrecipesexplorer.model.ModelEntity import org.hibernate.annotations.Fetch import org.hibernate.annotations.FetchMode -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder -import org.springframework.security.crypto.password.PasswordEncoder import java.time.LocalDateTime import javax.persistence.* -import javax.validation.constraints.NotBlank -import javax.validation.constraints.Size - -private const val VALIDATION_PASSWORD_LENGTH = "Must contains at least 8 characters" @Entity @Table(name = "user") @@ -23,210 +13,31 @@ data class User( override val id: Long, @Column(name = "first_name") - val firstName: String = "", + val firstName: String, @Column(name = "last_name") - val lastName: String = "", + val lastName: String, - val password: String = "", + val password: String, @Column(name = "default_group_user") - val isDefaultGroupUser: Boolean = false, + val isDefaultGroupUser: Boolean, @Column(name = "system_user") - val isSystemUser: Boolean = false, + val isSystemUser: Boolean, @ManyToOne @JoinColumn(name = "group_id") @Fetch(FetchMode.SELECT) - var group: Group? = null, + val group: Group?, @Enumerated(EnumType.STRING) @ElementCollection(fetch = FetchType.EAGER) @CollectionTable(name = "user_permission", joinColumns = [JoinColumn(name = "user_id")]) @Column(name = "permission") @Fetch(FetchMode.SUBSELECT) - val permissions: MutableSet = mutableSetOf(), + val permissions: List, @Column(name = "last_login_time") - var lastLoginTime: LocalDateTime? = null -) : Model { - val flatPermissions: Set - get() = permissions - .flatMap { it.flat() } - .filter { !it.deprecated } - .toMutableSet() - .apply { - if (group != null) this.addAll(group!!.flatPermissions) - } -} - -open class UserSaveDto( - val id: Long, - - @field:NotBlank - val firstName: String, - - @field:NotBlank - val lastName: String, - - @field:NotBlank - @field:Size(min = 8, message = VALIDATION_PASSWORD_LENGTH) - val password: String, - - val groupId: Long?, - - @Enumerated(EnumType.STRING) - val permissions: MutableSet = mutableSetOf() -) : EntityDto - -open class UserUpdateDto( - val id: Long, - - @field:NotBlank - val firstName: String?, - - @field:NotBlank - val lastName: String?, - - val groupId: Long?, - - @Enumerated(EnumType.STRING) - val permissions: Set? -) : EntityDto - -data class UserOutputDto( - override val id: Long, - val firstName: String, - val lastName: String, - val group: Group?, - val permissions: Set, - val explicitPermissions: Set, val lastLoginTime: LocalDateTime? -) : Model - -data class UserLoginRequest(val id: Long, val password: String) - -data class UserDetails(val user: User) : SpringUserDetails { - override fun getPassword() = user.password - override fun getUsername() = user.id.toString() - override fun getAuthorities() = user.flatPermissions.toAuthorities() - - override fun isAccountNonExpired() = true - override fun isAccountNonLocked() = true - override fun isCredentialsNonExpired() = true - override fun isEnabled() = true -} - -// ==== DSL ==== -fun user( - id: Long = 0L, - firstName: String = "firstName", - lastName: String = "lastName", - password: String = "password", - isDefaultGroupUser: Boolean = false, - isSystemUser: Boolean = false, - group: Group? = null, - permissions: MutableSet = mutableSetOf(), - lastLoginTime: LocalDateTime? = null, - op: User.() -> Unit = {} -) = User( - id, - firstName, - lastName, - password, - isDefaultGroupUser, - isSystemUser, - group, - permissions, - lastLoginTime -).apply(op) - -fun user( - id: Long = 0L, - firstName: String = "firstName", - lastName: String = "lastName", - plainPassword: String = "password", - isDefaultGroupUser: Boolean = false, - isSystemUser: Boolean = false, - group: Group? = null, - permissions: MutableSet = mutableSetOf(), - lastLoginTime: LocalDateTime? = null, - passwordEncoder: PasswordEncoder = BCryptPasswordEncoder(), - op: User.() -> Unit = {} -) = User( - id, - firstName, - lastName, - passwordEncoder.encode(plainPassword), - isDefaultGroupUser, - isSystemUser, - group, - permissions, - lastLoginTime -).apply(op) - -fun userSaveDto( - passwordEncoder: PasswordEncoder = BCryptPasswordEncoder(), - id: Long = 0L, - firstName: String = "firstName", - lastName: String = "lastName", - password: String = passwordEncoder.encode("password"), - groupId: Long? = null, - permissions: MutableSet = mutableSetOf(), - op: UserSaveDto.() -> Unit = {} -) = UserSaveDto(id, firstName, lastName, password, groupId, permissions).apply(op) - -fun userUpdateDto( - id: Long = 0L, - firstName: String = "firstName", - lastName: String = "lastName", - groupId: Long? = null, - permissions: MutableSet = mutableSetOf(), - op: UserUpdateDto.() -> Unit = {} -) = UserUpdateDto(id, firstName, lastName, groupId, permissions).apply(op) - -// ==== Extensions ==== -fun Set.toAuthorities() = - this.map { it.toAuthority() }.toMutableSet() - -fun User.toOutputDto() = - UserOutputDto( - this.id, - this.firstName, - this.lastName, - this.group, - this.flatPermissions, - this.permissions, - this.lastLoginTime - ) - -// ==== Exceptions ==== -private const val USER_NOT_FOUND_EXCEPTION_TITLE = "User not found" -private const val USER_ALREADY_EXISTS_EXCEPTION_TITLE = "User already exists" -private const val USER_EXCEPTION_ERROR_CODE = "user" - -fun userIdNotFoundException(id: Long) = - NotFoundException( - USER_EXCEPTION_ERROR_CODE, - USER_NOT_FOUND_EXCEPTION_TITLE, - "An user with the id $id could not be found", - id - ) - -fun userIdAlreadyExistsException(id: Long) = - AlreadyExistsException( - USER_EXCEPTION_ERROR_CODE, - USER_ALREADY_EXISTS_EXCEPTION_TITLE, - "An user with the id $id already exists", - id - ) - -fun userFullNameAlreadyExistsException(firstName: String, lastName: String) = - AlreadyExistsException( - USER_EXCEPTION_ERROR_CODE, - USER_ALREADY_EXISTS_EXCEPTION_TITLE, - "An user with the name '$firstName $lastName' already exists", - "$firstName $lastName", - "fullName" - ) +) : ModelEntity \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/touchupkit/TouchUpKit.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/touchupkit/TouchUpKit.kt index 1a90530..0539585 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/touchupkit/TouchUpKit.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/touchupkit/TouchUpKit.kt @@ -1,24 +1,15 @@ package dev.fyloz.colorrecipesexplorer.model.touchupkit -import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException -import dev.fyloz.colorrecipesexplorer.exception.NotFoundException -import dev.fyloz.colorrecipesexplorer.model.EntityDto -import dev.fyloz.colorrecipesexplorer.model.Model -import dev.fyloz.colorrecipesexplorer.model.VALIDATION_SIZE_GE_ONE +import dev.fyloz.colorrecipesexplorer.model.ModelEntity import java.time.LocalDate import javax.persistence.* -import javax.validation.constraints.Min -import javax.validation.constraints.NotBlank -import javax.validation.constraints.NotEmpty - -const val TOUCH_UP_KIT_DELIMITER = ';' @Entity @Table(name = "touch_up_kit") data class TouchUpKit( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - override val id: Long?, + override val id: Long, val project: String, @@ -35,208 +26,28 @@ data class TouchUpKit( val completionDate: LocalDate?, @Column(name = "finish") - private val finishConcatenated: String, + val finish: String, @Column(name = "material") - private val materialConcatenated: String, + val material: String, @OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true) @JoinColumn(name = "touch_up_kit_id") - val content: Set -) : Model { - val finish - get() = finishConcatenated.split(TOUCH_UP_KIT_DELIMITER) - - val material - get() = materialConcatenated.split(TOUCH_UP_KIT_DELIMITER) - - val completed - get() = completionDate != null -} + val content: List +) : ModelEntity @Entity @Table(name = "touch_up_kit_product") data class TouchUpKitProduct( @Id @GeneratedValue(strategy = GenerationType.IDENTITY) - override val id: Long?, - - val name: String, - - val description: String?, - - val quantity: Float, - - val ready: Boolean -) : Model - -data class TouchUpKitSaveDto( - @field:NotBlank - val project: String, - - @field:NotBlank - val buggy: String, - - @field:NotBlank - val company: String, - - @field:Min(1, message = VALIDATION_SIZE_GE_ONE) - val quantity: Int, - - val shippingDate: LocalDate, - - @field:NotEmpty - val finish: List, - - @field:NotEmpty - val material: List, - - @field:NotEmpty - val content: Set -) : EntityDto { - override fun toEntity() = touchUpKit(this) -} - -data class TouchUpKitUpdateDto( - val id: Long, - - @field:NotBlank - val project: String?, - - @field:NotBlank - val buggy: String?, - - @field:NotBlank - val company: String?, - - @field:Min(1, message = VALIDATION_SIZE_GE_ONE) - val quantity: Int?, - - val shippingDate: LocalDate?, - - val completionDate: LocalDate?, - - @field:NotEmpty - val finish: List?, - - @field:NotEmpty - val material: List?, - - @field:NotEmpty - val content: Set? -) : EntityDto - -data class TouchUpKitOutputDto( override val id: Long, - val project: String, - val buggy: String, - val company: String, - val quantity: Int, - val shippingDate: LocalDate, - val completed: Boolean, - val completionDate: LocalDate?, - val expired: Boolean, - val finish: List, - val material: List, - val content: Set, - val pdfUrl: String -) : Model -data class TouchUpKitProductDto( val name: String, + val description: String?, + val quantity: Float, + val ready: Boolean -) - -// ==== DSL ==== -fun touchUpKit( - id: Long? = null, - project: String = "project", - buggy: String = "buggy", - company: String = "company", - quantity: Int = 1, - shippingDate: LocalDate = LocalDate.now(), - completionDate: LocalDate? = null, - finish: List, - material: List, - content: Set, - op: TouchUpKit.() -> Unit = {} -) = TouchUpKit( - id, - project, - buggy, - company, - quantity, - shippingDate, - completionDate, - finish.reduce { acc, f -> "$acc$TOUCH_UP_KIT_DELIMITER$f" }, - material.reduce { acc, f -> "$acc$TOUCH_UP_KIT_DELIMITER$f" }, - content -).apply(op) - -fun touchUpKit(touchUpKitSaveDto: TouchUpKitSaveDto) = - with(touchUpKitSaveDto) { - touchUpKit( - project = project, - buggy = buggy, - company = company, - quantity = quantity, - shippingDate = shippingDate, - finish = finish, - material = material, - content = content.map { touchUpKitProduct(it) }.toSet() - ) - } - -fun touchUpKitProduct( - id: Long? = null, - name: String = "product", - description: String? = "description", - quantity: Float = 1f, - ready: Boolean = false, - op: TouchUpKitProduct.() -> Unit = {} -) = TouchUpKitProduct(id, name, description, quantity, ready) - .apply(op) - -fun touchUpKitUpdateDto( - id: Long = 0L, - project: String? = null, - buggy: String? = null, - company: String? = null, - quantity: Int? = null, - shippingDate: LocalDate? = null, - completionDate: LocalDate? = null, - finish: List? = null, - material: List? = null, - content: Set? = null -) = TouchUpKitUpdateDto(id, project, buggy, company, quantity, shippingDate, completionDate, finish, material, content) - -fun touchUpKitProduct(touchUpKitProductDto: TouchUpKitProductDto) = - touchUpKitProduct( - name = touchUpKitProductDto.name, - description = touchUpKitProductDto.description, - quantity = touchUpKitProductDto.quantity, - ready = touchUpKitProductDto.ready - ) - -// ==== Exceptions ==== -private const val TOUCH_UP_KIT_NOT_FOUND_EXCEPTION_TITLE = "Touch up kit not found" -private const val TOUCH_UP_KIT_ALREADY_EXISTS_EXCEPTION_TITLE = "Touch up kit already exists" -private const val TOUCH_UP_KIT_EXCEPTION_ERROR_CODE = "touchupkit" - -fun touchUpKitIdNotFoundException(id: Long) = - NotFoundException( - TOUCH_UP_KIT_EXCEPTION_ERROR_CODE, - TOUCH_UP_KIT_NOT_FOUND_EXCEPTION_TITLE, - "A touch up kit with the id $id could not be found", - id - ) - -fun touchUpKitIdAlreadyExistsException(id: Long) = - AlreadyExistsException( - TOUCH_UP_KIT_EXCEPTION_ERROR_CODE, - TOUCH_UP_KIT_ALREADY_EXISTS_EXCEPTION_TITLE, - "A touch up kit with the id $id already exists", - id - ) +) : ModelEntity \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/validation/NullOrNotBlank.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/validation/NullOrNotBlank.kt deleted file mode 100644 index 9a381b9..0000000 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/validation/NullOrNotBlank.kt +++ /dev/null @@ -1,45 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.model.validation - -import javax.validation.Constraint -import javax.validation.ConstraintValidator -import javax.validation.ConstraintValidatorContext -import javax.validation.Payload -import kotlin.contracts.ExperimentalContracts -import kotlin.contracts.contract -import kotlin.reflect.KClass - -private const val MESSAGE = "must be null or not blank" - -@Target(AnnotationTarget.FIELD) -@MustBeDocumented -@Constraint(validatedBy = [NullOrNotBlankValidator::class]) -annotation class NullOrNotBlank( - val message: String = MESSAGE, - val groups: Array> = [], - @Suppress("unused") val payload: Array> = [] -) - -class NullOrNotBlankValidator : ConstraintValidator { - var message = MESSAGE - - override fun initialize(constraintAnnotation: NullOrNotBlank) { - message = constraintAnnotation.message - } - - override fun isValid(value: String?, context: ConstraintValidatorContext): Boolean { - return value.isNullOrNotBlank().apply { - if (!this) context.buildConstraintViolationWithTemplate(message) - } - } -} - -fun String?.isNullOrNotBlank(): Boolean = this == null || isNotBlank() - -/** Checks if the given string [value] is not null and not blank. */ -@ExperimentalContracts -fun isNotNullAndNotBlank(value: String?): Boolean { - contract { returns(true) implies (value != null) } - return value != null && value.isNotBlank() -} - -infix fun String?.or(alternative: String): String = if (isNotNullAndNotBlank(this)) this else alternative diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/validation/NullOrSize.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/validation/NullOrSize.kt deleted file mode 100644 index e6c4208..0000000 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/model/validation/NullOrSize.kt +++ /dev/null @@ -1,46 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.model.validation - -import javax.validation.Constraint -import javax.validation.ConstraintValidator -import javax.validation.ConstraintValidatorContext -import javax.validation.Payload -import kotlin.reflect.KClass - -private const val MIN_SIZE = Long.MIN_VALUE -private const val MAX_SIZE = Long.MAX_VALUE -private const val MESSAGE = "must be null or have a correct length" - -@Target(AnnotationTarget.FIELD) -@MustBeDocumented -@Constraint(validatedBy = [NullOrSizeValidator::class]) -annotation class NullOrSize( - val min: Long = MIN_SIZE, - val max: Long = MAX_SIZE, - val message: String = MESSAGE, - val groups: Array> = [], - @Suppress("unused") val payload: Array> = [] -) - -class NullOrSizeValidator : ConstraintValidator { - var min = MIN_SIZE - var max = MAX_SIZE - var message = MESSAGE - - override fun initialize(constraintAnnotation: NullOrSize) { - min = constraintAnnotation.min - max = constraintAnnotation.max - message = constraintAnnotation.message - } - - override fun isValid(value: Any?, context: ConstraintValidatorContext): Boolean { - if (value == null) return true - return when (value) { - is Number -> value.toLong() in min..max - is String -> value.length in min..max - is Collection<*> -> value.size in min..max - else -> throw IllegalStateException("Cannot use @NullOrSize on type ${value::class}") - }.apply { - if (!this) context.buildConstraintViolationWithTemplate(message) - } - } -} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/AccountRepository.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/AccountRepository.kt index d2548ea..82575bd 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/AccountRepository.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/AccountRepository.kt @@ -3,18 +3,28 @@ package dev.fyloz.colorrecipesexplorer.repository import dev.fyloz.colorrecipesexplorer.model.account.Group import dev.fyloz.colorrecipesexplorer.model.account.User import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository @Repository interface UserRepository : JpaRepository { - fun existsByFirstNameAndLastName(firstName: String, lastName: String): Boolean - - fun findByFirstNameAndLastName(firstName: String, lastName: String): User? + /** Checks if a user with the given [firstName], [lastName] and a different [id] exists. */ + fun existsByFirstNameAndLastNameAndIdNot(firstName: String, lastName: String, id: Long): Boolean + /** Finds all users for the given [group]. */ + @Query("SELECT u FROM User u WHERE u.group = :group AND u.isSystemUser IS FALSE AND u.isDefaultGroupUser IS FALSE") fun findAllByGroup(group: Group): Collection - fun findByIsDefaultGroupUserIsTrueAndGroupIs(group: Group): User + /** Finds the user with the given [firstName] and [lastName]. */ + fun findByFirstNameAndLastName(firstName: String, lastName: String): User? + + /** Finds the default user for the given [group]. */ + @Query("SELECT u FROM User u WHERE u.group = :group AND u.isDefaultGroupUser IS TRUE") + fun findDefaultGroupUser(group: Group): User? } @Repository -interface GroupRepository : NamedJpaRepository +interface GroupRepository : JpaRepository { + /** Checks if a group with the given [name] and a different [id] exists. */ + fun existsByNameAndIdNot(name: String, id: Long): Boolean +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/CompanyRepository.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/CompanyRepository.kt index b0b4142..484bd4d 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/CompanyRepository.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/CompanyRepository.kt @@ -1,18 +1,21 @@ package dev.fyloz.colorrecipesexplorer.repository import dev.fyloz.colorrecipesexplorer.model.Company +import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository @Repository -interface CompanyRepository : NamedJpaRepository { +interface CompanyRepository : JpaRepository { + /** Checks if a company with the given [name] and a different [id] exists. */ + fun existsByNameAndIdNot(name: String, id: Long): Boolean + + /** Checks if a recipe depends on the company with the given [id]. */ @Query( - """ - select case when(count(r.id) > 0) then false else true end - from Company c - left join Recipe r on c.id = r.company.id - where c.id = :id + """ + select case when(count(r) > 0) then true else false end + from Recipe r where r.company.id = :id """ ) - fun canBeDeleted(id: Long): Boolean + fun isUsedByRecipe(id: Long): Boolean } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/MaterialRepository.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/MaterialRepository.kt index 03ed9c3..2cc5ada 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/MaterialRepository.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/MaterialRepository.kt @@ -1,29 +1,33 @@ package dev.fyloz.colorrecipesexplorer.repository import dev.fyloz.colorrecipesexplorer.model.Material -import dev.fyloz.colorrecipesexplorer.model.MaterialType +import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository @Repository -interface MaterialRepository : NamedJpaRepository { - /** Checks if one or more materials have the given [materialType]. */ - fun existsByMaterialType(materialType: MaterialType): Boolean +interface MaterialRepository : JpaRepository { + /** Checks if a material with the given [name] and a different [id] exists. */ + fun existsByNameAndIdNot(name: String, id: Long): Boolean + + /** Gets all non mix type materials. */ + fun findAllByIsMixTypeIsFalse(): Collection /** Updates the [inventoryQuantity] of the [Material] with the given [id]. */ @Modifying @Query("UPDATE Material m SET m.inventoryQuantity = :inventoryQuantity WHERE m.id = :id") fun updateInventoryQuantityById(id: Long, inventoryQuantity: Float) + /** Checks if a mix material or a mix type depends on the material with the given [id]. */ @Query( - """ + """ select case when(count(mm.id) + count(mt.id) > 0) then false else true end from Material m - left join MixMaterial mm on m.id = mm.material.id - left join MixType mt on m.id = mt.material.id + left join MixMaterial mm on mm.material.id = m.id + left join MixType mt on mt.material.id = m.id where m.id = :id - """ + """ ) - fun canBeDeleted(id: Long): Boolean + fun isUsedByMixMaterialOrMixType(id: Long): Boolean } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/MaterialTypeRepository.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/MaterialTypeRepository.kt index a664876..c2d4a22 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/MaterialTypeRepository.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/MaterialTypeRepository.kt @@ -1,27 +1,33 @@ package dev.fyloz.colorrecipesexplorer.repository import dev.fyloz.colorrecipesexplorer.model.MaterialType +import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository @Repository -interface MaterialTypeRepository : NamedJpaRepository { - /** Checks if a material type exists with the given [prefix]. */ - fun existsByPrefix(prefix: String): Boolean +interface MaterialTypeRepository : JpaRepository { + /** Checks if a system material type with the given [id] exists. */ + fun existsByIdAndSystemTypeIs(id: Long, systemType: Boolean): Boolean - /** Gets all material types which are not system types. */ - fun findAllBySystemTypeIs(value: Boolean): Collection + /** Checks if a material type with the given [name] and a different [id] exists. */ + fun existsByNameAndIdNot(name: String, id: Long): Boolean - /** Gets the material type with the given [prefix]. */ - fun findByPrefix(prefix: String): MaterialType? + /** Checks if a material type with the given [prefix] and a different [id] exists. */ + fun existsByPrefixAndIdNot(prefix: String, id: Long): Boolean + /** Find all material types which are or not [systemType]s. */ + fun findAllBySystemTypeIs(systemType: Boolean): Collection + + /** Find the material type with the given [name]. */ + fun findByName(name: String): MaterialType? + + /** Checks if a material depends on the material type with the given [id]. */ @Query( - """ - select case when(count(m.id) > 0) then false else true end - from MaterialType t - left join Material m on t.id = m.materialType.id - where t.id = :id - """ + """ + select case when(count(m) > 0) then true else false end + from Material m where m.materialType.id = :id + """ ) - fun canBeDeleted(id: Long): Boolean + fun isUsedByMaterial(id: Long): Boolean } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/MixMaterialRepository.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/MixMaterialRepository.kt index 6bd9aa9..ff2597f 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/MixMaterialRepository.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/MixMaterialRepository.kt @@ -1,12 +1,12 @@ package dev.fyloz.colorrecipesexplorer.repository -import dev.fyloz.colorrecipesexplorer.model.Material import dev.fyloz.colorrecipesexplorer.model.MixMaterial import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository @Repository interface MixMaterialRepository : JpaRepository { - /** Checks if one or more mix materials have the given [material]. */ - fun existsByMaterial(material: Material): Boolean + /** Checks if one or more mix materials have the given [materialId]. */ + fun existsByMaterialId(materialId: Long): Boolean } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/MixMixTypeRepository.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/MixMixTypeRepository.kt new file mode 100644 index 0000000..63aaf9c --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/MixMixTypeRepository.kt @@ -0,0 +1,34 @@ +package dev.fyloz.colorrecipesexplorer.repository + +import dev.fyloz.colorrecipesexplorer.model.MixMixType +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import org.springframework.stereotype.Repository + +@Repository +interface MixMixTypeRepository : JpaRepository { + @Query( + nativeQuery = true, value = """ + SELECT * FROM mix_mix_type mmt WHERE mmt.mix_id = :mixId + """ + ) + fun findAllByMixId(mixId: Long): List + + @Modifying + @Query( + nativeQuery = true, value = """ + INSERT INTO mix_mix_type (id, mix_type_id, mix_id, quantity, position) + VALUES (:id, :mixTypeId, :mixId, :quantity, :position) + """ + ) + fun saveForMixId(id: Long?, mixTypeId: Long, mixId: Long, quantity: Float, position: Int) + + @Modifying + @Query( + nativeQuery = true, value = """ + DELETE FROM mix_mix_type mmt WHERE mmt.mix_id = :mixId + """ + ) + fun deleteAllByMixId(mixId: Long) +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/MixRepository.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/MixRepository.kt index 51c39a9..cd3288b 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/MixRepository.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/MixRepository.kt @@ -7,21 +7,11 @@ import org.springframework.data.jpa.repository.Modifying import org.springframework.data.jpa.repository.Query interface MixRepository : JpaRepository { - /** Finds all mixes with the given [mixType]. */ - fun findAllByMixType(mixType: MixType): Collection + /** Finds all mixes with the mix type with the given [mixTypeId]. */ + fun findAllByMixTypeId(mixTypeId: Long): Collection /** Updates the [location] of the [Mix] with the given [id]. */ @Modifying @Query("UPDATE Mix m SET m.location = :location WHERE m.id = :id") fun updateLocationById(id: Long, location: String?) - - @Query( - """ - select case when(count(mm.id) > 0) then false else true end - from Mix m - left join MixMaterial mm on m.mixType.material.id = mm.material.id - where m.id = :id - """ - ) - fun canBeDeleted(id: Long): Boolean } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/MixTypeRepository.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/MixTypeRepository.kt index 7298eb6..e0d11d0 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/MixTypeRepository.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/MixTypeRepository.kt @@ -1,30 +1,41 @@ package dev.fyloz.colorrecipesexplorer.repository -import dev.fyloz.colorrecipesexplorer.model.Material -import dev.fyloz.colorrecipesexplorer.model.MaterialType import dev.fyloz.colorrecipesexplorer.model.MixType +import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.jpa.repository.Query import org.springframework.stereotype.Repository @Repository -interface MixTypeRepository : NamedJpaRepository { - @Query("select case when(count(m) > 0) then true else false end from MixType m where m.name = :name and m.material.materialType = :materialType") - fun existsByNameAndMaterialType(name: String, materialType: MaterialType): Boolean - - /** Gets the mix type with the given [material]. */ - fun findByMaterial(material: Material): MixType? - - /** Gets the [MixType] with the given [name] and [materialType]. */ - @Query("select m from MixType m where m.name = :name and m.material.materialType = :materialType") - fun findByNameAndMaterialType(name: String, materialType: MaterialType): MixType? - +interface MixTypeRepository : JpaRepository { + /** Checks if a mix type with the given [name], [materialTypeId] and a different [id] exists. */ @Query( - """ - select case when(count(m.id) > 0) then false else true end - from MixType t - left join Mix m on t.id = m.mixType.id - where t.id = :id + """ + SELECT CASE WHEN(COUNT(mt.id)) > 1 THEN TRUE ELSE FALSE END + FROM MixType mt + WHERE mt.name = :name AND mt.materialType.id = :materialTypeId AND mt.id <> :id """ ) - fun canBeDeleted(id: Long): Boolean + fun existsByNameAndMaterialTypeAndIdNot(name: String, materialTypeId: Long, id: Long): Boolean + + /** Finds the mix type with the given [name] and [materialTypeId]. */ + @Query("SELECT m FROM MixType m WHERE m.name = :name AND m.material.materialType.id = :materialTypeId") + fun findByNameAndMaterialType(name: String, materialTypeId: Long): MixType? + + /** Checks if a mix depends on the mix type with the given [id]. */ + @Query( + """ + select case when(count(m.id) > 0) then false else true end + from Mix m where m.mixType.id = :id + """ + ) + fun isUsedByMixes(id: Long): Boolean + + /** Checks if the mix type with the given [id] is used by more than one mix. */ + @Query( + """ + select case when(count(m.id) > 1) then false else true end + from Mix m where m.mixType.id = :id + """ + ) + fun isShared(id: Long): Boolean } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/RecipeRepository.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/RecipeRepository.kt index 251374d..2fa7ca5 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/RecipeRepository.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/RecipeRepository.kt @@ -1,19 +1,19 @@ package dev.fyloz.colorrecipesexplorer.repository -import dev.fyloz.colorrecipesexplorer.model.Company import dev.fyloz.colorrecipesexplorer.model.Recipe import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Query interface RecipeRepository : JpaRepository { - /** Checks if one or more recipes have the given [company]. */ - fun existsByCompany(company: Company): Boolean - - /** Checks if a recipe exists with the given [name] and [company]. */ - fun existsByNameAndCompany(name: String, company: Company): Boolean + /** Checks if a recipe with the given [name], [companyId] and a different [id] exists. */ + @Query( + """ + SELECT CASE WHEN(COUNT(r) > 0) THEN TRUE ELSE FALSE END + FROM Recipe r WHERE r.name = :name AND r.company.id = :companyId AND r.id <> :id + """ + ) + fun existsByNameAndCompanyAndIdNot(name: String, companyId: Long, id: Long): Boolean /** Gets all recipes with the given [name]. */ fun findAllByName(name: String): Collection - - /** Gets all recipes with the given [company]. */ - fun findAllByCompany(company: Company): Collection } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/Repository.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/Repository.kt deleted file mode 100644 index 59ebe12..0000000 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/Repository.kt +++ /dev/null @@ -1,18 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.repository - -import dev.fyloz.colorrecipesexplorer.model.NamedModel -import org.springframework.data.jpa.repository.JpaRepository -import org.springframework.data.repository.NoRepositoryBean - -/** Adds support for entities using a name identifier. */ -@NoRepositoryBean -interface NamedJpaRepository : JpaRepository { - /** Checks if an entity with the given [name]. */ - fun existsByName(name: String): Boolean - - /** Gets the entity with the given [name]. */ - fun findByName(name: String): E? - - /** Removes the entity with the given [name]. */ - fun deleteByName(name: String) -} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/TouchUpKitRepository.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/TouchUpKitRepository.kt index 0819613..e1ef51c 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/TouchUpKitRepository.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/repository/TouchUpKitRepository.kt @@ -2,5 +2,13 @@ package dev.fyloz.colorrecipesexplorer.repository import dev.fyloz.colorrecipesexplorer.model.touchupkit.TouchUpKit import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.data.jpa.repository.Modifying +import org.springframework.data.jpa.repository.Query +import java.time.LocalDate -interface TouchUpKitRepository : JpaRepository +interface TouchUpKitRepository : JpaRepository { + /** Updates the [completionDate] of the touch up kit with the given [id]. */ + @Modifying + @Query("UPDATE TouchUpKit t SET t.completionDate = :completionDate WHERE t.id = :id") + fun updateCompletionDateById(id: Long, completionDate: LocalDate) +} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/AccountControllers.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/AccountControllers.kt index e316ddf..dd423b4 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/AccountControllers.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/AccountControllers.kt @@ -1,11 +1,15 @@ package dev.fyloz.colorrecipesexplorer.rest +import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeEditUsers import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewUsers -import dev.fyloz.colorrecipesexplorer.model.account.* -import dev.fyloz.colorrecipesexplorer.service.users.GroupService -import dev.fyloz.colorrecipesexplorer.service.users.UserService -import mu.KotlinLogging +import dev.fyloz.colorrecipesexplorer.dtos.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.UserDto +import dev.fyloz.colorrecipesexplorer.dtos.UserSaveDto +import dev.fyloz.colorrecipesexplorer.dtos.UserUpdateDto +import dev.fyloz.colorrecipesexplorer.logic.users.GroupLogic +import dev.fyloz.colorrecipesexplorer.logic.users.UserLogic +import dev.fyloz.colorrecipesexplorer.model.account.Permission import org.springframework.context.annotation.Profile import org.springframework.http.MediaType import org.springframework.security.access.prepost.PreAuthorize @@ -14,44 +18,39 @@ import javax.servlet.http.HttpServletRequest import javax.servlet.http.HttpServletResponse import javax.validation.Valid -private const val USER_CONTROLLER_PATH = "api/user" -private const val GROUP_CONTROLLER_PATH = "api/user/group" - @RestController -@RequestMapping(USER_CONTROLLER_PATH) +@RequestMapping(Constants.ControllerPaths.USER) @Profile("!emergency") -class UserController(private val userService: UserService) { +class UserController(private val userLogic: UserLogic) { @GetMapping @PreAuthorizeViewUsers fun getAll() = - ok(userService.getAllForOutput()) + ok(userLogic.getAll()) @GetMapping("{id}") @PreAuthorizeViewUsers fun getById(@PathVariable id: Long) = - ok(userService.getByIdForOutput(id)) + ok(userLogic.getById(id)) @PostMapping @PreAuthorizeEditUsers fun save(@Valid @RequestBody user: UserSaveDto) = - created(USER_CONTROLLER_PATH) { - with(userService) { - save(user).toOutput() - } + created(Constants.ControllerPaths.USER) { + userLogic.save(user) } @PutMapping @PreAuthorizeEditUsers fun update(@Valid @RequestBody user: UserUpdateDto) = noContent { - userService.update(user) + userLogic.update(user) } @PutMapping("{id}/password", consumes = [MediaType.TEXT_PLAIN_VALUE]) @PreAuthorizeEditUsers fun updatePassword(@PathVariable id: Long, @RequestBody password: String) = noContent { - userService.updatePassword(id, password) + userLogic.updatePassword(id, password) } @PutMapping("{userId}/permissions/{permission}") @@ -60,7 +59,7 @@ class UserController(private val userService: UserService) { @PathVariable userId: Long, @PathVariable permission: Permission ) = noContent { - userService.addPermission(userId, permission) + userLogic.addPermission(userId, permission) } @DeleteMapping("{userId}/permissions/{permission}") @@ -69,92 +68,87 @@ class UserController(private val userService: UserService) { @PathVariable userId: Long, @PathVariable permission: Permission ) = noContent { - userService.removePermission(userId, permission) + userLogic.removePermission(userId, permission) } @DeleteMapping("{id}") @PreAuthorizeEditUsers fun deleteById(@PathVariable id: Long) = - userService.deleteById(id) + userLogic.deleteById(id) } @RestController -@RequestMapping(GROUP_CONTROLLER_PATH) +@RequestMapping(Constants.ControllerPaths.GROUP) @Profile("!emergency") class GroupsController( - private val groupService: GroupService, - private val userService: UserService + private val groupLogic: GroupLogic, + private val userLogic: UserLogic ) { @GetMapping @PreAuthorize("hasAnyAuthority('VIEW_RECIPES', 'VIEW_USERS')") fun getAll() = - ok(groupService.getAllForOutput()) + ok(groupLogic.getAll()) @GetMapping("{id}") @PreAuthorizeViewUsers fun getById(@PathVariable id: Long) = - ok(groupService.getByIdForOutput(id)) + ok(groupLogic.getById(id)) @GetMapping("{id}/users") @PreAuthorizeViewUsers fun getUsersForGroup(@PathVariable id: Long) = - ok(with(userService) { - groupService.getUsersForGroup(id) - .map { it.toOutput() } - }) + ok(groupLogic.getUsersForGroup(id)) @PostMapping("default/{groupId}") @PreAuthorizeViewUsers fun setDefaultGroup(@PathVariable groupId: Long, response: HttpServletResponse) = noContent { - groupService.setResponseDefaultGroup(groupId, response) + groupLogic.setResponseDefaultGroup(groupId, response) } @GetMapping("default") @PreAuthorizeViewUsers fun getRequestDefaultGroup(request: HttpServletRequest) = - ok(with(groupService) { - getRequestDefaultGroup(request).toOutput() + ok(with(groupLogic) { + getRequestDefaultGroup(request) }) @GetMapping("currentuser") fun getCurrentGroupUser(request: HttpServletRequest) = - ok(with(groupService.getRequestDefaultGroup(request)) { - userService.getDefaultGroupUser(this).toOutputDto() + ok(with(groupLogic.getRequestDefaultGroup(request)) { + userLogic.getDefaultGroupUser(this) }) @PostMapping @PreAuthorizeEditUsers - fun save(@Valid @RequestBody group: GroupSaveDto) = - created(GROUP_CONTROLLER_PATH) { - with(groupService) { - save(group).toOutput() - } + fun save(@Valid @RequestBody group: GroupDto) = + created(Constants.ControllerPaths.GROUP) { + groupLogic.save(group) } @PutMapping @PreAuthorizeEditUsers - fun update(@Valid @RequestBody group: GroupUpdateDto) = + fun update(@Valid @RequestBody group: GroupDto) = noContent { - groupService.update(group) + groupLogic.update(group) } @DeleteMapping("{id}") @PreAuthorizeEditUsers fun deleteById(@PathVariable id: Long) = noContent { - groupService.deleteById(id) + groupLogic.deleteById(id) } } @RestController @RequestMapping("api") @Profile("!emergency") -class LogoutController(private val userService: UserService) { +class LogoutController(private val userLogic: UserLogic) { @GetMapping("logout") @PreAuthorize("isFullyAuthenticated()") fun logout(request: HttpServletRequest) = ok { - userService.logout(request) + userLogic.logout(request) } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/CompanyController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/CompanyController.kt index 77704da..bbc7659 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/CompanyController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/CompanyController.kt @@ -1,48 +1,45 @@ package dev.fyloz.colorrecipesexplorer.rest +import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewCatalog -import dev.fyloz.colorrecipesexplorer.model.Company -import dev.fyloz.colorrecipesexplorer.model.CompanySaveDto -import dev.fyloz.colorrecipesexplorer.model.CompanyUpdateDto -import dev.fyloz.colorrecipesexplorer.service.CompanyService -import org.springframework.context.annotation.Profile +import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase +import dev.fyloz.colorrecipesexplorer.dtos.CompanyDto +import dev.fyloz.colorrecipesexplorer.logic.CompanyLogic import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.* import javax.validation.Valid -private const val COMPANY_CONTROLLER_PATH = "api/company" - @RestController -@RequestMapping(COMPANY_CONTROLLER_PATH) -@Profile("!emergency") +@RequestMapping(Constants.ControllerPaths.COMPANY) +@RequireDatabase @PreAuthorizeViewCatalog -class CompanyController(private val companyService: CompanyService) { +class CompanyController(private val companyLogic: CompanyLogic) { @GetMapping fun getAll() = - ok(companyService.getAllForOutput()) + ok(companyLogic.getAll()) @GetMapping("{id}") fun getById(@PathVariable id: Long) = - ok(companyService.getByIdForOutput(id)) + ok(companyLogic.getById(id)) @PostMapping @PreAuthorize("hasAuthority('EDIT_COMPANIES')") - fun save(@Valid @RequestBody company: CompanySaveDto) = - created(COMPANY_CONTROLLER_PATH) { - companyService.save(company) - } + fun save(@Valid @RequestBody company: CompanyDto) = + created(Constants.ControllerPaths.COMPANY) { + companyLogic.save(company) + } @PutMapping @PreAuthorize("hasAuthority('EDIT_COMPANIES')") - fun update(@Valid @RequestBody company: CompanyUpdateDto) = - noContent { - companyService.update(company) - } + fun update(@Valid @RequestBody company: CompanyDto) = + noContent { + companyLogic.update(company) + } @DeleteMapping("{id}") @PreAuthorize("hasAuthority('EDIT_COMPANIES')") fun deleteById(@PathVariable id: Long) = - noContent { - companyService.deleteById(id) - } + noContent { + companyLogic.deleteById(id) + } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/ConfigurationController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/ConfigurationController.kt index db64365..d66b67b 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/ConfigurationController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/ConfigurationController.kt @@ -1,37 +1,35 @@ package dev.fyloz.colorrecipesexplorer.rest +import dev.fyloz.colorrecipesexplorer.logic.config.ConfigurationLogic import dev.fyloz.colorrecipesexplorer.model.ConfigurationBase import dev.fyloz.colorrecipesexplorer.model.ConfigurationDto -import dev.fyloz.colorrecipesexplorer.model.ConfigurationImageDto import dev.fyloz.colorrecipesexplorer.model.account.Permission import dev.fyloz.colorrecipesexplorer.model.account.toAuthority import dev.fyloz.colorrecipesexplorer.restartApplication -import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService import org.springframework.http.MediaType import org.springframework.security.access.prepost.PreAuthorize import org.springframework.security.core.Authentication import org.springframework.web.bind.annotation.* import org.springframework.web.multipart.MultipartFile -import javax.validation.constraints.NotBlank @RestController @RequestMapping("api/config") -class ConfigurationController(val configurationService: ConfigurationService) { +class ConfigurationController(val configurationLogic: ConfigurationLogic) { @GetMapping fun getAll(@RequestParam(required = false) keys: String?, authentication: Authentication?) = - ok(with(configurationService) { + ok(with(configurationLogic) { if (keys != null) getAll(keys) else getAll() }.filter { authentication.hasAuthority(it) }) @GetMapping("{key}") - fun get(@PathVariable key: String, authentication: Authentication?) = with(configurationService.get(key)) { + fun get(@PathVariable key: String, authentication: Authentication?) = with(configurationLogic.get(key)) { if (authentication.hasAuthority(this)) ok(this) else forbidden() } @PutMapping @PreAuthorize("hasAuthority('ADMIN')") fun set(@RequestBody configurations: List) = noContent { - configurationService.set(configurations) + configurationLogic.set(configurations) } @PostMapping("restart") @@ -44,24 +42,24 @@ class ConfigurationController(val configurationService: ConfigurationService) { @GetMapping("icon") fun getIcon() = - okFile(configurationService.getConfiguredIcon(), MediaType.IMAGE_PNG_VALUE) + okFile(configurationLogic.getConfiguredIcon(), MediaType.IMAGE_PNG_VALUE) @PutMapping("icon") @PreAuthorize("hasAuthority('ADMIN')") fun setIcon(@RequestParam icon: MultipartFile) = noContent { - configurationService.setConfiguredIcon(icon) + configurationLogic.setConfiguredIcon(icon) } // Logo @GetMapping("logo") fun getLogo() = - okFile(configurationService.getConfiguredLogo(), MediaType.IMAGE_PNG_VALUE) + okFile(configurationLogic.getConfiguredLogo(), MediaType.IMAGE_PNG_VALUE) @PutMapping("logo") @PreAuthorize("hasAuthority('ADMIN')") fun setLogo(@RequestParam logo: MultipartFile) = noContent { - configurationService.setConfiguredLogo(logo) + configurationLogic.setConfiguredLogo(logo) } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/FileController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/FileController.kt index 92b078a..579bf39 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/FileController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/FileController.kt @@ -1,8 +1,9 @@ package dev.fyloz.colorrecipesexplorer.rest +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.logic.config.ConfigurationLogic +import dev.fyloz.colorrecipesexplorer.logic.files.WriteableFileLogic import dev.fyloz.colorrecipesexplorer.model.ConfigurationType -import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService -import dev.fyloz.colorrecipesexplorer.service.files.WriteableFileService import org.springframework.http.MediaType import org.springframework.http.ResponseEntity import org.springframework.security.access.prepost.PreAuthorize @@ -10,19 +11,17 @@ import org.springframework.web.bind.annotation.* import org.springframework.web.multipart.MultipartFile import java.net.URI -const val FILE_CONTROLLER_PATH = "/api/file" - @RestController -@RequestMapping(FILE_CONTROLLER_PATH) +@RequestMapping(Constants.ControllerPaths.FILE) class FileController( - private val fileService: WriteableFileService, - private val configService: ConfigurationService + private val fileLogic: WriteableFileLogic, + private val configurationLogic: ConfigurationLogic ) { @GetMapping(produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE]) fun upload( @RequestParam path: String, @RequestParam(required = false) mediaType: String? - ) = okFile(fileService.read(path), mediaType) + ) = okFile(fileLogic.read(path), mediaType) @PutMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) @PreAuthorize("hasAnyAuthority('WRITE_FILE')") @@ -31,7 +30,7 @@ class FileController( @RequestParam path: String, @RequestParam(required = false) overwrite: Boolean = false ): ResponseEntity { - fileService.write(file, path, overwrite) + fileLogic.write(file, path, overwrite) return created(path) } @@ -39,11 +38,11 @@ class FileController( @PreAuthorize("hasAnyAuthority('WRITE_FILE')") fun delete(@RequestParam path: String): ResponseEntity = noContent { - fileService.delete(path) + fileLogic.delete(path) } private fun created(path: String): ResponseEntity = ResponseEntity - .created(URI.create("${configService.get(ConfigurationType.INSTANCE_URL)}$FILE_CONTROLLER_PATH?path=$path")) + .created(URI.create("${configurationLogic.get(ConfigurationType.INSTANCE_URL)}${Constants.ControllerPaths.FILE}?path=$path")) .build() } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/InventoryController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/InventoryController.kt index abf6d49..2f5edb6 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/InventoryController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/InventoryController.kt @@ -1,36 +1,34 @@ package dev.fyloz.colorrecipesexplorer.rest -import dev.fyloz.colorrecipesexplorer.model.MaterialQuantityDto -import dev.fyloz.colorrecipesexplorer.model.MixDeductDto -import dev.fyloz.colorrecipesexplorer.service.InventoryService +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.dtos.MaterialQuantityDto +import dev.fyloz.colorrecipesexplorer.dtos.MixDeductDto +import dev.fyloz.colorrecipesexplorer.logic.InventoryLogic import org.springframework.context.annotation.Profile -import org.springframework.http.ResponseEntity import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.PutMapping import org.springframework.web.bind.annotation.RequestBody import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController -private const val INVENTORY_CONTROLLER_PATH = "api/inventory" - @RestController -@RequestMapping(INVENTORY_CONTROLLER_PATH) +@RequestMapping(Constants.ControllerPaths.INVENTORY) @Profile("!emergency") class InventoryController( - private val inventoryService: InventoryService + private val inventoryLogic: InventoryLogic ) { @PutMapping("add") @PreAuthorize("hasAuthority('ADD_TO_INVENTORY')") fun add(@RequestBody quantities: Collection) = - ok(inventoryService.add(quantities)) + ok(inventoryLogic.add(quantities)) @PutMapping("deduct") @PreAuthorize("hasAuthority('DEDUCT_FROM_INVENTORY')") fun deduct(@RequestBody quantities: Collection) = - ok(inventoryService.deduct(quantities)) + ok(inventoryLogic.deduct(quantities)) @PutMapping("deduct/mix") @PreAuthorize("hasAuthority('DEDUCT_FROM_INVENTORY')") fun deduct(@RequestBody mixRatio: MixDeductDto) = - ok(inventoryService.deductMix(mixRatio)) + ok(inventoryLogic.deductMix(mixRatio)) } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialController.kt index e5d13f9..882ab25 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialController.kt @@ -1,83 +1,59 @@ package dev.fyloz.colorrecipesexplorer.rest +import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewCatalog -import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties -import dev.fyloz.colorrecipesexplorer.model.* -import dev.fyloz.colorrecipesexplorer.service.MaterialService +import dev.fyloz.colorrecipesexplorer.dtos.MaterialDto +import dev.fyloz.colorrecipesexplorer.dtos.MaterialSaveDto +import dev.fyloz.colorrecipesexplorer.logic.MaterialLogic import org.springframework.context.annotation.Profile import org.springframework.http.MediaType -import org.springframework.http.ResponseEntity import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.* import org.springframework.web.multipart.MultipartFile -import java.net.URI import javax.validation.Valid -private const val MATERIAL_CONTROLLER_PATH = "api/material" - @RestController -@RequestMapping(MATERIAL_CONTROLLER_PATH) +@RequestMapping(Constants.ControllerPaths.MATERIAL) @Profile("!emergency") @PreAuthorizeViewCatalog class MaterialController( - private val materialService: MaterialService + private val materialLogic: MaterialLogic ) { @GetMapping fun getAll() = - ok(materialService.getAllForOutput()) - - @GetMapping("notmixtype") - fun getAllNotMixType() = - ok(materialService.getAllNotMixType()) + ok(materialLogic.getAll()) @GetMapping("{id}") fun getById(@PathVariable id: Long) = - ok(materialService.getByIdForOutput(id)) + ok(materialLogic.getById(id)) @PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) @PreAuthorize("hasAuthority('EDIT_MATERIALS')") fun save(@Valid material: MaterialSaveDto, simdutFile: MultipartFile?) = - created(MATERIAL_CONTROLLER_PATH) { - with(materialService) { - save( - materialSaveDto( - name = material.name, - inventoryQuantity = material.inventoryQuantity, - materialTypeId = material.materialTypeId, - simdutFile = simdutFile - ) - ).toOutput() - } + created(Constants.ControllerPaths.MATERIAL) { + materialLogic.save(material.copy(simdutFile = simdutFile)) } @PutMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) @PreAuthorize("hasAuthority('EDIT_MATERIALS')") - fun update(@Valid material: MaterialUpdateDto, simdutFile: MultipartFile?) = + fun update(@Valid material: MaterialSaveDto, simdutFile: MultipartFile?) = noContent { - materialService.update( - materialUpdateDto( - id = material.id, - name = material.name, - inventoryQuantity = material.inventoryQuantity, - materialTypeId = material.materialTypeId, - simdutFile = simdutFile - ) - ) + materialLogic.update(material.copy(simdutFile = simdutFile)) } @DeleteMapping("{id}") @PreAuthorize("hasAuthority('EDIT_MATERIALS')") fun deleteById(@PathVariable id: Long) = noContent { - materialService.deleteById(id) + materialLogic.deleteById(id) } @GetMapping("mix/create/{recipeId}") fun getAllForMixCreation(@PathVariable recipeId: Long) = - ok(materialService.getAllForMixCreation(recipeId)) + ok(materialLogic.getAllForRecipe(recipeId)) @GetMapping("mix/update/{mixId}") fun getAllForMixUpdate(@PathVariable mixId: Long) = - ok(materialService.getAllForMixUpdate(mixId)) + ok(materialLogic.getAllForMix(mixId)) } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialTypeController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialTypeController.kt index a8ff9bd..4e40cf6 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialTypeController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MaterialTypeController.kt @@ -1,49 +1,46 @@ package dev.fyloz.colorrecipesexplorer.rest +import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewCatalog -import dev.fyloz.colorrecipesexplorer.model.MaterialType -import dev.fyloz.colorrecipesexplorer.model.MaterialTypeSaveDto -import dev.fyloz.colorrecipesexplorer.model.MaterialTypeUpdateDto -import dev.fyloz.colorrecipesexplorer.service.MaterialTypeService +import dev.fyloz.colorrecipesexplorer.dtos.MaterialTypeDto +import dev.fyloz.colorrecipesexplorer.logic.MaterialTypeLogic import org.springframework.context.annotation.Profile import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.* import javax.validation.Valid -private const val MATERIAL_TYPE_CONTROLLER_PATH = "api/materialtype" - @RestController -@RequestMapping(MATERIAL_TYPE_CONTROLLER_PATH) +@RequestMapping(Constants.ControllerPaths.MATERIAL_TYPE) @Profile("!emergency") @PreAuthorizeViewCatalog -class MaterialTypeController(private val materialTypeService: MaterialTypeService) { +class MaterialTypeController(private val materialTypeLogic: MaterialTypeLogic) { @GetMapping fun getAll() = - ok(materialTypeService.getAllForOutput()) + ok(materialTypeLogic.getAll()) @GetMapping("{id}") fun getById(@PathVariable id: Long) = - ok(materialTypeService.getByIdForOutput(id)) + ok(materialTypeLogic.getById(id)) @PostMapping @PreAuthorize("hasAuthority('EDIT_MATERIAL_TYPES')") - fun save(@Valid @RequestBody materialType: MaterialTypeSaveDto) = - created(MATERIAL_TYPE_CONTROLLER_PATH) { - materialTypeService.save(materialType) - } + fun save(@Valid @RequestBody materialType: MaterialTypeDto) = + created(Constants.ControllerPaths.MATERIAL_TYPE) { + materialTypeLogic.save(materialType) + } @PutMapping @PreAuthorize("hasAuthority('EDIT_MATERIAL_TYPES')") - fun update(@Valid @RequestBody materialType: MaterialTypeUpdateDto) = - noContent { - materialTypeService.update(materialType) - } + fun update(@Valid @RequestBody materialType: MaterialTypeDto) = + noContent { + materialTypeLogic.updateNonSystemType(materialType) + } @DeleteMapping("{id}") @PreAuthorize("hasAuthority('EDIT_MATERIAL_TYPES')") fun deleteById(@PathVariable id: Long) = - noContent { - materialTypeService.deleteById(id) - } + noContent { + materialTypeLogic.deleteById(id) + } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MixController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MixController.kt new file mode 100644 index 0000000..7392f80 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/MixController.kt @@ -0,0 +1,42 @@ +package dev.fyloz.colorrecipesexplorer.rest + +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeEditRecipes +import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewRecipes +import dev.fyloz.colorrecipesexplorer.dtos.MixDto +import dev.fyloz.colorrecipesexplorer.dtos.MixSaveDto +import dev.fyloz.colorrecipesexplorer.logic.MixLogic +import org.springframework.context.annotation.Profile +import org.springframework.web.bind.annotation.* +import javax.validation.Valid + +@RestController +@RequestMapping(Constants.ControllerPaths.MIX) +@Profile("!emergency") +@PreAuthorizeViewRecipes +class MixController(private val mixLogic: MixLogic) { + @GetMapping("{id}") + fun getById(@PathVariable id: Long) = + ok(mixLogic.getById(id)) + + @PostMapping + @PreAuthorizeEditRecipes + fun save(@Valid @RequestBody mix: MixSaveDto) = + created(Constants.ControllerPaths.MIX) { + mixLogic.save(mix) + } + + @PutMapping + @PreAuthorizeEditRecipes + fun update(@Valid @RequestBody mix: MixSaveDto) = + noContent { + mixLogic.update(mix) + } + + @DeleteMapping("{id}") + @PreAuthorizeEditRecipes + fun deleteById(@PathVariable id: Long) = + noContent { + mixLogic.deleteById(id) + } +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RecipeController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RecipeController.kt index 9d601ba..b1c67b2 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RecipeController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RecipeController.kt @@ -1,116 +1,77 @@ package dev.fyloz.colorrecipesexplorer.rest +import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeEditRecipes import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewRecipes -import dev.fyloz.colorrecipesexplorer.model.* -import dev.fyloz.colorrecipesexplorer.service.MixService -import dev.fyloz.colorrecipesexplorer.service.RecipeImageService -import dev.fyloz.colorrecipesexplorer.service.RecipeService +import dev.fyloz.colorrecipesexplorer.dtos.RecipeDto +import dev.fyloz.colorrecipesexplorer.dtos.RecipePublicDataDto +import dev.fyloz.colorrecipesexplorer.dtos.RecipeSaveDto +import dev.fyloz.colorrecipesexplorer.dtos.RecipeUpdateDto +import dev.fyloz.colorrecipesexplorer.logic.RecipeImageLogic +import dev.fyloz.colorrecipesexplorer.logic.RecipeLogic import org.springframework.context.annotation.Profile import org.springframework.http.MediaType -import org.springframework.http.ResponseEntity import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.* import org.springframework.web.multipart.MultipartFile import javax.validation.Valid - -private const val RECIPE_CONTROLLER_PATH = "api/recipe" -private const val MIX_CONTROLLER_PATH = "api/recipe/mix" - @RestController -@RequestMapping(RECIPE_CONTROLLER_PATH) +@RequestMapping(Constants.ControllerPaths.RECIPE) @Profile("!emergency") @PreAuthorizeViewRecipes -class RecipeController( - private val recipeService: RecipeService, - private val recipeImageService: RecipeImageService -) { +class RecipeController(private val recipeLogic: RecipeLogic, private val recipeImageLogic: RecipeImageLogic) { @GetMapping - fun getAll(@RequestParam(required = false) name: String?) = - if (name == null) - ok(recipeService.getAllForOutput()) - else - ok(with(recipeService) { - getAllByName(name).map { it.toOutput() } - }) + fun getAll(@RequestParam(required = false) name: String?) = ok( + if (name == null) { + recipeLogic.getAll() + } else { + recipeLogic.getAllByName(name) + } + ) @GetMapping("{id}") - fun getById(@PathVariable id: Long) = - ok(recipeService.getByIdForOutput(id)) + fun getById(@PathVariable id: Long) = ok(recipeLogic.getById(id)) @PostMapping @PreAuthorizeEditRecipes fun save(@Valid @RequestBody recipe: RecipeSaveDto) = - created(RECIPE_CONTROLLER_PATH) { - with(recipeService) { - save(recipe).toOutput() - } + created(Constants.ControllerPaths.RECIPE) { + recipeLogic.save(recipe) } @PutMapping @PreAuthorizeEditRecipes - fun update(@Valid @RequestBody recipe: RecipeUpdateDto) = - noContent { - recipeService.update(recipe) - } + fun update(@Valid @RequestBody recipe: RecipeUpdateDto) = noContent { + recipeLogic.update(recipe) + } @PutMapping("public") @PreAuthorize("hasAuthority('EDIT_RECIPES_PUBLIC_DATA')") - fun updatePublicData(@Valid @RequestBody publicDataDto: RecipePublicDataDto) = - noContent { - recipeService.updatePublicData(publicDataDto) - } + fun updatePublicData(@Valid @RequestBody publicDataDto: RecipePublicDataDto) = noContent { + recipeLogic.updatePublicData(publicDataDto) + } @DeleteMapping("{id}") @PreAuthorizeEditRecipes - fun deleteById(@PathVariable id: Long) = - noContent { - recipeService.deleteById(id) - } + fun deleteById(@PathVariable id: Long) = noContent { + recipeLogic.deleteById(id) + } + + @GetMapping("{recipeId}/image") + fun getAllImages(@PathVariable recipeId: Long) = + ok(recipeImageLogic.getAllImages(recipeId)) @PutMapping("{recipeId}/image", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE]) @PreAuthorizeEditRecipes - fun downloadImage(@PathVariable recipeId: Long, image: MultipartFile): ResponseEntity { - recipeImageService.download(image, recipeService.getById(recipeId)) - return getById(recipeId) + fun downloadImage(@PathVariable recipeId: Long, image: MultipartFile) = + fileCreated("images/recipes/$recipeId") { + recipeImageLogic.download(image, recipeId) + } + + @DeleteMapping("{recipeId}/image/{id}") + @PreAuthorizeEditRecipes + fun deleteImage(@PathVariable recipeId: Long, @PathVariable id: String) = noContent { + recipeImageLogic.delete(recipeId, id) } - - @DeleteMapping("{recipeId}/image/{name}") - @PreAuthorizeEditRecipes - fun deleteImage(@PathVariable recipeId: Long, @PathVariable name: String) = - noContent { - recipeImageService.delete(recipeService.getById(recipeId), name) - } -} - -@RestController -@RequestMapping(MIX_CONTROLLER_PATH) -@Profile("!emergency") -@PreAuthorizeViewRecipes -class MixController(private val mixService: MixService) { - @GetMapping("{id}") - fun getById(@PathVariable id: Long) = - ok(mixService.getByIdForOutput(id)) - - @PostMapping - @PreAuthorizeEditRecipes - fun save(@Valid @RequestBody mix: MixSaveDto) = - created(MIX_CONTROLLER_PATH) { - mixService.save(mix) - } - - @PutMapping - @PreAuthorizeEditRecipes - fun update(@Valid @RequestBody mix: MixUpdateDto) = - noContent { - mixService.update(mix) - } - - @DeleteMapping("{id}") - @PreAuthorizeEditRecipes - fun deleteById(@PathVariable id: Long) = - noContent { - mixService.deleteById(id) - } } diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RestUtils.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RestUtils.kt index 7147aa0..0ffcfe8 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RestUtils.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/RestUtils.kt @@ -1,7 +1,9 @@ package dev.fyloz.colorrecipesexplorer.rest +import dev.fyloz.colorrecipesexplorer.Constants import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties -import dev.fyloz.colorrecipesexplorer.model.Model +import dev.fyloz.colorrecipesexplorer.dtos.EntityDto +import dev.fyloz.colorrecipesexplorer.model.ModelEntity import org.springframework.core.io.Resource import org.springframework.http.HttpHeaders import org.springframework.http.HttpStatus @@ -34,12 +36,22 @@ fun okFile(file: Resource, mediaType: String? = null): ResponseEntity .contentType(MediaType.parseMediaType(mediaType ?: DEFAULT_MEDIA_TYPE)) .body(file) +/** Creates a HTTP CREATED [ResponseEntity] for the file created by the given [producer]. */ +fun fileCreated(basePath: String, producer: () -> String): ResponseEntity { + val fileName = producer() + val path = "${Constants.ControllerPaths.FILE}?path=$basePath/$fileName" + + return ResponseEntity.created(URI.create(path)).body(fileName) +} + /** Creates a HTTP CREATED [ResponseEntity] from the given [body] with the location set to [controllerPath]/id. */ -fun created(controllerPath: String, body: T): ResponseEntity = - created(controllerPath, body, body.id!!) +@JvmName("createdDto") +fun created(controllerPath: String, body: T): ResponseEntity = + created(controllerPath, body, body.id) /** Creates a HTTP CREATED [ResponseEntity] with the result of the given [producer] as its body. */ -fun created(controllerPath: String, producer: () -> T): ResponseEntity = +@JvmName("createdDto") +fun created(controllerPath: String, producer: () -> T): ResponseEntity = created(controllerPath, producer()) /** Creates a HTTP CREATED [ResponseEntity] from the given [body] with the location set to [controllerPath]/id. */ diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/TouchUpKitController.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/TouchUpKitController.kt index 027d71d..d52e958 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/TouchUpKitController.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/rest/TouchUpKitController.kt @@ -1,9 +1,8 @@ package dev.fyloz.colorrecipesexplorer.rest -import dev.fyloz.colorrecipesexplorer.model.touchupkit.TouchUpKitOutputDto -import dev.fyloz.colorrecipesexplorer.model.touchupkit.TouchUpKitSaveDto -import dev.fyloz.colorrecipesexplorer.model.touchupkit.TouchUpKitUpdateDto -import dev.fyloz.colorrecipesexplorer.service.TouchUpKitService +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.dtos.TouchUpKitDto +import dev.fyloz.colorrecipesexplorer.logic.TouchUpKitLogic import org.springframework.context.annotation.Profile import org.springframework.core.io.Resource import org.springframework.http.MediaType @@ -12,53 +11,47 @@ import org.springframework.security.access.prepost.PreAuthorize import org.springframework.web.bind.annotation.* import javax.validation.Valid -const val TOUCH_UP_KIT_CONTROLLER_PATH = "/api/touchupkit" - @RestController -@RequestMapping(TOUCH_UP_KIT_CONTROLLER_PATH) +@RequestMapping(Constants.ControllerPaths.TOUCH_UP_KIT) @Profile("!emergency") @PreAuthorize("hasAuthority('VIEW_TOUCH_UP_KITS')") class TouchUpKitController( - private val touchUpKitService: TouchUpKitService + private val touchUpKitLogic: TouchUpKitLogic ) { @GetMapping - fun getAll() = - ok(touchUpKitService.getAllForOutput()) + fun getAll() = ok(touchUpKitLogic.getAll()) @GetMapping("{id}") - fun getById(@PathVariable id: Long) = - ok(touchUpKitService.getByIdForOutput(id)) + fun getById(@PathVariable id: Long) = ok(touchUpKitLogic.getById(id)) @PostMapping @PreAuthorize("hasAuthority('EDIT_TOUCH_UP_KITS')") - fun save(@Valid @RequestBody touchUpKit: TouchUpKitSaveDto) = - created(TOUCH_UP_KIT_CONTROLLER_PATH) { - with(touchUpKitService) { - save(touchUpKit).toOutput() - } + fun save(@Valid @RequestBody touchUpKit: TouchUpKitDto) = + created(Constants.ControllerPaths.TOUCH_UP_KIT) { + touchUpKitLogic.save(touchUpKit) } @PutMapping @PreAuthorize("hasAuthority('EDIT_TOUCH_UP_KITS')") - fun update(@Valid @RequestBody touchUpKit: TouchUpKitUpdateDto) = noContent { - touchUpKitService.update(touchUpKit) + fun update(@Valid @RequestBody touchUpKit: TouchUpKitDto) = noContent { + touchUpKitLogic.update(touchUpKit) } @PutMapping("{id}/complete") @PreAuthorize("hasAuthority('EDIT_TOUCH_UP_KITS')") fun complete(@PathVariable id: Long) = noContent { - touchUpKitService.complete(id) + touchUpKitLogic.complete(id) } @DeleteMapping("{id}") @PreAuthorize("hasAuthority('EDIT_TOUCH_UP_KITS')") fun deleteById(@PathVariable id: Long) = noContent { - touchUpKitService.deleteById(id) + touchUpKitLogic.deleteById(id) } @GetMapping("pdf") fun getJobPdf(@RequestParam project: String): ResponseEntity { - with(touchUpKitService.generateJobPdfResource(project)) { + with(touchUpKitLogic.generateJobPdfResource(project)) { return ResponseEntity.ok() .header("Content-Disposition", "filename=TouchUpKit_$project.pdf") .contentLength(this.contentLength()) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/CompanyService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/CompanyService.kt index 3e8e0a9..5004973 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/CompanyService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/CompanyService.kt @@ -1,50 +1,27 @@ package dev.fyloz.colorrecipesexplorer.service -import dev.fyloz.colorrecipesexplorer.model.* +import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent +import dev.fyloz.colorrecipesexplorer.dtos.CompanyDto +import dev.fyloz.colorrecipesexplorer.model.Company import dev.fyloz.colorrecipesexplorer.repository.CompanyRepository -import org.springframework.context.annotation.Lazy -import org.springframework.context.annotation.Profile -import org.springframework.stereotype.Service -interface CompanyService : - ExternalNamedModelService { - /** Checks if the given [company] is used by one or more recipes. */ - fun isLinkedToRecipes(company: Company): Boolean +interface CompanyService : Service { + /** Checks if a company with the given [name] exists. */ + fun existsByName(name: String, id: Long?): Boolean + + /** Checks if a recipe depends on the company with the given [id]. */ + fun isUsedByRecipe(id: Long): Boolean } -@Service -@Profile("!emergency") -class CompanyServiceImpl( - companyRepository: CompanyRepository, - @Lazy val recipeService: RecipeService -) : - AbstractExternalNamedModelService( - companyRepository - ), - CompanyService { - override fun idNotFoundException(id: Long) = companyIdNotFoundException(id) - override fun idAlreadyExistsException(id: Long) = companyIdAlreadyExistsException(id) - override fun nameNotFoundException(name: String) = companyNameNotFoundException(name) - override fun nameAlreadyExistsException(name: String) = companyNameAlreadyExistsException(name) +@ServiceComponent +class DefaultCompanyService(repository: CompanyRepository) : + BaseService(repository), CompanyService { + override fun existsByName(name: String, id: Long?) = repository.existsByNameAndIdNot(name, id ?: 0) + override fun isUsedByRecipe(id: Long) = repository.isUsedByRecipe(id) - override fun Company.toOutput() = this + override fun toDto(entity: Company) = + CompanyDto(entity.id, entity.name) - override fun isLinkedToRecipes(company: Company): Boolean = recipeService.existsByCompany(company) - - override fun update(entity: CompanyUpdateDto): Company { - // Lazy loaded to prevent checking the database when not necessary - val persistedCompany by lazy { getById(entity.id) } - - return update(with(entity) { - company( - id = id, - name = if (name != null && name.isNotBlank()) name else persistedCompany.name - ) - }) - } - - override fun delete(entity: Company) { - if (!repository.canBeDeleted(entity.id!!)) throw cannotDeleteCompany(entity) - super.delete(entity) - } -} + override fun toEntity(dto: CompanyDto) = + Company(dto.id, dto.name) +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/GroupService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/GroupService.kt new file mode 100644 index 0000000..1b13ced --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/GroupService.kt @@ -0,0 +1,31 @@ +package dev.fyloz.colorrecipesexplorer.service + +import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent +import dev.fyloz.colorrecipesexplorer.dtos.GroupDto +import dev.fyloz.colorrecipesexplorer.model.account.Group +import dev.fyloz.colorrecipesexplorer.model.account.Permission +import dev.fyloz.colorrecipesexplorer.model.account.flat +import dev.fyloz.colorrecipesexplorer.repository.GroupRepository + +interface GroupService : Service { + /** Checks if a group with the given [name] and a different [id] exists. */ + fun existsByName(name: String, id: Long? = null): Boolean + + /** Flatten the given the permissions of the given [group]. */ + fun flattenPermissions(group: Group): List +} + +@ServiceComponent +class DefaultGroupService(repository: GroupRepository) : BaseService(repository), + GroupService { + override fun existsByName(name: String, id: Long?) = repository.existsByNameAndIdNot(name, id ?: 0L) + + override fun toDto(entity: Group) = + GroupDto(entity.id, entity.name, flattenPermissions(entity), entity.permissions) + + override fun toEntity(dto: GroupDto) = + Group(dto.id, dto.name, dto.permissions) + + override fun flattenPermissions(group: Group) = + group.permissions.flatMap { it.flat() }.filter { !it.deprecated } +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/InventoryService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/InventoryService.kt deleted file mode 100644 index 77f10cb..0000000 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/InventoryService.kt +++ /dev/null @@ -1,115 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.service - -import dev.fyloz.colorrecipesexplorer.exception.RestException -import dev.fyloz.colorrecipesexplorer.model.* -import dev.fyloz.colorrecipesexplorer.utils.mapMayThrow -import org.springframework.context.annotation.Profile -import org.springframework.http.HttpStatus -import org.springframework.stereotype.Service -import javax.transaction.Transactional - -interface InventoryService { - /** Adds each given [MaterialQuantityDto] to the inventory and returns the updated quantities. */ - fun add(materialQuantities: Collection): Collection - - /** Adds a given quantity to the given [Material]'s inventory quantity according to the given [materialQuantity] and returns the updated quantity. */ - fun add(materialQuantity: MaterialQuantityDto): Float - - /** Deducts the inventory quantity of each [Material]s in the mix according to the ratio defined in the given [mixRatio] and returns the updated quantities. */ - fun deductMix(mixRatio: MixDeductDto): Collection - - /** Deducts the inventory quantity of each given [MaterialQuantityDto] and returns the updated quantities. */ - fun deduct(materialQuantities: Collection): Collection - - /** Deducts the inventory quantity of a given [Material] by a given quantity according to the given [materialQuantity] and returns the updated quantity. */ - fun deduct(materialQuantity: MaterialQuantityDto): Float -} - -@Service -@Profile("!emergency") -class InventoryServiceImpl( - private val materialService: MaterialService, - private val mixService: MixService -) : InventoryService { - @Transactional - override fun add(materialQuantities: Collection) = - materialQuantities.map { - materialQuantityDto(materialId = it.material, quantity = add(it)) - } - - override fun add(materialQuantity: MaterialQuantityDto) = - materialService.updateQuantity( - materialService.getById(materialQuantity.material), - materialQuantity.quantity - ) - - @Transactional - override fun deductMix(mixRatio: MixDeductDto): Collection { - val mix = mixService.getById(mixRatio.id) - val firstMixMaterial = mix.mixMaterials.first() - val adjustedFirstMaterialQuantity = firstMixMaterial.quantity * mixRatio.ratio - - fun adjustQuantity(mixMaterial: MixMaterial): Float = - if (!mixMaterial.material.materialType!!.usePercentages) - mixMaterial.quantity * mixRatio.ratio - else - (mixMaterial.quantity * adjustedFirstMaterialQuantity) / 100f - - return deduct(mix.mixMaterials.map { - materialQuantityDto( - materialId = it.material.id!!, - quantity = adjustQuantity(it) - ) - }) - } - - @Transactional - override fun deduct(materialQuantities: Collection): Collection { - val thrown = mutableListOf() - val updatedQuantities = - materialQuantities.mapMayThrow( - { thrown.add(it) } - ) { - materialQuantityDto(materialId = it.material, quantity = deduct(it)) - } - - if (thrown.isNotEmpty()) { - throw MultiplesNotEnoughInventoryException(thrown) - } - return updatedQuantities - } - - override fun deduct(materialQuantity: MaterialQuantityDto): Float = - with(materialService.getById(materialQuantity.material)) { - if (this.inventoryQuantity >= materialQuantity.quantity) { - materialService.updateQuantity(this, -materialQuantity.quantity) - } else { - throw NotEnoughInventoryException(materialQuantity.quantity, this) - } - } -} - -class NotEnoughInventoryException(quantity: Float, material: Material) : - RestException( - "notenoughinventory", - "Not enough inventory", - HttpStatus.BAD_REQUEST, - "Cannot deduct ${quantity}mL of ${material.name} because there is only ${material.inventoryQuantity}mL in inventory", - mapOf( - "material" to material.name, - "materialId" to material.id.toString(), - "requestQuantity" to quantity, - "availableQuantity" to material.inventoryQuantity - ) - ) - -class MultiplesNotEnoughInventoryException(exceptions: List) : - RestException( - "notenoughinventory-multiple", - "Not enough inventory", - HttpStatus.BAD_REQUEST, - "Cannot deduct requested quantities because there is no enough of them in inventory", - mapOf( - "lowQuantities" to exceptions.map { it.extensions } - ) - ) diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt index 327d6e2..1806da7 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialService.kt @@ -1,145 +1,51 @@ package dev.fyloz.colorrecipesexplorer.service -import dev.fyloz.colorrecipesexplorer.model.* +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent +import dev.fyloz.colorrecipesexplorer.dtos.MaterialDto +import dev.fyloz.colorrecipesexplorer.logic.files.FileLogic +import dev.fyloz.colorrecipesexplorer.model.Material import dev.fyloz.colorrecipesexplorer.repository.MaterialRepository -import dev.fyloz.colorrecipesexplorer.rest.FILE_CONTROLLER_PATH -import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService -import dev.fyloz.colorrecipesexplorer.service.files.WriteableFileService -import io.jsonwebtoken.lang.Assert -import org.springframework.context.annotation.Lazy -import org.springframework.context.annotation.Profile -import org.springframework.stereotype.Service -import java.net.URLEncoder -import java.nio.charset.StandardCharsets +import org.springframework.beans.factory.annotation.Qualifier -interface MaterialService : - ExternalNamedModelService { - /** Checks if a material with the given [materialType] exists. */ - fun existsByMaterialType(materialType: MaterialType): Boolean +interface MaterialService : Service { + /** Checks if a material with the given [name] and a different [id] exists. */ + fun existsByName(name: String, id: Long?): Boolean - /** Checks if the given [material] has a SIMDUT file. */ - fun hasSimdut(material: Material): Boolean + /** Updates the [inventoryQuantity] of the [Material] with the given [id]. */ + fun updateInventoryQuantityById(id: Long, inventoryQuantity: Float) - /** Gets all materials that are not a mix type. */ - fun getAllNotMixType(): Collection - - /** Gets all materials available for the creation of a mix for the recipe with the given [recipeId], including normal materials and materials from [MixType]s included in the said recipe. */ - fun getAllForMixCreation(recipeId: Long): Collection - - /** Gets all materials available for updating the mix with the given [mixId], including normal materials and materials from [MixType]s included in the mix recipe, excluding the material of the [MixType] of the said mix. */ - fun getAllForMixUpdate(mixId: Long): Collection - - /** Updates the quantity of the given [material] with the given [factor] and returns the updated quantity. */ - fun updateQuantity(material: Material, factor: Float): Float + /** Checks if a mix material or a mix type depends on the material with the given [id]. */ + fun isUsedByMixMaterialOrMixType(id: Long): Boolean } -@Service -@Profile("!emergency") -class MaterialServiceImpl( - materialRepository: MaterialRepository, - val recipeService: RecipeService, - val mixService: MixService, - @Lazy val materialTypeService: MaterialTypeService, - val fileService: WriteableFileService, - val configService: ConfigurationService +@ServiceComponent +class DefaultMaterialService( + repository: MaterialRepository, + private val materialTypeService: MaterialTypeService, + @Qualifier("defaultFileLogic") val fileLogic: FileLogic ) : - AbstractExternalNamedModelService( - materialRepository - ), - MaterialService { - override fun idNotFoundException(id: Long) = materialIdNotFoundException(id) - override fun idAlreadyExistsException(id: Long) = materialIdAlreadyExistsException(id) - override fun nameNotFoundException(name: String) = materialNameNotFoundException(name) - override fun nameAlreadyExistsException(name: String) = materialNameAlreadyExistsException(name) + BaseService(repository), MaterialService { + override fun existsByName(name: String, id: Long?) = repository.existsByNameAndIdNot(name, id ?: 0) + override fun getAll() = repository.findAllByIsMixTypeIsFalse().map(::toDto) + override fun updateInventoryQuantityById(id: Long, inventoryQuantity: Float) = + repository.updateInventoryQuantityById(id, inventoryQuantity) - override fun Material.toOutput(): MaterialOutputDto = - MaterialOutputDto( - id = this.id!!, - name = this.name, - inventoryQuantity = this.inventoryQuantity, - isMixType = this.isMixType, - materialType = this.materialType!!, - simdutUrl = if (fileService.exists(this.simdutFilePath)) - "${configService.getContent(ConfigurationType.INSTANCE_URL)}$FILE_CONTROLLER_PATH?path=${ - URLEncoder.encode( - this.simdutFilePath, - StandardCharsets.UTF_8 - ) - }" - else null + override fun isUsedByMixMaterialOrMixType(id: Long) = repository.isUsedByMixMaterialOrMixType(id) + + override fun toDto(entity: Material) = + MaterialDto( + entity.id, + entity.name, + entity.inventoryQuantity, + entity.isMixType, + materialTypeService.toDto(entity.materialType!!), + hasSimdut(entity) ) - override fun existsByMaterialType(materialType: MaterialType): Boolean = - repository.existsByMaterialType(materialType) + override fun toEntity(dto: MaterialDto) = + Material(dto.id, dto.name, dto.inventoryQuantity, dto.isMixType, materialTypeService.toEntity(dto.materialType)) - override fun hasSimdut(material: Material): Boolean = fileService.exists(material.simdutFilePath) - override fun getAllNotMixType(): Collection = getAllForOutput().filter { !it.isMixType } - - override fun save(entity: MaterialSaveDto): Material = - save(with(entity) { - material( - name = entity.name, - inventoryQuantity = entity.inventoryQuantity, - materialType = materialTypeService.getById(materialTypeId), - isMixType = false - ) - }).apply { - if (entity.simdutFile != null && !entity.simdutFile.isEmpty) fileService.write( - entity.simdutFile, - this.simdutFilePath, - false - ) - } - - override fun update(entity: MaterialUpdateDto): Material { - val persistedMaterial by lazy { - getById(entity.id).apply { assertPersistedMaterial(this) } - } - - return update(with(entity) { - material( - id = id, - name = if (name != null && name.isNotBlank()) name else persistedMaterial.name, - inventoryQuantity = if (inventoryQuantity != null && inventoryQuantity != Float.MIN_VALUE) inventoryQuantity else persistedMaterial.inventoryQuantity, - isMixType = persistedMaterial.isMixType, - materialType = if (materialTypeId != null) materialTypeService.getById(materialTypeId) else persistedMaterial.materialType - ) - }).apply { - if (entity.simdutFile != null && !entity.simdutFile.isEmpty) fileService.write( - entity.simdutFile, - this.simdutFilePath, - true - ) - } - } - - override fun updateQuantity(material: Material, factor: Float) = with(material) { - val updatedQuantity = this.inventoryQuantity + factor - repository.updateInventoryQuantityById(this.id!!, updatedQuantity) - updatedQuantity - } - - override fun getAllForMixCreation(recipeId: Long): Collection { - val recipesMixTypes = recipeService.getById(recipeId).mixTypes - return getAllForOutput() - .filter { !it.isMixType || recipesMixTypes.any { mixType -> mixType.material.id == it.id } } - } - - override fun getAllForMixUpdate(mixId: Long): Collection { - val mix = mixService.getById(mixId) - val recipesMixTypes = mix.recipe.mixTypes - return getAllForOutput() - .filter { !it.isMixType || recipesMixTypes.any { mixType -> mixType.material.id == it.id } } - .filter { it.id != mix.mixType.material.id } - } - - private fun assertPersistedMaterial(material: Material) { - Assert.notNull(material.name, "The persisted material with the id ${material.id} has a null name") - } - - override fun delete(entity: Material) { - if (!repository.canBeDeleted(entity.id!!)) throw cannotDeleteMaterialException(entity) - if (fileService.exists(entity.simdutFilePath)) fileService.delete(entity.simdutFilePath) - super.delete(entity) - } -} + private fun hasSimdut(material: Material) = + fileLogic.exists("${Constants.FilePaths.SIMDUT}/${material.name}.pdf") +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialTypeService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialTypeService.kt index 2b7eec1..1acbcb8 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialTypeService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialTypeService.kt @@ -1,76 +1,44 @@ package dev.fyloz.colorrecipesexplorer.service -import dev.fyloz.colorrecipesexplorer.model.* -import dev.fyloz.colorrecipesexplorer.model.validation.isNotNullAndNotBlank +import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent +import dev.fyloz.colorrecipesexplorer.dtos.MaterialTypeDto +import dev.fyloz.colorrecipesexplorer.model.MaterialType import dev.fyloz.colorrecipesexplorer.repository.MaterialTypeRepository -import org.springframework.context.annotation.Profile -import org.springframework.stereotype.Service -interface MaterialTypeService : - ExternalNamedModelService { - /** Checks if a material type with the given [prefix] exists. */ - fun existsByPrefix(prefix: String): Boolean +interface MaterialTypeService : Service { + /** Checks if a system material type with the given [id] exists. */ + fun existsById(id: Long, systemType: Boolean): Boolean - /** Checks if the given [materialType] is used by one or more materials. */ - fun isUsedByMaterial(materialType: MaterialType): Boolean + /** Checks if a material type with the given [name] and a different [id] exists. */ + fun existsByName(name: String, id: Long?): Boolean - /** Gets all system material types. */ - fun getAllSystemTypes(): Collection + /** Checks if a material type with the given [prefix] and a different [id] exists. */ + fun existsByPrefix(prefix: String, id: Long?): Boolean - /** Gets all material types who are not a system type. */ - fun getAllNonSystemType(): Collection + /** Gets all material types which are or not a [systemType]. */ + fun getAll(systemType: Boolean): Collection + + /** Gets the material type with the given [name]. */ + fun getByName(name: String): MaterialTypeDto? + + /** Checks if a material depends on the material type with the given [id]. */ + fun isUsedByMaterial(id: Long): Boolean } -@Service -@Profile("!emergency") -class MaterialTypeServiceImpl(repository: MaterialTypeRepository, private val materialService: MaterialService) : - AbstractExternalNamedModelService( - repository - ), MaterialTypeService { - override fun idNotFoundException(id: Long) = materialTypeIdNotFoundException(id) - override fun idAlreadyExistsException(id: Long) = materialIdAlreadyExistsException(id) - override fun nameNotFoundException(name: String) = materialTypeNameNotFoundException(name) - override fun nameAlreadyExistsException(name: String) = materialTypeNameAlreadyExistsException(name) +@ServiceComponent +class DefaultMaterialTypeService(repository: MaterialTypeRepository) : + BaseService(repository), MaterialTypeService { + override fun existsById(id: Long, systemType: Boolean) = repository.existsByIdAndSystemTypeIs(id, systemType) + override fun existsByName(name: String, id: Long?) = repository.existsByNameAndIdNot(name, id ?: 0) + override fun existsByPrefix(prefix: String, id: Long?) = repository.existsByPrefixAndIdNot(prefix, id ?: 0) + override fun getAll(systemType: Boolean) = repository.findAllBySystemTypeIs(systemType).map(::toDto) + override fun getByName(name: String) = repository.findByName(name)?.let(::toDto) - override fun MaterialType.toOutput() = this + override fun isUsedByMaterial(id: Long) = repository.isUsedByMaterial(id) - override fun existsByPrefix(prefix: String): Boolean = repository.existsByPrefix(prefix) - override fun isUsedByMaterial(materialType: MaterialType): Boolean = - materialService.existsByMaterialType(materialType) + override fun toDto(entity: MaterialType) = + MaterialTypeDto(entity.id, entity.name, entity.prefix, entity.usePercentages, entity.systemType) - override fun getAllSystemTypes(): Collection = repository.findAllBySystemTypeIs(true) - override fun getAllNonSystemType(): Collection = repository.findAllBySystemTypeIs(false) - - override fun save(entity: MaterialType): MaterialType { - if (existsByPrefix(entity.prefix)) - throw materialTypePrefixAlreadyExistsException(entity.prefix) - return super.save(entity) - } - - override fun update(entity: MaterialTypeUpdateDto): MaterialType { - val persistedMaterialType by lazy { getById(entity.id) } - - 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 - ) - }) - } - - override fun update(entity: MaterialType): MaterialType { - with(repository.findByPrefix(entity.prefix)) { - if (this != null && id != entity.id) - throw materialTypePrefixAlreadyExistsException(entity.prefix) - } - - return super.update(entity) - } - - override fun delete(entity: MaterialType) { - if (!repository.canBeDeleted(entity.id!!)) throw cannotDeleteMaterialTypeException(entity) - super.delete(entity) - } -} + override fun toEntity(dto: MaterialTypeDto) = + MaterialType(dto.id, dto.name, dto.prefix, dto.usePercentages, dto.systemType) +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixMaterialService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixMaterialService.kt index e977852..50df79e 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixMaterialService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixMaterialService.kt @@ -1,153 +1,23 @@ package dev.fyloz.colorrecipesexplorer.service -import dev.fyloz.colorrecipesexplorer.exception.RestException -import dev.fyloz.colorrecipesexplorer.model.* +import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent +import dev.fyloz.colorrecipesexplorer.dtos.MixMaterialDto +import dev.fyloz.colorrecipesexplorer.model.MixMaterial import dev.fyloz.colorrecipesexplorer.repository.MixMaterialRepository -import dev.fyloz.colorrecipesexplorer.utils.findDuplicated -import dev.fyloz.colorrecipesexplorer.utils.hasGaps -import org.springframework.context.annotation.Lazy -import org.springframework.context.annotation.Profile -import org.springframework.http.HttpStatus -import org.springframework.stereotype.Service -interface MixMaterialService : ModelService { - /** Checks if one or more mix materials have the given [material]. */ - fun existsByMaterial(material: Material): Boolean - - /** Creates [MixMaterial]s from the givens [MixMaterialDto]. */ - fun create(mixMaterials: Set): Set - - /** Creates a [MixMaterial] from a given [MixMaterialDto]. */ - fun create(mixMaterial: MixMaterialDto): MixMaterial - - /** Updates the [quantity] of the given [mixMaterial]. */ - fun updateQuantity(mixMaterial: MixMaterial, quantity: Float): MixMaterial - - /** - * Validates if the given [mixMaterials]. To be valid, the position of each mix material must be greater or equals to 1 and unique in the set. - * There must also be no gap between the positions. Also, the quantity of the first mix material in the set must not be expressed in percentages. - * If any of those criteria are not met, an [InvalidGroupStepsPositionsException] will be thrown. - */ - fun validateMixMaterials(mixMaterials: Set) - - fun MixMaterial.toOutput(): MixMaterialOutputDto +interface MixMaterialService : Service { + /** Checks if a mix material with the given [materialId] exists. */ + fun existsByMaterialId(materialId: Long): Boolean } -@Service -@Profile("!emergency") -class MixMaterialServiceImpl( - mixMaterialRepository: MixMaterialRepository, - @Lazy val materialService: MaterialService -) : AbstractModelService(mixMaterialRepository), MixMaterialService { - override fun idNotFoundException(id: Long) = mixMaterialIdNotFoundException(id) - override fun idAlreadyExistsException(id: Long) = mixMaterialIdAlreadyExistsException(id) +@ServiceComponent +class DefaultMixMaterialService(repository: MixMaterialRepository, private val materialService: MaterialService) : + BaseService(repository), MixMaterialService { + override fun existsByMaterialId(materialId: Long) = repository.existsByMaterialId(materialId) - override fun MixMaterial.toOutput() = MixMaterialOutputDto( - this.id!!, - with(materialService) { this@toOutput.material.toOutput() }, - this.quantity, - this.position - ) - - override fun existsByMaterial(material: Material): Boolean = repository.existsByMaterial(material) - override fun create(mixMaterials: Set): Set = - mixMaterials.map(::create).toSet() - - override fun create(mixMaterial: MixMaterialDto): MixMaterial = - mixMaterial( - material = materialService.getById(mixMaterial.materialId), - quantity = mixMaterial.quantity, - position = mixMaterial.position - ) - - override fun updateQuantity(mixMaterial: MixMaterial, quantity: Float) = - update(mixMaterial.apply { - this.quantity = quantity - }) - - override fun validateMixMaterials(mixMaterials: Set) { - if (mixMaterials.isEmpty()) return - - val sortedMixMaterials = mixMaterials.sortedBy { it.position } - val firstMixMaterial = sortedMixMaterials[0] - val errors = mutableSetOf() - - // Check if the first mix material position is 1 - fun isFirstMixMaterialPositionInvalid() = - sortedMixMaterials[0].position != 1 - - // Check if the first mix material is expressed in percents - fun isFirstMixMaterialPercentages() = - sortedMixMaterials[0].material.materialType!!.usePercentages - - // Check if any positions is duplicated - fun getDuplicatedPositionsErrors() = - sortedMixMaterials - .findDuplicated { it.position } - .map { duplicatedMixMaterialsPositions(it) } - - // Find all errors and throw if there is any - if (isFirstMixMaterialPositionInvalid()) errors += invalidFirstMixMaterialPosition(sortedMixMaterials[0]) - errors += getDuplicatedPositionsErrors() - if (errors.isEmpty() && mixMaterials.hasGaps { it.position }) errors += gapBetweenStepsPositions() - if (errors.isNotEmpty()) { - throw InvalidMixMaterialsPositionsException(errors) - } - - if (isFirstMixMaterialPercentages()) { - throw InvalidFirstMixMaterial(firstMixMaterial) - } - } -} - -class InvalidMixMaterialsPositionsError( - val type: String, - val details: String -) - -class InvalidMixMaterialsPositionsException( - val errors: Set -) : RestException( - "invalid-mixmaterial-position", - "Invalid mix materials positions", - HttpStatus.BAD_REQUEST, - "The position of mix materials are invalid", - mapOf( - "invalidMixMaterials" to errors - ) -) - -class InvalidFirstMixMaterial( - val mixMaterial: MixMaterial -) : RestException( - "invalid-mixmaterial-first", - "Invalid first mix material", - HttpStatus.BAD_REQUEST, - "The first mix material is invalid because its material must not be expressed in percents", - mapOf( - "mixMaterial" to mixMaterial - ) -) - -const val INVALID_FIRST_MIX_MATERIAL_POSITION_ERROR_CODE = "first" -const val DUPLICATED_MIX_MATERIALS_POSITIONS_ERROR_CODE = "duplicated" -const val GAP_BETWEEN_MIX_MATERIALS_POSITIONS_ERROR_CODE = "gap" - -private fun invalidFirstMixMaterialPosition(mixMaterial: MixMaterial) = - InvalidMixMaterialsPositionsError( - INVALID_FIRST_MIX_MATERIAL_POSITION_ERROR_CODE, - "The position ${mixMaterial.position} is under the minimum of 1" - ) - -private fun duplicatedMixMaterialsPositions(position: Int) = - InvalidMixMaterialsPositionsError( - DUPLICATED_MIX_MATERIALS_POSITIONS_ERROR_CODE, - "The position $position is duplicated" - ) - -private fun gapBetweenStepsPositions() = - InvalidMixMaterialsPositionsError( - GAP_BETWEEN_MIX_MATERIALS_POSITIONS_ERROR_CODE, - "There is a gap between mix materials positions" - ) + override fun toDto(entity: MixMaterial) = + MixMaterialDto(entity.id, materialService.toDto(entity.material), entity.quantity, entity.position) + override fun toEntity(dto: MixMaterialDto) = + MixMaterial(dto.id, materialService.toEntity(dto.material), dto.quantity, dto.position) +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixMixTypeService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixMixTypeService.kt new file mode 100644 index 0000000..8f1f0c4 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixMixTypeService.kt @@ -0,0 +1,34 @@ +package dev.fyloz.colorrecipesexplorer.service + +import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent +import dev.fyloz.colorrecipesexplorer.dtos.MixMixTypeDto +import dev.fyloz.colorrecipesexplorer.model.MixMixType +import dev.fyloz.colorrecipesexplorer.repository.MixMixTypeRepository + +interface MixMixTypeService : Service { + fun getAllByMixId(mixId: Long): List + + fun saveAllForMixId(mixMixTypes: List, mixId: Long): List +} + +@ServiceComponent +class DefaultMixMixTypeService(repository: MixMixTypeRepository, private val mixTypeService: MixTypeService) : + BaseService(repository), MixMixTypeService { + override fun getAllByMixId(mixId: Long) = repository.findAllByMixId(mixId).map(::toDto) + + override fun saveAllForMixId(mixMixTypes: List, mixId: Long): List { + repository.deleteAllByMixId(mixId) + + mixMixTypes.forEach { saveForMixId(it, mixId) } + return getAllByMixId(mixId) + } + + fun saveForMixId(mixMixType: MixMixTypeDto, mixId: Long) = + repository.saveForMixId(mixMixType.id, mixMixType.mixType.id, mixId, mixMixType.quantity, mixMixType.position) + + override fun toDto(entity: MixMixType) = + MixMixTypeDto(entity.id, mixTypeService.toDto(entity.mixType), entity.quantity, entity.position) + + override fun toEntity(dto: MixMixTypeDto) = + MixMixType(dto.id, mixTypeService.toEntity(dto.mixType), dto.quantity, dto.position) +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixService.kt index 72c0009..13f9e37 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixService.kt @@ -1,105 +1,54 @@ package dev.fyloz.colorrecipesexplorer.service -import dev.fyloz.colorrecipesexplorer.model.* +import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent +import dev.fyloz.colorrecipesexplorer.dtos.MixDto +import dev.fyloz.colorrecipesexplorer.dtos.MixQuantitiesDto +import dev.fyloz.colorrecipesexplorer.model.Mix import dev.fyloz.colorrecipesexplorer.repository.MixRepository -import dev.fyloz.colorrecipesexplorer.utils.setAll -import org.springframework.context.annotation.Lazy -import org.springframework.context.annotation.Profile -import org.springframework.stereotype.Service -import javax.transaction.Transactional -interface MixService : ExternalModelService { - /** Gets all mixes with the given [mixType]. */ - fun getAllByMixType(mixType: MixType): Collection +interface MixService : Service { + /** Gets all mixes with the mix type with the given [mixTypeId]. */ + fun getAllByMixTypeId(mixTypeId: Long): Collection - /** Checks if a [MixType] is shared by several [Mix]es or not. */ - fun mixTypeIsShared(mixType: MixType): Boolean - - /** Updates the location of each [Mix] in the given [MixLocationDto]s. */ - fun updateLocations(updatedLocations: Collection) - - /** Updates the location of a given [Mix] to the given [MixLocationDto]. */ - fun updateLocation(updatedLocation: MixLocationDto) + /** Updates the [location] of the mix with the given [id]. */ + fun updateLocationById(id: Long, location: String?) } -@Service -@Profile("!emergency") -class MixServiceImpl( - mixRepository: MixRepository, - @Lazy val recipeService: RecipeService, - @Lazy val materialTypeService: MaterialTypeService, - val mixMaterialService: MixMaterialService, - val mixTypeService: MixTypeService -) : AbstractExternalModelService(mixRepository), - MixService { - override fun idNotFoundException(id: Long) = mixIdNotFoundException(id) - override fun idAlreadyExistsException(id: Long) = mixIdAlreadyExistsException(id) +@ServiceComponent +class DefaultMixService( + repository: MixRepository, + private val mixTypeService: MixTypeService, + private val mixMaterialService: MixMaterialService, + private val mixMixTypeService: MixMixTypeService +) : BaseService(repository), MixService { + override fun getAllByMixTypeId(mixTypeId: Long) = repository.findAllByMixTypeId(mixTypeId).map(::toDto) + override fun updateLocationById(id: Long, location: String?) = repository.updateLocationById(id, location) - override fun getAllByMixType(mixType: MixType): Collection = repository.findAllByMixType(mixType) - override fun mixTypeIsShared(mixType: MixType): Boolean = getAllByMixType(mixType).count() > 1 + override fun save(dto: MixDto): MixDto { + val savedMix = super.save(dto) + val savedMixMixTypes = mixMixTypeService.saveAllForMixId(dto.mixQuantities.mixTypes, savedMix.id) - override fun Mix.toOutput() = MixOutputDto( - this.id!!, - this.location, - this.mixType, - this.mixMaterials.map { - with(mixMaterialService) { - return@with it.toOutput() - } - }.toSet() - ) - - @Transactional - override fun save(entity: MixSaveDto): Mix { - val recipe = recipeService.getById(entity.recipeId) - val materialType = materialTypeService.getById(entity.materialTypeId) - val mixType = mixTypeService.getOrCreateForNameAndMaterialType(entity.name, materialType) - - val mixMaterials = if (entity.mixMaterials != null) mixMaterialService.create(entity.mixMaterials) else setOf() - mixMaterialService.validateMixMaterials(mixMaterials) - - var mix = mix(recipe = recipe, mixType = mixType, mixMaterials = mixMaterials.toMutableSet()) - mix = save(mix) - - recipeService.addMix(recipe, mix) - - return mix + return savedMix.copy(mixQuantities = savedMix.mixQuantities.copy(mixTypes = savedMixMixTypes)) } - @Transactional - override fun update(entity: MixUpdateDto): Mix { - val mix = getById(entity.id) - if (entity.name != null || entity.materialTypeId != null) { - val name = entity.name ?: mix.mixType.name - val materialType = if (entity.materialTypeId != null) - materialTypeService.getById(entity.materialTypeId) - else - mix.mixType.material.materialType!! + override fun toDto(entity: Mix) = + MixDto( + entity.id, + entity.location, + entity.recipeId, + mixTypeService.toDto(entity.mixType), + MixQuantitiesDto( + entity.mixMaterials.filter { !it.material.isMixType }.map(mixMaterialService::toDto), + mixMixTypeService.getAllByMixId(entity.id) + ) + ) - mix.mixType = if (mixTypeIsShared(mix.mixType)) { - mixTypeService.saveForNameAndMaterialType(name, materialType) - } else { - mixTypeService.updateForNameAndMaterialType(mix.mixType, name, materialType) - } - } - if (entity.mixMaterials != null) { - mix.mixMaterials.setAll(mixMaterialService.create(entity.mixMaterials!!).toMutableSet()) - } - return update(mix) - } - - override fun updateLocations(updatedLocations: Collection) { - updatedLocations.forEach(::updateLocation) - } - - override fun updateLocation(updatedLocation: MixLocationDto) { - repository.updateLocationById(updatedLocation.mixId, updatedLocation.location) - } - - @Transactional - override fun delete(entity: Mix) { - if (!repository.canBeDeleted(entity.id!!)) throw cannotDeleteMixException(entity) - recipeService.removeMix(entity) - super.delete(entity) - } -} + override fun toEntity(dto: MixDto) = + Mix( + dto.id, + dto.location, + dto.recipeId, + mixTypeService.toEntity(dto.mixType), + dto.mixQuantities.materials.map(mixMaterialService::toEntity) + ) +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixTypeService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixTypeService.kt index 92327d8..d739b0b 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixTypeService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/MixTypeService.kt @@ -1,88 +1,52 @@ package dev.fyloz.colorrecipesexplorer.service -import dev.fyloz.colorrecipesexplorer.model.* +import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent +import dev.fyloz.colorrecipesexplorer.dtos.MixTypeDto +import dev.fyloz.colorrecipesexplorer.model.MixType import dev.fyloz.colorrecipesexplorer.repository.MixTypeRepository -import org.springframework.context.annotation.Lazy -import org.springframework.context.annotation.Profile -import org.springframework.stereotype.Service -interface MixTypeService : NamedModelService { - /** Checks if a [MixType] with the given [name] and [materialType] exists. */ - fun existsByNameAndMaterialType(name: String, materialType: MaterialType): Boolean +interface MixTypeService : Service { + /** Checks if a mix type with the given [name], [materialTypeId] and a different [id] exists. */ + fun existsByNameAndMaterialType(name: String, materialTypeId: Long, id: Long? = null): Boolean - /** Gets the mix type with the given [material]. */ - fun getByMaterial(material: Material): MixType + /** Finds the mix type with the given [name] and [materialTypeId]. */ + fun getByNameAndMaterialType(name: String, materialTypeId: Long): MixTypeDto? - /** Gets the [MixType] with the given [name] and [materialType]. */ - fun getByNameAndMaterialType(name: String, materialType: MaterialType): MixType + /** Checks if a mix depends on the mix type with the given [id]. */ + fun isUsedByMixes(id: Long): Boolean - /** Returns a [MixType] for the given [name] and [materialType]. If a mix type with these does not already exists, it will be created. */ - fun getOrCreateForNameAndMaterialType(name: String, materialType: MaterialType): MixType - - /** Returns a new and persisted [MixType] with the given [name] and [materialType]. */ - fun saveForNameAndMaterialType(name: String, materialType: MaterialType): MixType - - /** Returns the given [mixType] updated with the given [name] and [materialType]. */ - fun updateForNameAndMaterialType(mixType: MixType, name: String, materialType: MaterialType): MixType + /** Checks if the mix type with the given [id] is used by more than one mix. */ + fun isShared(id: Long): Boolean } -@Service -@Profile("!emergency") -class MixTypeServiceImpl( - mixTypeRepository: MixTypeRepository, - @Lazy val materialService: MaterialService, - @Lazy val mixService: MixService +@ServiceComponent +class DefaultMixTypeService( + repository: MixTypeRepository, + val materialService: MaterialService, + val materialTypeService: MaterialTypeService ) : - AbstractNamedModelService(mixTypeRepository), MixTypeService { - override fun idNotFoundException(id: Long) = mixTypeIdNotFoundException(id) - override fun idAlreadyExistsException(id: Long) = mixTypeIdAlreadyExistsException(id) - override fun nameNotFoundException(name: String) = mixTypeNameNotFoundException(name) - override fun nameAlreadyExistsException(name: String) = mixTypeNameAlreadyExistsException(name) + BaseService(repository), MixTypeService { + override fun existsByNameAndMaterialType(name: String, materialTypeId: Long, id: Long?) = + repository.existsByNameAndMaterialTypeAndIdNot(name, materialTypeId, id ?: 0L) - override fun existsByNameAndMaterialType(name: String, materialType: MaterialType): Boolean = - repository.existsByNameAndMaterialType(name, materialType) + override fun getByNameAndMaterialType(name: String, materialTypeId: Long) = + repository.findByNameAndMaterialType(name, materialTypeId)?.let(::toDto) - override fun getByMaterial(material: Material): MixType = - repository.findByMaterial(material) ?: throw nameNotFoundException(material.name) + override fun isUsedByMixes(id: Long) = repository.isUsedByMixes(id) + override fun isShared(id: Long) = repository.isShared(id) - override fun getByNameAndMaterialType(name: String, materialType: MaterialType): MixType = - repository.findByNameAndMaterialType(name, materialType) - ?: throw MixTypeNameAndMaterialTypeNotFoundException(name, materialType) - - override fun getOrCreateForNameAndMaterialType(name: String, materialType: MaterialType): MixType = - if (existsByNameAndMaterialType(name, materialType)) - getByNameAndMaterialType(name, materialType) - else - saveForNameAndMaterialType(name, materialType) - - override fun save(entity: MixType): MixType { - if (materialService.existsByName(entity.name)) - throw materialNameAlreadyExistsException(entity.name) - return super.save(entity) - } - - override fun saveForNameAndMaterialType(name: String, materialType: MaterialType): MixType = - save( - mixType( - name = name, - material = material( - name = name, - inventoryQuantity = Float.MIN_VALUE, - isMixType = true, - materialType = materialType - ) + override fun toDto(entity: MixType) = + MixTypeDto( + entity.id, + entity.name, + materialTypeService.toDto(entity.materialType), + if (entity.material != null) materialService.toDto(entity.material) else null ) - ) - override fun updateForNameAndMaterialType(mixType: MixType, name: String, materialType: MaterialType): MixType = - update(mixType.apply { - this.name = name - material.name = name - material.materialType = materialType - }) - - override fun delete(entity: MixType) { - if (!repository.canBeDeleted(entity.id!!)) throw cannotDeleteMixTypeException(entity) - super.delete(entity) - } -} + override fun toEntity(dto: MixTypeDto) = + MixType( + dto.id, dto.name, + materialTypeService.toEntity(dto.materialType), + if (dto.material != null) materialService.toEntity(dto.material) else null + ) +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt index dd9eccd..8532a02 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeService.kt @@ -1,272 +1,92 @@ package dev.fyloz.colorrecipesexplorer.service -import dev.fyloz.colorrecipesexplorer.model.* -import dev.fyloz.colorrecipesexplorer.model.account.Group -import dev.fyloz.colorrecipesexplorer.model.validation.or +import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent +import dev.fyloz.colorrecipesexplorer.dtos.RecipeDto +import dev.fyloz.colorrecipesexplorer.dtos.RecipeGroupInformationDto +import dev.fyloz.colorrecipesexplorer.logic.config.ConfigurationLogic +import dev.fyloz.colorrecipesexplorer.model.ConfigurationType +import dev.fyloz.colorrecipesexplorer.model.Recipe +import dev.fyloz.colorrecipesexplorer.model.RecipeGroupInformation import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository -import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService -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 dev.fyloz.colorrecipesexplorer.utils.collections.lazyMap +import org.springframework.transaction.annotation.Transactional import java.time.LocalDate import java.time.Period -import javax.transaction.Transactional -interface RecipeService : - ExternalModelService { - /** Checks if one or more recipes have the given [company]. */ - fun existsByCompany(company: Company): Boolean - - /** Checks if a recipe exists with the given [name] and [company]. */ - fun existsByNameAndCompany(name: String, company: Company): Boolean - - /** Checks if the approbation date of the given [recipe] is expired. */ - fun isApprobationExpired(recipe: Recipe): Boolean? +interface RecipeService : Service { + /** Checks if a recipe with the given [name], [companyId] and a different [id] exists. */ + fun existsByNameAndCompany(name: String, companyId: Long, id: Long?): Boolean /** Gets all recipes with the given [name]. */ - fun getAllByName(name: String): Collection - - /** Gets all recipes with the given [company]. */ - fun getAllByCompany(company: Company): Collection - - /** Updates the public data of a recipe with the given [publicDataDto]. */ - fun updatePublicData(publicDataDto: RecipePublicDataDto) - - /** Adds the given [mix] to the given [recipe]. */ - fun addMix(recipe: Recipe, mix: Mix): Recipe - - /** Removes the given [mix] from its recipe. */ - fun removeMix(mix: Mix): Recipe + fun getAllByName(name: String): Collection } -@Service -@Profile("!emergency") -class RecipeServiceImpl( - recipeRepository: RecipeRepository, - val companyService: CompanyService, - val mixService: MixService, - val recipeStepService: RecipeStepService, - @Lazy val groupService: GroupService, - val recipeImageService: RecipeImageService, - val configService: ConfigurationService +@ServiceComponent +class DefaultRecipeService( + repository: RecipeRepository, + private val companyService: CompanyService, + private val mixService: MixService, + private val recipeStepService: RecipeStepService, + private val groupService: GroupService, + private val configLogic: ConfigurationLogic ) : - AbstractExternalModelService( - recipeRepository - ), - RecipeService { - override fun idNotFoundException(id: Long) = recipeIdNotFoundException(id) - override fun idAlreadyExistsException(id: Long) = recipeIdAlreadyExistsException(id) + BaseService(repository), RecipeService { + override fun existsByNameAndCompany(name: String, companyId: Long, id: Long?) = + repository.existsByNameAndCompanyAndIdNot(name, companyId, id ?: 0L) - override fun Recipe.toOutput() = RecipeOutputDto( - this.id!!, - this.name, - this.description, - this.color, - this.gloss, - this.sample, - this.approbationDate, - isApprobationExpired(this), - this.remark, - this.company, - this.mixes.map { - with(mixService) { - it.toOutput() - } - }.toSet(), - this.groupsInformation, - recipeImageService.getAllImages(this) - .map { this.imageUrl(configService.getContent(ConfigurationType.INSTANCE_URL), it) } - .toSet() - ) + override fun getAllByName(name: String) = + repository.findAllByName(name).map(::toDto) - override fun existsByCompany(company: Company): Boolean = repository.existsByCompany(company) - override fun existsByNameAndCompany(name: String, company: Company) = - repository.existsByNameAndCompany(name, company) + @Transactional + override fun toDto(entity: Recipe) = + RecipeDto( + entity.id, + entity.name, + entity.description, + entity.color, + entity.gloss, + entity.sample, + entity.approbationDate, + isApprobationExpired(entity) ?: false, + entity.remark, + companyService.toDto(entity.company), + entity.mixes.lazyMap(mixService::toDto), + entity.groupsInformation.lazyMap(::groupInformationToDto) + ) - override fun isApprobationExpired(recipe: Recipe): Boolean? = - with(Period.parse(configService.getContent(ConfigurationType.RECIPE_APPROBATION_EXPIRATION))) { + private fun groupInformationToDto(entity: RecipeGroupInformation) = + RecipeGroupInformationDto( + entity.id, + groupService.toDto(entity.group), + entity.note, + entity.steps?.lazyMap(recipeStepService::toDto) ?: listOf() + ) + + override fun toEntity(dto: RecipeDto) = + Recipe( + dto.id, + dto.name, + dto.description, + dto.color, + dto.gloss, + dto.sample, + dto.approbationDate, + dto.remark, + companyService.toEntity(dto.company), + dto.mixes.map(mixService::toEntity), + dto.groupsInformation.map(::groupInformationToEntity) + ) + + private fun groupInformationToEntity(dto: RecipeGroupInformationDto) = + RecipeGroupInformation( + dto.id, + groupService.toEntity(dto.group), + dto.note, + dto.steps.map(recipeStepService::toEntity) + ) + + private fun isApprobationExpired(recipe: Recipe): Boolean? = + with(Period.parse(configLogic.getContent(ConfigurationType.RECIPE_APPROBATION_EXPIRATION))) { recipe.approbationDate?.plus(this)?.isBefore(LocalDate.now()) } - - override fun getAllByName(name: String) = repository.findAllByName(name) - override fun getAllByCompany(company: Company) = repository.findAllByCompany(company) - - override fun save(entity: RecipeSaveDto): Recipe { - val company = companyService.getById(entity.companyId) - - if (existsByNameAndCompany(entity.name, company)) { - throw recipeNameAlreadyExistsForCompanyException(entity.name, company) - } - - return save(with(entity) { - recipe( - name = name, - description = description, - color = color, - gloss = gloss, - sample = sample, - approbationDate = approbationDate, - remark = remark ?: "", - company = company - ) - }) - } - - @Transactional - override fun update(entity: RecipeUpdateDto): Recipe { - val persistedRecipe = getById(entity.id) - val name = entity.name - val company = persistedRecipe.company - - if (name != null && - name != persistedRecipe.name && - existsByNameAndCompany(name, company) - ) { - throw recipeNameAlreadyExistsForCompanyException(name, company) - } - - return update(with(entity) { - recipe( - id = id, - name = name or persistedRecipe.name, - description = description or persistedRecipe.description, - color = color or persistedRecipe.color, - gloss = gloss ?: persistedRecipe.gloss, - sample = sample ?: persistedRecipe.sample, - approbationDate = approbationDate ?: persistedRecipe.approbationDate, - remark = remark or persistedRecipe.remark, - company = company, - mixes = persistedRecipe.mixes, - groupsInformation = updateGroupsInformation(persistedRecipe, entity) - ) - }) - } - - private fun updateGroupsInformation(recipe: Recipe, updateDto: RecipeUpdateDto): Set { - val steps = updateDto.steps ?: return recipe.groupsInformation - - val updatedGroupsInformation = mutableSetOf() - steps.forEach { - with(recipe.groupInformationForGroup(it.groupId)) { - // Set steps for the existing RecipeGroupInformation or create a new one - val updatedGroupInformation = this?.apply { - if (this.steps != null) { - this.steps!!.setAll(it.steps) - } else { - this.steps = it.steps.toMutableSet() - } - } ?: recipeGroupInformation( - group = groupService.getById(it.groupId), - steps = it.steps.toMutableSet() - ) - - updatedGroupsInformation.add(updatedGroupInformation) - recipeStepService.validateGroupInformationSteps(updatedGroupInformation) - } - } - - return updatedGroupsInformation - } - - @Transactional - override fun updatePublicData(publicDataDto: RecipePublicDataDto) { - if (publicDataDto.notes != null) { - val recipe = getById(publicDataDto.recipeId) - - fun noteForGroup(group: Group) = - publicDataDto.notes.firstOrNull { it.groupId == group.id }?.content - - // Notes - recipe.groupsInformation.map { - val updatedNote = noteForGroup(it.group) - it.apply { - note = updatedNote - } - } - - update(recipe) - } - - if (publicDataDto.mixesLocation != null) { - mixService.updateLocations(publicDataDto.mixesLocation) - } - } - - override fun addMix(recipe: Recipe, mix: Mix) = - update(recipe.apply { mixes.add(mix) }) - - override fun removeMix(mix: Mix): Recipe = - update(mix.recipe.apply { mixes.remove(mix) }) -} - -interface RecipeImageService { - /** Gets the name of every images associated to the recipe with the given [recipe]. */ - fun getAllImages(recipe: Recipe): Set - - /** Saves the given [image] and associate it to the recipe with the given [recipe]. Returns the name of the saved image. */ - fun download(image: MultipartFile, recipe: Recipe): String - - /** 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") -class RecipeImageServiceImpl( - val fileService: WriteableFileService -) : RecipeImageService { - override fun getAllImages(recipe: Recipe): Set { - 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() - .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]. */ - fun getNextAvailableId(): Long = - with(getAllImages(recipe)) { - if (isEmpty()) - 0 - else - maxOf { - it.split(RECIPE_IMAGE_ID_DELIMITER) - .last() - .replace(RECIPE_IMAGE_EXTENSION, "") - .toLong() - } + 1L - } - - return getImageFileName(recipe, getNextAvailableId()).apply { - fileService.write(image, getImagePath(recipe, this), 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 - }) - - private fun getImageFileName(recipe: Recipe, id: Long) = - "${recipe.name}$RECIPE_IMAGE_ID_DELIMITER$id" - - private fun getImagePath(recipe: Recipe, name: String) = - "${recipe.imagesDirectoryPath}/$name$RECIPE_IMAGE_EXTENSION" -} +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeStepService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeStepService.kt index 0bde45d..556f40b 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeStepService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeStepService.kt @@ -1,124 +1,18 @@ package dev.fyloz.colorrecipesexplorer.service -import dev.fyloz.colorrecipesexplorer.exception.RestException -import dev.fyloz.colorrecipesexplorer.model.* -import dev.fyloz.colorrecipesexplorer.model.account.Group +import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent +import dev.fyloz.colorrecipesexplorer.dtos.RecipeStepDto +import dev.fyloz.colorrecipesexplorer.model.RecipeStep import dev.fyloz.colorrecipesexplorer.repository.RecipeStepRepository -import dev.fyloz.colorrecipesexplorer.utils.findDuplicated -import dev.fyloz.colorrecipesexplorer.utils.hasGaps -import org.springframework.context.annotation.Profile -import org.springframework.http.HttpStatus -import org.springframework.stereotype.Service -interface RecipeStepService : ModelService { - /** Validates the steps of the given [groupInformation], according to the criteria of [validateSteps]. */ - fun validateGroupInformationSteps(groupInformation: RecipeGroupInformation) +interface RecipeStepService : Service - /** - * Validates if the given [steps]. To be valid, the position of each step must be greater or equals to 1 and unique in the set. - * There must also be no gap between the positions. - * If any of those criteria are not met, an [InvalidGroupStepsPositionsException] will be thrown. - */ - fun validateSteps(steps: Set) -} +@ServiceComponent +class DefaultRecipeStepService(repository: RecipeStepRepository) : + BaseService(repository), RecipeStepService { + override fun toDto(entity: RecipeStep) = + RecipeStepDto(entity.id, entity.position, entity.message) -@Service -@Profile("!emergency") -class RecipeStepServiceImpl(recipeStepRepository: RecipeStepRepository) : - AbstractModelService(recipeStepRepository), - RecipeStepService { - override fun idNotFoundException(id: Long) = recipeStepIdNotFoundException(id) - override fun idAlreadyExistsException(id: Long) = recipeStepIdAlreadyExistsException(id) - - override fun validateGroupInformationSteps(groupInformation: RecipeGroupInformation) { - if (groupInformation.steps == null) return - - try { - validateSteps(groupInformation.steps!!) - } catch (validationException: InvalidStepsPositionsException) { - throw InvalidGroupStepsPositionsException(groupInformation.group, validationException) - } - } - - override fun validateSteps(steps: Set) { - if (steps.isEmpty()) return - - val sortedSteps = steps.sortedBy { it.position } - val errors = mutableSetOf() - - // Check if the first step position is 1 - fun isFirstStepPositionInvalid() = - sortedSteps[0].position != 1 - - // Check if any position is duplicated - fun getDuplicatedPositionsErrors() = - sortedSteps - .findDuplicated { it.position } - .map { duplicatedStepsPositions(it) } - - // Find all errors and throw if there is any - if (isFirstStepPositionInvalid()) errors += invalidFirstStepPosition(sortedSteps[0]) - errors += getDuplicatedPositionsErrors() - if (errors.isEmpty() && steps.hasGaps { it.position }) errors += gapBetweenStepsPositions() - if (errors.isNotEmpty()) { - throw InvalidStepsPositionsException(errors) - } - } -} - -data class InvalidStepsPositionsError( - val type: String, - val details: String -) - -class InvalidStepsPositionsException( - val errors: Set -) : RestException( - "invalid-recipestep-position", - "Invalid steps positions", - HttpStatus.BAD_REQUEST, - "The position of steps are invalid", - mapOf( - "invalidSteps" to errors - ) -) - -class InvalidGroupStepsPositionsException( - val group: Group, - val exception: InvalidStepsPositionsException -) : RestException( - "invalid-groupinformation-recipestep-position", - "Invalid steps positions", - HttpStatus.BAD_REQUEST, - "The position of steps for the group ${group.name} are invalid", - mapOf( - "group" to group.name, - "groupId" to group.id!!, - "invalidSteps" to exception.errors - ) -) { - val errors: Set - get() = exception.errors -} - -const val INVALID_FIRST_STEP_POSITION_ERROR_CODE = "first" -const val DUPLICATED_STEPS_POSITIONS_ERROR_CODE = "duplicated" -const val GAP_BETWEEN_STEPS_POSITIONS_ERROR_CODE = "gap" - -private fun invalidFirstStepPosition(step: RecipeStep) = - InvalidStepsPositionsError( - INVALID_FIRST_STEP_POSITION_ERROR_CODE, - "The position ${step.position} is under the minimum of 1" - ) - -private fun duplicatedStepsPositions(position: Int) = - InvalidStepsPositionsError( - DUPLICATED_STEPS_POSITIONS_ERROR_CODE, - "The position $position is duplicated" - ) - -private fun gapBetweenStepsPositions() = - InvalidStepsPositionsError( - GAP_BETWEEN_STEPS_POSITIONS_ERROR_CODE, - "There is a gap between steps positions" - ) + override fun toEntity(dto: RecipeStepDto) = + RecipeStep(dto.id, dto.position, dto.message) +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/Service.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/Service.kt index 902a32c..cfd846a 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/Service.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/Service.kt @@ -1,182 +1,67 @@ package dev.fyloz.colorrecipesexplorer.service -import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException -import dev.fyloz.colorrecipesexplorer.exception.NotFoundException -import dev.fyloz.colorrecipesexplorer.model.EntityDto -import dev.fyloz.colorrecipesexplorer.model.Model -import dev.fyloz.colorrecipesexplorer.model.NamedModel -import dev.fyloz.colorrecipesexplorer.repository.NamedJpaRepository -import io.jsonwebtoken.lang.Assert +import dev.fyloz.colorrecipesexplorer.dtos.EntityDto +import dev.fyloz.colorrecipesexplorer.model.ModelEntity import org.springframework.data.jpa.repository.JpaRepository import org.springframework.data.repository.findByIdOrNull /** - * A service implementing the basics CRUD operations for the given entities. + * Represents a service between the logic and the repository. + * Gives access to the repository using a DTO. * - * @param E The entity type - * @param R The entity repository type + * @param D The type of the entity DTO. + * @param E The type of the entity. + * @param R The repository of the entity. */ -interface Service> { - val repository: R - - /** Gets all entities. */ - fun getAll(): Collection - - /** Saves a given [entity]. */ - fun save(entity: E): E - - /** Updates a given [entity]. */ - fun update(entity: E): E - - /** Deletes a given [entity]. */ - fun delete(entity: E) -} - -/** A service for entities implementing the [Model] interface. This service add supports for numeric identifiers. */ -interface ModelService> : Service { +interface Service> { /** Checks if an entity with the given [id] exists. */ fun existsById(id: Long): Boolean - /** Gets the entity with the given [id]. */ - fun getById(id: Long): E + /** Gets all entities as DTOs. */ + fun getAll(): Collection + + /** Gets the entity DTO with the given [id].*/ + fun getById(id: Long): D? + + /** Saves the given [dto]. */ + fun save(dto: D): D + + /** Deletes the given [dto]. */ + fun delete(dto: D) /** Deletes the entity with the given [id]. */ fun deleteById(id: Long) + + /** Converts the given [entity] to a DTO. */ + fun toDto(entity: E): D + + /** Converts the given [dto] to an entity. */ + fun toEntity(dto: D): E } -/** A service for entities implementing the [NamedModel] interface. This service add supports for name identifiers. */ -interface NamedModelService> : ModelService { - /** Checks if an entity with the given [name] exists. */ - fun existsByName(name: String): Boolean +abstract class BaseService>(protected val repository: R) : + Service { + override fun existsById(id: Long) = + repository.existsById(id) - /** Gets the entity with the given [name]. */ - fun getByName(name: String): E -} + override fun getAll() = + repository.findAll().map(this::toDto) - -abstract class AbstractService>(override val repository: R) : Service { - override fun getAll(): Collection = repository.findAll() - override fun save(entity: E): E = repository.save(entity) - override fun update(entity: E): E = repository.save(entity) - override fun delete(entity: E) = repository.delete(entity) -} - -abstract class AbstractModelService>(repository: R) : - AbstractService(repository), ModelService { - protected abstract fun idNotFoundException(id: Long): NotFoundException - protected abstract fun idAlreadyExistsException(id: Long): AlreadyExistsException - - override fun existsById(id: Long): Boolean = repository.existsById(id) - override fun getById(id: Long): E = repository.findByIdOrNull(id) ?: throw idNotFoundException(id) - - override fun save(entity: E): E { - if (entity.id != null && existsById(entity.id!!)) - throw idAlreadyExistsException(entity.id!!) - return super.save(entity) + override fun getById(id: Long): D? { + val entity = repository.findByIdOrNull(id) ?: return null + return toDto(entity) } - override fun update(entity: E): E { - assertId(entity.id) - if (!existsById(entity.id!!)) - throw idNotFoundException(entity.id!!) - return super.update(entity) + override fun save(dto: D): D { + val entity = repository.save(toEntity(dto)) + return toDto(entity) } - override fun deleteById(id: Long) = - delete(getById(id)) // Use delete(entity) to prevent code duplication and to ease testing - - protected fun assertId(id: Long?) { - Assert.notNull(id, "${javaClass.simpleName}.update() was called with a null identifier") - } -} - -abstract class AbstractNamedModelService>(repository: R) : - AbstractModelService(repository), NamedModelService { - protected abstract fun nameNotFoundException(name: String): NotFoundException - protected abstract fun nameAlreadyExistsException(name: String): AlreadyExistsException - - override fun existsByName(name: String): Boolean = repository.existsByName(name) - override fun getByName(name: String): E = repository.findByName(name) ?: throw nameNotFoundException(name) - - override fun save(entity: E): E { - if (existsByName(entity.name)) - throw nameAlreadyExistsException(entity.name) - return super.save(entity) + override fun delete(dto: D) { + repository.delete(toEntity(dto)) } - override fun update(entity: E): E { - assertId(entity.id) - assertName(entity.name) - with(repository.findByName(entity.name)) { - if (this != null && id != entity.id) - throw nameAlreadyExistsException(entity.name) - } - return super.update(entity) + override fun deleteById(id: Long) { + repository.deleteById(id) } - - private fun assertName(name: String) { - Assert.notNull(name, "${javaClass.simpleName}.update() was called with a null name") - } -} - -/** - * A service that will receive *external* interactions, from the REST API, for example. - * - * @param E The entity type - * @param S The entity save DTO type - * @param U The entity update DTO type - */ -interface ExternalService, U : EntityDto, O, R : JpaRepository> : Service { - /** Gets all entities mapped to their output model. */ - fun getAllForOutput(): Collection - - /** Saves a given [entity]. */ - fun save(entity: S): E = save(entity.toEntity()) - - /** Updates a given [entity]. */ - fun update(entity: U): E - - /** Convert the given entity to its output model. */ - fun E.toOutput(): O -} - -/** An [ExternalService] for entities implementing the [Model] interface. */ -interface ExternalModelService, U : EntityDto, O, R : JpaRepository> : - ModelService, ExternalService { - /** Gets the entity with the given [id] mapped to its output model. */ - fun getByIdForOutput(id: Long): O -} - -/** An [ExternalService] for entities implementing the [NamedModel] interface. */ -interface ExternalNamedModelService, U : EntityDto, O, R : JpaRepository> : - NamedModelService, ExternalModelService - -/** An [AbstractService] with the functionalities of a [ExternalService]. */ -@Suppress("unused") -abstract class AbstractExternalService, U : EntityDto, O, R : JpaRepository>(repository: R) : - AbstractService(repository), ExternalService { - override fun getAllForOutput() = - getAll().map { it.toOutput() } -} - -/** An [AbstractModelService] with the functionalities of a [ExternalService]. */ -abstract class AbstractExternalModelService, U : EntityDto, O, R : JpaRepository>( - repository: R -) : AbstractModelService(repository), ExternalModelService { - override fun getAllForOutput() = - getAll().map { it.toOutput() } - - override fun getByIdForOutput(id: Long) = - getById(id).toOutput() -} - -/** An [AbstractNamedModelService] with the functionalities of a [ExternalService]. */ -abstract class AbstractExternalNamedModelService, U : EntityDto, O, R : NamedJpaRepository>( - repository: R -) : AbstractNamedModelService(repository), ExternalNamedModelService { - override fun getAllForOutput() = - getAll().map { it.toOutput() } - - override fun getByIdForOutput(id: Long) = - getById(id).toOutput() -} +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/TouchUpKitService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/TouchUpKitService.kt index a6bbc1e..73dfa70 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/TouchUpKitService.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/TouchUpKitService.kt @@ -1,151 +1,69 @@ package dev.fyloz.colorrecipesexplorer.service +import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent +import dev.fyloz.colorrecipesexplorer.dtos.TouchUpKitDto +import dev.fyloz.colorrecipesexplorer.dtos.TouchUpKitProductDto +import dev.fyloz.colorrecipesexplorer.logic.config.ConfigurationLogic import dev.fyloz.colorrecipesexplorer.model.ConfigurationType -import dev.fyloz.colorrecipesexplorer.model.touchupkit.* +import dev.fyloz.colorrecipesexplorer.model.touchupkit.TouchUpKit +import dev.fyloz.colorrecipesexplorer.model.touchupkit.TouchUpKitProduct import dev.fyloz.colorrecipesexplorer.repository.TouchUpKitRepository -import dev.fyloz.colorrecipesexplorer.rest.TOUCH_UP_KIT_CONTROLLER_PATH -import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService -import dev.fyloz.colorrecipesexplorer.service.files.FileService -import dev.fyloz.colorrecipesexplorer.service.files.WriteableFileService -import dev.fyloz.colorrecipesexplorer.utils.* -import org.springframework.context.annotation.Profile -import org.springframework.core.io.ByteArrayResource -import org.springframework.core.io.Resource -import org.springframework.stereotype.Service import java.time.LocalDate import java.time.Period -private const val TOUCH_UP_KIT_FILES_PATH = "pdf/touchupkits" - -const val TOUCH_UP_TEXT_FR = "KIT DE RETOUCHE" -const val TOUCH_UP_TEXT_EN = "TOUCH UP KIT" - -interface TouchUpKitService : - ExternalModelService { - fun isExpired(touchUpKit: TouchUpKit): Boolean - - fun complete(id: Long) - - /** Generates and returns a [PdfDocument] for the given [job]. */ - fun generateJobPdf(job: String): PdfDocument - - /** - * Generates and returns a [PdfDocument] for the given [job] as a [ByteArrayResource]. - * - * If TOUCH_UP_KIT_CACHE_PDF is enabled and a file exists for the job, its content will be returned. - * If caching is enabled but no file exists for the job, the generated ByteArrayResource will be cached on the disk. - */ - fun generateJobPdfResource(job: String): Resource - - /** Writes the given [document] to the [FileService] if TOUCH_UP_KIT_CACHE_PDF is enabled. */ - fun String.cachePdfDocument(document: PdfDocument) +interface TouchUpKitService : Service { + /** Updates the [completionDate] of the touch up kit with the given [id]. */ + fun updateCompletionDateById(id: Long, completionDate: LocalDate) } -@Service -@Profile("!emergency") -class TouchUpKitServiceImpl( - private val fileService: WriteableFileService, - private val configService: ConfigurationService, - touchUpKitRepository: TouchUpKitRepository -) : AbstractExternalModelService( - touchUpKitRepository -), TouchUpKitService { - private val cacheGeneratedFiles by lazy { - configService.getContent(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF) == true.toString() - } +@ServiceComponent +class DefaultTouchUpKitService(repository: TouchUpKitRepository, private val configurationLogic: ConfigurationLogic) : + BaseService(repository), TouchUpKitService { + override fun updateCompletionDateById(id: Long, completionDate: LocalDate) = + repository.updateCompletionDateById(id, completionDate) - override fun idNotFoundException(id: Long) = touchUpKitIdNotFoundException(id) - override fun idAlreadyExistsException(id: Long) = touchUpKitIdAlreadyExistsException(id) + override fun toDto(entity: TouchUpKit) = + TouchUpKitDto( + entity.id, + entity.project, + entity.buggy, + entity.company, + entity.quantity, + entity.shippingDate, + entity.completionDate, + entity.completionDate != null, + isExpired(entity), + entity.finish.split(LIST_DELIMITER), + entity.material.split(LIST_DELIMITER), + entity.content.map(::touchUpKitProductToDto) + ) - override fun TouchUpKit.toOutput() = TouchUpKitOutputDto( - this.id!!, - this.project, - this.buggy, - this.company, - this.quantity, - this.shippingDate, - this.completionDate != null, - this.completionDate, - isExpired(this), - this.finish, - this.material, - this.content, - this.pdfUrl() - ) + private fun touchUpKitProductToDto(entity: TouchUpKitProduct) = + TouchUpKitProductDto(entity.id, entity.name, entity.description, entity.quantity, entity.ready) - override fun update(entity: TouchUpKitUpdateDto): TouchUpKit { - val persistedKit by lazy { getById(entity.id) } + override fun toEntity(dto: TouchUpKitDto) = + TouchUpKit( + dto.id, + dto.project, + dto.buggy, + dto.company, + dto.quantity, + dto.shippingDate, + dto.completionDate, + dto.finish.joinToString(LIST_DELIMITER), + dto.material.joinToString(LIST_DELIMITER), + dto.content.map(::touchUpKitProductToEntity) + ) - return super.update(with(entity) { - touchUpKit( - id = id, - project = project ?: persistedKit.project, - buggy = buggy ?: persistedKit.buggy, - company = company ?: persistedKit.company, - quantity = quantity ?: persistedKit.quantity, - shippingDate = shippingDate ?: persistedKit.shippingDate, - completionDate = completionDate ?: persistedKit.completionDate, - finish = finish ?: persistedKit.finish, - material = material ?: persistedKit.material, - content = content?.map { touchUpKitProduct(it) }?.toSet() ?: persistedKit.content - ) - }) - } + private fun touchUpKitProductToEntity(dto: TouchUpKitProductDto) = + TouchUpKitProduct(dto.id, dto.name, dto.description, dto.quantity, dto.ready) - override fun isExpired(touchUpKit: TouchUpKit) = - with(Period.parse(configService.getContent(ConfigurationType.TOUCH_UP_KIT_EXPIRATION))) { - touchUpKit.completed && touchUpKit.completionDate!!.plus(this) < LocalDate.now() + private fun isExpired(touchUpKit: TouchUpKit) = + with(Period.parse(configurationLogic.getContent(ConfigurationType.TOUCH_UP_KIT_EXPIRATION))) { + touchUpKit.completionDate != null && touchUpKit.completionDate.plus(this) < LocalDate.now() } - override fun complete(id: Long) { - update(touchUpKitUpdateDto(id = id, completionDate = LocalDate.now())) + companion object { + private const val LIST_DELIMITER = ";" } - - override fun generateJobPdf(job: String) = pdf { - container { - centeredVertically = true - drawContainerBottom = true - text(TOUCH_UP_TEXT_FR) { - bold = true - fontSize = PDF_DEFAULT_FONT_SIZE + 12 - } - text(TOUCH_UP_TEXT_EN) { - bold = true - fontSize = PDF_DEFAULT_FONT_SIZE + 12 - } - text(job) { - marginTop = 10f - } - } - - container(containers[0]) { - drawContainerBottom = false - } - } - - override fun generateJobPdfResource(job: String): Resource { - if (cacheGeneratedFiles) { - with(job.pdfDocumentPath()) { - if (fileService.exists(this)) { - return fileService.read(this) - } - } - } - - return generateJobPdf(job).apply { - job.cachePdfDocument(this) - }.toByteArrayResource() - } - - override fun String.cachePdfDocument(document: PdfDocument) { - if (!cacheGeneratedFiles) return - - fileService.write(document.toByteArrayResource(), this.pdfDocumentPath(), true) - } - - private fun String.pdfDocumentPath() = - "$TOUCH_UP_KIT_FILES_PATH/$this.pdf" - - private fun TouchUpKit.pdfUrl() = - "${configService.getContent(ConfigurationType.INSTANCE_URL)}$TOUCH_UP_KIT_CONTROLLER_PATH/pdf?job=$project" -} +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/UserService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/UserService.kt new file mode 100644 index 0000000..4792397 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/UserService.kt @@ -0,0 +1,103 @@ +package dev.fyloz.colorrecipesexplorer.service + +import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent +import dev.fyloz.colorrecipesexplorer.dtos.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.UserDto +import dev.fyloz.colorrecipesexplorer.model.account.Permission +import dev.fyloz.colorrecipesexplorer.model.account.User +import dev.fyloz.colorrecipesexplorer.model.account.flat +import dev.fyloz.colorrecipesexplorer.repository.UserRepository +import org.springframework.data.repository.findByIdOrNull + +interface UserService : Service { + /** Checks if a user with the given [firstName] and [lastName] exists. */ + fun existsByFirstNameAndLastName(firstName: String, lastName: String, id: Long? = null): Boolean + + /** Gets all users, depending on [isSystemUser] and [isDefaultGroupUser]. */ + fun getAll(isSystemUser: Boolean, isDefaultGroupUser: Boolean): Collection + + /** Gets all users for the given [group]. */ + fun getAllByGroup(group: GroupDto): Collection + + /** Finds the user with the given [id], depending on [isSystemUser] and [isDefaultGroupUser]. */ + fun getById(id: Long, isSystemUser: Boolean, isDefaultGroupUser: Boolean): UserDto? + + /** Finds the user with the given [firstName] and [lastName]. */ + fun getByFirstNameAndLastName(firstName: String, lastName: String): UserDto? + + /** Find the default user for the given [group]. */ + fun getDefaultGroupUser(group: GroupDto): UserDto? +} + +@ServiceComponent +class DefaultUserService(repository: UserRepository, private val groupService: GroupService) : + BaseService(repository), UserService { + override fun existsByFirstNameAndLastName(firstName: String, lastName: String, id: Long?) = + repository.existsByFirstNameAndLastNameAndIdNot(firstName, lastName, id ?: 0L) + + override fun getAll(isSystemUser: Boolean, isDefaultGroupUser: Boolean) = + repository.findAll() + .filter { isSystemUser || !it.isSystemUser } + .filter { isDefaultGroupUser || !it.isDefaultGroupUser } + .map(::toDto) + + override fun getAllByGroup(group: GroupDto) = + repository.findAllByGroup(groupService.toEntity(group)) + .map(::toDto) + + override fun getById(id: Long, isSystemUser: Boolean, isDefaultGroupUser: Boolean): UserDto? { + val user = repository.findByIdOrNull(id) ?: return null + if ((!isSystemUser && user.isSystemUser) || + !isDefaultGroupUser && user.isDefaultGroupUser + ) { + return null + } + + return toDto(user) + } + + override fun getByFirstNameAndLastName(firstName: String, lastName: String): UserDto? { + val user = repository.findByFirstNameAndLastName(firstName, lastName) + return if (user != null) toDto(user) else null + } + + override fun getDefaultGroupUser(group: GroupDto): UserDto? { + val user = repository.findDefaultGroupUser(groupService.toEntity(group)) + return if (user != null) toDto(user) else null + } + + override fun toDto(entity: User) = UserDto( + entity.id, + entity.firstName, + entity.lastName, + entity.password, + if (entity.group != null) groupService.toDto(entity.group) else null, + getFlattenPermissions(entity), + entity.permissions, + entity.lastLoginTime, + entity.isDefaultGroupUser, + entity.isSystemUser + ) + + override fun toEntity(dto: UserDto) = User( + dto.id, + dto.firstName, + dto.lastName, + dto.password, + dto.isDefaultGroupUser, + dto.isSystemUser, + if (dto.group != null) groupService.toEntity(dto.group) else null, + dto.explicitPermissions, + dto.lastLoginTime + ) + + private fun getFlattenPermissions(user: User): List { + val perms = user.permissions.flatMap { it.flat() }.filter { !it.deprecated } + + if (user.group != null) { + return perms + groupService.flattenPermissions(user.group) + } + + return perms + } +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/ResourceFileService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/ResourceFileService.kt deleted file mode 100644 index be9ba6e..0000000 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/files/ResourceFileService.kt +++ /dev/null @@ -1,26 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.service.files - -import org.springframework.core.io.Resource -import org.springframework.core.io.ResourceLoader -import org.springframework.stereotype.Service - -@Service -class ResourceFileService( - private val resourceLoader: ResourceLoader -) : FileService { - override fun exists(path: String) = - path.fullPath().resource.exists() - - override fun read(path: String): Resource = - path.fullPath().resource.also { - if (!it.exists()) { - throw FileNotFoundException(path) - } - } - - override fun String.fullPath() = - FilePath("classpath:${this}") - - val FilePath.resource: Resource - get() = resourceLoader.getResource(this.path) -} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/users/GroupService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/users/GroupService.kt deleted file mode 100644 index 62ac1f0..0000000 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/users/GroupService.kt +++ /dev/null @@ -1,97 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.service.users - -import dev.fyloz.colorrecipesexplorer.config.security.defaultGroupCookieName -import dev.fyloz.colorrecipesexplorer.model.account.* -import dev.fyloz.colorrecipesexplorer.repository.GroupRepository -import dev.fyloz.colorrecipesexplorer.service.AbstractExternalNamedModelService -import dev.fyloz.colorrecipesexplorer.service.ExternalNamedModelService -import org.springframework.context.annotation.Profile -import org.springframework.stereotype.Service -import org.springframework.web.util.WebUtils -import javax.servlet.http.HttpServletRequest -import javax.servlet.http.HttpServletResponse -import javax.transaction.Transactional - -const val defaultGroupCookieMaxAge = 10 * 365 * 24 * 60 * 60 // 10 ans - -interface GroupService : - ExternalNamedModelService { - /** Gets all the users of the group with the given [id]. */ - fun getUsersForGroup(id: Long): Collection - - /** Gets the default group from a cookie in the given HTTP [request]. */ - fun getRequestDefaultGroup(request: HttpServletRequest): Group - - /** Sets the default group cookie for the given HTTP [response]. */ - fun setResponseDefaultGroup(groupId: Long, response: HttpServletResponse) -} - -@Service -@Profile("!emergency") -class GroupServiceImpl( - private val userService: UserService, - groupRepository: GroupRepository -) : AbstractExternalNamedModelService( - groupRepository -), - GroupService { - override fun idNotFoundException(id: Long) = groupIdNotFoundException(id) - override fun idAlreadyExistsException(id: Long) = groupIdAlreadyExistsException(id) - override fun nameNotFoundException(name: String) = groupNameNotFoundException(name) - override fun nameAlreadyExistsException(name: String) = groupNameAlreadyExistsException(name) - - override fun Group.toOutput() = GroupOutputDto( - this.id!!, - this.name, - this.permissions, - this.flatPermissions - ) - - override fun existsByName(name: String): Boolean = repository.existsByName(name) - override fun getUsersForGroup(id: Long): Collection = - userService.getByGroup(getById(id)) - - @Transactional - override fun save(entity: Group): Group { - return super.save(entity).apply { - userService.saveDefaultGroupUser(this) - } - } - - override fun update(entity: GroupUpdateDto): Group { - val persistedGroup by lazy { getById(entity.id) } - return update(with(entity) { - Group( - entity.id, - if (name.isNotBlank()) entity.name else persistedGroup.name, - if (permissions.isNotEmpty()) entity.permissions else persistedGroup.permissions - ) - }) - } - - @Transactional - override fun delete(entity: Group) { - userService.delete(userService.getDefaultGroupUser(entity)) - super.delete(entity) - } - - override fun getRequestDefaultGroup(request: HttpServletRequest): Group { - val defaultGroupCookie = WebUtils.getCookie(request, defaultGroupCookieName) - ?: throw NoDefaultGroupException() - val defaultGroupUser = userService.getById( - defaultGroupCookie.value.toLong(), - ignoreDefaultGroupUsers = false, - ignoreSystemUsers = true - ) - return defaultGroupUser.group!! - } - - override fun setResponseDefaultGroup(groupId: Long, response: HttpServletResponse) { - val group = getById(groupId) - val defaultGroupUser = userService.getDefaultGroupUser(group) - response.addHeader( - "Set-Cookie", - "$defaultGroupCookieName=${defaultGroupUser.id}; Max-Age=$defaultGroupCookieMaxAge; Path=/api; HttpOnly; Secure; SameSite=strict" - ) - } -} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/users/UserService.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/users/UserService.kt deleted file mode 100644 index 04add2b..0000000 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/service/users/UserService.kt +++ /dev/null @@ -1,196 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.service.users - -import dev.fyloz.colorrecipesexplorer.config.security.blacklistedJwtTokens -import dev.fyloz.colorrecipesexplorer.model.account.* -import dev.fyloz.colorrecipesexplorer.model.validation.or -import dev.fyloz.colorrecipesexplorer.repository.UserRepository -import dev.fyloz.colorrecipesexplorer.service.AbstractExternalModelService -import dev.fyloz.colorrecipesexplorer.service.ExternalModelService -import org.springframework.context.annotation.Lazy -import org.springframework.context.annotation.Profile -import org.springframework.security.crypto.password.PasswordEncoder -import org.springframework.stereotype.Service -import org.springframework.web.util.WebUtils -import java.time.LocalDateTime -import javax.servlet.http.HttpServletRequest - -interface UserService : - ExternalModelService { - /** Check if an [User] with the given [firstName] and [lastName] exists. */ - fun existsByFirstNameAndLastName(firstName: String, lastName: String): Boolean - - /** Gets the user with the given [id]. */ - fun getById(id: Long, ignoreDefaultGroupUsers: Boolean, ignoreSystemUsers: Boolean): User - - /** Gets all users which have the given [group]. */ - fun getByGroup(group: Group): Collection - - /** Gets the default user of the given [group]. */ - fun getDefaultGroupUser(group: Group): User - - /** Save a default group user for the given [group]. */ - fun saveDefaultGroupUser(group: Group) - - /** Updates de given [entity]. **/ - fun update(entity: User, ignoreDefaultGroupUsers: Boolean, ignoreSystemUsers: Boolean): User - - /** Updates the last login time of the user with the given [userId]. */ - fun updateLastLoginTime(userId: Long, time: LocalDateTime = LocalDateTime.now()): User - - /** Updates the password of the user with the given [id]. */ - fun updatePassword(id: Long, password: String): User - - /** Adds the given [permission] to the user with the given [userId]. */ - fun addPermission(userId: Long, permission: Permission): User - - /** Removes the given [permission] from the user with the given [userId]. */ - fun removePermission(userId: Long, permission: Permission): User - - /** Logout an user. Add the authorization token of the given [request] to the blacklisted tokens. */ - fun logout(request: HttpServletRequest) -} - -@Service -@Profile("!emergency") -class UserServiceImpl( - userRepository: UserRepository, - @Lazy val groupService: GroupService, -) : AbstractExternalModelService( - userRepository -), - UserService { - override fun idNotFoundException(id: Long) = userIdNotFoundException(id) - override fun idAlreadyExistsException(id: Long) = userIdAlreadyExistsException(id) - - override fun User.toOutput() = this.toOutputDto() - - override fun existsByFirstNameAndLastName(firstName: String, lastName: String): Boolean = - repository.existsByFirstNameAndLastName(firstName, lastName) - - override fun getAll(): Collection = - super.getAll().filter { !it.isSystemUser && !it.isDefaultGroupUser } - - override fun getById(id: Long): User = - getById(id, ignoreDefaultGroupUsers = true, ignoreSystemUsers = true) - - override fun getById(id: Long, ignoreDefaultGroupUsers: Boolean, ignoreSystemUsers: Boolean): User = - super.getById(id).apply { - if (ignoreSystemUsers && isSystemUser || ignoreDefaultGroupUsers && isDefaultGroupUser) - throw idNotFoundException(id) - } - - override fun getByGroup(group: Group): Collection = - repository.findAllByGroup(group).filter { - !it.isSystemUser && !it.isDefaultGroupUser - } - - override fun getDefaultGroupUser(group: Group): User = - repository.findByIsDefaultGroupUserIsTrueAndGroupIs(group) - - override fun save(entity: UserSaveDto): User = - save(with(entity) { - user( - id = id, - firstName = firstName, - lastName = lastName, - plainPassword = password, - isDefaultGroupUser = false, - isSystemUser = false, - group = if (groupId != null) groupService.getById(groupId) else null, - permissions = permissions - ) - }) - - override fun save(entity: User): User { - if (existsById(entity.id)) - throw userIdAlreadyExistsException(entity.id) - if (existsByFirstNameAndLastName(entity.firstName, entity.lastName)) - throw userFullNameAlreadyExistsException(entity.firstName, entity.lastName) - return super.save(entity) - } - - override fun saveDefaultGroupUser(group: Group) { - save( - user( - id = 1000000L + group.id!!, - firstName = group.name, - lastName = "User", - plainPassword = group.name, - group = group, - isDefaultGroupUser = true - ) - ) - } - - override fun updateLastLoginTime(userId: Long, time: LocalDateTime): User { - val user = getById(userId, ignoreDefaultGroupUsers = true, ignoreSystemUsers = false) - user.lastLoginTime = time - return update( - user, - ignoreDefaultGroupUsers = true, - ignoreSystemUsers = false - ) - } - - override fun update(entity: UserUpdateDto): User { - val persistedUser by lazy { getById(entity.id) } - return update(with(entity) { - User( - id = id, - firstName = firstName or persistedUser.firstName, - lastName = lastName or persistedUser.lastName, - password = persistedUser.password, - isDefaultGroupUser = false, - isSystemUser = false, - group = if (entity.groupId != null) groupService.getById(entity.groupId) else persistedUser.group, - permissions = permissions?.toMutableSet() ?: persistedUser.permissions, - lastLoginTime = persistedUser.lastLoginTime - ) - }) - } - - override fun update(entity: User): User = - update(entity, ignoreDefaultGroupUsers = true, ignoreSystemUsers = true) - - override fun update(entity: User, ignoreDefaultGroupUsers: Boolean, ignoreSystemUsers: Boolean): User { - with(repository.findByFirstNameAndLastName(entity.firstName, entity.lastName)) { - if (this != null && id != entity.id) - throw userFullNameAlreadyExistsException(entity.firstName, entity.lastName) - } - - return super.update(entity) - } - - override fun updatePassword(id: Long, password: String): User { - val persistedUser = getById(id, ignoreDefaultGroupUsers = true, ignoreSystemUsers = true) - return super.update(with(persistedUser) { - user( - id, - firstName, - lastName, - plainPassword = password, - isDefaultGroupUser, - isSystemUser, - group, - permissions, - lastLoginTime - ) - }) - } - - override fun addPermission(userId: Long, permission: Permission): User = - super.update(getById(userId).apply { permissions += permission }) - - override fun removePermission(userId: Long, permission: Permission): User = - super.update(getById(userId).apply { permissions -= permission }) - - override fun logout(request: HttpServletRequest) { - val authorizationCookie = WebUtils.getCookie(request, "Authorization") - if (authorizationCookie != null) { - val authorizationToken = authorizationCookie.value - if (authorizationToken != null && authorizationToken.startsWith("Bearer")) { - blacklistedJwtTokens.add(authorizationToken) - } - } - } -} diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Collections.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Collections.kt index 00b853c..5ce54ee 100644 --- a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Collections.kt +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Collections.kt @@ -1,6 +1,6 @@ package dev.fyloz.colorrecipesexplorer.utils -import dev.fyloz.colorrecipesexplorer.model.Model +import dev.fyloz.colorrecipesexplorer.dtos.EntityDto /** Returns a list containing the result of the given [transform] applied to each item of the [Iterable]. If the given [transform] throws, the [Throwable] will be passed to the given [throwableConsumer]. */ inline fun Iterable.mapMayThrow( @@ -19,17 +19,15 @@ inline fun Iterable.mapMayThrow( } } -/** Find duplicated in the given [Iterable] from keys obtained from the given [keySelector]. */ -inline fun Iterable.findDuplicated(keySelector: (T) -> K) = - this.groupBy(keySelector) +/** Find duplicated elements in the given [Iterable]. */ +fun Iterable.findDuplicated() = + this.groupBy { it } .filter { it.value.count() > 1 } .map { it.key } -/** Check if the given [Iterable] has gaps between each items, using keys obtained from the given [keySelector]. */ -inline fun Iterable.hasGaps(keySelector: (T) -> Int) = - this.map(keySelector) - .toIntArray() - .sorted() +/** Check if the given [Int] [Iterable] has gaps between each element. */ +fun Iterable.hasGaps() = + this.sorted() .filterIndexed { index, it -> it != index + 1 } .isNotEmpty() @@ -46,8 +44,20 @@ inline fun MutableCollection.excludeAll(predicate: (T) -> Boolean): Itera return matching } -/** Merge to [Model] [Iterable]s and prevent id duplication. */ -fun Iterable.merge(other: Iterable) = - this - .filter { model -> other.all { it.id != model.id } } - .plus(other) +/** + * Merge two [EntityDto] [Iterable]s and prevent duplication of their ids. + * In case of collision, the items from the [other] iterable will be taken. + */ +@JvmName("mergeDto") +fun Iterable.merge(other: Iterable) = + this.merge(other) { it.id } + +/** + * Merge two [Iterable]s and prevent duplication of the keys determined by the given [keyMapper]. + * In case of collision, the items from the [other] iterable will be taken. + */ +fun Iterable.merge(other: Iterable, keyMapper: (T) -> K) = + this.associateBy { keyMapper(it) } + .filter { pair -> other.all { keyMapper(it) != pair.key } } + .map { it.value } + .plus(other) \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Files.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Files.kt new file mode 100644 index 0000000..33c0edd --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/Files.kt @@ -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 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()) +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/PositionUtils.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/PositionUtils.kt new file mode 100644 index 0000000..858a280 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/PositionUtils.kt @@ -0,0 +1,50 @@ +package dev.fyloz.colorrecipesexplorer.utils + +import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionError +import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionsException + +object PositionUtils { + const val INVALID_FIRST_POSITION_ERROR_CODE = "first" + const val DUPLICATED_POSITION_ERROR_CODE = "duplicated" + const val GAP_BETWEEN_POSITIONS_ERROR_CODE = "gap" + + private const val FIRST_POSITION = 1 + + fun validate(positions: List) { + if (positions.isEmpty()) { + return + } + + val sortedPositions = positions.sorted() + val errors = mutableSetOf() + + validateFirstPosition(sortedPositions[0], errors) + validateDuplicatedPositions(sortedPositions, errors) + validateGapsInPositions(sortedPositions, errors) + + if (errors.isNotEmpty()) { + throw InvalidPositionsException(errors) + } + } + + private fun validateFirstPosition(position: Int, errors: MutableSet) { + if (position == FIRST_POSITION) { + return + } + + errors += InvalidPositionError(INVALID_FIRST_POSITION_ERROR_CODE, "The first position must be $FIRST_POSITION") + } + + private fun validateDuplicatedPositions(positions: List, errors: MutableSet) { + errors += positions.findDuplicated() + .map { InvalidPositionError(DUPLICATED_POSITION_ERROR_CODE, "The position $it is duplicated") } + } + + private fun validateGapsInPositions(positions: List, errors: MutableSet) { + if (!positions.hasGaps()) { + return + } + + errors += InvalidPositionError(GAP_BETWEEN_POSITIONS_ERROR_CODE, "There is a gap between the positions") + } +} \ No newline at end of file diff --git a/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/collections/LazyMapList.kt b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/collections/LazyMapList.kt new file mode 100644 index 0000000..7863c65 --- /dev/null +++ b/src/main/kotlin/dev/fyloz/colorrecipesexplorer/utils/collections/LazyMapList.kt @@ -0,0 +1,26 @@ +package dev.fyloz.colorrecipesexplorer.utils.collections + +class LazyMapList(private val sourceList: List, private val transform: (T) -> R) : List { + private val list by lazy { sourceList.map(transform) } + + fun initialize() { + // Call a property so the list is initialized + size + } + + override val size: Int + get() = list.size + + override fun contains(element: R) = list.contains(element) + override fun containsAll(elements: Collection) = list.containsAll(elements) + override fun get(index: Int) = list[index] + override fun indexOf(element: R) = list.indexOf(element) + override fun isEmpty() = list.isEmpty() + override fun iterator() = list.iterator() + override fun lastIndexOf(element: R) = list.lastIndexOf(element) + override fun listIterator() = list.listIterator() + override fun listIterator(index: Int) = list.listIterator(index) + override fun subList(fromIndex: Int, toIndex: Int) = list.subList(fromIndex, toIndex) +} + +fun List.lazyMap(transform: (T) -> R) = LazyMapList(this, transform) \ No newline at end of file diff --git a/src/main/resources/application-debug.properties b/src/main/resources/application-debug.properties new file mode 100644 index 0000000..72fc330 --- /dev/null +++ b/src/main/resources/application-debug.properties @@ -0,0 +1 @@ +spring.jpa.show-sql=true \ No newline at end of file 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/logic/BaseLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/BaseLogicTest.kt new file mode 100644 index 0000000..bebab7d --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/BaseLogicTest.kt @@ -0,0 +1,173 @@ +package dev.fyloz.colorrecipesexplorer.logic + +import dev.fyloz.colorrecipesexplorer.dtos.EntityDto +import dev.fyloz.colorrecipesexplorer.exception.NotFoundException +import dev.fyloz.colorrecipesexplorer.model.ModelEntity +import dev.fyloz.colorrecipesexplorer.service.Service +import io.mockk.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.data.jpa.repository.JpaRepository +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BaseLogicTest { + private val serviceMock = mockk>>() + + private val baseLogic = spyk(TestBaseLogic(serviceMock)) + + private val dto = TestEntityDto(id = 1L) + + @AfterEach + internal fun afterEach() { + clearAllMocks() + } + + @Test + fun existsById_normalBehavior_returnsTrue() { + // Arrange + every { serviceMock.existsById(any()) } returns true + + // Act + val exists = baseLogic.existsById(dto.id) + + // Assert + assertTrue(exists) + } + + @Test + fun exists_notFound_returnsFalse() { + // Arrange + every { serviceMock.existsById(any()) } returns false + + // Act + val exists = baseLogic.existsById(dto.id) + + // Assert + assertFalse(exists) + } + + @Test + fun getAll_normalBehavior_returnsAllDtos() { + // Arrange + val expectedDtos = listOf(dto) + + every { serviceMock.getAll() } returns expectedDtos + + // Act + val actualDtos = baseLogic.getAll() + + // Assert + assertEquals(expectedDtos, actualDtos) + } + + @Test + fun getById_normalBehavior_returnsDtoWithGivenId() { + // Arrange + every { serviceMock.getById(any()) } returns dto + + // Act + val dtoById = baseLogic.getById(dto.id) + + // Assert + assertEquals(dto, dtoById) + } + + @Test + fun getById_notFound_throwsNotFoundException() { + // Arrange + every { serviceMock.getById(any()) } returns null + + // Act + // Assert + assertThrows { baseLogic.getById(dto.id) } + } + + @Test + fun save_normalBehavior_callsServiceSave() { + // Arrange + every { serviceMock.save(any()) } returns dto + + // Act + baseLogic.save(dto) + + // Assert + verify { + serviceMock.save(dto) + } + confirmVerified(serviceMock) + } + + @Test + fun save_normalBehavior_returnsSavedDto() { + // Arrange + every { serviceMock.save(any()) } returns dto + + // Act + val savedDto = baseLogic.save(dto) + + // Assert + assertEquals(dto, savedDto) + } + + @Test + fun update_normalBehavior_callsServiceSave() { + // Arrange + every { serviceMock.save(any()) } returns dto + every { baseLogic.existsById(any()) } returns true + + // Act + baseLogic.update(dto) + + // Assert + verify { + serviceMock.save(dto) + } + confirmVerified(serviceMock) + } + + @Test + fun update_normalBehavior_returnsUpdatedDto() { + // Arrange + every { serviceMock.save(any()) } returns dto + every { baseLogic.existsById(any()) } returns true + + // Act + val updatedDto = baseLogic.update(dto) + + // Assert + assertEquals(dto, updatedDto) + } + + @Test + fun update_notFound_throwsNotFoundException() { + // Arrange + every { serviceMock.save(any()) } returns dto + every { baseLogic.existsById(any()) } returns false + + // Act + // Assert + assertThrows { baseLogic.update(dto) } + } + + @Test + fun deleteById_normalBehavior_callsServiceDeleteById() { + // Arrange + every { serviceMock.deleteById(any()) } just runs + + // Act + baseLogic.deleteById(dto.id) + + // Assert + verify { + serviceMock.deleteById(dto.id) + } + confirmVerified(serviceMock) + } +} + +private data class TestEntityDto(override val id: Long) : EntityDto +private class TestBaseLogic>(service: S) : + BaseLogic(service, "UnitTestType") \ No newline at end of file diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/ConfigurationServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/ConfigurationLogicTest.kt similarity index 72% rename from src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/ConfigurationServiceTest.kt rename to src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/ConfigurationLogicTest.kt index ea4a73f..82dc865 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/ConfigurationServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/ConfigurationLogicTest.kt @@ -1,12 +1,12 @@ -package dev.fyloz.colorrecipesexplorer.service +package dev.fyloz.colorrecipesexplorer.logic import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties +import dev.fyloz.colorrecipesexplorer.logic.config.CONFIGURATION_FORMATTED_LIST_DELIMITER +import dev.fyloz.colorrecipesexplorer.logic.config.ConfigurationSource +import dev.fyloz.colorrecipesexplorer.logic.config.DefaultConfigurationLogic +import dev.fyloz.colorrecipesexplorer.logic.files.ResourceFileLogic +import dev.fyloz.colorrecipesexplorer.logic.files.WriteableFileLogic import dev.fyloz.colorrecipesexplorer.model.* -import dev.fyloz.colorrecipesexplorer.service.config.CONFIGURATION_FORMATTED_LIST_DELIMITER -import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationServiceImpl -import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationSource -import dev.fyloz.colorrecipesexplorer.service.files.ResourceFileService -import dev.fyloz.colorrecipesexplorer.service.files.WriteableFileService import dev.fyloz.colorrecipesexplorer.utils.encrypt import io.mockk.* import org.junit.jupiter.api.AfterEach @@ -18,20 +18,19 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue -class ConfigurationServiceTest { - private val fileService = mockk() - private val resourceFileService = mockk() +class ConfigurationLogicTest { + private val fileLogic = mockk() + private val resourceFileLogic = mockk() private val configurationSource = mockk() private val securityProperties = mockk { every { configSalt } returns "d32270943af7e1cc" } - private val service = spyk( - ConfigurationServiceImpl( - fileService, - resourceFileService, + private val logic = spyk( + DefaultConfigurationLogic( + fileLogic, + resourceFileLogic, configurationSource, - securityProperties, - mockk() + securityProperties ) ) @@ -44,17 +43,17 @@ class ConfigurationServiceTest { @Test fun `getAll() gets the Configuration of each ConfigurationType`() { - every { service.get(any()) } answers { throw ConfigurationNotSetException(this.args[0] as ConfigurationType) } + every { logic.get(any()) } answers { throw ConfigurationNotSetException(this.args[0] as ConfigurationType) } - service.getAll() + logic.getAll() verify { - service.getAll() + logic.getAll() ConfigurationType.values().forEach { - service.get(it) + logic.get(it) } } - confirmVerified(service) + confirmVerified(logic) } @Test @@ -65,15 +64,15 @@ class ConfigurationServiceTest { ConfigurationType.INSTANCE_ICON_SET ) - every { service.get(match { it in unsetConfigurationTypes }) } answers { + every { logic.get(match { it in unsetConfigurationTypes }) } answers { throw ConfigurationNotSetException(this.firstArg() as ConfigurationType) } - every { service.get(match { it !in unsetConfigurationTypes }) } answers { + every { logic.get(match { it !in unsetConfigurationTypes }) } answers { val type = firstArg() configuration(type, type.key) } - val found = service.getAll() + val found = logic.getAll() assertFalse { found.any { @@ -82,12 +81,12 @@ class ConfigurationServiceTest { } verify { - service.getAll() + logic.getAll() ConfigurationType.values().forEach { - service.get(it) + logic.get(it) } } - confirmVerified(service) + confirmVerified(logic) } @Test @@ -101,24 +100,24 @@ class ConfigurationServiceTest { .map { it.key } .reduce { acc, s -> "$acc$CONFIGURATION_FORMATTED_LIST_DELIMITER$s" } - every { service.get(any()) } answers { + every { logic.get(any()) } answers { val key = firstArg() configuration(key.toConfigurationType(), key) } - val found = service.getAll(formattedKeyList) + val found = logic.getAll(formattedKeyList) assertTrue { found.all { it.type in configurationTypes } } verify { - service.getAll(formattedKeyList) + logic.getAll(formattedKeyList) configurationTypes.forEach { - service.get(it.key) + logic.get(it.key) } } - confirmVerified(service) + confirmVerified(logic) } // get() @@ -128,18 +127,18 @@ class ConfigurationServiceTest { val type = ConfigurationType.INSTANCE_ICON_SET val key = type.key - every { service.get(type) } answers { + every { logic.get(type) } answers { val type = firstArg() configuration(type, type.key) } - service.get(key) + logic.get(key) verify { - service.get(key) - service.get(type) + logic.get(key) + logic.get(type) } - confirmVerified(service) + confirmVerified(logic) } @Test @@ -149,7 +148,7 @@ class ConfigurationServiceTest { every { configurationSource.get(type) } returns configuration - val found = service.get(type) + val found = logic.get(type) assertEquals(configuration, found) } @@ -160,12 +159,12 @@ class ConfigurationServiceTest { every { configurationSource.get(type) } returns null - with(assertThrows { service.get(type) }) { + with(assertThrows { logic.get(type) }) { assertEquals(type, this.type) } verify { - service.get(type) + logic.get(type) configurationSource.get(type) } } @@ -174,7 +173,7 @@ class ConfigurationServiceTest { fun `get(type) throws InvalidConfigurationKeyException when the given ConfigurationType is encryption salt`() { val type = ConfigurationType.GENERATED_ENCRYPTION_SALT - assertThrows { service.get(type) } + assertThrows { logic.get(type) } } @Test @@ -187,7 +186,7 @@ class ConfigurationServiceTest { every { configurationSource.get(type) } returns configuration - val found = service.get(type) + val found = logic.get(type) assertTrue { found is SecureConfiguration } } @@ -200,9 +199,9 @@ class ConfigurationServiceTest { content = "content" ) - every { service.get(type) } returns configuration + every { logic.get(type) } returns configuration - val found = service.getContent(type) + val found = logic.getContent(type) assertEquals(configuration.content, found) } @@ -212,9 +211,9 @@ class ConfigurationServiceTest { val type = ConfigurationType.DATABASE_PASSWORD val configuration = secureConfiguration(type) - every { service.get(type) } returns configuration + every { logic.get(type) } returns configuration - assertThrows { service.getContent(type) } + assertThrows { logic.getContent(type) } } @Test @@ -228,7 +227,7 @@ class ConfigurationServiceTest { every { configurationSource.get(type) } returns configuration - val found = service.getSecure(type) + val found = logic.getSecure(type) assertEquals(content, found) } @@ -237,7 +236,7 @@ class ConfigurationServiceTest { fun `getSecure(type) throws UnsupportedOperationException when configuration is not secure`() { val type = ConfigurationType.INSTANCE_NAME - assertThrows { service.getSecure(type) } + assertThrows { logic.getSecure(type) } } private fun getConfiguredImageTest( @@ -247,9 +246,9 @@ class ConfigurationServiceTest { ) { val resource = mockk() val configuration = configuration(configurationType, imageSet.toString()) - val imageService = if (imageSet) fileService else resourceFileService + val imageService = if (imageSet) fileLogic else resourceFileLogic - every { service.get(configurationType) } returns configuration + every { logic.get(configurationType) } returns configuration every { imageService.read(any()) } returns resource test(resource) @@ -258,7 +257,7 @@ class ConfigurationServiceTest { @Test fun `getConfiguredIcon() gets icon from resources when INSTANCE_ICON_SET configuration is false`() { getConfiguredImageTest(ConfigurationType.INSTANCE_ICON_SET, false) { resource -> - val found = service.getConfiguredIcon() + val found = logic.getConfiguredIcon() assertEquals(resource, found) } @@ -267,7 +266,7 @@ class ConfigurationServiceTest { @Test fun `getConfiguredIcon() gets icon from files when INSTANCE_ICON_SET configuration is true`() { getConfiguredImageTest(ConfigurationType.INSTANCE_ICON_SET, true) { resource -> - val found = service.getConfiguredIcon() + val found = logic.getConfiguredIcon() assertEquals(resource, found) } @@ -276,7 +275,7 @@ class ConfigurationServiceTest { @Test fun `getConfiguredLogo() gets logo from resources when INSTANCE_LOGO_SET is false`() { getConfiguredImageTest(ConfigurationType.INSTANCE_LOGO_SET, false) { resource -> - val found = service.getConfiguredLogo() + val found = logic.getConfiguredLogo() assertEquals(resource, found) } @@ -285,7 +284,7 @@ class ConfigurationServiceTest { @Test fun `getConfiguredLogo() gets logo from files when INSTANCE_LOGO_SET is true`() { getConfiguredImageTest(ConfigurationType.INSTANCE_LOGO_SET, true) { resource -> - val found = service.getConfiguredLogo() + val found = logic.getConfiguredLogo() assertEquals(resource, found) } @@ -297,7 +296,7 @@ class ConfigurationServiceTest { every { configurationSource.set(any()) } just runs - service.set(configuration) + logic.set(configuration) verify { configurationSource.set(configuration) @@ -316,7 +315,7 @@ class ConfigurationServiceTest { every { configurationSource.set(any()) } just runs every { content.encrypt(any(), any()) } returns encryptedContent - service.set(configuration) + logic.set(configuration) verify { configurationSource.set(match { @@ -328,8 +327,8 @@ class ConfigurationServiceTest { private fun setConfiguredImageTest(test: (MultipartFile) -> Unit) { val file = mockk() - every { service.set(any()) } just runs - every { fileService.write(any(), any(), any()) } just runs + every { logic.set(any()) } just runs + every { fileLogic.write(any(), any(), any()) } just runs test(file) } @@ -337,10 +336,10 @@ class ConfigurationServiceTest { @Test fun `setConfiguredIcon() sets icon in files`() { setConfiguredImageTest { file -> - service.setConfiguredIcon(file) + logic.setConfiguredIcon(file) verify { - fileService.write(file, any(), true) + fileLogic.write(file, any(), true) } } } @@ -350,10 +349,10 @@ class ConfigurationServiceTest { val type = ConfigurationType.INSTANCE_ICON_SET setConfiguredImageTest { file -> - service.setConfiguredIcon(file) + logic.setConfiguredIcon(file) verify { - service.set(match { + logic.set(match { it.key == type.key && it.content == true.toString() }) } @@ -363,10 +362,10 @@ class ConfigurationServiceTest { @Test fun `setConfiguredLogo() sets logo in files`() { setConfiguredImageTest { file -> - service.setConfiguredLogo(file) + logic.setConfiguredLogo(file) verify { - fileService.write(file, any(), true) + fileLogic.write(file, any(), true) } } } @@ -376,10 +375,10 @@ class ConfigurationServiceTest { val type = ConfigurationType.INSTANCE_LOGO_SET setConfiguredImageTest { file -> - service.setConfiguredLogo(file) + logic.setConfiguredLogo(file) verify { - service.set(match { + logic.set(match { it.key == type.key && it.content == true.toString() }) } diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultCompanyLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultCompanyLogicTest.kt new file mode 100644 index 0000000..0cde4a3 --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultCompanyLogicTest.kt @@ -0,0 +1,55 @@ +package dev.fyloz.colorrecipesexplorer.logic + +import dev.fyloz.colorrecipesexplorer.dtos.CompanyDto +import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException +import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException +import dev.fyloz.colorrecipesexplorer.service.CompanyService +import io.mockk.clearAllMocks +import io.mockk.every +import io.mockk.mockk +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class DefaultCompanyLogicTest { + private val companyServiceMock = mockk() + + private val companyLogic = DefaultCompanyLogic(companyServiceMock) + + private val company = CompanyDto(id = 1L, name = "UnitTestCompany") + + @AfterEach + internal fun afterEach() { + clearAllMocks() + } + + @Test + fun save_nameExists_throwsAlreadyExistsException() { + // Arrange + every { companyServiceMock.existsByName(any(), any()) } returns true + + // Act + // Assert + assertThrows { companyLogic.save(company) } + } + + @Test + fun update_nameExists_throwsAlreadyExistsException() { + // Arrange + every { companyServiceMock.existsByName(any(), any()) } returns true + + // Act + // Assert + assertThrows { companyLogic.update(company) } + } + + @Test + fun deleteById_recipesDependsOnCompany_throwsCannotDeleteException() { + // Arrange + every { companyServiceMock.isUsedByRecipe(company.id) } returns true + + // Act + // Assert + assertThrows { companyLogic.deleteById(company.id) } + } +} \ No newline at end of file diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultInventoryLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultInventoryLogicTest.kt new file mode 100644 index 0000000..0a82d67 --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultInventoryLogicTest.kt @@ -0,0 +1,247 @@ +package dev.fyloz.colorrecipesexplorer.logic + +import dev.fyloz.colorrecipesexplorer.dtos.* +import io.mockk.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import kotlin.test.assertEquals + +class DefaultInventoryLogicTest { + private val materialLogicMock = mockk() + private val mixLogicMock = mockk() + + private val inventoryLogic = spyk(DefaultInventoryLogic(materialLogicMock, mixLogicMock)) + + private val defaultInventoryQuantity = 5000f + private val materialType = MaterialTypeDto(1L, "Unit test material type", "UTMT", false) + private val material = MaterialDto(1L, "Unit test material", defaultInventoryQuantity, false, materialType) + + @AfterEach + internal fun afterEach() { + clearAllMocks() + } + + @Test + fun add_collection_normalBehavior_callsAddSingleForEachQuantity() { + // Arrange + val quantities = setOf( + MaterialQuantityDto(1L, 1000f), + MaterialQuantityDto(2L, 2000f) + ) + val expectedQuantities = quantities.map { it.copy(quantity = it.quantity + defaultInventoryQuantity) } + + every { inventoryLogic.add(any()) } answers { expectedQuantities[this.nArgs].quantity } + + // Act + inventoryLogic.add(quantities) + + // Assert + verify { + quantities.forEach { + inventoryLogic.add(it) + } + } + } + + @Test + fun add_collection_normalBehavior_returnsFromAddSingle() { + // Arrange + val quantities = setOf( + MaterialQuantityDto(1L, 1000f), + MaterialQuantityDto(2L, 2000f) + ) + val expectedQuantities = quantities.map { it.copy(quantity = it.quantity + defaultInventoryQuantity) } + + every { inventoryLogic.add(any()) } returnsMany (expectedQuantities.map { it.quantity }) + + // Act + val actualQuantities = inventoryLogic.add(quantities) + + // Assert + assertEquals(expectedQuantities, actualQuantities) + } + + @Test + fun add_single_normalBehavior_callsUpdateQuantityInMaterialLogic() { + // Arrange + every { materialLogicMock.getById(any()) } returns material + every { materialLogicMock.updateQuantity(any(), any()) } answers { + this.secondArg() + defaultInventoryQuantity + } + + val quantity = MaterialQuantityDto(1L, 1000f) + + // Act + inventoryLogic.add(quantity) + + // Assert + verify { + materialLogicMock.updateQuantity(material, quantity.quantity) + } + } + + @Test + fun deductMix_normalBehavior_callsDeductWithMixMaterials() { + // Arrange + val company = CompanyDto(1L, "Unit test company") + val recipe = RecipeDto( + 1L, + "Unit test recipe", + "Unit test recipe", + "FFFFFF", + 0xf, + null, + null, + false, + "Remark", + company, + mutableListOf(), + listOf() + ) + val mixType = MixTypeDto(1L, "Unit test mix type", materialType) + val mixMaterial = MixMaterialDto(1L, material, 1000f, 1) + val mix = MixDto(1L, null, recipe.id, mixType, MixQuantitiesDto(listOf(mixMaterial), listOf())) + + val dto = MixDeductDto(mix.id, 2f) + val expectedQuantities = listOf(MaterialQuantityDto(material.id, mixMaterial.quantity * dto.ratio)) + + every { mixLogicMock.getById(any()) } returns mix + every { inventoryLogic.deduct(any>()) } returns listOf() + + // Act + inventoryLogic.deductMix(dto) + + // Assert + verify { + inventoryLogic.deduct(expectedQuantities) + } + } + + @Test + fun deductMix_normalBehavior_returnsFromDeduct() { + // Arrange + val company = CompanyDto(1L, "Unit test company") + val recipe = RecipeDto( + 1L, + "Unit test recipe", + "Unit test recipe", + "FFFFFF", + 0xf, + null, + null, + false, + "Remark", + company, + mutableListOf(), + listOf() + ) + val mixType = MixTypeDto(1L, "Unit test mix type", materialType) + val mixMaterial = MixMaterialDto(1L, material, 1000f, 1) + val mix = MixDto(1L, null, recipe.id, mixType, MixQuantitiesDto(listOf(mixMaterial), listOf())) + + val dto = MixDeductDto(mix.id, 2f) + val expectedQuantities = listOf(MaterialQuantityDto(material.id, mixMaterial.quantity * dto.ratio)) + + every { mixLogicMock.getById(any()) } returns mix + every { inventoryLogic.deduct(any>()) } returns expectedQuantities + + // Act + val actualQuantities = inventoryLogic.deductMix(dto) + + // Assert + assertEquals(expectedQuantities, actualQuantities) + } + + @Test + fun deduct_collection_normalBehavior_callsDeductForEachQuantity() { + // Arrange + val quantities = setOf( + MaterialQuantityDto(1L, 1000f), + MaterialQuantityDto(2L, 2000f) + ) + + every { inventoryLogic.deduct(any()) } answers { + defaultInventoryQuantity - firstArg().quantity + } + + // Act + inventoryLogic.deduct(quantities) + + // Assert + verify { + quantities.forEach { + inventoryLogic.deduct(it) + } + } + } + + @Test + fun deduct_collection_normalBehavior_returnsFromDeduct() { + // Arrange + val quantities = setOf( + MaterialQuantityDto(1L, 1000f), + MaterialQuantityDto(2L, 2000f) + ) + + every { inventoryLogic.deduct(any()) } answers { + defaultInventoryQuantity - firstArg().quantity + } + + val expectedQuantities = + quantities.map { MaterialQuantityDto(it.materialId, defaultInventoryQuantity - it.quantity) } + + // Act + val actualQuantities = inventoryLogic.deduct(quantities) + + // Assert + assertEquals(expectedQuantities, actualQuantities) + } + + @Test + fun deduct_collection_notEnoughInventory_throwsMultiplesNotEnoughInventoryException() { + // Arrange + val quantities = setOf( + MaterialQuantityDto(1L, 1000f), + MaterialQuantityDto(2L, 2000f) + ) + + every { inventoryLogic.deduct(any()) } throws NotEnoughInventoryException(1000f, material) + + // Act + // Assert + assertThrows { inventoryLogic.deduct(quantities) } + } + + @Test + fun deduct_single_normalBehavior_callsUpdateQuantityInMaterialLogic() { + // Arrange + val quantity = MaterialQuantityDto(material.id, 1000f) + + every { materialLogicMock.getById(any()) } returns material + every { materialLogicMock.updateQuantity(any(), any()) } returns defaultInventoryQuantity - quantity.quantity + + // Act + inventoryLogic.deduct(quantity) + + // Assert + verify { + materialLogicMock.getById(quantity.materialId) + materialLogicMock.updateQuantity(material, -quantity.quantity) + } + confirmVerified(materialLogicMock) + } + + @Test + fun deduct_single_notEnoughInventory_throwsNotEnoughInventoryException() { + // Arrange + val quantity = MaterialQuantityDto(material.id, material.inventoryQuantity + 1000f) + + every { materialLogicMock.getById(any()) } returns material + every { materialLogicMock.updateQuantity(any(), any()) } returns defaultInventoryQuantity - quantity.quantity + + // Act + // Assert + assertThrows { inventoryLogic.deduct(quantity) } + } +} \ No newline at end of file diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/JwtServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultJwtLogicTest.kt similarity index 67% rename from src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/JwtServiceTest.kt rename to src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultJwtLogicTest.kt index 8a0a241..e5ddab7 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/JwtServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultJwtLogicTest.kt @@ -1,26 +1,25 @@ -package dev.fyloz.colorrecipesexplorer.service +package dev.fyloz.colorrecipesexplorer.logic import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import dev.fyloz.colorrecipesexplorer.config.properties.CreSecurityProperties -import dev.fyloz.colorrecipesexplorer.model.account.UserDetails -import dev.fyloz.colorrecipesexplorer.model.account.UserOutputDto -import dev.fyloz.colorrecipesexplorer.model.account.toOutputDto -import dev.fyloz.colorrecipesexplorer.model.account.user -import dev.fyloz.colorrecipesexplorer.service.users.JwtServiceImpl -import dev.fyloz.colorrecipesexplorer.service.users.jwtClaimUser +import dev.fyloz.colorrecipesexplorer.dtos.UserDetails +import dev.fyloz.colorrecipesexplorer.dtos.UserDto +import dev.fyloz.colorrecipesexplorer.logic.users.DefaultJwtLogic +import dev.fyloz.colorrecipesexplorer.logic.users.jwtClaimUser import dev.fyloz.colorrecipesexplorer.utils.base64encode import dev.fyloz.colorrecipesexplorer.utils.isAround import io.jsonwebtoken.Jwts import io.jsonwebtoken.jackson.io.JacksonDeserializer +import io.mockk.clearAllMocks import io.mockk.spyk -import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import java.time.Instant import kotlin.test.assertEquals import kotlin.test.assertTrue -class JwtServiceTest { +class DefaultJwtLogicTest { private val objectMapper = jacksonObjectMapper() private val securityProperties = CreSecurityProperties().apply { jwtSecret = "XRRm7OflmFuCrOB2Xvmfsercih9DCKom" @@ -33,14 +32,16 @@ class JwtServiceTest { .build() } - private val jwtService = spyk(JwtServiceImpl(objectMapper, securityProperties)) + private val jwtService = spyk(DefaultJwtLogic(objectMapper, securityProperties)) - private val user = user() - private val userOutputDto = user.toOutputDto() + private val user = UserDto(0L, "Unit test", "User", "", null, listOf()) - // buildJwt() + @AfterEach + internal fun afterEach() { + clearAllMocks() + } - private fun withParsedUserOutputDto(jwt: String, test: (UserOutputDto) -> Unit) { + private fun withParsedUserOutputDto(jwt: String, test: (UserDto) -> Unit) { val serializedUser = jwtParser.parseClaimsJws(jwt) .body.get(jwtClaimUser, String::class.java) @@ -48,27 +49,27 @@ class JwtServiceTest { } @Test - fun `buildJwt(userDetails) returns jwt string with valid user`() { + fun buildJwt_userDetails_normalBehavior_returnsJwtStringWithValidUser() { val userDetails = UserDetails(user) val builtJwt = jwtService.buildJwt(userDetails) withParsedUserOutputDto(builtJwt) { parsedUser -> - assertEquals(user.toOutputDto(), parsedUser) + assertEquals(user, parsedUser) } } @Test - fun `buildJwt() returns jwt string with valid user`() { + fun buildJwt_user_normalBehavior_returnsJwtStringWithValidUser() { val builtJwt = jwtService.buildJwt(user) withParsedUserOutputDto(builtJwt) { parsedUser -> - assertEquals(user.toOutputDto(), parsedUser) + assertEquals(user, parsedUser) } } @Test - fun `buildJwt() returns jwt string with valid subject`() { + fun buildJwt_user_normalBehavior_returnsJwtStringWithValidSubject() { val builtJwt = jwtService.buildJwt(user) val jwtSubject = jwtParser.parseClaimsJws(builtJwt).body.subject @@ -76,7 +77,7 @@ class JwtServiceTest { } @Test - fun `buildJwt() returns jwt with valid expiration date`() { + fun buildJwt_user_returnsJwtWithValidExpirationDate() { val jwtExpectedExpirationDate = Instant.now().plusSeconds(securityProperties.jwtDuration) val builtJwt = jwtService.buildJwt(user) @@ -90,10 +91,10 @@ class JwtServiceTest { // parseJwt() @Test - fun `parseJwt() returns expected user`() { + fun parseJwt_normalBehavior_returnsExpectedUser() { val jwt = jwtService.buildJwt(user) val parsedUser = jwtService.parseJwt(jwt) - assertEquals(userOutputDto, parsedUser) + assertEquals(user, parsedUser) } } diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultMaterialLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultMaterialLogicTest.kt new file mode 100644 index 0000000..f7a929f --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultMaterialLogicTest.kt @@ -0,0 +1,358 @@ +package dev.fyloz.colorrecipesexplorer.logic + +import dev.fyloz.colorrecipesexplorer.dtos.* +import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException +import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException +import dev.fyloz.colorrecipesexplorer.logic.files.WriteableFileLogic +import dev.fyloz.colorrecipesexplorer.model.Material +import dev.fyloz.colorrecipesexplorer.service.MaterialService +import io.mockk.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.mock.web.MockMultipartFile +import org.springframework.web.multipart.MultipartFile +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class DefaultMaterialLogicTest { + private val materialServiceMock = mockk() + private val recipeLogicMock = mockk() + private val mixLogicMock = mockk() + private val materialTypeLogicMock = mockk() + private val fileLogicMock = mockk() + + private val materialLogic = spyk( + DefaultMaterialLogic( + materialServiceMock, recipeLogicMock, mixLogicMock, materialTypeLogicMock, fileLogicMock + ) + ) + + private val materialType = MaterialTypeDto(1L, "Unit test material type", "UNT", usePercentages = false) + private val material = MaterialDto(1L, "Unit test material", 1000f, false, materialType) + private val materialMixType = material.copy(id = 2L, isMixType = true) + private val materialMixType2 = material.copy(id = 3L, isMixType = true) + private val company = CompanyDto(1L, "Unit test company") + private val recipe = RecipeDto( + 1L, + "Unit test recipe", + "Unit test recipe", + "#FFFFFF", + 0, + 123, + null, + false, + "A remark", + company, + mutableListOf(), + listOf() + ) + private val mix = MixDto( + 1L, "location", recipe.id, mixType = MixTypeDto(1L, "Unit test mix type", materialType), MixQuantitiesDto() + ) + private val mix2 = mix.copy(id = 2L, mixType = mix.mixType.copy(id = 2L, material = materialMixType2)) + + private val simdutFileMock = MockMultipartFile( + "Unit test SIMDUT", + byteArrayOf(1, 2, 3, 4) + ) // Put some content in the mock file, so it is not ignored + private val materialSaveDto = MaterialSaveDto(1L, "Unit test material", 1000f, materialType.id, simdutFileMock) + + @AfterEach + internal fun afterEach() { + clearAllMocks() + } + + @Test + fun existsByName_normalBehavior_returnsTrue() { + // Arrange + every { materialServiceMock.existsByName(any(), any()) } returns true + + // Act + val exists = materialLogic.existsByName(material.name) + + // Assert + assertTrue(exists) + } + + @Test + fun existsByName_notFound_returnsFalse() { + // Arrange + every { materialServiceMock.existsByName(any(), any()) } returns false + + // Act + val exists = materialLogic.existsByName(material.name) + + // Assert + assertFalse(exists) + } + + @Test + fun getAllForMixCreation_normalBehavior_returnsNonMixTypeMaterials() { + // Arrange + every { materialLogic.getAll() } returns listOf(material, materialMixType2) + every { recipeLogicMock.getById(any()) } returns recipe + + // Act + val materials = materialLogic.getAllForRecipe(recipe.id) + + // Assert + assertContains(materials, material) + } + + @Test + fun getAllForMixCreation_normalBehavior_returnsRecipeMixTypesMaterials() { + // Arrange + val recipe = recipe.copy(mixes = listOf(mix, mix2)) + + every { materialLogic.getAll() } returns listOf(material, materialMixType2) + every { recipeLogicMock.getById(any()) } returns recipe + + // Act + val materials = materialLogic.getAllForRecipe(recipe.id) + + // Assert + assertContains(materials, materialMixType2) + } + + @Test + fun getAllForMixUpdate_normalBehavior_returnsNonMixTypeMaterials() { + // Arrange + every { materialLogic.getAll() } returns listOf(material, materialMixType, materialMixType2) + every { recipeLogicMock.getById(any()) } returns recipe + every { mixLogicMock.getById(any()) } returns mix + + // Act + val materials = materialLogic.getAllForMix(mix.id) + + // Assert + assertContains(materials, material) + } + + @Test + fun getAllForMixUpdate_normalBehavior_returnsRecipeMixTypesMaterials() { + // Arrange + val recipe = recipe.copy(mixes = listOf(mix, mix2)) + val mix = mix.copy(recipeId = recipe.id) + + every { materialLogic.getAll() } returns listOf(material, materialMixType, materialMixType2) + every { recipeLogicMock.getById(any()) } returns recipe + every { mixLogicMock.getById(any()) } returns mix + + // Act + val materials = materialLogic.getAllForMix(mix.id) + + // Assert + assertContains(materials, materialMixType2) + } + + @Test + fun save_materialSaveDto_normalBehavior_callsSave() { + // Arrange + every { materialLogic.save(any()) } returns material + every { materialTypeLogicMock.getById(any()) } returns materialType + every { fileLogicMock.write(any(), any(), any()) } just runs + + // Act + materialLogic.save(materialSaveDto) + + // Assert + verify { + materialLogic.save(any()) + } + } + + @Test + fun save_materialSaveDto_normalBehavior_callsWriteInFileService() { + // Arrange + every { materialLogic.save(any()) } returns material + every { materialTypeLogicMock.getById(any()) } returns materialType + every { fileLogicMock.write(any(), any(), any()) } just runs + + // Act + materialLogic.save(materialSaveDto) + + // Assert + verify { + fileLogicMock.write(simdutFileMock, any(), false) + } + confirmVerified(fileLogicMock) + } + + @Test + fun save_materialSaveDto_noSimdutFile_doesNotCallWriteInFileService() { + // Arrange + every { materialLogic.save(any()) } returns material + every { materialTypeLogicMock.getById(any()) } returns materialType + every { fileLogicMock.write(any(), any(), any()) } just runs + + val saveDto = materialSaveDto.copy(simdutFile = null) + + // Act + materialLogic.save(saveDto) + + // Assert + verify(exactly = 0) { + fileLogicMock.write(simdutFileMock, any(), false) + } + confirmVerified(fileLogicMock) + } + + @Test + fun save_nameExists_throwsNameAlreadyExists() { + // Arrange + every { materialServiceMock.existsByName(any(), any()) } returns true + + // Act + // Assert + assertThrows { materialLogic.save(material) } + } + + @Test + fun update_saveDto_normalBehavior_callsUpdate() { + // Arrange + every { materialLogic.getById(any()) } returns material + every { materialLogic.update(any()) } returns material + every { materialTypeLogicMock.getById(any()) } returns materialType + every { fileLogicMock.write(any(), any(), any()) } just runs + + // Act + materialLogic.update(materialSaveDto) + + // Assert + verify { + materialLogic.update(any()) + } + } + + @Test + fun update_materialSaveDto_normalBehavior_callsWriteInFileService() { + // Arrange + every { materialLogic.getById(any()) } returns material + every { materialLogic.update(any()) } returns material + every { materialTypeLogicMock.getById(any()) } returns materialType + every { fileLogicMock.write(any(), any(), any()) } just runs + + // Act + materialLogic.update(materialSaveDto) + + // Assert + verify { + fileLogicMock.write(simdutFileMock, any(), true) + } + confirmVerified(fileLogicMock) + } + + @Test + fun update_materialSaveDto_noSimdutFile_doesNotCallWriteInFileService() { + // Arrange + every { materialLogic.getById(any()) } returns material + every { materialLogic.update(any()) } returns material + every { materialTypeLogicMock.getById(any()) } returns materialType + every { fileLogicMock.write(any(), any(), any()) } just runs + + val saveDto = materialSaveDto.copy(simdutFile = null) + + // Act + materialLogic.update(saveDto) + + // Assert + verify(exactly = 0) { + fileLogicMock.write(simdutFileMock, any(), true) + } + confirmVerified(fileLogicMock) + } + + @Test + fun updateQuantity_normalBehavior_callsUpdateInventoryQuantityByIdInService() { + // Arrange + every { materialServiceMock.updateInventoryQuantityById(any(), any()) } just runs + + val factor = 3f + + // Act + materialLogic.updateQuantity(material, factor) + + // Assert + verify { + materialServiceMock.updateInventoryQuantityById(material.id, material.inventoryQuantity + factor) + } + confirmVerified(materialServiceMock) + } + + @Test + fun updateQuantity_normalBehavior_returnsUpdatedQuantity() { + // Arrange + every { materialServiceMock.updateInventoryQuantityById(any(), any()) } just runs + + val factor = 3f + + // Act + val updatedQuantity = materialLogic.updateQuantity(material, factor) + + // Assert + assertEquals(material.inventoryQuantity + factor, updatedQuantity) + } + + @Test + fun deleteById_normalBehavior_callsDeleteInFileLogic() { + // Arrange + every { materialLogic.getById(any()) } returns material + every { materialServiceMock.isUsedByMixMaterialOrMixType(any()) } returns false + every { materialServiceMock.deleteById(any()) } just runs + every { fileLogicMock.exists(any()) } returns true + every { fileLogicMock.delete(any()) } just runs + + val simdutPath = Material.getSimdutFilePath(material.name) + + // Act + materialLogic.deleteById(material.id) + + // Assert + verify { + fileLogicMock.exists(simdutPath) + fileLogicMock.delete(simdutPath) + } + confirmVerified(fileLogicMock) + } + + @Test + fun deleteById_simdutFileNotExists_doesNotCallDeleteInFileLogic() { + // Arrange + every { materialLogic.getById(any()) } returns material + every { materialServiceMock.isUsedByMixMaterialOrMixType(any()) } returns false + every { materialServiceMock.deleteById(any()) } just runs + every { fileLogicMock.exists(any()) } returns false + every { fileLogicMock.delete(any()) } just runs + + val simdutPath = Material.getSimdutFilePath(material.name) + + // Act + materialLogic.deleteById(material.id) + + // Assert + verify { + fileLogicMock.exists(simdutPath) + } + verify(exactly = 0) { + fileLogicMock.delete(simdutPath) + } + confirmVerified(fileLogicMock) + } + + @Test + fun deleteById_usedByMixMaterialOrMixType_throwsCannotDeleteException() { + // Arrange + every { materialLogic.getById(any()) } returns material + every { materialServiceMock.isUsedByMixMaterialOrMixType(any()) } returns true + every { materialServiceMock.deleteById(any()) } just runs + every { fileLogicMock.exists(any()) } returns false + every { fileLogicMock.delete(any()) } just runs + + // Act + // Assert + assertThrows { materialLogic.deleteById(material.id) } + } +} \ No newline at end of file diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultMaterialTypeLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultMaterialTypeLogicTest.kt new file mode 100644 index 0000000..d9f0d3b --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultMaterialTypeLogicTest.kt @@ -0,0 +1,146 @@ +package dev.fyloz.colorrecipesexplorer.logic + +import dev.fyloz.colorrecipesexplorer.dtos.MaterialTypeDto +import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException +import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException +import dev.fyloz.colorrecipesexplorer.exception.CannotUpdateException +import dev.fyloz.colorrecipesexplorer.service.MaterialTypeService +import io.mockk.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DefaultMaterialTypeLogicTest { + private val materialTypeServiceMock = mockk() + + private val materialTypeLogic = spyk(DefaultMaterialTypeLogic(materialTypeServiceMock)) + + private val materialType = MaterialTypeDto(1L, "Unit test material type", "UTMT", false) + private val systemMaterialType = + MaterialTypeDto(2L, "Unit test system material type", "UTSMT", false, systemType = true) + + @AfterEach + internal fun afterEach() { + clearAllMocks() + } + + @Test + fun getAll_normalBehavior_returnsFromService() { + // Arrange + val expectedMaterialTypes = listOf(materialType, systemMaterialType) + + every { materialTypeServiceMock.getAll(any()) } returns expectedMaterialTypes + + // Act + val actualMaterialTypes = materialTypeLogic.getAll(true) + + // Assert + assertEquals(expectedMaterialTypes, actualMaterialTypes) + } + + @Test + fun getByName_normalBehavior_returnsMaterialType() { + // Arrange + every { materialTypeServiceMock.getByName(any()) } returns materialType + + // Act + val actualMaterialType = materialTypeLogic.getByName(materialType.name) + + // Assert + assertEquals(materialType, actualMaterialType) + } + + @Test + fun getByName_nameNotFound_returnsNull() { + // Arrange + every { materialTypeServiceMock.getByName(any()) } returns null + + // Act + val actualMaterialType = materialTypeLogic.getByName(materialType.name) + + // Assert + assertNull(actualMaterialType) + } + + @Test + fun updateNonSystemType_normalBehavior_callsUpdate() { + // Arrange + every { materialTypeServiceMock.existsById(any(), any()) } returns false + every { materialTypeLogic.update(any()) } returnsArgument 0 + + // Act + materialTypeLogic.updateNonSystemType(materialType) + + // Assert + verify { + materialTypeLogic.update(materialType) + } + } + + @Test + fun updateNonSystemType_isSystemType_throwsCannotUpdateException() { + // Arrange + every { materialTypeServiceMock.existsById(any(), any()) } returns true + every { materialTypeLogic.update(any()) } returnsArgument 0 + + // Act + // Assert + assertThrows { materialTypeLogic.updateNonSystemType(materialType) } + } + + @Test + fun save_nameExists_throwsAlreadyExistsException() { + // Arrange + every { materialTypeServiceMock.existsByName(any(), any()) } returns true + every { materialTypeServiceMock.existsByPrefix(any(), any()) } returns false + + // Act + // Assert + assertThrows { materialTypeLogic.save(materialType) } + } + + @Test + fun save_prefixExists_throwsAlreadyExistsException() { + // Arrange + every { materialTypeServiceMock.existsByName(any(), any()) } returns false + every { materialTypeServiceMock.existsByPrefix(any(), any()) } returns true + + // Act + // Assert + assertThrows { materialTypeLogic.save(materialType) } + } + + @Test + fun update_nameExists_throwsAlreadyExistsException() { + // Arrange + every { materialTypeServiceMock.existsByName(any(), any()) } returns true + every { materialTypeServiceMock.existsByPrefix(any(), any()) } returns false + + // Act + // Assert + assertThrows { materialTypeLogic.update(materialType) } + } + + @Test + fun update_prefixExists_throwsAlreadyExistsException() { + // Arrange + every { materialTypeServiceMock.existsByName(any(), any()) } returns false + every { materialTypeServiceMock.existsByPrefix(any(), any()) } returns true + + // Act + // Assert + assertThrows { materialTypeLogic.update(materialType) } + } + + @Test + fun deleteById_usedByMaterial_throwsCannotDeleteException() { + // Arrange + every { materialTypeServiceMock.isUsedByMaterial(any()) } returns true + + // Act + // Assert + assertThrows { materialTypeLogic.deleteById(materialType.id) } + } +} \ No newline at end of file diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultMixLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultMixLogicTest.kt new file mode 100644 index 0000000..93adba9 --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultMixLogicTest.kt @@ -0,0 +1,181 @@ +package dev.fyloz.colorrecipesexplorer.logic + +import dev.fyloz.colorrecipesexplorer.dtos.* +import dev.fyloz.colorrecipesexplorer.service.MixService +import io.mockk.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test + +class DefaultMixLogicTest { + private val mixServiceMock = mockk() + private val recipeLogicMock = mockk() + private val materialTypeLogicMock = mockk() + private val mixTypeLogicMock = mockk() + private val mixQuantityLogicMock = mockk() + + private val mixLogic = spyk( + DefaultMixLogic( + mixServiceMock, + recipeLogicMock, + materialTypeLogicMock, + mixTypeLogicMock, + mixQuantityLogicMock + ) + ) + + private val company = CompanyDto(1L, "Unit test company") + private val recipe = RecipeDto( + 1L, + "Unit test recipe", + "Unit test recipe", + "FFFFFF", + 0xf, + null, + null, + false, + "A remark", + company, + mutableListOf(), + listOf() + ) + private val materialType = MaterialTypeDto(1L, "Unit test material type", "UTMT", false) + private val mixType = + MixTypeDto(1L, "Unit test mix type", materialType) + private val mixMaterial = + MixMaterialDto(1L, MaterialDto(2L, "Unit test material", 1000f, false, materialType), 50f, 1) + private val mixQuantities = MixQuantitiesDto(listOf(mixMaterial)) + private val mix = MixDto(recipeId = recipe.id, mixType = mixType, mixQuantities = mixQuantities) + + @AfterEach + internal fun afterEach() { + clearAllMocks() + } + + private fun setup_save_normalBehavior() { + every { recipeLogicMock.getById(any()) } returns recipe + every { materialTypeLogicMock.getById(any()) } returns materialType + every { mixTypeLogicMock.getOrCreateForNameAndMaterialType(any(), any()) } returns mixType + every { mixQuantityLogicMock.validateAndPrepareForMix(any()) } returns mixQuantities + every { mixLogic.save(any()) } returnsArgument 0 + } + + private fun setup_update_normalBehavior() { + every { recipeLogicMock.getById(any()) } returns recipe + every { materialTypeLogicMock.getById(any()) } returns materialType + every { mixTypeLogicMock.updateOrCreateForNameAndMaterialType(any(), any(), any()) } returns mixType + every { mixQuantityLogicMock.validateAndPrepareForMix(any()) } returns mixQuantities + every { mixLogic.getById(any()) } returns mix + every { mixLogic.update(any()) } returnsArgument 0 + } + + @Test + fun save_dto_normalBehavior_callsSave() { + // Arrange + setup_save_normalBehavior() + + val mixMaterialDto = + MixQuantitySaveDto(mixMaterial.id, mixMaterial.material.id, mixMaterial.quantity, mixMaterial.position, false) + val saveDto = MixSaveDto(0L, mixType.name, recipe.id, materialType.id, listOf(mixMaterialDto)) + + // Act + mixLogic.save(saveDto) + + // Assert + verify { + mixLogic.save(mix) + } + } + + @Test + fun save_dto_normalBehavior_callsValidateAndSaveAllInMixMaterialLogic() { + // Arrange + setup_save_normalBehavior() + + val mixMaterialDtos = + listOf( + MixQuantitySaveDto( + mixMaterial.id, + mixMaterial.material.id, + mixMaterial.quantity, + mixMaterial.position, + false + ) + ) + val saveDto = MixSaveDto(0L, mixType.name, recipe.id, materialType.id, mixMaterialDtos) + + // Act + mixLogic.save(saveDto) + + // Assert + verify { + mixQuantityLogicMock.validateAndPrepareForMix(mixMaterialDtos) + } + confirmVerified(mixQuantityLogicMock) + } + + @Test + fun update_dto_normalBehavior_callsUpdate() { + // Arrange + setup_update_normalBehavior() + + val mixMaterialDto = + MixQuantitySaveDto(mixMaterial.id, mixMaterial.material.id, mixMaterial.quantity, mixMaterial.position, false) + val saveDto = MixSaveDto(mix.id, mixType.name, recipe.id, materialType.id, listOf(mixMaterialDto)) + + // Act + mixLogic.update(saveDto) + + // Assert + verify { + mixLogic.update(mix) + } + } + + @Test + fun update_dto_normalBehavior_callsValidateAndSaveAllInMixMaterialLogic() { + // Arrange + setup_update_normalBehavior() + + val mixMaterialDtos = listOf( + MixQuantitySaveDto( + mixMaterial.id, + mixMaterial.material.id, + mixMaterial.quantity, + mixMaterial.position, + false + ) + ) + val saveDto = MixSaveDto(mix.id, mixType.name, recipe.id, materialType.id, mixMaterialDtos) + + // Act + mixLogic.update(saveDto) + + // Assert + verify { + mixQuantityLogicMock.validateAndPrepareForMix(mixMaterialDtos) + } + confirmVerified(mixQuantityLogicMock) + } + + @Test + fun updateLocations_normalBehavior_callsUpdateLocationByIdInServiceForEachUpdatedLocation() { + // Arrange + every { mixServiceMock.updateLocationById(any(), any()) } just runs + + val updatedLocations = listOf( + MixLocationDto(1L, "A location"), + MixLocationDto(2L, "Another location") + ) + + // Act + mixLogic.updateLocations(updatedLocations) + + // Assert + updatedLocations.forEach { + verify { + mixServiceMock.updateLocationById(it.mixId, it.location) + } + } + confirmVerified(mixServiceMock) + } +} \ No newline at end of file diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultMixQuantityLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultMixQuantityLogicTest.kt new file mode 100644 index 0000000..39374aa --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultMixQuantityLogicTest.kt @@ -0,0 +1,79 @@ +package dev.fyloz.colorrecipesexplorer.logic + +import dev.fyloz.colorrecipesexplorer.dtos.MaterialDto +import dev.fyloz.colorrecipesexplorer.dtos.MaterialTypeDto +import dev.fyloz.colorrecipesexplorer.dtos.MixMaterialDto +import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionError +import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionsException +import dev.fyloz.colorrecipesexplorer.utils.PositionUtils +import io.mockk.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class DefaultMixQuantityLogicTest { + private val materialLogicMock = mockk() + private val mixTypeLogicMock = mockk() + + private val mixMaterialLogic = DefaultMixQuantityLogic(materialLogicMock, mixTypeLogicMock) + + @AfterEach + internal fun afterEach() { + clearAllMocks() + } + + @Test + fun validateMixMaterials_normalBehavior_doesNothing() { + // Arrange + val materialType = MaterialTypeDto(1L, "Unit test material type", "UTMT", false) + val material = MaterialDto(1L, "Unit test material", 1000f, false, materialType) + val mixMaterial = MixMaterialDto(1L, material, 100f, 1) + + mockkObject(PositionUtils) + every { PositionUtils.validate(any()) } just runs + + // Act + // Assert + mixMaterialLogic.validateMixQuantities(listOf(mixMaterial)) + } + + @Test + fun validateMixMaterials_emptySet_doesNothing() { + // Arrange + // Act + // Assert + mixMaterialLogic.validateMixQuantities(listOf()) + } + + @Test + fun validateMixMaterials_firstUsesPercents_throwsInvalidFirstMixMaterialException() { + // Arrange + val materialType = MaterialTypeDto(1L, "Unit test material type", "UTMT", true) + val material = MaterialDto(1L, "Unit test material", 1000f, false, materialType) + val mixMaterial = MixMaterialDto(1L, material, 100f, 1) + + mockkObject(PositionUtils) + every { PositionUtils.validate(any()) } just runs + + // Act + // Assert + assertThrows { mixMaterialLogic.validateMixQuantities(listOf(mixMaterial)) } + } + + @Test + fun validateMixMaterials_invalidPositions_throwsInvalidMixMaterialsPositionsException() { + // Arrange + val materialType = MaterialTypeDto(1L, "Unit test material type", "UTMT", false) + val material = MaterialDto(1L, "Unit test material", 1000f, false, materialType) + val mixMaterial = MixMaterialDto(1L, material, 100f, 1) + + val errors = setOf(InvalidPositionError("error", "An unit test error")) + + mockkObject(PositionUtils) + every { PositionUtils.validate(any()) } throws InvalidPositionsException(errors) + + // Act + // Assert + assertThrows { mixMaterialLogic.validateMixQuantities(listOf(mixMaterial)) } + } +} \ No newline at end of file diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultMixTypeLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultMixTypeLogicTest.kt new file mode 100644 index 0000000..f5a41c6 --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultMixTypeLogicTest.kt @@ -0,0 +1,162 @@ +package dev.fyloz.colorrecipesexplorer.logic + +import dev.fyloz.colorrecipesexplorer.dtos.MaterialTypeDto +import dev.fyloz.colorrecipesexplorer.dtos.MixTypeDto +import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException +import dev.fyloz.colorrecipesexplorer.service.MixTypeService +import io.mockk.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import kotlin.test.assertEquals + +class DefaultMixTypeLogicTest { + private val mixTypeServiceMock = mockk() + + private val mixTypeLogic = spyk(DefaultMixTypeLogic(mixTypeServiceMock)) + + private val materialType = MaterialTypeDto(1L, "Unit test material type", "UTMT", false) + private val mixType = MixTypeDto(id = 1L, name = "Unit test mix type", materialType) + + @AfterEach + fun afterEach() { + clearAllMocks() + } + + @Test + fun getOrCreateForNameAndMaterialType_normalBehavior_returnsFromService() { + // Arrange + every { mixTypeServiceMock.getByNameAndMaterialType(any(), any()) } returns mixType + + // Act + val actualMixType = mixTypeLogic.getOrCreateForNameAndMaterialType(mixType.name, materialType) + + // Assert + assertEquals(mixType, actualMixType) + } + + @Test + fun getOrCreateForNameAndMaterialType_notFound_callsSave() { + // Arrange + every { mixTypeServiceMock.getByNameAndMaterialType(any(), any()) } returns null + every { mixTypeLogic.save(any()) } returnsArgument 0 + + val expectedMixType = mixType.copy(id = 0L) + + // Act + mixTypeLogic.getOrCreateForNameAndMaterialType(mixType.name, materialType) + + // Assert + verify { + mixTypeLogic.save(expectedMixType) + } + } + + @Test + fun getOrCreateForNameAndMaterialType_notFound_returnsFromSave() { + // Arrange + every { mixTypeServiceMock.getByNameAndMaterialType(any(), any()) } returns null + every { mixTypeLogic.save(any()) } returnsArgument 0 + + val expectedMixType = mixType.copy(id = 0L) + + // Act + val actualMixType = mixTypeLogic.getOrCreateForNameAndMaterialType(mixType.name, materialType) + + // Assert + assertEquals(expectedMixType, actualMixType) + } + + @Test + fun updateOrCreateForNameAndMaterialType_alreadyExists_returnsFromgetByNameAndMaterialType() { + // Arrange + every { mixTypeServiceMock.existsByNameAndMaterialType(any(), any(), any()) } returns true + every { mixTypeServiceMock.isShared(any()) } returns true + every { mixTypeServiceMock.getByNameAndMaterialType(any(), any()) } returns mixType + every { mixTypeLogic.save(any()) } returnsArgument 0 + + // Act + val actualMixType = mixTypeLogic.updateOrCreateForNameAndMaterialType(mixType, mixType.name, materialType) + + // Assert + assertEquals(mixType, actualMixType) + } + + @Test + fun updateOrCreateForNameAndMaterialType_mixTypeShared_callsSave() { + // Arrange + every { mixTypeServiceMock.existsByNameAndMaterialType(any(), any(), any()) } returns false + every { mixTypeServiceMock.isShared(any()) } returns true + every { mixTypeLogic.save(any()) } returnsArgument 0 + + val expectedMixType = mixType.copy(id = 0L, name = "${mixType.name} updated") + + // Act + mixTypeLogic.updateOrCreateForNameAndMaterialType(mixType, expectedMixType.name, materialType) + + // Assert + verify { + mixTypeLogic.save(expectedMixType) + } + } + + @Test + fun updateOrCreateForNameAndMaterialType_mixTypeShared_returnsFromSave() { + // Arrange + every { mixTypeServiceMock.existsByNameAndMaterialType(any(), any(), any()) } returns false + every { mixTypeServiceMock.isShared(any()) } returns true + every { mixTypeLogic.save(any()) } returnsArgument 0 + + val expectedMixType = mixType.copy(id = 0L, name = "${mixType.name} updated") + + // Act + val actualMixType = mixTypeLogic.updateOrCreateForNameAndMaterialType(mixType, expectedMixType.name, materialType) + + // Assert + assertEquals(expectedMixType, actualMixType) + } + + @Test + fun updateOrCreateForNameAndMaterialType_mixTypeNotShared_callsUpdate() { + // Arrange + every { mixTypeServiceMock.existsByNameAndMaterialType(any(), any(), any()) } returns false + every { mixTypeServiceMock.isShared(any()) } returns false + every { mixTypeLogic.update(any()) } returnsArgument 0 + + val expectedMixType = mixType.copy(name = "${mixType.name} updated") + + // Act + mixTypeLogic.updateOrCreateForNameAndMaterialType(mixType, expectedMixType.name, materialType) + + // Assert + verify { + mixTypeLogic.update(expectedMixType) + } + } + + @Test + fun updateOrCreateForNameAndMaterialType_mixTypeNotShared_returnsFromUpdate() { + // Arrange + every { mixTypeServiceMock.existsByNameAndMaterialType(any(), any(), any()) } returns false + every { mixTypeServiceMock.isShared(any()) } returns false + every { mixTypeLogic.update(any()) } returnsArgument 0 + + val expectedMixType = mixType.copy(name = "${mixType.name} updated") + + // Act + val actualMixType = mixTypeLogic.updateOrCreateForNameAndMaterialType(mixType, expectedMixType.name, materialType) + + // Assert + assertEquals(expectedMixType, actualMixType) + } + + @Test + fun deleteById_usedByMix_throwsCannotDeleteException() { + // Arrange + every { mixTypeServiceMock.isUsedByMixes(any()) } returns true + + // Act + // Assert + assertThrows { mixTypeLogic.deleteById(mixType.id) } + } +} \ No newline at end of file diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeImageLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeImageLogicTest.kt new file mode 100644 index 0000000..50ac6f8 --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeImageLogicTest.kt @@ -0,0 +1,102 @@ +package dev.fyloz.colorrecipesexplorer.logic + +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.logic.files.CachedFile +import dev.fyloz.colorrecipesexplorer.logic.files.WriteableFileLogic +import dev.fyloz.colorrecipesexplorer.utils.FilePath +import io.mockk.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.springframework.mock.web.MockMultipartFile +import kotlin.test.assertEquals + +class DefaultRecipeImageLogicTest { + private val fileLogicMock = mockk() + + private val recipeImageLogic = spyk(DefaultRecipeImageLogic(fileLogicMock)) + + private val recipeId = 1L + + @AfterEach + internal fun afterEach() { + clearAllMocks() + } + + @Test + fun getAllImages_normalBehavior_returnsAllRecipeImages() { + // Arrange + val filePath = FilePath("${Constants.FilePaths.RECIPE_IMAGES}/$recipeId") + val files = listOf( + CachedFile("0", filePath, true), + CachedFile("1", filePath, true) + ) + val expectedImages = files.map { it.name } + + every { fileLogicMock.listDirectoryFiles(any()) } returns files + + // Act + val actualImages = recipeImageLogic.getAllImages(recipeId) + + // Assert + assertEquals(expectedImages, actualImages) + } + + @Test + fun download_normalBehavior_callsWriteToDirectoryInFileLogic() { + // Arrange + val previousImageId = 0L + + every { recipeImageLogic.getAllImages(recipeId) } returns listOf(previousImageId.toString()) + every { fileLogicMock.writeToDirectory(any(), any(), any(), any()) } just runs + + val file = MockMultipartFile("Unit test name", byteArrayOf()) + + val expectedFilePath = "${Constants.FilePaths.RECIPE_IMAGES}/$recipeId" + val expectedImageId = previousImageId + 1 + + // Act + recipeImageLogic.download(file, recipeId) + + // Assert + verify { + fileLogicMock.writeToDirectory(file, "$expectedFilePath/$expectedImageId", expectedFilePath, true) + } + } + + @Test + fun download_normalBehavior_returnsImageId() { + // Arrange + val previousImageId = 0L + + every { recipeImageLogic.getAllImages(recipeId) } returns listOf(previousImageId.toString()) + every { fileLogicMock.writeToDirectory(any(), any(), any(), any()) } just runs + + val file = MockMultipartFile("Unit test name", byteArrayOf()) + + val expectedImageId = previousImageId + 1 + + // Act + val downloadedImageId = recipeImageLogic.download(file, recipeId) + + // Assert + assertEquals(expectedImageId.toString(), downloadedImageId) + } + + @Test + fun delete_normalBehavior_callsDeleteFromDirectoryInFileLogic() { + // Arrange + every { fileLogicMock.deleteFromDirectory(any(), any()) } just runs + + val recipeImagesDirectoryPath = "${Constants.FilePaths.RECIPE_IMAGES}/$recipeId" + val imageId = 1.toString() + val imagePath = "$recipeImagesDirectoryPath/$imageId" + + // Act + recipeImageLogic.delete(recipeId, imageId) + + // Assert + verify { + fileLogicMock.deleteFromDirectory(imagePath, recipeImagesDirectoryPath) + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeLogicTest.kt new file mode 100644 index 0000000..101da52 --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeLogicTest.kt @@ -0,0 +1,216 @@ +package dev.fyloz.colorrecipesexplorer.logic + +import dev.fyloz.colorrecipesexplorer.dtos.* +import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException +import dev.fyloz.colorrecipesexplorer.logic.users.GroupLogic +import dev.fyloz.colorrecipesexplorer.service.RecipeService +import io.mockk.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import kotlin.test.assertContains +import kotlin.test.assertEquals + +class DefaultRecipeLogicTest { + private val recipeServiceMock = mockk() + private val companyLogicMock = mockk() + private val recipeStepLogicMock = mockk() + private val mixLogicMock = mockk() + private val groupLogicMock = mockk() + + private val recipeLogic = + spyk(DefaultRecipeLogic(recipeServiceMock, companyLogicMock, recipeStepLogicMock, mixLogicMock, groupLogicMock)) + + private val company = CompanyDto(1L, "Unit test company") + private val group = GroupDto(1L, "Unit test group", listOf()) + private val recipe = RecipeDto( + 1L, + "Unit test recipe", + "Unit test recipe", + "FFFFFF", + 0xf, + null, + null, + false, + "Remark", + company, + listOf(), + listOf() + ) + + @AfterEach + internal fun afterEach() { + clearAllMocks() + } + + @Test + fun getAllByName_normalBehavior_returnsFromService() { + // Arrange + val expectedRecipes = listOf(recipe) + + every { recipeServiceMock.getAllByName(any()) } returns expectedRecipes + + // Act + val actualRecipes = recipeLogic.getAllByName(recipe.name) + + // Assert + assertEquals(actualRecipes, expectedRecipes) + } + + @Test + fun save_dto_normalBehavior_returnsFromSave() { + // Arrange + every { recipeServiceMock.existsByNameAndCompany(any(), any(), any()) } returns false + every { companyLogicMock.getById(any()) } returns company + every { recipeLogic.save(any()) } returns recipe + + val dto = RecipeSaveDto( + recipe.name, + recipe.description, + recipe.color, + recipe.gloss, + recipe.sample, + recipe.approbationDate, + recipe.remark, + company.id + ) + + // Act + val savedRecipe = recipeLogic.save(dto) + + // Assert + assertEquals(recipe, savedRecipe) + } + + @Test + fun save_nameAndCompanyExists_throwsAlreadyExistsException() { + // Arrange + every { recipeServiceMock.existsByNameAndCompany(any(), any(), any()) } returns true + + // Act + // Assert + assertThrows { recipeLogic.save(recipe) } + } + + @Test + fun update_dto_normalBehavior_returnsFromSave() { + // Arrange + every { recipeServiceMock.existsByNameAndCompany(any(), any(), any()) } returns false + every { recipeServiceMock.getById(any()) } returns recipe + every { companyLogicMock.getById(any()) } returns company + every { recipeLogic.update(any()) } returns recipe + + val dto = RecipeUpdateDto( + recipe.id, + recipe.name, + recipe.description, + recipe.color, + recipe.gloss, + recipe.sample, + recipe.approbationDate, + recipe.remark, + listOf() + ) + + // Act + val updatedRecipe = recipeLogic.update(dto) + + // Assert + assertEquals(recipe, updatedRecipe) + } + + @Test + fun update_nameAndCompanyExists_throwsAlreadyExistsException() { + // Arrange + every { recipeServiceMock.existsByNameAndCompany(any(), any(), any()) } returns true + + // Act + // Assert + assertThrows { recipeLogic.update(recipe) } + } + + @Test + fun updatePublicData_normalBehavior_callsUpdate() { + // Arrange + every { recipeLogic.getById(any()) } returns recipe + every { recipeLogic.update(any()) } returnsArgument 0 + every { groupLogicMock.getById(any()) } returns group + + val groupNote = RecipeGroupNoteDto(1L, "Unit test note") + val dto = RecipePublicDataDto(recipe.id, listOf(groupNote), listOf()) + + // Act + recipeLogic.updatePublicData(dto) + + // Assert + verify { + recipeLogic.update(any()) + } + } + + @Test + fun updatePublicData_normalBehavior_updatesRecipeGroupsInformation() { + // Arrange + var updatedRecipe = recipe + + every { recipeLogic.getById(any()) } returns recipe + every { recipeLogic.update(any()) } answers { firstArg().also { updatedRecipe = it } } + every { groupLogicMock.getById(any()) } returns group + + val expectedGroupInformation = RecipeGroupInformationDto(0L, group, "Unit test note", listOf()) + + val groupNote = RecipeGroupNoteDto(group.id, expectedGroupInformation.note) + val dto = RecipePublicDataDto(recipe.id, listOf(groupNote), listOf()) + + // Act + recipeLogic.updatePublicData(dto) + + // Assert + assertContains(updatedRecipe.groupsInformation, expectedGroupInformation) + } + + @Test + fun updatePublicData_emptyNotes_doesNothing() { + // Arrange + val dto = RecipePublicDataDto(recipe.id, listOf(), listOf()) + + // Act + recipeLogic.updatePublicData(dto) + + // Assert + verify(exactly = 0) { + recipeLogic.update(any()) + } + } + + @Test + fun updatePublicData_normalBehavior_callsUpdateLocationsInMixLogic() { + // Arrange + every { mixLogicMock.updateLocations(any()) } just runs + + val mixesLocation = listOf(MixLocationDto(group.id, "location")) + val dto = RecipePublicDataDto(recipe.id, listOf(), mixesLocation) + + // Act + recipeLogic.updatePublicData(dto) + + // Assert + verify { + mixLogicMock.updateLocations(mixesLocation) + } + } + + @Test + fun updatePublicData_emptyMixesLocation_doesNothing() { + // Arrange + val dto = RecipePublicDataDto(recipe.id, listOf(), listOf()) + + // Act + recipeLogic.updatePublicData(dto) + + // Assert + verify(exactly = 0) { + mixLogicMock.updateLocations(any()) + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeStepLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeStepLogicTest.kt new file mode 100644 index 0000000..23aac43 --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultRecipeStepLogicTest.kt @@ -0,0 +1,60 @@ +package dev.fyloz.colorrecipesexplorer.logic + +import dev.fyloz.colorrecipesexplorer.dtos.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.RecipeGroupInformationDto +import dev.fyloz.colorrecipesexplorer.dtos.RecipeStepDto +import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionError +import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionsException +import dev.fyloz.colorrecipesexplorer.service.RecipeStepService +import dev.fyloz.colorrecipesexplorer.utils.PositionUtils +import io.mockk.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class DefaultRecipeStepLogicTest { + private val recipeStepServiceMock = mockk() + + private val recipeStepLogic = DefaultRecipeStepLogic(recipeStepServiceMock) + + @AfterEach + internal fun afterEach() { + clearAllMocks() + } + + @Test + fun validateGroupInformationSteps_normalBehavior_callsValidateSteps() { + // Arrange + mockkObject(PositionUtils) + every { PositionUtils.validate(any()) } just runs + + val group = GroupDto(1L, "Unit test group", listOf()) + val steps = listOf(RecipeStepDto(1L, 1, "A message")) + val groupInfo = RecipeGroupInformationDto(1L, group, "A note", steps) + + // Act + recipeStepLogic.validateGroupInformationSteps(groupInfo) + + // Assert + verify { + PositionUtils.validate(steps.map { it.position }) + } + } + + @Test + fun validateGroupInformationSteps_invalidSteps_throwsInvalidGroupStepsPositionsException() { + // Arrange + val errors = setOf(InvalidPositionError("error", "An unit test error")) + + mockkObject(PositionUtils) + every { PositionUtils.validate(any()) } throws InvalidPositionsException(errors) + + val group = GroupDto(1L, "Unit test group", listOf()) + val steps = listOf(RecipeStepDto(1L, 1, "A message")) + val groupInfo = RecipeGroupInformationDto(1L, group, "A note", steps) + + // Act + // Assert + assertThrows { recipeStepLogic.validateGroupInformationSteps(groupInfo) } + } +} \ No newline at end of file diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultTouchUpKitLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultTouchUpKitLogicTest.kt new file mode 100644 index 0000000..0e3ce09 --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/DefaultTouchUpKitLogicTest.kt @@ -0,0 +1,146 @@ +package dev.fyloz.colorrecipesexplorer.logic + +import dev.fyloz.colorrecipesexplorer.Constants +import dev.fyloz.colorrecipesexplorer.logic.config.ConfigurationLogic +import dev.fyloz.colorrecipesexplorer.logic.files.WriteableFileLogic +import dev.fyloz.colorrecipesexplorer.model.ConfigurationType +import dev.fyloz.colorrecipesexplorer.service.TouchUpKitService +import dev.fyloz.colorrecipesexplorer.utils.PdfDocument +import dev.fyloz.colorrecipesexplorer.utils.toByteArrayResource +import io.mockk.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.springframework.core.io.ByteArrayResource +import kotlin.test.assertEquals + +class DefaultTouchUpKitLogicTest { + private val touchUpKitServiceMock = mockk() + private val fileLogicMock = mockk() + private val configLogicMock = mockk() + + private val touchUpKitLogic = spyk(DefaultTouchUpKitLogic(touchUpKitServiceMock, fileLogicMock, configLogicMock)) + + private val pdfMockData = mockk() + private val pdfMock = mockk { + mockkStatic(PdfDocument::toByteArrayResource) + mockkStatic(PdfDocument::toByteArrayResource) + every { toByteArrayResource() } returns pdfMockData + } + + @AfterEach + internal fun afterEach() { + clearAllMocks() + } + + @Test + fun complete_normalBehavior_callsUpdateCompletionDateByIdInService() { + // Arrange + every { touchUpKitServiceMock.updateCompletionDateById(any(), any()) } just runs + + val touchUpKitId = 1L + + // Act + touchUpKitLogic.complete(touchUpKitId) + + // Assert + verify { + touchUpKitServiceMock.updateCompletionDateById(touchUpKitId, any()) + } + } + + @Test + fun generateJobPdfResource_normalBehavior_returnsGeneratedPdf() { + // Arrange + every { touchUpKitLogic.generateJobPdf(any()) } returns pdfMock + every { configLogicMock.getContent(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF) } returns false.toString() + + // Act + val generatedPdfData = touchUpKitLogic.generateJobPdfResource("Unit test job") + + // Assert + assertEquals(pdfMockData, generatedPdfData) + } + + @Test + fun generateJobPdfResource_normalBehavior_callsCacheJobPdf() { + // Arrange + every { touchUpKitLogic.generateJobPdf(any()) } returns pdfMock + every { configLogicMock.getContent(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF) } returns false.toString() + + val job = "Unit test job" + + // Act + touchUpKitLogic.generateJobPdfResource(job) + + // Assert + verify { + touchUpKitLogic.cacheJobPdf(job, pdfMock) + } + } + + @Test + fun generateJobPdfResource_cacheEnabledAndPdfExists_returnsCachedJobPdf() { + // Arrange + every { configLogicMock.getContent(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF) } returns true.toString() + every { fileLogicMock.exists(any()) } returns true + every { fileLogicMock.read(any()) } returns pdfMockData + + // Act + val pdfData = touchUpKitLogic.generateJobPdfResource("Unit test job") + + // Assert + assertEquals(pdfMockData, pdfData) + } + + @Test + fun generateJobPdfResource_cacheEnabledAndPdfNotExists_generatesPdf() { + // Arrange + every { touchUpKitLogic.generateJobPdf(any()) } returns pdfMock + every { touchUpKitLogic.cacheJobPdf(any(), any()) } just runs + every { configLogicMock.getContent(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF) } returns true.toString() + every { fileLogicMock.exists(any()) } returns false + + // Act + touchUpKitLogic.generateJobPdfResource("Unit test job") + + // Assert + verify { + touchUpKitLogic.cacheJobPdf(any(), any()) + } + } + + @Test + fun cacheJobPdf_normalBehavior_callsWriteInFileLogic() { + // Arrange + every { configLogicMock.getContent(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF) } returns true.toString() + every { fileLogicMock.write(any(), any(), any()) } just runs + + val job = "Unit test job" + val pdfPath = "${Constants.FilePaths.TOUCH_UP_KITS}/$job.pdf" + + // Act + touchUpKitLogic.cacheJobPdf(job, pdfMock) + + // Assert + verify { + fileLogicMock.write(pdfMockData, pdfPath, true) + } + confirmVerified(fileLogicMock) + } + + @Test + fun cacheJobPdf_cacheDisabled_doNothing() { + // Arrange + every { configLogicMock.getContent(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF) } returns false.toString() + every { fileLogicMock.write(any(), any(), any()) } just runs + + // Act + touchUpKitLogic.cacheJobPdf("Unit test job", pdfMock) + + // Assert + verify(exactly = 0) { + fileLogicMock.write(any(), any(), any()) + } + confirmVerified(fileLogicMock) + } +} \ No newline at end of file diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupLogicTest.kt new file mode 100644 index 0000000..1f208b1 --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultGroupLogicTest.kt @@ -0,0 +1,87 @@ +package dev.fyloz.colorrecipesexplorer.logic.account + +import dev.fyloz.colorrecipesexplorer.dtos.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.UserDto +import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException +import dev.fyloz.colorrecipesexplorer.logic.users.DefaultGroupLogic +import dev.fyloz.colorrecipesexplorer.logic.users.UserLogic +import dev.fyloz.colorrecipesexplorer.service.GroupService +import io.mockk.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows + +class DefaultGroupLogicTest { + private val group = GroupDto(1L, "Unit test group", listOf()) + private val user = UserDto(1L, "Unit test", "User", "asecurepassword", null, listOf()) + + private val groupServiceMock = mockk { + every { existsById(any()) } returns false + every { existsByName(any(), any()) } returns false + every { getAll() } returns listOf() + every { getById(any()) } returns group + every { save(any()) } returns group + every { deleteById(any()) } just runs + } + private val userLogicMock = mockk { + every { getAllByGroup(any()) } returns listOf() + every { getById(any(), any(), any()) } returns user + every { getDefaultGroupUser(any()) } returns user + every { saveDefaultGroupUser(any()) } just runs + every { deleteById(any()) } just runs + } + + private val groupLogic = spyk(DefaultGroupLogic(groupServiceMock, userLogicMock)) + + @AfterEach + internal fun afterEach() { + clearAllMocks() + } + + @Test + fun getUsersForGroup_normalBehavior_callsGetAllByGroupInUserLogic() { + // Arrange + every { groupLogic.getById(any()) } returns group + + // Act + groupLogic.getUsersForGroup(group.id) + + // Assert + verify { + userLogicMock.getAllByGroup(group) + } + confirmVerified(userLogicMock) + } + + @Test + fun save_nameAlreadyExists_throwsAlreadyExists() { + // Arrange + every { groupServiceMock.existsByName(any(), any()) } returns true + + // Act + // Assert + assertThrows { groupLogic.save(group) } + } + + @Test + fun update_normalBehavior_throwsAlreadyExists() { + // Arrange + every { groupServiceMock.existsByName(any(), any()) } returns true + + // Act + // Assert + assertThrows { groupLogic.update(group) } + } + + @Test + fun deleteById_normalBehavior_callsDeleteByIdInUserLogicWithDefaultGroupUserId() { + // Arrange + // Act + groupLogic.deleteById(group.id) + + // Assert + verify { + userLogicMock.deleteById(group.defaultGroupUserId) + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultUserLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultUserLogicTest.kt new file mode 100644 index 0000000..d4f8b32 --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/account/DefaultUserLogicTest.kt @@ -0,0 +1,306 @@ +package dev.fyloz.colorrecipesexplorer.logic.account + +import dev.fyloz.colorrecipesexplorer.dtos.GroupDto +import dev.fyloz.colorrecipesexplorer.dtos.UserDto +import dev.fyloz.colorrecipesexplorer.dtos.UserSaveDto +import dev.fyloz.colorrecipesexplorer.dtos.UserUpdateDto +import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException +import dev.fyloz.colorrecipesexplorer.exception.NotFoundException +import dev.fyloz.colorrecipesexplorer.logic.users.DefaultUserLogic +import dev.fyloz.colorrecipesexplorer.logic.users.GroupLogic +import dev.fyloz.colorrecipesexplorer.model.account.Permission +import dev.fyloz.colorrecipesexplorer.service.UserService +import io.mockk.* +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows +import org.springframework.security.crypto.password.PasswordEncoder +import java.time.LocalDateTime + +class DefaultUserLogicTest { + private val user = UserDto(1L, "Unit test", "User", "asecurepassword", null, listOf()) + private val group = GroupDto(1L, "Unit test group", listOf()) + + private val userServiceMock = mockk { + every { existsById(any()) } returns false + every { existsByFirstNameAndLastName(any(), any(), any()) } returns false + every { getAll(any(), any()) } returns listOf() + every { getAllByGroup(any()) } returns listOf() + every { getById(any(), any(), any()) } returns user + every { getByFirstNameAndLastName(any(), any()) } returns user + every { getDefaultGroupUser(any()) } returns user + } + private val groupLogicMock = mockk { + every { getById(any()) } returns group + } + private val passwordEncoderMock = mockk { + every { encode(any()) } answers { "encoded ${this.firstArg()}" } + } + + private val userLogic = spyk(DefaultUserLogic(userServiceMock, groupLogicMock, passwordEncoderMock)) + + private val userSaveDto = UserSaveDto( + user.id, + user.firstName, + user.lastName, + user.password, + null, + user.permissions, + user.isSystemUser, + user.isDefaultGroupUser + ) + private val userUpdateDto = UserUpdateDto(user.id, user.firstName, user.lastName, null, listOf()) + + @AfterEach + internal fun afterEach() { + clearAllMocks() + } + + @Test + fun getAll_normalBehavior_callsGetAllInServiceWithSpecialUsersDisabled() { + // Arrange + // Act + userLogic.getAll() + + // Assert + verify { + userServiceMock.getAll(isSystemUser = false, isDefaultGroupUser = false) + } + confirmVerified(userServiceMock) + } + + @Test + fun getAllByGroup_normalBehavior_callsGetAllByGroupInService() { + // Arrange + // Act + userLogic.getAllByGroup(group) + + // Assert + verify { + userServiceMock.getAllByGroup(group) + } + confirmVerified(userServiceMock) + } + + @Test + fun getById_default_normalBehavior_callsGetByIdWithSpecialUsersDisabled() { + // Arrange + // Act + userLogic.getById(user.id) + + // Assert + verify { + userLogic.getById(user.id, isSystemUser = false, isDefaultGroupUser = false) + } + } + + @Test + fun getById_normalBehavior_callsGetByIdInService() { + // Arrange + // Act + userLogic.getById(user.id, isSystemUser = false, isDefaultGroupUser = true) + + // Assert + verify { + userServiceMock.getById(user.id, isSystemUser = false, isDefaultGroupUser = true) + } + confirmVerified(userServiceMock) + } + + @Test + fun getById_notFound_throwsNotFoundException() { + // Arrange + every { userServiceMock.getById(any(), any(), any()) } returns null + + // Act + // Assert + assertThrows { userLogic.getById(user.id) } + } + + @Test + fun getDefaultGroupUser_normalBehavior_callsGetDefaultGroupUserInService() { + // Arrange + // Act + userLogic.getDefaultGroupUser(group) + + // Assert + verify { + userServiceMock.getDefaultGroupUser(group) + } + confirmVerified(userServiceMock) + } + + @Test + fun getDefaultGroupUser_notFound_throwsNotFoundException() { + // Arrange + every { userServiceMock.getDefaultGroupUser(any()) } returns null + + // Act + // Assert + assertThrows { userLogic.getDefaultGroupUser(group) } + } + + @Test + fun saveDefaultGroupUser_normalBehavior_callsSaveWithValidSaveDto() { + // Arrange + every { userLogic.save(any()) } returns user + + val expectedSaveDto = UserSaveDto( + group.defaultGroupUserId, group.name, "User", group.name, group.id, listOf(), isDefaultGroupUser = true + ) + + // Act + userLogic.saveDefaultGroupUser(group) + + // Assert + verify { + userLogic.save(expectedSaveDto) + } + } + + @Test + fun save_dto_normalBehavior_callsSaveWithValidUser() { + // Arrange + every { userLogic.save(any()) } returns user + + val expectedUser = user.copy(password = "encoded ${user.password}") + + // Act + userLogic.save(userSaveDto) + + // Assert + verify { + userLogic.save(expectedUser) + } + } + +// TODO Causes a stackoverflow because of a bug in mockk +// @Test +// fun save_normalBehavior_callsSaveInService() { +// // Arrange +// // Act +// userLogic.save(user) +// +// // Assert +// verify { +// userServiceMock.save(user) +// } +// } + + @Test + fun save_idAlreadyExists_throwsAlreadyExistsException() { + // Arrange + every { userServiceMock.existsById(any()) } returns true + + // Act + // Assert + assertThrows { userLogic.save(user) } + } + + @Test + fun save_fullNameAlreadyExists_throwsAlreadyExistsException() { + // Arrange + every { userServiceMock.existsByFirstNameAndLastName(any(), any(), any()) } returns true + + // Act + // Assert + assertThrows { userLogic.save(userSaveDto) } + } + + @Test + fun update_dto_normalBehavior_callsUpdateWithValidUser() { + // Arrange + every { userLogic.getById(any(), any(), any()) } returns user + every { userLogic.update(any()) } returns user + + // Act + userLogic.update(userUpdateDto) + + // Assert + verify { + userLogic.update(user) + } + } + + @Test + fun update_fullNameAlreadyExists_ThrowAlreadyExistsException() { + // Arrange + every { userServiceMock.existsByFirstNameAndLastName(any(), any(), any()) } returns true + + // Act + // Assert + assertThrows { userLogic.update(user) } + } + + @Test + fun updateLastLoginTime_normalBehavior_callsUpdateWithUpdatedTime() { + // Arrange + every { userLogic.getById(any()) } returns user + every { userLogic.update(any()) } returns user + + val time = LocalDateTime.now() + val expectedUser = user.copy(lastLoginTime = time) + + // Act + userLogic.updateLastLoginTime(user.id, time) + + // Assert + verify { + userLogic.update(expectedUser) + } + } + + @Test + fun updatePassword_normalBehavior_callsUpdateWithUpdatedTime() { + // Arrange + every { userLogic.getById(any()) } returns user + every { userLogic.update(any()) } returns user + + val updatedPassword = "updatedpassword" + val expectedUser = user.copy(password = "encoded $updatedPassword") + + // Act + userLogic.updatePassword(user.id, updatedPassword) + + // Assert + verify { + userLogic.update(expectedUser) + } + } + + @Test + fun addPermission_normalBehavior_callsUpdateWithAddedPermission() { + // Arrange + every { userLogic.getById(any()) } returns user + every { userLogic.update(any()) } returns user + + val addedPermission = Permission.VIEW_COMPANY + val expectedUser = user.copy(permissions = user.permissions + addedPermission) + + // Act + userLogic.addPermission(user.id, addedPermission) + + // Assert + verify { + userLogic.update(expectedUser) + } + } + + @Test + fun removePermission_normalBehavior_callsUpdateWithAddedPermission() { + // Arrange + val removedPermission = Permission.VIEW_COMPANY + val baseUser = user.copy(permissions = user.permissions + removedPermission) + + every { userLogic.getById(any()) } returns baseUser + every { userLogic.update(any()) } returns user + + // Act + userLogic.removePermission(user.id, removedPermission) + + // Assert + verify { + userLogic.update(user) + } + } +} \ No newline at end of file diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/files/DefaultFileCacheTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/files/DefaultFileCacheTest.kt new file mode 100644 index 0000000..4e79298 --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/files/DefaultFileCacheTest.kt @@ -0,0 +1,426 @@ +package dev.fyloz.colorrecipesexplorer.logic.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 diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/files/DefaultFileLogicTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/files/DefaultFileLogicTest.kt new file mode 100644 index 0000000..7cb3fde --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/files/DefaultFileLogicTest.kt @@ -0,0 +1,339 @@ +package dev.fyloz.colorrecipesexplorer.logic.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.IOException +import java.nio.file.Path +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +private val creProperties = CreProperties().apply { + dataDirectory = "data" +} +private const val mockFilePath = "existingFile" +private val mockFilePathPath = Path.of(mockFilePath) +private val mockFileData = byteArrayOf(0x1, 0x8, 0xa, 0xf) + +class DefaultFileLogicTest { + private val fileCacheMock = mockk { + every { setExists(any(), any()) } just runs + } + private val fileService = spyk(DefaultFileLogic(fileCacheMock, creProperties)) + + private val mockFile = mockk { + every { file } returns mockk() + every { exists() } returns true + every { isFile } returns true + every { toPath() } returns mockFilePathPath + } + private val mockMultipartFile = spyk(MockMultipartFile(mockFilePath, mockFileData)) + + @BeforeEach + internal fun beforeEach() { + mockkObject(File.Companion) + every { File.from(any()) } returns mockFile + } + + @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`() { + 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`() { + 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`() { + 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) + } + } + + // read() + + @Test + fun `read() returns a valid ByteArrayResource`() { + whenMockFilePathExists { + mockkStatic(File::readBytes) + every { mockFile.readBytes() } returns mockFileData + + val redResource = fileService.read(mockFilePath) + + assertEquals(mockFileData, redResource.byteArray) + } + } + + @Test + fun `read() throws FileNotFoundException when no file exists at the given path`() { + whenMockFilePathExists(false) { + with(assertThrows { fileService.read(mockFilePath) }) { + assertEquals(mockFilePath, this.path) + } + } + } + + @Test + fun `read() throws FileReadException when an IOException is thrown`() { + whenMockFilePathExists { + mockkStatic(File::readBytes) + every { mockFile.readBytes() } throws IOException() + + with(assertThrows { fileService.read(mockFilePath) }) { + assertEquals(mockFilePath, this.path) + } + } + } + + // create() + + @Test + fun `create() creates a file at the given path`() { + whenMockFilePathExists(false) { + whenFileNotCached { + mockkStatic(File::create) { + every { mockFile.create() } just runs + + fileService.create(mockFilePath) + + verify { + mockFile.create() + + fileCacheMock.setExists(any()) + } + confirmVerified(mockFile, fileCacheMock) + } + } + } + } + + @Test + fun `create() does nothing when a file already exists at the given path`() { + whenMockFilePathExists { + fileService.create(mockFilePath) + + verify(exactly = 0) { + mockFile.create() + } + } + } + + @Test + fun `create() throws FileCreateException when the file creation throws an IOException`() { + whenMockFilePathExists(false) { + mockkStatic(File::create) + every { mockFile.create() } throws IOException() + + with(assertThrows { fileService.create(mockFilePath) }) { + assertEquals(mockFilePath, this.path) + } + } + } + + // write() + + @Test + fun `write() creates and writes the given MultipartFile to the file at the given path`() { + whenMockFilePathExists(false) { + every { fileService.create(mockFilePath) } just runs + every { mockMultipartFile.transferTo(mockFilePathPath) } just runs + + fileService.write(mockMultipartFile, mockFilePath, false) + + 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`() { + whenMockFilePathExists { + with(assertThrows { fileService.write(mockMultipartFile, mockFilePath, false) }) { + assertEquals(mockFilePath, this.path) + } + } + } + + @Test + fun `write() writes the given MultipartFile to an existing file when overwrite is enabled`() { + whenMockFilePathExists { + every { mockMultipartFile.transferTo(mockFilePathPath) } just runs + + fileService.write(mockMultipartFile, mockFilePath, true) + + verify { + mockMultipartFile.transferTo(mockFilePathPath) + } + } + } + + @Test + fun `write() throws FileWriteException when writing the given file throws an IOException`() { + whenMockFilePathExists(false) { + every { fileService.create(mockFilePath) } just runs + every { mockMultipartFile.transferTo(mockFilePathPath) } throws IOException() + + with(assertThrows { + fileService.write(mockMultipartFile, mockFilePath, false) + }) { + assertEquals(mockFilePath, this.path) + } + } + } + + // delete() + + @Test + fun `delete() deletes the file at the given path`() { + 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`() { + whenMockFilePathExists(false) { + with(assertThrows { fileService.delete(mockFilePath) }) { + assertEquals(mockFilePath, this.path) + } + } + } + + @Test + fun `delete() throws FileDeleteException when deleting throw and IOException`() { + whenMockFilePathExists { + every { mockFile.delete() } throws IOException() + + with(assertThrows { fileService.delete(mockFilePath) }) { + assertEquals(mockFilePath, this.path) + } + } + } + + // String.fullPath() + + @Test + fun `fullPath() appends the given path to the given working directory`() { + with(fileService) { + val fullFilePath = fullPath(mockFilePath) + + assertEquals("${creProperties.dataDirectory}/$mockFilePath", fullFilePath.value) + } + } + + @Test + fun `fullPath() throws InvalidFilePathException when the given path contains invalid fragments`() { + with(fileService) { + BANNED_FILE_PATH_SHARDS.forEach { + val maliciousPath = "$it/$mockFilePath" + + with(assertThrows { fullPath(maliciousPath) }) { + assertEquals(maliciousPath, this.path) + assertEquals(it, this.fragment) + } + } + } + } +} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/ResourceFileServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/files/ResourceFileLogicTest.kt similarity index 77% rename from src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/ResourceFileServiceTest.kt rename to src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/files/ResourceFileLogicTest.kt index 5c0d6ff..801359a 100644 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/ResourceFileServiceTest.kt +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/logic/files/ResourceFileLogicTest.kt @@ -1,5 +1,6 @@ -package dev.fyloz.colorrecipesexplorer.service.files +package dev.fyloz.colorrecipesexplorer.logic.files +import dev.fyloz.colorrecipesexplorer.utils.FilePath import io.mockk.clearAllMocks import io.mockk.every import io.mockk.mockk @@ -13,10 +14,10 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue -class ResourceFileServiceTest { +class ResourceFileLogicTest { private val resourceLoader = mockk() - private val service = spyk(ResourceFileService(resourceLoader)) + private val logic = spyk(ResourceFileLogic(resourceLoader)) @AfterEach fun afterEach() { @@ -25,8 +26,8 @@ class ResourceFileServiceTest { private fun existsTest(shouldExists: Boolean, test: (String) -> Unit) { val path = "unit_test_resource" - with(service) { - every { path.fullPath() } returns mockk { + with(logic) { + every { fullPath(path) } returns mockk { every { resource } returns mockk { every { exists() } returns shouldExists } @@ -39,7 +40,7 @@ class ResourceFileServiceTest { @Test fun `exists() returns true when a resource exists at the given path`() { existsTest(true) { path -> - val found = service.exists(path) + val found = logic.exists(path) assertTrue { found } } @@ -48,7 +49,7 @@ class ResourceFileServiceTest { @Test fun `exists() returns false when no resource exists at the given path`() { existsTest(false) { path -> - val found = service.exists(path) + val found = logic.exists(path) assertFalse { found } } @@ -59,8 +60,8 @@ class ResourceFileServiceTest { every { exists() } returns shouldExists } val path = "unit_test_path" - with(service) { - every { path.fullPath() } returns mockk { + with(logic) { + every { fullPath(path) } returns mockk { every { resource } returns mockResource } @@ -71,7 +72,7 @@ class ResourceFileServiceTest { @Test fun `read() returns the resource at the given path`() { readTest(true) { resource, path -> - val found = service.read(path) + val found = logic.read(path) assertEquals(resource, found) } @@ -81,7 +82,7 @@ class ResourceFileServiceTest { fun `read() throws FileNotFoundException when no resource exists at the given path`() { readTest(false) { _, path -> assertThrows { - service.read(path) + logic.read(path) } } } @@ -91,11 +92,9 @@ class ResourceFileServiceTest { val path = "unit_test_path" val expectedPath = "classpath:$path" - with(service) { - val found = path.fullPath() + val found = logic.fullPath(path) - assertEquals(expectedPath, found.path) - } + assertEquals(expectedPath, found.value) } @Test @@ -103,9 +102,9 @@ class ResourceFileServiceTest { val filePath = FilePath("classpath:unit_test_path") val resource = mockk() - every { resourceLoader.getResource(filePath.path) } returns resource + every { resourceLoader.getResource(filePath.value) } returns resource - with(service) { + with(logic) { val found = filePath.resource assertEquals(resource, found) diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/repository/MaterialRepositoryTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/repository/MaterialRepositoryTest.kt deleted file mode 100644 index fa622f0..0000000 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/repository/MaterialRepositoryTest.kt +++ /dev/null @@ -1,29 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.repository - -import dev.fyloz.colorrecipesexplorer.model.material -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest -import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager -import kotlin.test.assertEquals - -@DataJpaTest(excludeAutoConfiguration = [LiquibaseAutoConfiguration::class]) -class MaterialRepositoryTest @Autowired constructor( - private val materialRepository: MaterialRepository, - private val entityManager: TestEntityManager -) { - // updateInventoryQuantityById() - - @Test - fun `updateInventoryQuantityById() updates the quantity of the material with the given identifier`() { - var material = entityManager.persist(material(inventoryQuantity = 1000f, materialType = null)) - val updatedQuantity = 1235f - - materialRepository.updateInventoryQuantityById(material.id!!, updatedQuantity) - - material = entityManager.refresh(material) - - assertEquals(updatedQuantity, material.inventoryQuantity) - } -} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/repository/MixRepositoryTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/repository/MixRepositoryTest.kt deleted file mode 100644 index e87c425..0000000 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/repository/MixRepositoryTest.kt +++ /dev/null @@ -1,41 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.repository - -import dev.fyloz.colorrecipesexplorer.model.* -import org.junit.jupiter.api.Test -import org.springframework.beans.factory.annotation.Autowired -import org.springframework.boot.autoconfigure.liquibase.LiquibaseAutoConfiguration -import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest -import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager -import kotlin.test.assertEquals - -@DataJpaTest(excludeAutoConfiguration = [LiquibaseAutoConfiguration::class]) -class MixRepositoryTest @Autowired constructor( - private val mixRepository: MixRepository, - private val entityManager: TestEntityManager -) { - // updateLocationById() - - @Test - fun `updateLocationById() updates the location of the mix with the given identifier`() { - withMixLocation(null) { mix -> - val updatedLocation = "new location" - - mixRepository.updateLocationById(mix.id!!, updatedLocation) - - val updated = entityManager.refresh(mix) - - assertEquals(updatedLocation, updated.location) - } - } - - private fun withMixLocation(location: String?, test: (Mix) -> Unit) { - val materialType = entityManager.persist(materialType()) - val mixType = entityManager.persist(mixType(materialType = materialType)) - - val company = entityManager.persist(company()) - val recipe = entityManager.persist(recipe(company = company)) - - val mix = mix(id = null, location = location, recipe = recipe, mixType = mixType) - test(entityManager.persist(mix)) - } -} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/AbstractServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/AbstractServiceTest.kt deleted file mode 100644 index 8d7f95c..0000000 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/AbstractServiceTest.kt +++ /dev/null @@ -1,349 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.service - -import com.nhaarman.mockitokotlin2.* -import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException -import dev.fyloz.colorrecipesexplorer.exception.NotFoundException -import dev.fyloz.colorrecipesexplorer.exception.RestException -import dev.fyloz.colorrecipesexplorer.model.EntityDto -import dev.fyloz.colorrecipesexplorer.model.Model -import dev.fyloz.colorrecipesexplorer.model.NamedModel -import dev.fyloz.colorrecipesexplorer.repository.NamedJpaRepository -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import org.springframework.data.jpa.repository.JpaRepository -import java.util.* -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue -import dev.fyloz.colorrecipesexplorer.service.AbstractServiceTest as AbstractServiceTest1 - -abstract class AbstractServiceTest, R : JpaRepository> { - protected abstract val repository: R - protected abstract val service: S - - protected abstract val entity: E - protected abstract val anotherEntity: E - - protected val entityList: List - get() = listOf( - entity, - anotherEntity - ) - - @AfterEach - open fun afterEach() { - reset(repository, service) - } - - // getAll() - - @Test - open fun `getAll() returns all available entities`() { - whenever(repository.findAll()).doReturn(entityList) - - val found = service.getAll() - - assertEquals(entityList, found) - } - - @Test - open fun `getAll() returns empty list when there is no entities`() { - whenever(repository.findAll()).doReturn(listOf()) - - val found = service.getAll() - - assertTrue { found.isEmpty() } - } - - // save() - - @Test - open fun `save() saves in the repository and returns the saved value`() { - whenever(repository.save(entity)).doReturn(entity) - - val found = service.save(entity) - - verify(repository).save(entity) - assertEquals(entity, found) - } - - // update() - - @Test - open fun `update() saves in the repository and returns the updated value`() { - whenever(repository.save(entity)).doReturn(entity) - - val found = service.update(entity) - - verify(repository).save(entity) - assertEquals(entity, found) - } - - // delete() - - @Test - open fun `delete() deletes in the repository`() { - service.delete(entity) - - verify(repository).delete(entity) - } -} - -abstract class AbstractModelServiceTest, R : JpaRepository> : - AbstractServiceTest1() { - - // existsById() - - @Test - open fun `existsById() returns true when an entity with the given id exists in the repository`() { - whenever(repository.existsById(entity.id!!)).doReturn(true) - - val found = service.existsById(entity.id!!) - - assertTrue(found) - } - - @Test - open fun `existsById() returns false when no entity with the given id exists in the repository`() { - whenever(repository.existsById(entity.id!!)).doReturn(false) - - val found = service.existsById(entity.id!!) - - assertFalse(found) - } - - // getById() - - @Test - open fun `getById() returns the entity with the given id from the repository`() { - whenever(repository.findById(entity.id!!)).doReturn(Optional.of(entity)) - - val found = service.getById(entity.id!!) - - assertEquals(entity, found) - } - - @Test - open fun `getById() throws NotFoundException when no entity with the given id exists in the repository`() { - whenever(repository.findById(entity.id!!)).doReturn(Optional.empty()) - - assertThrows { service.getById(entity.id!!) } - .assertErrorCode() - } - - // save() - - @Test - open fun `save() throws AlreadyExistsException when an entity with the given id exists in the repository`() { - doReturn(true).whenever(service).existsById(entity.id!!) - - assertThrows { service.save(entity) } - .assertErrorCode() - } - - // update() - - @Test - override fun `update() saves in the repository and returns the updated value`() { - whenever(repository.save(entity)).doReturn(entity) - doReturn(true).whenever(service).existsById(entity.id!!) - doReturn(entity).whenever(service).getById(entity.id!!) - - val found = service.update(entity) - - verify(repository).save(entity) - assertEquals(entity, found) - } - - @Test - open fun `update() throws NotFoundException when no entity with the given id exists in the repository`() { - doReturn(false).whenever(service).existsById(entity.id!!) - - assertThrows { service.update(entity) } - .assertErrorCode() - } - - // deleteById() - - @Test - open fun `deleteById() deletes the entity with the given id in the repository`() { - doReturn(entity).whenever(service).getById(entity.id!!) - - service.deleteById(entity.id!!) - - verify(repository).delete(entity) - } -} - -abstract class AbstractNamedModelServiceTest, R : NamedJpaRepository> : - AbstractModelServiceTest() { - protected abstract val entityWithEntityName: E - - // existsByName() - - @Test - open fun `existsByName() returns true when an entity with the given name exists`() { - whenever(repository.existsByName(entity.name)).doReturn(true) - - val found = service.existsByName(entity.name) - - assertTrue(found) - } - - @Test - open fun `existsByName() returns false when no entity with the given name exists`() { - whenever(repository.existsByName(entity.name)).doReturn(false) - - val found = service.existsByName(entity.name) - - assertFalse(found) - } - - // getByName() - - @Test - open fun `getByName() returns the entity with the given name`() { - whenever(repository.findByName(entity.name)).doReturn(entity) - - val found = service.getByName(entity.name) - - assertEquals(entity, found) - } - - @Test - open fun `getByName() throws NotFoundException when no entity with the given name exists`() { - whenever(repository.findByName(entity.name)).doReturn(null) - - assertThrows { service.getByName(entity.name) } - .assertErrorCode("name") - } - - // save() - - @Test - open fun `save() throws AlreadyExistsException when an entity with the given name exists`() { - doReturn(true).whenever(service).existsByName(entity.name) - - assertThrows { service.save(entity) } - .assertErrorCode("name") - } - - // update() - - @Test - override fun `update() saves in the repository and returns the updated value`() { - whenever(repository.save(entity)).doReturn(entity) - whenever(repository.findByName(entity.name)).doReturn(null) - doReturn(true).whenever(service).existsById(entity.id!!) - doReturn(entity).whenever(service).getById(entity.id!!) - - val found = service.update(entity) - - verify(repository).save(entity) - assertEquals(entity, found) - } - - @Test - override fun `update() throws NotFoundException when no entity with the given id exists in the repository`() { - whenever(repository.findByName(entity.name)).doReturn(null) - doReturn(false).whenever(service).existsById(entity.id!!) - - assertThrows { service.update(entity) } - } - - @Test - open fun `update() throws AlreadyExistsException when an entity with the updated name exists`() { - whenever(repository.findByName(entity.name)).doReturn(entityWithEntityName) - doReturn(entity).whenever(service).getById(entity.id!!) - - assertThrows { service.update(entity) } - .assertErrorCode("name") - } -} - -interface ExternalModelServiceTest { - fun `save(dto) calls and returns save() with the created entity`() - fun `update(dto) calls and returns update() with the created entity`() -} - -// ==== IMPLEMENTATIONS FOR EXTERNAL SERVICES ==== -// Lots of code duplication but I don't have a better solution for now -abstract class AbstractExternalModelServiceTest, U : EntityDto, S : ExternalModelService, R : JpaRepository> : - AbstractModelServiceTest(), ExternalModelServiceTest { - protected abstract val entitySaveDto: N - protected abstract val entityUpdateDto: U - - @AfterEach - override fun afterEach() { - reset(entitySaveDto, entityUpdateDto) - super.afterEach() - } -} - -abstract class AbstractExternalNamedModelServiceTest, U : EntityDto, S : ExternalNamedModelService, R : NamedJpaRepository> : - AbstractNamedModelServiceTest(), ExternalModelServiceTest { - protected abstract val entitySaveDto: N - protected abstract val entityUpdateDto: U - - @AfterEach - override fun afterEach() { - reset(entitySaveDto, entityUpdateDto) - super.afterEach() - } -} - -fun NotFoundException.assertErrorCode(identifierName: String = "id") = - this.assertErrorCode("notfound", identifierName) - -fun AlreadyExistsException.assertErrorCode(identifierName: String = "id") = - this.assertErrorCode("exists", identifierName) - -fun RestException.assertErrorCode(type: String, identifierName: String) { - assertTrue { - this.errorCode.startsWith(type) && - this.errorCode.endsWith(identifierName) - } -} - -fun RestException.assertErrorCode(errorCode: String) { - assertEquals(errorCode, this.errorCode) -} - -fun > withBaseSaveDtoTest( - entity: E, - entitySaveDto: N, - service: ExternalService, - saveMockMatcher: () -> E = { entity }, - op: () -> Unit = {} -) { - doReturn(entity).whenever(service).save(saveMockMatcher()) - doReturn(entity).whenever(entitySaveDto).toEntity() - - val found = service.save(entitySaveDto) - - verify(service).save(saveMockMatcher()) - assertEquals(entity, found) - - op() -} - -fun > withBaseUpdateDtoTest( - entity: E, - entityUpdateDto: U, - service: ExternalModelService, - updateMockMatcher: () -> E, - op: E.() -> Unit = {} -) { - doAnswer { it.arguments[0] }.whenever(service).update(updateMockMatcher()) - doReturn(entity).whenever(entityUpdateDto).toEntity() - doReturn(entity).whenever(service).getById(entity.id!!) - doReturn(true).whenever(service).existsById(entity.id!!) - - val found = service.update(entityUpdateDto) - - verify(service).update(updateMockMatcher()) - assertEquals(entity, found) - - found.op() -} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountsServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountsServiceTest.kt deleted file mode 100644 index 7fd9b53..0000000 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/AccountsServiceTest.kt +++ /dev/null @@ -1,348 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.service - -import com.nhaarman.mockitokotlin2.* -import dev.fyloz.colorrecipesexplorer.config.security.defaultGroupCookieName -import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException -import dev.fyloz.colorrecipesexplorer.exception.NotFoundException -import dev.fyloz.colorrecipesexplorer.model.account.* -import dev.fyloz.colorrecipesexplorer.repository.GroupRepository -import dev.fyloz.colorrecipesexplorer.repository.UserRepository -import dev.fyloz.colorrecipesexplorer.service.users.* -import org.junit.jupiter.api.* -import org.springframework.mock.web.MockHttpServletResponse -import org.springframework.security.core.userdetails.UsernameNotFoundException -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder -import java.util.* -import javax.servlet.http.Cookie -import javax.servlet.http.HttpServletRequest -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class UserServiceTest : - AbstractExternalModelServiceTest() { - private val passwordEncoder = BCryptPasswordEncoder() - - override val entity: User = user(id = 0L, passwordEncoder = passwordEncoder) - override val anotherEntity: User = user(id = 1L, passwordEncoder = passwordEncoder) - private val entityDefaultGroupUser = user(id = 2L, isDefaultGroupUser = true, passwordEncoder = passwordEncoder) - private val entitySystemUser = user(id = 3L, isSystemUser = true, passwordEncoder = passwordEncoder) - private val group = group(id = 0L) - override val entitySaveDto: UserSaveDto = spy(userSaveDto(passwordEncoder, id = 0L)) - override val entityUpdateDto: UserUpdateDto = spy(userUpdateDto(id = 0L)) - - override val repository: UserRepository = mock() - private val groupService: GroupService = mock() - override val service: UserService = spy(UserServiceImpl(repository, groupService)) - - private val entitySaveDtoUser = User( - entitySaveDto.id, - entitySaveDto.firstName, - entitySaveDto.lastName, - passwordEncoder.encode(entitySaveDto.password), - isDefaultGroupUser = false, - isSystemUser = false, - group = null, - permissions = entitySaveDto.permissions - ) - - @AfterEach - override fun afterEach() { - reset(groupService) - super.afterEach() - } - - // existsByFirstNameAndLastName() - - @Test - fun `existsByFirstNameAndLastName() returns true when an user with the given first name and last name exists`() { - whenever(repository.existsByFirstNameAndLastName(entity.firstName, entity.lastName)).doReturn(true) - - val found = service.existsByFirstNameAndLastName(entity.firstName, entity.lastName) - - assertTrue(found) - } - - @Test - fun `existsByFirstNameAndLastName() returns false when no user with the given first name and last name exists`() { - whenever(repository.existsByFirstNameAndLastName(entity.firstName, entity.lastName)).doReturn(false) - - val found = service.existsByFirstNameAndLastName(entity.firstName, entity.lastName) - - assertFalse(found) - } - - // getById() - - @Test - fun `getById() throws NotFoundException when the corresponding user is a default group user`() { - whenever(repository.findById(entityDefaultGroupUser.id)).doReturn(Optional.of(entityDefaultGroupUser)) - - assertThrows { - service.getById( - entityDefaultGroupUser.id, - ignoreDefaultGroupUsers = true, - ignoreSystemUsers = false - ) - }.assertErrorCode() - } - - @Test - fun `getById() throws NotFoundException when the corresponding user is a system user`() { - whenever(repository.findById(entitySystemUser.id)).doReturn(Optional.of(entitySystemUser)) - - assertThrows { - service.getById( - entitySystemUser.id, - ignoreDefaultGroupUsers = false, - ignoreSystemUsers = true - ) - }.assertErrorCode() - } - - // getByGroup() - - @Test - fun `getByGroup() returns all the users with the given group from the repository`() { - whenever(repository.findAllByGroup(group)).doReturn(entityList) - - val found = service.getByGroup(group) - - assertTrue(found.containsAll(entityList)) - assertTrue(entityList.containsAll(found)) - } - - @Test - fun `getByGroup() returns an empty list when there is no user with the given group in the repository`() { - whenever(repository.findAllByGroup(group)).doReturn(listOf()) - - val found = service.getByGroup(group) - - assertTrue(found.isEmpty()) - } - - // getDefaultGroupUser() - - @Test - fun `getDefaultGroupUser() returns the default user of the given group from the repository`() { - whenever(repository.findByIsDefaultGroupUserIsTrueAndGroupIs(group)).doReturn(entityDefaultGroupUser) - - val found = service.getDefaultGroupUser(group) - - assertEquals(entityDefaultGroupUser, found) - } - - // save() - - override fun `save() saves in the repository and returns the saved value`() { - whenever(repository.save(entity)).doReturn(entity) - doReturn(false).whenever(repository).existsByFirstNameAndLastName(entity.firstName, entity.lastName) - - val found = service.save(entity) - - verify(repository).save(entity) - assertEquals(entity, found) - } - - @Test - fun `save() throws AlreadyExistsException when firstName and lastName exists`() { - doReturn(true).whenever(repository).existsByFirstNameAndLastName(entity.firstName, entity.lastName) - - assertThrows { service.save(entity) } - .assertErrorCode("fullName") - } - - @Test - override fun `save(dto) calls and returns save() with the created entity`() { - withBaseSaveDtoTest(entity, entitySaveDto, service, { - argThat { - this.id == entity.id && this.firstName == entity.firstName && this.lastName == entity.lastName - } - }) - } - - @Test - fun `save(dto) calls and returns save() with the created user`() { - doReturn(entitySaveDtoUser).whenever(service).save(any()) - - val found = service.save(entitySaveDto) - - verify(service).save(argThat { this.id == entity.id && this.firstName == entity.firstName && this.lastName == entity.lastName }) - assertEquals(entitySaveDtoUser, found) - } - - // update() - - @Test - override fun `update(dto) calls and returns update() with the created entity`() = - withBaseUpdateDtoTest(entity, entityUpdateDto, service, { any() }) - - @Test - fun `update() throws AlreadyExistsException when a different user with the given first name and last name exists`() { - whenever(repository.findByFirstNameAndLastName(entity.firstName, entity.lastName)).doReturn( - entityDefaultGroupUser - ) - doReturn(entity).whenever(service).getById(eq(entity.id), any(), any()) - - assertThrows { - service.update( - entity, - true, - ignoreSystemUsers = true - ) - }.assertErrorCode("fullName") - } -} - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class GroupServiceTest : - AbstractExternalNamedModelServiceTest() { - private val userService: UserService = mock() - override val repository: GroupRepository = mock() - override val service: GroupServiceImpl = spy(GroupServiceImpl(userService, repository)) - - override val entity: Group = group(id = 0L, name = "group") - override val anotherEntity: Group = group(id = 1L, name = "another group") - override val entitySaveDto: GroupSaveDto = spy(groupSaveDto(name = "group")) - override val entityUpdateDto: GroupUpdateDto = spy(groupUpdateDto(id = 0L, name = "group")) - override val entityWithEntityName: Group = group(id = 2L, name = entity.name) - - private val groupUserId = 1000000L + entity.id!! - private val groupUser = user(passwordEncoder = BCryptPasswordEncoder(), id = groupUserId, group = entity) - - @BeforeEach - override fun afterEach() { - reset(userService) - super.afterEach() - } - - // getUsersForGroup() - - @Test - fun `getUsersForGroup() returns all users in the given group`() { - val group = group(id = 1L) - - doReturn(group).whenever(service).getById(group.id!!) - whenever(userService.getByGroup(group)).doReturn(listOf(groupUser)) - - val found = service.getUsersForGroup(group.id!!) - - assertTrue(found.contains(groupUser)) - assertTrue(found.size == 1) - } - - @Test - fun `getUsersForGroup() returns empty collection when the given group contains any user`() { - doReturn(entity).whenever(service).getById(entity.id!!) - - val found = service.getUsersForGroup(entity.id!!) - - assertTrue(found.isEmpty()) - } - - // getRequestDefaultGroup() - - @Test - fun `getRequestDefaultGroup() returns the group contained in the cookie of the HTTP request`() { - val cookies: Array = arrayOf(Cookie(defaultGroupCookieName, groupUserId.toString())) - val request: HttpServletRequest = mock() - - whenever(request.cookies).doReturn(cookies) - whenever(userService.getById(eq(groupUserId), any(), any())).doReturn(groupUser) - - val found = service.getRequestDefaultGroup(request) - - assertEquals(entity, found) - } - - @Test - fun `getRequestDefaultGroup() throws NoDefaultGroupException when the HTTP request does not contains a cookie for the default group`() { - val request: HttpServletRequest = mock() - - whenever(request.cookies).doReturn(arrayOf()) - - assertThrows { service.getRequestDefaultGroup(request) } - } - - // setResponseDefaultGroup() - - @Test - fun `setResponseDefaultGroup() the default group cookie has been added to the given HTTP response with the given group id`() { - val response = MockHttpServletResponse() - - whenever(userService.getDefaultGroupUser(entity)).doReturn(groupUser) - doReturn(entity).whenever(service).getById(entity.id!!) - - service.setResponseDefaultGroup(entity.id!!, response) - val found = response.getCookie(defaultGroupCookieName) - - assertNotNull(found) - assertEquals(defaultGroupCookieName, found.name) - assertEquals(groupUserId.toString(), found.value) - assertEquals(defaultGroupCookieMaxAge, found.maxAge) - assertTrue(found.isHttpOnly) - assertTrue(found.secure) - } - - // save() - - @Test - override fun `save(dto) calls and returns save() with the created entity`() { - withBaseSaveDtoTest(entity, entitySaveDto, service) - } - - // update() - - @Test - override fun `update(dto) calls and returns update() with the created entity`() = - withBaseUpdateDtoTest(entity, entityUpdateDto, service, { any() }) -} - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class UserUserDetailsServiceTest { - private val userService: UserService = mock() - private val service = spy(UserDetailsServiceImpl(userService)) - - private val user = user(id = 0L) - - @BeforeEach - fun beforeEach() { - reset(userService, service) - } - - // loadUserByUsername() - - @Test - fun `loadUserByUsername() calls loadUserByUserId() with the given username as an id`() { - whenever(userService.getById(eq(user.id), any(), any())).doReturn(user) - doReturn(UserDetails(user(id = user.id, plainPassword = user.password))) - .whenever(service).loadUserById(user.id) - - service.loadUserByUsername(user.id.toString()) - - verify(service).loadUserById(eq(user.id), any()) - } - - @Test - fun `loadUserByUsername() throws UsernameNotFoundException when no user with the given id exists`() { - whenever(userService.getById(eq(user.id), any(), any())).doThrow( - userIdNotFoundException(user.id) - ) - - assertThrows { service.loadUserByUsername(user.id.toString()) } - } - - // loadUserByUserId - - @Test - fun `loadUserByUserId() returns an User corresponding to the user with the given id`() { - whenever(userService.getById(eq(user.id), any(), any())).doReturn(user) - - val found = service.loadUserById(user.id) - - assertEquals(user.id, found.username.toLong()) - assertEquals(user.password, found.password) - } -} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/CompanyServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/CompanyServiceTest.kt deleted file mode 100644 index 7843cae..0000000 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/CompanyServiceTest.kt +++ /dev/null @@ -1,85 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.service - -import com.nhaarman.mockitokotlin2.* -import dev.fyloz.colorrecipesexplorer.model.* -import dev.fyloz.colorrecipesexplorer.repository.CompanyRepository -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class CompanyServiceTest : - AbstractExternalNamedModelServiceTest() { - private val recipeService: RecipeService = mock() - override val repository: CompanyRepository = mock() - override val service: CompanyService = spy(CompanyServiceImpl(repository, recipeService)) - - override val entity: Company = company(id = 0L, name = "company") - override val anotherEntity: Company = company(id = 1L, name = "another company") - override val entityWithEntityName: Company = company(id = 2L, name = entity.name) - override val entitySaveDto: CompanySaveDto = spy(companySaveDto()) - override val entityUpdateDto: CompanyUpdateDto = spy(companyUpdateDto(id = entity.id!!, name = null)) - - @AfterEach - override fun afterEach() { - reset(recipeService) - super.afterEach() - } - - // isLinkedToRecipes - - @Test - fun `isLinkedToRecipes() returns true when a given company is linked to one or more recipes`() { - whenever(recipeService.existsByCompany(entity)).doReturn(true) - - val found = service.isLinkedToRecipes(entity) - - assertTrue(found) - } - - @Test - fun `isLinkedToRecipes() returns false when a given company is not linked to any recipe`() { - whenever(recipeService.existsByCompany(entity)).doReturn(false) - - val found = service.isLinkedToRecipes(entity) - - assertFalse(found) - } - - // save() - - @Test - override fun `save(dto) calls and returns save() with the created entity`() { - withBaseSaveDtoTest(entity, entitySaveDto, service) - } - - // update() - - @Test - override fun `update(dto) calls and returns update() with the created entity`() = - withBaseUpdateDtoTest(entity, entityUpdateDto, service, { any() }) - - // delete() - - override fun `delete() deletes in the repository`() { - whenCanBeDeleted { - super.`delete() deletes in the repository`() - } - } - - // deleteById() - - override fun `deleteById() deletes the entity with the given id in the repository`() { - whenCanBeDeleted { - super.`deleteById() deletes the entity with the given id in the repository`() - } - } - - private fun whenCanBeDeleted(id: Long = any(), test: () -> Unit) { - whenever(repository.canBeDeleted(id)).doReturn(true) - - test() - } -} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/InventoryServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/InventoryServiceTest.kt deleted file mode 100644 index 98eb32e..0000000 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/InventoryServiceTest.kt +++ /dev/null @@ -1,182 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.service - -import com.nhaarman.mockitokotlin2.* -import dev.fyloz.colorrecipesexplorer.model.* -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import org.junit.jupiter.api.assertThrows -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class InventoryServiceTest { - private val materialService: MaterialService = mock() - private val mixService: MixService = mock() - private val service = spy(InventoryServiceImpl(materialService, mixService)) - - @AfterEach - fun afterEach() { - reset(materialService, service) - } - - // add() - - @Test - fun `add(materialQuantities) calls add() for each MaterialQuantityDto`() { - val materialQuantities = listOf( - materialQuantityDto(materialId = 1, quantity = 1234f), - materialQuantityDto(materialId = 2, quantity = 2345f), - materialQuantityDto(materialId = 3, quantity = 3456f), - materialQuantityDto(materialId = 4, quantity = 4567f) - ) - val storedQuantity = 2000f - - doAnswer { storedQuantity + (it.arguments[0] as MaterialQuantityDto).quantity }.whenever(service) - .add(any()) - - val found = service.add(materialQuantities) - - materialQuantities.forEach { - verify(service).add(it) - assertTrue { found.any { updated -> updated.material == it.material && updated.quantity == storedQuantity + it.quantity } } - } - } - - @Test - fun `add(materialQuantity) updates material's quantity`() { - withGivenQuantities(0f, 1000f) { - val updatedQuantity = it + this.quantity - whenever(materialService.updateQuantity(any(), eq(this.quantity))).doReturn(updatedQuantity) - - val found = service.add(this) - - verify(materialService).updateQuantity( - argThat { this.id == this@withGivenQuantities.material }, - eq(this.quantity) - ) - assertEquals(updatedQuantity, found) - } - } - - // deductMix() - - @Test - fun `deductMix() calls deduct() with a collection of MaterialQuantityDto with adjusted quantities`() { - val material = material(id = 0L, materialType = materialType(usePercentages = false)) - val materialPercents = material(id = 1L, materialType = materialType(usePercentages = true)) - val mixRatio = mixRatio(ratio = 1.5f) - val mix = mix( - id = mixRatio.id, - mixMaterials = mutableSetOf( - mixMaterial(id = 0L, material = material, quantity = 1000f, position = 0), - mixMaterial(id = 1L, material = materialPercents, quantity = 50f, position = 1) - ) - ) - val expectedQuantities = mapOf( - 0L to 1500f, - 1L to 750f - ) - - whenever(mixService.getById(mix.id!!)).doReturn(mix) - doAnswer { - (it.arguments[0] as Collection).map { materialQuantity -> - materialQuantityDto(materialId = materialQuantity.material, quantity = 0f) - } - }.whenever(service).deduct(any>()) - - val found = service.deductMix(mixRatio) - - verify(service).deduct(argThat> { - this.all { it.quantity == expectedQuantities[it.material] } - }) - - assertEquals(expectedQuantities.size, found.size) - } - - // deduct() - - @Test - fun `deduct(materialQuantities) calls deduct() for each MaterialQuantityDto`() { - val materialQuantities = listOf( - materialQuantityDto(materialId = 1, quantity = 1234f), - materialQuantityDto(materialId = 2, quantity = 2345f), - materialQuantityDto(materialId = 3, quantity = 3456f), - materialQuantityDto(materialId = 4, quantity = 4567f) - ) - val storedQuantity = 5000f - - doAnswer { storedQuantity - (it.arguments[0] as MaterialQuantityDto).quantity }.whenever(service) - .deduct(any()) - - val found = service.deduct(materialQuantities) - - materialQuantities.forEach { - verify(service).deduct(it) - assertTrue { found.any { updated -> updated.material == it.material && updated.quantity == storedQuantity - it.quantity } } - } - } - - @Test - fun `deduct(materialQuantities) throws MultiplesNotEnoughInventoryException when there is not enough inventory of a given material`() { - val materialQuantities = listOf( - materialQuantityDto(materialId = 1, quantity = 1234f), - materialQuantityDto(materialId = 2, quantity = 2345f), - materialQuantityDto(materialId = 3, quantity = 3456f), - materialQuantityDto(materialId = 4, quantity = 4567f) - ) - val inventoryQuantity = 3000f - - materialQuantities.forEach { - withGivenQuantities(inventoryQuantity, it) - } - - assertThrows { service.deduct(materialQuantities) } - } - - @Test - fun `deduct(materialQuantity) updates material's quantity`() { - withGivenQuantities(5000f, 1000f) { - val updatedQuantity = it - this.quantity - whenever(materialService.updateQuantity(any(), eq(-this.quantity))).doReturn(updatedQuantity) - - val found = service.deduct(this) - - verify(materialService).updateQuantity( - argThat { this.id == this@withGivenQuantities.material }, - eq(-this.quantity) - ) - assertEquals(updatedQuantity, found) - } - } - - @Test - fun `deduct(materialQuantity) throws NotEnoughInventoryException when there is not enough inventory of the given material`() { - withGivenQuantities(0f, 1000f) { - assertThrows { service.deduct(this) } - } - } - - private fun withGivenQuantities( - stored: Float, - quantity: Float, - materialId: Long = 0L, - test: MaterialQuantityDto.(Float) -> Unit = {} - ) { - val materialQuantity = materialQuantityDto(materialId = materialId, quantity = quantity) - - withGivenQuantities(stored, materialQuantity, test) - } - - private fun withGivenQuantities( - stored: Float, - materialQuantity: MaterialQuantityDto, - test: MaterialQuantityDto.(Float) -> Unit = {} - ) { - val material = material(id = materialQuantity.material, inventoryQuantity = stored) - - whenever(materialService.getById(material.id!!)).doReturn(material) - - materialQuantity.test(stored) - } -} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialServiceTest.kt deleted file mode 100644 index be7c476..0000000 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialServiceTest.kt +++ /dev/null @@ -1,249 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.service - -import com.nhaarman.mockitokotlin2.* -import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException -import dev.fyloz.colorrecipesexplorer.model.* -import dev.fyloz.colorrecipesexplorer.repository.MaterialRepository -import dev.fyloz.colorrecipesexplorer.service.files.WriteableFileService -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import org.junit.jupiter.api.assertThrows -import org.springframework.mock.web.MockMultipartFile -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class MaterialServiceTest : - AbstractExternalNamedModelServiceTest() { - override val repository: MaterialRepository = mock() - private val recipeService: RecipeService = mock() - private val mixService: MixService = mock() - private val materialTypeService: MaterialTypeService = mock() - private val fileService: WriteableFileService = mock() - override val service: MaterialService = - spy(MaterialServiceImpl(repository, recipeService, mixService, materialTypeService, fileService, mock())) - - override val entity: Material = material(id = 0L, name = "material") - private val entityOutput = materialOutputDto(entity) - override val anotherEntity: Material = material(id = 1L, name = "another material") - override val entityWithEntityName: Material = material(id = 2L, name = "material") - override val entitySaveDto: MaterialSaveDto = spy(materialSaveDto()) - override val entityUpdateDto: MaterialUpdateDto = spy(materialUpdateDto(id = 0L)) - - private val materialType = materialType() - - @AfterEach - override fun afterEach() { - reset(recipeService, mixService, materialTypeService, fileService) - super.afterEach() - } - - // existsByMaterialType - - @Test - fun `existsByMaterialType() returns true when a material with the given material type exists in the repository`() { - whenever(repository.existsByMaterialType(materialType)).doReturn(true) - - val found = service.existsByMaterialType(materialType) - - assertTrue(found) - } - - @Test - fun `existsByMaterialType() returns false when no material with the given material type exists in the repository`() { - whenever(repository.existsByMaterialType(materialType)).doReturn(false) - - val found = service.existsByMaterialType(materialType) - - assertFalse(found) - } - - // hasSimdut() - - @Test - fun `hasSimdut() returns false when simdutService_exists() returns false`() { - whenever(fileService.exists(any())).doReturn(false) - doReturn(entity).whenever(service).getById(entity.id!!) - - val found = service.hasSimdut(entity) - - assertFalse(found) - } - - @Test - fun `hasSimdut() returns true when simdutService_exists() returns true`() { - whenever(fileService.exists(any())).doReturn(true) - doReturn(entity).whenever(service).getById(entity.id!!) - - val found = service.hasSimdut(entity) - - assertTrue(found) - } - - // getAllNotMixType() - - @Test - fun `getAllNotMixType() returns a list containing every material that are not a mix type`() { - val mixTypeMaterial = material(id = 1L, name = "mix type material", isMixType = true) - val mixTypeMaterialOutput = materialOutputDto(mixTypeMaterial) - val materialList = listOf(entity, mixTypeMaterial) - - doReturn(materialList).whenever(service).getAll() - - val found = service.getAllNotMixType() - - assertTrue(found.contains(entityOutput)) - assertFalse(found.contains(mixTypeMaterialOutput)) - } - - // save() - - @Test - fun `save() throws AlreadyExistsException when a material with the given name exists in the repository`() { - doReturn(true).whenever(service).existsByName(entity.name) - - assertThrows { service.save(entity) } - .assertErrorCode("name") - } - - @Test - override fun `save(dto) calls and returns save() with the created entity`() { - withBaseSaveDtoTest(entity, entitySaveDto, service, { any() }) - } - - @Test - fun `save(dto) calls simdutService_write() with the saved entity`() { - val mockMultipartFile = spy(MockMultipartFile("simdut", byteArrayOf())) - val materialSaveDto = spy(materialSaveDto(simdutFile = mockMultipartFile)) - - doReturn(false).whenever(mockMultipartFile).isEmpty - doReturn(entity).whenever(service).save(any()) - - service.save(materialSaveDto) - - verify(fileService).write(mockMultipartFile, entity.simdutFilePath, false) - } - - // update() - - @Test - fun `update() throws AlreadyExistsException when another material with the updated name exists in the repository`() { - val material = material(id = 0L, name = "name") - val anotherMaterial = material(id = 1L, name = "name") - - whenever(repository.findByName(material.name)).doReturn(anotherMaterial) - doReturn(entity).whenever(service).getById(material.id!!) - - assertThrows { service.update(material) } - .assertErrorCode("name") - } - - @Test - override fun `update(dto) calls and returns update() with the created entity`() { - val mockSimdutFile = MockMultipartFile("simdut", byteArrayOf(1, 2, 3, 4, 5)) - val materialUpdateDto = spy(materialUpdateDto(id = 0L, simdutFile = mockSimdutFile)) - - doReturn(entity).whenever(service).getById(any()) - doReturn(entity).whenever(service).update(any()) - doReturn(entity).whenever(materialUpdateDto).toEntity() - - service.update(materialUpdateDto) - - verify(fileService).write(mockSimdutFile, entity.simdutFilePath, true) - } - - // updateQuantity() - - @Test - fun `updateQuantity() updates the quantity of the the given material in the repository`() { - val material = material(id = 0L, inventoryQuantity = 4321f) - val quantity = 1234f - val totalQuantity = material.inventoryQuantity + quantity - - val found = service.updateQuantity(material, quantity) - - verify(repository).updateInventoryQuantityById(material.id!!, totalQuantity) - assertEquals(totalQuantity, found) - } - - // getAllForMixCreation() - - @Test - fun `getAllForMixCreation() returns all normal materials and all mix type materials for the given recipe`() { - val normalMaterial = material(id = 0L, isMixType = false) - val mixTypeMaterial = material(id = 1L, isMixType = true) - val anotherMixTypeMaterial = material(id = 2L, isMixType = true) - val materials = listOf(normalMaterial, mixTypeMaterial, anotherMixTypeMaterial) - val recipe = - recipe(id = 0L, mixes = mutableListOf(mix(mixType = mixType(id = 0L, material = mixTypeMaterial)))) - - whenever(recipeService.getById(recipe.id!!)).doReturn(recipe) - doReturn(materials).whenever(service).getAll() - - val found = service.getAllForMixCreation(recipe.id!!) - - assertTrue(materialOutputDto(normalMaterial) in found) - assertTrue(materialOutputDto(mixTypeMaterial) in found) - assertFalse(materialOutputDto(anotherMixTypeMaterial) in found) - } - - // getAllForMixUpdate() - - @Test - fun `getAllForMixUpdate() returns all normal materials and all mix type materials for the recipe of the given mix without the mix type of the said mix`() { - val normalMaterial = material(id = 0L, isMixType = false) - val mixTypeMaterial = material(id = 1L, isMixType = true) - val anotherMixTypeMaterial = material(id = 2L, isMixType = true) - val materials = listOf(normalMaterial, mixTypeMaterial, anotherMixTypeMaterial) - val recipe = recipe(id = 0L, mixes = mutableListOf(mix(mixType = mixType(material = mixTypeMaterial)))) - val mix = mix(id = 1L, recipe = recipe, mixType = mixType(material = anotherMixTypeMaterial)) - recipe.mixes.add(mix) - - whenever(mixService.getById(mix.id!!)).doReturn(mix) - doReturn(materials).whenever(service).getAll() - - val found = service.getAllForMixUpdate(mix.id!!) - - assertTrue(materialOutputDto(normalMaterial) in found) - assertTrue(materialOutputDto(mixTypeMaterial) in found) - assertFalse(materialOutputDto(anotherMixTypeMaterial) in found) - } - - - // delete() - - override fun `delete() deletes in the repository`() { - whenCanBeDeleted { - super.`delete() deletes in the repository`() - } - } - - // deleteById() - - override fun `deleteById() deletes the entity with the given id in the repository`() { - whenCanBeDeleted { - super.`deleteById() deletes the entity with the given id in the repository`() - } - } - - /** Utility property to check if the identifier of the given [Material] is even. */ - private val Material.evenId: Boolean - get() = this.id!! % 2 == 0L - - private fun whenCanBeDeleted(id: Long = any(), test: () -> Unit) { - whenever(repository.canBeDeleted(id)).doReturn(true) - - test() - } - - private fun materialOutputDto(material: Material) = MaterialOutputDto( - id = material.id!!, - name = material.name, - inventoryQuantity = material.inventoryQuantity, - isMixType = material.isMixType, - materialType = material.materialType!!, - simdutUrl = null - ) -} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialTypeServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialTypeServiceTest.kt deleted file mode 100644 index 7d8a1dd..0000000 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MaterialTypeServiceTest.kt +++ /dev/null @@ -1,186 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.service - -import com.nhaarman.mockitokotlin2.* -import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException -import dev.fyloz.colorrecipesexplorer.exception.NotFoundException -import dev.fyloz.colorrecipesexplorer.model.* -import dev.fyloz.colorrecipesexplorer.repository.MaterialTypeRepository -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import org.junit.jupiter.api.assertThrows -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class MaterialTypeServiceTest : - AbstractExternalNamedModelServiceTest() { - override val repository: MaterialTypeRepository = mock() - private val materialService: MaterialService = mock() - override val service: MaterialTypeService = spy(MaterialTypeServiceImpl(repository, materialService)) - override val entity: MaterialType = materialType(id = 0L, name = "material type", prefix = "MAT") - override val anotherEntity: MaterialType = materialType(id = 1L, name = "another material type", prefix = "AMT") - override val entityWithEntityName: MaterialType = materialType(2L, name = entity.name, prefix = "EEN") - private val systemType = materialType(id = 3L, name = "systype", prefix = "SYS", systemType = true) - private val anotherSystemType = materialType(id = 4L, name = "another systype", prefix = "ASY", systemType = true) - override val entitySaveDto: MaterialTypeSaveDto = spy(materialTypeSaveDto(name = "material type", prefix = "MAT")) - override val entityUpdateDto: MaterialTypeUpdateDto = - spy(materialTypeUpdateDto(id = 0L, name = "material type", prefix = "MAT")) - - @AfterEach - override fun afterEach() { - reset(materialService) - super.afterEach() - } - - // existsByPrefix() - - @Test - fun `existsByPrefix() returns true when a material type with the given prefix exists`() { - whenever(repository.existsByPrefix(entity.prefix)).doReturn(true) - - val found = service.existsByPrefix(entity.prefix) - - assertTrue(found) - } - - @Test - fun `existsByPrefix() returns false when no material type with the given prefix exists`() { - whenever(repository.existsByPrefix(entity.prefix)).doReturn(false) - - val found = service.existsByPrefix(entity.prefix) - - assertFalse(found) - } - - // isUsedByMaterial() - - @Test - fun `isUsedByMaterial() returns true when materialService_existsByMaterialType() returns true`() { - whenever(materialService.existsByMaterialType(entity)).doReturn(true) - - val found = service.isUsedByMaterial(entity) - - assertTrue(found) - } - - @Test - fun `isUsedByMaterial() returns false when materialService_existsByMaterialType() returns false`() { - whenever(materialService.existsByMaterialType(entity)).doReturn(false) - - val found = service.isUsedByMaterial(entity) - - assertFalse(found) - } - - // getAllSystemTypes() - - @Test - fun `getAllSystemTypes() returns all system types`() { - whenever(repository.findAllBySystemTypeIs(true)).doReturn(listOf(systemType, anotherSystemType)) - - val found = service.getAllSystemTypes() - - assertTrue(found.contains(systemType)) - assertTrue(found.contains(anotherSystemType)) - } - - // getAllNonSystemTypes() - - @Test - fun `getAllNonSystemTypes() returns all non system types`() { - whenever(repository.findAllBySystemTypeIs(false)).doReturn(listOf(entity, anotherEntity)) - - val found = service.getAllNonSystemType() - - assertTrue(found.contains(entity)) - assertTrue(found.contains(anotherEntity)) - } - - // save() - - @Test - override fun `save(dto) calls and returns save() with the created entity`() { - withBaseSaveDtoTest(entity, entitySaveDto, service) - } - - // saveMaterialType() - - @Test - fun `saveMaterialType() throws AlreadyExistsException when a material type with the given prefix already exists`() { - doReturn(true).whenever(service).existsByPrefix(entity.prefix) - - assertThrows { service.save(entity) } - .assertErrorCode("prefix") - } - - // update() - - @Test - override fun `update(dto) calls and returns update() with the created entity`() = - withBaseUpdateDtoTest(entity, entityUpdateDto, service, { any() }) - - override fun `update() saves in the repository and returns the updated value`() { - whenever(repository.save(entity)).doReturn(entity) - whenever(repository.findByName(entity.name)).doReturn(null) - whenever(repository.findByPrefix(entity.prefix)).doReturn(null) - doReturn(true).whenever(service).existsById(entity.id!!) - doReturn(entity).whenever(service).getById(entity.id!!) - - val found = service.update(entity) - - verify(repository).save(entity) - assertEquals(entity, found) - } - - override fun `update() throws NotFoundException when no entity with the given id exists in the repository`() { - whenever(repository.findByName(entity.name)).doReturn(null) - whenever(repository.findByPrefix(entity.prefix)).doReturn(null) - doReturn(false).whenever(service).existsById(entity.id!!) - doReturn(null).whenever(service).getById(entity.id!!) - - assertThrows { service.update(entity) } - .assertErrorCode() - } - - override fun `update() throws AlreadyExistsException when an entity with the updated name exists`() { - whenever(repository.findByName(entity.name)).doReturn(entityWithEntityName) - whenever(repository.findByPrefix(entity.prefix)).doReturn(null) - doReturn(true).whenever(service).existsById(entity.id!!) - doReturn(entity).whenever(service).getById(entity.id!!) - - assertThrows { service.update(entity) } - .assertErrorCode("name") - } - - @Test - fun `update() throws AlreadyExistsException when an entity with the updated prefix exists`() { - val anotherMaterialType = materialType(prefix = entity.prefix) - whenever(repository.findByPrefix(entity.prefix)).doReturn(anotherMaterialType) - doReturn(entity).whenever(service).getById(entity.id!!) - - assertThrows { service.update(entity) } - .assertErrorCode("prefix") - } - - // delete() - - override fun `delete() deletes in the repository`() { - whenCanBeDeleted { - super.`delete() deletes in the repository`() - } - } - - override fun `deleteById() deletes the entity with the given id in the repository`() { - whenCanBeDeleted { - super.`deleteById() deletes the entity with the given id in the repository`() - } - } - - private fun whenCanBeDeleted(id: Long = any(), test: () -> Unit) { - whenever(repository.canBeDeleted(id)).doReturn(true) - - test() - } -} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MixMaterialServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MixMaterialServiceTest.kt deleted file mode 100644 index 0ab18cf..0000000 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MixMaterialServiceTest.kt +++ /dev/null @@ -1,171 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.service - -import com.nhaarman.mockitokotlin2.* -import dev.fyloz.colorrecipesexplorer.model.* -import dev.fyloz.colorrecipesexplorer.repository.MixMaterialRepository -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import org.junit.jupiter.api.assertThrows -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotEquals -import kotlin.test.assertTrue - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class MixMaterialServiceTest : AbstractModelServiceTest() { - override val repository: MixMaterialRepository = mock() - private val materialService: MaterialService = mock() - override val service: MixMaterialService = spy(MixMaterialServiceImpl(repository, materialService)) - - private val material: Material = material(id = 0L) - override val entity: MixMaterial = mixMaterial(id = 0L, material = material, quantity = 1000f) - override val anotherEntity: MixMaterial = mixMaterial(id = 1L, material = material) - - // existsByMaterial() - - @Test - fun `existsByMaterial() returns true when a mix material with the given material exists`() { - whenever(repository.existsByMaterial(material)).doReturn(true) - - val found = service.existsByMaterial(material) - - assertTrue(found) - } - - @Test - fun `existsByMaterial() returns false when no mix material with the given material exists`() { - whenever(repository.existsByMaterial(material)).doReturn(false) - - val found = service.existsByMaterial(material) - - assertFalse(found) - } - - // create() - - @Test - fun `create(set) calls create() for each MixMaterialDto`() { - val mixMaterialDtos = setOf( - mixMaterialDto(materialId = 0L, quantity = 1000f, position = 1), - mixMaterialDto(materialId = 1L, quantity = 2000f, position = 2), - mixMaterialDto(materialId = 2L, quantity = 3000f, position = 3), - mixMaterialDto(materialId = 3L, quantity = 4000f, position = 4) - ) - - doAnswer { - with(it.arguments[0] as MixMaterialDto) { - mixMaterial( - material = material(id = this.materialId), - quantity = this.quantity, - position = this.position - ) - } - }.whenever(service).create(any()) - - val found = service.create(mixMaterialDtos) - - mixMaterialDtos.forEach { dto -> - verify(service).create(dto) - assertTrue { - found.any { - it.material.id == dto.materialId && it.quantity == dto.quantity && it.position == dto.position - } - } - } - } - - @Test - fun `create() creates a mix material according to the given MixUpdateDto`() { - val mixMaterialDto = mixMaterialDto(materialId = 0L, quantity = 1000f, position = 1) - - whenever(materialService.getById(mixMaterialDto.materialId)).doAnswer { material(id = it.arguments[0] as Long) } - - val found = service.create(mixMaterialDto) - - assertTrue { - found.material.id == mixMaterialDto.materialId && - found.quantity == mixMaterialDto.quantity && - found.position == mixMaterialDto.position - } - } - - // updateQuantity() - - @Test - fun `updateQuantity() updates the given mix material with the given quantity`() { - val quantity = 5000f - assertNotEquals(quantity, entity.quantity, message = "Quantities must not be equals for this test to works") - - doAnswer { it.arguments[0] }.whenever(service).update(any()) - - val found = service.updateQuantity(entity, quantity) - - assertEquals(found.quantity, quantity) - } - - // validateMixMaterials() - - @Test - fun `validateMixMaterials() throws InvalidMixMaterialsPositionsException when the position of the first mix material is not 1`() { - assertInvalidMixMaterialsPositionsException( - setOf( - mixMaterial(id = 0L, position = 0), - mixMaterial(id = 1L, position = 1), - mixMaterial(id = 2L, position = 2), - mixMaterial(id = 3L, position = 3) - ), - INVALID_FIRST_MIX_MATERIAL_POSITION_ERROR_CODE - ) - } - - @Test - fun `validateMixMaterials() throws InvalidMixMaterialsPositionsException when positions are duplicated`() { - assertInvalidMixMaterialsPositionsException( - setOf( - mixMaterial(id = 0L, position = 1), - mixMaterial(id = 1L, position = 2), - mixMaterial(id = 2L, position = 2), - mixMaterial(id = 3L, position = 3) - ), - DUPLICATED_MIX_MATERIALS_POSITIONS_ERROR_CODE - ) - } - - @Test - fun `validateMixMaterials() throws InvalidMixMaterialsPositionsException when there is a gap between positions`() { - assertInvalidMixMaterialsPositionsException( - setOf( - mixMaterial(id = 0L, position = 1), - mixMaterial(id = 1L, position = 2), - mixMaterial(id = 2L, position = 4), - mixMaterial(id = 3L, position = 5) - ), - GAP_BETWEEN_MIX_MATERIALS_POSITIONS_ERROR_CODE - ) - } - - @Test - fun `validateMixMaterials() throws InvalidFirstMixMaterial when the first mix material's quantity is expressed in percents`() { - val normalMaterial = material(materialType = materialType(usePercentages = false)) - val percentsMaterial = material(materialType = materialType(usePercentages = true)) - val mixMaterials = setOf( - mixMaterial(id = 0L, position = 1, material = percentsMaterial), - mixMaterial(id = 1L, position = 2, material = normalMaterial), - mixMaterial(id = 2L, position = 3, material = normalMaterial), - mixMaterial(id = 3L, position = 4, material = normalMaterial) - ) - - assertThrows { - service.validateMixMaterials(mixMaterials) - } - } - - private fun assertInvalidMixMaterialsPositionsException(mixMaterials: Set, errorType: String) { - val exception = assertThrows { - service.validateMixMaterials(mixMaterials) - } - - assertTrue { exception.errors.size == 1 } - assertTrue { exception.errors.first().type == errorType } - } -} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MixServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MixServiceTest.kt deleted file mode 100644 index 707da9f..0000000 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MixServiceTest.kt +++ /dev/null @@ -1,255 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.service - -import com.nhaarman.mockitokotlin2.* -import dev.fyloz.colorrecipesexplorer.model.* -import dev.fyloz.colorrecipesexplorer.repository.MixRepository -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class MixServiceTest : AbstractExternalModelServiceTest() { - override val repository: MixRepository = mock() - private val recipeService: RecipeService = mock() - private val materialTypeService: MaterialTypeService = mock() - private val mixMaterialService: MixMaterialService = mock() - private val mixTypeService: MixTypeService = mock() - override val service: MixService = - spy(MixServiceImpl(repository, recipeService, materialTypeService, mixMaterialService, mixTypeService)) - - override val entity: Mix = mix(id = 0L, location = "location") - override val anotherEntity: Mix = mix(id = 1L) - override val entitySaveDto: MixSaveDto = - spy(mixSaveDto(mixMaterials = setOf(mixMaterialDto(materialId = 1L, quantity = 1000f, position = 0)))) - override val entityUpdateDto: MixUpdateDto = spy(mixUpdateDto(id = entity.id!!)) - - @AfterEach - override fun afterEach() { - super.afterEach() - reset(recipeService, materialTypeService, mixMaterialService, mixTypeService) - } - - // getAllByMixType() - - @Test - fun `getAllByMixType() returns all mixes with the given mix type`() { - val mixType = mixType(id = 0L) - - whenever(repository.findAllByMixType(mixType)).doReturn(entityList) - - val found = service.getAllByMixType(mixType) - - assertEquals(entityList, found) - } - - // save() - - @Test - override fun `save(dto) calls and returns save() with the created entity`() { - val recipe = recipe(id = entitySaveDto.recipeId) - val materialType = materialType(id = entitySaveDto.materialTypeId) - val material = material( - name = entitySaveDto.name, - inventoryQuantity = Float.MIN_VALUE, - isMixType = true, - materialType = materialType - ) - val mixType = mixType(name = entitySaveDto.name, material = material) - val mix = mix(recipe = recipe, mixType = mixType) - val mixWithId = mix(id = 0L, recipe = recipe, mixType = mixType) - val mixMaterials = setOf(mixMaterial(material = material(id = 1L), quantity = 1000f)) - - whenever(recipeService.getById(recipe.id!!)).doReturn(recipe) - whenever(materialTypeService.getById(materialType.id!!)).doReturn(materialType) - whenever(mixMaterialService.create(entitySaveDto.mixMaterials!!)).doReturn(mixMaterials) - whenever( - mixTypeService.getOrCreateForNameAndMaterialType( - mixType.name, - mixType.material.materialType!! - ) - ).doReturn(mixType) - doReturn(true).whenever(service).existsById(mixWithId.id!!) - doReturn(mixWithId).whenever(service).save(any()) - - val found = service.save(entitySaveDto) - - verify(service).save(argThat { this.recipe == mix.recipe }) - verify(recipeService).addMix(recipe, mixWithId) - - // Verify if this method is called instead of the MixType's constructor, which does not check if the name is already taken by a material. - verify(mixTypeService).getOrCreateForNameAndMaterialType(mixType.name, mixType.material.materialType!!) - - assertEquals(mixWithId, found) - } - - // update() - - private fun mixUpdateDtoTest( - scope: MixUpdateDtoTestScope = MixUpdateDtoTestScope(), - sharedMixType: Boolean = false, - op: MixUpdateDtoTestScope.() -> Unit - ) { - with(scope) { - doReturn(true).whenever(service).existsById(mix.id!!) - doReturn(mix).whenever(service).getById(mix.id!!) - doReturn(sharedMixType).whenever(service).mixTypeIsShared(mix.mixType) - doAnswer { it.arguments[0] }.whenever(service).update(any()) - - if (mixUpdateDto.materialTypeId != null) { - whenever(materialTypeService.getById(materialType.id!!)).doReturn(materialType) - } - - op() - } - } - - private fun mixUpdateDtoMixTypeTest(sharedMixType: Boolean = false, op: MixUpdateDtoTestScope.() -> Unit) { - with(MixUpdateDtoTestScope(mixUpdateDto = mixUpdateDto(id = 0L, name = "name", materialTypeId = 0L))) { - mixUpdateDtoTest(this, sharedMixType, op) - } - } - - @Test - override fun `update(dto) calls and returns update() with the created entity`() { - val mixUpdateDto = spy(mixUpdateDto(id = 0L, name = null, materialTypeId = null)) - - doReturn(entity).whenever(service).getById(any()) - doReturn(entity).whenever(service).update(entity) - - val found = service.update(mixUpdateDto) - - verify(service).update(entity) - - assertEquals(entity, found) - } - - @Test - fun `update(dto) calls MixTypeService saveForNameAndMaterialType() when mix type is shared`() { - mixUpdateDtoMixTypeTest(sharedMixType = true) { - whenever(mixTypeService.saveForNameAndMaterialType(mixUpdateDto.name!!, materialType)) - .doReturn(newMixType) - - val found = service.update(mixUpdateDto) - - verify(mixTypeService).saveForNameAndMaterialType(mixUpdateDto.name!!, materialType) - - assertEquals(newMixType, found.mixType) - } - } - - @Test - fun `update(dto) calls MixTypeService updateForNameAndMaterialType() when mix type is not shared`() { - mixUpdateDtoMixTypeTest { - whenever(mixTypeService.updateForNameAndMaterialType(mixType, mixUpdateDto.name!!, materialType)) - .doReturn(newMixType) - - val found = service.update(mixUpdateDto) - - verify(mixTypeService).updateForNameAndMaterialType(mixType, mixUpdateDto.name!!, materialType) - - assertEquals(newMixType, found.mixType) - } - } - - @Test - fun `update(dto) update, create and delete mix materials according to the given mix materials map`() { - mixUpdateDtoTest { - val mixMaterials = setOf( - mixMaterialDto(materialId = 0L, quantity = 100f, position = 0), - mixMaterialDto(materialId = 1L, quantity = 200f, position = 1), - mixMaterialDto(materialId = 2L, quantity = 300f, position = 2), - mixMaterialDto(materialId = 3L, quantity = 400f, position = 3), - ) - mixUpdateDto.mixMaterials = mixMaterials - - whenever(mixMaterialService.create(any>())).doAnswer { - (it.arguments[0] as Set).map { dto -> - mixMaterial( - material = material(id = dto.materialId), - quantity = dto.quantity, - position = dto.position - ) - }.toSet() - } - - val found = service.update(mixUpdateDto) - - mixMaterials.forEach { - assertTrue { - found.mixMaterials.any { mixMaterial -> - mixMaterial.material.id == it.materialId && mixMaterial.quantity == it.quantity && mixMaterial.position == it.position - } - } - } - } - } - - // updateLocations() - - @Test - fun `updateLocations() calls updateLocation() for each given MixLocationDto`() { - val locations = setOf( - mixLocationDto(mixId = 0, location = "Loc 0"), - mixLocationDto(mixId = 1, location = "Loc 1"), - mixLocationDto(mixId = 2, location = "Loc 2"), - mixLocationDto(mixId = 3, location = "Loc 3") - ) - - service.updateLocations(locations) - - locations.forEach { - verify(service).updateLocation(it) - } - } - - // updateLocation() - - @Test - fun `updateLocation() updates the location of a mix in the repository according to the given MixLocationDto`() { - val locationDto = mixLocationDto(mixId = 0L, location = "Location") - - service.updateLocation(locationDto) - - verify(repository).updateLocationById(locationDto.mixId, locationDto.location) - } - - // delete() - - override fun `delete() deletes in the repository`() { - whenCanBeDeleted { - super.`delete() deletes in the repository`() - } - } - - // deleteById() - - @Test - override fun `deleteById() deletes the entity with the given id in the repository`() { - whenCanBeDeleted { - super.`deleteById() deletes the entity with the given id in the repository`() - } - } - - private fun whenCanBeDeleted(id: Long = any(), test: () -> Unit) { - whenever(repository.canBeDeleted(id)).doReturn(true) - - test() - } -} - -data class MixUpdateDtoTestScope( - val mixType: MixType = mixType(name = "mix type"), - val newMixType: MixType = mixType(name = "another mix type"), - val materialType: MaterialType = materialType(id = 0L), - val mix: Mix = mix(id = 0L, mixType = mixType), - val mixUpdateDto: MixUpdateDto = spy( - mixUpdateDto( - id = 0L, - name = null, - materialTypeId = null, - mixMaterials = setOf() - ) - ) -) diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MixTypeServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MixTypeServiceTest.kt deleted file mode 100644 index 4e6f246..0000000 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/MixTypeServiceTest.kt +++ /dev/null @@ -1,173 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.service - -import com.nhaarman.mockitokotlin2.* -import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException -import dev.fyloz.colorrecipesexplorer.exception.NotFoundException -import dev.fyloz.colorrecipesexplorer.model.* -import dev.fyloz.colorrecipesexplorer.repository.MixTypeRepository -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import org.junit.jupiter.api.assertThrows -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class MixTypeServiceTest : AbstractNamedModelServiceTest() { - override val repository: MixTypeRepository = mock() - private val materialService: MaterialService = mock() - private val mixService: MixService = mock() - override val service: MixTypeService = spy(MixTypeServiceImpl(repository, materialService, mixService)) - - private val materialType: MaterialType = materialType() - private val material: Material = material(id = 0L, materialType = materialType) - override val entity: MixType = mixType(id = 0L, name = "mix type", material = material) - override val anotherEntity: MixType = mixType(id = 1L, name = "another mix type") - override val entityWithEntityName: MixType = mixType(id = 2L, name = entity.name) - - @AfterEach - override fun afterEach() { - reset(materialService) - super.afterEach() - } - - // existsByNameAndMaterialType - - @Test - fun `existsByNameAndMaterialType() returns repository's answer`() { - setOf(true, false).forEach { - whenever(repository.existsByNameAndMaterialType(entity.name, materialType)).doReturn(it) - - val found = service.existsByNameAndMaterialType(entity.name, materialType) - - assertTrue { found == it } - } - } - - // getByMaterial() - - @Test - fun `getByMaterial() returns the mix type with the given material`() { - whenever(repository.findByMaterial(material)).doReturn(entity) - - val found = service.getByMaterial(material) - - assertEquals(entity, found) - } - - @Test - fun `getByMaterial() throws NotFoundException when no mix type with the given material exists`() { - whenever(repository.findByMaterial(material)).doReturn(null) - - assertThrows { service.getByMaterial(material) } - .assertErrorCode("name") - } - - // getByNameAndMaterialType() - - @Test - fun `getByNameAndMaterialType() returns the mix type with the given name and material type`() { - whenever(repository.findByNameAndMaterialType(entity.name, materialType)).doReturn(entity) - - val found = service.getByNameAndMaterialType(entity.name, materialType) - - assertEquals(entity, found) - } - - // getOrCreateForNameAndMaterialType() - @Test - fun `getOrCreateForNameAndMaterialType() calls getForNameAndMaterialType() when a mix type with the given name and material type exists`() { - doReturn(true).whenever(service).existsByNameAndMaterialType(entity.name, materialType) - doReturn(entity).whenever(service).getByNameAndMaterialType(entity.name, materialType) - - val found = service.getOrCreateForNameAndMaterialType(entity.name, materialType) - - verify(service).getByNameAndMaterialType(entity.name, materialType) - - assertEquals(entity, found) - } - - @Test - fun `getOrCreateForNameAndMaterialType() calls saveForNameAndMaterialType() when no mix type with the given name and material type exists`() { - doReturn(false).whenever(service).existsByNameAndMaterialType(entity.name, materialType) - doReturn(entity).whenever(service).saveForNameAndMaterialType(entity.name, materialType) - - val found = service.getOrCreateForNameAndMaterialType(entity.name, materialType) - - verify(service).saveForNameAndMaterialType(entity.name, materialType) - - assertEquals(entity, found) - } - - // save() - - @Test - fun `save() throws AlreadyExistsException when a material with the name of the new mix type exists`() { - whenever(materialService.existsByName(entity.name)).doReturn(true) - - assertThrows { service.save(entity) } - .assertErrorCode("name") - } - - // saveForNameAndMaterialType() - - @Test - fun `saveForNameAndMaterialType() creates a save a valid mix type with the given name and material type`() { - val name = entity.name - val materialType = materialType() - - doAnswer { it.arguments[0] }.whenever(service).save(any()) - - val found = service.saveForNameAndMaterialType(name, materialType) - - verify(service).save(any()) - - assertEquals(name, found.name) - assertEquals(name, found.material.name) - assertEquals(materialType, found.material.materialType) - assertTrue(found.material.isMixType) - } - - // updateForNameAndMaterialType() - - @Test - fun `updateForNameAndMaterialType() updates the given mix type with the given name and material type`() { - val mixType = mixType(id = 1L, material = material(isMixType = true)) - val name = entity.name - val materialType = materialType() - - doAnswer { it.arguments[0] }.whenever(service).update(any()) - - val found = service.updateForNameAndMaterialType(mixType, name, materialType) - - verify(service).update(any()) - - assertEquals(mixType.id, found.id) - assertEquals(name, found.name) - assertEquals(name, found.material.name) - assertEquals(materialType, found.material.materialType) - assertTrue(found.material.isMixType) - } - - // delete() - - override fun `delete() deletes in the repository`() { - whenCanBeDeleted { - super.`delete() deletes in the repository`() - } - } - - // deleteById() - - override fun `deleteById() deletes the entity with the given id in the repository`() { - whenCanBeDeleted { - super.`deleteById() deletes the entity with the given id in the repository`() - } - } - - private fun whenCanBeDeleted(id: Long = any(), test: () -> Unit) { - whenever(repository.canBeDeleted(id)).doReturn(true) - - test() - } -} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeServiceTest.kt deleted file mode 100644 index ede2dce..0000000 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeServiceTest.kt +++ /dev/null @@ -1,363 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.service - -import com.nhaarman.mockitokotlin2.* -import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException -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.WriteableFileService -import dev.fyloz.colorrecipesexplorer.service.users.GroupService -import io.mockk.* -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Test -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.* - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class RecipeServiceTest : - AbstractExternalModelServiceTest() { - override val repository: RecipeRepository = mock() - private val companyService: CompanyService = mock() - private val mixService: MixService = mock() - private val groupService: GroupService = mock() - private val recipeStepService: RecipeStepService = mock() - private val configService: ConfigurationService = mock() - override val service: RecipeService = - 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) - override val anotherEntity: Recipe = recipe(id = 1L, name = "another recipe", company = company) - override val entitySaveDto: RecipeSaveDto = spy(recipeSaveDto(name = entity.name, companyId = entity.company.id!!)) - override val entityUpdateDto: RecipeUpdateDto = spy(recipeUpdateDto(id = entity.id!!, name = entity.name)) - - @AfterEach - override fun afterEach() { - reset(companyService, mixService) - super.afterEach() - } - - // existsByCompany() - - @Test - fun `existsByCompany() returns true when at least one recipe exists for the given company`() { - whenever(repository.existsByCompany(company)).doReturn(true) - - val found = service.existsByCompany(company) - - assertTrue(found) - } - - @Test - fun `existsByCompany() returns false when no recipe exists for the given company`() { - whenever(repository.existsByCompany(company)).doReturn(false) - - val found = service.existsByCompany(company) - - assertFalse(found) - } - - // existsByNameAndCompany() - - @Test - fun `existsByNameAndCompany() returns if a recipe exists for the given name and company in the repository`() { - setOf(true, false).forEach { - whenever(repository.existsByNameAndCompany(entity.name, company)).doReturn(it) - - val exists = service.existsByNameAndCompany(entity.name, company) - - assertEquals(it, exists) - } - } - - // isApprobationExpired() - - @Test - fun `isApprobationExpired() returns false when the approbation date of the given recipe is within the configured period`() { - val period = Period.ofMonths(4) - val recipe = recipe(approbationDate = LocalDate.now()) - - whenever(configService.getContent(ConfigurationType.RECIPE_APPROBATION_EXPIRATION)).doReturn(period.toString()) - - val approbationExpired = service.isApprobationExpired(recipe) - - assertNotNull(approbationExpired) - assertFalse(approbationExpired) - } - - @Test - fun `isApprobationExpired() returns true when the approbation date of the given recipe is outside the configured period`() { - val period = Period.ofMonths(4) - val recipe = recipe(approbationDate = LocalDate.now().minus(period).minusMonths(1)) - - whenever(configService.getContent(ConfigurationType.RECIPE_APPROBATION_EXPIRATION)).doReturn(period.toString()) - - val approbationExpired = service.isApprobationExpired(recipe) - - assertNotNull(approbationExpired) - assertTrue(approbationExpired) - } - - @Test - fun `isApprobationExpired() returns null when the given recipe as no approbation date`() { - val period = Period.ofMonths(4) - val recipe = recipe(approbationDate = null) - - whenever(configService.getContent(ConfigurationType.RECIPE_APPROBATION_EXPIRATION)).doReturn(period.toString()) - - val approbationExpired = service.isApprobationExpired(recipe) - - assertNull(approbationExpired) - } - - // getAllByName() - - @Test - fun `getAllByName() returns the recipes with the given name`() { - val recipes = listOf(entity, anotherEntity) - - whenever(repository.findAllByName(entity.name)).doReturn(recipes) - - val found = service.getAllByName(entity.name) - - assertEquals(recipes, found) - } - - // getAllByCompany() - - @Test - fun `getAllByCompany() returns the recipes with the given company`() { - val companies = listOf(entity, anotherEntity) - whenever(repository.findAllByCompany(company)).doReturn(companies) - - val found = service.getAllByCompany(company) - - assertEquals(companies, found) - } - - // save() - - @Test - override fun `save(dto) calls and returns save() with the created entity`() { - whenever(companyService.getById(company.id!!)).doReturn(company) - doReturn(false).whenever(service).existsByNameAndCompany(entity.name, company) - withBaseSaveDtoTest(entity, entitySaveDto, service, { argThat { this.id == null && this.color == color } }) - } - - @Test - fun `save(dto) throw AlreadyExistsException when a recipe with the given name and company exists in the repository`() { - whenever(companyService.getById(company.id!!)).doReturn(company) - doReturn(true).whenever(service).existsByNameAndCompany(entity.name, company) - - with(assertThrows { service.save(entitySaveDto) }) { - this.assertErrorCode("company-name") - } - } - - // update() - - @Test - override fun `update(dto) calls and returns update() with the created entity`() { - doReturn(false).whenever(service).existsByNameAndCompany(entity.name, company) - withBaseUpdateDtoTest(entity, entityUpdateDto, service, { any() }) - } - - @Test - fun `update(dto) throws AlreadyExistsException when a recipe exists for the given name and company`() { - val name = "another recipe" - - doReturn(entity).whenever(service).getById(entity.id!!) - doReturn(true).whenever(service).existsByNameAndCompany(name, company) - doReturn(name).whenever(entityUpdateDto).name - - with(assertThrows { service.update(entityUpdateDto) }) { - this.assertErrorCode("company-name") - } - } - - // updatePublicData() - - @Test - fun `updatePublicData() updates the notes of a recipe groups information according to the RecipePublicDataDto`() { - val recipe = recipe( - id = 0L, groupsInformation = setOf( - recipeGroupInformation(id = 0L, group = group(id = 1L), note = "Old note"), - recipeGroupInformation(id = 1L, group = group(id = 2L), note = "Another note"), - recipeGroupInformation(id = 2L, group = group(id = 3L), note = "Up to date note") - ) - ) - val notes = setOf( - noteDto(groupId = 1, content = "Note 1"), - noteDto(groupId = 2, content = null) - ) - val publicData = recipePublicDataDto(recipeId = recipe.id!!, notes = notes) - - doReturn(recipe).whenever(service).getById(recipe.id!!) - doAnswer { it.arguments[0] }.whenever(service).update(any()) - - service.updatePublicData(publicData) - - verify(service).update(argThat { - assertTrue { this.groupsInformation.first { it.group.id == 1L }.note == notes.first { it.groupId == 1L }.content } - assertTrue { this.groupsInformation.first { it.group.id == 2L }.note == notes.first { it.groupId == 2L }.content } - assertTrue { this.groupsInformation.any { it.group.id == 3L } && this.groupsInformation.first { it.group.id == 3L }.note == null } - true - }) - verify(mixService, times(0)).updateLocations(any()) - } - - @Test - fun `updatePublicData() update the location of a recipe mixes in the mix service according to the RecipePublicDataDto`() { - val publicData = recipePublicDataDto( - mixesLocation = setOf( - mixLocationDto(mixId = 0L, location = "Loc 1"), - mixLocationDto(mixId = 1L, location = "Loc 2") - ) - ) - - service.updatePublicData(publicData) - - verify(mixService).updateLocations(publicData.mixesLocation!!) - verify(service, times(0)).update(any()) - } - - // addMix() - - @Test - fun `addMix() adds the given mix to the given recipe and updates it`() { - val mix = mix(id = 0L) - val recipe = recipe(id = 0L, mixes = mutableListOf()) - - doAnswer { it.arguments[0] }.whenever(service).update(any()) - - val found = service.addMix(recipe, mix) - - verify(service).update(any()) - - assertEquals(recipe.id, found.id) - assertTrue(found.mixes.contains(mix)) - } - - // removeMix() - - @Test - fun `removeMix() removes the given mix from its recipe and updates it`() { - val recipe = recipe(id = 0L, mixes = mutableListOf()) - val mix = mix(id = 0L, recipe = recipe) - recipe.mixes.add(mix) - - doAnswer { it.arguments[0] }.whenever(service).update(any()) - - val found = service.removeMix(mix) - - verify(service).update(any()) - - assertEquals(recipe.id, found.id) - assertFalse(found.mixes.contains(mix)) - } -} - -private class RecipeImageServiceTestContext { - val fileService = mockk { - every { write(any(), any(), any()) } just Runs - every { delete(any()) } just Runs - } - val recipeImageService = spyk(RecipeImageServiceImpl(fileService)) - val recipe = spyk(recipe()) - val recipeImagesIds = setOf(1L, 10L, 21L) - val recipeImagesNames = recipeImagesIds.map { it.imageName }.toSet() - val recipeImagesFiles = recipeImagesNames.map { File(it) }.toTypedArray() - val recipeDirectory = mockk { - every { exists() } returns true - every { isDirectory } returns true - every { listFiles() } returns recipeImagesFiles - } - - init { - with(recipeImageService) { - every { recipe.getDirectory() } returns recipeDirectory - } - } - - val Long.imageName - get() = "${recipe.name}$RECIPE_IMAGE_ID_DELIMITER$this" - - val String.imagePath - get() = "${recipe.imagesDirectoryPath}/$this$RECIPE_IMAGE_EXTENSION" -} - -class RecipeImageServiceTest { - @AfterEach - internal fun afterEach() { - clearAllMocks() - } - - private fun test(test: RecipeImageServiceTestContext.() -> Unit) { - RecipeImageServiceTestContext().test() - } - - // getAllImages() - - @Test - fun `getAllImages() returns a Set containing the name of every files in the recipe's directory`() { - test { - val foundImagesNames = recipeImageService.getAllImages(recipe) - - assertEquals(recipeImagesNames, foundImagesNames) - } - } - - @Test - fun `getAllImages() returns an empty Set when the recipe's directory does not exists`() { - test { - every { recipeDirectory.exists() } returns false - - assertTrue { - recipeImageService.getAllImages(recipe).isEmpty() - } - } - } - - // download() - - @Test - fun `download() writes the given image to the FileService and returns its name`() { - test { - val mockImage = MockMultipartFile("image.jpg", byteArrayOf(*"Random data".encodeToByteArray())) - val expectedImageId = recipeImagesIds.maxOrNull()!! + 1L - val expectedImageName = expectedImageId.imageName - val expectedImagePath = expectedImageName.imagePath - - val foundImageName = recipeImageService.download(mockImage, recipe) - - assertEquals(expectedImageName, foundImageName) - - verify { - fileService.write(mockImage, expectedImagePath, true) - } - } - } - - // delete() - - @Test - fun `delete() deletes the image with the given name in the FileService`() { - test { - val imageName = recipeImagesIds.first().imageName - val imagePath = imageName.imagePath - - recipeImageService.delete(recipe, imageName) - - verify { - fileService.delete(imagePath) - } - } - } -} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeStepServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeStepServiceTest.kt deleted file mode 100644 index 31933b0..0000000 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/RecipeStepServiceTest.kt +++ /dev/null @@ -1,109 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.service - -import com.nhaarman.mockitokotlin2.* -import dev.fyloz.colorrecipesexplorer.model.RecipeGroupInformation -import dev.fyloz.colorrecipesexplorer.model.RecipeStep -import dev.fyloz.colorrecipesexplorer.model.account.group -import dev.fyloz.colorrecipesexplorer.model.recipeGroupInformation -import dev.fyloz.colorrecipesexplorer.model.recipeStep -import dev.fyloz.colorrecipesexplorer.repository.RecipeStepRepository -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.TestInstance -import org.junit.jupiter.api.assertThrows -import kotlin.test.assertTrue - -@TestInstance(TestInstance.Lifecycle.PER_CLASS) -class RecipeStepServiceTest : - AbstractModelServiceTest() { - override val repository: RecipeStepRepository = mock() - override val service: RecipeStepService = spy(RecipeStepServiceImpl(repository)) - - override val entity: RecipeStep = recipeStep(id = 0L, message = "message") - override val anotherEntity: RecipeStep = recipeStep(id = 1L, message = "another message") - - // validateGroupInformationSteps() - - @Test - fun `validateGroupInformationSteps() calls validateSteps() with the given RecipeGroupInformation steps`() { - withGroupInformation { - service.validateGroupInformationSteps(this) - - verify(service).validateSteps(this.steps!!) - } - } - - @Test - fun `validateGroupInformationSteps() throws InvalidGroupStepsPositionsException when validateSteps() throws an InvalidStepsPositionsException`() { - withGroupInformation { - doAnswer { throw InvalidStepsPositionsException(setOf()) }.whenever(service).validateSteps(this.steps!!) - - assertThrows { - service.validateGroupInformationSteps(this) - } - } - } - - // validateSteps() - - @Test - fun `validateSteps() throws an InvalidStepsPositionsException when the position of the first step of the given groupInformation is not 1`() { - assertInvalidStepsPositionsException( - mutableSetOf( - recipeStep(id = 0L, position = 0), - recipeStep(id = 1L, position = 1), - recipeStep(id = 2L, position = 2), - recipeStep(id = 3L, position = 3) - ), - INVALID_FIRST_STEP_POSITION_ERROR_CODE - ) - } - - @Test - fun `validateSteps() throws an InvalidStepsPositionsException when steps positions are duplicated in the given groupInformation`() { - assertInvalidStepsPositionsException( - mutableSetOf( - recipeStep(id = 0L, position = 1), - recipeStep(id = 1L, position = 2), - recipeStep(id = 2L, position = 2), - recipeStep(id = 3L, position = 3) - ), - DUPLICATED_STEPS_POSITIONS_ERROR_CODE - ) - } - - @Test - fun `validateSteps() throws an InvalidStepsPositionsException when there is a gap between steps positions in the given groupInformation`() { - assertInvalidStepsPositionsException( - mutableSetOf( - recipeStep(id = 0L, position = 1), - recipeStep(id = 1L, position = 2), - recipeStep(id = 2L, position = 4), - recipeStep(id = 3L, position = 5) - ), - GAP_BETWEEN_STEPS_POSITIONS_ERROR_CODE - ) - } - - private fun withGroupInformation(steps: MutableSet? = null, test: RecipeGroupInformation.() -> Unit) { - recipeGroupInformation( - group = group(id = 0L), - steps = steps ?: mutableSetOf( - recipeStep(id = 0L, position = 1), - recipeStep(id = 1L, position = 2), - recipeStep(id = 2L, position = 3), - recipeStep(id = 3L, position = 4) - ) - ) { - test() - } - } - - private fun assertInvalidStepsPositionsException(steps: MutableSet, errorType: String) { - val exception = assertThrows { - service.validateSteps(steps) - } - - assertTrue { exception.errors.size == 1 } - assertTrue { exception.errors.first().type == errorType } - } -} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/TouchUpKitServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/TouchUpKitServiceTest.kt deleted file mode 100644 index 51ef288..0000000 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/TouchUpKitServiceTest.kt +++ /dev/null @@ -1,138 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.service - -import dev.fyloz.colorrecipesexplorer.model.ConfigurationType -import dev.fyloz.colorrecipesexplorer.model.configuration -import dev.fyloz.colorrecipesexplorer.repository.TouchUpKitRepository -import dev.fyloz.colorrecipesexplorer.service.config.ConfigurationService -import dev.fyloz.colorrecipesexplorer.service.files.WriteableFileService -import dev.fyloz.colorrecipesexplorer.utils.PdfDocument -import dev.fyloz.colorrecipesexplorer.utils.toByteArrayResource -import io.mockk.* -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Test -import org.springframework.core.io.ByteArrayResource -import kotlin.test.assertEquals - -private class TouchUpKitServiceTestContext { - val touchUpKitRepository = mockk() - val fileService = mockk { - every { write(any(), any(), any()) } just Runs - } - val configService = mockk(relaxed = true) - val touchUpKitService = spyk(TouchUpKitServiceImpl(fileService, configService, touchUpKitRepository)) - val pdfDocumentData = mockk() - val pdfDocument = mockk { - mockkStatic(PdfDocument::toByteArrayResource) - mockkStatic(PdfDocument::toByteArrayResource) - every { toByteArrayResource() } returns pdfDocumentData - } -} - -class TouchUpKitServiceTest { - private val job = "job" - - @AfterEach - internal fun afterEach() { - clearAllMocks() - } - - // generateJobPdf() - - @Test - fun `generateJobPdf() generates a valid PdfDocument for the given job`() { - test { - val generatedPdfDocument = touchUpKitService.generateJobPdf(job) - - setOf(0, 1).forEach { - assertEquals(TOUCH_UP_TEXT_FR, generatedPdfDocument.containers[it].texts[0].text) - assertEquals(TOUCH_UP_TEXT_EN, generatedPdfDocument.containers[it].texts[1].text) - assertEquals(job, generatedPdfDocument.containers[it].texts[2].text) - } - } - } - - // generateJobPdfResource() - - @Test - fun `generateJobPdfResource() generates and returns a ByteArrayResource for the given job then cache it`() { - test { - every { touchUpKitService.generateJobPdf(any()) } returns pdfDocument - with(touchUpKitService) { - every { job.cachePdfDocument(pdfDocument) } just Runs - } - - val generatedResource = touchUpKitService.generateJobPdfResource(job) - - assertEquals(pdfDocumentData, generatedResource) - - verify { - with(touchUpKitService) { - job.cachePdfDocument(pdfDocument) - } - } - } - } - - @Test - fun `generateJobPdfResource() returns a cached ByteArrayResource from the FileService when caching is enabled and a cached file eixsts for the given job`() { - test { - enableCachePdf() - every { fileService.exists(any()) } returns true - every { fileService.read(any()) } returns pdfDocumentData - every { configService.get(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF) } returns configuration( - ConfigurationType.TOUCH_UP_KIT_CACHE_PDF, - "true" - ) - - val redResource = touchUpKitService.generateJobPdfResource(job) - - assertEquals(pdfDocumentData, redResource) - } - } - - // String.cachePdfDocument() - - @Test - fun `cachePdfDocument() does nothing when caching is disabled`() { - test { - disableCachePdf() - - with(touchUpKitService) { - job.cachePdfDocument(pdfDocument) - } - - verify(exactly = 0) { - fileService.write(any(), any(), any()) - } - } - } - - @Test - fun `cachePdfDocument() writes the given document to the FileService when cache is enabled`() { - test { - enableCachePdf() - - with(touchUpKitService) { - job.cachePdfDocument(pdfDocument) - } - - verify { - fileService.write(pdfDocumentData, any(), true) - } - } - } - - private fun TouchUpKitServiceTestContext.enableCachePdf() = - this.setCachePdf(true) - - private fun TouchUpKitServiceTestContext.disableCachePdf() = - this.setCachePdf(false) - - private fun TouchUpKitServiceTestContext.setCachePdf(enabled: Boolean) { - every { configService.getContent(ConfigurationType.TOUCH_UP_KIT_CACHE_PDF) } returns enabled.toString() - } - - private fun test(test: TouchUpKitServiceTestContext.() -> Unit) { - TouchUpKitServiceTestContext().test() - } -} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileServiceTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileServiceTest.kt deleted file mode 100644 index 936bf47..0000000 --- a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/service/files/FileServiceTest.kt +++ /dev/null @@ -1,297 +0,0 @@ -package dev.fyloz.colorrecipesexplorer.service.files - -import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties -import io.mockk.* -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows -import org.springframework.mock.web.MockMultipartFile -import java.io.File -import java.io.IOException -import java.nio.file.Path -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -private val creProperties = CreProperties().apply { - dataDirectory = "data" -} -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()) } just Runs - })) - val mockFile = mockk { - every { path } returns mockFilePath - every { exists() } returns true - every { isFile } returns true - every { toPath() } returns mockFilePathPath - } - val mockFileFullPath = spyk(FilePath("${creProperties.dataDirectory}/$mockFilePath")) { - every { file } returns mockFile - - with(fileService) { - every { mockFilePath.fullPath() } returns this@spyk - } - } - val mockMultipartFile = spyk(MockMultipartFile(mockFilePath, mockFileData)) -} - -class FileServiceTest { - @AfterEach - internal fun afterEach() { - clearAllMocks() - } - - // exists() - - @Test - fun `exists() returns true when the file at the given path exists and is a file`() { - test { - assertTrue { fileService.exists(mockFilePath) } - } - } - - @Test - fun `exists() returns false when the file at the given path does not exist`() { - test { - every { mockFile.exists() } returns false - - assertFalse { fileService.exists(mockFilePath) } - } - } - - @Test - fun `exists() returns false when the file at the given path is not a file`() { - test { - every { mockFile.isFile } returns false - - assertFalse { fileService.exists(mockFilePath) } - } - } - - // read() - - @Test - fun `read() returns a valid ByteArrayResource`() { - test { - whenMockFilePathExists { - mockkStatic(File::readBytes) - every { mockFile.readBytes() } returns mockFileData - - val redResource = fileService.read(mockFilePath) - - assertEquals(mockFileData, redResource.byteArray) - } - } - } - - @Test - fun `read() throws FileNotFoundException when no file exists at the given path`() { - test { - whenMockFilePathExists(false) { - with(assertThrows { 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() - - with(assertThrows { fileService.read(mockFilePath) }) { - assertEquals(mockFilePath, this.path) - } - } - } - } - - // create() - - @Test - fun `create() creates a file at the given path`() { - test { - whenMockFilePathExists(false) { - mockkStatic(File::create) - every { mockFile.create() } just Runs - - fileService.create(mockFilePath) - - verify { - mockFile.create() - } - } - } - } - - @Test - fun `create() does nothing when a file already exists at the given path`() { - test { - whenMockFilePathExists { - fileService.create(mockFilePath) - - 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() - - with(assertThrows { fileService.create(mockFilePath) }) { - assertEquals(mockFilePath, this.path) - } - } - } - } - - // write() - - @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 - - fileService.write(mockMultipartFile, mockFilePath, false) - - 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 { 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 - - fileService.write(mockMultipartFile, mockFilePath, true) - - 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() - - with(assertThrows { - fileService.write(mockMultipartFile, mockFilePath, false) - }) { - assertEquals(mockFilePath, this.path) - } - } - } - } - - // delete() - - @Test - fun `delete() deletes the file at the given path`() { - test { - whenMockFilePathExists { - every { mockFile.delete() } returns true - - fileService.delete(mockFilePath) - } - } - } - - @Test - fun `delete() throws FileNotFoundException when no file exists at the given path`() { - test { - whenMockFilePathExists(false) { - with(assertThrows { fileService.delete(mockFilePath) }) { - assertEquals(mockFilePath, this.path) - } - } - } - } - - @Test - fun `delete() throws FileDeleteException when deleting throw and IOException`() { - test { - whenMockFilePathExists { - every { mockFile.delete() } throws IOException() - - with(assertThrows { fileService.delete(mockFilePath) }) { - assertEquals(mockFilePath, this.path) - } - } - } - } - - // String.fullPath() - - @Test - fun `fullPath() appends the given path to the given working directory`() { - test { - with(fileService) { - val fullFilePath = mockFilePath.fullPath() - - assertEquals("${creProperties.dataDirectory}/$mockFilePath", fullFilePath.path) - } - } - } - - @Test - fun `fullPath() throws InvalidFilePathException when the given path contains invalid fragments`() { - test { - with(fileService) { - BANNED_FILE_PATH_SHARDS.forEach { - val maliciousPath = "$it/$mockFilePath" - - with(assertThrows { maliciousPath.fullPath() }) { - assertEquals(maliciousPath, this.path) - assertEquals(it, this.fragment) - } - } - } - } - } - - private fun test(test: FileServiceTestContext.() -> Unit) { - FileServiceTestContext().test() - } - - private fun FileServiceTestContext.whenMockFilePathExists(exists: Boolean = true, test: () -> Unit) { - every { fileService.exists(mockFilePath) } returns exists - test() - } -} diff --git a/src/test/kotlin/dev/fyloz/colorrecipesexplorer/utils/PositionUtilsTest.kt b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/utils/PositionUtilsTest.kt new file mode 100644 index 0000000..89809c7 --- /dev/null +++ b/src/test/kotlin/dev/fyloz/colorrecipesexplorer/utils/PositionUtilsTest.kt @@ -0,0 +1,88 @@ +package dev.fyloz.colorrecipesexplorer.utils + +import dev.fyloz.colorrecipesexplorer.exception.InvalidPositionsException +import io.mockk.clearAllMocks +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertDoesNotThrow +import org.junit.jupiter.api.assertThrows +import kotlin.test.assertTrue + +class PositionUtilsTest { + @AfterEach + internal fun afterEach() { + clearAllMocks() + } + + @Test + fun validateSteps_normalBehavior_doesNothing() { + // Arrange + val positions = listOf(1, 2) + + // Act + // Assert + assertDoesNotThrow { PositionUtils.validate(positions) } + } + + @Test + fun validateSteps_emptyStepSet_doesNothing() { + // Arrange + val positions = listOf() + + // Act + // Assert + assertDoesNotThrow { PositionUtils.validate(positions) } + } + + @Test + fun validateSteps_hasInvalidPositions_throwsInvalidStepsPositionsException() { + // Arrange + val positions = listOf(2, 3) + + // Act + // Assert + assertThrows { PositionUtils.validate(positions) } + } + + @Test + fun validateSteps_firstStepPositionInvalid_returnsInvalidStepValidationError() { + // Arrange + val positions = listOf(2, 3) + + // Act + val exception = assertThrows { PositionUtils.validate(positions) } + + // Assert + assertTrue { + exception.errors.any { it.type == PositionUtils.INVALID_FIRST_POSITION_ERROR_CODE } + } + } + + @Test + fun validateSteps_duplicatedPositions_returnsInvalidStepValidationError() { + // Arrange + val positions = listOf(1, 1) + + // Act + val exception = assertThrows { PositionUtils.validate(positions) } + + // Assert + assertTrue { + exception.errors.any { it.type == PositionUtils.DUPLICATED_POSITION_ERROR_CODE } + } + } + + @Test + fun validateSteps_gapsInPositions_returnsInvalidStepValidationError() { + // Arrange + val positions = listOf(1, 3) + + // Act + val exception = assertThrows { PositionUtils.validate(positions) } + + // Assert + assertTrue { + exception.errors.any { it.type == PositionUtils.GAP_BETWEEN_POSITIONS_ERROR_CODE } + } + } +} \ No newline at end of file